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
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
WORKDIR /app/
COPY ./pyproject.toml /app/
RUN apk add --update --no-cache --virtual .build-deps \
curl \
g++ \

View File

@ -12,10 +12,11 @@ RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-
poetry config virtualenvs.create false
# Copy poetry.lock* in case it doesn't exist in the repo
COPY ./pyproject.toml ./poetry.lock* /app/
RUN poetry install
COPY ./pyproject.toml /app/
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.description }}

View File

@ -29,8 +29,8 @@ services:
db_type: sqlite
TZ: America/Anchorage # Specify Correct Timezone for Date/Time to line up correctly.
volumes:
- ./app_data:/app_data
- ./mealie:/app
- ./dev/data:/app/dev/data
- ./mealie:/app/mealie
# Mkdocs
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
### 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.
Have Ideas? Submit a PR!
#### 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!

View File

@ -23,6 +23,11 @@ docker run \
```
!!! tip "Default Credentials"
**Username:** changeme@email.com
**Password:** MyPassword
## 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.

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"
- Development Road Map: "roadmap.md"
- Change Log:
- v0.4.1 Frontend/UI: "changelog/v0.4.1.md"
- v0.4.0 Authentication: "changelog/v0.4.0.md"
- v0.3.0 Improvements: "changelog/v0.3.0.md"
- v0.2.0 Now With Tests!: "changelog/v0.2.0.md"

View File

@ -1,35 +1,6 @@
<template>
<v-app>
<v-app-bar 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 />
<LanguageMenu />
</v-app-bar>
<TheAppBar />
<v-main>
<v-banner v-if="demo" sticky
><div class="text-center">
@ -47,34 +18,25 @@
</template>
<script>
import SiteMenu from "@/components/UI/SiteMenu";
import SearchBar from "@/components/UI/Search/SearchBar";
import TheAppBar from "@/components/UI/TheAppBar";
import AddRecipeFab from "@/components/UI/AddRecipeFab";
import LanguageMenu from "@/components/UI/LanguageMenu";
import Vuetify from "./plugins/vuetify";
import { user } from "@/mixins/user";
import { api } from "./api";
export default {
name: "App",
components: {
SiteMenu,
TheAppBar,
AddRecipeFab,
SearchBar,
LanguageMenu,
},
mixins: [user],
watch: {
$route() {
this.search = false;
},
},
computed: {
isMobile() {
return this.$vuetify.breakpoint.name === "xs";
demo() {
const appInfo = this.$store.getters.getAppInfo;
return appInfo.demoStatus;
},
},
@ -96,15 +58,9 @@ export default {
this.$store.dispatch("requestTags");
this.darkModeSystemCheck();
this.darkModeAddEventListener();
const api_status = await api.meta.getIsDemo();
this.demo = api_status.demoStatus;
this.$store.dispatch("requestAppInfo");
},
data: () => ({
search: false,
demo: false,
}),
methods: {
// For Later!
@ -126,9 +82,6 @@ export default {
this.darkModeSystemCheck();
});
},
navigateFromSearch(slug) {
this.$router.push(`/recipe/${slug}`);
},
},
};
</script>
@ -160,6 +113,7 @@ export default {
.notify-base {
color: white !important;
/* min-height: 50px; */
margin-right: 60px;
margin-bottom: -5px;
opacity: 0.9 !important;
@ -176,11 +130,4 @@ export default {
*::-webkit-scrollbar-thumb {
background: grey;
}
.notify-base {
color: white !important;
margin-right: 60px;
margin-bottom: -5px;
opacity: 0.9 !important;
}
</style>

View File

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

View File

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

View File

@ -8,6 +8,7 @@ const prefix = baseURL + "recipes/";
const recipeURLs = {
allRecipes: baseURL + "recipes",
summary: baseURL + "recipes" + "/summary",
allRecipesByCategory: prefix + "category",
create: prefix + "create",
createByURL: prefix + "create-url",
@ -56,9 +57,7 @@ export const recipeAPI = {
const fd = new FormData();
fd.append("image", fileObject);
fd.append("extension", fileObject.name.split(".").pop());
let response = apiReq.put(recipeURLs.updateImage(recipeSlug), fd);
return response;
},
@ -87,4 +86,21 @@ export const recipeAPI = {
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 nav dense class="fixedBottom">
<v-list-item href="">
<v-list-item to="/admin/about">
<v-list-item-icon class="mr-3 pt-1">
<v-icon :color="newVersionAvailable ? 'red--text' : ''">
mdi-information
@ -83,10 +83,11 @@
<v-list-item-content>
<v-list-item-title>
{{ $t("settings.current") }}
{{ version }}
{{ appVersion }}
</v-list-item-title>
<v-list-item-subtitle>
<a
@click.prevent
href="https://github.com/hay-kot/mealie/releases/latest"
target="_blank"
:class="newVersionAvailable ? 'red--text' : 'green--text'"
@ -106,14 +107,12 @@
import { validators } from "@/mixins/validators";
import { initials } from "@/mixins/initials";
import { user } from "@/mixins/user";
import { api } from "@/api";
import axios from "axios";
export default {
mixins: [validators, initials, user],
data() {
return {
latestVersion: null,
version: null,
hideImage: false,
showSidebar: false,
mobile: false,
@ -163,8 +162,6 @@ export default {
this.mobile = this.viewScale();
this.showSidebar = !this.viewScale();
this.getVersion();
let versionData = await api.meta.get_version();
this.version = versionData.version;
},
computed: {
@ -172,7 +169,11 @@ export default {
return `api/users/${this.user.id}/image`;
},
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-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") }}
</v-btn>
<v-spacer></v-spacer>
@ -61,6 +66,7 @@
<script>
import ImportOptions from "@/components/Admin/Backup/ImportOptions";
import axios from "axios";
export default {
components: { ImportOptions },
props: {
@ -83,6 +89,7 @@ export default {
dialog: false,
forceImport: false,
rebaseImport: false,
downloading: false,
};
},
methods: {
@ -109,6 +116,23 @@ export default {
this.close();
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>

View File

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

View File

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

View File

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

View File

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

View File

@ -40,7 +40,13 @@
<v-btn color="error" text @click="deleteMigration(migration.name)">
{{ $t("general.delete") }}
</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") }}
</v-btn>
</v-card-actions>
@ -82,10 +88,10 @@ export default {
this.$emit("refresh");
},
async importMigration(file_name) {
this.loading == true;
this.loading = true;
let response = await api.migrations.import(this.folder, file_name);
this.$emit("imported", response.successful, response.failed);
this.loading == false;
this.loading = false;
},
readableTime(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-if="!options.isLoggingIn"
v-model="user.name"
light="light"
prepend-icon="person"
:label="$t('general.name')"
></v-text-field>
<v-text-field
v-model="user.email"
light="light"
prepend-icon="mdi-email"
validate-on-blur
:label="$t('user.email')"
@ -36,7 +34,6 @@
></v-text-field>
<v-text-field
v-model="user.password"
light="light"
class="mb-2s"
prepend-icon="mdi-lock"
:label="$t('user.password')"
@ -47,7 +44,6 @@
<v-card-actions>
<v-btn
v-if="options.isLoggingIn"
dark
color="primary"
block="block"
type="submit"

View File

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

View File

@ -1,8 +1,12 @@
<template>
<v-card>
<v-card-title class="headline">
<v-card-title class=" headline">
{{ $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-divider></v-divider>
<v-card-text>
<v-row dense>
@ -101,6 +105,7 @@ export default {
endDate: null,
menu1: false,
menu2: false,
usedRecipes: [1],
};
},
@ -115,17 +120,14 @@ export default {
});
}
},
groupSettings() {
this.buildMealStore();
},
},
async mounted() {
this.$store.dispatch("requestCurrentGroup");
await this.$store.dispatch("requestCurrentGroup");
await this.buildMealStore();
},
computed: {
groupSettings() {
console.log(this.$store.getters.getCurrentGroup);
return this.$store.getters.getCurrentGroup;
},
actualStartDate() {
@ -152,11 +154,15 @@ export default {
endComputedDateFormatted() {
return this.formatDate(this.endDate);
},
filteredRecipes() {
const recipes = this.items.filter(x => !this.usedRecipes.includes(x));
return recipes.length > 0 ? recipes : this.items;
},
},
methods: {
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);
if (this.items.length === 0) {
@ -171,15 +177,20 @@ export default {
this.items = await api.recipes.allByKeys(keys);
}
},
get_random(list) {
const object = list[Math.floor(Math.random() * list.length)];
return object;
getRandom(list) {
let recipe = 1;
while (this.usedRecipes.includes(recipe)) {
recipe = list[Math.floor(Math.random() * list.length)];
}
return recipe;
},
random() {
this.usedRecipes = [1];
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]["name"] = recipe.name;
this.usedRecipes.push(recipe);
});
},
processTime(index) {
@ -212,7 +223,7 @@ export default {
},
getImage(image) {
return utils.getImageURL(image);
return api.recipes.recipeSmallImage(image);
},
formatDate(date) {
@ -227,6 +238,33 @@ export default {
const [month, day, year] = date.split("/");
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>

View File

@ -1,8 +1,13 @@
<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-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-content class="align-self-start">
<v-list-item-title>
@ -20,7 +25,7 @@
</template>
<script>
import utils from "@/utils";
import { api } from "@/api";
export default {
props: {
name: String,
@ -35,7 +40,7 @@ export default {
methods: {
getImage(image) {
return utils.getImageURL(image);
return api.recipes.recipeSmallImage(image);
},
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" width="600px" height="0">
<div class="text-center ">
<v-dialog v-model="dialog" width="600px" height="0" :fullscreen="isMobile">
<v-card>
<v-app-bar dark color="primary">
<v-toolbar-title class="headline">Search a Recipe</v-toolbar-title>
@ -9,13 +9,27 @@
<SearchBar
@results="updateResults"
@selected="emitSelect"
:show-results="true"
:show-results="!isMobile"
max-width="550px"
:dense="false"
:nav-on-click="false"
:reset-search="dialog"
: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>
</v-dialog>
@ -24,16 +38,32 @@
<script>
import SearchBar from "./SearchBar";
import MobileRecipeCard from "@/components/Recipe/MobileRecipeCard";
export default {
components: {
SearchBar,
MobileRecipeCard,
},
data() {
return {
searchResults: null,
searchResults: [],
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: {
updateResults(results) {
this.searchResults = results;
@ -44,15 +74,22 @@ export default {
},
open() {
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>
<style>
.v-dialog__content {
margin-top: 10%;
<style scope>
.mobile-dialog {
align-items: flex-start;
justify-content: center;
justify-content: flex-start;
}
</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 {
emailRule: v =>
!v ||
/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(v) ||
/^[^@\s]+@[^@\s.]+.[^@.\s]+$/.test(v) ||
this.$t('user.e-mail-must-be-valid'),
existsRule: value => !!value || this.$t('general.field-required'),

View File

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

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@
<v-card v-else id="myRecipe">
<v-img
height="400"
:src="getImage(recipeDetails.image)"
:src="getImage(recipeDetails.slug)"
class="d-print-none"
:key="imageKey"
>
@ -71,7 +71,6 @@
<script>
import { api } from "@/api";
import utils from "@/utils";
import VJsoneditor from "v-jsoneditor";
import RecipeViewer from "@/components/Recipe/RecipeViewer";
import RecipeEditor from "@/components/Recipe/RecipeEditor";
@ -160,7 +159,7 @@ export default {
},
getImage(image) {
if (image) {
return utils.getImageURL(image) + "?rnd=" + this.imageKey;
return api.recipes.recipeImage(image) + "&rnd=" + this.imageKey;
}
},
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 ManageUsers from "@/pages/Admin/ManageUsers";
import Settings from "@/pages/Admin/Settings";
import About from "@/pages/Admin/About";
import { store } from "../store";
export default {
@ -50,5 +51,9 @@ export default {
path: "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 AllRecipes from "@/pages/Recipes/AllRecipes";
import CategoryPage from "@/pages/Recipes/CategoryPage";
import TagPage from "@/pages/Recipes/TagPage";
import Planner from "@/pages/MealPlan/Planner";
import Debug from "@/pages/Debug";
import LoginPage from "@/pages/LoginPage";
@ -33,7 +34,8 @@ export const routes = [
{ path: "/search", component: SearchPage },
{ path: "/recipes/all", component: AllRecipes },
{ 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: "/new/", component: NewRecipe },
{ path: "/meal-plan/planner", component: Planner },

View File

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

View File

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

View File

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

View File

@ -51,7 +51,7 @@ start_scheduler()
def main():
uvicorn.run(
"app:app",
host="0.0.0.0",

View File

@ -1,18 +1,18 @@
import os
import secrets
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"
CWD = Path(__file__).parent
BASE_DIR = CWD.parent.parent
ENV = BASE_DIR.joinpath(".env")
dotenv.load_dotenv(ENV)
PRODUCTION = os.environ.get("ENV")
PRODUCTION = os.getenv("ENV", "False").lower() in ["true", "1"]
def determine_data_dir(production: bool) -> Path:
@ -38,21 +38,26 @@ def determine_secrets(data_dir: Path, production: bool) -> str:
return new_secret
# General
DATA_DIR = determine_data_dir(PRODUCTION)
LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
class AppDirectories:
def __init__(self, cwd, data_dir) -> None:
self.DATA_DIR = data_dir
self.WEB_PATH = cwd.joinpath("dist")
self.IMG_DIR = data_dir.joinpath("img")
self.BACKUP_DIR = data_dir.joinpath("backups")
self.DEBUG_DIR = data_dir.joinpath("debug")
self.MIGRATION_DIR = data_dir.joinpath("migration")
self.NEXTCLOUD_DIR = self.MIGRATION_DIR.joinpath("nextcloud")
self.CHOWDOWN_DIR = self.MIGRATION_DIR.joinpath("chowdown")
self.TEMPLATE_DIR = data_dir.joinpath("templates")
self.USER_DIR = data_dir.joinpath("users")
self.SQLITE_DIR = data_dir.joinpath("db")
self.RECIPE_DATA_DIR = data_dir.joinpath("recipes")
self.TEMP_DIR = data_dir.joinpath(".temp")
self.DATA_DIR: Path = data_dir
self.WEB_PATH: Path = cwd.joinpath("dist")
self.IMG_DIR: Path = data_dir.joinpath("img")
self.BACKUP_DIR: Path = data_dir.joinpath("backups")
self.DEBUG_DIR: Path = data_dir.joinpath("debug")
self.MIGRATION_DIR: Path = data_dir.joinpath("migration")
self.NEXTCLOUD_DIR: Path = self.MIGRATION_DIR.joinpath("nextcloud")
self.CHOWDOWN_DIR: Path = self.MIGRATION_DIR.joinpath("chowdown")
self.TEMPLATE_DIR: Path = data_dir.joinpath("templates")
self.USER_DIR: Path = data_dir.joinpath("users")
self.SQLITE_DIR: Path = data_dir.joinpath("db")
self.RECIPE_DATA_DIR: Path = data_dir.joinpath("recipes")
self.TEMP_DIR: Path = data_dir.joinpath(".temp")
self.ensure_directories()
@ -74,36 +79,51 @@ class AppDirectories:
dir.mkdir(parents=True, exist_ok=True)
class AppSettings:
def __init__(self, app_dirs: AppDirectories) -> None:
global DB_VERSION
self.PRODUCTION = bool(os.environ.get("ENV"))
self.IS_DEMO = os.getenv("DEMO", "False") == "True"
self.API_PORT = int(os.getenv("API_PORT", 9000))
self.API = os.getenv("API_DOCS", "True") == "True"
self.DOCS_URL = "/docs" if self.API else None
self.REDOC_URL = "/redoc" if self.API else None
self.SECRET = determine_secrets(app_dirs.DATA_DIR, self.PRODUCTION)
self.DATABASE_TYPE = os.getenv("DB_TYPE", "sqlite")
# Used to Set SQLite File Version
self.SQLITE_FILE = None
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")
self.DEFAULT_PASSWORD = os.getenv("DEFAULT_PASSWORD", "MyPassword")
# Not Used!
self.SFTP_USERNAME = os.getenv("SFTP_USERNAME", None)
self.SFTP_PASSWORD = os.getenv("SFTP_PASSWORD", None)
# General
DATA_DIR = determine_data_dir(PRODUCTION)
LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
app_dirs = AppDirectories(CWD, DATA_DIR)
settings = AppSettings(app_dirs)
class AppSettings(BaseSettings):
global DATA_DIR
PRODUCTION: bool = Field(False, env="ENV")
IS_DEMO: bool = False
API_PORT: int = 9000
API_DOCS: bool = True
@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
SQLITE_FILE: Optional[Union[str, Path]]
@validator("SQLITE_FILE", pre=True)
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!
SFTP_USERNAME: Optional[str]
SFTP_PASSWORD: Optional[str]
class Config:
env_file = BASE_DIR.joinpath(".env")
env_file_encoding = "utf-8"
settings = AppSettings()

View File

@ -9,7 +9,8 @@ from mealie.db.models.users import User
from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse
from mealie.schema.meal import MealPlanInDB
from mealie.schema.recipe import Recipe
from mealie.schema.settings import CustomPageOut, SiteSettings as SiteSettingsSchema
from mealie.schema.settings import CustomPageOut
from mealie.schema.settings import SiteSettings as SiteSettingsSchema
from mealie.schema.sign_up import SignUpOut
from mealie.schema.theme import SiteTheme
from mealie.schema.user import GroupInDB, UserInDB

View File

@ -15,17 +15,10 @@ class BaseDocument:
self.schema: BaseModel
# 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 [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
return [eff_schema.from_orm(x) for x in session.query(self.sql_model).limit(limit).all()]
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

View File

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

View File

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

View File

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

View File

@ -3,20 +3,35 @@ import json
from fastapi import APIRouter, Depends
from mealie.core.config import APP_VERSION, LOGGER_FILE, app_dirs, settings
from mealie.routes.deps import get_current_user
from mealie.schema.debug import AppInfo, DebugInfo
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")
async def get_mealie_version(current_user=Depends(get_current_user)):
async def get_mealie_version():
""" Returns the current version of mealie"""
return {"version": APP_VERSION}
@router.get("/is-demo")
async def get_demo_status():
print(settings.IS_DEMO)
return {"demoStatus": settings.IS_DEMO}
return AppInfo(
version=APP_VERSION,
demo_status=settings.IS_DEMO,
production=settings.PRODUCTION,
)
@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.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.meal import MealPlanIn, MealPlanInDB
from mealie.schema.snackbar import SnackResponse
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 sqlalchemy.orm.session import Session
from starlette.responses import FileResponse
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)
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 mealie.db.database import db
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 sqlalchemy.orm.session import Session
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")
def get_all_recipes(
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.responses import FileResponse
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.schema.recipe import Recipe, RecipeURLIn
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 sqlalchemy.orm.session import Session
@ -59,6 +61,9 @@ def update_recipe(
recipe: Recipe = db.recipes.update(session, recipe_slug, data.dict())
if recipe_slug != recipe.slug:
rename_image(original_slug=recipe_slug, new_slug=recipe.slug)
return recipe.slug
@ -72,20 +77,34 @@ def delete_recipe(
try:
db.recipes.delete(session, recipe_slug)
delete_image(recipe_slug)
except:
raise HTTPException(status_code=404, detail=SnackResponse.error("Unable to Delete Recipe"))
return SnackResponse.error(f"Recipe {recipe_slug} Deleted")
class ImageType(str, Enum):
original = "original"
small = "small"
tiny = "tiny"
@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 """
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:
return FileResponse(recipe_image)
else:
return
raise HTTPException(404, "file not found")
@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.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.category import RecipeTagResponse, TagIn
from mealie.schema.snackbar import SnackResponse
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 """
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)):
""" Returns a list of recipes associated with the provided 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.responses import FileResponse
from mealie.core import security
from mealie.core.config import settings, app_dirs
from mealie.core.config import app_dirs, settings
from mealie.core.security import get_password_hash, verify_password
from mealie.db.database import db
from mealie.db.db_setup import generate_session

View File

@ -1,13 +1,32 @@
#!/bin/sh
# Initialize Database Prerun
python mealie/db/init_db.py
# Get Reload Arg `run.sh reload` for dev server
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
# Migrations
# Set Port from ENV Variable
## Web Server
caddy start --config ./Caddyfile
if [[ "$ARG1" = "reload" ]]
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": [{}]}}
class TagIn(CategoryIn):
pass
class TagBase(CategoryBase):
pass
class RecipeTagResponse(TagBase):
class RecipeTagResponse(RecipeCategoryResponse):
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
class Recipe(BaseModel):
class RecipeSummary(BaseModel):
name: str
description: Optional[str]
slug: Optional[str] = ""
image: Optional[Any]
recipeYield: Optional[str]
description: Optional[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]]
recipeInstructions: Optional[list[RecipeStep]]
nutrition: Optional[Nutrition]
@ -50,11 +68,8 @@ class Recipe(BaseModel):
performTime: Optional[str] = None
# Mealie Specific
slug: Optional[str] = ""
tags: Optional[List[str]] = []
dateAdded: Optional[datetime.date]
notes: Optional[List[RecipeNote]] = []
rating: Optional[int]
orgURL: Optional[str]
extras: Optional[dict] = {}

View File

@ -65,8 +65,7 @@ class ExportDatabase:
f.write(content)
def export_images(self):
for file in app_dirs.IMG_DIR.iterdir():
shutil.copy(file, self.img_dir.joinpath(file.name))
shutil.copytree(app_dirs.IMG_DIR, self.img_dir, dirs_exist_ok=True)
def export_items(self, items: list[BaseModel], folder_name: str, export_list=True):
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.theme import SiteTheme
from mealie.schema.user import UpdateGroup, UserInDB
from mealie.services.image import minify
from pydantic.main import BaseModel
from sqlalchemy.orm.session import Session
@ -108,7 +109,13 @@ class ImportDatabase:
image_dir = self.import_dir.joinpath("images")
for image in image_dir.iterdir():
if image.stem in successful_imports:
shutil.copy(image, app_dirs.IMG_DIR)
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)
minify.migrate_images()
def import_themes(self):
themes_file = self.import_dir.joinpath("themes", "themes.json")

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:
Recipe: Pydantic Recipe Object
"""
session = session if session else create_session()
session = session or create_session()
if isinstance(group, int):
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.db.database import db
from mealie.schema.recipe import Recipe
from mealie.services.image.minify import migrate_images
from mealie.utils.unzip import unpack_zip
from sqlalchemy.orm.session import Session
@ -89,4 +90,5 @@ def chowdown_migrate(session: Session, zip_file: Path):
failed_images.append(image.name)
report = {"successful": successful_recipes, "failed": failed_recipes}
migrate_images()
return report

View File

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

View File

@ -1,6 +1,6 @@
import html
import re
from datetime import datetime
from datetime import datetime, timedelta
from typing import List
from slugify import slugify
@ -10,8 +10,6 @@ class Cleaner:
"""A Namespace for utility function to clean recipe data extracted
from a url and returns a dictionary that is ready for import into
the database. Cleaner.clean is the main entrypoint
"""
@staticmethod
@ -44,7 +42,7 @@ class Cleaner:
@staticmethod
def category(category: str):
if isinstance(category, str):
if isinstance(category, str) and category != "":
return [category]
else:
return []
@ -144,7 +142,100 @@ class Cleaner:
def time(time_entry):
if time_entry is None:
return None
elif isinstance(time_entry, timedelta):
pretty_print_timedelta(time_entry)
elif isinstance(time_entry, datetime):
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:
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
from mealie.core.config import app_dirs
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.services.scraper import open_graph
from mealie.services.scraper.cleaner import Cleaner

189
poetry.lock generated
View File

@ -50,7 +50,7 @@ zookeeper = ["kazoo"]
[[package]]
name = "astroid"
version = "2.5.1"
version = "2.5.2"
description = "An abstract syntax tree for Python with inference support."
category = "dev"
optional = false
@ -191,11 +191,11 @@ toml = ["toml"]
[[package]]
name = "decorator"
version = "4.4.2"
version = "5.0.4"
description = "Decorators for Humans"
category = "main"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*"
python-versions = ">=3.5"
[[package]]
name = "ecdsa"
@ -412,11 +412,11 @@ python-versions = "*"
[[package]]
name = "lazy-object-proxy"
version = "1.5.2"
version = "1.6.0"
description = "A fast and thorough lazy object proxy."
category = "dev"
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]]
name = "livereload"
@ -606,6 +606,14 @@ category = "dev"
optional = false
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]]
name = "pluggy"
version = "0.13.1"
@ -690,14 +698,14 @@ python-versions = "*"
[[package]]
name = "pylint"
version = "2.7.2"
version = "2.7.4"
description = "python code static checker"
category = "dev"
optional = false
python-versions = "~=3.6"
[package.dependencies]
astroid = ">=2.5.1,<2.6"
astroid = ">=2.5.2,<2.7"
colorama = {version = "*", markers = "sys_platform == \"win32\""}
isort = ">=4.2.5,<6"
mccabe = ">=0.6,<0.7"
@ -943,7 +951,7 @@ python-versions = ">=3.6"
[[package]]
name = "sqlalchemy"
version = "1.4.2"
version = "1.4.5"
description = "Database Abstraction Library"
category = "main"
optional = false
@ -954,6 +962,7 @@ greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\""}
[package.extras]
aiomysql = ["greenlet (!=0.4.17)", "aiomysql"]
aiosqlite = ["greenlet (!=0.4.17)", "aiosqlite"]
asyncio = ["greenlet (!=0.4.17)"]
mariadb_connector = ["mariadb (>=1.0.1)"]
mssql = ["pyodbc"]
@ -969,6 +978,7 @@ postgresql_pg8000 = ["pg8000 (>=1.16.6)"]
postgresql_psycopg2binary = ["psycopg2-binary"]
postgresql_psycopg2cffi = ["psycopg2cffi"]
pymysql = ["pymysql (<1)", "pymysql"]
sqlcipher = ["sqlcipher3-binary"]
[[package]]
name = "starlette"
@ -1154,7 +1164,7 @@ python-versions = "*"
[metadata]
lock-version = "1.1"
python-versions = "^3.9"
content-hash = "a6c10e179bc15efc30627c9793218bb944f43dce5e624a7bcabcc47545e661e8"
content-hash = "a81463b941cfdbc0e32e215644b172ec1111d5ada27864292d299d7d64fae4cf"
[metadata.files]
aiofiles = [
@ -1174,8 +1184,8 @@ apscheduler = [
{file = "APScheduler-3.7.0.tar.gz", hash = "sha256:1cab7f2521e107d07127b042155b632b7a1cd5e02c34be5a28ff62f77c900c6a"},
]
astroid = [
{file = "astroid-2.5.1-py3-none-any.whl", hash = "sha256:21d735aab248253531bb0f1e1e6d068f0ee23533e18ae8a6171ff892b98297cf"},
{file = "astroid-2.5.1.tar.gz", hash = "sha256:cfc35498ee64017be059ceffab0a25bedf7548ab76f2bea691c5565896e7128d"},
{file = "astroid-2.5.2-py3-none-any.whl", hash = "sha256:cd80bf957c49765dce6d92c43163ff9d2abc43132ce64d4b1b47717c6d2522df"},
{file = "astroid-2.5.2.tar.gz", hash = "sha256:6b0ed1af831570e500e2437625979eaa3b36011f66ddfc4ce930128610258ca9"},
]
atomicwrites = [
{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"},
]
decorator = [
{file = "decorator-4.4.2-py2.py3-none-any.whl", hash = "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760"},
{file = "decorator-4.4.2.tar.gz", hash = "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"},
{file = "decorator-5.0.4-py3-none-any.whl", hash = "sha256:7280eff5351d7004144b1f302347328c3d06e84271dbe690a5dc4b17eb586994"},
{file = "decorator-5.0.4.tar.gz", hash = "sha256:cdd9d86d8aca11e4496f3cd26d48020db5a2fac247af0e918b3e0bbdb6e4a174"},
]
ecdsa = [
{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"},
]
lazy-object-proxy = [
{file = "lazy-object-proxy-1.5.2.tar.gz", hash = "sha256:5944a9b95e97de1980c65f03b79b356f30a43de48682b8bdd90aa5089f0ec1f4"},
{file = "lazy_object_proxy-1.5.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:e960e8be509e8d6d618300a6c189555c24efde63e85acaf0b14b2cd1ac743315"},
{file = "lazy_object_proxy-1.5.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:522b7c94b524389f4a4094c4bf04c2b02228454ddd17c1a9b2801fac1d754871"},
{file = "lazy_object_proxy-1.5.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:3782931963dc89e0e9a0ae4348b44762e868ea280e4f8c233b537852a8996ab9"},
{file = "lazy_object_proxy-1.5.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:429c4d1862f3fc37cd56304d880f2eae5bd0da83bdef889f3bd66458aac49128"},
{file = "lazy_object_proxy-1.5.2-cp35-cp35m-win32.whl", hash = "sha256:cd1bdace1a8762534e9a36c073cd54e97d517a17d69a17985961265be6d22847"},
{file = "lazy_object_proxy-1.5.2-cp35-cp35m-win_amd64.whl", hash = "sha256:ddbdcd10eb999d7ab292677f588b658372aadb9a52790f82484a37127a390108"},
{file = "lazy_object_proxy-1.5.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ecb5dd5990cec6e7f5c9c1124a37cb2c710c6d69b0c1a5c4aa4b35eba0ada068"},
{file = "lazy_object_proxy-1.5.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:b6577f15d5516d7d209c1a8cde23062c0f10625f19e8dc9fb59268859778d7d7"},
{file = "lazy_object_proxy-1.5.2-cp36-cp36m-win32.whl", hash = "sha256:c8fe2d6ff0ff583784039d0255ea7da076efd08507f2be6f68583b0da32e3afb"},
{file = "lazy_object_proxy-1.5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:fa5b2dee0e231fa4ad117be114251bdfe6afe39213bd629d43deb117b6a6c40a"},
{file = "lazy_object_proxy-1.5.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1d33d6f789697f401b75ce08e73b1de567b947740f768376631079290118ad39"},
{file = "lazy_object_proxy-1.5.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:57fb5c5504ddd45ed420b5b6461a78f58cbb0c1b0cbd9cd5a43ad30a4a3ee4d0"},
{file = "lazy_object_proxy-1.5.2-cp37-cp37m-win32.whl", hash = "sha256:e7273c64bccfd9310e9601b8f4511d84730239516bada26a0c9846c9697617ef"},
{file = "lazy_object_proxy-1.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6f4e5e68b7af950ed7fdb594b3f19a0014a3ace0fedb86acb896e140ffb24302"},
{file = "lazy_object_proxy-1.5.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:cadfa2c2cf54d35d13dc8d231253b7985b97d629ab9ca6e7d672c35539d38163"},
{file = "lazy_object_proxy-1.5.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:e7428977763150b4cf83255625a80a23dfdc94d43be7791ce90799d446b4e26f"},
{file = "lazy_object_proxy-1.5.2-cp38-cp38-win32.whl", hash = "sha256:2f2de8f8ac0be3e40d17730e0600619d35c78c13a099ea91ef7fb4ad944ce694"},
{file = "lazy_object_proxy-1.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:38c3865bd220bd983fcaa9aa11462619e84a71233bafd9c880f7b1cb753ca7fa"},
{file = "lazy_object_proxy-1.5.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:8a44e9901c0555f95ac401377032f6e6af66d8fc1fbfad77a7a8b1a826e0b93c"},
{file = "lazy_object_proxy-1.5.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fa7fb7973c622b9e725bee1db569d2c2ee64d2f9a089201c5e8185d482c7352d"},
{file = "lazy_object_proxy-1.5.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:71a1ef23f22fa8437974b2d60fedb947c99a957ad625f83f43fd3de70f77f458"},
{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"},
{file = "lazy-object-proxy-1.6.0.tar.gz", hash = "sha256:489000d368377571c6f982fba6497f2aa13c6d1facc40660963da62f5c379726"},
{file = "lazy_object_proxy-1.6.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c6938967f8528b3668622a9ed3b31d145fab161a32f5891ea7b84f6b790be05b"},
{file = "lazy_object_proxy-1.6.0-cp27-cp27m-win32.whl", hash = "sha256:ebfd274dcd5133e0afae738e6d9da4323c3eb021b3e13052d8cbd0e457b1256e"},
{file = "lazy_object_proxy-1.6.0-cp27-cp27m-win_amd64.whl", hash = "sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93"},
{file = "lazy_object_proxy-1.6.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d900d949b707778696fdf01036f58c9876a0d8bfe116e8d220cfd4b15f14e741"},
{file = "lazy_object_proxy-1.6.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5743a5ab42ae40caa8421b320ebf3a998f89c85cdc8376d6b2e00bd12bd1b587"},
{file = "lazy_object_proxy-1.6.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:bf34e368e8dd976423396555078def5cfc3039ebc6fc06d1ae2c5a65eebbcde4"},
{file = "lazy_object_proxy-1.6.0-cp36-cp36m-win32.whl", hash = "sha256:b579f8acbf2bdd9ea200b1d5dea36abd93cabf56cf626ab9c744a432e15c815f"},
{file = "lazy_object_proxy-1.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:4f60460e9f1eb632584c9685bccea152f4ac2130e299784dbaf9fae9f49891b3"},
{file = "lazy_object_proxy-1.6.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d7124f52f3bd259f510651450e18e0fd081ed82f3c08541dffc7b94b883aa981"},
{file = "lazy_object_proxy-1.6.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:22ddd618cefe54305df49e4c069fa65715be4ad0e78e8d252a33debf00f6ede2"},
{file = "lazy_object_proxy-1.6.0-cp37-cp37m-win32.whl", hash = "sha256:9d397bf41caad3f489e10774667310d73cb9c4258e9aed94b9ec734b34b495fd"},
{file = "lazy_object_proxy-1.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:24a5045889cc2729033b3e604d496c2b6f588c754f7a62027ad4437a7ecc4837"},
{file = "lazy_object_proxy-1.6.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:17e0967ba374fc24141738c69736da90e94419338fd4c7c7bef01ee26b339653"},
{file = "lazy_object_proxy-1.6.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:410283732af311b51b837894fa2f24f2c0039aa7f220135192b38fcc42bd43d3"},
{file = "lazy_object_proxy-1.6.0-cp38-cp38-win32.whl", hash = "sha256:85fb7608121fd5621cc4377a8961d0b32ccf84a7285b4f1d21988b2eae2868e8"},
{file = "lazy_object_proxy-1.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:d1c2676e3d840852a2de7c7d5d76407c772927addff8d742b9808fe0afccebdf"},
{file = "lazy_object_proxy-1.6.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b865b01a2e7f96db0c5d12cfea590f98d8c5ba64ad222300d93ce6ff9138bcad"},
{file = "lazy_object_proxy-1.6.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:4732c765372bd78a2d6b2150a6e99d00a78ec963375f236979c0626b97ed8e43"},
{file = "lazy_object_proxy-1.6.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:9698110e36e2df951c7c36b6729e96429c9c32b3331989ef19976592c5f3c77a"},
{file = "lazy_object_proxy-1.6.0-cp39-cp39-win32.whl", hash = "sha256:1fee665d2638491f4d6e55bd483e15ef21f6c8c2095f235fef72601021e64f61"},
{file = "lazy_object_proxy-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b"},
]
livereload = [
{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.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 = [
{file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
{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"},
]
pylint = [
{file = "pylint-2.7.2-py3-none-any.whl", hash = "sha256:d09b0b07ba06bcdff463958f53f23df25e740ecd81895f7d2699ec04bbd8dc3b"},
{file = "pylint-2.7.2.tar.gz", hash = "sha256:0e21d3b80b96740909d77206d741aa3ce0b06b41be375d92e1f3244a274c1f8a"},
{file = "pylint-2.7.4-py3-none-any.whl", hash = "sha256:209d712ec870a0182df034ae19f347e725c1e615b2269519ab58a35b3fcbbe7a"},
{file = "pylint-2.7.4.tar.gz", hash = "sha256:bd38914c7731cdc518634a8d3c5585951302b6e2b6de60fbb3f7a0220e21eeee"},
]
pymdown-extensions = [
{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"},
]
sqlalchemy = [
{file = "SQLAlchemy-1.4.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:aed22be55a608787bb6875dbcf3561349a0e88fe33fd88c318c1e5b4eeb2306a"},
{file = "SQLAlchemy-1.4.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:7e1b0ed6d720750f02333d2f52502dfc2a23185aacc2cc6ce6ec29d28c21397c"},
{file = "SQLAlchemy-1.4.2-cp27-cp27m-win32.whl", hash = "sha256:9406b96a979ab8d6de5d89f58b1f103c9aeef6fb5367448537a8228619f11258"},
{file = "SQLAlchemy-1.4.2-cp27-cp27m-win_amd64.whl", hash = "sha256:59ec279f1bd55e1d703e3d4b651600cc463cc3eafa8d8e5a70ab844f736348d4"},
{file = "SQLAlchemy-1.4.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8cfcfcf2582b19c874fa20d0b75100abe17be80a4c637c0683b4eb919946dfee"},
{file = "SQLAlchemy-1.4.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:a6b4b7688fe7d251bbae3f9da4a487568bd584d13201bc7591c8639ad01fecdc"},
{file = "SQLAlchemy-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0abab6d1044198993256f073340b14c459736777c550a7e914cd00444dcf9c30"},
{file = "SQLAlchemy-1.4.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:5fb8f6a391992dd6aafe4fdf1dffbf7934fba1f5938593f20b152aa7f9619f82"},
{file = "SQLAlchemy-1.4.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:97e333260a99d989f2a131aa8aa74140636dfbd030987150cb3748da607ea7db"},
{file = "SQLAlchemy-1.4.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:3fa75c854dba3f9b9c28bc5d88d246f6bc6f20b7480367c65339bcb2864d4707"},
{file = "SQLAlchemy-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:1b9f3c7b281aa1c3d0c74ef12c4633e5f8358bb94f01be7b964887183fd53e5e"},
{file = "SQLAlchemy-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:da72e3499bde4548e8b7d7f2ab23ceed09a5bac307bf51057e066c406a0ba2e1"},
{file = "SQLAlchemy-1.4.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:8383292298bb85d7ad79a13c6571aff213b96c49737f3c3af129de63bbfb42c9"},
{file = "SQLAlchemy-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:4d1447183356c9679853926e81c7ebce3fbca9b1c607ea439975298c72137a36"},
{file = "SQLAlchemy-1.4.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:ff76d7dbf33f62e30e5a1d1b095d46afcdc49e42cbe33ce12014110147466700"},
{file = "SQLAlchemy-1.4.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:1ba6922331b3f38e116c9266206b044baf64576e5cebd87917b5ad872d7a025f"},
{file = "SQLAlchemy-1.4.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:d3b2819f4d7ae56191efc6fc456eb1805ada2bd5ba93d918893bc24fa7a1e30c"},
{file = "SQLAlchemy-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:3b290ff34de625143a05d2d172a88a064bb04a7938265b09d4e4bf45f21948f6"},
{file = "SQLAlchemy-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:5289cafee71037f15feeeaf736f01910b9e3572525b73b201bdd21816db010ed"},
{file = "SQLAlchemy-1.4.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:0bb04fd7414718fb1f4dfa17efcb0be787363451cf99a5e992728925d298d9ae"},
{file = "SQLAlchemy-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:4e88549a5e58ba8c80c5ea071ac3b4e590236672a882bb80f56da4afcee45d96"},
{file = "SQLAlchemy-1.4.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:edec945ed57d11a1123657e4066f0bf747aaa93c8a65ec1c2c98172d1f2a9b7d"},
{file = "SQLAlchemy-1.4.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:06125670280111e39014af87f14d74599fd4b39a512c74f1a10e21e5626eb158"},
{file = "SQLAlchemy-1.4.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:e1692bdf1b95c97caab1201773a4576f59627997f598d30bdadc50dd9f897fec"},
{file = "SQLAlchemy-1.4.2-cp38-cp38-win32.whl", hash = "sha256:65c4df9517da9cce2c1255282d3e39f2afbc3a02deba60d99b0a3283ae80ec0b"},
{file = "SQLAlchemy-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:c6197c88ad53c31f58de5a8180936b8ef027356e788cd5f6514b3439d3d897ac"},
{file = "SQLAlchemy-1.4.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:6d6115edf1297bfa58994986ffe0dff21af18f0cba51dfa6d1769aa8a277be32"},
{file = "SQLAlchemy-1.4.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:facacaea95e0822f7bbeaa6909b30b2836b14cff8790209d52a0c866e240b673"},
{file = "SQLAlchemy-1.4.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:6e517126d3bc13d455826befdc35a89f82f01d163848f68db02caa80d25433fc"},
{file = "SQLAlchemy-1.4.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:09b08eb1bea621e47c2b0fcb0334fcbb00e1da2a3c2d45a98e56cd072b840719"},
{file = "SQLAlchemy-1.4.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:7eba42098a13a3bcd509080b5e44d73783d9129ba0383793979bf518d01e8bb3"},
{file = "SQLAlchemy-1.4.2-cp39-cp39-win32.whl", hash = "sha256:920db115eb06fc507fe2c774fb5c82a898b05dffbdadc7fafad51ce2cfd8c549"},
{file = "SQLAlchemy-1.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:dcde5067a7dab1ff2eaea2f3622b2055c5225ce2aaf589c5a4c703d43519c4ba"},
{file = "SQLAlchemy-1.4.2.tar.gz", hash = "sha256:6a8e4c2e65028933a6dc8643c8f5a4f295a367131195b3c708634925cb3e8ec1"},
{file = "SQLAlchemy-1.4.5-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c3810ebcf1d42c532c8f5c3f442c705d94442a27a32f2df5344f0857306ab321"},
{file = "SQLAlchemy-1.4.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:7481f9c2c832a3bf37c80bee44d91ac9938b815cc06f7e795b976e300914aab9"},
{file = "SQLAlchemy-1.4.5-cp27-cp27m-win32.whl", hash = "sha256:94040a92b6676f9ffdab6c6b479b3554b927a635c90698c761960b266b04fc88"},
{file = "SQLAlchemy-1.4.5-cp27-cp27m-win_amd64.whl", hash = "sha256:02b039e0e7e6de2f15ea2d2de3995e31a170e700ec0b37b4eded662171711d19"},
{file = "SQLAlchemy-1.4.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:f16801795f1ffe9472360589a04301018c79e4582a85e68067275bb4f765e4e2"},
{file = "SQLAlchemy-1.4.5-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:82f11b679df91275788be6734dd4a9dfa29bac67b85326992609f62b05bdab37"},
{file = "SQLAlchemy-1.4.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:a08027ae84efc563f0f2f341dda572eadebeca38c0ae028a009988f27e9e6230"},
{file = "SQLAlchemy-1.4.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:70a1387396ea5b3022539b560c287daf79403d8b4b365f89b56d660e625a4457"},
{file = "SQLAlchemy-1.4.5-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:4f7ce3bfdab6520554af4a5b1df4513d45388624d015ba4d921daf48ce1d6503"},
{file = "SQLAlchemy-1.4.5-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:08943201a1e3c6238e48f4d5d56c27ea1e1b39d3d9f36a9d81fc3cfb0e1b83bd"},
{file = "SQLAlchemy-1.4.5-cp36-cp36m-win32.whl", hash = "sha256:fbb0fda1c574975807aceb0e2332e0ecfe9e5656c191ed482c1a5eafe7a33823"},
{file = "SQLAlchemy-1.4.5-cp36-cp36m-win_amd64.whl", hash = "sha256:8d6a9feb5efd2fdab25c6d5a0a5589fed9d789f5ec57ec12263fd0e60ce1dea6"},
{file = "SQLAlchemy-1.4.5-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:c22bfac8d3b955cdb13f0fcd6343156bf56d925196cf7d9ab9ce9f61d3f1e11c"},
{file = "SQLAlchemy-1.4.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:7c0c7bb49167ac738ca6ee6e7f94a9988a7e4e261d8da335341e8c8c8f3b2e9b"},
{file = "SQLAlchemy-1.4.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:344b58b4b4193b72e8b768a51ef6eb5a4c948ce313a0f23e2ea081e71ce8ac0e"},
{file = "SQLAlchemy-1.4.5-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:48540072f43b3c080159ec1f24a4b014c0ee83d3b73795399974aa358a8cf71b"},
{file = "SQLAlchemy-1.4.5-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:81badd7d3e0e6aba70a5d1b50fabe8112e9835a6fdb0684054c3fe5378ce0d01"},
{file = "SQLAlchemy-1.4.5-cp37-cp37m-win32.whl", hash = "sha256:a103294583383660d9e06dbd82037dc8e94c184bdcb27b2be44ae4457dafc6b4"},
{file = "SQLAlchemy-1.4.5-cp37-cp37m-win_amd64.whl", hash = "sha256:5361e25181b9872d6906c8c9be7dc05cb0a0951d71ee59ee5a71c1deb301b8a8"},
{file = "SQLAlchemy-1.4.5-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:7f5087104c3c5af11ea59e49ae66c33ca98b14a47d3796ae97498fca53f84aef"},
{file = "SQLAlchemy-1.4.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:11e7a86209f69273e75d2dd64b06c0c2660e39cd942fce2170515c404ed7358a"},
{file = "SQLAlchemy-1.4.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:8301ecf3e819eb5dbc171e84654ff60872807775301a55fe35b0ab2ba3742031"},
{file = "SQLAlchemy-1.4.5-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:44e11a06168782b6d485daef197783366ce7ab0d5eea0066c899ae06cef47bbc"},
{file = "SQLAlchemy-1.4.5-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:6f8fdad2f335d2f3ca2f3ee3b01404f7abcf519b03de2c510f1f42d16e39ffb4"},
{file = "SQLAlchemy-1.4.5-cp38-cp38-win32.whl", hash = "sha256:f62c57ceadedeb8e7b98b48ac4d684bf2b0f73b9d882fed3ca260d9aedf6403f"},
{file = "SQLAlchemy-1.4.5-cp38-cp38-win_amd64.whl", hash = "sha256:301d0cd6ef1dc73b607748183da857e712d6f743de8d92b1e1f8facfb0ba2aa2"},
{file = "SQLAlchemy-1.4.5-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:915d4fa08776c0252dc5a34fa15c6490f66f411ea1ac9492022f98875d6baf20"},
{file = "SQLAlchemy-1.4.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7de84feb31af3d8fdf819cac2042928d0b60d3cb16f49c4b2f48d88db46e79f6"},
{file = "SQLAlchemy-1.4.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:45b091ccbf94374ed14abde17e9a04522b0493a17282eaaf4383efdd413f5243"},
{file = "SQLAlchemy-1.4.5-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:4df07161897191ed8d4a0cfc92425c81296160e5c5f76c9256716d3085172883"},
{file = "SQLAlchemy-1.4.5-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:ee4ddc904fb6414b5118af5b8d45e428aac2ccda01326b2ba2fe4354b0d8d1ae"},
{file = "SQLAlchemy-1.4.5-cp39-cp39-win32.whl", hash = "sha256:2f11b5783933bff55291ca06496124347627d211ff2e509e846af1c35de0a3fb"},
{file = "SQLAlchemy-1.4.5-cp39-cp39-win_amd64.whl", hash = "sha256:0ee0054d4a598d2920cae14bcbd33e200e02c5e3b47b902627f8cf5d4c9a2a4b"},
{file = "SQLAlchemy-1.4.5.tar.gz", hash = "sha256:1294f05916c044631fd626a4866326bbfbd17f62bd37510d000afaef4b35bd74"},
]
starlette = [
{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"
passlib = "^1.7.4"
lxml = "4.6.2"
Pillow = "^8.2.0"
[tool.poetry.dev-dependencies]
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 len(recipe_data["recipeIngredient"]) == 13
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
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):
monkeypatch.setenv("DEFAULT_GROUP", "Test Group")
monkeypatch.setenv("DEFAULT_PASSWORD", "Test Password")
monkeypatch.setenv("API_PORT", "8000")
monkeypatch.setenv("API_DOCS", False)
monkeypatch.setenv("API_DOCS", 'False')
app_dirs = AppDirectories(CWD, DATA_DIR)
app_settings = AppSettings(app_dirs)
app_settings = AppSettings()
assert app_settings.DEFAULT_GROUP == "Test Group"
assert app_settings.DEFAULT_PASSWORD == "Test Password"
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.DOCS_URL is None
@ -25,9 +45,8 @@ def test_non_default_settings(monkeypatch):
def test_unknown_database(monkeypatch):
monkeypatch.setenv("DB_TYPE", "nonsense")
with pytest.raises(Exception, match="Unable to determine database type. Acceptible options are 'sqlite'"):
app_dirs = AppDirectories(CWD, DATA_DIR)
AppSettings(app_dirs)
with pytest.raises(ValueError, match="Unable to determine database type. Acceptible options are 'sqlite'"):
AppSettings()
def test_secret_generation(tmp_path):

View File

@ -1,3 +1,4 @@
import shutil
from pathlib import Path
import pytest
@ -36,4 +37,4 @@ def test_zip_extraction(file_name: str, final_path: Path):
def test_nextcloud_migration(recipe_dir: Path):
recipe = import_recipes(recipe_dir)
assert isinstance(recipe, Recipe)
app_dirs.IMG_DIR.joinpath(recipe.image).unlink(missing_ok=True)
shutil.rmtree(app_dirs.IMG_DIR.joinpath(recipe.image), ignore_errors=True)