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:
Hayden 2021-04-06 22:29:02 -08:00 committed by GitHub
parent a396604520
commit 1cf95bb3b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 334 additions and 356 deletions

View File

@ -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"]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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 {

View File

@ -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 {

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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);

View File

@ -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;
},
},

View File

@ -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)"

View File

@ -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'),

View File

@ -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>

View File

@ -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() {

View File

@ -127,5 +127,3 @@ class AppSettings(BaseSettings):
settings = AppSettings()
print(settings.dict())

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)):

View File

@ -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

View File

@ -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

View File

@ -23,6 +23,10 @@ class RecipeCategoryResponse(CategoryBase):
schema_extra = {"example": {"id": 1, "name": "dinner", "recipes": [{}]}}
class TagIn(CategoryIn):
pass
class TagBase(CategoryBase):
pass

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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}

View File

@ -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()

View File

@ -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)