Merge remote-tracking branch 'upstream/dev' into locale-settings

This commit is contained in:
Florian Dupret 2021-04-13 18:23:27 +02:00
commit cedefea562
83 changed files with 3877 additions and 2901 deletions

View file

@ -11,6 +11,8 @@ on:
jobs:
tests:
env:
PRODUCTION: false
runs-on: ubuntu-latest
steps:
#----------------------------------------------

9
.vscode/tasks.json vendored
View file

@ -52,6 +52,15 @@
"group": "groupA"
},
"problemMatcher": []
},
{
"label": "Run python tests",
"command": "make test",
"type": "shell",
"presentation": {
"reveal": "always"
},
"problemMatcher": []
}
]
}

View file

@ -20,7 +20,7 @@ RUN apk add --no-cache libxml2-dev \
zlib-dev
ENV ENV True
ENV PRODUCTION true
EXPOSE 80
WORKDIR /app/
@ -40,14 +40,15 @@ RUN apk add --update --no-cache --virtual .build-deps \
cd /app/ && poetry install --no-root --no-dev && \
apk --purge del .build-deps
COPY ./mealie /app/mealie
RUN poetry install --no-dev
COPY ./Caddyfile /app
COPY ./dev/data/templates /app/data/templates
COPY --from=build-stage /app/dist /app/dist
VOLUME [ "/app/data/" ]
RUN chmod +x /app/mealie/run.sh
CMD /app/mealie/run.sh

View file

@ -2,6 +2,8 @@ FROM python:3
WORKDIR /app/
ENV PRODUCTION false
RUN apt-get update -y && \
apt-get install -y python-pip python-dev
@ -14,12 +16,9 @@ RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-
# Copy poetry.lock* in case it doesn't exist in the repo
COPY ./pyproject.toml /app/
# RUN poetry install
COPY ./mealie /app/mealie
RUN poetry install
RUN ["poetry", "run", "python", "mealie/db/init_db.py"]
RUN ["poetry", "run", "python", "mealie/services/image/minify.py"]
CMD ["poetry", "run", "python", "mealie/app.py"]
RUN chmod +x /app/mealie/run.sh
CMD ["/app/mealie/run.sh", "reload"]

View file

@ -1,12 +0,0 @@
#!/bin/sh
# Usage: ./download-and-extract.sh something https://example.com/something.tar.gz
archive=$1
url=$2
if [ ! -f $archive.tar.gz ]; then
wget -O $archive.tar.gz $url
fi
rm -r $archive
tar -xvzf $archive.tar.gz

View file

@ -1,12 +0,0 @@
#!/bin/bash
# install webp
archive=libwebp-1.2.0
./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/master/$archive.tar.gz
pushd $archive
./configure --prefix=/usr --enable-libwebpmux --enable-libwebpdemux && make -j4 && sudo make -j4 install
popd

View file

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

View file

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

View file

@ -0,0 +1,39 @@
# Usage
## Getting a Token
Currently Mealie doesn't support creating a long-live token. You can however get a token from the API. This example was pulled from the automatic API documentation provided by Mealie.
### Curl
```bash
curl -X 'POST' \
'https://mealie-demo.hay-kot.dev/api/auth/token' \
-H 'accept: application/json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=&username=changeme%40email.com&password=demo&scope=&client_id=&client_secret='
```
#### Response
```json
{
"snackbar": {
"text": "User Successfully Logged In",
"type": "success"
},
"access_token": "your-long-token-string",
"token_type": "bearer"
}
```
## Key Components
### Exploring Your Local API
On your local installation you can access interactive API documentation that provides `curl` examples and expected results. This allows you to easily test and interact with your API to identify places to include your own functionality. You can visit the documentation at `http://mealie.yourdomain.com/docs or see the example at the [Demo Site](https://mealie-demo.hay-kot.dev/docs)
### Recipe Extras
Recipes extras are a key feature of the Mealie API. They allow you to create custom json key/value pairs within a recipe to reference from 3rd part applications. You can use these keys to contain information to trigger automation or custom messages to relay to your desired device.
For example you could add `{"message": "Remember to thaw the chicken"}` to a recipe and use the webhooks built into mealie to send that message payload to a destination to be processed.
![api-extras-gif](../assets/gifs/api-extras.gif)

View file

@ -0,0 +1,30 @@
In a lot of ways, Home Assistant is why this project exists! Since it Mealie has a robust API it makes it a great fit for interacting with Home Assistant and pulling information into your dashboard.
### Get Todays Meal in Lovelace
Starting in v0.4.1 you are now able to use the uri `/api/meal-plans/today/image?group_name=Home` to directly access the image to todays meal. This makes it incredible easy to include the image into your Home Assistant Dashboard using the picture entity.
Here's an example where `sensor.mealie_todays_meal` is pulling in the meal-plan name and I'm using the url to get the image.
![api-extras-gif](../assets/img/home-assistant-card.png)
```yaml
type: picture-entity
entity: sensor.mealie_todays_meal
name: Dinner Tonight
show_state: true
show_name: true
image: 'http://localhost:9000/api/meal-plans/today/image?group_name=Home'
style:
.: |
ha-card {
max-height: 300px !important;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
```
!!! tip
Due to how Home Assistant works with images, I had to include the additional styling to get the images to not appear distorted. This includes and [additional installation](https://github.com/thomasloven/lovelace-card-mod) from HACS.

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 KiB

View file

@ -4,6 +4,13 @@
**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
@ -19,5 +26,10 @@
- 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.
- First day of the week in a calendar (Meal planner) is now customizable in Site Settings.
- 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

@ -0,0 +1,24 @@
# v0.4.2
**App Version: v0.4.2**
**Database Version: v0.4.0**
!!! error "Breaking Changes"
1. With a recent refactor some users been experiencing issues with an environmental variable not being set correct. If you are experiencing issues, please provide your comments [Here](https://github.com/hay-kot/mealie/issues/281).
2. If you are a developer, you may experience issues with development as a new environmental variable has been introduced. Setting `PRODUCTION=false` will allow you to develop as normal.
- Improved Nextcloud Migration. Mealie will now walk the directories in a zip file looking for directories that match the pattern of a Nextcloud Recipe. Closes #254
- Rewrite Keywords to Tag Fields
- Rewrite url to orgURL
- Improved Chowdown Migration
- Migration report is now similar to the Backup report
- Tags/Categories are now title cased on import "dinner" -> "Dinner"
- Fixed Initialization script (v0.4.1a Hot Fix) Closes #274
- Depreciate `ENV` variable to `PRODUCTION`
- Set `PRODUCTION` env variable to default to true
- Unify Logger across the backend
- mealie.log and last_recipe.json are now downloadable from the frontend from the /admin/about
- New download schema where you request a token and then use that token to hit a single endpoint to download a file. This is a notable change if you are using the API to download backups.
- Recipe images can no be added directly from a URL - [See #177 for details](https://github.com/hay-kot/mealie/issues/117)

View file

@ -1,5 +1,8 @@
# Usage
## Getting a Token
Bla Bla
## Key Components
### Recipe Extras
Recipes extras are a key feature of the Mealie API. They allow you to create custom json key/value pairs within a recipe to reference from 3rd part applications. You can use these keys to contain information to trigger automation or custom messages to relay to your desired device.
@ -8,96 +11,4 @@ For example you could add `{"message": "Remember to thaw the chicken"}` to a rec
![api-extras-gif](../assets/gifs/api-extras.gif)
## Examples
### Bulk import
Recipes can be imported in bulk from a file containing a list of URLs. This can be done using the following bash or python scripts with the `list` file containing one URL per line.
#### Bash
```bash
#!/bin/bash
function authentification () {
auth=$(curl -X 'POST' \
"$3/api/auth/token" \
-H 'accept: application/json' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=&username='$1'&password='$2'&scope=&client_id=&client_secret=')
echo $auth | sed -e 's/.*token":"\(.*\)",.*/\1/'
}
function import_from_file () {
while IFS= read -r line
do
echo $line
curl -X 'POST' \
"$3/api/recipes/create-url" \
-H "Authorization: Bearer $2" \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{"url": "'$line'" }'
echo
done < "$1"
}
input="list"
mail="changeme@email.com"
password="MyPassword"
mealie_url=http://localhost:9000
token=$(authentification $mail $password $mealie_url)
import_from_file $input $token $mealie_url
```
#### Python
```python
import requests
import re
def authentification(mail, password, mealie_url):
headers = {
'accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
}
data = {
'grant_type': '',
'username': mail,
'password': password,
'scope': '',
'client_id': '',
'client_secret': ''
}
auth = requests.post(mealie_url + "/api/auth/token", headers=headers, data=data)
token = re.sub(r'.*token":"(.*)",.*', r'\1', auth.text)
return token
def import_from_file(input_file, token, mealie_url):
with open(input_file) as fp:
for l in fp:
line = re.sub(r'(.*)\n', r'\1', l)
print(line)
headers = {
'Authorization': "Bearer " + token,
'accept': 'application/json',
'Content-Type': 'application/json'
}
data = {
'url': line
}
response = requests.post(mealie_url + "/api/recipes/create-url", headers=headers, json=data)
print(response.text)
input_file="list"
mail="changeme@email.com"
password="MyPassword"
mealie_url="http://localhost:9000"
token = authentification(mail, password, mealie_url)
import_from_file(input_file, token, mealie_url)
```
Have Ideas? Submit a PR!

File diff suppressed because one or more lines are too long

View file

@ -56,7 +56,6 @@ nav:
- Organizing Recipes: "getting-started/organizing-recipes.md"
- Planning Meals: "getting-started/meal-planner.md"
- iOS Shortcuts: "getting-started/ios.md"
- API Usage: "getting-started/api-usage.md"
- Site Administration:
- User Settings: "site-administration/user-settings.md"
- Site Settings: "site-administration/site-settings.md"
@ -64,6 +63,10 @@ nav:
- User Management: "site-administration/user-management.md"
- Backups and Restore: "site-administration/backups-and-exports.md"
- Recipe Migration: "site-administration/migration-imports.md"
- API Usage:
- Getting Started: "api-usage/getting-started.md"
- Home Assistant: "api-usage/home-assistant.md"
- Bulk Url Import: "api-usage/bulk-url-import.md"
- API Reference: "api/redoc.md"
- Contributors Guide:
- Non-Code: "contributors/non-coders.md"
@ -74,6 +77,7 @@ nav:
- Guidelines: "contributors/developers-guide/general-guidelines.md"
- Development Road Map: "roadmap.md"
- Change Log:
- v0.4.2 Backend/Migrations: "changelog/v0.4.2.md"
- 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"

View file

@ -61,9 +61,16 @@ const apiReq = {
processResponse(response);
return response;
},
async download(url) {
const response = await this.get(url);
const token = response.data.fileToken;
const tokenURL = baseURL + "utils/download?token=" + token;
window.open(tokenURL, "_blank");
return response.data;
},
};
export { apiReq };
export { baseURL };

View file

@ -4,7 +4,7 @@ import { store } from "@/store";
const backupBase = baseURL + "backups/";
const backupURLs = {
export const backupURLs = {
// Backup
available: `${backupBase}available`,
createBackup: `${backupBase}export/database`,
@ -13,6 +13,8 @@ const backupURLs = {
downloadBackup: fileName => `${backupBase}${fileName}/download`,
};
export const backupAPI = {
/**
* Request all backups available on the server
@ -43,19 +45,19 @@ export const backupAPI = {
/**
* Creates a backup on the serve given a set of options
* @param {object} data
* @returns
* @returns
*/
async create(options) {
let response = apiReq.post(backupURLs.createBackup, options);
return response;
},
/**
* Downloads a file from the server. I don't actually think this is used?
* @param {string} fileName
* Downloads a file from the server. I don't actually think this is used?
* @param {string} fileName
* @returns Download URL
*/
async download(fileName) {
let response = await apiReq.get(backupURLs.downloadBackup(fileName));
return response.data;
const url = backupURLs.downloadBackup(fileName);
apiReq.download(url);
},
};

View file

@ -44,6 +44,11 @@ export const tagAPI = {
let response = await apiReq.get(tagURLs.getAll);
return response.data;
},
async create(name) {
let response = await apiReq.post(tagURLs.getAll, { name: name });
store.dispatch("requestTags");
return response.data;
},
async getRecipesInTag(tag) {
let response = await apiReq.get(tagURLs.getTag(tag));
return response.data;

View file

@ -61,6 +61,11 @@ export const recipeAPI = {
return response;
},
async updateImagebyURL(slug, url) {
const response = apiReq.post(recipeURLs.updateImage(slug), { url: url });
return response;
},
async update(data) {
let response = await apiReq.put(recipeURLs.update(data.slug), data);
store.dispatch("requestRecentRecipes");

View file

@ -37,14 +37,7 @@
<v-divider></v-divider>
<v-card-actions>
<v-btn
color="accent"
text
:loading="downloading"
@click="downloadFile(`/api/backups/${name}/download`)"
>
{{ $t("general.download") }}
</v-btn>
<TheDownloadBtn :download-url="downloadUrl" />
<v-spacer></v-spacer>
<v-btn color="error" text @click="raiseEvent('delete')">
{{ $t("general.delete") }}
@ -66,9 +59,10 @@
<script>
import ImportOptions from "@/components/Admin/Backup/ImportOptions";
import axios from "axios";
import TheDownloadBtn from "@/components/UI/TheDownloadBtn.vue";
import { backupURLs } from "@/api/backup";
export default {
components: { ImportOptions },
components: { ImportOptions, TheDownloadBtn },
props: {
name: {
default: "Backup Name",
@ -92,6 +86,11 @@ export default {
downloading: false,
};
},
computed: {
downloadUrl() {
return backupURLs.downloadBackup(this.name);
},
},
methods: {
updateOptions(options) {
this.options = options;
@ -116,23 +115,6 @@ 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

@ -45,7 +45,7 @@
</template>
<script>
import DataTable from "./DataTable";
import DataTable from "@/components/Admin/Backup/ImportSummaryDialog/DataTable";
export default {
components: {
DataTable,
@ -145,4 +145,4 @@ export default {
</script>
<style>
</style>
</style>

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

@ -81,7 +81,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>
@ -149,13 +149,13 @@
import { api } from "@/api";
import LanguageMenu from "@/components/UI/LanguageMenu";
import draggable from "vuedraggable";
import NewCategoryDialog from "./NewCategoryDialog.vue";
import NewCategoryTagDialog from "@/components/UI/Dialogs/NewCategoryTagDialog.vue";
export default {
components: {
draggable,
LanguageMenu,
NewCategoryDialog,
NewCategoryTagDialog,
},
data() {
return {

View file

@ -1,5 +1,6 @@
<template>
<v-card outlined class="my-2" :loading="loading">
<MigrationDialog ref="migrationDialog" />
<v-card-title>
{{ title }}
<v-spacer></v-spacer>
@ -40,7 +41,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>
@ -61,6 +68,7 @@
import UploadBtn from "../../UI/UploadBtn";
import utils from "@/utils";
import { api } from "@/api";
import MigrationDialog from "@/components/Admin/Migration/MigrationDialog.vue";
export default {
props: {
folder: String,
@ -70,6 +78,7 @@ export default {
},
components: {
UploadBtn,
MigrationDialog,
},
data() {
return {
@ -82,10 +91,11 @@ 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.$refs.migrationDialog.open(response);
// this.$emit("imported", response.successful, response.failed);
this.loading = false;
},
readableTime(timestamp) {
let date = new Date(timestamp);

View file

@ -0,0 +1,109 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" width="70%">
<v-card>
<v-app-bar dark color="primary mb-2">
<v-icon large left>
mdi-import
</v-icon>
<v-toolbar-title class="headline">
Migration Summary
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text class="mb-n4">
<v-row>
<div v-for="values in allNumbers" :key="values.title">
<v-card-text>
<div>
<h3>{{ values.title }}</h3>
</div>
<div class="success--text">Success: {{ values.success }}</div>
<div class="error--text">Failed: {{ values.failure }}</div>
</v-card-text>
</div>
</v-row>
</v-card-text>
<v-tabs v-model="tab">
<v-tab>{{ $t("general.recipes") }}</v-tab>
</v-tabs>
<v-tabs-items v-model="tab">
<v-tab-item v-for="(table, index) in allTables" :key="index">
<v-card flat>
<DataTable :data-headers="importHeaders" :data-set="table" />
</v-card>
</v-tab-item>
</v-tabs-items>
</v-card>
</v-dialog>
</div>
</template>
<script>
import DataTable from "@/components/Admin/Backup/ImportSummaryDialog/DataTable";
export default {
components: {
DataTable,
},
data: () => ({
tab: null,
dialog: false,
recipeData: [],
themeData: [],
settingsData: [],
userData: [],
groupData: [],
pageData: [],
importHeaders: [
{
text: "Status",
value: "status",
},
{
text: "Name",
align: "start",
sortable: true,
value: "name",
},
{ text: "Exception", value: "data-table-expand", align: "center" },
],
allDataTables: [],
}),
computed: {
recipeNumbers() {
return this.calculateNumbers(this.$t("general.recipes"), this.recipeData);
},
allNumbers() {
return [this.recipeNumbers];
},
allTables() {
return [this.recipeData];
},
},
methods: {
calculateNumbers(title, list_array) {
if (!list_array) return;
let numbers = { title: title, success: 0, failure: 0 };
list_array.forEach(element => {
if (element.status) {
numbers.success++;
} else numbers.failure++;
});
return numbers;
},
open(importData) {
this.recipeData = importData;
this.dialog = true;
},
},
};
</script>
<style>
</style>

View file

@ -1,79 +0,0 @@
<template>
<v-select
:items="allCategories"
v-model="selected"
label="Categories"
chips
deletable-chips
:dense="dense"
item-text="name"
multiple
:solo="solo"
:return-object="returnObject"
:flat="flat"
@input="emitChange"
>
<template v-slot:selection="data">
<v-chip
class="ma-1"
:input-value="data.selected"
close
@click:close="removeByIndex(data.index)"
label
color="accent"
dark
>
{{ data.item.name }}
</v-chip>
</template></v-select
>
</template>
<script>
const MOUNTED_EVENT = "mounted";
export default {
props: {
value: Array,
solo: {
default: false,
},
dense: {
default: true,
},
returnObject: {
default: true,
},
},
data() {
return {
selected: [],
};
},
mounted() {
this.$emit(MOUNTED_EVENT);
},
computed: {
allCategories() {
return this.$store.getters.getAllCategories;
},
flat() {
return this.selected.length > 0 && this.solo;
},
},
methods: {
emitChange() {
this.$emit("input", this.selected);
},
setInit(val) {
this.selected = val;
},
removeByIndex(index) {
this.selected.splice(index, 1);
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,129 @@
<template>
<v-autocomplete
:items="activeItems"
v-model="selected"
:value="value"
:label="inputLabel"
chips
deletable-chips
:dense="dense"
item-text="name"
persistent-hint
multiple
:hint="hint"
:solo="solo"
:return-object="returnObject"
:flat="flat"
@input="emitChange"
>
<template v-slot:selection="data">
<v-chip
class="ma-1"
:input-value="data.selected"
close
@click:close="removeByIndex(data.index)"
label
color="accent"
dark
:key="data.index"
>
{{ data.item.name || data.item }}
</v-chip>
</template>
<template v-slot:append-outer="">
<NewCategoryTagDialog
v-if="showAdd"
:tag-dialog="tagSelector"
@created-item="pushToItem"
/>
</template>
</v-autocomplete>
</template>
<script>
import NewCategoryTagDialog from "@/components/UI/Dialogs/NewCategoryTagDialog";
const MOUNTED_EVENT = "mounted";
export default {
components: {
NewCategoryTagDialog,
},
props: {
value: Array,
solo: {
default: false,
},
dense: {
default: true,
},
returnObject: {
default: true,
},
tagSelector: {
default: false,
},
hint: {
default: null,
},
showAdd: {
default: false,
},
showLabel: {
default: true,
},
},
data() {
return {
selected: [],
};
},
mounted() {
this.$emit(MOUNTED_EVENT);
this.setInit(this.value);
},
watch: {
value(val) {
this.selected = val;
},
},
computed: {
inputLabel() {
if (!this.showLabel) return null;
return this.tagSelector ? "Tags" : "Categories";
},
activeItems() {
let ItemObjects = [];
if (this.tagSelector) ItemObjects = this.$store.getters.getAllTags;
else {
ItemObjects = this.$store.getters.getAllCategories;
}
if (this.returnObject) return ItemObjects;
else {
return ItemObjects.map(x => x.name);
}
},
flat() {
return this.selected.length > 0 && this.solo;
},
},
methods: {
emitChange() {
this.$emit("input", this.selected);
},
setInit(val) {
this.selected = val;
},
removeByIndex(index) {
this.selected.splice(index, 1);
},
pushToItem(createdItem) {
createdItem = this.returnObject ? createdItem : createdItem.name;
this.selected.push(createdItem);
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,79 +0,0 @@
<template>
<v-select
:items="allTags"
v-model="selected"
label="Tags"
chips
deletable-chips
:dense="dense"
:solo="solo"
:flat="flat"
item-text="name"
multiple
:return-object="returnObject"
@input="emitChange"
>
<template v-slot:selection="data">
<v-chip
class="ma-1"
:input-value="data.selected"
close
@click:close="removeByIndex(data.index)"
label
color="accent"
dark
>
{{ data.item.name }}
</v-chip>
</template>
</v-select>
</template>
<script>
const MOUNTED_EVENT = "mounted";
export default {
props: {
value: Array,
solo: {
default: false,
},
dense: {
default: true,
},
returnObject: {
default: true,
},
},
data() {
return {
selected: [],
};
},
mounted() {
this.$emit(MOUNTED_EVENT);
},
computed: {
allTags() {
return this.$store.getters.getAllTags;
},
flat() {
return this.selected.length > 0 && this.solo;
},
},
methods: {
emitChange() {
this.$emit("input", this.selected);
},
setInit(val) {
this.selected = val;
},
removeByIndex(index) {
this.selected.splice(index, 1);
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,76 @@
<template>
<div class="text-center">
<v-menu offset-y top nudge-top="6" :close-on-content-click="false">
<template v-slot:activator="{ on, attrs }">
<v-btn color="accent" dark v-bind="attrs" v-on="on">
Image
</v-btn>
</template>
<v-card width="400">
<v-card-title class="headline flex mb-0">
<div>
Recipe Image
</div>
<UploadBtn
class="ml-auto"
url="none"
file-name="image"
:text-btn="false"
@uploaded="uploadImage"
/>
</v-card-title>
<v-card-text class="mt-n5">
<div>
<v-text-field label="URL" class="pt-5" clearable v-model="url">
<template v-slot:append-outer>
<v-btn
class="ml-2"
color="primary"
@click="getImageFromURL"
:loading="loading"
>
Get
</v-btn>
</template>
</v-text-field>
</div>
</v-card-text>
</v-card>
</v-menu>
</div>
</template>
<script>
const REFRESH_EVENT = "refresh";
const UPLOAD_EVENT = "upload";
import UploadBtn from "@/components/UI/UploadBtn";
import { api } from "@/api";
// import axios from "axios";
export default {
components: {
UploadBtn,
},
props: {
slug: String,
},
data: () => ({
items: [{ title: "Upload Image" }, { title: "From URL" }],
url: "",
loading: false,
}),
methods: {
uploadImage(fileObject) {
this.$emit(UPLOAD_EVENT, fileObject);
},
async getImageFromURL() {
this.loading = true;
const response = await api.recipes.updateImagebyURL(this.slug, this.url);
if (response) this.$emit(REFRESH_EVENT);
this.loading = false;
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,81 @@
<template>
<div v-if="valueNotNull || edit">
<h2 class="my-4">Nutrition</h2>
<div v-if="edit">
<div v-for="(item, key, index) in value" :key="index">
<v-text-field
dense
:value="value[key]"
:label="labels[key].label"
:suffix="labels[key].suffix"
type="number"
autocomplete="off"
@input="updateValue(key, $event)"
></v-text-field>
</div>
</div>
<div v-if="showViewer">
<v-list dense>
<v-list-item-group color="primary">
<v-list-item v-for="(item, key, index) in labels" :key="index">
<v-list-item-content>
<v-list-item-title class="pl-4 text-subtitle-1 flex row ">
<div>{{ item.label }}</div>
<div class="ml-auto mr-1">{{ value[key] }}</div>
<div>{{ item.suffix }}</div>
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-list>
</div>
</div>
</template>
<script>
export default {
props: {
value: {},
edit: {
type: Boolean,
default: true,
},
},
data() {
return {
labels: {
calories: {
label: "Calories",
suffix: "calories",
},
fatContent: { label: "Fat Content", suffix: "grams" },
fiberContent: { label: "Fiber Content", suffix: "grams" },
proteinContent: { label: "Protein Content", suffix: "grams" },
sodiumContent: { label: "Sodium Content", suffix: "milligrams" },
sugarContent: { label: "Sugar Content", suffix: "grams" },
},
};
},
computed: {
showViewer() {
return !this.edit && this.valueNotNull;
},
valueNotNull() {
for (const property in this.value) {
const valueProperty = this.value[property];
if (valueProperty && valueProperty !== "") return true;
}
return false;
},
},
methods: {
updateValue(key, value) {
this.$emit("input", { ...this.value, [key]: value });
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -2,16 +2,12 @@
<v-form ref="form">
<v-card-text>
<v-row dense>
<v-col cols="3"></v-col>
<v-col>
<v-file-input
v-model="fileObject"
:label="$t('general.image-file')"
truncate-length="30"
@change="uploadImage"
></v-file-input>
</v-col>
<v-col cols="3"></v-col>
<ImageUploadBtn
class="mt-2"
@upload="uploadImage"
:slug="value.slug"
@refresh="$emit('upload')"
/>
</v-row>
<v-row dense>
<v-col>
@ -92,7 +88,7 @@
auto-grow
solo
dense
rows="2"
rows="1"
>
<v-icon
class="mr-n1"
@ -114,60 +110,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
@ -204,6 +161,7 @@
<v-btn class="mt-1" color="secondary" fab dark small @click="addNote">
<v-icon>mdi-plus</v-icon>
</v-btn>
<NutritionEditor v-model="value.nutrition" :edit="true" />
<ExtrasEditor :extras="value.extras" @save="saveExtras" />
</v-col>
@ -261,15 +219,20 @@
<script>
import draggable from "vuedraggable";
import { api } from "@/api";
import utils from "@/utils";
import BulkAdd from "./BulkAdd";
import ExtrasEditor from "./ExtrasEditor";
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
import NutritionEditor from "./NutritionEditor";
import ImageUploadBtn from "./ImageUploadBtn.vue";
export default {
components: {
BulkAdd,
ExtrasEditor,
draggable,
CategoryTagSelector,
NutritionEditor,
ImageUploadBtn,
},
props: {
value: Object,
@ -285,27 +248,11 @@ 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;
api.recipes.updateImage(slug, this.fileObject);
uploadImage(fileObject) {
this.$emit("upload", fileObject);
},
toggleDisabled(stepIndex) {
if (this.disabledSteps.includes(stepIndex)) {
@ -327,9 +274,6 @@ export default {
generateKey(item, index) {
return utils.generateUniqueKey(item, index);
},
deleteRecipe() {
this.$emit("delete");
},
appendIngredients(ingredients) {
this.value.recipeIngredient.push(...ingredients);

View file

@ -40,6 +40,7 @@
:isCategory="false"
/>
<Notes :notes="notes" />
<NutritionEditor :value="nutrition" :edit="false" />
</div>
</v-col>
<v-divider
@ -56,6 +57,7 @@
<RecipeChips :title="$t('recipe.categories')" :items="categories" />
<RecipeChips :title="$t('recipe.tags')" :items="tags" />
<Notes :notes="notes" />
<NutritionEditor :value="nutrition" :edit="false" />
</div>
<v-row class="mt-2 mb-1">
<v-col></v-col>
@ -80,6 +82,7 @@
</template>
<script>
import NutritionEditor from "@/components/Recipe/RecipeEditor/NutritionEditor";
import VueMarkdown from "@adapttive/vue-markdown";
import utils from "@/utils";
import RecipeChips from "./RecipeChips";
@ -93,6 +96,7 @@ export default {
Steps,
Notes,
Ingredients,
NutritionEditor,
},
props: {
name: String,
@ -105,6 +109,7 @@ export default {
rating: Number,
yields: String,
orgURL: String,
nutrition: Object,
},
data() {
return {

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

@ -17,11 +17,18 @@
</v-text-field>
</template>
<v-card v-if="showResults" max-height="500" :max-width="maxWidth">
<v-card-text class="py-1">Results</v-card-text>
<v-card-text class="flex row mx-auto">
<div class="mr-auto">
Results
</div>
<router-link to="/search">
Advanced Search
</router-link>
</v-card-text>
<v-divider></v-divider>
<v-list scrollable>
<v-list scrollable v-if="autoResults">
<v-list-item
v-for="(item, index) in autoResults"
v-for="(item, index) in autoResults.slice(0, 15)"
:key="index"
:to="navOnClick ? `/recipe/${item.item.slug}` : null"
@click="navOnClick ? null : selected(item.item.slug, item.item.name)"

View file

@ -0,0 +1,51 @@
<template>
<v-btn color="accent" text :loading="downloading" @click="downloadFile">
{{ showButtonText }}
</v-btn>
</template>
<script>
/**
* The download button used for the entire site
* pass a URL to the endpoint that will return a
* file_token which will then be used to request the file
* from the server and open that link in a new tab
*/
import { apiReq } from "@/api/api-utils";
export default {
props: {
/**
* URL to get token from
*/
downloadUrl: {
default: "",
},
/**
* Override button text. Defaults to "Download"
*/
buttonText: {
default: null,
},
},
data() {
return {
downloading: false,
};
},
computed: {
showButtonText() {
return this.buttonText || this.$t("general.download");
},
},
methods: {
async downloadFile() {
this.downloading = true;
await apiReq.download(this.downloadUrl);
this.downloading = false;
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,7 +1,12 @@
<template>
<v-form ref="file">
<input ref="uploader" class="d-none" type="file" @change="onFileChanged" />
<v-btn :loading="isSelecting" @click="onButtonClick" color="accent" text>
<v-btn
:loading="isSelecting"
@click="onButtonClick"
color="accent"
:text="textBtn"
>
<v-icon left> {{ icon }}</v-icon>
{{ text ? text : defaultText }}
</v-btn>
@ -13,10 +18,17 @@ const UPLOAD_EVENT = "uploaded";
import { api } from "@/api";
export default {
props: {
post: {
type: Boolean,
default: true,
},
url: String,
text: { default: "Upload" },
icon: { default: "mdi-cloud-upload" },
fileName: { default: "archive" },
textBtn: {
default: true,
},
},
data: () => ({
file: null,
@ -33,6 +45,12 @@ export default {
async upload() {
if (this.file != null) {
this.isSelecting = true;
if (this.post) {
this.$emit(UPLOAD_EVENT, this.file);
this.isSelecting = false;
return;
}
let formData = new FormData();
formData.append(this.fileName, this.file);

View file

@ -0,0 +1,160 @@
{
"404": {
"page-not-found": "404 Página não encontrada",
"take-me-home": "Voltar ao início"
},
"new-recipe": {
"from-url": "Do URL",
"recipe-url": "URL da Receita",
"error-message": "Ocorreu um erro ao ler o URL. Verifica os registos e o debug/last_recipe.json para perceber o que correu mal." ,
"bulk-add": "Adicionar Vários",
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Insira os dados da sua receita. Cada linha será tratada como um item numa lista."
},
"general": {
"upload": "Enviar",
"submit": "Submeter",
"name": "Nome",
"settings": "Definições",
"close": "Fechar",
"save": "Guardar",
"image-file": "Ficheiro de Imagem",
"update": "Atualizar",
"edit": "Editar",
"delete": "Eliminar",
"select": "Seleccionar",
"random": "Aleatório",
"new": "Novo",
"create": "Criar",
"cancel": "Cancelar",
"ok": "OK",
"enabled": "Ativado",
"download": "Transferir",
"import": "Importar",
"options": "Opções",
"templates": "Templates",
"recipes": "Receitas",
"themes": "Temas",
"confirm": "Confirmar"
},
"login": {
"stay-logged-in": "Manter a sessão iniciada?",
"email": "Email",
"password": "Password",
"sign-in": "Iniciar Sessão",
"sign-up": "Criar Conta"
},
"meal-plan": {
"shopping-list": "Lista de Compras",
"dinner-this-week": "Jantar esta semana",
"meal-planner": "Planeador de Refeições",
"dinner-today": "Jantar Hoje",
"planner": "Planeador",
"edit-meal-plan": "Editar Plano de Refeições",
"meal-plans": "Planos de Refeições",
"create-a-new-meal-plan": "Criar novo Plano de Refeições",
"start-date": "Data de Inicio",
"end-date": "Data de Fim"
},
"recipe": {
"description": "Descrição",
"ingredients": "Ingredientes",
"categories": "Categorias",
"tags": "Etiquetas",
"instructions": "Instruções",
"step-index": "Passo: {step}",
"recipe-name": "Nome da Receita",
"servings": "Porções",
"ingredient": "Ingrediente",
"notes": "Notas",
"note": "Nota",
"original-url": "URL Original",
"view-recipe": "Ver Receita",
"title": "Título",
"total-time": "Tempo Total",
"prep-time": "Tempo de Preparação",
"perform-time": "Tempo de Cozedura",
"api-extras": "Extras API",
"object-key": "Chave do Objeto",
"object-value": "Valor do Objeto",
"new-key-name": "Novo nome da Chave",
"add-key": "Adicionar Chave",
"key-name-required": "Nome da Chave é Obrigatório",
"no-white-space-allowed": "Espaço em Branco não Permitido",
"delete-recipe": "Eliminar Receita",
"delete-confirmation": "Tem a certeza que deseja eliminar esta receita?"
},
"search": {
"search-mealie": "Pesquisar Mealie"
},
"settings": {
"general-settings": "Definições Gerais",
"local-api": "API Local",
"language": "Língua",
"add-a-new-theme": "Adicionar novo tema",
"set-new-time": "Definir hora",
"current": "Versão:",
"latest": "Mais Recente",
"explore-the-docs": "Explorar Documentação",
"contribute": "Contribuir",
"backup-and-exports": "Backups",
"backup-info": "Backups are exported in standard JSON format along with all the images stored on the file system. In your backup folder you'll find a .zip file that contains all of the recipe JSON and images from the database. Additionally, if you selected a markdown file, those will also be stored in the .zip file. To import a backup, it must be located in your backups folder. Automated backups are done each day at 3:00 AM.",
"available-backups": "Backups Disponíveis",
"theme": {
"theme-name": "Nome do Tema",
"theme-settings": "Definições do Tema",
"select-a-theme-from-the-dropdown-or-create-a-new-theme-note-that-the-default-theme-will-be-served-to-all-users-who-have-not-set-a-theme-preference": "Selecione um tema da lista ou crie um novo tema. Note que o tema por defeito será utilizado por todos os utilizadores que não selecionaram um tema preferido.",
"dark-mode": "Modo Escuro",
"theme-is-required": "Tema é Obrigatório",
"primary": "Primário",
"secondary": "Secondário",
"accent": "Accent",
"success": "Successo",
"info": "Info",
"warning": "Aviso",
"error": "Erro",
"default-to-system": "Mesmo do Sistema",
"light": "Claro",
"dark": "Escuro",
"theme": "Tema",
"saved-color-theme": "Cor de Tema Guardado",
"delete-theme": "Eliminar Tema",
"are-you-sure-you-want-to-delete-this-theme": "Tem a certeza que deseja eliminar este tema?",
"choose-how-mealie-looks-to-you-set-your-theme-preference-to-follow-your-system-settings-or-choose-to-use-the-light-or-dark-theme": "Escolha como o Mealie estará visivel. Escolha o Mesmo do sistema para seguir o tema do seu dispositivo, ou selecione claro ou escuro.",
"theme-name-is-required": "Nome do Tema é Obrigatório."
},
"webhooks": {
"meal-planner-webhooks": "Webhooks do Organizador de Refeições",
"the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at": "Os URLs apresentados abaixo receberão webhooks que contêm os dados da receita para o plano de refeições no dia marcado. Atualmente, os webhooks serão executados a ",
"test-webhooks": "Webhooks de Teste",
"webhook-url": "Webhook URL"
},
"new-version-available": "Uma nova versão do Mealie está disponível, <a {aContents}> Visite o Repo </a>",
"backup": {
"import-recipes": "Importar Receitas",
"import-themes": "Importar Temas",
"import-settings": "Importa Definições",
"create-heading": "Criar um Backup",
"backup-tag": "Etiqueta do Backup",
"full-backup": "Backup Completo",
"partial-backup": "Backup Parcial",
"backup-restore-report": "Análise do Resultado do Backup",
"successfully-imported": "Importado com Sucesso",
"failed-imports": "Importações falhadas"
}
},
"migration": {
"recipe-migration": "Migração da Receita",
"failed-imports": "Importações Falhadas",
"migration-report": "Análise das Migrações",
"successful-imports": "Importações Bem sucedidas",
"no-migration-data-available": "Não há dados de migração disponíveis",
"nextcloud": {
"title": "Nextcloud Cookbook",
"description": "Migraar dados de uma instância do Nextcloud CookBook"
},
"chowdown": {
"title": "Chowdown",
"description": "Migrar dados do Chowdown"
}
}
}

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

@ -20,6 +20,17 @@
</v-list-item>
</v-list-item-group>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<TheDownloadBtn
button-text="Download Recipe JSON"
download-url="/api/debug/last-recipe-json"
/>
<TheDownloadBtn
button-text="Download Log"
download-url="/api/debug/log"
/>
</v-card-actions>
<v-divider></v-divider>
</v-card>
</div>
@ -27,7 +38,9 @@
<script>
import { api } from "@/api";
import TheDownloadBtn from "@/components/UI/TheDownloadBtn";
export default {
components: { TheDownloadBtn },
data() {
return {
prettyInfo: [],

View file

@ -7,41 +7,19 @@
<v-card-text>
<h2 class="mt-1">{{ $t("recipe.categories") }}</h2>
<v-row>
<v-col sm="12" md="6">
<v-select
outlined
:flat="isFlat"
elavation="0"
v-model="groupSettings.categories"
:items="categories"
item-text="name"
return-object
multiple
chips
:hint="
$t(
'meal-plan.only-recipes-with-these-categories-will-be-used-in-meal-plans'
)
"
class="mt-2"
persistent-hint
>
<template v-slot:selection="data">
<v-chip
outlined
:input-value="data.selected"
close
@click:close="removeCategory(data.index)"
color="secondary"
dark
>
{{ data.item.name }}
</v-chip>
</template>
</v-select>
</v-col>
</v-row>
<CategoryTagSelector
class="mt-4"
:solo="true"
:dense="false"
v-model="groupSettings.categories"
:return-object="true"
:show-add="true"
:hint="
$t(
'meal-plan.only-recipes-with-these-categories-will-be-used-in-meal-plans'
)
"
/>
</v-card-text>
<v-divider> </v-divider>
<v-card-text>
@ -59,7 +37,7 @@
<v-row dense class="flex align-center">
<v-switch
class="mx-2"
class="mx-2"
v-model="groupSettings.webhookEnable"
:label="$t('general.enabled')"
></v-switch>
@ -105,9 +83,11 @@
<script>
import { api } from "@/api";
import TimePickerDialog from "@/components/Admin/MealPlanner/TimePickerDialog";
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
export default {
components: {
TimePickerDialog,
CategoryTagSelector,
},
data() {
return {
@ -155,6 +135,7 @@ export default {
this.groupSettings.webhookUrls.splice(index, 1);
},
async saveGroupSettings() {
console.log(this.groupSettings);
await api.groups.update(this.groupSettings);
await this.$store.dispatch("requestCurrentGroup");
this.getSiteSettings();
@ -162,9 +143,6 @@ export default {
testWebhooks() {
api.settings.testWebhooks();
},
removeCategory(index) {
this.groupSettings.categories.splice(index, 1);
},
},
};
</script>

View file

@ -50,6 +50,7 @@
:rating="recipeDetails.rating"
:yields="recipeDetails.recipeYield"
:orgURL="recipeDetails.orgURL"
:nutrition="recipeDetails.nutrition"
/>
<VJsoneditor
@error="logError()"
@ -151,6 +152,7 @@ export default {
methods: {
getImageFile(fileObject) {
this.fileObject = fileObject;
this.saveImage();
},
async getRecipeDetails() {
this.recipeDetails = await api.recipes.requestDetails(this.currentRecipe);
@ -172,19 +174,21 @@ export default {
return this.$refs.recipeEditor.validateRecipe();
}
},
async saveImage() {
if (this.fileObject) {
await api.recipes.updateImage(this.recipeDetails.slug, this.fileObject);
}
this.imageKey += 1;
},
async saveRecipe() {
if (this.validateRecipe()) {
let slug = await api.recipes.update(this.recipeDetails);
if (this.fileObject) {
await api.recipes.updateImage(
this.recipeDetails.slug,
this.fileObject
);
this.saveImage();
}
this.form = false;
this.imageKey += 1;
if (slug != this.recipeDetails.slug) {
this.$router.push(`/recipe/${slug}`);
}

View file

@ -28,7 +28,7 @@
<v-col>
<h3 class="pl-2 text-center headline">Category Filter</h3>
<FilterSelector class="mb-1" @update="updateCatParams" />
<CategorySelector
<CategoryTagSelector
:solo="true"
:dense="false"
v-model="includeCategories"
@ -38,11 +38,13 @@
<v-col>
<h3 class="pl-2 text-center headline">Tag Filter</h3>
<FilterSelector class="mb-1" @update="updateTagParams" />
<TagSelector
<CategoryTagSelector
:solo="true"
:dense="false"
v-model="includeTags"
:return-object="false"
:tag-selector="true"
/>
</v-col>
</v-row>
@ -74,16 +76,14 @@
import Fuse from "fuse.js";
import RecipeCard from "@/components/Recipe/RecipeCard";
import CategorySidebar from "@/components/UI/CategorySidebar";
import CategorySelector from "@/components/FormHelpers/CategorySelector";
import TagSelector from "@/components/FormHelpers/TagSelector";
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
import FilterSelector from "./FilterSelector.vue";
export default {
components: {
RecipeCard,
CategorySidebar,
CategorySelector,
TagSelector,
CategoryTagSelector,
FilterSelector,
},
data() {

View file

@ -35,6 +35,10 @@ const state = {
name: "German",
value: "de",
},
{
name: "Português",
value: "pt-PT",
},
],
};
@ -48,13 +52,13 @@ const mutations = {
const actions = {
initLang({ getters }, { currentVueComponent }) {
VueI18n.locale = getters.getActiveLang;
currentVueComponent.$vuetify.lang.current = getters.getActiveLang;
currentVueComponent.$vuetify.lang.current = getters.getActiveLang;
},
};
const getters = {
getActiveLang: (state) => state.lang,
getAllLangs: (state) => state.allLangs,
getActiveLang: state => state.lang,
getAllLangs: state => state.allLangs,
};
export default {

View file

@ -1,16 +1,19 @@
import uvicorn
from fastapi import FastAPI
from fastapi.logger import logger
from mealie.core import root_logger
# import utils.startup as startup
from mealie.core.config import APP_VERSION, settings
from mealie.routes import backup_routes, debug_routes, migration_routes, theme_routes
from mealie.routes import backup_routes, debug_routes, migration_routes, theme_routes, utility_routes
from mealie.routes.groups import groups
from mealie.routes.mealplans import mealplans
from mealie.routes.recipe import all_recipe_routes, category_routes, recipe_crud_routes, tag_routes
from mealie.routes.site_settings import all_settings
from mealie.routes.users import users
logger = root_logger.get_logger()
app = FastAPI(
title="Mealie",
description="A place for all your recipes",
@ -26,6 +29,7 @@ def start_scheduler():
def api_routers():
# Authentication
app.include_router(utility_routes.router)
app.include_router(users.router)
app.include_router(groups.router)
# Recipes
@ -33,7 +37,6 @@ def api_routers():
app.include_router(category_routes.router)
app.include_router(tag_routes.router)
app.include_router(recipe_crud_routes.router)
# Meal Routes
app.include_router(mealplans.router)
# Settings Routes
@ -50,6 +53,13 @@ api_routers()
start_scheduler()
@app.on_event("startup")
def system_startup():
logger.info("-----SYSTEM STARTUP----- \n")
logger.info("------APP SETTINGS------")
logger.info(settings.json(indent=4, exclude={"SECRET", "DEFAULT_PASSWORD", "SFTP_PASSWORD", "SFTP_USERNAME"}))
def main():
uvicorn.run(
@ -60,11 +70,11 @@ def main():
reload_dirs=["mealie"],
debug=True,
log_level="info",
log_config=None,
workers=1,
forwarded_allow_ips="*",
)
if __name__ == "__main__":
logger.info("-----SYSTEM STARTUP-----")
main()

View file

@ -3,16 +3,19 @@ import secrets
from pathlib import Path
from typing import Optional, Union
import dotenv
from pydantic import BaseSettings, Field, validator
APP_VERSION = "v0.4.1"
APP_VERSION = "v0.4.2"
DB_VERSION = "v0.4.0"
CWD = Path(__file__).parent
BASE_DIR = CWD.parent.parent
ENV = BASE_DIR.joinpath(".env")
PRODUCTION = os.getenv("ENV", "False").lower() in ["true", "1"]
dotenv.load_dotenv(ENV)
PRODUCTION = os.getenv("PRODUCTION", "True").lower() in ["true", "1"]
def determine_data_dir(production: bool) -> Path:
@ -40,7 +43,6 @@ def determine_secrets(data_dir: Path, production: bool) -> str:
# General
DATA_DIR = determine_data_dir(PRODUCTION)
LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
class AppDirectories:
@ -84,7 +86,7 @@ app_dirs = AppDirectories(CWD, DATA_DIR)
class AppSettings(BaseSettings):
global DATA_DIR
PRODUCTION: bool = Field(False, env="ENV")
PRODUCTION: bool = Field(True, env="PRODUCTION")
IS_DEMO: bool = False
API_PORT: int = 9000
API_DOCS: bool = True
@ -127,5 +129,3 @@ class AppSettings(BaseSettings):
settings = AppSettings()
print(settings.dict())

View file

@ -0,0 +1,43 @@
import logging
import sys
from mealie.core.config import DATA_DIR
LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
LOGGER_FORMAT = "%(levelname)s: \t%(message)s"
DATE_FORMAT = "%d-%b-%y %H:%M:%S"
logging.basicConfig(level=logging.INFO, format=LOGGER_FORMAT, datefmt="%d-%b-%y %H:%M:%S")
def logger_init() -> logging.Logger:
""" Returns the Root Loggin Object for Mealie """
logger = logging.getLogger("mealie")
logger.propagate = False
# File Handler
output_file_handler = logging.FileHandler(LOGGER_FILE)
handler_format = logging.Formatter(LOGGER_FORMAT, datefmt=DATE_FORMAT)
output_file_handler.setFormatter(handler_format)
# Stdout
stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setFormatter(handler_format)
logger.addHandler(output_file_handler)
logger.addHandler(stdout_handler)
return logger
def get_logger(module=None) -> logging.Logger:
""" Returns a child logger for mealie """
global root_logger
if module is None:
return root_logger
return root_logger.getChild(module)
root_logger = logger_init()

View file

@ -1,9 +1,10 @@
from datetime import datetime, timedelta
from mealie.schema.user import UserInDB
from pathlib import Path
from jose import jwt
from mealie.core.config import settings
from mealie.db.database import db
from mealie.schema.user import UserInDB
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@ -20,6 +21,11 @@ def create_access_token(data: dict(), expires_delta: timedelta = None) -> str:
return jwt.encode(to_encode, settings.SECRET, algorithm=ALGORITHM)
def create_file_token(file_path: Path) -> bool:
token_data = {"file": str(file_path)}
return create_access_token(token_data, expires_delta=timedelta(minutes=30))
def authenticate_user(session, email: str, password: str) -> UserInDB:
user: UserInDB = db.users.get(session, email, "email")
if not user:

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

@ -1,4 +1,4 @@
from fastapi.logger import logger
from mealie.core import root_logger
from mealie.core.config import settings
from mealie.core.security import get_password_hash
from mealie.db.database import db
@ -7,6 +7,8 @@ from mealie.schema.settings import SiteSettings
from mealie.schema.theme import SiteTheme
from sqlalchemy.orm import Session
logger = root_logger.get_logger("init_db")
def init_db(db: Session = None) -> None:
if not db:
@ -48,10 +50,13 @@ def default_user_init(session: Session):
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

@ -1,10 +1,12 @@
import sqlalchemy as sa
import sqlalchemy.orm as orm
from fastapi.logger import logger
from mealie.core import root_logger
from mealie.db.models.model_base import SqlAlchemyBase
from slugify import slugify
from sqlalchemy.orm import validates
logger = root_logger.get_logger()
site_settings2categories = sa.Table(
"site_settings2categoories",
SqlAlchemyBase.metadata,
@ -59,8 +61,8 @@ class Category(SqlAlchemyBase):
test_slug = slugify(name)
result = session.query(Category).filter(Category.slug == test_slug).one_or_none()
if result:
logger.info("Category exists, associating recipe")
logger.debug("Category exists, associating recipe")
return result
else:
logger.info("Category doesn't exists, creating tag")
logger.debug("Category doesn't exists, creating tag")
return Category(name=name)

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,10 +1,12 @@
import sqlalchemy as sa
import sqlalchemy.orm as orm
from mealie.core import root_logger
from mealie.db.models.model_base import SqlAlchemyBase
from fastapi.logger import logger
from slugify import slugify
from sqlalchemy.orm import validates
logger = root_logger.get_logger()
recipes2tags = sa.Table(
"recipes2tags",
SqlAlchemyBase.metadata,
@ -25,7 +27,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)
@ -35,8 +37,8 @@ class Tag(SqlAlchemyBase):
result = session.query(Tag).filter(Tag.slug == test_slug).one_or_none()
if result:
logger.info("Tag exists, associating recipe")
logger.debug("Tag exists, associating recipe")
return result
else:
logger.info("Tag doesn't exists, creating tag")
logger.debug("Tag doesn't exists, creating tag")
return Tag(name=name)

View file

@ -1,10 +1,12 @@
import operator
import shutil
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from mealie.core.config import app_dirs
from mealie.core.security import create_file_token
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.routes.deps import get_current_user, validate_file_token
from mealie.schema.backup import BackupJob, ImportJob, Imports, LocalBackup
from mealie.schema.snackbar import SnackResponse
from mealie.services.backups import imports
@ -68,13 +70,10 @@ def upload_backup_file(archive: UploadFile = File(...)):
@router.get("/{file_name}/download")
async def download_backup_file(file_name: str):
""" Upload a .zip File to later be imported into Mealie """
""" Returns a token to download a file """
file = app_dirs.BACKUP_DIR.joinpath(file_name)
if file.is_file:
return FileResponse(file, media_type="application/octet-stream", filename=file_name)
else:
return SnackResponse.error("No File Found")
return {"fileToken": create_file_token(file)}
@router.post("/{file_name}/import", status_code=200)

View file

@ -1,7 +1,9 @@
import json
from fastapi import APIRouter, Depends
from mealie.core.config import APP_VERSION, LOGGER_FILE, app_dirs, settings
from mealie.core.config import APP_VERSION, app_dirs, settings
from mealie.core.root_logger import LOGGER_FILE
from mealie.core.security import create_file_token
from mealie.routes.deps import get_current_user
from mealie.schema.debug import AppInfo, DebugInfo
@ -36,10 +38,8 @@ async def get_mealie_version():
@router.get("/last-recipe-json")
async def get_last_recipe_json(current_user=Depends(get_current_user)):
""" Doc Str """
with open(app_dirs.DEBUG_DIR.joinpath("last_recipe.json"), "r") as f:
return json.loads(f.read())
""" Returns a token to download a file """
return {"fileToken": create_file_token(app_dirs.DEBUG_DIR.joinpath("last_recipe.json"))}
@router.get("/log/{num}")
@ -50,6 +50,12 @@ async def get_log(num: int, current_user=Depends(get_current_user)):
return log_text
@router.get("/log")
async def get_log_file():
""" Returns a token to download a file """
return {"fileToken": create_file_token(LOGGER_FILE)}
def tail(f, lines=20):
total_lines_wanted = lines

View file

@ -1,3 +1,6 @@
from pathlib import Path
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
@ -25,7 +28,25 @@ async def get_current_user(token: str = Depends(oauth2_scheme), session=Depends(
token_data = TokenData(username=username)
except JWTError:
raise credentials_exception
user = db.users.get(session, token_data.username, "email")
if user is None:
raise credentials_exception
return user
async def validate_file_token(token: Optional[str] = None) -> Path:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="could not validate file token",
)
if not token:
return None
try:
payload = jwt.decode(token, settings.SECRET, algorithms=[ALGORITHM])
file_path = Path(payload.get("file"))
except JWTError:
raise credentials_exception
return file_path

View file

@ -79,7 +79,7 @@ def get_today(session: Session = Depends(generate_session), current_user: UserIn
@router.get("/today/image", tags=["Meal Plan"])
def get_today(session: Session = Depends(generate_session), group_name: str = "Home"):
def get_todays_image(session: Session = Depends(generate_session), group_name: str = "Home"):
"""
Returns the image for todays meal-plan.
"""

View file

@ -8,15 +8,14 @@ from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.migration import MigrationFile, Migrations
from mealie.schema.snackbar import SnackResponse
from mealie.services.migrations.chowdown import chowdown_migrate as chowdow_migrate
from mealie.services.migrations.nextcloud import migrate as nextcloud_migrate
from mealie.services.migrations import migration
from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/api/migrations", tags=["Migration"], dependencies=[Depends(get_current_user)])
@router.get("", response_model=List[Migrations])
def get_avaiable_nextcloud_imports():
def get_all_migration_options():
""" Returns a list of avaiable directories that can be imported into Mealie """
response_data = []
migration_dirs = [
@ -36,23 +35,18 @@ def get_avaiable_nextcloud_imports():
return response_data
@router.post("/{type}/{file_name}/import")
def import_nextcloud_directory(type: str, file_name: str, session: Session = Depends(generate_session)):
@router.post("/{import_type}/{file_name}/import")
def import_migration(import_type: migration.Migration, file_name: str, session: Session = Depends(generate_session)):
""" Imports all the recipes in a given directory """
file_path = app_dirs.MIGRATION_DIR.joinpath(type, file_name)
if type == "nextcloud":
return nextcloud_migrate(session, file_path)
elif type == "chowdown":
return chowdow_migrate(session, file_path)
else:
return SnackResponse.error("Incorrect Migration Type Selected")
file_path = app_dirs.MIGRATION_DIR.joinpath(import_type.value, file_name)
return migration.migrate(import_type, file_path, session)
@router.delete("/{type}/{file_name}/delete")
def delete_migration_data(type: str, file_name: str):
@router.delete("/{import_type}/{file_name}/delete")
def delete_migration_data(import_type: migration.Migration, file_name: str):
""" Removes migration data from the file system """
remove_path = app_dirs.MIGRATION_DIR.joinpath(type, file_name)
remove_path = app_dirs.MIGRATION_DIR.joinpath(import_type.value, file_name)
if remove_path.is_file():
remove_path.unlink()
@ -64,10 +58,10 @@ def delete_migration_data(type: str, file_name: str):
return SnackResponse.error(f"Migration Data Remove: {remove_path.absolute()}")
@router.post("/{type}/upload")
def upload_nextcloud_zipfile(type: str, archive: UploadFile = File(...)):
@router.post("/{import_type}/upload")
def upload_nextcloud_zipfile(import_type: migration.Migration, archive: UploadFile = File(...)):
""" Upload a .zip File to later be imported into Mealie """
dir = app_dirs.MIGRATION_DIR.joinpath(type)
dir = app_dirs.MIGRATION_DIR.joinpath(import_type.value)
dir.mkdir(parents=True, exist_ok=True)
dest = dir.joinpath(archive.filename)

View file

@ -1,5 +1,7 @@
import shutil
from enum import Enum
import requests
from fastapi import APIRouter, Depends, File, Form, HTTPException
from fastapi.responses import FileResponse
from mealie.db.database import db
@ -7,7 +9,7 @@ from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.recipe import Recipe, RecipeURLIn
from mealie.schema.snackbar import SnackResponse
from mealie.services.image.image import IMG_OPTIONS, delete_image, read_image, write_image
from mealie.services.image.image import IMG_OPTIONS, delete_image, read_image, rename_image, scrape_image, write_image
from mealie.services.scraper.scraper import create_from_url
from sqlalchemy.orm.session import Session
@ -61,6 +63,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
@ -117,3 +122,16 @@ def update_recipe_image(
db.recipes.update_image(session, recipe_slug, extension)
return response
@router.post("/{recipe_slug}/image")
def scrape_image_url(
recipe_slug: str,
url: RecipeURLIn,
current_user=Depends(get_current_user),
):
""" Removes an existing image and replaces it with the incoming file. """
scrape_image(url.url, recipe_slug)
return SnackResponse.success("Recipe Image Updated")

View file

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

@ -0,0 +1,20 @@
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends
from mealie.routes.deps import validate_file_token
from mealie.schema.snackbar import SnackResponse
from starlette.responses import FileResponse
router = APIRouter(prefix="/api/utils", tags=["Utils"], include_in_schema=True)
@router.get("/download")
async def download_file(file_path: Optional[Path] = Depends(validate_file_token)):
""" Uses a file token obtained by an active user to retrieve a file from the operating
system. """
print("File Name:", file_path)
if file_path.is_file():
return FileResponse(file_path, media_type="application/octet-stream", filename=file_path.name)
else:
return SnackResponse.error("No File Found")

View file

@ -1,14 +1,32 @@
#!/bin/sh
# Initialize Database Prerun
python mealie/db/init_db.py
python mealie/services/image/minify.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 /app/mealie/db/init_db.py
poetry run python /app/mealie/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,6 +23,10 @@ class RecipeCategoryResponse(CategoryBase):
schema_extra = {"example": {"id": 1, "name": "dinner", "recipes": [{}]}}
class TagIn(CategoryIn):
pass
class TagBase(CategoryBase):
pass

View file

@ -7,9 +7,10 @@ class AppInfo(CamelModel):
version: str
demo_status: bool
class DebugInfo(AppInfo):
api_port: int
api_docs: bool
db_type: str
sqlite_file: Path
default_group: str
default_group: str

View file

@ -1,6 +1,7 @@
from datetime import datetime
from typing import List
from mealie.schema.restore import RecipeImport
from pydantic.main import BaseModel
@ -23,3 +24,7 @@ class MigrationFile(BaseModel):
class Migrations(BaseModel):
type: str
files: List[MigrationFile] = []
class MigrationImport(RecipeImport):
pass

View file

@ -4,13 +4,16 @@ from datetime import datetime
from pathlib import Path
from typing import Union
from fastapi.logger import logger
from jinja2 import Template
from mealie.core import root_logger
from mealie.core.config import app_dirs
from mealie.db.database import db
from mealie.db.db_setup import create_session
from pathvalidate import sanitize_filename
from pydantic.main import BaseModel
logger = root_logger.get_logger()
class ExportDatabase:
def __init__(self, tag=None, templates=None) -> None:
@ -76,7 +79,8 @@ class ExportDatabase:
ExportDatabase._write_json_file(items, out_dir.joinpath(f"{folder_name}.json"))
else:
for item in items:
ExportDatabase._write_json_file(item, out_dir.joinpath(f"{item.get('name')}.json"))
filename = sanitize_filename(f"{item.get('name')}.json")
ExportDatabase._write_json_file(item, out_dir.joinpath(filename))
@staticmethod
def _write_json_file(data: Union[dict, list], out_file: Path):

View file

@ -1,13 +1,14 @@
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Union
import requests
from fastapi.logger import logger
from mealie.core import root_logger
from mealie.core.config import app_dirs
from mealie.services.image import minify
logger = root_logger.get_logger()
@dataclass
class ImageOptions:
@ -29,7 +30,6 @@ def read_image(recipe_slug: str, image_type: str = "original") -> Path:
Returns:
Path: [description]
"""
print(image_type)
recipe_slug = recipe_slug.split(".")[0] # Incase of File Name
recipe_image_dir = app_dirs.IMG_DIR.joinpath(recipe_slug)
@ -39,25 +39,40 @@ def read_image(recipe_slug: str, image_type: str = "original") -> Path:
return None
def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path.name:
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:
try:
delete_image(recipe_slug)
except:
pass
image_dir = Path(app_dirs.IMG_DIR.joinpath(f"{recipe_slug}"))
image_dir.mkdir()
image_dir.mkdir(exist_ok=True, parents=True)
extension = extension.replace(".", "")
image_path = image_dir.joinpath(f"original.{extension}")
if isinstance(file_data, bytes):
if isinstance(file_data, Path):
shutil.copy2(file_data, image_path)
elif 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()
print(image_path)
minify.minify_image(image_path)
return image_path
@ -94,7 +109,7 @@ def scrape_image(image_url: str, slug: str) -> Path:
write_image(slug, r.raw, filename.suffix)
filename.unlink()
filename.unlink(missing_ok=True)
return slug

View file

@ -1,11 +1,33 @@
import shutil
from dataclasses import dataclass
from pathlib import Path
from mealie.core import root_logger
from mealie.core.config import app_dirs
from PIL import Image, UnidentifiedImageError
from mealie.db.database import db
from mealie.db.db_setup import create_session
from PIL import Image
from sqlalchemy.orm.session import Session
logger = root_logger.get_logger()
def minify_image(image_file: Path, min_dest: Path, tiny_dest: Path):
@dataclass
class ImageSizes:
org: str
min: str
tiny: str
def get_image_sizes(org_img: Path, min_img: Path, tiny_img: Path) -> ImageSizes:
return ImageSizes(
org=sizeof_fmt(org_img),
min=sizeof_fmt(min_img),
tiny=sizeof_fmt(tiny_img),
)
def minify_image(image_file: Path) -> ImageSizes:
"""Minifies an image in it's original file format. Quality is lost
Args:
@ -13,6 +35,11 @@ def minify_image(image_file: Path, min_dest: Path, tiny_dest: Path):
min_dest (Path): FULL Destination File Path
tiny_dest (Path): FULL Destination File Path
"""
min_dest = image_file.parent.joinpath(f"min-original{image_file.suffix}")
tiny_dest = image_file.parent.joinpath(f"tiny-original{image_file.suffix}")
if min_dest.exists() and tiny_dest.exists():
return
try:
img = Image.open(image_file)
basewidth = 720
@ -24,10 +51,16 @@ def minify_image(image_file: Path, min_dest: Path, tiny_dest: Path):
tiny_image = crop_center(img)
tiny_image.save(tiny_dest, quality=70)
except:
except Exception:
shutil.copy(image_file, min_dest)
shutil.copy(image_file, tiny_dest)
image_sizes = get_image_sizes(image_file, min_dest, tiny_dest)
logger.info(f"{image_file.name} Minified: {image_sizes.org} -> {image_sizes.min} -> {image_sizes.tiny}")
return image_sizes
def crop_center(pil_img, crop_width=300, crop_height=300):
img_width, img_height = pil_img.size
@ -41,7 +74,10 @@ def crop_center(pil_img, crop_width=300, crop_height=300):
)
def sizeof_fmt(size, decimal_places=2):
def sizeof_fmt(file_path: Path, decimal_places=2):
if not file_path.exists():
return "(File Not Found)"
size = file_path.stat().st_size
for unit in ["B", "kB", "MB", "GB", "TB", "PB"]:
if size < 1024.0 or unit == "PiB":
break
@ -56,31 +92,46 @@ def move_all_images():
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}"))
new_file = new_folder.joinpath(f"original{image_file.suffix}")
if new_file.is_file():
new_file.unlink()
image_file.rename(new_file)
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...")
logger.info("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)
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)
print(f"{image.name} Minified: {org_size} -> {dest_size} -> {tiny_size}")
print("Finished Minification Check")
logger.info("Finished Minification Check")
if __name__ == "__main__":
migrate_images()
validate_slugs_in_database()

View file

@ -0,0 +1,173 @@
import json
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any, Callable, Optional
import yaml
from mealie.core import root_logger
from mealie.db.database import db
from mealie.schema.migration import MigrationImport
from mealie.schema.recipe import Recipe
from mealie.services.image import image, minify
from mealie.services.scraper.cleaner import Cleaner
from mealie.utils.unzip import unpack_zip
from pydantic import BaseModel
logger = root_logger.get_logger()
class MigrationAlias(BaseModel):
"""A datatype used by MigrationBase to pre-process a recipe dictionary to rewrite
the alias key in the dictionary, if it exists, to the key. If set a `func` attribute
will be called on the value before assigning the value to the new key
"""
key: str
alias: str
func: Optional[Callable] = None
class MigrationBase(BaseModel):
migration_report: list[MigrationImport] = []
migration_file: Path
session: Optional[Any]
key_aliases: Optional[list[MigrationAlias]]
@property
def temp_dir(self) -> TemporaryDirectory:
"""unpacks the migration_file into a temporary directory
that can be used as a context manager.
Returns:
TemporaryDirectory:
"""
return unpack_zip(self.migration_file)
@staticmethod
def json_reader(json_file: Path) -> dict:
print(json_file)
with open(json_file, "r") as f:
return json.loads(f.read())
@staticmethod
def yaml_reader(yaml_file: Path) -> dict:
"""A helper function to read in a yaml file from a Path. This assumes that the
first yaml document is the recipe data and the second, if exists, is the description.
Args:
yaml_file (Path): Path to yaml file
Returns:
dict: representing the yaml file as a dictionary
"""
with open(yaml_file, "r") as f:
contents = f.read().split("---")
recipe_data = {}
for x, document in enumerate(contents):
# Check if None or Empty String
if document is None or document == "":
continue
# Check if 'title:' present
elif "title:" in document:
recipe_data.update(yaml.safe_load(document))
else:
recipe_data["description"] = document
return recipe_data
@staticmethod
def glob_walker(directory: Path, glob_str: str, return_parent=True) -> list[Path]: # TODO:
"""A Helper function that will return the glob matches for the temporary directotry
that was unpacked and passed in as the `directory` parameter. If `return_parent` is
True the return Paths will be the parent directory for the file that was matched. If
false the file itself will be returned.
Args:
directory (Path): Path to search directory
glob_str ([type]): glob style match string
return_parent (bool, optional): To return parent directory of match. Defaults to True.
Returns:
list[Path]:
"""
directory = directory if isinstance(directory, Path) else Path(directory)
matches = []
for match in directory.glob(glob_str):
if return_parent:
matches.append(match.parent)
else:
matches.append(match)
return matches
@staticmethod
def import_image(src: Path, dest_slug: str):
"""Read the successful migrations attribute and for each import the image
appropriately into the image directory. Minification is done in mass
after the migration occurs.
"""
image.write_image(dest_slug, src, extension=src.suffix)
def rewrite_alias(self, recipe_dict: dict) -> dict:
"""A helper function to reassign attributes by an alias using a list
of MigrationAlias objects to rewrite the alias attribute found in the recipe_dict
to a
Args:
recipe_dict (dict): [description]
key_aliases (list[MigrationAlias]): [description]
Returns:
dict: [description]
"""
if not self.key_aliases:
return recipe_dict
for alias in self.key_aliases:
try:
prop_value = recipe_dict.pop(alias.alias)
except KeyError:
logger.info(f"Key {alias.alias} Not Found. Skipping...")
continue
if alias.func:
prop_value = alias.func(prop_value)
recipe_dict[alias.key] = prop_value
return recipe_dict
def clean_recipe_dictionary(self, recipe_dict) -> Recipe:
"""Calls the rewrite_alias function and the Cleaner.clean function on a
dictionary and returns the result unpacked into a Recipe object"""
recipe_dict = self.rewrite_alias(recipe_dict)
recipe_dict = Cleaner.clean(recipe_dict, url=recipe_dict.get("orgURL", None))
return Recipe(**recipe_dict)
def import_recipes_to_database(self, validated_recipes: list[Recipe]) -> None:
"""Used as a single access point to process a list of Recipe objects into the
database in a predictable way. If an error occurs the session is rolled back
and the process will continue. All import information is appended to the
'migration_report' attribute to be returned to the frontend for display.
Args:
validated_recipes (list[Recipe]):
"""
for recipe in validated_recipes:
exception = ""
status = False
try:
db.recipes.create(self.session, recipe.dict())
status = True
except Exception as inst:
exception = inst
self.session.rollback()
import_status = MigrationImport(slug=recipe.slug, name=recipe.name, status=status, exception=str(exception))
self.migration_report.append(import_status)

View file

@ -1,92 +1,46 @@
import shutil
from pathlib import Path
from typing import Optional
import yaml
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.utils.unzip import unpack_zip
from mealie.schema.migration import MigrationImport
from mealie.services.migrations import helpers
from mealie.services.migrations._migration_base import MigrationAlias, MigrationBase
from sqlalchemy.orm.session import Session
try:
from yaml import CLoader as Loader
except ImportError:
from yaml import Loader
class ChowdownMigration(MigrationBase):
key_aliases: Optional[list[MigrationAlias]] = [
MigrationAlias(key="name", alias="title", func=None),
MigrationAlias(key="recipeIngredient", alias="ingredients", func=None),
MigrationAlias(key="recipeInstructions", alias="directions", func=None),
MigrationAlias(key="tags", alias="tags", func=helpers.split_by_comma),
]
def read_chowdown_file(recipe_file: Path) -> Recipe:
"""Parse through the yaml file to try and pull out the relavent information.
Some issues occur when ":" are used in the text. I have no put a lot of effort
into this so there may be better ways of going about it. Currently, I get about 80-90%
of recipes from repos I've tried.
def migrate(session: Session, zip_path: Path) -> list[MigrationImport]:
cd_migration = ChowdownMigration(migration_file=zip_path, session=session)
Args:
recipe_file (Path): Path to the .yml file
Returns:
Recipe: Recipe class object
"""
with open(recipe_file, "r") as stream:
recipe_description: str = str
recipe_data: dict = {}
try:
for x, item in enumerate(yaml.load_all(stream, Loader=Loader)):
if x == 0:
recipe_data = item
elif x == 1:
recipe_description = str(item)
except yaml.YAMLError:
return
reformat_data = {
"name": recipe_data.get("title"),
"description": recipe_description,
"image": recipe_data.get("image", ""),
"recipeIngredient": recipe_data.get("ingredients"),
"recipeInstructions": recipe_data.get("directions"),
"tags": recipe_data.get("tags").split(","),
}
reformated_list = [{"text": instruction} for instruction in reformat_data["recipeInstructions"]]
reformat_data["recipeInstructions"] = reformated_list
return Recipe(**reformat_data)
def chowdown_migrate(session: Session, zip_file: Path):
temp_dir = unpack_zip(zip_file)
with temp_dir as dir:
with cd_migration.temp_dir as dir:
chow_dir = next(Path(dir).iterdir())
image_dir = app_dirs.TEMP_DIR.joinpath(chow_dir, "images")
recipe_dir = app_dirs.TEMP_DIR.joinpath(chow_dir, "_recipes")
failed_recipes = []
successful_recipes = []
for recipe in recipe_dir.glob("*.md"):
try:
new_recipe = read_chowdown_file(recipe)
db.recipes.create(session, new_recipe.dict())
successful_recipes.append(new_recipe.name)
except Exception as inst:
session.rollback()
logger.error(inst)
failed_recipes.append(recipe.stem)
recipes_as_dicts = [y for x in recipe_dir.glob("*.md") if (y := ChowdownMigration.yaml_reader(x)) is not None]
failed_images = []
for image in image_dir.iterdir():
try:
if image.stem not in failed_recipes:
shutil.copy(image, app_dirs.IMG_DIR.joinpath(image.name))
except Exception as inst:
logger.error(inst)
failed_images.append(image.name)
report = {"successful": successful_recipes, "failed": failed_recipes}
recipes = [cd_migration.clean_recipe_dictionary(x) for x in recipes_as_dicts]
return report
cd_migration.import_recipes_to_database(recipes)
recipe_lookup = {r.slug: r for r in recipes}
for report in cd_migration.migration_report:
if report.status:
try:
original_image = recipe_lookup.get(report.slug).image
cd_image = image_dir.joinpath(original_image)
except StopIteration:
continue
if cd_image:
ChowdownMigration.import_image(cd_image, report.slug)
return cd_migration.migration_report

View file

@ -0,0 +1,12 @@
def split_by_comma(tag_string: str):
"""Splits a single string by ',' performs a line strip and then title cases the resulting string
Args:
tag_string (str): [description]
Returns:
[type]: [description]
"""
if not isinstance(tag_string, str):
return None
return [x.title().lstrip() for x in tag_string.split(",") if x != ""]

View file

@ -0,0 +1,49 @@
from enum import Enum
from pathlib import Path
from mealie.core import root_logger
from mealie.schema.migration import MigrationImport
from mealie.services.migrations import chowdown, nextcloud
from sqlalchemy.orm.session import Session
logger = root_logger.get_logger()
class Migration(str, Enum):
"""The class defining the supported types of migrations for Mealie. Pass the
class attribute of the class instead of the string when using.
"""
nextcloud = "nextcloud"
chowdown = "chowdown"
def migrate(migration_type: str, file_path: Path, session: Session) -> list[MigrationImport]:
"""The new entry point for accessing migrations within the 'migrations' service.
Using the 'Migrations' enum class as a selector for migration_type to direct which function
to call. All migrations will return a MigrationImport object that is built for displaying
detailed information on the frontend. This will provide a single point of access
Args:
migration_type (str): a string option representing the migration type. See Migration attributes for options
file_path (Path): Path to the zip file containing the data
session (Session): a SqlAlchemy Session
Returns:
list[MigrationImport]: [description]
"""
logger.info(f"Starting Migration from {migration_type}")
if migration_type == Migration.nextcloud.value:
migration_imports = nextcloud.migrate(session, file_path)
elif migration_type == Migration.chowdown.value:
migration_imports = chowdown.migrate(session, file_path)
else:
return []
logger.info(f"Finishing Migration from {migration_type}")
return migration_imports

View file

@ -1,90 +1,69 @@
import json
import logging
import shutil
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from mealie.core.config import app_dirs
from mealie.db.database import db
from mealie.schema.recipe import Recipe
from mealie.services.scraper.cleaner import Cleaner
from mealie.schema.migration import MigrationImport
from mealie.services.migrations import helpers
from mealie.services.migrations._migration_base import MigrationAlias, MigrationBase
from slugify import slugify
from sqlalchemy.orm.session import Session
def process_selection(selection: Path) -> Path:
if selection.is_dir():
return selection
elif selection.suffix == ".zip":
with zipfile.ZipFile(selection, "r") as zip_ref:
nextcloud_dir = app_dirs.TEMP_DIR.joinpath("nextcloud")
nextcloud_dir.mkdir(exist_ok=False, parents=True)
zip_ref.extractall(nextcloud_dir)
return nextcloud_dir
else:
return None
@dataclass
class NextcloudDir:
name: str
recipe: dict
image: Optional[Path]
@property
def slug(self):
return slugify(self.recipe.get("name"))
@classmethod
def from_dir(cls, dir: Path):
try:
json_file = next(dir.glob("*.json"))
except StopIteration:
return None
try: # TODO: There's got to be a better way to do this.
image_file = next(dir.glob("full.*"))
except StopIteration:
image_file = None
return cls(name=dir.name, recipe=NextcloudMigration.json_reader(json_file), image=image_file)
def import_recipes(recipe_dir: Path) -> Recipe:
image = False
for file in recipe_dir.glob("full.*"):
image = file
for file in recipe_dir.glob("*.json"):
recipe_file = file
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"
recipe = Recipe(**recipe_data)
if image:
shutil.copy(image, app_dirs.IMG_DIR.joinpath(image_name))
return recipe
class NextcloudMigration(MigrationBase):
key_aliases: Optional[list[MigrationAlias]] = [
MigrationAlias(key="tags", alias="keywords", func=helpers.split_by_comma),
MigrationAlias(key="orgURL", alias="url", func=None),
]
def prep():
try:
shutil.rmtree(app_dirs.TEMP_DIR)
except:
pass
app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True)
def migrate(session: Session, zip_path: Path) -> list[MigrationImport]:
nc_migration = NextcloudMigration(migration_file=zip_path, session=session)
def cleanup():
shutil.rmtree(app_dirs.TEMP_DIR)
with nc_migration.temp_dir as dir:
potential_recipe_dirs = NextcloudMigration.glob_walker(dir, glob_str="**/[!.]*.json", return_parent=True)
# nextcloud_dirs = [NextcloudDir.from_dir(x) for x in potential_recipe_dirs]
nextcloud_dirs = {y.slug: y for x in potential_recipe_dirs if (y := NextcloudDir.from_dir(x))}
# nextcloud_dirs = {x.slug: x for x in nextcloud_dirs}
def migrate(session, selection: str):
prep()
app_dirs.MIGRATION_DIR.mkdir(exist_ok=True)
selection = app_dirs.MIGRATION_DIR.joinpath(selection)
all_recipes = []
for _, nc_dir in nextcloud_dirs.items():
recipe = nc_migration.clean_recipe_dictionary(nc_dir.recipe)
all_recipes.append(recipe)
nextcloud_dir = process_selection(selection)
nc_migration.import_recipes_to_database(all_recipes)
successful_imports = []
failed_imports = []
for dir in nextcloud_dir.iterdir():
if dir.is_dir():
for report in nc_migration.migration_report:
try:
recipe = import_recipes(dir)
db.recipes.create(session, recipe.dict())
if report.status:
nc_dir: NextcloudDir = nextcloud_dirs[report.slug]
if nc_dir.image:
NextcloudMigration.import_image(nc_dir.image, nc_dir.slug)
successful_imports.append(recipe.name)
except:
logging.error(f"Failed Nextcloud Import: {dir.name}")
logging.exception("")
failed_imports.append(dir.name)
cleanup()
return {"successful": successful_imports, "failed": failed_imports}
return nc_migration.migration_report

View file

@ -1,15 +1,18 @@
from apscheduler.schedulers.background import BackgroundScheduler
from mealie.core import root_logger
from mealie.db.database import db
from mealie.db.db_setup import create_session
from fastapi.logger import logger
from mealie.schema.user import GroupInDB
from mealie.services.backups.exports import auto_backup_job
from mealie.services.scheduler.global_scheduler import scheduler
from mealie.services.scheduler.scheduler_utils import Cron, cron_parser
from mealie.utils.post_webhooks import post_webhooks
logger = root_logger.get_logger()
# TODO Fix Scheduler
@scheduler.scheduled_job(trigger="interval", minutes=30)
def update_webhook_schedule():
"""

View file

@ -128,8 +128,10 @@ class Cleaner:
@staticmethod
def ingredient(ingredients: list) -> str:
return [Cleaner.html(html.unescape(ing)) for ing in ingredients]
if ingredients:
return [Cleaner.html(html.unescape(ing)) for ing in ingredients]
else:
return []
@staticmethod
def yield_amount(yld) -> str:

View file

@ -3,15 +3,17 @@ from typing import List
import requests
import scrape_schema_recipe
from mealie.core import root_logger
from mealie.core.config import app_dirs
from fastapi.logger import logger
from mealie.services.image.image import scrape_image
from mealie.schema.recipe import Recipe
from mealie.services.image.image import scrape_image
from mealie.services.scraper import open_graph
from mealie.services.scraper.cleaner import Cleaner
LAST_JSON = app_dirs.DEBUG_DIR.joinpath("last_recipe.json")
logger = root_logger.get_logger()
def create_from_url(url: str) -> Recipe:
"""Main entry point for generating a recipe from a URL. Pass in a URL and

4121
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,63 +1,64 @@
[tool.poetry]
name = "mealie"
version = "0.3.0"
description = "A Recipe Manager"
authors = ["Hayden <hay-kot@pm.me>"]
license = "MIT"
[tool.poetry.scripts]
start = "mealie.app:main"
[tool.poetry.dependencies]
python = "^3.9"
aiofiles = "0.5.0"
aniso8601 = "7.0.0"
appdirs = "1.4.4"
fastapi = "^0.63.0"
uvicorn = {extras = ["standard"], version = "^0.13.0"}
APScheduler = "^3.6.3"
SQLAlchemy = "^1.3.22"
Jinja2 = "^2.11.2"
python-dotenv = "^0.15.0"
python-slugify = "^4.0.1"
requests = "^2.25.1"
PyYAML = "^5.3.1"
extruct = "^0.12.0"
scrape-schema-recipe = "^0.1.3"
python-multipart = "^0.0.5"
fastapi-camelcase = "^1.0.2"
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"
black = "^20.8b1"
pytest = "^6.2.1"
pytest-cov = "^2.11.0"
mkdocs-material = "^7.0.2"
flake8 = "^3.9.0"
coverage = "^5.5"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.black]
line-length = 120
[tool.pytest.ini_options]
minversion = "6.0"
addopts = "-ra -q --cov=mealie"
python_files = 'test_*'
python_classes = '*Tests'
python_functions = 'test_*'
testpaths = [
"tests",
]
[tool.coverage.report]
[tool.poetry]
name = "mealie"
version = "0.3.0"
description = "A Recipe Manager"
authors = ["Hayden <hay-kot@pm.me>"]
license = "MIT"
[tool.poetry.scripts]
start = "mealie.app:main"
[tool.poetry.dependencies]
python = "^3.9"
aiofiles = "0.5.0"
aniso8601 = "7.0.0"
appdirs = "1.4.4"
fastapi = "^0.63.0"
uvicorn = {extras = ["standard"], version = "^0.13.0"}
APScheduler = "^3.6.3"
SQLAlchemy = "^1.3.22"
Jinja2 = "^2.11.2"
python-dotenv = "^0.15.0"
python-slugify = "^4.0.1"
requests = "^2.25.1"
PyYAML = "^5.3.1"
extruct = "^0.12.0"
scrape-schema-recipe = "^0.1.3"
python-multipart = "^0.0.5"
fastapi-camelcase = "^1.0.2"
bcrypt = "^3.2.0"
python-jose = "^3.2.0"
passlib = "^1.7.4"
lxml = "4.6.2"
Pillow = "^8.2.0"
pathvalidate = "^2.4.1"
[tool.poetry.dev-dependencies]
pylint = "^2.6.0"
black = "^20.8b1"
pytest = "^6.2.1"
pytest-cov = "^2.11.0"
mkdocs-material = "^7.0.2"
flake8 = "^3.9.0"
coverage = "^5.5"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.black]
line-length = 120
[tool.pytest.ini_options]
minversion = "6.0"
addopts = "-ra -q --cov=mealie"
python_files = 'test_*'
python_classes = '*Tests'
python_functions = 'test_*'
testpaths = [
"tests",
]
[tool.coverage.report]
skip_empty = true

View file

@ -9,7 +9,6 @@ from tests.app_routes import AppRoutes
from tests.test_config import TEST_CHOWDOWN_DIR, TEST_NEXTCLOUD_DIR
# Chowdown
@pytest.fixture(scope="session")
def chowdown_zip():
zip = TEST_CHOWDOWN_DIR.joinpath("test_chowdown-gh-pages.zip")
@ -42,14 +41,10 @@ def test_import_chowdown_directory(api_client: TestClient, api_routes: AppRoutes
assert response.status_code == 200
report = json.loads(response.content)
assert report["failed"] == []
reports = json.loads(response.content)
expected_slug = "roasted-okra"
recipe_url = api_routes.recipes_recipe_slug(expected_slug)
response = api_client.get(recipe_url)
assert response.status_code == 200
for report in reports:
assert report.get("status") is True
def test_delete_chowdown_migration_data(api_client: TestClient, api_routes: AppRoutes, chowdown_zip: Path, token):
@ -91,13 +86,9 @@ def test_import_nextcloud_directory(api_client: TestClient, api_routes: AppRoute
assert response.status_code == 200
report = json.loads(response.content)
assert report["failed"] == []
expected_slug = "air-fryer-shrimp"
recipe_url = api_routes.recipes_recipe_slug(expected_slug)
response = api_client.get(recipe_url)
assert response.status_code == 200
reports = json.loads(response.content)
for report in reports:
assert report.get("status") is True
def test_delete__nextcloud_migration_data(api_client: TestClient, api_routes: AppRoutes, nextcloud_zip: Path, token):

View file

@ -80,7 +80,7 @@ def test_cleaner_instructions(instructions):
def test_html_with_recipe_data():
path = TEST_RAW_HTML.joinpath("healthy_pasta_bake_60759.html")
url = "https://www.bbc.co.uk/food/recipes/healthy_pasta_bake_60759"
recipe_data = extract_recipe_from_html(open(path).read(), url)
recipe_data = extract_recipe_from_html(open(path, encoding="utf8").read(), url)
assert len(recipe_data["name"]) > 10
assert len(recipe_data["slug"]) > 10

View file

@ -29,7 +29,7 @@ def test_non_default_settings(monkeypatch):
monkeypatch.setenv("DEFAULT_GROUP", "Test Group")
monkeypatch.setenv("DEFAULT_PASSWORD", "Test Password")
monkeypatch.setenv("API_PORT", "8000")
monkeypatch.setenv("API_DOCS", False)
monkeypatch.setenv("API_DOCS", "False")
app_settings = AppSettings()

View file

@ -1,39 +1,39 @@
from pathlib import Path
# import shutil
# from pathlib import Path
import pytest
from mealie.core.config import app_dirs
from mealie.schema.recipe import Recipe
from mealie.services.migrations.nextcloud import cleanup, import_recipes, prep, process_selection
from tests.test_config import TEST_NEXTCLOUD_DIR
# import pytest
# from mealie.core.config import app_dirs
# from mealie.schema.recipe import Recipe
# from tests.test_config import TEST_NEXTCLOUD_DIR
CWD = Path(__file__).parent
TEST_NEXTCLOUD_DIR
TEMP_NEXTCLOUD = app_dirs.TEMP_DIR.joinpath("nextcloud")
# CWD = Path(__file__).parent
# TEST_NEXTCLOUD_DIR
# TEMP_NEXTCLOUD = app_dirs.TEMP_DIR.joinpath("nextcloud")
@pytest.mark.parametrize(
"file_name,final_path",
[("nextcloud.zip", TEMP_NEXTCLOUD)],
)
def test_zip_extraction(file_name: str, final_path: Path):
prep()
zip = TEST_NEXTCLOUD_DIR.joinpath(file_name)
dir = process_selection(zip)
# @pytest.mark.parametrize(
# "file_name,final_path",
# [("nextcloud.zip", TEMP_NEXTCLOUD)],
# )
# def test_zip_extraction(file_name: str, final_path: Path):
# prep()
# zip = TEST_NEXTCLOUD_DIR.joinpath(file_name)
# dir = process_selection(zip)
assert dir == final_path
cleanup()
assert dir.exists() is False
# assert dir == final_path
# cleanup()
# assert dir.exists() is False
@pytest.mark.parametrize(
"recipe_dir",
[
TEST_NEXTCLOUD_DIR.joinpath("Air Fryer Shrimp"),
TEST_NEXTCLOUD_DIR.joinpath("Chicken Parmigiana"),
TEST_NEXTCLOUD_DIR.joinpath("Skillet Shepherd's Pie"),
],
)
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)
# @pytest.mark.parametrize(
# "recipe_dir",
# [
# TEST_NEXTCLOUD_DIR.joinpath("Air Fryer Shrimp"),
# TEST_NEXTCLOUD_DIR.joinpath("Chicken Parmigiana"),
# TEST_NEXTCLOUD_DIR.joinpath("Skillet Shepherd's Pie"),
# ],
# )
# def test_nextcloud_migration(recipe_dir: Path):
# recipe = import_recipes(recipe_dir)
# assert isinstance(recipe, Recipe)
# shutil.rmtree(app_dirs.IMG_DIR.joinpath(recipe.image), ignore_errors=True)