v0.4.1 - Frontend/UI Improvements (#267)

* fix links

* actually fix #238

* Feature/mkdocs version bump (#240)

* fix links (#239)

Co-authored-by: hay-kot <hay-kot@pm.me>

* fix #238

* bump mkdocs version

* light/dark toggle

* light/dark mode css

* API_DOCS defaults to True

* disable build on push for master

Co-authored-by: hay-kot <hay-kot@pm.me>

* Feature/recipe viewer (#244)

* fix dialog placement

* markdown support in ingredients

* fix line render issue

* fix tag rendering bug

* change ingredients to text area

* no slug error

* add tag pages

* remove console.logs

Co-authored-by: hay-kot <hay-kot@pm.me>

* changelog v0.4.1

* bug/backup-download (#245)

* fix blocked download

* + download blocked

Co-authored-by: hay-kot <hay-kot@pm.me>

* Feature/meal planner (#246)

* fixes duplicate recipes in meal-plan #221

* add quick week option

* scope css

* add mealplanner info

Co-authored-by: hay-kot <hay-kot@pm.me>

* Nextcloud Import Bugs - #248 (#250)

* parses datetime properly + clean category - #248

* add default credentials to docs

Co-authored-by: hay-kot <hay-kot@pm.me>

* Add bulk import examples to docs. (#252)

* Add bulk import examples to docs.

* Update api-usage.md

* Add Python example for bulk import.

* Change IP address in API example.

* Refactor/app settings (#251)

* fix env setup bugs

* remove unused import

* fix layout issues

* changelog

Co-authored-by: hay-kot <hay-kot@pm.me>

* env setup fixes

* Feature/about api (#253)

* fix settings

* app info cleanup

Co-authored-by: hay-kot <hay-kot@pm.me>

* Feature/image minify (#256)

* fix settings

* app info cleanup

* bottom-bar experiment

* remove dup key

* type hints

* add dependency

* updated image with query parameters

* read image options

* add image minification

* add image minification step

* alt image routes

* add image minification

* set mobile bar to top

Co-authored-by: hay-kot <hay-kot@pm.me>

* Feature/additional endpoints (#257)

* new recipe summary route

* add categories to cards

* add pillow

* show tags instead of categories

* additional debug info

* add todays meal image url

* about page

* fix reactive tag

* changelog + docs

* bump version

Co-authored-by: hay-kot <hay-kot@pm.me>

* add pillow dependencies (#258)

Co-authored-by: hay-kot <hay-kot@pm.me>

* Feature/search page (#259)

* add pillow dependencies

* advanced search page

* advanced search apge

* remove extra dependencies

* add pre-run script

Co-authored-by: hay-kot <hay-kot@pm.me>

* no image assignment

* advanced search

* fix docker dev build

* Do not force theme settings on login form (#260)

* Fix docker dev db persistence (#264)

* Fix docker dev db persistence

* Make run.sh the only startup script for prod + dev

Credits to @hay-kot for run.sh script logic

* Restore dev backend initialization in non-docker setup

* Make run.sh POSIX-friendly

* Allow dev backend to auto-reload in Docker

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

* bug-fixes/category-tag-creator (#266)

* fix category labels

* set loader for migration

* v0.4.1

Co-authored-by: hay-kot <hay-kot@pm.me>

Co-authored-by: hay-kot <hay-kot@pm.me>
Co-authored-by: Nat <nathanynath@yahoo.fr>
Co-authored-by: sephrat <34862846+sephrat@users.noreply.github.com>
This commit is contained in:
Hayden 2021-04-07 17:22:25 -08:00 committed by GitHub
parent b8cddfd6c5
commit 9c379dfb83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
89 changed files with 2001 additions and 710 deletions

View File

@ -7,14 +7,25 @@ RUN npm run build
FROM python:3.9-alpine FROM python:3.9-alpine
RUN apk add --no-cache libxml2-dev libxslt-dev libxml2 caddy libffi-dev
ENV ENV prod RUN apk add --no-cache libxml2-dev \
libxslt-dev \
libxml2 caddy \
libffi-dev \
python3 \
python3-dev \
jpeg-dev \
lcms2-dev \
openjpeg-dev \
zlib-dev
ENV ENV True
EXPOSE 80 EXPOSE 80
WORKDIR /app/ WORKDIR /app/
COPY ./pyproject.toml /app/ COPY ./pyproject.toml /app/
RUN apk add --update --no-cache --virtual .build-deps \ RUN apk add --update --no-cache --virtual .build-deps \
curl \ curl \
g++ \ g++ \

View File

@ -12,10 +12,11 @@ RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-
poetry config virtualenvs.create false poetry config virtualenvs.create false
# Copy poetry.lock* in case it doesn't exist in the repo # Copy poetry.lock* in case it doesn't exist in the repo
COPY ./pyproject.toml ./poetry.lock* /app/ COPY ./pyproject.toml /app/
RUN poetry install
COPY ./mealie /app/mealie COPY ./mealie /app/mealie
CMD ["uvicorn", "mealie.app:app", "--host", "0.0.0.0", "--port", "9000", "--reload"] RUN poetry install
RUN chmod +x /app/mealie/run.sh
CMD ["/app/mealie/run.sh", "reload"]

View File

@ -1,6 +1,6 @@
![Recipe Image](../../images/{{ recipe.image }}) ![Recipe Image](../../images/{{ recipe.slug }}/original.jpg)
# {{ recipe.name }} # {{ recipe.name }}
{{ recipe.description }} {{ recipe.description }}

View File

@ -29,8 +29,8 @@ services:
db_type: sqlite db_type: sqlite
TZ: America/Anchorage # Specify Correct Timezone for Date/Time to line up correctly. TZ: America/Anchorage # Specify Correct Timezone for Date/Time to line up correctly.
volumes: volumes:
- ./app_data:/app_data - ./dev/data:/app/dev/data
- ./mealie:/app - ./mealie:/app/mealie
# Mkdocs # Mkdocs
mealie-docs: mealie-docs:

View File

@ -0,0 +1,35 @@
# v0.4.1
**App Version: v0.4.1**
**Database Version: v0.4.0**
!!! error "Breaking Changes"
#### Recipe Images
While it *shouldn't* be a breaking change, I feel it is important to note that you may experience issues with the new image migration. Recipe images are now minified, this is done on start-up, import, migration, and when a new recipe is created. The initial boot or load may be a bit slow if you have lots of recipes but you likely won't notice. What you may notice is that if your recipe slug and the image name do not match, you will encounter issues with your images showing up. This can be resolved by finding the image directory and rename it to the appropriate slug. I did fix multiple edge cases, but it is likely more exists. As always make a backup before you update!
On the plus side, this comes with a huge performance increase! 🎉
- Add markdown support for ingredients - Resolves #32
- Ingredients editor improvements
- Fix Tags/Categories render problems on recipes
- Tags redirect to new tag pages
- Categories redirect to category pages
- Fix Backup download blocked by authentication
- Random meal-planner will no longer duplicate recipes unless no other options
- New Quick Week button to generate next 5 day week of recipe slots.
- Minor UI tweaks
- Recipe Cards now display 2 recipe tags
- Recipe images are now minified. This comes with a serious performance improvement. On initial startup you may experience some delays. Images are migrated to the new structure on startup, depending on the size of your database this can take some time.
- Note that original images are still kept for large displays like on the individual recipe pages.
- 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
- Recipe Tag/Category Input components have been unified and now share a single way to interact. To add a new category in the recipe editor you need to click to '+' icon next to the input and fill out the form. This is the same for adding a Tag.

View File

@ -10,5 +10,94 @@ For example you could add `{"message": "Remember to thaw the chicken"}` to a rec
## Examples ## Examples
### Bulk import
Recipes can be imported in bulk from a file containing a list of URLs. This can be done using the following bash or python scripts with the `list` file containing one URL per line.
#### Bash
```bash
#!/bin/bash
function authentification () {
auth=$(curl -X 'POST' \
"$3/api/auth/token" \
-H 'accept: application/json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=&username='$1'&password='$2'&scope=&client_id=&client_secret=')
echo $auth | sed -e 's/.*token":"\(.*\)",.*/\1/'
}
function import_from_file () {
while IFS= read -r line
do
echo $line
curl -X 'POST' \
"$3/api/recipes/create-url" \
-H "Authorization: Bearer $2" \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{"url": "'$line'" }'
echo
done < "$1"
}
input="list"
mail="changeme@email.com"
password="MyPassword"
mealie_url=http://localhost:9000
token=$(authentification $mail $password $mealie_url)
import_from_file $input $token $mealie_url
```
#### Python
```python
import requests
import re
def authentification(mail, password, mealie_url):
headers = {
'accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
}
data = {
'grant_type': '',
'username': mail,
'password': password,
'scope': '',
'client_id': '',
'client_secret': ''
}
auth = requests.post(mealie_url + "/api/auth/token", headers=headers, data=data)
token = re.sub(r'.*token":"(.*)",.*', r'\1', auth.text)
return token
def import_from_file(input_file, token, mealie_url):
with open(input_file) as fp:
for l in fp:
line = re.sub(r'(.*)\n', r'\1', l)
print(line)
headers = {
'Authorization': "Bearer " + token,
'accept': 'application/json',
'Content-Type': 'application/json'
}
data = {
'url': line
}
response = requests.post(mealie_url + "/api/recipes/create-url", headers=headers, json=data)
print(response.text)
input_file="list"
mail="changeme@email.com"
password="MyPassword"
mealie_url="http://localhost:9000"
token = authentification(mail, password, mealie_url)
import_from_file(input_file, token, mealie_url)
```
Have Ideas? Submit a PR! Have Ideas? Submit a PR!

View File

@ -23,6 +23,11 @@ docker run \
``` ```
!!! tip "Default Credentials"
**Username:** changeme@email.com
**Password:** MyPassword
## Docker Compose with SQLite ## Docker Compose with SQLite
Deployment with docker-compose is the recommended method for deployment. The example below will create an instance of mealie available on port `9925` with the data volume mounted from the local directory. To use, create a docker-compose.yml file, paste the contents below and save. In the terminal run `docker-compose up -d` to start the container. Deployment with docker-compose is the recommended method for deployment. The example below will create an instance of mealie available on port `9925` with the data volume mounted from the local directory. To use, create a docker-compose.yml file, paste the contents below and save. In the terminal run `docker-compose up -d` to start the container.

File diff suppressed because one or more lines are too long

View File

@ -74,6 +74,7 @@ nav:
- Guidelines: "contributors/developers-guide/general-guidelines.md" - Guidelines: "contributors/developers-guide/general-guidelines.md"
- Development Road Map: "roadmap.md" - Development Road Map: "roadmap.md"
- Change Log: - Change Log:
- v0.4.1 Frontend/UI: "changelog/v0.4.1.md"
- v0.4.0 Authentication: "changelog/v0.4.0.md" - v0.4.0 Authentication: "changelog/v0.4.0.md"
- v0.3.0 Improvements: "changelog/v0.3.0.md" - v0.3.0 Improvements: "changelog/v0.3.0.md"
- v0.2.0 Now With Tests!: "changelog/v0.2.0.md" - v0.2.0 Now With Tests!: "changelog/v0.2.0.md"

View File

@ -1,35 +1,6 @@
<template> <template>
<v-app> <v-app>
<v-app-bar clipped-left dense app color="primary" dark class="d-print-none"> <TheAppBar />
<router-link v-if="!(isMobile && search)" to="/">
<v-btn icon>
<v-icon size="40"> mdi-silverware-variant </v-icon>
</v-btn>
</router-link>
<div v-if="!isMobile" btn class="pl-2">
<v-toolbar-title style="cursor: pointer" @click="$router.push('/')"
>Mealie
</v-toolbar-title>
</div>
<v-spacer></v-spacer>
<v-expand-x-transition>
<SearchBar
ref="mainSearchBar"
v-if="search"
:show-results="true"
@selected="navigateFromSearch"
:max-width="isMobile ? '100%' : '450px'"
/>
</v-expand-x-transition>
<v-btn icon @click="search = !search">
<v-icon>mdi-magnify</v-icon>
</v-btn>
<SiteMenu />
<LanguageMenu />
</v-app-bar>
<v-main> <v-main>
<v-banner v-if="demo" sticky <v-banner v-if="demo" sticky
><div class="text-center"> ><div class="text-center">
@ -47,34 +18,25 @@
</template> </template>
<script> <script>
import SiteMenu from "@/components/UI/SiteMenu"; import TheAppBar from "@/components/UI/TheAppBar";
import SearchBar from "@/components/UI/Search/SearchBar";
import AddRecipeFab from "@/components/UI/AddRecipeFab"; import AddRecipeFab from "@/components/UI/AddRecipeFab";
import LanguageMenu from "@/components/UI/LanguageMenu";
import Vuetify from "./plugins/vuetify"; import Vuetify from "./plugins/vuetify";
import { user } from "@/mixins/user"; import { user } from "@/mixins/user";
import { api } from "./api";
export default { export default {
name: "App", name: "App",
components: { components: {
SiteMenu, TheAppBar,
AddRecipeFab, AddRecipeFab,
SearchBar,
LanguageMenu,
}, },
mixins: [user], mixins: [user],
watch: {
$route() {
this.search = false;
},
},
computed: { computed: {
isMobile() { demo() {
return this.$vuetify.breakpoint.name === "xs"; const appInfo = this.$store.getters.getAppInfo;
return appInfo.demoStatus;
}, },
}, },
@ -96,15 +58,9 @@ export default {
this.$store.dispatch("requestTags"); this.$store.dispatch("requestTags");
this.darkModeSystemCheck(); this.darkModeSystemCheck();
this.darkModeAddEventListener(); this.darkModeAddEventListener();
this.$store.dispatch("requestAppInfo");
const api_status = await api.meta.getIsDemo();
this.demo = api_status.demoStatus;
}, },
data: () => ({
search: false,
demo: false,
}),
methods: { methods: {
// For Later! // For Later!
@ -126,9 +82,6 @@ export default {
this.darkModeSystemCheck(); this.darkModeSystemCheck();
}); });
}, },
navigateFromSearch(slug) {
this.$router.push(`/recipe/${slug}`);
},
}, },
}; };
</script> </script>
@ -160,6 +113,7 @@ export default {
.notify-base { .notify-base {
color: white !important; color: white !important;
/* min-height: 50px; */
margin-right: 60px; margin-right: 60px;
margin-bottom: -5px; margin-bottom: -5px;
opacity: 0.9 !important; opacity: 0.9 !important;
@ -176,11 +130,4 @@ export default {
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
background: grey; background: grey;
} }
.notify-base {
color: white !important;
margin-right: 60px;
margin-bottom: -5px;
opacity: 0.9 !important;
}
</style> </style>

View File

@ -5,27 +5,27 @@ import { store } from "@/store";
const prefix = baseURL + "categories"; const prefix = baseURL + "categories";
const categoryURLs = { const categoryURLs = {
get_all: `${prefix}`, getAll: `${prefix}`,
get_category: category => `${prefix}/${category}`, getCategory: category => `${prefix}/${category}`,
delete_category: category => `${prefix}/${category}`, deleteCategory: category => `${prefix}/${category}`,
}; };
export const categoryAPI = { export const categoryAPI = {
async getAll() { async getAll() {
let response = await apiReq.get(categoryURLs.get_all); let response = await apiReq.get(categoryURLs.getAll);
return response.data; return response.data;
}, },
async create(name) { async create(name) {
let response = await apiReq.post(categoryURLs.get_all, { name: name }); let response = await apiReq.post(categoryURLs.getAll, { name: name });
store.dispatch("requestCategories"); store.dispatch("requestCategories");
return response.data; return response.data;
}, },
async getRecipesInCategory(category) { async getRecipesInCategory(category) {
let response = await apiReq.get(categoryURLs.get_category(category)); let response = await apiReq.get(categoryURLs.getCategory(category));
return response.data; return response.data;
}, },
async delete(category) { async delete(category) {
let response = await apiReq.delete(categoryURLs.delete_category(category)); let response = await apiReq.delete(categoryURLs.deleteCategory(category));
store.dispatch("requestCategories"); store.dispatch("requestCategories");
return response.data; return response.data;
}, },
@ -44,6 +44,11 @@ export const tagAPI = {
let response = await apiReq.get(tagURLs.getAll); let response = await apiReq.get(tagURLs.getAll);
return response.data; return response.data;
}, },
async create(name) {
let response = await apiReq.post(tagURLs.getAll, { name: name });
store.dispatch("requestTags");
return response.data;
},
async getRecipesInTag(tag) { async getRecipesInTag(tag) {
let response = await apiReq.get(tagURLs.getTag(tag)); let response = await apiReq.get(tagURLs.getTag(tag));
return response.data; return response.data;

View File

@ -5,15 +5,22 @@ const prefix = baseURL + "debug";
const debugURLs = { const debugURLs = {
version: `${prefix}/version`, version: `${prefix}/version`,
debug: `${prefix}`,
lastRecipe: `${prefix}/last-recipe-json`, lastRecipe: `${prefix}/last-recipe-json`,
demo: `${prefix}/is-demo`, demo: `${prefix}/is-demo`,
}; };
export const metaAPI = { export const metaAPI = {
async get_version() { async getAppInfo() {
let response = await apiReq.get(debugURLs.version); let response = await apiReq.get(debugURLs.version);
return response.data; return response.data;
}, },
async getDebugInfo() {
const response = await apiReq.get(debugURLs.debug);
return response.data;
},
async getLastJson() { async getLastJson() {
let response = await apiReq.get(debugURLs.lastRecipe); let response = await apiReq.get(debugURLs.lastRecipe);
return response.data; return response.data;
@ -21,7 +28,6 @@ export const metaAPI = {
async getIsDemo() { async getIsDemo() {
let response = await apiReq.get(debugURLs.demo); let response = await apiReq.get(debugURLs.demo);
console.log(response);
return response.data; return response.data;
}, },
}; };

View File

@ -8,6 +8,7 @@ const prefix = baseURL + "recipes/";
const recipeURLs = { const recipeURLs = {
allRecipes: baseURL + "recipes", allRecipes: baseURL + "recipes",
summary: baseURL + "recipes" + "/summary",
allRecipesByCategory: prefix + "category", allRecipesByCategory: prefix + "category",
create: prefix + "create", create: prefix + "create",
createByURL: prefix + "create-url", createByURL: prefix + "create-url",
@ -56,9 +57,7 @@ export const recipeAPI = {
const fd = new FormData(); const fd = new FormData();
fd.append("image", fileObject); fd.append("image", fileObject);
fd.append("extension", fileObject.name.split(".").pop()); fd.append("extension", fileObject.name.split(".").pop());
let response = apiReq.put(recipeURLs.updateImage(recipeSlug), fd); let response = apiReq.put(recipeURLs.updateImage(recipeSlug), fd);
return response; return response;
}, },
@ -87,4 +86,21 @@ export const recipeAPI = {
return response.data; return response.data;
}, },
async allSummary() {
const response = await apiReq.get(recipeURLs.summary);
return response.data;
},
recipeImage(recipeSlug) {
return `/api/recipes/${recipeSlug}/image?image_type=original`;
},
recipeSmallImage(recipeSlug) {
return `/api/recipes/${recipeSlug}/image?image_type=small`;
},
recipeTinyImage(recipeSlug) {
return `/api/recipes/${recipeSlug}/image?image_type=tiny`;
},
}; };

View File

@ -74,7 +74,7 @@
</v-list> </v-list>
<v-list nav dense class="fixedBottom"> <v-list nav dense class="fixedBottom">
<v-list-item href=""> <v-list-item to="/admin/about">
<v-list-item-icon class="mr-3 pt-1"> <v-list-item-icon class="mr-3 pt-1">
<v-icon :color="newVersionAvailable ? 'red--text' : ''"> <v-icon :color="newVersionAvailable ? 'red--text' : ''">
mdi-information mdi-information
@ -83,10 +83,11 @@
<v-list-item-content> <v-list-item-content>
<v-list-item-title> <v-list-item-title>
{{ $t("settings.current") }} {{ $t("settings.current") }}
{{ version }} {{ appVersion }}
</v-list-item-title> </v-list-item-title>
<v-list-item-subtitle> <v-list-item-subtitle>
<a <a
@click.prevent
href="https://github.com/hay-kot/mealie/releases/latest" href="https://github.com/hay-kot/mealie/releases/latest"
target="_blank" target="_blank"
:class="newVersionAvailable ? 'red--text' : 'green--text'" :class="newVersionAvailable ? 'red--text' : 'green--text'"
@ -106,14 +107,12 @@
import { validators } from "@/mixins/validators"; import { validators } from "@/mixins/validators";
import { initials } from "@/mixins/initials"; import { initials } from "@/mixins/initials";
import { user } from "@/mixins/user"; import { user } from "@/mixins/user";
import { api } from "@/api";
import axios from "axios"; import axios from "axios";
export default { export default {
mixins: [validators, initials, user], mixins: [validators, initials, user],
data() { data() {
return { return {
latestVersion: null, latestVersion: null,
version: null,
hideImage: false, hideImage: false,
showSidebar: false, showSidebar: false,
mobile: false, mobile: false,
@ -163,8 +162,6 @@ export default {
this.mobile = this.viewScale(); this.mobile = this.viewScale();
this.showSidebar = !this.viewScale(); this.showSidebar = !this.viewScale();
this.getVersion(); this.getVersion();
let versionData = await api.meta.get_version();
this.version = versionData.version;
}, },
computed: { computed: {
@ -172,7 +169,11 @@ export default {
return `api/users/${this.user.id}/image`; return `api/users/${this.user.id}/image`;
}, },
newVersionAvailable() { newVersionAvailable() {
return this.latestVersion == this.version ? false : true; return this.latestVersion == this.appVersion ? false : true;
},
appVersion() {
const appInfo = this.$store.getters.getAppInfo;
return appInfo.version;
}, },
}, },

View File

@ -37,7 +37,12 @@
<v-divider></v-divider> <v-divider></v-divider>
<v-card-actions> <v-card-actions>
<v-btn color="accent" text :href="`/api/backups/${name}/download`"> <v-btn
color="accent"
text
:loading="downloading"
@click="downloadFile(`/api/backups/${name}/download`)"
>
{{ $t("general.download") }} {{ $t("general.download") }}
</v-btn> </v-btn>
<v-spacer></v-spacer> <v-spacer></v-spacer>
@ -61,6 +66,7 @@
<script> <script>
import ImportOptions from "@/components/Admin/Backup/ImportOptions"; import ImportOptions from "@/components/Admin/Backup/ImportOptions";
import axios from "axios";
export default { export default {
components: { ImportOptions }, components: { ImportOptions },
props: { props: {
@ -83,6 +89,7 @@ export default {
dialog: false, dialog: false,
forceImport: false, forceImport: false,
rebaseImport: false, rebaseImport: false,
downloading: false,
}; };
}, },
methods: { methods: {
@ -109,6 +116,23 @@ export default {
this.close(); this.close();
this.$emit(event, eventData); this.$emit(event, eventData);
}, },
async downloadFile(downloadURL) {
this.downloading = true;
const response = await axios({
url: downloadURL,
method: "GET",
responseType: "blob", // important
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", `${this.name}.zip`);
document.body.appendChild(link);
link.click();
this.downloading = false;
},
}, },
}; };
</script> </script>

View File

@ -19,10 +19,11 @@
v-model="page.name" v-model="page.name"
label="Page Name" label="Page Name"
></v-text-field> ></v-text-field>
<CategorySelector <CategoryTagSelector
v-model="page.categories" v-model="page.categories"
ref="categoryFormSelector" ref="categoryFormSelector"
@mounted="catMounted = true" @mounted="catMounted = true"
tag-selector="false"
/> />
</v-card-text> </v-card-text>
@ -43,10 +44,10 @@
<script> <script>
const NEW_PAGE_EVENT = "refresh-page"; const NEW_PAGE_EVENT = "refresh-page";
import { api } from "@/api"; import { api } from "@/api";
import CategorySelector from "@/components/FormHelpers/CategorySelector"; import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
export default { export default {
components: { components: {
CategorySelector, CategoryTagSelector,
}, },
data() { data() {
return { return {

View File

@ -84,7 +84,7 @@
</v-toolbar-title> </v-toolbar-title>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<NewCategoryDialog /> <NewCategoryTagDialog :tag-dialog="false" />
</v-app-bar> </v-app-bar>
<v-list height="300" dense style="overflow:auto"> <v-list height="300" dense style="overflow:auto">
<v-list-item-group> <v-list-item-group>
@ -133,13 +133,13 @@
import { api } from "@/api"; import { api } from "@/api";
import LanguageMenu from "@/components/UI/LanguageMenu"; import LanguageMenu from "@/components/UI/LanguageMenu";
import draggable from "vuedraggable"; import draggable from "vuedraggable";
import NewCategoryDialog from "./NewCategoryDialog.vue"; import NewCategoryTagDialog from "@/components/UI/Dialogs/NewCategoryTagDialog.vue";
export default { export default {
components: { components: {
draggable, draggable,
LanguageMenu, LanguageMenu,
NewCategoryDialog, NewCategoryTagDialog,
}, },
data() { data() {
return { return {

View File

@ -273,7 +273,6 @@ export default {
await this.initialize(); await this.initialize();
}, },
resetPassword() { resetPassword() {
console.log(this.activeId);
api.users.resetPassword(this.editedItem.id); api.users.resetPassword(this.editedItem.id);
}, },
}, },

View File

@ -42,4 +42,7 @@ export default {
</script> </script>
<style scoped> <style scoped>
.v-text-field{
max-width: 300px;
}
</style> </style>

View File

@ -40,7 +40,13 @@
<v-btn color="error" text @click="deleteMigration(migration.name)"> <v-btn color="error" text @click="deleteMigration(migration.name)">
{{ $t("general.delete") }} {{ $t("general.delete") }}
</v-btn> </v-btn>
<v-btn color="accent" text @click="importMigration(migration.name)"> <v-btn
color="accent"
text
@click="importMigration(migration.name)"
:loading="loading"
:disabled="loading"
>
{{ $t("general.import") }} {{ $t("general.import") }}
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
@ -82,10 +88,10 @@ export default {
this.$emit("refresh"); this.$emit("refresh");
}, },
async importMigration(file_name) { async importMigration(file_name) {
this.loading == true; this.loading = true;
let response = await api.migrations.import(this.folder, file_name); let response = await api.migrations.import(this.folder, file_name);
this.$emit("imported", response.successful, response.failed); this.$emit("imported", response.successful, response.failed);
this.loading == false; this.loading = false;
}, },
readableTime(timestamp) { readableTime(timestamp) {
let date = new Date(timestamp); let date = new Date(timestamp);

View File

@ -1,50 +0,0 @@
<template>
<div>
<v-select
:items="allCategories"
v-model="selected"
label="Categories"
chips
deletable-chips
dense
item-text="name"
multiple
return-object
@input="emitChange"
></v-select>
</div>
</template>
<script>
const MOUNTED_EVENT = "mounted";
export default {
props: {
value: Array,
},
data() {
return {
selected: [],
};
},
mounted() {
this.$emit(MOUNTED_EVENT);
},
computed: {
allCategories() {
return this.$store.getters.getAllCategories;
},
},
methods: {
emitChange() {
this.$emit("input", this.selected);
},
setInit(val) {
this.selected = val;
},
},
};
</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

@ -22,13 +22,11 @@
<v-text-field <v-text-field
v-if="!options.isLoggingIn" v-if="!options.isLoggingIn"
v-model="user.name" v-model="user.name"
light="light"
prepend-icon="person" prepend-icon="person"
:label="$t('general.name')" :label="$t('general.name')"
></v-text-field> ></v-text-field>
<v-text-field <v-text-field
v-model="user.email" v-model="user.email"
light="light"
prepend-icon="mdi-email" prepend-icon="mdi-email"
validate-on-blur validate-on-blur
:label="$t('user.email')" :label="$t('user.email')"
@ -36,7 +34,6 @@
></v-text-field> ></v-text-field>
<v-text-field <v-text-field
v-model="user.password" v-model="user.password"
light="light"
class="mb-2s" class="mb-2s"
prepend-icon="mdi-lock" prepend-icon="mdi-lock"
:label="$t('user.password')" :label="$t('user.password')"
@ -47,7 +44,6 @@
<v-card-actions> <v-card-actions>
<v-btn <v-btn
v-if="options.isLoggingIn" v-if="options.isLoggingIn"
dark
color="primary" color="primary"
block="block" block="block"
type="submit" type="submit"

View File

@ -28,8 +28,8 @@
</template> </template>
<script> <script>
import utils from "@/utils";
import SearchDialog from "../UI/Search/SearchDialog"; import SearchDialog from "../UI/Search/SearchDialog";
import { api } from "@/api";
export default { export default {
components: { components: {
SearchDialog, SearchDialog,
@ -47,7 +47,7 @@ export default {
methods: { methods: {
getImage(slug) { getImage(slug) {
if (slug) { if (slug) {
return utils.getImageURL(slug); return api.recipes.recipeSmallImage(slug);
} }
}, },
setSlug(name, slug) { setSlug(name, slug) {

View File

@ -1,8 +1,12 @@
<template> <template>
<v-card> <v-card>
<v-card-title class="headline"> <v-card-title class=" headline">
{{ $t("meal-plan.create-a-new-meal-plan") }} {{ $t("meal-plan.create-a-new-meal-plan") }}
<v-btn color="info" class="ml-auto" @click="setQuickWeek()">
<v-icon left>mdi-calendar-minus</v-icon> Quick Week
</v-btn>
</v-card-title> </v-card-title>
<v-divider></v-divider> <v-divider></v-divider>
<v-card-text> <v-card-text>
<v-row dense> <v-row dense>
@ -101,6 +105,7 @@ export default {
endDate: null, endDate: null,
menu1: false, menu1: false,
menu2: false, menu2: false,
usedRecipes: [1],
}; };
}, },
@ -115,17 +120,14 @@ export default {
}); });
} }
}, },
groupSettings() {
this.buildMealStore();
},
}, },
async mounted() { async mounted() {
this.$store.dispatch("requestCurrentGroup"); await this.$store.dispatch("requestCurrentGroup");
await this.buildMealStore();
}, },
computed: { computed: {
groupSettings() { groupSettings() {
console.log(this.$store.getters.getCurrentGroup);
return this.$store.getters.getCurrentGroup; return this.$store.getters.getCurrentGroup;
}, },
actualStartDate() { actualStartDate() {
@ -152,11 +154,15 @@ export default {
endComputedDateFormatted() { endComputedDateFormatted() {
return this.formatDate(this.endDate); return this.formatDate(this.endDate);
}, },
filteredRecipes() {
const recipes = this.items.filter(x => !this.usedRecipes.includes(x));
return recipes.length > 0 ? recipes : this.items;
},
}, },
methods: { methods: {
async buildMealStore() { async buildMealStore() {
let categories = Array.from(this.groupSettings.categories, x => x.name); const categories = Array.from(this.groupSettings.categories, x => x.name);
this.items = await api.recipes.getAllByCategory(categories); this.items = await api.recipes.getAllByCategory(categories);
if (this.items.length === 0) { if (this.items.length === 0) {
@ -171,15 +177,20 @@ export default {
this.items = await api.recipes.allByKeys(keys); this.items = await api.recipes.allByKeys(keys);
} }
}, },
get_random(list) { getRandom(list) {
const object = list[Math.floor(Math.random() * list.length)]; let recipe = 1;
return object; while (this.usedRecipes.includes(recipe)) {
recipe = list[Math.floor(Math.random() * list.length)];
}
return recipe;
}, },
random() { random() {
this.usedRecipes = [1];
this.meals.forEach((element, index) => { this.meals.forEach((element, index) => {
let recipe = this.get_random(this.items); let recipe = this.getRandom(this.filteredRecipes);
this.meals[index]["slug"] = recipe.slug; this.meals[index]["slug"] = recipe.slug;
this.meals[index]["name"] = recipe.name; this.meals[index]["name"] = recipe.name;
this.usedRecipes.push(recipe);
}); });
}, },
processTime(index) { processTime(index) {
@ -212,7 +223,7 @@ export default {
}, },
getImage(image) { getImage(image) {
return utils.getImageURL(image); return api.recipes.recipeSmallImage(image);
}, },
formatDate(date) { formatDate(date) {
@ -227,6 +238,33 @@ export default {
const [month, day, year] = date.split("/"); const [month, day, year] = date.split("/");
return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`; return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
}, },
getNextDayOfTheWeek(dayName, excludeToday = true, refDate = new Date()) {
const dayOfWeek = [
"sun",
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
].indexOf(dayName.slice(0, 3).toLowerCase());
if (dayOfWeek < 0) return;
refDate.setHours(0, 0, 0, 0);
refDate.setDate(
refDate.getDate() +
+!!excludeToday +
((dayOfWeek + 7 - refDate.getDay() - +!!excludeToday) % 7)
);
return refDate;
},
setQuickWeek() {
const nextMonday = this.getNextDayOfTheWeek("Monday", false);
const nextEndDate = new Date(nextMonday);
nextEndDate.setDate(nextEndDate.getDate() + 4);
this.startDate = nextMonday.toISOString().substr(0, 10);
this.endDate = nextEndDate.toISOString().substr(0, 10);
},
}, },
}; };
</script> </script>

View File

@ -1,8 +1,13 @@
<template> <template>
<v-card hover :to="`/recipe/${slug}`" max-height="125"> <v-card
hover
:to="`/recipe/${slug}`"
max-height="125"
@click="$emit('selected')"
>
<v-list-item> <v-list-item>
<v-list-item-avatar rounded size="125" class="mt-0 ml-n4"> <v-list-item-avatar rounded size="125" class="mt-0 ml-n4">
<v-img :src="getImage(image)"> </v-img> <v-img :src="getImage(slug)"> </v-img>
</v-list-item-avatar> </v-list-item-avatar>
<v-list-item-content class="align-self-start"> <v-list-item-content class="align-self-start">
<v-list-item-title> <v-list-item-title>
@ -20,7 +25,7 @@
</template> </template>
<script> <script>
import utils from "@/utils"; import { api } from "@/api";
export default { export default {
props: { props: {
name: String, name: String,
@ -35,7 +40,7 @@ export default {
methods: { methods: {
getImage(image) { getImage(image) {
return utils.getImageURL(image); return api.recipes.recipeSmallImage(image);
}, },
}, },
}; };

View File

@ -5,8 +5,9 @@
:elevation="hover ? 12 : 2" :elevation="hover ? 12 : 2"
:to="route ? `/recipe/${slug}` : ''" :to="route ? `/recipe/${slug}` : ''"
@click="$emit('click')" @click="$emit('click')"
min-height="275"
> >
<v-img height="200" :src="getImage(image)"> <v-img height="200" :src="getImage(slug)">
<v-expand-transition v-if="description"> <v-expand-transition v-if="description">
<div <div
v-if="hover" v-if="hover"
@ -25,11 +26,9 @@
</div> </div>
</v-card-title> </v-card-title>
<v-card-actions class=""> <v-card-actions>
<v-row dense align="center">
<v-col>
<v-rating <v-rating
class="mr-2" class="mr-2 my-auto"
color="secondary" color="secondary"
background-color="secondary lighten-3" background-color="secondary lighten-3"
dense dense
@ -37,31 +36,43 @@
size="15" size="15"
:value="rating" :value="rating"
></v-rating> ></v-rating>
</v-col> <v-spacer></v-spacer>
<v-col></v-col> <RecipeChips
<v-col align="end"> </v-col> :items="tags"
</v-row> :title="false"
:limit="2"
:small="true"
:isCategory="false"
/>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-hover> </v-hover>
</template> </template>
<script> <script>
import utils from "@/utils"; import RecipeChips from "@/components/Recipe/RecipeViewer/RecipeChips";
import { api } from "@/api";
export default { export default {
components: {
RecipeChips,
},
props: { props: {
name: String, name: String,
slug: String, slug: String,
description: String, description: String,
rating: Number, rating: Number,
image: String, image: String,
route: { route: {
default: true, default: true,
}, },
tags: {
default: true,
},
}, },
methods: { methods: {
getImage(image) { getImage(image) {
return utils.getImageURL(image); return api.recipes.recipeSmallImage(image);
}, },
}, },
}; };

View File

@ -83,14 +83,16 @@
:key="generateKey('ingredient', index)" :key="generateKey('ingredient', index)"
> >
<v-row align="center"> <v-row align="center">
<v-text-field <v-textarea
class="mr-2" class="mr-2"
:label="$t('recipe.ingredient')" :label="$t('recipe.ingredient')"
v-model="value.recipeIngredient[index]" v-model="value.recipeIngredient[index]"
append-outer-icon="mdi-menu" append-outer-icon="mdi-menu"
mdi-move-resize mdi-move-resize
auto-grow
solo solo
dense dense
rows="2"
> >
<v-icon <v-icon
class="mr-n1" class="mr-n1"
@ -100,7 +102,7 @@
> >
mdi-delete mdi-delete
</v-icon> </v-icon>
</v-text-field> </v-textarea>
</v-row> </v-row>
</div> </div>
</transition-group> </transition-group>
@ -112,60 +114,21 @@
<BulkAdd @bulk-data="appendIngredients" /> <BulkAdd @bulk-data="appendIngredients" />
<h2 class="mt-6">{{ $t("recipe.categories") }}</h2> <h2 class="mt-6">{{ $t("recipe.categories") }}</h2>
<v-combobox <CategoryTagSelector
dense :return-object="false"
multiple
chips
item-color="secondary"
deletable-chips
v-model="value.recipeCategory" v-model="value.recipeCategory"
hide-selected :show-add="true"
:items="allCategories" :show-label="false"
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>
<h2 class="mt-4">{{ $t("recipe.tags") }}</h2> <h2 class="mt-4">{{ $t("recipe.tags") }}</h2>
<v-combobox <CategoryTagSelector
dense :return-object="false"
multiple
chips
deletable-chips
v-model="value.tags" v-model="value.tags"
hide-selected :show-add="true"
:items="allTags" :tag-selector="true"
:search-input.sync="tagsSearchInput" :show-label="false"
@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>
<h2 class="my-4">{{ $t("recipe.notes") }}</h2> <h2 class="my-4">{{ $t("recipe.notes") }}</h2>
<v-card <v-card
@ -235,6 +198,7 @@
dense dense
v-model="value.recipeInstructions[index]['text']" v-model="value.recipeInstructions[index]['text']"
:key="generateKey('instructions', index)" :key="generateKey('instructions', index)"
rows="4"
> >
</v-textarea> </v-textarea>
</v-card-text> </v-card-text>
@ -262,11 +226,13 @@ import { api } from "@/api";
import utils from "@/utils"; import utils from "@/utils";
import BulkAdd from "./BulkAdd"; import BulkAdd from "./BulkAdd";
import ExtrasEditor from "./ExtrasEditor"; import ExtrasEditor from "./ExtrasEditor";
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
export default { export default {
components: { components: {
BulkAdd, BulkAdd,
ExtrasEditor, ExtrasEditor,
draggable, draggable,
CategoryTagSelector,
}, },
props: { props: {
value: Object, value: Object,
@ -282,26 +248,14 @@ export default {
v.split(" ").length <= 1 || v.split(" ").length <= 1 ||
this.$i18n.t("recipe.no-white-space-allowed"), 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: { methods: {
uploadImage() { uploadImage() {
this.$emit("upload", this.fileObject); this.$emit("upload", this.fileObject);
}, },
async updateImage() { async updateImage() {
let slug = this.value.slug; const slug = this.value.slug;
api.recipes.updateImage(slug, this.fileObject); api.recipes.updateImage(slug, this.fileObject);
}, },
toggleDisabled(stepIndex) { toggleDisabled(stepIndex) {
@ -324,9 +278,6 @@ export default {
generateKey(item, index) { generateKey(item, index) {
return utils.generateUniqueKey(item, index); return utils.generateUniqueKey(item, index);
}, },
deleteRecipe() {
this.$emit("delete");
},
appendIngredients(ingredients) { appendIngredients(ingredients) {
this.value.recipeIngredient.push(...ingredients); this.value.recipeIngredient.push(...ingredients);

View File

@ -162,6 +162,7 @@
<script> <script>
import utils from "@/utils"; import utils from "@/utils";
import { api } from "@/api";
export default { export default {
props: { props: {
@ -175,7 +176,7 @@ export default {
methods: { methods: {
getImage(image) { getImage(image) {
if (image) { if (image) {
return utils.getImageURL(image) + "?rnd=" + this.imageKey; return api.recipes.recipeImage(image) + "?rnd=" + this.imageKey;
} }
}, },
generateKey(item, index) { generateKey(item, index) {

View File

@ -1,27 +1,52 @@
<template> <template>
<div> <div>
<h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2> <h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2>
<div <v-list-item
v-for="(ingredient, index) in ingredients" dense
v-for="(ingredient, index) in displayIngredients"
:key="generateKey('ingredient', index)" :key="generateKey('ingredient', index)"
@click="ingredient.checked = !ingredient.checked"
> >
<v-checkbox <v-checkbox
hide-details hide-details
class="ingredients" v-model="ingredient.checked"
:label="ingredient" class="pt-0 my-auto py-auto"
color="secondary" color="secondary"
> >
</v-checkbox> </v-checkbox>
</div>
<v-list-item-content>
<vue-markdown
class="ma-0 pa-0 text-subtitle-1 dense-markdown"
:source="ingredient.text"
>
</vue-markdown>
</v-list-item-content>
</v-list-item>
</div> </div>
</template> </template>
<script> <script>
import VueMarkdown from "@adapttive/vue-markdown";
import utils from "@/utils"; import utils from "@/utils";
export default { export default {
components: {
VueMarkdown,
},
props: { props: {
ingredients: Array, ingredients: Array,
}, },
data() {
return {
displayIngredients: [],
};
},
mounted() {
this.displayIngredients = this.ingredients.map(x => ({
text: x,
checked: false,
}));
},
methods: { methods: {
generateKey(item, index) { generateKey(item, index) {
return utils.generateUniqueKey(item, index); return utils.generateUniqueKey(item, index);
@ -30,5 +55,8 @@ export default {
}; };
</script> </script>
<style> <style >
.dense-markdown p {
margin: auto !important;
}
</style> </style>

View File

@ -1,13 +1,14 @@
<template> <template>
<div v-if="items && items.length > 0"> <div v-if="items.length > 0">
<h2 class="mt-4">{{ title }}</h2> <h2 v-if="title" class="mt-4">{{ title }}</h2>
<v-chip <v-chip
:to="`/recipes/${getSlug(category)}`"
label label
class="ma-1" class="ma-1"
color="accent" color="accent"
:small="small"
dark dark
v-for="category in items" v-for="category in items.slice(0, limit)"
:to="`/recipes/${urlParam}/${getSlug(category)}`"
:key="category" :key="category"
> >
{{ category }} {{ category }}
@ -18,21 +19,43 @@
<script> <script>
export default { export default {
props: { props: {
items: Array, items: {
title: String, default: [],
category: { },
title: {
default: null,
},
isCategory: {
default: true, default: true,
}, },
limit: {
default: 999,
},
small: {
default: false,
},
}, },
computed: { computed: {
allCategories() { allCategories() {
return this.$store.getters.getAllCategories; return this.$store.getters.getAllCategories;
}, },
allTags() {
return this.$store.getters.getAllTags;
},
urlParam() {
return this.isCategory ? "category" : "tag";
},
}, },
methods: { methods: {
getSlug(name) { getSlug(name) {
if (this.category) { if (!name) return;
return this.allCategories.filter(x => x.name == name)[0].slug;
if (this.isCategory) {
const matches = this.allCategories.filter(x => x.name == name);
if (matches.length > 0) return matches[0].slug;
} else {
const matches = this.allTags.filter(x => x.name == name);
if (matches.length > 0) return matches[0].slug;
} }
}, },
}, },

View File

@ -34,7 +34,11 @@
<Ingredients :ingredients="ingredients" /> <Ingredients :ingredients="ingredients" />
<div v-if="medium"> <div v-if="medium">
<RecipeChips :title="$t('recipe.categories')" :items="categories" /> <RecipeChips :title="$t('recipe.categories')" :items="categories" />
<RecipeChips :title="$t('recipe.tags')" :items="tags" /> <RecipeChips
:title="$t('recipe.tags')"
:items="tags"
:isCategory="false"
/>
<Notes :notes="notes" /> <Notes :notes="notes" />
</div> </div>
</v-col> </v-col>

View File

@ -15,9 +15,9 @@
<v-menu offset-y v-if="sortable"> <v-menu offset-y v-if="sortable">
<template v-slot:activator="{ on, attrs }"> <template v-slot:activator="{ on, attrs }">
<v-btn-toggle group> <v-btn-toggle group>
<v-btn text v-bind="attrs" v-on="on">{{ <v-btn text v-bind="attrs" v-on="on">
$t("general.sort") {{ $t("general.sort") }}
}}</v-btn> </v-btn>
</v-btn-toggle> </v-btn-toggle>
</template> </template>
<v-list> <v-list>
@ -53,6 +53,7 @@
:slug="recipe.slug" :slug="recipe.slug"
:rating="recipe.rating" :rating="recipe.rating"
:image="recipe.image" :image="recipe.image"
:tags="recipe.tags"
/> />
</v-col> </v-col>
</v-row> </v-row>

View File

@ -51,6 +51,11 @@ export default {
to: "/recipes/all", to: "/recipes/all",
title: this.$t("page.all-recipes"), title: this.$t("page.all-recipes"),
}, },
{
icon: "mdi-magnify",
to: "/search",
title: "search",
},
], ],
}; };
}, },

View File

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<v-btn icon @click="dialog = true"> <v-btn icon @click="dialog = true" class="mt-n1">
<v-icon color="white">mdi-plus</v-icon> <v-icon :color="color">mdi-plus</v-icon>
</v-btn> </v-btn>
<v-dialog v-model="dialog" width="500"> <v-dialog v-model="dialog" width="500">
<v-card> <v-card>
@ -11,7 +11,7 @@
</v-icon> </v-icon>
<v-toolbar-title class="headline"> <v-toolbar-title class="headline">
Create a Category {{ title }}
</v-toolbar-title> </v-toolbar-title>
<v-spacer></v-spacer> <v-spacer></v-spacer>
@ -21,8 +21,8 @@
<v-card-text> <v-card-text>
<v-text-field <v-text-field
dense dense
label="Category Name" :label="inputLabel"
v-model="categoryName" v-model="itemName"
:rules="[rules.required]" :rules="[rules.required]"
></v-text-field> ></v-text-field>
</v-card-text> </v-card-text>
@ -31,7 +31,7 @@
<v-btn color="grey" text @click="dialog = false"> <v-btn color="grey" text @click="dialog = false">
{{ $t("general.cancel") }} {{ $t("general.cancel") }}
</v-btn> </v-btn>
<v-btn color="success" text type="submit" :disabled="!categoryName"> <v-btn color="success" text type="submit" :disabled="!itemName">
{{ $t("general.create") }} {{ $t("general.create") }}
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
@ -43,31 +43,55 @@
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
const CREATED_ITEM_EVENT = "created-item";
export default { export default {
props: { props: {
buttonText: String, buttonText: String,
value: String, value: String,
color: {
default: null,
},
tagDialog: {
default: true,
},
}, },
data() { data() {
return { return {
dialog: false, dialog: false,
categoryName: "", itemName: "",
rules: { rules: {
required: val => required: val => !!val || "A Name is Required",
!!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" : "Category Name";
},
},
watch: { watch: {
dialog(val) { dialog(val) {
if (!val) this.categoryName = ""; if (!val) this.itemName = "";
}, },
}, },
methods: { methods: {
async select() { async select() {
await api.categories.create(this.categoryName); const newItem = await (async () => {
this.$emit("new-category", this.categoryName); 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; this.dialog = false;
}, },
}, },

View File

@ -17,17 +17,24 @@
</v-text-field> </v-text-field>
</template> </template>
<v-card v-if="showResults" max-height="500" :max-width="maxWidth"> <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-divider></v-divider>
<v-list scrollable> <v-list scrollable v-if="autoResults">
<v-list-item <v-list-item
v-for="(item, index) in autoResults" v-for="(item, index) in autoResults.slice(0, 15)"
:key="index" :key="index"
:to="navOnClick ? `/recipe/${item.item.slug}` : null" :to="navOnClick ? `/recipe/${item.item.slug}` : null"
@click="navOnClick ? null : selected(item.item.slug, item.item.name)" @click="navOnClick ? null : selected(item.item.slug, item.item.name)"
> >
<v-list-item-avatar> <v-list-item-avatar>
<v-img :src="getImage(item.item.image)"></v-img> <v-img :src="getImage(item.item.slug)"></v-img>
</v-list-item-avatar> </v-list-item-avatar>
<v-list-item-content <v-list-item-content
@click=" @click="
@ -54,7 +61,7 @@
<script> <script>
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import utils from "@/utils"; import { api } from "@/api";
export default { export default {
props: { props: {
@ -136,6 +143,7 @@ export default {
this.fuseResults = this.result; this.fuseResults = this.result;
} }
}, },
searchSlug() { searchSlug() {
this.selected(this.searchSlug); this.selected(this.searchSlug);
}, },
@ -151,10 +159,9 @@ export default {
); );
}, },
getImage(image) { getImage(image) {
return utils.getImageURL(image); return api.recipes.recipeTinyImage(image);
}, },
selected(slug, name) { selected(slug, name) {
console.log("Selected", slug, name);
this.$emit("selected", slug, name); this.$emit("selected", slug, name);
}, },
async onFocus() { async onFocus() {

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="text-center"> <div class="text-center ">
<v-dialog v-model="dialog" width="600px" height="0"> <v-dialog v-model="dialog" width="600px" height="0" :fullscreen="isMobile">
<v-card> <v-card>
<v-app-bar dark color="primary"> <v-app-bar dark color="primary">
<v-toolbar-title class="headline">Search a Recipe</v-toolbar-title> <v-toolbar-title class="headline">Search a Recipe</v-toolbar-title>
@ -9,13 +9,27 @@
<SearchBar <SearchBar
@results="updateResults" @results="updateResults"
@selected="emitSelect" @selected="emitSelect"
:show-results="true" :show-results="!isMobile"
max-width="550px" max-width="550px"
:dense="false" :dense="false"
:nav-on-click="false" :nav-on-click="false"
:reset-search="dialog" :reset-search="dialog"
:solo="false" :solo="false"
/> />
<div v-if="isMobile">
<div v-for="recipe in searchResults.slice(0, 7)" :key="recipe.name">
<MobileRecipeCard
class="ma-1 px-0"
:name="recipe.item.name"
:description="recipe.item.description"
:slug="recipe.item.slug"
:rating="recipe.item.rating"
:image="recipe.item.image"
:route="true"
@selected="dialog = false"
/>
</div>
</div>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-dialog> </v-dialog>
@ -24,16 +38,32 @@
<script> <script>
import SearchBar from "./SearchBar"; import SearchBar from "./SearchBar";
import MobileRecipeCard from "@/components/Recipe/MobileRecipeCard";
export default { export default {
components: { components: {
SearchBar, SearchBar,
MobileRecipeCard,
}, },
data() { data() {
return { return {
searchResults: null, searchResults: [],
dialog: false, dialog: false,
}; };
}, },
computed: {
isMobile() {
return this.$vuetify.breakpoint.name === "xs";
},
},
watch: {
"$route.hash"(newHash, oldHash) {
if (newHash === "#mobile-search") {
this.dialog = true;
} else if (oldHash === "#mobile-search") {
this.dialog = false;
}
},
},
methods: { methods: {
updateResults(results) { updateResults(results) {
this.searchResults = results; this.searchResults = results;
@ -44,15 +74,22 @@ export default {
}, },
open() { open() {
this.dialog = true; this.dialog = true;
this.$router.push("#mobile-search");
},
toggleDialog(open) {
if (open) {
this.$router.push("#mobile-search");
} else {
this.$router.back(); // 😎 back button click
}
}, },
}, },
}; };
</script> </script>
<style> <style scope>
.v-dialog__content { .mobile-dialog {
margin-top: 10%;
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: flex-start;
} }
</style> </style>

View File

@ -0,0 +1,114 @@
<template>
<div>
<v-app-bar
v-if="!isMobile"
clipped-left
dense
app
color="primary"
dark
class="d-print-none"
>
<router-link v-if="!(isMobile && search)" to="/">
<v-btn icon>
<v-icon size="40"> mdi-silverware-variant </v-icon>
</v-btn>
</router-link>
<div v-if="!isMobile" btn class="pl-2">
<v-toolbar-title style="cursor: pointer" @click="$router.push('/')"
>Mealie
</v-toolbar-title>
</div>
<v-spacer></v-spacer>
<v-expand-x-transition>
<SearchBar
ref="mainSearchBar"
v-if="search"
:show-results="true"
@selected="navigateFromSearch"
:max-width="isMobile ? '100%' : '450px'"
/>
</v-expand-x-transition>
<v-btn icon @click="search = !search">
<v-icon>mdi-magnify</v-icon>
</v-btn>
<SiteMenu />
</v-app-bar>
<v-app-bar
v-else
bottom
clipped-left
dense
app
color="primary"
dark
class="d-print-none"
>
<router-link to="/">
<v-btn icon>
<v-icon size="40"> mdi-silverware-variant </v-icon>
</v-btn>
</router-link>
<div v-if="!isMobile" btn class="pl-2">
<v-toolbar-title style="cursor: pointer" @click="$router.push('/')"
>Mealie
</v-toolbar-title>
</div>
<v-spacer></v-spacer>
<v-expand-x-transition>
<SearchDialog ref="mainSearchDialog" />
</v-expand-x-transition>
<v-btn icon @click="$refs.mainSearchDialog.open()">
<v-icon>mdi-magnify</v-icon>
</v-btn>
<SiteMenu />
</v-app-bar>
</div>
</template>
<script>
import SiteMenu from "@/components/UI/SiteMenu";
import SearchBar from "@/components/UI/Search/SearchBar";
import SearchDialog from "@/components/UI/Search/SearchDialog";
import { user } from "@/mixins/user";
export default {
name: "AppBar",
mixins: [user],
components: {
SiteMenu,
SearchBar,
SearchDialog,
},
data() {
return {
search: false,
isMobile: false,
};
},
watch: {
$route() {
this.search = false;
},
},
computed: {
// isMobile() {
// return this.$vuetify.breakpoint.name === "xs";
// },
},
methods: {
navigateFromSearch(slug) {
this.$router.push(`/recipe/${slug}`);
},
},
};
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,7 @@
export const utilMixins = {
commputed: {
isMobile() {
return this.$vuetify.breakpoint.name === "xs";
},
},
};

View File

@ -3,7 +3,7 @@ export const validators = {
return { return {
emailRule: v => emailRule: v =>
!v || !v ||
/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(v) || /^[^@\s]+@[^@\s.]+.[^@.\s]+$/.test(v) ||
this.$t('user.e-mail-must-be-valid'), this.$t('user.e-mail-must-be-valid'),
existsRule: value => !!value || this.$t('general.field-required'), existsRule: value => !!value || this.$t('general.field-required'),

View File

@ -0,0 +1,91 @@
<template>
<div>
<v-card class="mt-3">
<v-card-title class="headline">
About Mealie
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-list-item-group color="primary">
<v-list-item v-for="property in prettyInfo" :key="property.name">
<v-list-item-icon>
<v-icon> {{ property.icon || "mdi-account" }} </v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title class="pl-4 flex row justify-space-between">
<div>{{ property.name }}</div>
<div>{{ property.value }}</div>
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-card-text>
<v-divider></v-divider>
</v-card>
</div>
</template>
<script>
import { api } from "@/api";
export default {
data() {
return {
prettyInfo: [],
};
},
async mounted() {
await this.getInfo();
},
methods: {
async getInfo() {
const debugInfo = await api.meta.getDebugInfo();
this.prettyInfo = [
{
name: "Version",
icon: "mdi-information",
value: debugInfo.version,
},
{
name: "Application Mode",
icon: "mdi-dev-to",
value: debugInfo.production ? "Production" : "Development",
},
{
name: "Demo Status",
icon: "mdi-test-tube",
value: debugInfo.demoStatus ? "Demo" : "Not Demo",
},
{
name: "API Port",
icon: "mdi-api",
value: debugInfo.apiPort,
},
{
name: "API Docs",
icon: "mdi-file-document",
value: debugInfo.apiDocs ? "Enabled" : "Disabled",
},
{
name: "Database Type",
icon: "mdi-database",
value: debugInfo.dbType,
},
{
name: "SQLite File",
icon: "mdi-file-cabinet",
value: debugInfo.sqliteFile,
},
{
name: "Default Group",
icon: "mdi-account-group",
value: debugInfo.defaultGroup,
},
];
},
},
};
</script>
<style lang="scss" scoped>
</style>

View File

@ -7,41 +7,19 @@
<v-card-text> <v-card-text>
<h2 class="mt-1">{{ $t("recipe.categories") }}</h2> <h2 class="mt-1">{{ $t("recipe.categories") }}</h2>
<v-row> <CategoryTagSelector
<v-col sm="12" md="6"> class="mt-4"
<v-select :solo="true"
outlined :dense="false"
:flat="isFlat"
elavation="0"
v-model="groupSettings.categories" v-model="groupSettings.categories"
:items="categories" :return-object="true"
item-text="name" :show-add="true"
return-object
multiple
chips
:hint=" :hint="
$t( $t(
'meal-plan.only-recipes-with-these-categories-will-be-used-in-meal-plans' '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>
</v-card-text> </v-card-text>
<v-divider> </v-divider> <v-divider> </v-divider>
<v-card-text> <v-card-text>
@ -57,28 +35,23 @@
<strong>{{ groupSettings.webhookTime }}</strong> <strong>{{ groupSettings.webhookTime }}</strong>
</p> </p>
<v-row dense align="center"> <v-row dense class="flex align-center">
<v-col cols="12" md="2" sm="5">
<v-switch <v-switch
class="mx-2"
v-model="groupSettings.webhookEnable" v-model="groupSettings.webhookEnable"
:label="$t('general.enabled')" :label="$t('general.enabled')"
></v-switch> ></v-switch>
</v-col> <TimePickerDialog @save-time="saveTime" class="ma-2" />
<v-col cols="12" md="3" sm="5"> <v-btn class="ma-2" color="info" @click="testWebhooks">
<TimePickerDialog @save-time="saveTime" />
</v-col>
<v-col cols="12" md="4" sm="5">
<v-btn text color="info" @click="testWebhooks">
<v-icon left> mdi-webhook </v-icon> <v-icon left> mdi-webhook </v-icon>
{{ $t("settings.webhooks.test-webhooks") }} {{ $t("settings.webhooks.test-webhooks") }}
</v-btn> </v-btn>
</v-col>
</v-row> </v-row>
<v-row <v-row
v-for="(url, index) in groupSettings.webhookUrls" v-for="(url, index) in groupSettings.webhookUrls"
:key="index" :key="index"
align="center" align=" center"
dense dense
> >
<v-col cols="1"> <v-col cols="1">
@ -110,9 +83,11 @@
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
import TimePickerDialog from "@/components/Admin/MealPlanner/TimePickerDialog"; import TimePickerDialog from "@/components/Admin/MealPlanner/TimePickerDialog";
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
export default { export default {
components: { components: {
TimePickerDialog, TimePickerDialog,
CategoryTagSelector,
}, },
data() { data() {
return { return {
@ -160,6 +135,7 @@ export default {
this.groupSettings.webhookUrls.splice(index, 1); this.groupSettings.webhookUrls.splice(index, 1);
}, },
async saveGroupSettings() { async saveGroupSettings() {
console.log(this.groupSettings);
await api.groups.update(this.groupSettings); await api.groups.update(this.groupSettings);
await this.$store.dispatch("requestCurrentGroup"); await this.$store.dispatch("requestCurrentGroup");
this.getSiteSettings(); this.getSiteSettings();
@ -167,9 +143,6 @@ export default {
testWebhooks() { testWebhooks() {
api.settings.testWebhooks(); api.settings.testWebhooks();
}, },
removeCategory(index) {
this.groupSettings.categories.splice(index, 1);
},
}, },
}; };
</script> </script>

View File

@ -36,7 +36,6 @@ export default {
}, },
computed: { computed: {
siteSettings() { siteSettings() {
console.log(this.$store.getters.getSiteSettings);
return this.$store.getters.getSiteSettings; return this.$store.getters.getSiteSettings;
}, },
recentRecipes() { recentRecipes() {
@ -54,7 +53,6 @@ export default {
this.siteSettings.categories.forEach(async element => { this.siteSettings.categories.forEach(async element => {
let recipes = await this.getRecipeByCategory(element.slug); let recipes = await this.getRecipeByCategory(element.slug);
if (recipes.recipes.length < 0) recipes.recipes = []; if (recipes.recipes.length < 0) recipes.recipes = [];
console.log(recipes);
this.recipeByCategory.push(recipes); this.recipeByCategory.push(recipes);
}); });
}, },

View File

@ -117,7 +117,7 @@ export default {
return utils.getDateAsTextAlt(dateObject); return utils.getDateAsTextAlt(dateObject);
}, },
getImage(image) { getImage(image) {
return utils.getImageURL(image); return api.recipes.recipeTinyImage(image);
}, },
editPlan(id) { editPlan(id) {

View File

@ -52,7 +52,6 @@
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
import utils from "@/utils";
export default { export default {
data() { data() {
return { return {
@ -68,7 +67,7 @@ export default {
else return 0; else return 0;
}, },
getImage(image) { getImage(image) {
return utils.getImageURL(image); return api.recipes.recipeImage(image);
}, },
}, },
}; };

View File

@ -14,7 +14,7 @@
<v-card v-else id="myRecipe"> <v-card v-else id="myRecipe">
<v-img <v-img
height="400" height="400"
:src="getImage(recipeDetails.image)" :src="getImage(recipeDetails.slug)"
class="d-print-none" class="d-print-none"
:key="imageKey" :key="imageKey"
> >
@ -71,7 +71,6 @@
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
import utils from "@/utils";
import VJsoneditor from "v-jsoneditor"; import VJsoneditor from "v-jsoneditor";
import RecipeViewer from "@/components/Recipe/RecipeViewer"; import RecipeViewer from "@/components/Recipe/RecipeViewer";
import RecipeEditor from "@/components/Recipe/RecipeEditor"; import RecipeEditor from "@/components/Recipe/RecipeEditor";
@ -160,7 +159,7 @@ export default {
}, },
getImage(image) { getImage(image) {
if (image) { if (image) {
return utils.getImageURL(image) + "?rnd=" + this.imageKey; return api.recipes.recipeImage(image) + "&rnd=" + this.imageKey;
} }
}, },
deleteRecipe() { deleteRecipe() {

View File

@ -0,0 +1,60 @@
<template>
<v-container>
<CategorySidebar />
<CardSection
:sortable="true"
:title="title"
:recipes="recipes"
:card-limit="9999"
@sort="sortAZ"
@sort-recent="sortRecent"
/>
</v-container>
</template>
<script>
import { api } from "@/api";
import CardSection from "@/components/UI/CardSection";
import CategorySidebar from "@/components/UI/CategorySidebar";
export default {
components: {
CardSection,
CategorySidebar,
},
data() {
return {
title: "",
recipes: [],
};
},
computed: {
currentTag() {
return this.$route.params.tag;
},
},
watch: {
async currentTag() {
this.getRecipes();
},
},
mounted() {
this.getRecipes();
},
methods: {
async getRecipes() {
let data = await api.tags.getRecipesInTag(this.currentTag);
this.title = data.name;
this.recipes = data.recipes;
},
sortAZ() {
this.recipes.sort((a, b) => (a.name > b.name ? 1 : -1));
},
sortRecent() {
this.recipes.sort((a, b) => (a.dateAdded > b.dateAdded ? -1 : 1));
},
},
};
</script>
<style>
</style>

View File

@ -1,58 +0,0 @@
<template>
<v-container>
<v-row justify="center">
<v-col cols="1"> </v-col>
<v-col>
<SearchBar @results="updateResults" :show-results="false" />
</v-col>
<v-col cols="2">
<v-btn icon>
<v-icon large> mdi-filter </v-icon>
</v-btn>
</v-col>
</v-row>
<v-row v-if="searchResults">
<v-col
:sm="6"
:md="6"
:lg="4"
:xl="3"
v-for="item in searchResults.slice(0, 10)"
:key="item.item.name"
>
<RecipeCard
:name="item.item.name"
:description="item.item.description"
:slug="item.item.slug"
:rating="item.item.rating"
:image="item.item.image"
/>
</v-col>
</v-row>
</v-container>
</template>
<script>
import SearchBar from "../components/UI/Search/SearchBar";
import RecipeCard from "../components/Recipe/RecipeCard";
export default {
components: {
SearchBar,
RecipeCard,
},
data() {
return {
searchResults: [],
};
},
methods: {
updateResults(results) {
this.searchResults = results;
},
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,69 @@
<template>
<v-toolbar dense flat>
<v-btn-toggle
dense
v-model="selected"
tile
color="primary accent-3"
@change="emitMulti"
group
mandatory
>
<v-btn :value="false">
Include
</v-btn>
<v-btn :value="true">
Exclude
</v-btn>
</v-btn-toggle>
<v-spacer></v-spacer>
<v-btn-toggle
dense
v-model="match"
tile
color="primary accent-3"
@change="emitMulti"
group
mandatory
>
<v-btn :value="false">
And
</v-btn>
<v-btn :value="true">
Or
</v-btn>
</v-btn-toggle>
</v-toolbar>
</template>
<script>
export default {
props: {
value: {
default: "include", // Optionas: "include", "exclude", "any"
},
},
data() {
return {
selected: false,
match: false,
};
},
methods: {
emitChange() {
this.$emit("input", this.selected);
},
emitMulti() {
const updateData = {
exclude: this.selected,
matchAny: this.match,
};
this.$emit("update", updateData);
},
},
};
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,178 @@
<template>
<v-container>
<CategorySidebar />
<v-card flat>
<v-row dense>
<v-col>
<v-text-field
v-model="searchString"
outlined
color="primary accent-3"
placeholder="Placeholder"
append-icon="mdi-magnify"
>
</v-text-field>
</v-col>
<v-col cols="12" md="2" sm="12">
<v-text-field
class="mt-0 pt-0"
label="Max Results"
v-model="maxResults"
type="number"
outlined
/>
</v-col>
</v-row>
<v-row dense class="mt-0 flex-row align-center justify-space-around">
<v-col>
<h3 class="pl-2 text-center headline">Category Filter</h3>
<FilterSelector class="mb-1" @update="updateCatParams" />
<CategoryTagSelector
:solo="true"
:dense="false"
v-model="includeCategories"
:return-object="false"
/>
</v-col>
<v-col>
<h3 class="pl-2 text-center headline">Tag Filter</h3>
<FilterSelector class="mb-1" @update="updateTagParams" />
<CategoryTagSelector
:solo="true"
:dense="false"
v-model="includeTags"
:return-object="false"
:tag-selector="true"
/>
</v-col>
</v-row>
<v-row v-if="fuzzyRecipes">
<v-col
:sm="6"
:md="6"
:lg="4"
:xl="3"
v-for="item in fuzzyRecipes.slice(0, maxResults)"
:key="item.name"
>
<RecipeCard
:name="item.item.name"
:description="item.item.description"
:slug="item.item.slug"
:rating="item.item.rating"
:image="item.item.image"
:tags="item.item.tags"
/>
</v-col>
</v-row>
</v-card>
</v-container>
</template>
<script>
import Fuse from "fuse.js";
import RecipeCard from "@/components/Recipe/RecipeCard";
import CategorySidebar from "@/components/UI/CategorySidebar";
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
import FilterSelector from "./FilterSelector.vue";
export default {
components: {
RecipeCard,
CategorySidebar,
CategoryTagSelector,
FilterSelector,
},
data() {
return {
searchString: "",
maxResults: 21,
searchResults: [],
catFilter: {
exclude: false,
matchAny: false,
},
tagFilter: {
exclude: false,
matchAny: false,
},
includeCategories: [],
includeTags: [],
options: {
shouldSort: true,
threshold: 0.6,
location: 0,
distance: 100,
findAllMatches: true,
maxPatternLength: 32,
minMatchCharLength: 2,
keys: ["name", "description"],
},
};
},
computed: {
allRecipes() {
return this.$store.getters.getRecentRecipes;
},
filteredRecipes() {
return this.allRecipes.filter(recipe => {
const includesTags = this.check(
this.includeTags,
recipe.tags,
this.tagFilter.matchAny,
this.tagFilter.exclude
);
const includesCats = this.check(
this.includeCategories,
recipe.recipeCategory,
this.catFilter.matchAny,
this.catFilter.exclude
);
return [includesTags, includesCats].every(x => x === true);
});
},
fuse() {
return new Fuse(this.filteredRecipes, this.options);
},
fuzzyRecipes() {
if (this.searchString.trim() === "") {
return this.filteredRecipes.map(x => ({ item: x }));
}
const result = this.fuse.search(this.searchString.trim());
return result;
},
isSearching() {
return this.searchString && this.searchString.length > 0;
},
},
methods: {
check(filterBy, recipeList, matchAny, exclude) {
let isMatch = true;
if (filterBy.length === 0) return isMatch;
if (recipeList) {
if (matchAny) {
isMatch = filterBy.some(t => recipeList.includes(t)); // Checks if some items are a match
} else {
isMatch = filterBy.every(t => recipeList.includes(t)); // Checks if every items is a match
}
return exclude ? !isMatch : isMatch;
} else;
return false;
},
updateTagParams(params) {
this.tagFilter = params;
},
updateCatParams(params) {
this.catFilter = params;
},
},
};
</script>
<style>
</style>

View File

@ -6,6 +6,7 @@ import Migration from "@/pages/Admin/Migration";
import Profile from "@/pages/Admin/Profile"; import Profile from "@/pages/Admin/Profile";
import ManageUsers from "@/pages/Admin/ManageUsers"; import ManageUsers from "@/pages/Admin/ManageUsers";
import Settings from "@/pages/Admin/Settings"; import Settings from "@/pages/Admin/Settings";
import About from "@/pages/Admin/About";
import { store } from "../store"; import { store } from "../store";
export default { export default {
@ -50,5 +51,9 @@ export default {
path: "settings", path: "settings",
component: Settings, component: Settings,
}, },
{
path: "about",
component: About,
},
], ],
}; };

View File

@ -6,6 +6,7 @@ import NewRecipe from "@/pages/Recipe/NewRecipe";
import CustomPage from "@/pages/Recipes/CustomPage"; import CustomPage from "@/pages/Recipes/CustomPage";
import AllRecipes from "@/pages/Recipes/AllRecipes"; import AllRecipes from "@/pages/Recipes/AllRecipes";
import CategoryPage from "@/pages/Recipes/CategoryPage"; import CategoryPage from "@/pages/Recipes/CategoryPage";
import TagPage from "@/pages/Recipes/TagPage";
import Planner from "@/pages/MealPlan/Planner"; import Planner from "@/pages/MealPlan/Planner";
import Debug from "@/pages/Debug"; import Debug from "@/pages/Debug";
import LoginPage from "@/pages/LoginPage"; import LoginPage from "@/pages/LoginPage";
@ -33,7 +34,8 @@ export const routes = [
{ path: "/search", component: SearchPage }, { path: "/search", component: SearchPage },
{ path: "/recipes/all", component: AllRecipes }, { path: "/recipes/all", component: AllRecipes },
{ path: "/pages/:customPage", component: CustomPage }, { path: "/pages/:customPage", component: CustomPage },
{ path: "/recipes/:category", component: CategoryPage }, { path: "/recipes/tag/:tag", component: TagPage },
{ path: "/recipes/category/:category", component: CategoryPage },
{ path: "/recipe/:recipe", component: ViewRecipe }, { path: "/recipe/:recipe", component: ViewRecipe },
{ path: "/new/", component: NewRecipe }, { path: "/new/", component: NewRecipe },
{ path: "/meal-plan/planner", component: Planner }, { path: "/meal-plan/planner", component: Planner },

View File

@ -28,6 +28,10 @@ const store = new Vuex.Store({
mealPlanCategories: [], mealPlanCategories: [],
allCategories: [], allCategories: [],
allTags: [], allTags: [],
appInfo: {
version: "",
demoStatus: false,
},
}, },
mutations: { mutations: {
@ -43,19 +47,22 @@ const store = new Vuex.Store({
setAllTags(state, payload) { setAllTags(state, payload) {
state.allTags = payload; state.allTags = payload;
}, },
setAppInfo(state, payload) {
state.appInfo = payload;
},
}, },
actions: { actions: {
async requestRecentRecipes() { async requestRecentRecipes() {
const keys = [ // const keys = [
"name", // "name",
"slug", // "slug",
"image", // "image",
"description", // "description",
"dateAdded", // "dateAdded",
"rating", // "rating",
]; // ];
const payload = await api.recipes.allByKeys(keys); const payload = await api.recipes.allSummary();
this.commit("setRecentRecipes", payload); this.commit("setRecentRecipes", payload);
}, },
@ -67,6 +74,11 @@ const store = new Vuex.Store({
const tags = await api.tags.getAll(); const tags = await api.tags.getAll();
commit("setAllTags", tags); commit("setAllTags", tags);
}, },
async requestAppInfo({ commit }) {
const response = await api.meta.getAppInfo();
commit("setAppInfo", response);
},
}, },
getters: { getters: {
@ -76,6 +88,7 @@ const store = new Vuex.Store({
state.allCategories.sort((a, b) => (a.slug > b.slug ? 1 : -1)), state.allCategories.sort((a, b) => (a.slug > b.slug ? 1 : -1)),
getAllTags: state => getAllTags: state =>
state.allTags.sort((a, b) => (a.slug > b.slug ? 1 : -1)), state.allTags.sort((a, b) => (a.slug > b.slug ? 1 : -1)),
getAppInfo: state => state.appInfo,
}, },
}); });

View File

@ -70,7 +70,7 @@ const actions = {
async refreshToken({ commit, getters }) { async refreshToken({ commit, getters }) {
if (!getters.getIsLoggedIn) { if (!getters.getIsLoggedIn) {
commit("setIsLoggedIn", false); // This is to be here... for some reasons? ¯\_(ツ)_/¯ commit("setIsLoggedIn", false); // This has to be here... for some reasons? ¯\_(ツ)_/¯
console.log("Not Logged In"); console.log("Not Logged In");
return; return;
} }

View File

@ -50,7 +50,7 @@ const monthsShort = [
export default { export default {
getImageURL(image) { getImageURL(image) {
return `/api/recipes/${image}/image`; return `/api/recipes/${image}/image?image_type=small`;
}, },
generateUniqueKey(item, index) { generateUniqueKey(item, index) {
const uniqueKey = `${item}-${index}`; const uniqueKey = `${item}-${index}`;

View File

@ -54,6 +54,7 @@ setup: ## Setup Development Instance
backend: ## Start Mealie Backend Development Server backend: ## Start Mealie Backend Development Server
poetry run python mealie/db/init_db.py && \ poetry run python mealie/db/init_db.py && \
poetry run python mealie/services/image/minify.py && \
poetry run python mealie/app.py poetry run python mealie/app.py

View File

@ -1,18 +1,18 @@
import os import os
import secrets import secrets
from pathlib import Path from pathlib import Path
from typing import Optional, Union
import dotenv from pydantic import BaseSettings, Field, validator
APP_VERSION = "v0.4.0" APP_VERSION = "v0.4.1"
DB_VERSION = "v0.4.0" DB_VERSION = "v0.4.0"
CWD = Path(__file__).parent CWD = Path(__file__).parent
BASE_DIR = CWD.parent.parent BASE_DIR = CWD.parent.parent
ENV = BASE_DIR.joinpath(".env") ENV = BASE_DIR.joinpath(".env")
dotenv.load_dotenv(ENV) PRODUCTION = os.getenv("ENV", "False").lower() in ["true", "1"]
PRODUCTION = os.environ.get("ENV")
def determine_data_dir(production: bool) -> Path: def determine_data_dir(production: bool) -> Path:
@ -38,21 +38,26 @@ def determine_secrets(data_dir: Path, production: bool) -> str:
return new_secret return new_secret
# General
DATA_DIR = determine_data_dir(PRODUCTION)
LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
class AppDirectories: class AppDirectories:
def __init__(self, cwd, data_dir) -> None: def __init__(self, cwd, data_dir) -> None:
self.DATA_DIR = data_dir self.DATA_DIR: Path = data_dir
self.WEB_PATH = cwd.joinpath("dist") self.WEB_PATH: Path = cwd.joinpath("dist")
self.IMG_DIR = data_dir.joinpath("img") self.IMG_DIR: Path = data_dir.joinpath("img")
self.BACKUP_DIR = data_dir.joinpath("backups") self.BACKUP_DIR: Path = data_dir.joinpath("backups")
self.DEBUG_DIR = data_dir.joinpath("debug") self.DEBUG_DIR: Path = data_dir.joinpath("debug")
self.MIGRATION_DIR = data_dir.joinpath("migration") self.MIGRATION_DIR: Path = data_dir.joinpath("migration")
self.NEXTCLOUD_DIR = self.MIGRATION_DIR.joinpath("nextcloud") self.NEXTCLOUD_DIR: Path = self.MIGRATION_DIR.joinpath("nextcloud")
self.CHOWDOWN_DIR = self.MIGRATION_DIR.joinpath("chowdown") self.CHOWDOWN_DIR: Path = self.MIGRATION_DIR.joinpath("chowdown")
self.TEMPLATE_DIR = data_dir.joinpath("templates") self.TEMPLATE_DIR: Path = data_dir.joinpath("templates")
self.USER_DIR = data_dir.joinpath("users") self.USER_DIR: Path = data_dir.joinpath("users")
self.SQLITE_DIR = data_dir.joinpath("db") self.SQLITE_DIR: Path = data_dir.joinpath("db")
self.RECIPE_DATA_DIR = data_dir.joinpath("recipes") self.RECIPE_DATA_DIR: Path = data_dir.joinpath("recipes")
self.TEMP_DIR = data_dir.joinpath(".temp") self.TEMP_DIR: Path = data_dir.joinpath(".temp")
self.ensure_directories() self.ensure_directories()
@ -74,36 +79,51 @@ class AppDirectories:
dir.mkdir(parents=True, exist_ok=True) dir.mkdir(parents=True, exist_ok=True)
class AppSettings: app_dirs = AppDirectories(CWD, DATA_DIR)
def __init__(self, app_dirs: AppDirectories) -> None:
global DB_VERSION
self.PRODUCTION = bool(os.environ.get("ENV")) class AppSettings(BaseSettings):
self.IS_DEMO = os.getenv("DEMO", "False") == "True" global DATA_DIR
self.API_PORT = int(os.getenv("API_PORT", 9000)) PRODUCTION: bool = Field(False, env="ENV")
self.API = os.getenv("API_DOCS", "True") == "True" IS_DEMO: bool = False
self.DOCS_URL = "/docs" if self.API else None API_PORT: int = 9000
self.REDOC_URL = "/redoc" if self.API else None API_DOCS: bool = True
self.SECRET = determine_secrets(app_dirs.DATA_DIR, self.PRODUCTION)
self.DATABASE_TYPE = os.getenv("DB_TYPE", "sqlite") @property
def DOCS_URL(self) -> str:
return "/docs" if self.API_DOCS else None
@property
def REDOC_URL(self) -> str:
return "/redoc" if self.API_DOCS else None
SECRET: str = determine_secrets(DATA_DIR, PRODUCTION)
DATABASE_TYPE: str = Field("sqlite", env="DB_TYPE")
@validator("DATABASE_TYPE", pre=True)
def validate_db_type(cls, v: str) -> Optional[str]:
if v != "sqlite":
raise ValueError("Unable to determine database type. Acceptible options are 'sqlite'")
else:
return v
# Used to Set SQLite File Version # Used to Set SQLite File Version
self.SQLITE_FILE = None SQLITE_FILE: Optional[Union[str, Path]]
if self.DATABASE_TYPE == "sqlite":
self.SQLITE_FILE = app_dirs.SQLITE_DIR.joinpath(f"mealie_{DB_VERSION}.sqlite")
else:
raise Exception("Unable to determine database type. Acceptible options are 'sqlite'")
self.DEFAULT_GROUP = os.getenv("DEFAULT_GROUP", "Home") @validator("SQLITE_FILE", pre=True)
self.DEFAULT_PASSWORD = os.getenv("DEFAULT_PASSWORD", "MyPassword") def identify_sqlite_file(cls, v: str) -> Optional[str]:
return app_dirs.SQLITE_DIR.joinpath(f"mealie_{DB_VERSION}.sqlite")
DEFAULT_GROUP: str = "Home"
DEFAULT_PASSWORD: str = "MyPassword"
# Not Used! # Not Used!
self.SFTP_USERNAME = os.getenv("SFTP_USERNAME", None) SFTP_USERNAME: Optional[str]
self.SFTP_PASSWORD = os.getenv("SFTP_PASSWORD", None) SFTP_PASSWORD: Optional[str]
class Config:
env_file = BASE_DIR.joinpath(".env")
env_file_encoding = "utf-8"
# General settings = AppSettings()
DATA_DIR = determine_data_dir(PRODUCTION)
LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
app_dirs = AppDirectories(CWD, DATA_DIR)
settings = AppSettings(app_dirs)

View File

@ -9,7 +9,8 @@ from mealie.db.models.users import User
from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse
from mealie.schema.meal import MealPlanInDB from mealie.schema.meal import MealPlanInDB
from mealie.schema.recipe import Recipe 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.sign_up import SignUpOut
from mealie.schema.theme import SiteTheme from mealie.schema.theme import SiteTheme
from mealie.schema.user import GroupInDB, UserInDB from mealie.schema.user import GroupInDB, UserInDB

View File

@ -15,17 +15,10 @@ class BaseDocument:
self.schema: BaseModel self.schema: BaseModel
# TODO: Improve Get All Query Functionality # TODO: Improve Get All Query Functionality
def get_all(self, session: Session, limit: int = None, order_by: str = None) -> List[dict]: def get_all(self, session: Session, limit: int = None, order_by: str = None, override_schema=None) -> List[dict]:
eff_schema = override_schema or self.schema
if self.orm_mode: return [eff_schema.from_orm(x) for x in session.query(self.sql_model).limit(limit).all()]
return [self.schema.from_orm(x) for x in session.query(self.sql_model).limit(limit).all()]
# list = [x.dict() for x in session.query(self.sql_model).limit(limit).all()]
# if limit == 1:
# return list[0]
# return list
def get_all_limit_columns(self, session: Session, fields: List[str], limit: int = None) -> List[SqlAlchemyBase]: def get_all_limit_columns(self, session: Session, fields: List[str], limit: int = None) -> List[SqlAlchemyBase]:
"""Queries the database for the selected model. Restricts return responses to the """Queries the database for the selected model. Restricts return responses to the

View File

@ -47,11 +47,12 @@ def default_user_init(session: Session):
logger.info("Generating Default User") logger.info("Generating Default User")
db.users.create(session, default_user) db.users.create(session, default_user)
def main():
if __name__ == "__main__":
if sql_exists: if sql_exists:
print("Database Exists") print("Database Exists")
exit()
else: else:
print("Database Doesn't Exists, Initializing...") print("Database Doesn't Exists, Initializing...")
init_db() init_db()
if __name__ == "__main__":
main()

View File

@ -60,7 +60,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
@validates("name") @validates("name")
def validate_name(self, key, name): def validate_name(self, key, name):
assert not name == "" assert name != ""
return name return name
def __init__( def __init__(
@ -92,11 +92,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
self.image = image self.image = image
self.recipeCuisine = recipeCuisine self.recipeCuisine = recipeCuisine
if self.nutrition: self.nutrition = Nutrition(**nutrition) if self.nutrition else Nutrition()
self.nutrition = Nutrition(**nutrition)
else:
self.nutrition = Nutrition()
self.tools = [Tool(tool=x) for x in tools] if tools else [] self.tools = [Tool(tool=x) for x in tools] if tools else []
self.recipeYield = recipeYield self.recipeYield = recipeYield

View File

@ -1,7 +1,7 @@
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from mealie.db.models.model_base import SqlAlchemyBase
from fastapi.logger import logger from fastapi.logger import logger
from mealie.db.models.model_base import SqlAlchemyBase
from slugify import slugify from slugify import slugify
from sqlalchemy.orm import validates from sqlalchemy.orm import validates
@ -25,7 +25,7 @@ class Tag(SqlAlchemyBase):
assert name != "" assert name != ""
return name return name
def __init__(self, name) -> None: def __init__(self, name, session=None) -> None:
self.name = name.strip() self.name = name.strip()
self.slug = slugify(self.name) self.slug = slugify(self.name)

View File

@ -3,20 +3,35 @@ import json
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from mealie.core.config import APP_VERSION, LOGGER_FILE, app_dirs, settings from mealie.core.config import APP_VERSION, LOGGER_FILE, app_dirs, settings
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.debug import AppInfo, DebugInfo
router = APIRouter(prefix="/api/debug", tags=["Debug"]) router = APIRouter(prefix="/api/debug", tags=["Debug"])
@router.get("")
async def get_debug_info(current_user=Depends(get_current_user)):
""" Returns general information about the application for debugging """
return DebugInfo(
production=settings.PRODUCTION,
version=APP_VERSION,
demo_status=settings.IS_DEMO,
api_port=settings.API_PORT,
api_docs=settings.API_DOCS,
db_type=settings.DATABASE_TYPE,
sqlite_file=settings.SQLITE_FILE,
default_group=settings.DEFAULT_GROUP,
)
@router.get("/version") @router.get("/version")
async def get_mealie_version(current_user=Depends(get_current_user)): async def get_mealie_version():
""" Returns the current version of mealie""" """ Returns the current version of mealie"""
return {"version": APP_VERSION} return AppInfo(
version=APP_VERSION,
demo_status=settings.IS_DEMO,
@router.get("/is-demo") production=settings.PRODUCTION,
async def get_demo_status(): )
print(settings.IS_DEMO)
return {"demoStatus": settings.IS_DEMO}
@router.get("/last-recipe-json") @router.get("/last-recipe-json")

View File

@ -1,12 +1,14 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.meal import MealPlanIn, MealPlanInDB from mealie.schema.meal import MealPlanIn, MealPlanInDB
from mealie.schema.snackbar import SnackResponse from mealie.schema.snackbar import SnackResponse
from mealie.schema.user import GroupInDB, UserInDB from mealie.schema.user import GroupInDB, UserInDB
from mealie.services.image import image
from mealie.services.meal_services import get_todays_meal, process_meals from mealie.services.meal_services import get_todays_meal, process_meals
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse
router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"]) router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"])
@ -74,3 +76,22 @@ def get_today(session: Session = Depends(generate_session), current_user: UserIn
recipe = get_todays_meal(session, group_in_db) recipe = get_todays_meal(session, group_in_db)
return recipe.slug return recipe.slug
@router.get("/today/image", tags=["Meal Plan"])
def get_today(session: Session = Depends(generate_session), group_name: str = "Home"):
"""
Returns the image for todays meal-plan.
"""
group_in_db: GroupInDB = db.groups.get(session, group_name, "name")
recipe = get_todays_meal(session, group_in_db)
if recipe:
recipe_image = image.read_image(recipe.slug, image_type=image.IMG_OPTIONS.ORIGINAL_IMAGE)
else:
raise HTTPException(404, "no meal for today")
if recipe_image:
return FileResponse(recipe_image)
else:
raise HTTPException(404, "file not found")

View File

@ -3,13 +3,24 @@ from typing import List, Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.schema.recipe import AllRecipeRequest from mealie.schema.recipe import AllRecipeRequest, RecipeSummary
from slugify import slugify from slugify import slugify
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
router = APIRouter(tags=["Query All Recipes"]) router = APIRouter(tags=["Query All Recipes"])
@router.get("/api/recipes/summary")
async def get_recipe_summary(
skip=0,
end=9999,
session: Session = Depends(generate_session),
):
""" Returns the summary data for recipes in the database """
return db.recipes.get_all(session, limit=end, override_schema=RecipeSummary)
@router.get("/api/recipes") @router.get("/api/recipes")
def get_all_recipes( def get_all_recipes(
keys: Optional[List[str]] = Query(...), keys: Optional[List[str]] = Query(...),

View File

@ -1,3 +1,5 @@
from enum import Enum
from fastapi import APIRouter, Depends, File, Form, HTTPException from fastapi import APIRouter, Depends, File, Form, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from mealie.db.database import db from mealie.db.database import db
@ -5,7 +7,7 @@ from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.recipe import Recipe, RecipeURLIn from mealie.schema.recipe import Recipe, RecipeURLIn
from mealie.schema.snackbar import SnackResponse from mealie.schema.snackbar import SnackResponse
from mealie.services.image_services import 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 mealie.services.scraper.scraper import create_from_url
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -59,6 +61,9 @@ def update_recipe(
recipe: Recipe = db.recipes.update(session, recipe_slug, data.dict()) 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 return recipe.slug
@ -72,20 +77,34 @@ def delete_recipe(
try: try:
db.recipes.delete(session, recipe_slug) db.recipes.delete(session, recipe_slug)
delete_image(recipe_slug)
except: except:
raise HTTPException(status_code=404, detail=SnackResponse.error("Unable to Delete Recipe")) raise HTTPException(status_code=404, detail=SnackResponse.error("Unable to Delete Recipe"))
return SnackResponse.error(f"Recipe {recipe_slug} Deleted") return SnackResponse.error(f"Recipe {recipe_slug} Deleted")
class ImageType(str, Enum):
original = "original"
small = "small"
tiny = "tiny"
@router.get("/{recipe_slug}/image") @router.get("/{recipe_slug}/image")
async def get_recipe_img(recipe_slug: str): async def get_recipe_img(recipe_slug: str, image_type: ImageType = ImageType.original):
""" Takes in a recipe slug, returns the static image """ """ Takes in a recipe slug, returns the static image """
recipe_image = read_image(recipe_slug) if image_type == ImageType.original:
which_image = IMG_OPTIONS.ORIGINAL_IMAGE
elif image_type == ImageType.small:
which_image = IMG_OPTIONS.MINIFIED_IMAGE
elif image_type == ImageType.tiny:
which_image = IMG_OPTIONS.TINY_IMAGE
recipe_image = read_image(recipe_slug, image_type=which_image)
if recipe_image: if recipe_image:
return FileResponse(recipe_image) return FileResponse(recipe_image)
else: else:
return raise HTTPException(404, "file not found")
@router.put("/{recipe_slug}/image") @router.put("/{recipe_slug}/image")

View File

@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.category import RecipeTagResponse, TagIn
from mealie.schema.snackbar import SnackResponse from mealie.schema.snackbar import SnackResponse
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -18,8 +19,16 @@ async def get_all_recipe_tags(session: Session = Depends(generate_session)):
""" Returns a list of available tags in the database """ """ Returns a list of available tags in the database """
return db.tags.get_all_limit_columns(session, ["slug", "name"]) 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 """
@router.get("/{tag}") 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)): def get_all_recipes_by_tag(tag: str, session: Session = Depends(generate_session)):
""" Returns a list of recipes associated with the provided tag. """ """ Returns a list of recipes associated with the provided tag. """
return db.tags.get(session, tag) return db.tags.get(session, tag)

View File

@ -4,7 +4,7 @@ from datetime import timedelta
from fastapi import APIRouter, Depends, File, UploadFile from fastapi import APIRouter, Depends, File, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from mealie.core import security 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.core.security import get_password_hash, verify_password
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session

View File

@ -1,13 +1,32 @@
#!/bin/sh #!/bin/sh
# Initialize Database Prerun # Get Reload Arg `run.sh reload` for dev server
python mealie/db/init_db.py ARG1=${1:-production}
## Migrations # Set Script Directory - Used for running the script from a different directory.
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
# # Initialize Database Prerun
poetry run python $DIR/db/init_db.py
poetry run python $DIR/services/image/minify.py
# Migrations
# TODO # TODO
# Migrations
# Set Port from ENV Variable
## Web Server if [[ "$ARG1" = "reload" ]]
caddy start --config ./Caddyfile then
echo "Hot Reload!"
# Start API
uvicorn mealie.app:app --host 0.0.0.0 --port 9000 --reload
else
echo "Production"
# Web Server
caddy start --config ./Caddyfile
# Start API
uvicorn mealie.app:app --host 0.0.0.0 --port 9000
fi
# Start API
uvicorn mealie.app:app --host 0.0.0.0 --port 9000

View File

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

15
mealie/schema/debug.py Normal file
View File

@ -0,0 +1,15 @@
from pathlib import Path
from fastapi_camelcase import CamelModel
class AppInfo(CamelModel):
production: bool
version: str
demo_status: bool
class DebugInfo(AppInfo):
api_port: int
api_docs: bool
db_type: str
sqlite_file: Path
default_group: str

View File

@ -34,12 +34,30 @@ class Nutrition(BaseModel):
orm_mode = True orm_mode = True
class Recipe(BaseModel): class RecipeSummary(BaseModel):
name: str name: str
description: Optional[str] slug: Optional[str] = ""
image: Optional[Any] image: Optional[Any]
recipeYield: Optional[str]
description: Optional[str]
recipeCategory: Optional[List[str]] = [] recipeCategory: Optional[List[str]] = []
tags: Optional[List[str]] = []
rating: Optional[int]
class Config:
orm_mode = True
@classmethod
def getter_dict(_cls, name_orm: RecipeModel):
return {
**GetterDict(name_orm),
"recipeCategory": [x.name for x in name_orm.recipeCategory],
"tags": [x.name for x in name_orm.tags],
}
class Recipe(RecipeSummary):
recipeYield: Optional[str]
recipeIngredient: Optional[list[str]] recipeIngredient: Optional[list[str]]
recipeInstructions: Optional[list[RecipeStep]] recipeInstructions: Optional[list[RecipeStep]]
nutrition: Optional[Nutrition] nutrition: Optional[Nutrition]
@ -50,11 +68,8 @@ class Recipe(BaseModel):
performTime: Optional[str] = None performTime: Optional[str] = None
# Mealie Specific # Mealie Specific
slug: Optional[str] = ""
tags: Optional[List[str]] = []
dateAdded: Optional[datetime.date] dateAdded: Optional[datetime.date]
notes: Optional[List[RecipeNote]] = [] notes: Optional[List[RecipeNote]] = []
rating: Optional[int]
orgURL: Optional[str] orgURL: Optional[str]
extras: Optional[dict] = {} extras: Optional[dict] = {}

View File

@ -65,8 +65,7 @@ class ExportDatabase:
f.write(content) f.write(content)
def export_images(self): def export_images(self):
for file in app_dirs.IMG_DIR.iterdir(): shutil.copytree(app_dirs.IMG_DIR, self.img_dir, dirs_exist_ok=True)
shutil.copy(file, self.img_dir.joinpath(file.name))
def export_items(self, items: list[BaseModel], folder_name: str, export_list=True): def export_items(self, items: list[BaseModel], folder_name: str, export_list=True):
items = [x.dict() for x in items] items = [x.dict() for x in items]

View File

@ -11,6 +11,7 @@ from mealie.schema.restore import CustomPageImport, GroupImport, RecipeImport, S
from mealie.schema.settings import CustomPageOut, SiteSettings from mealie.schema.settings import CustomPageOut, SiteSettings
from mealie.schema.theme import SiteTheme from mealie.schema.theme import SiteTheme
from mealie.schema.user import UpdateGroup, UserInDB from mealie.schema.user import UpdateGroup, UserInDB
from mealie.services.image import minify
from pydantic.main import BaseModel from pydantic.main import BaseModel
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -108,8 +109,14 @@ class ImportDatabase:
image_dir = self.import_dir.joinpath("images") image_dir = self.import_dir.joinpath("images")
for image in image_dir.iterdir(): for image in image_dir.iterdir():
if image.stem in successful_imports: if image.stem in successful_imports:
if image.is_dir():
dest = app_dirs.IMG_DIR.joinpath(image.stem)
shutil.copytree(image, dest, dirs_exist_ok=True)
if image.is_file():
shutil.copy(image, app_dirs.IMG_DIR) shutil.copy(image, app_dirs.IMG_DIR)
minify.migrate_images()
def import_themes(self): def import_themes(self):
themes_file = self.import_dir.joinpath("themes", "themes.json") themes_file = self.import_dir.joinpath("themes", "themes.json")
themes = ImportDatabase.read_models_file(themes_file, SiteTheme) themes = ImportDatabase.read_models_file(themes_file, SiteTheme)

View File

@ -0,0 +1,112 @@
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Union
import requests
from fastapi.logger import logger
from mealie.core.config import app_dirs
from mealie.services.image import minify
@dataclass
class ImageOptions:
ORIGINAL_IMAGE: str = "original*"
MINIFIED_IMAGE: str = "min-original*"
TINY_IMAGE: str = "tiny-original*"
IMG_OPTIONS = ImageOptions()
def read_image(recipe_slug: str, image_type: str = "original") -> Path:
"""returns the path to the image file for the recipe base of image_type
Args:
recipe_slug (str): Recipe Slug
image_type (str, optional): Glob Style Matcher "original*" | "min-original* | "tiny-original*"
Returns:
Path: [description]
"""
recipe_slug = recipe_slug.split(".")[0] # Incase of File Name
recipe_image_dir = app_dirs.IMG_DIR.joinpath(recipe_slug)
for file in recipe_image_dir.glob(image_type):
return file
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)
except:
pass
image_dir = Path(app_dirs.IMG_DIR.joinpath(f"{recipe_slug}"))
image_dir.mkdir()
extension = extension.replace(".", "")
image_path = image_dir.joinpath(f"original.{extension}")
if isinstance(file_data, bytes):
with open(image_path, "ab") as f:
f.write(file_data)
else:
with open(image_path, "ab") as f:
shutil.copyfileobj(file_data, f)
minify.migrate_images()
return image_path
def delete_image(recipe_slug: str) -> str:
recipe_slug = recipe_slug.split(".")[0]
for file in app_dirs.IMG_DIR.glob(f"{recipe_slug}*"):
return shutil.rmtree(file)
def scrape_image(image_url: str, slug: str) -> Path:
if isinstance(image_url, str): # Handles String Types
image_url = image_url
if isinstance(image_url, list): # Handles List Types
image_url = image_url[0]
if isinstance(image_url, dict): # Handles Dictionary Types
for key in image_url:
if key == "url":
image_url = image_url.get("url")
filename = slug + "." + image_url.split(".")[-1]
filename = app_dirs.IMG_DIR.joinpath(filename)
try:
r = requests.get(image_url, stream=True)
except:
logger.exception("Fatal Image Request Exception")
return None
if r.status_code == 200:
r.raw.decode_content = True
write_image(slug, r.raw, filename.suffix)
filename.unlink()
return slug
return None

View File

@ -0,0 +1,113 @@
import shutil
from pathlib import Path
from fastapi.logger import logger
from mealie.core.config import app_dirs
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):
"""Minifies an image in it's original file format. Quality is lost
Args:
my_path (Path): Source Files
min_dest (Path): FULL Destination File Path
tiny_dest (Path): FULL Destination File Path
"""
try:
img = Image.open(image_file)
basewidth = 720
wpercent = basewidth / float(img.size[0])
hsize = int((float(img.size[1]) * float(wpercent)))
img = img.resize((basewidth, hsize), Image.ANTIALIAS)
img.save(min_dest, quality=70)
tiny_image = crop_center(img)
tiny_image.save(tiny_dest, quality=70)
except Exception:
shutil.copy(image_file, min_dest)
shutil.copy(image_file, tiny_dest)
def crop_center(pil_img, crop_width=300, crop_height=300):
img_width, img_height = pil_img.size
return pil_img.crop(
(
(img_width - crop_width) // 2,
(img_height - crop_height) // 2,
(img_width + crop_width) // 2,
(img_height + crop_height) // 2,
)
)
def sizeof_fmt(size, decimal_places=2):
for unit in ["B", "kB", "MB", "GB", "TB", "PB"]:
if size < 1024.0 or unit == "PiB":
break
size /= 1024.0
return f"{size:.{decimal_places}f} {unit}"
def move_all_images():
for image_file in app_dirs.IMG_DIR.iterdir():
if image_file.is_file():
if image_file.name == ".DS_Store":
continue
new_folder = app_dirs.IMG_DIR.joinpath(image_file.stem)
new_folder.mkdir(parents=True, exist_ok=True)
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...")
move_all_images()
# Minify Loop
for image in app_dirs.IMG_DIR.glob("*/original.*"):
min_dest = image.parent.joinpath(f"min-original{image.suffix}")
tiny_dest = image.parent.joinpath(f"tiny-original{image.suffix}")
if min_dest.exists() and tiny_dest.exists():
continue
minify_image(image, min_dest, tiny_dest)
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)
logger.info(f"{image.name} Minified: {org_size} -> {dest_size} -> {tiny_size}")
logger.info("Finished Minification Check")
if __name__ == "__main__":
migrate_images()
validate_slugs_in_database()

View File

@ -1,63 +0,0 @@
import shutil
from pathlib import Path
import requests
from fastapi.logger import logger
from mealie.core.config import app_dirs
def read_image(recipe_slug: str) -> Path:
if app_dirs.IMG_DIR.joinpath(recipe_slug).is_file():
return app_dirs.IMG_DIR.joinpath(recipe_slug)
recipe_slug = recipe_slug.split(".")[0]
for file in app_dirs.IMG_DIR.glob(f"{recipe_slug}*"):
return file
def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path.name:
delete_image(recipe_slug)
image_path = Path(app_dirs.IMG_DIR.joinpath(f"{recipe_slug}.{extension}"))
with open(image_path, "ab") as f:
f.write(file_data)
return image_path
def delete_image(recipe_slug: str) -> str:
recipe_slug = recipe_slug.split(".")[0]
for file in app_dirs.IMG_DIR.glob(f"{recipe_slug}*"):
return file.unlink()
def scrape_image(image_url: str, slug: str) -> Path:
if isinstance(image_url, str): # Handles String Types
image_url = image_url
if isinstance(image_url, list): # Handles List Types
image_url = image_url[0]
if isinstance(image_url, dict): # Handles Dictionary Types
for key in image_url:
if key == "url":
image_url = image_url.get("url")
filename = slug + "." + image_url.split(".")[-1]
filename = app_dirs.IMG_DIR.joinpath(filename)
try:
r = requests.get(image_url, stream=True)
except:
logger.exception("Fatal Image Request Exception")
return None
if r.status_code == 200:
r.raw.decode_content = True
with open(filename, "wb") as f:
shutil.copyfileobj(r.raw, f)
return filename
return None

View File

@ -52,7 +52,7 @@ def get_todays_meal(session: Session, group: Union[int, GroupInDB]) -> Recipe:
Returns: Returns:
Recipe: Pydantic Recipe Object Recipe: Pydantic Recipe Object
""" """
session = session if session else create_session() session = session or create_session()
if isinstance(group, int): if isinstance(group, int):
group: GroupInDB = db.groups.get(session, group) group: GroupInDB = db.groups.get(session, group)

View File

@ -6,6 +6,7 @@ from fastapi.logger import logger
from mealie.core.config import app_dirs from mealie.core.config import app_dirs
from mealie.db.database import db from mealie.db.database import db
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.services.image.minify import migrate_images
from mealie.utils.unzip import unpack_zip from mealie.utils.unzip import unpack_zip
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -89,4 +90,5 @@ def chowdown_migrate(session: Session, zip_file: Path):
failed_images.append(image.name) failed_images.append(image.name)
report = {"successful": successful_recipes, "failed": failed_recipes} report = {"successful": successful_recipes, "failed": failed_recipes}
migrate_images()
return report return report

View File

@ -7,6 +7,7 @@ from pathlib import Path
from mealie.core.config import app_dirs from mealie.core.config import app_dirs
from mealie.db.database import db from mealie.db.database import db
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.services.image import minify
from mealie.services.scraper.cleaner import Cleaner from mealie.services.scraper.cleaner import Cleaner
@ -23,39 +24,43 @@ def process_selection(selection: Path) -> Path:
return None 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: def import_recipes(recipe_dir: Path) -> Recipe:
image = False image = False
for file in recipe_dir.glob("full.*"): for file in recipe_dir.glob("full.*"):
image = file image = file
break
for file in recipe_dir.glob("*.json"): for file in recipe_dir.glob("*.json"):
recipe_file = file recipe_file = file
break
with open(recipe_file, "r") as f: with open(recipe_file, "r") as f:
recipe_dict = json.loads(f.read()) recipe_dict = json.loads(f.read())
recipe_data = Cleaner.clean(recipe_dict) recipe_data = Cleaner.clean(recipe_dict)
image_name = None image_name = recipe_data["slug"]
if image: recipe_data["image"] = recipe_data["slug"]
image_name = recipe_data["slug"] + image.suffix recipe_data["tags"] = clean_nextcloud_tags(recipe_data.get("keywords"))
recipe_data["image"] = image_name
else:
recipe_data["image"] = "none"
recipe = Recipe(**recipe_data) recipe = Recipe(**recipe_data)
if image: 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 return recipe
def prep(): def prep():
try: shutil.rmtree(app_dirs.TEMP_DIR, ignore_errors=True)
shutil.rmtree(app_dirs.TEMP_DIR)
except:
pass
app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=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()) db.recipes.create(session, recipe.dict())
successful_imports.append(recipe.name) successful_imports.append(recipe.name)
except: except Exception:
session.rollback()
logging.error(f"Failed Nextcloud Import: {dir.name}") logging.error(f"Failed Nextcloud Import: {dir.name}")
logging.exception("") logging.exception("")
failed_imports.append(dir.name) failed_imports.append(dir.name)
cleanup() cleanup()
minify.migrate_images()
return {"successful": successful_imports, "failed": failed_imports} return {"successful": successful_imports, "failed": failed_imports}

View File

@ -1,6 +1,6 @@
import html import html
import re import re
from datetime import datetime from datetime import datetime, timedelta
from typing import List from typing import List
from slugify import slugify from slugify import slugify
@ -10,8 +10,6 @@ class Cleaner:
"""A Namespace for utility function to clean recipe data extracted """A Namespace for utility function to clean recipe data extracted
from a url and returns a dictionary that is ready for import into from a url and returns a dictionary that is ready for import into
the database. Cleaner.clean is the main entrypoint the database. Cleaner.clean is the main entrypoint
""" """
@staticmethod @staticmethod
@ -44,7 +42,7 @@ class Cleaner:
@staticmethod @staticmethod
def category(category: str): def category(category: str):
if isinstance(category, str): if isinstance(category, str) and category != "":
return [category] return [category]
else: else:
return [] return []
@ -144,7 +142,100 @@ class Cleaner:
def time(time_entry): def time(time_entry):
if time_entry is None: if time_entry is None:
return None return None
elif isinstance(time_entry, timedelta):
pretty_print_timedelta(time_entry)
elif isinstance(time_entry, datetime): elif isinstance(time_entry, datetime):
print(time_entry) print(time_entry)
elif isinstance(time_entry, str):
if re.match("PT.*H.*M", time_entry):
time_delta_object = parse_duration(time_entry)
return pretty_print_timedelta(time_delta_object)
else: else:
return str(time_entry) return str(time_entry)
# ! TODO: Cleanup Code Below
def parse_duration(iso_duration):
"""Parses an ISO 8601 duration string into a datetime.timedelta instance.
Args:
iso_duration: an ISO 8601 duration string.
Returns:
a datetime.timedelta instance
"""
m = re.match(r"^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:.\d+)?)S)?$", iso_duration)
if m is None:
raise ValueError("invalid ISO 8601 duration string")
days = 0
hours = 0
minutes = 0
seconds = 0.0
# Years and months are not being utilized here, as there is not enough
# information provided to determine which year and which month.
# Python's time_delta class stores durations as days, seconds and
# microseconds internally, and therefore we'd have to
# convert parsed years and months to specific number of days.
if m[3]:
days = int(m[3])
if m[4]:
hours = int(m[4])
if m[5]:
minutes = int(m[5])
if m[6]:
seconds = float(m[6])
return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
def pretty_print_timedelta(t, max_components=None, max_decimal_places=2):
"""
Print a pretty string for a timedelta.
For example datetime.timedelta(days=2, seconds=17280) will be printed as '2 days, 4 hours, 48 minutes'. Setting max_components to e.g. 1 will change this to '2.2 days', where the
number of decimal points can also be set.
"""
time_scales = [
timedelta(days=365),
timedelta(days=1),
timedelta(hours=1),
timedelta(minutes=1),
timedelta(seconds=1),
timedelta(microseconds=1000),
timedelta(microseconds=1),
]
time_scale_names_dict = {
timedelta(days=365): "year",
timedelta(days=1): "day",
timedelta(hours=1): "Hour",
timedelta(minutes=1): "Minute",
timedelta(seconds=1): "Second",
timedelta(microseconds=1000): "millisecond",
timedelta(microseconds=1): "microsecond",
}
count = 0
txt = ""
first = True
for scale in time_scales:
if t >= scale:
count += 1
n = t / scale if count == max_components else int(t / scale)
t -= n * scale
n_txt = str(round(n, max_decimal_places))
if n_txt[-2:] == ".0":
n_txt = n_txt[:-2]
txt += "{}{} {}{}".format(
"" if first else " ",
n_txt,
time_scale_names_dict[scale],
"s" if n > 1 else "",
)
if first:
first = False
if len(txt) == 0:
txt = "none"
return txt

View File

@ -5,7 +5,7 @@ import requests
import scrape_schema_recipe import scrape_schema_recipe
from mealie.core.config import app_dirs from mealie.core.config import app_dirs
from fastapi.logger import logger from fastapi.logger import logger
from mealie.services.image_services import scrape_image from mealie.services.image.image import scrape_image
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.services.scraper import open_graph from mealie.services.scraper import open_graph
from mealie.services.scraper.cleaner import Cleaner from mealie.services.scraper.cleaner import Cleaner

189
poetry.lock generated
View File

@ -50,7 +50,7 @@ zookeeper = ["kazoo"]
[[package]] [[package]]
name = "astroid" name = "astroid"
version = "2.5.1" version = "2.5.2"
description = "An abstract syntax tree for Python with inference support." description = "An abstract syntax tree for Python with inference support."
category = "dev" category = "dev"
optional = false optional = false
@ -191,11 +191,11 @@ toml = ["toml"]
[[package]] [[package]]
name = "decorator" name = "decorator"
version = "4.4.2" version = "5.0.4"
description = "Decorators for Humans" description = "Decorators for Humans"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*" python-versions = ">=3.5"
[[package]] [[package]]
name = "ecdsa" name = "ecdsa"
@ -412,11 +412,11 @@ python-versions = "*"
[[package]] [[package]]
name = "lazy-object-proxy" name = "lazy-object-proxy"
version = "1.5.2" version = "1.6.0"
description = "A fast and thorough lazy object proxy." description = "A fast and thorough lazy object proxy."
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
[[package]] [[package]]
name = "livereload" name = "livereload"
@ -606,6 +606,14 @@ category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pillow"
version = "8.2.0"
description = "Python Imaging Library (Fork)"
category = "main"
optional = false
python-versions = ">=3.6"
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "0.13.1" version = "0.13.1"
@ -690,14 +698,14 @@ python-versions = "*"
[[package]] [[package]]
name = "pylint" name = "pylint"
version = "2.7.2" version = "2.7.4"
description = "python code static checker" description = "python code static checker"
category = "dev" category = "dev"
optional = false optional = false
python-versions = "~=3.6" python-versions = "~=3.6"
[package.dependencies] [package.dependencies]
astroid = ">=2.5.1,<2.6" astroid = ">=2.5.2,<2.7"
colorama = {version = "*", markers = "sys_platform == \"win32\""} colorama = {version = "*", markers = "sys_platform == \"win32\""}
isort = ">=4.2.5,<6" isort = ">=4.2.5,<6"
mccabe = ">=0.6,<0.7" mccabe = ">=0.6,<0.7"
@ -943,7 +951,7 @@ python-versions = ">=3.6"
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "1.4.2" version = "1.4.5"
description = "Database Abstraction Library" description = "Database Abstraction Library"
category = "main" category = "main"
optional = false optional = false
@ -954,6 +962,7 @@ greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\""}
[package.extras] [package.extras]
aiomysql = ["greenlet (!=0.4.17)", "aiomysql"] aiomysql = ["greenlet (!=0.4.17)", "aiomysql"]
aiosqlite = ["greenlet (!=0.4.17)", "aiosqlite"]
asyncio = ["greenlet (!=0.4.17)"] asyncio = ["greenlet (!=0.4.17)"]
mariadb_connector = ["mariadb (>=1.0.1)"] mariadb_connector = ["mariadb (>=1.0.1)"]
mssql = ["pyodbc"] mssql = ["pyodbc"]
@ -969,6 +978,7 @@ postgresql_pg8000 = ["pg8000 (>=1.16.6)"]
postgresql_psycopg2binary = ["psycopg2-binary"] postgresql_psycopg2binary = ["psycopg2-binary"]
postgresql_psycopg2cffi = ["psycopg2cffi"] postgresql_psycopg2cffi = ["psycopg2cffi"]
pymysql = ["pymysql (<1)", "pymysql"] pymysql = ["pymysql (<1)", "pymysql"]
sqlcipher = ["sqlcipher3-binary"]
[[package]] [[package]]
name = "starlette" name = "starlette"
@ -1154,7 +1164,7 @@ python-versions = "*"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.9" python-versions = "^3.9"
content-hash = "a6c10e179bc15efc30627c9793218bb944f43dce5e624a7bcabcc47545e661e8" content-hash = "a81463b941cfdbc0e32e215644b172ec1111d5ada27864292d299d7d64fae4cf"
[metadata.files] [metadata.files]
aiofiles = [ aiofiles = [
@ -1174,8 +1184,8 @@ apscheduler = [
{file = "APScheduler-3.7.0.tar.gz", hash = "sha256:1cab7f2521e107d07127b042155b632b7a1cd5e02c34be5a28ff62f77c900c6a"}, {file = "APScheduler-3.7.0.tar.gz", hash = "sha256:1cab7f2521e107d07127b042155b632b7a1cd5e02c34be5a28ff62f77c900c6a"},
] ]
astroid = [ astroid = [
{file = "astroid-2.5.1-py3-none-any.whl", hash = "sha256:21d735aab248253531bb0f1e1e6d068f0ee23533e18ae8a6171ff892b98297cf"}, {file = "astroid-2.5.2-py3-none-any.whl", hash = "sha256:cd80bf957c49765dce6d92c43163ff9d2abc43132ce64d4b1b47717c6d2522df"},
{file = "astroid-2.5.1.tar.gz", hash = "sha256:cfc35498ee64017be059ceffab0a25bedf7548ab76f2bea691c5565896e7128d"}, {file = "astroid-2.5.2.tar.gz", hash = "sha256:6b0ed1af831570e500e2437625979eaa3b36011f66ddfc4ce930128610258ca9"},
] ]
atomicwrites = [ atomicwrites = [
{file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
@ -1312,8 +1322,8 @@ coverage = [
{file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"},
] ]
decorator = [ decorator = [
{file = "decorator-4.4.2-py2.py3-none-any.whl", hash = "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760"}, {file = "decorator-5.0.4-py3-none-any.whl", hash = "sha256:7280eff5351d7004144b1f302347328c3d06e84271dbe690a5dc4b17eb586994"},
{file = "decorator-4.4.2.tar.gz", hash = "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"}, {file = "decorator-5.0.4.tar.gz", hash = "sha256:cdd9d86d8aca11e4496f3cd26d48020db5a2fac247af0e918b3e0bbdb6e4a174"},
] ]
ecdsa = [ ecdsa = [
{file = "ecdsa-0.14.1-py2.py3-none-any.whl", hash = "sha256:e108a5fe92c67639abae3260e43561af914e7fd0d27bae6d2ec1312ae7934dfe"}, {file = "ecdsa-0.14.1-py2.py3-none-any.whl", hash = "sha256:e108a5fe92c67639abae3260e43561af914e7fd0d27bae6d2ec1312ae7934dfe"},
@ -1436,30 +1446,28 @@ jstyleson = [
{file = "jstyleson-0.0.2.tar.gz", hash = "sha256:680003f3b15a2959e4e6a351f3b858e3c07dd3e073a0d54954e34d8ea5e1308e"}, {file = "jstyleson-0.0.2.tar.gz", hash = "sha256:680003f3b15a2959e4e6a351f3b858e3c07dd3e073a0d54954e34d8ea5e1308e"},
] ]
lazy-object-proxy = [ lazy-object-proxy = [
{file = "lazy-object-proxy-1.5.2.tar.gz", hash = "sha256:5944a9b95e97de1980c65f03b79b356f30a43de48682b8bdd90aa5089f0ec1f4"}, {file = "lazy-object-proxy-1.6.0.tar.gz", hash = "sha256:489000d368377571c6f982fba6497f2aa13c6d1facc40660963da62f5c379726"},
{file = "lazy_object_proxy-1.5.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:e960e8be509e8d6d618300a6c189555c24efde63e85acaf0b14b2cd1ac743315"}, {file = "lazy_object_proxy-1.6.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c6938967f8528b3668622a9ed3b31d145fab161a32f5891ea7b84f6b790be05b"},
{file = "lazy_object_proxy-1.5.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:522b7c94b524389f4a4094c4bf04c2b02228454ddd17c1a9b2801fac1d754871"}, {file = "lazy_object_proxy-1.6.0-cp27-cp27m-win32.whl", hash = "sha256:ebfd274dcd5133e0afae738e6d9da4323c3eb021b3e13052d8cbd0e457b1256e"},
{file = "lazy_object_proxy-1.5.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:3782931963dc89e0e9a0ae4348b44762e868ea280e4f8c233b537852a8996ab9"}, {file = "lazy_object_proxy-1.6.0-cp27-cp27m-win_amd64.whl", hash = "sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93"},
{file = "lazy_object_proxy-1.5.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:429c4d1862f3fc37cd56304d880f2eae5bd0da83bdef889f3bd66458aac49128"}, {file = "lazy_object_proxy-1.6.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d900d949b707778696fdf01036f58c9876a0d8bfe116e8d220cfd4b15f14e741"},
{file = "lazy_object_proxy-1.5.2-cp35-cp35m-win32.whl", hash = "sha256:cd1bdace1a8762534e9a36c073cd54e97d517a17d69a17985961265be6d22847"}, {file = "lazy_object_proxy-1.6.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5743a5ab42ae40caa8421b320ebf3a998f89c85cdc8376d6b2e00bd12bd1b587"},
{file = "lazy_object_proxy-1.5.2-cp35-cp35m-win_amd64.whl", hash = "sha256:ddbdcd10eb999d7ab292677f588b658372aadb9a52790f82484a37127a390108"}, {file = "lazy_object_proxy-1.6.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:bf34e368e8dd976423396555078def5cfc3039ebc6fc06d1ae2c5a65eebbcde4"},
{file = "lazy_object_proxy-1.5.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ecb5dd5990cec6e7f5c9c1124a37cb2c710c6d69b0c1a5c4aa4b35eba0ada068"}, {file = "lazy_object_proxy-1.6.0-cp36-cp36m-win32.whl", hash = "sha256:b579f8acbf2bdd9ea200b1d5dea36abd93cabf56cf626ab9c744a432e15c815f"},
{file = "lazy_object_proxy-1.5.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:b6577f15d5516d7d209c1a8cde23062c0f10625f19e8dc9fb59268859778d7d7"}, {file = "lazy_object_proxy-1.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:4f60460e9f1eb632584c9685bccea152f4ac2130e299784dbaf9fae9f49891b3"},
{file = "lazy_object_proxy-1.5.2-cp36-cp36m-win32.whl", hash = "sha256:c8fe2d6ff0ff583784039d0255ea7da076efd08507f2be6f68583b0da32e3afb"}, {file = "lazy_object_proxy-1.6.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d7124f52f3bd259f510651450e18e0fd081ed82f3c08541dffc7b94b883aa981"},
{file = "lazy_object_proxy-1.5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:fa5b2dee0e231fa4ad117be114251bdfe6afe39213bd629d43deb117b6a6c40a"}, {file = "lazy_object_proxy-1.6.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:22ddd618cefe54305df49e4c069fa65715be4ad0e78e8d252a33debf00f6ede2"},
{file = "lazy_object_proxy-1.5.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1d33d6f789697f401b75ce08e73b1de567b947740f768376631079290118ad39"}, {file = "lazy_object_proxy-1.6.0-cp37-cp37m-win32.whl", hash = "sha256:9d397bf41caad3f489e10774667310d73cb9c4258e9aed94b9ec734b34b495fd"},
{file = "lazy_object_proxy-1.5.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:57fb5c5504ddd45ed420b5b6461a78f58cbb0c1b0cbd9cd5a43ad30a4a3ee4d0"}, {file = "lazy_object_proxy-1.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:24a5045889cc2729033b3e604d496c2b6f588c754f7a62027ad4437a7ecc4837"},
{file = "lazy_object_proxy-1.5.2-cp37-cp37m-win32.whl", hash = "sha256:e7273c64bccfd9310e9601b8f4511d84730239516bada26a0c9846c9697617ef"}, {file = "lazy_object_proxy-1.6.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:17e0967ba374fc24141738c69736da90e94419338fd4c7c7bef01ee26b339653"},
{file = "lazy_object_proxy-1.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6f4e5e68b7af950ed7fdb594b3f19a0014a3ace0fedb86acb896e140ffb24302"}, {file = "lazy_object_proxy-1.6.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:410283732af311b51b837894fa2f24f2c0039aa7f220135192b38fcc42bd43d3"},
{file = "lazy_object_proxy-1.5.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:cadfa2c2cf54d35d13dc8d231253b7985b97d629ab9ca6e7d672c35539d38163"}, {file = "lazy_object_proxy-1.6.0-cp38-cp38-win32.whl", hash = "sha256:85fb7608121fd5621cc4377a8961d0b32ccf84a7285b4f1d21988b2eae2868e8"},
{file = "lazy_object_proxy-1.5.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:e7428977763150b4cf83255625a80a23dfdc94d43be7791ce90799d446b4e26f"}, {file = "lazy_object_proxy-1.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:d1c2676e3d840852a2de7c7d5d76407c772927addff8d742b9808fe0afccebdf"},
{file = "lazy_object_proxy-1.5.2-cp38-cp38-win32.whl", hash = "sha256:2f2de8f8ac0be3e40d17730e0600619d35c78c13a099ea91ef7fb4ad944ce694"}, {file = "lazy_object_proxy-1.6.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b865b01a2e7f96db0c5d12cfea590f98d8c5ba64ad222300d93ce6ff9138bcad"},
{file = "lazy_object_proxy-1.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:38c3865bd220bd983fcaa9aa11462619e84a71233bafd9c880f7b1cb753ca7fa"}, {file = "lazy_object_proxy-1.6.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:4732c765372bd78a2d6b2150a6e99d00a78ec963375f236979c0626b97ed8e43"},
{file = "lazy_object_proxy-1.5.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:8a44e9901c0555f95ac401377032f6e6af66d8fc1fbfad77a7a8b1a826e0b93c"}, {file = "lazy_object_proxy-1.6.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:9698110e36e2df951c7c36b6729e96429c9c32b3331989ef19976592c5f3c77a"},
{file = "lazy_object_proxy-1.5.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fa7fb7973c622b9e725bee1db569d2c2ee64d2f9a089201c5e8185d482c7352d"}, {file = "lazy_object_proxy-1.6.0-cp39-cp39-win32.whl", hash = "sha256:1fee665d2638491f4d6e55bd483e15ef21f6c8c2095f235fef72601021e64f61"},
{file = "lazy_object_proxy-1.5.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:71a1ef23f22fa8437974b2d60fedb947c99a957ad625f83f43fd3de70f77f458"}, {file = "lazy_object_proxy-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b"},
{file = "lazy_object_proxy-1.5.2-cp39-cp39-win32.whl", hash = "sha256:ef3f5e288aa57b73b034ce9c1f1ac753d968f9069cd0742d1d69c698a0167166"},
{file = "lazy_object_proxy-1.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:37d9c34b96cca6787fe014aeb651217944a967a5b165e2cacb6b858d2997ab84"},
] ]
livereload = [ livereload = [
{file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"}, {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"},
@ -1603,6 +1611,41 @@ pathspec = [
{file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"},
{file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"},
] ]
pillow = [
{file = "Pillow-8.2.0-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:dc38f57d8f20f06dd7c3161c59ca2c86893632623f33a42d592f097b00f720a9"},
{file = "Pillow-8.2.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a013cbe25d20c2e0c4e85a9daf438f85121a4d0344ddc76e33fd7e3965d9af4b"},
{file = "Pillow-8.2.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8bb1e155a74e1bfbacd84555ea62fa21c58e0b4e7e6b20e4447b8d07990ac78b"},
{file = "Pillow-8.2.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c5236606e8570542ed424849f7852a0ff0bce2c4c8d0ba05cc202a5a9c97dee9"},
{file = "Pillow-8.2.0-cp36-cp36m-win32.whl", hash = "sha256:12e5e7471f9b637762453da74e390e56cc43e486a88289995c1f4c1dc0bfe727"},
{file = "Pillow-8.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5afe6b237a0b81bd54b53f835a153770802f164c5570bab5e005aad693dab87f"},
{file = "Pillow-8.2.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:cb7a09e173903541fa888ba010c345893cd9fc1b5891aaf060f6ca77b6a3722d"},
{file = "Pillow-8.2.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0d19d70ee7c2ba97631bae1e7d4725cdb2ecf238178096e8c82ee481e189168a"},
{file = "Pillow-8.2.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:083781abd261bdabf090ad07bb69f8f5599943ddb539d64497ed021b2a67e5a9"},
{file = "Pillow-8.2.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:c6b39294464b03457f9064e98c124e09008b35a62e3189d3513e5148611c9388"},
{file = "Pillow-8.2.0-cp37-cp37m-win32.whl", hash = "sha256:01425106e4e8cee195a411f729cff2a7d61813b0b11737c12bd5991f5f14bcd5"},
{file = "Pillow-8.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3b570f84a6161cf8865c4e08adf629441f56e32f180f7aa4ccbd2e0a5a02cba2"},
{file = "Pillow-8.2.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:031a6c88c77d08aab84fecc05c3cde8414cd6f8406f4d2b16fed1e97634cc8a4"},
{file = "Pillow-8.2.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:66cc56579fd91f517290ab02c51e3a80f581aba45fd924fcdee01fa06e635812"},
{file = "Pillow-8.2.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6c32cc3145928c4305d142ebec682419a6c0a8ce9e33db900027ddca1ec39178"},
{file = "Pillow-8.2.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:624b977355cde8b065f6d51b98497d6cd5fbdd4f36405f7a8790e3376125e2bb"},
{file = "Pillow-8.2.0-cp38-cp38-win32.whl", hash = "sha256:5cbf3e3b1014dddc45496e8cf38b9f099c95a326275885199f427825c6522232"},
{file = "Pillow-8.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:463822e2f0d81459e113372a168f2ff59723e78528f91f0bd25680ac185cf797"},
{file = "Pillow-8.2.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:95d5ef984eff897850f3a83883363da64aae1000e79cb3c321915468e8c6add5"},
{file = "Pillow-8.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b91c36492a4bbb1ee855b7d16fe51379e5f96b85692dc8210831fbb24c43e484"},
{file = "Pillow-8.2.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d68cb92c408261f806b15923834203f024110a2e2872ecb0bd2a110f89d3c602"},
{file = "Pillow-8.2.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f217c3954ce5fd88303fc0c317af55d5e0204106d86dea17eb8205700d47dec2"},
{file = "Pillow-8.2.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:5b70110acb39f3aff6b74cf09bb4169b167e2660dabc304c1e25b6555fa781ef"},
{file = "Pillow-8.2.0-cp39-cp39-win32.whl", hash = "sha256:a7d5e9fad90eff8f6f6106d3b98b553a88b6f976e51fce287192a5d2d5363713"},
{file = "Pillow-8.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:238c197fc275b475e87c1453b05b467d2d02c2915fdfdd4af126145ff2e4610c"},
{file = "Pillow-8.2.0-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:0e04d61f0064b545b989126197930807c86bcbd4534d39168f4aa5fda39bb8f9"},
{file = "Pillow-8.2.0-pp36-pypy36_pp73-manylinux2010_i686.whl", hash = "sha256:63728564c1410d99e6d1ae8e3b810fe012bc440952168af0a2877e8ff5ab96b9"},
{file = "Pillow-8.2.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:c03c07ed32c5324939b19e36ae5f75c660c81461e312a41aea30acdd46f93a7c"},
{file = "Pillow-8.2.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:4d98abdd6b1e3bf1a1cbb14c3895226816e666749ac040c4e2554231068c639b"},
{file = "Pillow-8.2.0-pp37-pypy37_pp73-manylinux2010_i686.whl", hash = "sha256:aac00e4bc94d1b7813fe882c28990c1bc2f9d0e1aa765a5f2b516e8a6a16a9e4"},
{file = "Pillow-8.2.0-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:22fd0f42ad15dfdde6c581347eaa4adb9a6fc4b865f90b23378aa7914895e120"},
{file = "Pillow-8.2.0-pp37-pypy37_pp73-win32.whl", hash = "sha256:e98eca29a05913e82177b3ba3d198b1728e164869c613d76d0de4bde6768a50e"},
{file = "Pillow-8.2.0.tar.gz", hash = "sha256:a787ab10d7bb5494e5f76536ac460741788f1fbce851068d73a87ca7c35fc3e1"},
]
pluggy = [ pluggy = [
{file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
{file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
@ -1671,8 +1714,8 @@ pyhumps = [
{file = "pyhumps-1.6.1.tar.gz", hash = "sha256:01612603c5ad73a407299d806d30708a3935052276fdd93776953bccc0724e0a"}, {file = "pyhumps-1.6.1.tar.gz", hash = "sha256:01612603c5ad73a407299d806d30708a3935052276fdd93776953bccc0724e0a"},
] ]
pylint = [ pylint = [
{file = "pylint-2.7.2-py3-none-any.whl", hash = "sha256:d09b0b07ba06bcdff463958f53f23df25e740ecd81895f7d2699ec04bbd8dc3b"}, {file = "pylint-2.7.4-py3-none-any.whl", hash = "sha256:209d712ec870a0182df034ae19f347e725c1e615b2269519ab58a35b3fcbbe7a"},
{file = "pylint-2.7.2.tar.gz", hash = "sha256:0e21d3b80b96740909d77206d741aa3ce0b06b41be375d92e1f3244a274c1f8a"}, {file = "pylint-2.7.4.tar.gz", hash = "sha256:bd38914c7731cdc518634a8d3c5585951302b6e2b6de60fbb3f7a0220e21eeee"},
] ]
pymdown-extensions = [ pymdown-extensions = [
{file = "pymdown-extensions-8.1.1.tar.gz", hash = "sha256:632371fa3bf1b21a0e3f4063010da59b41db049f261f4c0b0872069a9b6d1735"}, {file = "pymdown-extensions-8.1.1.tar.gz", hash = "sha256:632371fa3bf1b21a0e3f4063010da59b41db049f261f4c0b0872069a9b6d1735"},
@ -1806,40 +1849,40 @@ soupsieve = [
{file = "soupsieve-2.2.1.tar.gz", hash = "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc"}, {file = "soupsieve-2.2.1.tar.gz", hash = "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc"},
] ]
sqlalchemy = [ sqlalchemy = [
{file = "SQLAlchemy-1.4.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:aed22be55a608787bb6875dbcf3561349a0e88fe33fd88c318c1e5b4eeb2306a"}, {file = "SQLAlchemy-1.4.5-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c3810ebcf1d42c532c8f5c3f442c705d94442a27a32f2df5344f0857306ab321"},
{file = "SQLAlchemy-1.4.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:7e1b0ed6d720750f02333d2f52502dfc2a23185aacc2cc6ce6ec29d28c21397c"}, {file = "SQLAlchemy-1.4.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:7481f9c2c832a3bf37c80bee44d91ac9938b815cc06f7e795b976e300914aab9"},
{file = "SQLAlchemy-1.4.2-cp27-cp27m-win32.whl", hash = "sha256:9406b96a979ab8d6de5d89f58b1f103c9aeef6fb5367448537a8228619f11258"}, {file = "SQLAlchemy-1.4.5-cp27-cp27m-win32.whl", hash = "sha256:94040a92b6676f9ffdab6c6b479b3554b927a635c90698c761960b266b04fc88"},
{file = "SQLAlchemy-1.4.2-cp27-cp27m-win_amd64.whl", hash = "sha256:59ec279f1bd55e1d703e3d4b651600cc463cc3eafa8d8e5a70ab844f736348d4"}, {file = "SQLAlchemy-1.4.5-cp27-cp27m-win_amd64.whl", hash = "sha256:02b039e0e7e6de2f15ea2d2de3995e31a170e700ec0b37b4eded662171711d19"},
{file = "SQLAlchemy-1.4.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8cfcfcf2582b19c874fa20d0b75100abe17be80a4c637c0683b4eb919946dfee"}, {file = "SQLAlchemy-1.4.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:f16801795f1ffe9472360589a04301018c79e4582a85e68067275bb4f765e4e2"},
{file = "SQLAlchemy-1.4.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:a6b4b7688fe7d251bbae3f9da4a487568bd584d13201bc7591c8639ad01fecdc"}, {file = "SQLAlchemy-1.4.5-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:82f11b679df91275788be6734dd4a9dfa29bac67b85326992609f62b05bdab37"},
{file = "SQLAlchemy-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0abab6d1044198993256f073340b14c459736777c550a7e914cd00444dcf9c30"}, {file = "SQLAlchemy-1.4.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:a08027ae84efc563f0f2f341dda572eadebeca38c0ae028a009988f27e9e6230"},
{file = "SQLAlchemy-1.4.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:5fb8f6a391992dd6aafe4fdf1dffbf7934fba1f5938593f20b152aa7f9619f82"}, {file = "SQLAlchemy-1.4.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:70a1387396ea5b3022539b560c287daf79403d8b4b365f89b56d660e625a4457"},
{file = "SQLAlchemy-1.4.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:97e333260a99d989f2a131aa8aa74140636dfbd030987150cb3748da607ea7db"}, {file = "SQLAlchemy-1.4.5-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:4f7ce3bfdab6520554af4a5b1df4513d45388624d015ba4d921daf48ce1d6503"},
{file = "SQLAlchemy-1.4.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:3fa75c854dba3f9b9c28bc5d88d246f6bc6f20b7480367c65339bcb2864d4707"}, {file = "SQLAlchemy-1.4.5-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:08943201a1e3c6238e48f4d5d56c27ea1e1b39d3d9f36a9d81fc3cfb0e1b83bd"},
{file = "SQLAlchemy-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:1b9f3c7b281aa1c3d0c74ef12c4633e5f8358bb94f01be7b964887183fd53e5e"}, {file = "SQLAlchemy-1.4.5-cp36-cp36m-win32.whl", hash = "sha256:fbb0fda1c574975807aceb0e2332e0ecfe9e5656c191ed482c1a5eafe7a33823"},
{file = "SQLAlchemy-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:da72e3499bde4548e8b7d7f2ab23ceed09a5bac307bf51057e066c406a0ba2e1"}, {file = "SQLAlchemy-1.4.5-cp36-cp36m-win_amd64.whl", hash = "sha256:8d6a9feb5efd2fdab25c6d5a0a5589fed9d789f5ec57ec12263fd0e60ce1dea6"},
{file = "SQLAlchemy-1.4.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:8383292298bb85d7ad79a13c6571aff213b96c49737f3c3af129de63bbfb42c9"}, {file = "SQLAlchemy-1.4.5-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:c22bfac8d3b955cdb13f0fcd6343156bf56d925196cf7d9ab9ce9f61d3f1e11c"},
{file = "SQLAlchemy-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:4d1447183356c9679853926e81c7ebce3fbca9b1c607ea439975298c72137a36"}, {file = "SQLAlchemy-1.4.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:7c0c7bb49167ac738ca6ee6e7f94a9988a7e4e261d8da335341e8c8c8f3b2e9b"},
{file = "SQLAlchemy-1.4.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:ff76d7dbf33f62e30e5a1d1b095d46afcdc49e42cbe33ce12014110147466700"}, {file = "SQLAlchemy-1.4.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:344b58b4b4193b72e8b768a51ef6eb5a4c948ce313a0f23e2ea081e71ce8ac0e"},
{file = "SQLAlchemy-1.4.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:1ba6922331b3f38e116c9266206b044baf64576e5cebd87917b5ad872d7a025f"}, {file = "SQLAlchemy-1.4.5-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:48540072f43b3c080159ec1f24a4b014c0ee83d3b73795399974aa358a8cf71b"},
{file = "SQLAlchemy-1.4.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:d3b2819f4d7ae56191efc6fc456eb1805ada2bd5ba93d918893bc24fa7a1e30c"}, {file = "SQLAlchemy-1.4.5-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:81badd7d3e0e6aba70a5d1b50fabe8112e9835a6fdb0684054c3fe5378ce0d01"},
{file = "SQLAlchemy-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:3b290ff34de625143a05d2d172a88a064bb04a7938265b09d4e4bf45f21948f6"}, {file = "SQLAlchemy-1.4.5-cp37-cp37m-win32.whl", hash = "sha256:a103294583383660d9e06dbd82037dc8e94c184bdcb27b2be44ae4457dafc6b4"},
{file = "SQLAlchemy-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:5289cafee71037f15feeeaf736f01910b9e3572525b73b201bdd21816db010ed"}, {file = "SQLAlchemy-1.4.5-cp37-cp37m-win_amd64.whl", hash = "sha256:5361e25181b9872d6906c8c9be7dc05cb0a0951d71ee59ee5a71c1deb301b8a8"},
{file = "SQLAlchemy-1.4.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:0bb04fd7414718fb1f4dfa17efcb0be787363451cf99a5e992728925d298d9ae"}, {file = "SQLAlchemy-1.4.5-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:7f5087104c3c5af11ea59e49ae66c33ca98b14a47d3796ae97498fca53f84aef"},
{file = "SQLAlchemy-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:4e88549a5e58ba8c80c5ea071ac3b4e590236672a882bb80f56da4afcee45d96"}, {file = "SQLAlchemy-1.4.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:11e7a86209f69273e75d2dd64b06c0c2660e39cd942fce2170515c404ed7358a"},
{file = "SQLAlchemy-1.4.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:edec945ed57d11a1123657e4066f0bf747aaa93c8a65ec1c2c98172d1f2a9b7d"}, {file = "SQLAlchemy-1.4.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:8301ecf3e819eb5dbc171e84654ff60872807775301a55fe35b0ab2ba3742031"},
{file = "SQLAlchemy-1.4.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:06125670280111e39014af87f14d74599fd4b39a512c74f1a10e21e5626eb158"}, {file = "SQLAlchemy-1.4.5-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:44e11a06168782b6d485daef197783366ce7ab0d5eea0066c899ae06cef47bbc"},
{file = "SQLAlchemy-1.4.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:e1692bdf1b95c97caab1201773a4576f59627997f598d30bdadc50dd9f897fec"}, {file = "SQLAlchemy-1.4.5-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:6f8fdad2f335d2f3ca2f3ee3b01404f7abcf519b03de2c510f1f42d16e39ffb4"},
{file = "SQLAlchemy-1.4.2-cp38-cp38-win32.whl", hash = "sha256:65c4df9517da9cce2c1255282d3e39f2afbc3a02deba60d99b0a3283ae80ec0b"}, {file = "SQLAlchemy-1.4.5-cp38-cp38-win32.whl", hash = "sha256:f62c57ceadedeb8e7b98b48ac4d684bf2b0f73b9d882fed3ca260d9aedf6403f"},
{file = "SQLAlchemy-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:c6197c88ad53c31f58de5a8180936b8ef027356e788cd5f6514b3439d3d897ac"}, {file = "SQLAlchemy-1.4.5-cp38-cp38-win_amd64.whl", hash = "sha256:301d0cd6ef1dc73b607748183da857e712d6f743de8d92b1e1f8facfb0ba2aa2"},
{file = "SQLAlchemy-1.4.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:6d6115edf1297bfa58994986ffe0dff21af18f0cba51dfa6d1769aa8a277be32"}, {file = "SQLAlchemy-1.4.5-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:915d4fa08776c0252dc5a34fa15c6490f66f411ea1ac9492022f98875d6baf20"},
{file = "SQLAlchemy-1.4.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:facacaea95e0822f7bbeaa6909b30b2836b14cff8790209d52a0c866e240b673"}, {file = "SQLAlchemy-1.4.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7de84feb31af3d8fdf819cac2042928d0b60d3cb16f49c4b2f48d88db46e79f6"},
{file = "SQLAlchemy-1.4.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:6e517126d3bc13d455826befdc35a89f82f01d163848f68db02caa80d25433fc"}, {file = "SQLAlchemy-1.4.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:45b091ccbf94374ed14abde17e9a04522b0493a17282eaaf4383efdd413f5243"},
{file = "SQLAlchemy-1.4.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:09b08eb1bea621e47c2b0fcb0334fcbb00e1da2a3c2d45a98e56cd072b840719"}, {file = "SQLAlchemy-1.4.5-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:4df07161897191ed8d4a0cfc92425c81296160e5c5f76c9256716d3085172883"},
{file = "SQLAlchemy-1.4.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:7eba42098a13a3bcd509080b5e44d73783d9129ba0383793979bf518d01e8bb3"}, {file = "SQLAlchemy-1.4.5-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:ee4ddc904fb6414b5118af5b8d45e428aac2ccda01326b2ba2fe4354b0d8d1ae"},
{file = "SQLAlchemy-1.4.2-cp39-cp39-win32.whl", hash = "sha256:920db115eb06fc507fe2c774fb5c82a898b05dffbdadc7fafad51ce2cfd8c549"}, {file = "SQLAlchemy-1.4.5-cp39-cp39-win32.whl", hash = "sha256:2f11b5783933bff55291ca06496124347627d211ff2e509e846af1c35de0a3fb"},
{file = "SQLAlchemy-1.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:dcde5067a7dab1ff2eaea2f3622b2055c5225ce2aaf589c5a4c703d43519c4ba"}, {file = "SQLAlchemy-1.4.5-cp39-cp39-win_amd64.whl", hash = "sha256:0ee0054d4a598d2920cae14bcbd33e200e02c5e3b47b902627f8cf5d4c9a2a4b"},
{file = "SQLAlchemy-1.4.2.tar.gz", hash = "sha256:6a8e4c2e65028933a6dc8643c8f5a4f295a367131195b3c708634925cb3e8ec1"}, {file = "SQLAlchemy-1.4.5.tar.gz", hash = "sha256:1294f05916c044631fd626a4866326bbfbd17f62bd37510d000afaef4b35bd74"},
] ]
starlette = [ starlette = [
{file = "starlette-0.13.6-py3-none-any.whl", hash = "sha256:bd2ffe5e37fb75d014728511f8e68ebf2c80b0fa3d04ca1479f4dc752ae31ac9"}, {file = "starlette-0.13.6-py3-none-any.whl", hash = "sha256:bd2ffe5e37fb75d014728511f8e68ebf2c80b0fa3d04ca1479f4dc752ae31ac9"},

View File

@ -30,6 +30,8 @@ bcrypt = "^3.2.0"
python-jose = "^3.2.0" python-jose = "^3.2.0"
passlib = "^1.7.4" passlib = "^1.7.4"
lxml = "4.6.2" lxml = "4.6.2"
Pillow = "^8.2.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pylint = "^2.6.0" pylint = "^2.6.0"

View File

@ -89,3 +89,11 @@ def test_html_with_recipe_data():
assert url_validation_regex.match(recipe_data["image"]) assert url_validation_regex.match(recipe_data["image"])
assert len(recipe_data["recipeIngredient"]) == 13 assert len(recipe_data["recipeIngredient"]) == 13
assert len(recipe_data["recipeInstructions"]) == 4 assert len(recipe_data["recipeInstructions"]) == 4
def test_time_cleaner():
my_time_delta = "PT2H30M"
return_delta = Cleaner.time(my_time_delta)
assert return_delta == "2 Hours 30 Minutes"

View File

@ -4,19 +4,39 @@ import pytest
from mealie.core.config import CWD, DATA_DIR, AppDirectories, AppSettings, determine_data_dir, determine_secrets from mealie.core.config import CWD, DATA_DIR, AppDirectories, AppSettings, determine_data_dir, determine_secrets
def test_default_settings(monkeypatch):
monkeypatch.delenv("DEFAULT_GROUP", raising=False)
monkeypatch.delenv("DEFAULT_PASSWORD", raising=False)
monkeypatch.delenv("API_PORT", raising=False)
monkeypatch.delenv("API_DOCS", raising=False)
monkeypatch.delenv("DB_TYPE", raising=False)
monkeypatch.delenv("IS_DEMO", raising=False)
app_settings = AppSettings()
assert app_settings.DEFAULT_GROUP == "Home"
assert app_settings.DEFAULT_PASSWORD == "MyPassword"
assert app_settings.DATABASE_TYPE == "sqlite"
assert app_settings.API_PORT == 9000
assert app_settings.API_DOCS is True
assert app_settings.IS_DEMO is False
assert app_settings.REDOC_URL == "/redoc"
assert app_settings.DOCS_URL == "/docs"
def test_non_default_settings(monkeypatch): def test_non_default_settings(monkeypatch):
monkeypatch.setenv("DEFAULT_GROUP", "Test Group") monkeypatch.setenv("DEFAULT_GROUP", "Test Group")
monkeypatch.setenv("DEFAULT_PASSWORD", "Test Password") monkeypatch.setenv("DEFAULT_PASSWORD", "Test Password")
monkeypatch.setenv("API_PORT", "8000") monkeypatch.setenv("API_PORT", "8000")
monkeypatch.setenv("API_DOCS", False) monkeypatch.setenv("API_DOCS", 'False')
app_dirs = AppDirectories(CWD, DATA_DIR) app_settings = AppSettings()
app_settings = AppSettings(app_dirs)
assert app_settings.DEFAULT_GROUP == "Test Group" assert app_settings.DEFAULT_GROUP == "Test Group"
assert app_settings.DEFAULT_PASSWORD == "Test Password" assert app_settings.DEFAULT_PASSWORD == "Test Password"
assert app_settings.API_PORT == 8000 assert app_settings.API_PORT == 8000
assert app_settings.API is False assert app_settings.API_DOCS is False
assert app_settings.REDOC_URL is None assert app_settings.REDOC_URL is None
assert app_settings.DOCS_URL is None assert app_settings.DOCS_URL is None
@ -25,9 +45,8 @@ def test_non_default_settings(monkeypatch):
def test_unknown_database(monkeypatch): def test_unknown_database(monkeypatch):
monkeypatch.setenv("DB_TYPE", "nonsense") monkeypatch.setenv("DB_TYPE", "nonsense")
with pytest.raises(Exception, match="Unable to determine database type. Acceptible options are 'sqlite'"): with pytest.raises(ValueError, match="Unable to determine database type. Acceptible options are 'sqlite'"):
app_dirs = AppDirectories(CWD, DATA_DIR) AppSettings()
AppSettings(app_dirs)
def test_secret_generation(tmp_path): def test_secret_generation(tmp_path):

View File

@ -1,3 +1,4 @@
import shutil
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -36,4 +37,4 @@ def test_zip_extraction(file_name: str, final_path: Path):
def test_nextcloud_migration(recipe_dir: Path): def test_nextcloud_migration(recipe_dir: Path):
recipe = import_recipes(recipe_dir) recipe = import_recipes(recipe_dir)
assert isinstance(recipe, Recipe) 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)