mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-24 01:12:54 -04:00
Frontend Refactor + Bug Fixes
* merge category and tag selector * unifiy category selector * add hint * spacing * fix nextcloud migration * simplify email validator #261 * formatting * cleanup * auto-gen * format * update run script * unified category/tag selector * rename component * Add advanced search link * remove old code * convert keywords to tags * add proper behavior on rename * proper image name association on rename * fix test cleanup * changelog * set docker comppand * minify on migration Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
parent
a396604520
commit
1cf95bb3b0
@ -19,4 +19,4 @@ COPY ./mealie /app/mealie
|
||||
RUN poetry install
|
||||
|
||||
RUN chmod +x /app/mealie/run.sh
|
||||
CMD /app/mealie/run.sh reload
|
||||
CMD ["/app/mealie/run.sh", "reload"]
|
||||
|
@ -1,12 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Usage: ./download-and-extract.sh something https://example.com/something.tar.gz
|
||||
|
||||
archive=$1
|
||||
url=$2
|
||||
|
||||
if [ ! -f $archive.tar.gz ]; then
|
||||
wget -O $archive.tar.gz $url
|
||||
fi
|
||||
|
||||
rm -r $archive
|
||||
tar -xvzf $archive.tar.gz
|
@ -1,12 +0,0 @@
|
||||
#!/bin/bash
|
||||
# install webp
|
||||
|
||||
archive=libwebp-1.2.0
|
||||
|
||||
./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/master/$archive.tar.gz
|
||||
|
||||
pushd $archive
|
||||
|
||||
./configure --prefix=/usr --enable-libwebpmux --enable-libwebpdemux && make -j4 && sudo make -j4 install
|
||||
|
||||
popd
|
@ -19,5 +19,9 @@
|
||||
- A smaller image is used for recipe cards
|
||||
- A 'tiny' image is used for search images.
|
||||
- Advanced Search Page. You can now use the search page to filter recipes to include/exclude tags and categories as well as select And/Or matching criteria.
|
||||
- Added link to advanced search on quick search
|
||||
- Better support for Nextcloud imports
|
||||
- Translate keywords to tags
|
||||
- Fix rollback on failure
|
||||
|
||||
|
||||
|
File diff suppressed because one or more lines are too long
@ -44,6 +44,11 @@ export const tagAPI = {
|
||||
let response = await apiReq.get(tagURLs.getAll);
|
||||
return response.data;
|
||||
},
|
||||
async create(name) {
|
||||
let response = await apiReq.post(tagURLs.getAll, { name: name });
|
||||
store.dispatch("requestTags");
|
||||
return response.data;
|
||||
},
|
||||
async getRecipesInTag(tag) {
|
||||
let response = await apiReq.get(tagURLs.getTag(tag));
|
||||
return response.data;
|
||||
|
@ -19,7 +19,7 @@
|
||||
v-model="page.name"
|
||||
label="Page Name"
|
||||
></v-text-field>
|
||||
<CategorySelector
|
||||
<CategoryTagSelector
|
||||
v-model="page.categories"
|
||||
ref="categoryFormSelector"
|
||||
@mounted="catMounted = true"
|
||||
@ -43,10 +43,10 @@
|
||||
<script>
|
||||
const NEW_PAGE_EVENT = "refresh-page";
|
||||
import { api } from "@/api";
|
||||
import CategorySelector from "@/components/FormHelpers/CategorySelector";
|
||||
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
|
||||
export default {
|
||||
components: {
|
||||
CategorySelector,
|
||||
CategoryTagSelector,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -84,7 +84,7 @@
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<NewCategoryDialog />
|
||||
<NewCategoryTagDialog />
|
||||
</v-app-bar>
|
||||
<v-list height="300" dense style="overflow:auto">
|
||||
<v-list-item-group>
|
||||
@ -133,13 +133,13 @@
|
||||
import { api } from "@/api";
|
||||
import LanguageMenu from "@/components/UI/LanguageMenu";
|
||||
import draggable from "vuedraggable";
|
||||
import NewCategoryDialog from "./NewCategoryDialog.vue";
|
||||
import NewCategoryTagDialog from "@/components/UI/Dialogs/NewCategoryTagDialog.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
draggable,
|
||||
LanguageMenu,
|
||||
NewCategoryDialog,
|
||||
NewCategoryTagDialog,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -1,79 +0,0 @@
|
||||
<template>
|
||||
<v-select
|
||||
:items="allCategories"
|
||||
v-model="selected"
|
||||
label="Categories"
|
||||
chips
|
||||
deletable-chips
|
||||
:dense="dense"
|
||||
item-text="name"
|
||||
multiple
|
||||
:solo="solo"
|
||||
:return-object="returnObject"
|
||||
:flat="flat"
|
||||
@input="emitChange"
|
||||
>
|
||||
<template v-slot:selection="data">
|
||||
<v-chip
|
||||
class="ma-1"
|
||||
:input-value="data.selected"
|
||||
close
|
||||
@click:close="removeByIndex(data.index)"
|
||||
label
|
||||
color="accent"
|
||||
dark
|
||||
>
|
||||
{{ data.item.name }}
|
||||
</v-chip>
|
||||
</template></v-select
|
||||
>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const MOUNTED_EVENT = "mounted";
|
||||
export default {
|
||||
props: {
|
||||
value: Array,
|
||||
solo: {
|
||||
default: false,
|
||||
},
|
||||
dense: {
|
||||
default: true,
|
||||
},
|
||||
returnObject: {
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selected: [],
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$emit(MOUNTED_EVENT);
|
||||
},
|
||||
|
||||
computed: {
|
||||
allCategories() {
|
||||
return this.$store.getters.getAllCategories;
|
||||
},
|
||||
flat() {
|
||||
return this.selected.length > 0 && this.solo;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
emitChange() {
|
||||
this.$emit("input", this.selected);
|
||||
},
|
||||
setInit(val) {
|
||||
this.selected = val;
|
||||
},
|
||||
removeByIndex(index) {
|
||||
this.selected.splice(index, 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
129
frontend/src/components/FormHelpers/CategoryTagSelector.vue
Normal file
129
frontend/src/components/FormHelpers/CategoryTagSelector.vue
Normal file
@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<v-autocomplete
|
||||
:items="activeItems"
|
||||
v-model="selected"
|
||||
:value="value"
|
||||
:label="inputLabel"
|
||||
chips
|
||||
deletable-chips
|
||||
:dense="dense"
|
||||
item-text="name"
|
||||
persistent-hint
|
||||
multiple
|
||||
:hint="hint"
|
||||
:solo="solo"
|
||||
:return-object="returnObject"
|
||||
:flat="flat"
|
||||
@input="emitChange"
|
||||
>
|
||||
<template v-slot:selection="data">
|
||||
<v-chip
|
||||
class="ma-1"
|
||||
:input-value="data.selected"
|
||||
close
|
||||
@click:close="removeByIndex(data.index)"
|
||||
label
|
||||
color="accent"
|
||||
dark
|
||||
:key="data.index"
|
||||
>
|
||||
{{ data.item.name || data.item }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template v-slot:append-outer="">
|
||||
<NewCategoryTagDialog
|
||||
v-if="showAdd"
|
||||
:tag-dialog="tagSelector"
|
||||
@created-item="pushToItem"
|
||||
/>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NewCategoryTagDialog from "@/components/UI/Dialogs/NewCategoryTagDialog";
|
||||
const MOUNTED_EVENT = "mounted";
|
||||
export default {
|
||||
components: {
|
||||
NewCategoryTagDialog,
|
||||
},
|
||||
props: {
|
||||
value: Array,
|
||||
solo: {
|
||||
default: false,
|
||||
},
|
||||
dense: {
|
||||
default: true,
|
||||
},
|
||||
returnObject: {
|
||||
default: true,
|
||||
},
|
||||
tagSelector: {
|
||||
default: false,
|
||||
},
|
||||
hint: {
|
||||
default: null,
|
||||
},
|
||||
showAdd: {
|
||||
default: false,
|
||||
},
|
||||
showLabel: {
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selected: [],
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$emit(MOUNTED_EVENT);
|
||||
this.setInit(this.value);
|
||||
},
|
||||
|
||||
watch: {
|
||||
value(val) {
|
||||
this.selected = val;
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
inputLabel() {
|
||||
if (!this.showLabel) return null;
|
||||
return this.tagSelector ? "Tags" : "Categories";
|
||||
},
|
||||
activeItems() {
|
||||
let ItemObjects = [];
|
||||
if (this.tagSelector) ItemObjects = this.$store.getters.getAllTags;
|
||||
else {
|
||||
ItemObjects = this.$store.getters.getAllCategories;
|
||||
}
|
||||
if (this.returnObject) return ItemObjects;
|
||||
else {
|
||||
return ItemObjects.map(x => x.name);
|
||||
}
|
||||
},
|
||||
flat() {
|
||||
return this.selected.length > 0 && this.solo;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
emitChange() {
|
||||
this.$emit("input", this.selected);
|
||||
},
|
||||
setInit(val) {
|
||||
this.selected = val;
|
||||
},
|
||||
removeByIndex(index) {
|
||||
this.selected.splice(index, 1);
|
||||
},
|
||||
pushToItem(createdItem) {
|
||||
createdItem = this.returnObject ? createdItem : createdItem.name;
|
||||
this.selected.push(createdItem);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
@ -1,79 +0,0 @@
|
||||
<template>
|
||||
<v-select
|
||||
:items="allTags"
|
||||
v-model="selected"
|
||||
label="Tags"
|
||||
chips
|
||||
deletable-chips
|
||||
:dense="dense"
|
||||
:solo="solo"
|
||||
:flat="flat"
|
||||
item-text="name"
|
||||
multiple
|
||||
:return-object="returnObject"
|
||||
@input="emitChange"
|
||||
>
|
||||
<template v-slot:selection="data">
|
||||
<v-chip
|
||||
class="ma-1"
|
||||
:input-value="data.selected"
|
||||
close
|
||||
@click:close="removeByIndex(data.index)"
|
||||
label
|
||||
color="accent"
|
||||
dark
|
||||
>
|
||||
{{ data.item.name }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const MOUNTED_EVENT = "mounted";
|
||||
export default {
|
||||
props: {
|
||||
value: Array,
|
||||
solo: {
|
||||
default: false,
|
||||
},
|
||||
dense: {
|
||||
default: true,
|
||||
},
|
||||
returnObject: {
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selected: [],
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$emit(MOUNTED_EVENT);
|
||||
},
|
||||
|
||||
computed: {
|
||||
allTags() {
|
||||
return this.$store.getters.getAllTags;
|
||||
},
|
||||
flat() {
|
||||
return this.selected.length > 0 && this.solo;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
emitChange() {
|
||||
this.$emit("input", this.selected);
|
||||
},
|
||||
setInit(val) {
|
||||
this.selected = val;
|
||||
},
|
||||
removeByIndex(index) {
|
||||
this.selected.splice(index, 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
@ -114,60 +114,21 @@
|
||||
<BulkAdd @bulk-data="appendIngredients" />
|
||||
|
||||
<h2 class="mt-6">{{ $t("recipe.categories") }}</h2>
|
||||
<v-combobox
|
||||
dense
|
||||
multiple
|
||||
chips
|
||||
item-color="secondary"
|
||||
deletable-chips
|
||||
<CategoryTagSelector
|
||||
:return-object="false"
|
||||
v-model="value.recipeCategory"
|
||||
hide-selected
|
||||
:items="allCategories"
|
||||
text="name"
|
||||
:search-input.sync="categoriesSearchInput"
|
||||
@change="categoriesSearchInput = ''"
|
||||
>
|
||||
<template v-slot:selection="data">
|
||||
<v-chip
|
||||
class="ma-1"
|
||||
:input-value="data.selected"
|
||||
close
|
||||
@click:close="removeCategory(data.index)"
|
||||
label
|
||||
color="accent"
|
||||
dark
|
||||
>
|
||||
{{ data.item }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-combobox>
|
||||
:show-add="true"
|
||||
:show-label="false"
|
||||
/>
|
||||
|
||||
<h2 class="mt-4">{{ $t("recipe.tags") }}</h2>
|
||||
<v-combobox
|
||||
dense
|
||||
multiple
|
||||
chips
|
||||
deletable-chips
|
||||
<CategoryTagSelector
|
||||
:return-object="false"
|
||||
v-model="value.tags"
|
||||
hide-selected
|
||||
:items="allTags"
|
||||
:search-input.sync="tagsSearchInput"
|
||||
@change="tagssSearchInput = ''"
|
||||
>
|
||||
<template v-slot:selection="data">
|
||||
<v-chip
|
||||
class="ma-1"
|
||||
:input-value="data.selected"
|
||||
close
|
||||
label
|
||||
@click:close="removeTags(data.index)"
|
||||
color="accent"
|
||||
dark
|
||||
>
|
||||
{{ data.item }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-combobox>
|
||||
:show-add="true"
|
||||
:tag-selector="true"
|
||||
:show-label="false"
|
||||
/>
|
||||
|
||||
<h2 class="my-4">{{ $t("recipe.notes") }}</h2>
|
||||
<v-card
|
||||
@ -265,11 +226,13 @@ import { api } from "@/api";
|
||||
import utils from "@/utils";
|
||||
import BulkAdd from "./BulkAdd";
|
||||
import ExtrasEditor from "./ExtrasEditor";
|
||||
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
|
||||
export default {
|
||||
components: {
|
||||
BulkAdd,
|
||||
ExtrasEditor,
|
||||
draggable,
|
||||
CategoryTagSelector,
|
||||
},
|
||||
props: {
|
||||
value: Object,
|
||||
@ -285,26 +248,14 @@ export default {
|
||||
v.split(" ").length <= 1 ||
|
||||
this.$i18n.t("recipe.no-white-space-allowed"),
|
||||
},
|
||||
categoriesSearchInput: "",
|
||||
tagsSearchInput: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
allCategories() {
|
||||
const categories = this.$store.getters.getAllCategories;
|
||||
return categories.map(cat => cat.name);
|
||||
},
|
||||
allTags() {
|
||||
const tags = this.$store.getters.getAllTags;
|
||||
return tags.map(cat => cat.name);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
uploadImage() {
|
||||
this.$emit("upload", this.fileObject);
|
||||
},
|
||||
async updateImage() {
|
||||
let slug = this.value.slug;
|
||||
const slug = this.value.slug;
|
||||
api.recipes.updateImage(slug, this.fileObject);
|
||||
},
|
||||
toggleDisabled(stepIndex) {
|
||||
@ -327,9 +278,6 @@ export default {
|
||||
generateKey(item, index) {
|
||||
return utils.generateUniqueKey(item, index);
|
||||
},
|
||||
deleteRecipe() {
|
||||
this.$emit("delete");
|
||||
},
|
||||
|
||||
appendIngredients(ingredients) {
|
||||
this.value.recipeIngredient.push(...ingredients);
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-btn icon @click="dialog = true">
|
||||
<v-icon color="white">mdi-plus</v-icon>
|
||||
<v-btn icon @click="dialog = true" class="mt-n1">
|
||||
<v-icon :color="color">mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
<v-dialog v-model="dialog" width="500">
|
||||
<v-card>
|
||||
@ -11,7 +11,7 @@
|
||||
</v-icon>
|
||||
|
||||
<v-toolbar-title class="headline">
|
||||
Create a Category
|
||||
{{ title }}
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
@ -21,8 +21,8 @@
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
dense
|
||||
label="Category Name"
|
||||
v-model="categoryName"
|
||||
:label="inputLabel"
|
||||
v-model="itemName"
|
||||
:rules="[rules.required]"
|
||||
></v-text-field>
|
||||
</v-card-text>
|
||||
@ -31,7 +31,7 @@
|
||||
<v-btn color="grey" text @click="dialog = false">
|
||||
{{ $t("general.cancel") }}
|
||||
</v-btn>
|
||||
<v-btn color="success" text type="submit" :disabled="!categoryName">
|
||||
<v-btn color="success" text type="submit" :disabled="!itemName">
|
||||
{{ $t("general.create") }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
@ -43,31 +43,56 @@
|
||||
|
||||
<script>
|
||||
import { api } from "@/api";
|
||||
const CREATED_ITEM_EVENT = "created-item";
|
||||
export default {
|
||||
props: {
|
||||
buttonText: String,
|
||||
value: String,
|
||||
color: {
|
||||
default: null,
|
||||
},
|
||||
tagDialog: {
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dialog: false,
|
||||
categoryName: "",
|
||||
itemName: "",
|
||||
rules: {
|
||||
required: val =>
|
||||
!!val || this.$t("settings.theme.theme-name-is-required"),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
title() {
|
||||
return this.tagDialog ? "Create a Tag" : "Create a Category";
|
||||
},
|
||||
inputLabel() {
|
||||
return this.tagDialog ? "Tag Name" : "Tag Category";
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
dialog(val) {
|
||||
if (!val) this.categoryName = "";
|
||||
if (!val) this.itemName = "";
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async select() {
|
||||
await api.categories.create(this.categoryName);
|
||||
this.$emit("new-category", this.categoryName);
|
||||
const newItem = await (async () => {
|
||||
if (this.tagDialog) {
|
||||
const newItem = await api.tags.create(this.itemName);
|
||||
return newItem;
|
||||
} else {
|
||||
const newItem = await api.categories.create(this.itemName);
|
||||
return newItem;
|
||||
}
|
||||
})();
|
||||
|
||||
this.$emit(CREATED_ITEM_EVENT, newItem);
|
||||
this.dialog = false;
|
||||
},
|
||||
},
|
@ -17,11 +17,18 @@
|
||||
</v-text-field>
|
||||
</template>
|
||||
<v-card v-if="showResults" max-height="500" :max-width="maxWidth">
|
||||
<v-card-text class="py-1">Results</v-card-text>
|
||||
<v-card-text class="flex row mx-auto">
|
||||
<div class="mr-auto">
|
||||
Results
|
||||
</div>
|
||||
<router-link to="/search">
|
||||
Advanced Search
|
||||
</router-link>
|
||||
</v-card-text>
|
||||
<v-divider></v-divider>
|
||||
<v-list scrollable>
|
||||
<v-list scrollable v-if="autoResults">
|
||||
<v-list-item
|
||||
v-for="(item, index) in autoResults"
|
||||
v-for="(item, index) in autoResults.slice(0, 15)"
|
||||
:key="index"
|
||||
:to="navOnClick ? `/recipe/${item.item.slug}` : null"
|
||||
@click="navOnClick ? null : selected(item.item.slug, item.item.name)"
|
||||
|
@ -3,7 +3,7 @@ export const validators = {
|
||||
return {
|
||||
emailRule: v =>
|
||||
!v ||
|
||||
/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(v) ||
|
||||
/^[^@\s]+@[^@\s.]+.[^@.\s]+$/.test(v) ||
|
||||
this.$t('user.e-mail-must-be-valid'),
|
||||
|
||||
existsRule: value => !!value || this.$t('general.field-required'),
|
||||
|
@ -7,41 +7,19 @@
|
||||
<v-card-text>
|
||||
<h2 class="mt-1">{{ $t("recipe.categories") }}</h2>
|
||||
|
||||
<v-row>
|
||||
<v-col sm="12" md="6">
|
||||
<v-select
|
||||
outlined
|
||||
:flat="isFlat"
|
||||
elavation="0"
|
||||
v-model="groupSettings.categories"
|
||||
:items="categories"
|
||||
item-text="name"
|
||||
return-object
|
||||
multiple
|
||||
chips
|
||||
:hint="
|
||||
$t(
|
||||
'meal-plan.only-recipes-with-these-categories-will-be-used-in-meal-plans'
|
||||
)
|
||||
"
|
||||
class="mt-2"
|
||||
persistent-hint
|
||||
>
|
||||
<template v-slot:selection="data">
|
||||
<v-chip
|
||||
outlined
|
||||
:input-value="data.selected"
|
||||
close
|
||||
@click:close="removeCategory(data.index)"
|
||||
color="secondary"
|
||||
dark
|
||||
>
|
||||
{{ data.item.name }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<CategoryTagSelector
|
||||
class="mt-4"
|
||||
:solo="true"
|
||||
:dense="false"
|
||||
v-model="groupSettings.categories"
|
||||
:return-object="true"
|
||||
:show-add="true"
|
||||
:hint="
|
||||
$t(
|
||||
'meal-plan.only-recipes-with-these-categories-will-be-used-in-meal-plans'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-divider> </v-divider>
|
||||
<v-card-text>
|
||||
@ -59,7 +37,7 @@
|
||||
|
||||
<v-row dense class="flex align-center">
|
||||
<v-switch
|
||||
class="mx-2"
|
||||
class="mx-2"
|
||||
v-model="groupSettings.webhookEnable"
|
||||
:label="$t('general.enabled')"
|
||||
></v-switch>
|
||||
@ -105,9 +83,11 @@
|
||||
<script>
|
||||
import { api } from "@/api";
|
||||
import TimePickerDialog from "@/components/Admin/MealPlanner/TimePickerDialog";
|
||||
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
|
||||
export default {
|
||||
components: {
|
||||
TimePickerDialog,
|
||||
CategoryTagSelector,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -155,6 +135,7 @@ export default {
|
||||
this.groupSettings.webhookUrls.splice(index, 1);
|
||||
},
|
||||
async saveGroupSettings() {
|
||||
console.log(this.groupSettings);
|
||||
await api.groups.update(this.groupSettings);
|
||||
await this.$store.dispatch("requestCurrentGroup");
|
||||
this.getSiteSettings();
|
||||
@ -162,9 +143,6 @@ export default {
|
||||
testWebhooks() {
|
||||
api.settings.testWebhooks();
|
||||
},
|
||||
removeCategory(index) {
|
||||
this.groupSettings.categories.splice(index, 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -28,7 +28,7 @@
|
||||
<v-col>
|
||||
<h3 class="pl-2 text-center headline">Category Filter</h3>
|
||||
<FilterSelector class="mb-1" @update="updateCatParams" />
|
||||
<CategorySelector
|
||||
<CategoryTagSelector
|
||||
:solo="true"
|
||||
:dense="false"
|
||||
v-model="includeCategories"
|
||||
@ -38,11 +38,13 @@
|
||||
<v-col>
|
||||
<h3 class="pl-2 text-center headline">Tag Filter</h3>
|
||||
<FilterSelector class="mb-1" @update="updateTagParams" />
|
||||
<TagSelector
|
||||
|
||||
<CategoryTagSelector
|
||||
:solo="true"
|
||||
:dense="false"
|
||||
v-model="includeTags"
|
||||
:return-object="false"
|
||||
:tag-selector="true"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@ -74,16 +76,14 @@
|
||||
import Fuse from "fuse.js";
|
||||
import RecipeCard from "@/components/Recipe/RecipeCard";
|
||||
import CategorySidebar from "@/components/UI/CategorySidebar";
|
||||
import CategorySelector from "@/components/FormHelpers/CategorySelector";
|
||||
import TagSelector from "@/components/FormHelpers/TagSelector";
|
||||
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
|
||||
import FilterSelector from "./FilterSelector.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
RecipeCard,
|
||||
CategorySidebar,
|
||||
CategorySelector,
|
||||
TagSelector,
|
||||
CategoryTagSelector,
|
||||
FilterSelector,
|
||||
},
|
||||
data() {
|
||||
|
@ -127,5 +127,3 @@ class AppSettings(BaseSettings):
|
||||
|
||||
|
||||
settings = AppSettings()
|
||||
|
||||
print(settings.dict())
|
||||
|
@ -9,7 +9,8 @@ from mealie.db.models.users import User
|
||||
from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse
|
||||
from mealie.schema.meal import MealPlanInDB
|
||||
from mealie.schema.recipe import Recipe
|
||||
from mealie.schema.settings import CustomPageOut, SiteSettings as SiteSettingsSchema
|
||||
from mealie.schema.settings import CustomPageOut
|
||||
from mealie.schema.settings import SiteSettings as SiteSettingsSchema
|
||||
from mealie.schema.sign_up import SignUpOut
|
||||
from mealie.schema.theme import SiteTheme
|
||||
from mealie.schema.user import GroupInDB, UserInDB
|
||||
|
@ -60,7 +60,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
@validates("name")
|
||||
def validate_name(self, key, name):
|
||||
assert not name == ""
|
||||
assert name != ""
|
||||
return name
|
||||
|
||||
def __init__(
|
||||
@ -92,11 +92,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
self.image = image
|
||||
self.recipeCuisine = recipeCuisine
|
||||
|
||||
if self.nutrition:
|
||||
self.nutrition = Nutrition(**nutrition)
|
||||
else:
|
||||
self.nutrition = Nutrition()
|
||||
|
||||
self.nutrition = Nutrition(**nutrition) if self.nutrition else Nutrition()
|
||||
self.tools = [Tool(tool=x) for x in tools] if tools else []
|
||||
|
||||
self.recipeYield = recipeYield
|
||||
|
@ -1,7 +1,7 @@
|
||||
import sqlalchemy as sa
|
||||
import sqlalchemy.orm as orm
|
||||
from mealie.db.models.model_base import SqlAlchemyBase
|
||||
from fastapi.logger import logger
|
||||
from mealie.db.models.model_base import SqlAlchemyBase
|
||||
from slugify import slugify
|
||||
from sqlalchemy.orm import validates
|
||||
|
||||
@ -25,7 +25,7 @@ class Tag(SqlAlchemyBase):
|
||||
assert name != ""
|
||||
return name
|
||||
|
||||
def __init__(self, name) -> None:
|
||||
def __init__(self, name, session=None) -> None:
|
||||
self.name = name.strip()
|
||||
self.slug = slugify(self.name)
|
||||
|
||||
|
@ -7,7 +7,7 @@ from mealie.db.db_setup import generate_session
|
||||
from mealie.routes.deps import get_current_user
|
||||
from mealie.schema.recipe import Recipe, RecipeURLIn
|
||||
from mealie.schema.snackbar import SnackResponse
|
||||
from mealie.services.image.image import IMG_OPTIONS, delete_image, read_image, write_image
|
||||
from mealie.services.image.image import IMG_OPTIONS, delete_image, read_image, rename_image, write_image
|
||||
from mealie.services.scraper.scraper import create_from_url
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
@ -61,6 +61,9 @@ def update_recipe(
|
||||
|
||||
recipe: Recipe = db.recipes.update(session, recipe_slug, data.dict())
|
||||
|
||||
if recipe_slug != recipe.slug:
|
||||
rename_image(original_slug=recipe_slug, new_slug=recipe.slug)
|
||||
|
||||
return recipe.slug
|
||||
|
||||
|
||||
|
@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
|
||||
from mealie.db.database import db
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.routes.deps import get_current_user
|
||||
from mealie.schema.category import RecipeTagResponse
|
||||
from mealie.schema.category import RecipeTagResponse, TagIn
|
||||
from mealie.schema.snackbar import SnackResponse
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
@ -19,6 +19,14 @@ async def get_all_recipe_tags(session: Session = Depends(generate_session)):
|
||||
""" Returns a list of available tags in the database """
|
||||
return db.tags.get_all_limit_columns(session, ["slug", "name"])
|
||||
|
||||
@router.post("")
|
||||
async def create_recipe_tag(
|
||||
tag: TagIn, session: Session = Depends(generate_session), current_user=Depends(get_current_user)
|
||||
):
|
||||
""" Creates a Tag in the database """
|
||||
|
||||
return db.tags.create(session, tag.dict())
|
||||
|
||||
|
||||
@router.get("/{tag}", response_model=RecipeTagResponse)
|
||||
def get_all_recipes_by_tag(tag: str, session: Session = Depends(generate_session)):
|
||||
|
@ -4,7 +4,7 @@ from datetime import timedelta
|
||||
from fastapi import APIRouter, Depends, File, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from mealie.core import security
|
||||
from mealie.core.config import settings, app_dirs
|
||||
from mealie.core.config import app_dirs, settings
|
||||
from mealie.core.security import get_password_hash, verify_password
|
||||
from mealie.db.database import db
|
||||
from mealie.db.db_setup import generate_session
|
||||
|
@ -3,24 +3,30 @@
|
||||
# Get Reload Arg `run.sh reload` for dev server
|
||||
ARG1=${1:-production}
|
||||
|
||||
# Initialize Database Prerun
|
||||
python mealie/db/init_db.py
|
||||
python mealie/services/image/minify.py
|
||||
# Set Script Directory - Used for running the script from a different directory.
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
|
||||
## Migrations
|
||||
# # Initialize Database Prerun
|
||||
poetry run python $DIR/db/init_db.py
|
||||
poetry run python $DIR/services/image/minify.py
|
||||
|
||||
# Migrations
|
||||
# TODO
|
||||
# Migrations
|
||||
# Set Port from ENV Variable
|
||||
|
||||
if [ "$ARG1" = "reload" ]
|
||||
if [[ "$ARG1" = "reload" ]]
|
||||
then
|
||||
echo "Hot reload"
|
||||
echo "Hot Reload!"
|
||||
|
||||
# Start API
|
||||
uvicorn mealie.app:app --host 0.0.0.0 --port 9000 --reload
|
||||
else
|
||||
echo "Production config"
|
||||
echo "Production"
|
||||
# Web Server
|
||||
caddy start --config ./Caddyfile
|
||||
|
||||
# Start API
|
||||
uvicorn mealie.app:app --host 0.0.0.0 --port 9000
|
||||
fi
|
||||
fi
|
||||
|
||||
|
@ -23,6 +23,10 @@ class RecipeCategoryResponse(CategoryBase):
|
||||
schema_extra = {"example": {"id": 1, "name": "dinner", "recipes": [{}]}}
|
||||
|
||||
|
||||
class TagIn(CategoryIn):
|
||||
pass
|
||||
|
||||
|
||||
class TagBase(CategoryBase):
|
||||
pass
|
||||
|
||||
|
@ -29,7 +29,6 @@ def read_image(recipe_slug: str, image_type: str = "original") -> Path:
|
||||
Returns:
|
||||
Path: [description]
|
||||
"""
|
||||
print(image_type)
|
||||
recipe_slug = recipe_slug.split(".")[0] # Incase of File Name
|
||||
recipe_image_dir = app_dirs.IMG_DIR.joinpath(recipe_slug)
|
||||
|
||||
@ -39,6 +38,18 @@ def read_image(recipe_slug: str, image_type: str = "original") -> Path:
|
||||
return None
|
||||
|
||||
|
||||
def rename_image(original_slug, new_slug) -> Path:
|
||||
current_path = app_dirs.IMG_DIR.joinpath(original_slug)
|
||||
new_path = app_dirs.IMG_DIR.joinpath(new_slug)
|
||||
|
||||
try:
|
||||
new_path = current_path.rename(new_path)
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Image Directory {original_slug} Doesn't Exist")
|
||||
|
||||
return new_path
|
||||
|
||||
|
||||
def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path.name:
|
||||
try:
|
||||
delete_image(recipe_slug)
|
||||
|
@ -1,8 +1,12 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.logger import logger
|
||||
from mealie.core.config import app_dirs
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from mealie.db.database import db
|
||||
from mealie.db.db_setup import create_session
|
||||
from PIL import Image
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
|
||||
def minify_image(image_file: Path, min_dest: Path, tiny_dest: Path):
|
||||
@ -24,7 +28,7 @@ def minify_image(image_file: Path, min_dest: Path, tiny_dest: Path):
|
||||
tiny_image = crop_center(img)
|
||||
tiny_image.save(tiny_dest, quality=70)
|
||||
|
||||
except:
|
||||
except Exception:
|
||||
shutil.copy(image_file, min_dest)
|
||||
shutil.copy(image_file, tiny_dest)
|
||||
|
||||
@ -59,6 +63,28 @@ def move_all_images():
|
||||
image_file.rename(new_folder.joinpath(f"original{image_file.suffix}"))
|
||||
|
||||
|
||||
def validate_slugs_in_database(session: Session = None):
|
||||
def check_image_path(image_name: str, slug_path: str) -> bool:
|
||||
existing_path: Path = app_dirs.IMG_DIR.joinpath(image_name)
|
||||
slug_path: Path = app_dirs.IMG_DIR.joinpath(slug_path)
|
||||
|
||||
if existing_path.is_dir():
|
||||
slug_path.rename(existing_path)
|
||||
else:
|
||||
logger.info("No Image Found")
|
||||
|
||||
session = session or create_session()
|
||||
all_recipes = db.recipes.get_all(session)
|
||||
|
||||
slugs_and_images = [(x.slug, x.image) for x in all_recipes]
|
||||
|
||||
for slug, image in slugs_and_images:
|
||||
image_slug = image.split(".")[0] # Remove Extension
|
||||
if slug != image_slug:
|
||||
logger.info(f"{slug}, Doesn't Match '{image_slug}'")
|
||||
check_image_path(image, slug)
|
||||
|
||||
|
||||
def migrate_images():
|
||||
print("Checking for Images to Minify...")
|
||||
|
||||
@ -77,10 +103,11 @@ def migrate_images():
|
||||
org_size = sizeof_fmt(image.stat().st_size)
|
||||
dest_size = sizeof_fmt(min_dest.stat().st_size)
|
||||
tiny_size = sizeof_fmt(tiny_dest.stat().st_size)
|
||||
print(f"{image.name} Minified: {org_size} -> {dest_size} -> {tiny_size}")
|
||||
logger.info(f"{image.name} Minified: {org_size} -> {dest_size} -> {tiny_size}")
|
||||
|
||||
print("Finished Minification Check")
|
||||
logger.info("Finished Minification Check")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_images()
|
||||
validate_slugs_in_database()
|
||||
|
@ -6,6 +6,7 @@ from fastapi.logger import logger
|
||||
from mealie.core.config import app_dirs
|
||||
from mealie.db.database import db
|
||||
from mealie.schema.recipe import Recipe
|
||||
from mealie.services.image.minify import migrate_images
|
||||
from mealie.utils.unzip import unpack_zip
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
@ -89,4 +90,5 @@ def chowdown_migrate(session: Session, zip_file: Path):
|
||||
failed_images.append(image.name)
|
||||
report = {"successful": successful_recipes, "failed": failed_recipes}
|
||||
|
||||
migrate_images()
|
||||
return report
|
||||
|
@ -7,6 +7,7 @@ from pathlib import Path
|
||||
from mealie.core.config import app_dirs
|
||||
from mealie.db.database import db
|
||||
from mealie.schema.recipe import Recipe
|
||||
from mealie.services.image import minify
|
||||
from mealie.services.scraper.cleaner import Cleaner
|
||||
|
||||
|
||||
@ -23,39 +24,43 @@ def process_selection(selection: Path) -> Path:
|
||||
return None
|
||||
|
||||
|
||||
def clean_nextcloud_tags(nextcloud_tags: str):
|
||||
if not isinstance(nextcloud_tags, str):
|
||||
return None
|
||||
|
||||
return [x.title().lstrip() for x in nextcloud_tags.split(",") if x != ""]
|
||||
|
||||
|
||||
def import_recipes(recipe_dir: Path) -> Recipe:
|
||||
image = False
|
||||
|
||||
for file in recipe_dir.glob("full.*"):
|
||||
image = file
|
||||
break
|
||||
|
||||
for file in recipe_dir.glob("*.json"):
|
||||
recipe_file = file
|
||||
break
|
||||
|
||||
with open(recipe_file, "r") as f:
|
||||
recipe_dict = json.loads(f.read())
|
||||
|
||||
recipe_data = Cleaner.clean(recipe_dict)
|
||||
|
||||
image_name = None
|
||||
if image:
|
||||
image_name = recipe_data["slug"] + image.suffix
|
||||
recipe_data["image"] = image_name
|
||||
else:
|
||||
recipe_data["image"] = "none"
|
||||
image_name = recipe_data["slug"]
|
||||
recipe_data["image"] = recipe_data["slug"]
|
||||
recipe_data["tags"] = clean_nextcloud_tags(recipe_data.get("keywords"))
|
||||
|
||||
recipe = Recipe(**recipe_data)
|
||||
|
||||
if image:
|
||||
shutil.copy(image, app_dirs.IMG_DIR.joinpath(image_name))
|
||||
shutil.copy(image, app_dirs.IMG_DIR.joinpath(image_name + image.suffix))
|
||||
|
||||
return recipe
|
||||
|
||||
|
||||
def prep():
|
||||
try:
|
||||
shutil.rmtree(app_dirs.TEMP_DIR)
|
||||
except:
|
||||
pass
|
||||
shutil.rmtree(app_dirs.TEMP_DIR, ignore_errors=True)
|
||||
app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
|
||||
@ -80,11 +85,13 @@ def migrate(session, selection: str):
|
||||
db.recipes.create(session, recipe.dict())
|
||||
|
||||
successful_imports.append(recipe.name)
|
||||
except:
|
||||
except Exception:
|
||||
session.rollback()
|
||||
logging.error(f"Failed Nextcloud Import: {dir.name}")
|
||||
logging.exception("")
|
||||
failed_imports.append(dir.name)
|
||||
|
||||
cleanup()
|
||||
minify.migrate_images()
|
||||
|
||||
return {"successful": successful_imports, "failed": failed_imports}
|
||||
|
@ -29,7 +29,7 @@ def test_non_default_settings(monkeypatch):
|
||||
monkeypatch.setenv("DEFAULT_GROUP", "Test Group")
|
||||
monkeypatch.setenv("DEFAULT_PASSWORD", "Test Password")
|
||||
monkeypatch.setenv("API_PORT", "8000")
|
||||
monkeypatch.setenv("API_DOCS", False)
|
||||
monkeypatch.setenv("API_DOCS", 'False')
|
||||
|
||||
app_settings = AppSettings()
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@ -36,4 +37,4 @@ def test_zip_extraction(file_name: str, final_path: Path):
|
||||
def test_nextcloud_migration(recipe_dir: Path):
|
||||
recipe = import_recipes(recipe_dir)
|
||||
assert isinstance(recipe, Recipe)
|
||||
app_dirs.IMG_DIR.joinpath(recipe.image).unlink(missing_ok=True)
|
||||
shutil.rmtree(app_dirs.IMG_DIR.joinpath(recipe.image), ignore_errors=True)
|
||||
|
Loading…
x
Reference in New Issue
Block a user