mirror of
https://github.com/hay-kot/mealie.git
synced 2025-08-22 14:33:33 -07:00
Merge remote-tracking branch 'upstream/dev' into locale-settings
This commit is contained in:
commit
cedefea562
83 changed files with 3877 additions and 2901 deletions
2
.github/workflows/pytest.yml
vendored
2
.github/workflows/pytest.yml
vendored
|
@ -11,6 +11,8 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
|
env:
|
||||||
|
PRODUCTION: false
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
#----------------------------------------------
|
#----------------------------------------------
|
||||||
|
|
9
.vscode/tasks.json
vendored
9
.vscode/tasks.json
vendored
|
@ -52,6 +52,15 @@
|
||||||
"group": "groupA"
|
"group": "groupA"
|
||||||
},
|
},
|
||||||
"problemMatcher": []
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Run python tests",
|
||||||
|
"command": "make test",
|
||||||
|
"type": "shell",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ RUN apk add --no-cache libxml2-dev \
|
||||||
zlib-dev
|
zlib-dev
|
||||||
|
|
||||||
|
|
||||||
ENV ENV True
|
ENV PRODUCTION true
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
WORKDIR /app/
|
WORKDIR /app/
|
||||||
|
|
||||||
|
@ -40,14 +40,15 @@ RUN apk add --update --no-cache --virtual .build-deps \
|
||||||
cd /app/ && poetry install --no-root --no-dev && \
|
cd /app/ && poetry install --no-root --no-dev && \
|
||||||
apk --purge del .build-deps
|
apk --purge del .build-deps
|
||||||
|
|
||||||
|
|
||||||
COPY ./mealie /app/mealie
|
COPY ./mealie /app/mealie
|
||||||
RUN poetry install --no-dev
|
RUN poetry install --no-dev
|
||||||
|
|
||||||
COPY ./Caddyfile /app
|
COPY ./Caddyfile /app
|
||||||
COPY ./dev/data/templates /app/data/templates
|
COPY ./dev/data/templates /app/data/templates
|
||||||
COPY --from=build-stage /app/dist /app/dist
|
COPY --from=build-stage /app/dist /app/dist
|
||||||
|
|
||||||
VOLUME [ "/app/data/" ]
|
VOLUME [ "/app/data/" ]
|
||||||
|
|
||||||
RUN chmod +x /app/mealie/run.sh
|
RUN chmod +x /app/mealie/run.sh
|
||||||
CMD /app/mealie/run.sh
|
CMD /app/mealie/run.sh
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,8 @@ FROM python:3
|
||||||
|
|
||||||
WORKDIR /app/
|
WORKDIR /app/
|
||||||
|
|
||||||
|
ENV PRODUCTION false
|
||||||
|
|
||||||
RUN apt-get update -y && \
|
RUN apt-get update -y && \
|
||||||
apt-get install -y python-pip python-dev
|
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 poetry.lock* in case it doesn't exist in the repo
|
||||||
COPY ./pyproject.toml /app/
|
COPY ./pyproject.toml /app/
|
||||||
|
|
||||||
# RUN poetry install
|
|
||||||
|
|
||||||
COPY ./mealie /app/mealie
|
COPY ./mealie /app/mealie
|
||||||
|
|
||||||
RUN poetry install
|
RUN poetry install
|
||||||
|
|
||||||
RUN ["poetry", "run", "python", "mealie/db/init_db.py"]
|
RUN chmod +x /app/mealie/run.sh
|
||||||
RUN ["poetry", "run", "python", "mealie/services/image/minify.py"]
|
CMD ["/app/mealie/run.sh", "reload"]
|
||||||
CMD ["poetry", "run", "python", "mealie/app.py"]
|
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
# Usage: ./download-and-extract.sh something https://example.com/something.tar.gz
|
|
||||||
|
|
||||||
archive=$1
|
|
||||||
url=$2
|
|
||||||
|
|
||||||
if [ ! -f $archive.tar.gz ]; then
|
|
||||||
wget -O $archive.tar.gz $url
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -r $archive
|
|
||||||
tar -xvzf $archive.tar.gz
|
|
|
@ -1,12 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# install webp
|
|
||||||
|
|
||||||
archive=libwebp-1.2.0
|
|
||||||
|
|
||||||
./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/master/$archive.tar.gz
|
|
||||||
|
|
||||||
pushd $archive
|
|
||||||
|
|
||||||
./configure --prefix=/usr --enable-libwebpmux --enable-libwebpdemux && make -j4 && sudo make -j4 install
|
|
||||||
|
|
||||||
popd
|
|
|
@ -29,7 +29,7 @@ services:
|
||||||
db_type: sqlite
|
db_type: sqlite
|
||||||
TZ: America/Anchorage # Specify Correct Timezone for Date/Time to line up correctly.
|
TZ: America/Anchorage # Specify Correct Timezone for Date/Time to line up correctly.
|
||||||
volumes:
|
volumes:
|
||||||
- ./app_data:/app_data
|
- ./dev/data:/app/dev/data
|
||||||
- ./mealie:/app/mealie
|
- ./mealie:/app/mealie
|
||||||
|
|
||||||
# Mkdocs
|
# Mkdocs
|
||||||
|
|
93
docs/docs/api-usage/bulk-url-import.md
Normal file
93
docs/docs/api-usage/bulk-url-import.md
Normal 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)
|
||||||
|
```
|
||||||
|
|
39
docs/docs/api-usage/getting-started.md
Normal file
39
docs/docs/api-usage/getting-started.md
Normal 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.
|
||||||
|
|
||||||
|

|
30
docs/docs/api-usage/home-assistant.md
Normal file
30
docs/docs/api-usage/home-assistant.md
Normal 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```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.
|
BIN
docs/docs/assets/img/home-assistant-card.png
Normal file
BIN
docs/docs/assets/img/home-assistant-card.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 802 KiB |
|
@ -4,6 +4,13 @@
|
||||||
|
|
||||||
**Database Version: v0.4.0**
|
**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
|
- Add markdown support for ingredients - Resolves #32
|
||||||
- Ingredients editor improvements
|
- Ingredients editor improvements
|
||||||
- Fix Tags/Categories render problems on recipes
|
- Fix Tags/Categories render problems on recipes
|
||||||
|
@ -19,5 +26,10 @@
|
||||||
- A smaller image is used for recipe cards
|
- A smaller image is used for recipe cards
|
||||||
- A 'tiny' image is used for search images.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
|
||||||
|
|
24
docs/docs/changelog/v0.4.2.md
Normal file
24
docs/docs/changelog/v0.4.2.md
Normal 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)
|
|
@ -1,5 +1,8 @@
|
||||||
# Usage
|
# Usage
|
||||||
|
|
||||||
|
## Getting a Token
|
||||||
|
Bla Bla
|
||||||
|
|
||||||
## Key Components
|
## Key Components
|
||||||
### Recipe Extras
|
### 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.
|
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
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
### Bulk import
|
|
||||||
Recipes can be imported in bulk from a file containing a list of URLs. This can be done using the following bash or python scripts with the `list` file containing one URL per line.
|
|
||||||
|
|
||||||
#### Bash
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
function authentification () {
|
|
||||||
auth=$(curl -X 'POST' \
|
|
||||||
"$3/api/auth/token" \
|
|
||||||
-H 'accept: application/json' \
|
|
||||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
|
||||||
-d 'grant_type=&username='$1'&password='$2'&scope=&client_id=&client_secret=')
|
|
||||||
|
|
||||||
echo $auth | sed -e 's/.*token":"\(.*\)",.*/\1/'
|
|
||||||
}
|
|
||||||
|
|
||||||
function import_from_file () {
|
|
||||||
while IFS= read -r line
|
|
||||||
do
|
|
||||||
echo $line
|
|
||||||
curl -X 'POST' \
|
|
||||||
"$3/api/recipes/create-url" \
|
|
||||||
-H "Authorization: Bearer $2" \
|
|
||||||
-H 'accept: application/json' \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"url": "'$line'" }'
|
|
||||||
echo
|
|
||||||
done < "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
input="list"
|
|
||||||
mail="changeme@email.com"
|
|
||||||
password="MyPassword"
|
|
||||||
mealie_url=http://localhost:9000
|
|
||||||
|
|
||||||
|
|
||||||
token=$(authentification $mail $password $mealie_url)
|
|
||||||
import_from_file $input $token $mealie_url
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Python
|
|
||||||
```python
|
|
||||||
import requests
|
|
||||||
import re
|
|
||||||
|
|
||||||
def authentification(mail, password, mealie_url):
|
|
||||||
headers = {
|
|
||||||
'accept': 'application/json',
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
}
|
|
||||||
data = {
|
|
||||||
'grant_type': '',
|
|
||||||
'username': mail,
|
|
||||||
'password': password,
|
|
||||||
'scope': '',
|
|
||||||
'client_id': '',
|
|
||||||
'client_secret': ''
|
|
||||||
}
|
|
||||||
auth = requests.post(mealie_url + "/api/auth/token", headers=headers, data=data)
|
|
||||||
token = re.sub(r'.*token":"(.*)",.*', r'\1', auth.text)
|
|
||||||
return token
|
|
||||||
|
|
||||||
def import_from_file(input_file, token, mealie_url):
|
|
||||||
with open(input_file) as fp:
|
|
||||||
for l in fp:
|
|
||||||
line = re.sub(r'(.*)\n', r'\1', l)
|
|
||||||
print(line)
|
|
||||||
headers = {
|
|
||||||
'Authorization': "Bearer " + token,
|
|
||||||
'accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
data = {
|
|
||||||
'url': line
|
|
||||||
}
|
|
||||||
response = requests.post(mealie_url + "/api/recipes/create-url", headers=headers, json=data)
|
|
||||||
print(response.text)
|
|
||||||
|
|
||||||
input_file="list"
|
|
||||||
mail="changeme@email.com"
|
|
||||||
password="MyPassword"
|
|
||||||
mealie_url="http://localhost:9000"
|
|
||||||
|
|
||||||
|
|
||||||
token = authentification(mail, password, mealie_url)
|
|
||||||
import_from_file(input_file, token, mealie_url)
|
|
||||||
```
|
|
||||||
|
|
||||||
Have Ideas? Submit a PR!
|
Have Ideas? Submit a PR!
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -56,7 +56,6 @@ nav:
|
||||||
- Organizing Recipes: "getting-started/organizing-recipes.md"
|
- Organizing Recipes: "getting-started/organizing-recipes.md"
|
||||||
- Planning Meals: "getting-started/meal-planner.md"
|
- Planning Meals: "getting-started/meal-planner.md"
|
||||||
- iOS Shortcuts: "getting-started/ios.md"
|
- iOS Shortcuts: "getting-started/ios.md"
|
||||||
- API Usage: "getting-started/api-usage.md"
|
|
||||||
- Site Administration:
|
- Site Administration:
|
||||||
- User Settings: "site-administration/user-settings.md"
|
- User Settings: "site-administration/user-settings.md"
|
||||||
- Site Settings: "site-administration/site-settings.md"
|
- Site Settings: "site-administration/site-settings.md"
|
||||||
|
@ -64,6 +63,10 @@ nav:
|
||||||
- User Management: "site-administration/user-management.md"
|
- User Management: "site-administration/user-management.md"
|
||||||
- Backups and Restore: "site-administration/backups-and-exports.md"
|
- Backups and Restore: "site-administration/backups-and-exports.md"
|
||||||
- Recipe Migration: "site-administration/migration-imports.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"
|
- API Reference: "api/redoc.md"
|
||||||
- Contributors Guide:
|
- Contributors Guide:
|
||||||
- Non-Code: "contributors/non-coders.md"
|
- Non-Code: "contributors/non-coders.md"
|
||||||
|
@ -74,6 +77,7 @@ nav:
|
||||||
- Guidelines: "contributors/developers-guide/general-guidelines.md"
|
- Guidelines: "contributors/developers-guide/general-guidelines.md"
|
||||||
- Development Road Map: "roadmap.md"
|
- Development Road Map: "roadmap.md"
|
||||||
- Change Log:
|
- Change Log:
|
||||||
|
- v0.4.2 Backend/Migrations: "changelog/v0.4.2.md"
|
||||||
- v0.4.1 Frontend/UI: "changelog/v0.4.1.md"
|
- v0.4.1 Frontend/UI: "changelog/v0.4.1.md"
|
||||||
- v0.4.0 Authentication: "changelog/v0.4.0.md"
|
- v0.4.0 Authentication: "changelog/v0.4.0.md"
|
||||||
- v0.3.0 Improvements: "changelog/v0.3.0.md"
|
- v0.3.0 Improvements: "changelog/v0.3.0.md"
|
||||||
|
|
|
@ -61,9 +61,16 @@ const apiReq = {
|
||||||
processResponse(response);
|
processResponse(response);
|
||||||
return 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 { apiReq };
|
||||||
export { baseURL };
|
export { baseURL };
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { store } from "@/store";
|
||||||
|
|
||||||
const backupBase = baseURL + "backups/";
|
const backupBase = baseURL + "backups/";
|
||||||
|
|
||||||
const backupURLs = {
|
export const backupURLs = {
|
||||||
// Backup
|
// Backup
|
||||||
available: `${backupBase}available`,
|
available: `${backupBase}available`,
|
||||||
createBackup: `${backupBase}export/database`,
|
createBackup: `${backupBase}export/database`,
|
||||||
|
@ -13,6 +13,8 @@ const backupURLs = {
|
||||||
downloadBackup: fileName => `${backupBase}${fileName}/download`,
|
downloadBackup: fileName => `${backupBase}${fileName}/download`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const backupAPI = {
|
export const backupAPI = {
|
||||||
/**
|
/**
|
||||||
* Request all backups available on the server
|
* Request all backups available on the server
|
||||||
|
@ -55,7 +57,7 @@ export const backupAPI = {
|
||||||
* @returns Download URL
|
* @returns Download URL
|
||||||
*/
|
*/
|
||||||
async download(fileName) {
|
async download(fileName) {
|
||||||
let response = await apiReq.get(backupURLs.downloadBackup(fileName));
|
const url = backupURLs.downloadBackup(fileName);
|
||||||
return response.data;
|
apiReq.download(url);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -44,6 +44,11 @@ export const tagAPI = {
|
||||||
let response = await apiReq.get(tagURLs.getAll);
|
let response = await apiReq.get(tagURLs.getAll);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
async create(name) {
|
||||||
|
let response = await apiReq.post(tagURLs.getAll, { name: name });
|
||||||
|
store.dispatch("requestTags");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
async getRecipesInTag(tag) {
|
async getRecipesInTag(tag) {
|
||||||
let response = await apiReq.get(tagURLs.getTag(tag));
|
let response = await apiReq.get(tagURLs.getTag(tag));
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|
|
@ -61,6 +61,11 @@ export const recipeAPI = {
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateImagebyURL(slug, url) {
|
||||||
|
const response = apiReq.post(recipeURLs.updateImage(slug), { url: url });
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
async update(data) {
|
async update(data) {
|
||||||
let response = await apiReq.put(recipeURLs.update(data.slug), data);
|
let response = await apiReq.put(recipeURLs.update(data.slug), data);
|
||||||
store.dispatch("requestRecentRecipes");
|
store.dispatch("requestRecentRecipes");
|
||||||
|
|
|
@ -37,14 +37,7 @@
|
||||||
<v-divider></v-divider>
|
<v-divider></v-divider>
|
||||||
|
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-btn
|
<TheDownloadBtn :download-url="downloadUrl" />
|
||||||
color="accent"
|
|
||||||
text
|
|
||||||
:loading="downloading"
|
|
||||||
@click="downloadFile(`/api/backups/${name}/download`)"
|
|
||||||
>
|
|
||||||
{{ $t("general.download") }}
|
|
||||||
</v-btn>
|
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<v-btn color="error" text @click="raiseEvent('delete')">
|
<v-btn color="error" text @click="raiseEvent('delete')">
|
||||||
{{ $t("general.delete") }}
|
{{ $t("general.delete") }}
|
||||||
|
@ -66,9 +59,10 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import ImportOptions from "@/components/Admin/Backup/ImportOptions";
|
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 {
|
export default {
|
||||||
components: { ImportOptions },
|
components: { ImportOptions, TheDownloadBtn },
|
||||||
props: {
|
props: {
|
||||||
name: {
|
name: {
|
||||||
default: "Backup Name",
|
default: "Backup Name",
|
||||||
|
@ -92,6 +86,11 @@ export default {
|
||||||
downloading: false,
|
downloading: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
downloadUrl() {
|
||||||
|
return backupURLs.downloadBackup(this.name);
|
||||||
|
},
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
updateOptions(options) {
|
updateOptions(options) {
|
||||||
this.options = options;
|
this.options = options;
|
||||||
|
@ -116,23 +115,6 @@ export default {
|
||||||
this.close();
|
this.close();
|
||||||
this.$emit(event, eventData);
|
this.$emit(event, eventData);
|
||||||
},
|
},
|
||||||
async downloadFile(downloadURL) {
|
|
||||||
this.downloading = true;
|
|
||||||
const response = await axios({
|
|
||||||
url: downloadURL,
|
|
||||||
method: "GET",
|
|
||||||
responseType: "blob", // important
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.setAttribute("download", `${this.name}.zip`);
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
this.downloading = false;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -45,7 +45,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import DataTable from "./DataTable";
|
import DataTable from "@/components/Admin/Backup/ImportSummaryDialog/DataTable";
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
DataTable,
|
DataTable,
|
||||||
|
|
|
@ -19,10 +19,11 @@
|
||||||
v-model="page.name"
|
v-model="page.name"
|
||||||
label="Page Name"
|
label="Page Name"
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
<CategorySelector
|
<CategoryTagSelector
|
||||||
v-model="page.categories"
|
v-model="page.categories"
|
||||||
ref="categoryFormSelector"
|
ref="categoryFormSelector"
|
||||||
@mounted="catMounted = true"
|
@mounted="catMounted = true"
|
||||||
|
:tag-selector="false"
|
||||||
/>
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
|
@ -43,10 +44,10 @@
|
||||||
<script>
|
<script>
|
||||||
const NEW_PAGE_EVENT = "refresh-page";
|
const NEW_PAGE_EVENT = "refresh-page";
|
||||||
import { api } from "@/api";
|
import { api } from "@/api";
|
||||||
import CategorySelector from "@/components/FormHelpers/CategorySelector";
|
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
CategorySelector,
|
CategoryTagSelector,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -81,7 +81,7 @@
|
||||||
</v-toolbar-title>
|
</v-toolbar-title>
|
||||||
|
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<NewCategoryDialog />
|
<NewCategoryTagDialog :tag-dialog="false" />
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
<v-list height="300" dense style="overflow:auto">
|
<v-list height="300" dense style="overflow:auto">
|
||||||
<v-list-item-group>
|
<v-list-item-group>
|
||||||
|
@ -149,13 +149,13 @@
|
||||||
import { api } from "@/api";
|
import { api } from "@/api";
|
||||||
import LanguageMenu from "@/components/UI/LanguageMenu";
|
import LanguageMenu from "@/components/UI/LanguageMenu";
|
||||||
import draggable from "vuedraggable";
|
import draggable from "vuedraggable";
|
||||||
import NewCategoryDialog from "./NewCategoryDialog.vue";
|
import NewCategoryTagDialog from "@/components/UI/Dialogs/NewCategoryTagDialog.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
draggable,
|
draggable,
|
||||||
LanguageMenu,
|
LanguageMenu,
|
||||||
NewCategoryDialog,
|
NewCategoryTagDialog,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<v-card outlined class="my-2" :loading="loading">
|
<v-card outlined class="my-2" :loading="loading">
|
||||||
|
<MigrationDialog ref="migrationDialog" />
|
||||||
<v-card-title>
|
<v-card-title>
|
||||||
{{ title }}
|
{{ title }}
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
|
@ -40,7 +41,13 @@
|
||||||
<v-btn color="error" text @click="deleteMigration(migration.name)">
|
<v-btn color="error" text @click="deleteMigration(migration.name)">
|
||||||
{{ $t("general.delete") }}
|
{{ $t("general.delete") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn color="accent" text @click="importMigration(migration.name)">
|
<v-btn
|
||||||
|
color="accent"
|
||||||
|
text
|
||||||
|
@click="importMigration(migration.name)"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
{{ $t("general.import") }}
|
{{ $t("general.import") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
|
@ -61,6 +68,7 @@
|
||||||
import UploadBtn from "../../UI/UploadBtn";
|
import UploadBtn from "../../UI/UploadBtn";
|
||||||
import utils from "@/utils";
|
import utils from "@/utils";
|
||||||
import { api } from "@/api";
|
import { api } from "@/api";
|
||||||
|
import MigrationDialog from "@/components/Admin/Migration/MigrationDialog.vue";
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
folder: String,
|
folder: String,
|
||||||
|
@ -70,6 +78,7 @@ export default {
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
UploadBtn,
|
UploadBtn,
|
||||||
|
MigrationDialog,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -82,10 +91,11 @@ export default {
|
||||||
this.$emit("refresh");
|
this.$emit("refresh");
|
||||||
},
|
},
|
||||||
async importMigration(file_name) {
|
async importMigration(file_name) {
|
||||||
this.loading == true;
|
this.loading = true;
|
||||||
let response = await api.migrations.import(this.folder, file_name);
|
let response = await api.migrations.import(this.folder, file_name);
|
||||||
this.$emit("imported", response.successful, response.failed);
|
this.$refs.migrationDialog.open(response);
|
||||||
this.loading == false;
|
// this.$emit("imported", response.successful, response.failed);
|
||||||
|
this.loading = false;
|
||||||
},
|
},
|
||||||
readableTime(timestamp) {
|
readableTime(timestamp) {
|
||||||
let date = new Date(timestamp);
|
let date = new Date(timestamp);
|
||||||
|
|
109
frontend/src/components/Admin/Migration/MigrationDialog.vue
Normal file
109
frontend/src/components/Admin/Migration/MigrationDialog.vue
Normal 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>
|
|
@ -1,79 +0,0 @@
|
||||||
<template>
|
|
||||||
<v-select
|
|
||||||
:items="allCategories"
|
|
||||||
v-model="selected"
|
|
||||||
label="Categories"
|
|
||||||
chips
|
|
||||||
deletable-chips
|
|
||||||
:dense="dense"
|
|
||||||
item-text="name"
|
|
||||||
multiple
|
|
||||||
:solo="solo"
|
|
||||||
:return-object="returnObject"
|
|
||||||
:flat="flat"
|
|
||||||
@input="emitChange"
|
|
||||||
>
|
|
||||||
<template v-slot:selection="data">
|
|
||||||
<v-chip
|
|
||||||
class="ma-1"
|
|
||||||
:input-value="data.selected"
|
|
||||||
close
|
|
||||||
@click:close="removeByIndex(data.index)"
|
|
||||||
label
|
|
||||||
color="accent"
|
|
||||||
dark
|
|
||||||
>
|
|
||||||
{{ data.item.name }}
|
|
||||||
</v-chip>
|
|
||||||
</template></v-select
|
|
||||||
>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const MOUNTED_EVENT = "mounted";
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
value: Array,
|
|
||||||
solo: {
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
dense: {
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
returnObject: {
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
selected: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.$emit(MOUNTED_EVENT);
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
allCategories() {
|
|
||||||
return this.$store.getters.getAllCategories;
|
|
||||||
},
|
|
||||||
flat() {
|
|
||||||
return this.selected.length > 0 && this.solo;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
emitChange() {
|
|
||||||
this.$emit("input", this.selected);
|
|
||||||
},
|
|
||||||
setInit(val) {
|
|
||||||
this.selected = val;
|
|
||||||
},
|
|
||||||
removeByIndex(index) {
|
|
||||||
this.selected.splice(index, 1);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
</style>
|
|
129
frontend/src/components/FormHelpers/CategoryTagSelector.vue
Normal file
129
frontend/src/components/FormHelpers/CategoryTagSelector.vue
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
<template>
|
||||||
|
<v-autocomplete
|
||||||
|
:items="activeItems"
|
||||||
|
v-model="selected"
|
||||||
|
:value="value"
|
||||||
|
:label="inputLabel"
|
||||||
|
chips
|
||||||
|
deletable-chips
|
||||||
|
:dense="dense"
|
||||||
|
item-text="name"
|
||||||
|
persistent-hint
|
||||||
|
multiple
|
||||||
|
:hint="hint"
|
||||||
|
:solo="solo"
|
||||||
|
:return-object="returnObject"
|
||||||
|
:flat="flat"
|
||||||
|
@input="emitChange"
|
||||||
|
>
|
||||||
|
<template v-slot:selection="data">
|
||||||
|
<v-chip
|
||||||
|
class="ma-1"
|
||||||
|
:input-value="data.selected"
|
||||||
|
close
|
||||||
|
@click:close="removeByIndex(data.index)"
|
||||||
|
label
|
||||||
|
color="accent"
|
||||||
|
dark
|
||||||
|
:key="data.index"
|
||||||
|
>
|
||||||
|
{{ data.item.name || data.item }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
<template v-slot:append-outer="">
|
||||||
|
<NewCategoryTagDialog
|
||||||
|
v-if="showAdd"
|
||||||
|
:tag-dialog="tagSelector"
|
||||||
|
@created-item="pushToItem"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-autocomplete>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import NewCategoryTagDialog from "@/components/UI/Dialogs/NewCategoryTagDialog";
|
||||||
|
const MOUNTED_EVENT = "mounted";
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
NewCategoryTagDialog,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
value: Array,
|
||||||
|
solo: {
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
dense: {
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
returnObject: {
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
tagSelector: {
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
showAdd: {
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showLabel: {
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selected: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$emit(MOUNTED_EVENT);
|
||||||
|
this.setInit(this.value);
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
value(val) {
|
||||||
|
this.selected = val;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
inputLabel() {
|
||||||
|
if (!this.showLabel) return null;
|
||||||
|
return this.tagSelector ? "Tags" : "Categories";
|
||||||
|
},
|
||||||
|
activeItems() {
|
||||||
|
let ItemObjects = [];
|
||||||
|
if (this.tagSelector) ItemObjects = this.$store.getters.getAllTags;
|
||||||
|
else {
|
||||||
|
ItemObjects = this.$store.getters.getAllCategories;
|
||||||
|
}
|
||||||
|
if (this.returnObject) return ItemObjects;
|
||||||
|
else {
|
||||||
|
return ItemObjects.map(x => x.name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
flat() {
|
||||||
|
return this.selected.length > 0 && this.solo;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
emitChange() {
|
||||||
|
this.$emit("input", this.selected);
|
||||||
|
},
|
||||||
|
setInit(val) {
|
||||||
|
this.selected = val;
|
||||||
|
},
|
||||||
|
removeByIndex(index) {
|
||||||
|
this.selected.splice(index, 1);
|
||||||
|
},
|
||||||
|
pushToItem(createdItem) {
|
||||||
|
createdItem = this.returnObject ? createdItem : createdItem.name;
|
||||||
|
this.selected.push(createdItem);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
|
@ -1,79 +0,0 @@
|
||||||
<template>
|
|
||||||
<v-select
|
|
||||||
:items="allTags"
|
|
||||||
v-model="selected"
|
|
||||||
label="Tags"
|
|
||||||
chips
|
|
||||||
deletable-chips
|
|
||||||
:dense="dense"
|
|
||||||
:solo="solo"
|
|
||||||
:flat="flat"
|
|
||||||
item-text="name"
|
|
||||||
multiple
|
|
||||||
:return-object="returnObject"
|
|
||||||
@input="emitChange"
|
|
||||||
>
|
|
||||||
<template v-slot:selection="data">
|
|
||||||
<v-chip
|
|
||||||
class="ma-1"
|
|
||||||
:input-value="data.selected"
|
|
||||||
close
|
|
||||||
@click:close="removeByIndex(data.index)"
|
|
||||||
label
|
|
||||||
color="accent"
|
|
||||||
dark
|
|
||||||
>
|
|
||||||
{{ data.item.name }}
|
|
||||||
</v-chip>
|
|
||||||
</template>
|
|
||||||
</v-select>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const MOUNTED_EVENT = "mounted";
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
value: Array,
|
|
||||||
solo: {
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
dense: {
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
returnObject: {
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
selected: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.$emit(MOUNTED_EVENT);
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
allTags() {
|
|
||||||
return this.$store.getters.getAllTags;
|
|
||||||
},
|
|
||||||
flat() {
|
|
||||||
return this.selected.length > 0 && this.solo;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
emitChange() {
|
|
||||||
this.$emit("input", this.selected);
|
|
||||||
},
|
|
||||||
setInit(val) {
|
|
||||||
this.selected = val;
|
|
||||||
},
|
|
||||||
removeByIndex(index) {
|
|
||||||
this.selected.splice(index, 1);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
</style>
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -2,16 +2,12 @@
|
||||||
<v-form ref="form">
|
<v-form ref="form">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-row dense>
|
<v-row dense>
|
||||||
<v-col cols="3"></v-col>
|
<ImageUploadBtn
|
||||||
<v-col>
|
class="mt-2"
|
||||||
<v-file-input
|
@upload="uploadImage"
|
||||||
v-model="fileObject"
|
:slug="value.slug"
|
||||||
:label="$t('general.image-file')"
|
@refresh="$emit('upload')"
|
||||||
truncate-length="30"
|
/>
|
||||||
@change="uploadImage"
|
|
||||||
></v-file-input>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="3"></v-col>
|
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-row dense>
|
<v-row dense>
|
||||||
<v-col>
|
<v-col>
|
||||||
|
@ -92,7 +88,7 @@
|
||||||
auto-grow
|
auto-grow
|
||||||
solo
|
solo
|
||||||
dense
|
dense
|
||||||
rows="2"
|
rows="1"
|
||||||
>
|
>
|
||||||
<v-icon
|
<v-icon
|
||||||
class="mr-n1"
|
class="mr-n1"
|
||||||
|
@ -114,60 +110,21 @@
|
||||||
<BulkAdd @bulk-data="appendIngredients" />
|
<BulkAdd @bulk-data="appendIngredients" />
|
||||||
|
|
||||||
<h2 class="mt-6">{{ $t("recipe.categories") }}</h2>
|
<h2 class="mt-6">{{ $t("recipe.categories") }}</h2>
|
||||||
<v-combobox
|
<CategoryTagSelector
|
||||||
dense
|
:return-object="false"
|
||||||
multiple
|
|
||||||
chips
|
|
||||||
item-color="secondary"
|
|
||||||
deletable-chips
|
|
||||||
v-model="value.recipeCategory"
|
v-model="value.recipeCategory"
|
||||||
hide-selected
|
:show-add="true"
|
||||||
:items="allCategories"
|
:show-label="false"
|
||||||
text="name"
|
/>
|
||||||
:search-input.sync="categoriesSearchInput"
|
|
||||||
@change="categoriesSearchInput = ''"
|
|
||||||
>
|
|
||||||
<template v-slot:selection="data">
|
|
||||||
<v-chip
|
|
||||||
class="ma-1"
|
|
||||||
:input-value="data.selected"
|
|
||||||
close
|
|
||||||
@click:close="removeCategory(data.index)"
|
|
||||||
label
|
|
||||||
color="accent"
|
|
||||||
dark
|
|
||||||
>
|
|
||||||
{{ data.item }}
|
|
||||||
</v-chip>
|
|
||||||
</template>
|
|
||||||
</v-combobox>
|
|
||||||
|
|
||||||
<h2 class="mt-4">{{ $t("recipe.tags") }}</h2>
|
<h2 class="mt-4">{{ $t("recipe.tags") }}</h2>
|
||||||
<v-combobox
|
<CategoryTagSelector
|
||||||
dense
|
:return-object="false"
|
||||||
multiple
|
|
||||||
chips
|
|
||||||
deletable-chips
|
|
||||||
v-model="value.tags"
|
v-model="value.tags"
|
||||||
hide-selected
|
:show-add="true"
|
||||||
:items="allTags"
|
:tag-selector="true"
|
||||||
:search-input.sync="tagsSearchInput"
|
:show-label="false"
|
||||||
@change="tagssSearchInput = ''"
|
/>
|
||||||
>
|
|
||||||
<template v-slot:selection="data">
|
|
||||||
<v-chip
|
|
||||||
class="ma-1"
|
|
||||||
:input-value="data.selected"
|
|
||||||
close
|
|
||||||
label
|
|
||||||
@click:close="removeTags(data.index)"
|
|
||||||
color="accent"
|
|
||||||
dark
|
|
||||||
>
|
|
||||||
{{ data.item }}
|
|
||||||
</v-chip>
|
|
||||||
</template>
|
|
||||||
</v-combobox>
|
|
||||||
|
|
||||||
<h2 class="my-4">{{ $t("recipe.notes") }}</h2>
|
<h2 class="my-4">{{ $t("recipe.notes") }}</h2>
|
||||||
<v-card
|
<v-card
|
||||||
|
@ -204,6 +161,7 @@
|
||||||
<v-btn class="mt-1" color="secondary" fab dark small @click="addNote">
|
<v-btn class="mt-1" color="secondary" fab dark small @click="addNote">
|
||||||
<v-icon>mdi-plus</v-icon>
|
<v-icon>mdi-plus</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
<NutritionEditor v-model="value.nutrition" :edit="true" />
|
||||||
<ExtrasEditor :extras="value.extras" @save="saveExtras" />
|
<ExtrasEditor :extras="value.extras" @save="saveExtras" />
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
|
@ -261,15 +219,20 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import draggable from "vuedraggable";
|
import draggable from "vuedraggable";
|
||||||
import { api } from "@/api";
|
|
||||||
import utils from "@/utils";
|
import utils from "@/utils";
|
||||||
import BulkAdd from "./BulkAdd";
|
import BulkAdd from "./BulkAdd";
|
||||||
import ExtrasEditor from "./ExtrasEditor";
|
import ExtrasEditor from "./ExtrasEditor";
|
||||||
|
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
|
||||||
|
import NutritionEditor from "./NutritionEditor";
|
||||||
|
import ImageUploadBtn from "./ImageUploadBtn.vue";
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
BulkAdd,
|
BulkAdd,
|
||||||
ExtrasEditor,
|
ExtrasEditor,
|
||||||
draggable,
|
draggable,
|
||||||
|
CategoryTagSelector,
|
||||||
|
NutritionEditor,
|
||||||
|
ImageUploadBtn,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
value: Object,
|
value: Object,
|
||||||
|
@ -285,27 +248,11 @@ export default {
|
||||||
v.split(" ").length <= 1 ||
|
v.split(" ").length <= 1 ||
|
||||||
this.$i18n.t("recipe.no-white-space-allowed"),
|
this.$i18n.t("recipe.no-white-space-allowed"),
|
||||||
},
|
},
|
||||||
categoriesSearchInput: "",
|
|
||||||
tagsSearchInput: "",
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
|
||||||
allCategories() {
|
|
||||||
const categories = this.$store.getters.getAllCategories;
|
|
||||||
return categories.map(cat => cat.name);
|
|
||||||
},
|
|
||||||
allTags() {
|
|
||||||
const tags = this.$store.getters.getAllTags;
|
|
||||||
return tags.map(cat => cat.name);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
uploadImage() {
|
uploadImage(fileObject) {
|
||||||
this.$emit("upload", this.fileObject);
|
this.$emit("upload", fileObject);
|
||||||
},
|
|
||||||
async updateImage() {
|
|
||||||
let slug = this.value.slug;
|
|
||||||
api.recipes.updateImage(slug, this.fileObject);
|
|
||||||
},
|
},
|
||||||
toggleDisabled(stepIndex) {
|
toggleDisabled(stepIndex) {
|
||||||
if (this.disabledSteps.includes(stepIndex)) {
|
if (this.disabledSteps.includes(stepIndex)) {
|
||||||
|
@ -327,9 +274,6 @@ export default {
|
||||||
generateKey(item, index) {
|
generateKey(item, index) {
|
||||||
return utils.generateUniqueKey(item, index);
|
return utils.generateUniqueKey(item, index);
|
||||||
},
|
},
|
||||||
deleteRecipe() {
|
|
||||||
this.$emit("delete");
|
|
||||||
},
|
|
||||||
|
|
||||||
appendIngredients(ingredients) {
|
appendIngredients(ingredients) {
|
||||||
this.value.recipeIngredient.push(...ingredients);
|
this.value.recipeIngredient.push(...ingredients);
|
||||||
|
|
|
@ -40,6 +40,7 @@
|
||||||
:isCategory="false"
|
:isCategory="false"
|
||||||
/>
|
/>
|
||||||
<Notes :notes="notes" />
|
<Notes :notes="notes" />
|
||||||
|
<NutritionEditor :value="nutrition" :edit="false" />
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-divider
|
<v-divider
|
||||||
|
@ -56,6 +57,7 @@
|
||||||
<RecipeChips :title="$t('recipe.categories')" :items="categories" />
|
<RecipeChips :title="$t('recipe.categories')" :items="categories" />
|
||||||
<RecipeChips :title="$t('recipe.tags')" :items="tags" />
|
<RecipeChips :title="$t('recipe.tags')" :items="tags" />
|
||||||
<Notes :notes="notes" />
|
<Notes :notes="notes" />
|
||||||
|
<NutritionEditor :value="nutrition" :edit="false" />
|
||||||
</div>
|
</div>
|
||||||
<v-row class="mt-2 mb-1">
|
<v-row class="mt-2 mb-1">
|
||||||
<v-col></v-col>
|
<v-col></v-col>
|
||||||
|
@ -80,6 +82,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import NutritionEditor from "@/components/Recipe/RecipeEditor/NutritionEditor";
|
||||||
import VueMarkdown from "@adapttive/vue-markdown";
|
import VueMarkdown from "@adapttive/vue-markdown";
|
||||||
import utils from "@/utils";
|
import utils from "@/utils";
|
||||||
import RecipeChips from "./RecipeChips";
|
import RecipeChips from "./RecipeChips";
|
||||||
|
@ -93,6 +96,7 @@ export default {
|
||||||
Steps,
|
Steps,
|
||||||
Notes,
|
Notes,
|
||||||
Ingredients,
|
Ingredients,
|
||||||
|
NutritionEditor,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
name: String,
|
name: String,
|
||||||
|
@ -105,6 +109,7 @@ export default {
|
||||||
rating: Number,
|
rating: Number,
|
||||||
yields: String,
|
yields: String,
|
||||||
orgURL: String,
|
orgURL: String,
|
||||||
|
nutrition: Object,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-btn icon @click="dialog = true">
|
<v-btn icon @click="dialog = true" class="mt-n1">
|
||||||
<v-icon color="white">mdi-plus</v-icon>
|
<v-icon :color="color">mdi-plus</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-dialog v-model="dialog" width="500">
|
<v-dialog v-model="dialog" width="500">
|
||||||
<v-card>
|
<v-card>
|
||||||
|
@ -11,7 +11,7 @@
|
||||||
</v-icon>
|
</v-icon>
|
||||||
|
|
||||||
<v-toolbar-title class="headline">
|
<v-toolbar-title class="headline">
|
||||||
Create a Category
|
{{ title }}
|
||||||
</v-toolbar-title>
|
</v-toolbar-title>
|
||||||
|
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
|
@ -21,8 +21,8 @@
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
dense
|
dense
|
||||||
label="Category Name"
|
:label="inputLabel"
|
||||||
v-model="categoryName"
|
v-model="itemName"
|
||||||
:rules="[rules.required]"
|
:rules="[rules.required]"
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
@ -31,7 +31,7 @@
|
||||||
<v-btn color="grey" text @click="dialog = false">
|
<v-btn color="grey" text @click="dialog = false">
|
||||||
{{ $t("general.cancel") }}
|
{{ $t("general.cancel") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn color="success" text type="submit" :disabled="!categoryName">
|
<v-btn color="success" text type="submit" :disabled="!itemName">
|
||||||
{{ $t("general.create") }}
|
{{ $t("general.create") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
|
@ -43,31 +43,55 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { api } from "@/api";
|
import { api } from "@/api";
|
||||||
|
const CREATED_ITEM_EVENT = "created-item";
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
buttonText: String,
|
buttonText: String,
|
||||||
value: String,
|
value: String,
|
||||||
|
color: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
tagDialog: {
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
dialog: false,
|
dialog: false,
|
||||||
categoryName: "",
|
itemName: "",
|
||||||
rules: {
|
rules: {
|
||||||
required: val =>
|
required: val => !!val || "A Name is Required",
|
||||||
!!val || this.$t("settings.theme.theme-name-is-required"),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
title() {
|
||||||
|
return this.tagDialog ? "Create a Tag" : "Create a Category";
|
||||||
|
},
|
||||||
|
inputLabel() {
|
||||||
|
return this.tagDialog ? "Tag Name" : "Category Name";
|
||||||
|
},
|
||||||
|
},
|
||||||
watch: {
|
watch: {
|
||||||
dialog(val) {
|
dialog(val) {
|
||||||
if (!val) this.categoryName = "";
|
if (!val) this.itemName = "";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
async select() {
|
async select() {
|
||||||
await api.categories.create(this.categoryName);
|
const newItem = await (async () => {
|
||||||
this.$emit("new-category", this.categoryName);
|
if (this.tagDialog) {
|
||||||
|
const newItem = await api.tags.create(this.itemName);
|
||||||
|
return newItem;
|
||||||
|
} else {
|
||||||
|
const newItem = await api.categories.create(this.itemName);
|
||||||
|
return newItem;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
this.$emit(CREATED_ITEM_EVENT, newItem);
|
||||||
this.dialog = false;
|
this.dialog = false;
|
||||||
},
|
},
|
||||||
},
|
},
|
|
@ -17,11 +17,18 @@
|
||||||
</v-text-field>
|
</v-text-field>
|
||||||
</template>
|
</template>
|
||||||
<v-card v-if="showResults" max-height="500" :max-width="maxWidth">
|
<v-card v-if="showResults" max-height="500" :max-width="maxWidth">
|
||||||
<v-card-text class="py-1">Results</v-card-text>
|
<v-card-text class="flex row mx-auto">
|
||||||
|
<div class="mr-auto">
|
||||||
|
Results
|
||||||
|
</div>
|
||||||
|
<router-link to="/search">
|
||||||
|
Advanced Search
|
||||||
|
</router-link>
|
||||||
|
</v-card-text>
|
||||||
<v-divider></v-divider>
|
<v-divider></v-divider>
|
||||||
<v-list scrollable>
|
<v-list scrollable v-if="autoResults">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="(item, index) in autoResults"
|
v-for="(item, index) in autoResults.slice(0, 15)"
|
||||||
:key="index"
|
:key="index"
|
||||||
:to="navOnClick ? `/recipe/${item.item.slug}` : null"
|
:to="navOnClick ? `/recipe/${item.item.slug}` : null"
|
||||||
@click="navOnClick ? null : selected(item.item.slug, item.item.name)"
|
@click="navOnClick ? null : selected(item.item.slug, item.item.name)"
|
||||||
|
|
51
frontend/src/components/UI/TheDownloadBtn.vue
Normal file
51
frontend/src/components/UI/TheDownloadBtn.vue
Normal 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>
|
|
@ -1,7 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<v-form ref="file">
|
<v-form ref="file">
|
||||||
<input ref="uploader" class="d-none" type="file" @change="onFileChanged" />
|
<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>
|
<v-icon left> {{ icon }}</v-icon>
|
||||||
{{ text ? text : defaultText }}
|
{{ text ? text : defaultText }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
@ -13,10 +18,17 @@ const UPLOAD_EVENT = "uploaded";
|
||||||
import { api } from "@/api";
|
import { api } from "@/api";
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
post: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
url: String,
|
url: String,
|
||||||
text: { default: "Upload" },
|
text: { default: "Upload" },
|
||||||
icon: { default: "mdi-cloud-upload" },
|
icon: { default: "mdi-cloud-upload" },
|
||||||
fileName: { default: "archive" },
|
fileName: { default: "archive" },
|
||||||
|
textBtn: {
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data: () => ({
|
data: () => ({
|
||||||
file: null,
|
file: null,
|
||||||
|
@ -33,6 +45,12 @@ export default {
|
||||||
async upload() {
|
async upload() {
|
||||||
if (this.file != null) {
|
if (this.file != null) {
|
||||||
this.isSelecting = true;
|
this.isSelecting = true;
|
||||||
|
|
||||||
|
if (this.post) {
|
||||||
|
this.$emit(UPLOAD_EVENT, this.file);
|
||||||
|
this.isSelecting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
let formData = new FormData();
|
let formData = new FormData();
|
||||||
formData.append(this.fileName, this.file);
|
formData.append(this.fileName, this.file);
|
||||||
|
|
||||||
|
|
160
frontend/src/locales/pt-PT.json
Normal file
160
frontend/src/locales/pt-PT.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,7 +3,7 @@ export const validators = {
|
||||||
return {
|
return {
|
||||||
emailRule: v =>
|
emailRule: v =>
|
||||||
!v ||
|
!v ||
|
||||||
/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(v) ||
|
/^[^@\s]+@[^@\s.]+.[^@.\s]+$/.test(v) ||
|
||||||
this.$t('user.e-mail-must-be-valid'),
|
this.$t('user.e-mail-must-be-valid'),
|
||||||
|
|
||||||
existsRule: value => !!value || this.$t('general.field-required'),
|
existsRule: value => !!value || this.$t('general.field-required'),
|
||||||
|
|
|
@ -20,6 +20,17 @@
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list-item-group>
|
</v-list-item-group>
|
||||||
</v-card-text>
|
</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-divider></v-divider>
|
||||||
</v-card>
|
</v-card>
|
||||||
</div>
|
</div>
|
||||||
|
@ -27,7 +38,9 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { api } from "@/api";
|
import { api } from "@/api";
|
||||||
|
import TheDownloadBtn from "@/components/UI/TheDownloadBtn";
|
||||||
export default {
|
export default {
|
||||||
|
components: { TheDownloadBtn },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
prettyInfo: [],
|
prettyInfo: [],
|
||||||
|
|
|
@ -7,41 +7,19 @@
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<h2 class="mt-1">{{ $t("recipe.categories") }}</h2>
|
<h2 class="mt-1">{{ $t("recipe.categories") }}</h2>
|
||||||
|
|
||||||
<v-row>
|
<CategoryTagSelector
|
||||||
<v-col sm="12" md="6">
|
class="mt-4"
|
||||||
<v-select
|
:solo="true"
|
||||||
outlined
|
:dense="false"
|
||||||
:flat="isFlat"
|
|
||||||
elavation="0"
|
|
||||||
v-model="groupSettings.categories"
|
v-model="groupSettings.categories"
|
||||||
:items="categories"
|
:return-object="true"
|
||||||
item-text="name"
|
:show-add="true"
|
||||||
return-object
|
|
||||||
multiple
|
|
||||||
chips
|
|
||||||
:hint="
|
:hint="
|
||||||
$t(
|
$t(
|
||||||
'meal-plan.only-recipes-with-these-categories-will-be-used-in-meal-plans'
|
'meal-plan.only-recipes-with-these-categories-will-be-used-in-meal-plans'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
class="mt-2"
|
/>
|
||||||
persistent-hint
|
|
||||||
>
|
|
||||||
<template v-slot:selection="data">
|
|
||||||
<v-chip
|
|
||||||
outlined
|
|
||||||
:input-value="data.selected"
|
|
||||||
close
|
|
||||||
@click:close="removeCategory(data.index)"
|
|
||||||
color="secondary"
|
|
||||||
dark
|
|
||||||
>
|
|
||||||
{{ data.item.name }}
|
|
||||||
</v-chip>
|
|
||||||
</template>
|
|
||||||
</v-select>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-divider> </v-divider>
|
<v-divider> </v-divider>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
|
@ -105,9 +83,11 @@
|
||||||
<script>
|
<script>
|
||||||
import { api } from "@/api";
|
import { api } from "@/api";
|
||||||
import TimePickerDialog from "@/components/Admin/MealPlanner/TimePickerDialog";
|
import TimePickerDialog from "@/components/Admin/MealPlanner/TimePickerDialog";
|
||||||
|
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
TimePickerDialog,
|
TimePickerDialog,
|
||||||
|
CategoryTagSelector,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -155,6 +135,7 @@ export default {
|
||||||
this.groupSettings.webhookUrls.splice(index, 1);
|
this.groupSettings.webhookUrls.splice(index, 1);
|
||||||
},
|
},
|
||||||
async saveGroupSettings() {
|
async saveGroupSettings() {
|
||||||
|
console.log(this.groupSettings);
|
||||||
await api.groups.update(this.groupSettings);
|
await api.groups.update(this.groupSettings);
|
||||||
await this.$store.dispatch("requestCurrentGroup");
|
await this.$store.dispatch("requestCurrentGroup");
|
||||||
this.getSiteSettings();
|
this.getSiteSettings();
|
||||||
|
@ -162,9 +143,6 @@ export default {
|
||||||
testWebhooks() {
|
testWebhooks() {
|
||||||
api.settings.testWebhooks();
|
api.settings.testWebhooks();
|
||||||
},
|
},
|
||||||
removeCategory(index) {
|
|
||||||
this.groupSettings.categories.splice(index, 1);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -50,6 +50,7 @@
|
||||||
:rating="recipeDetails.rating"
|
:rating="recipeDetails.rating"
|
||||||
:yields="recipeDetails.recipeYield"
|
:yields="recipeDetails.recipeYield"
|
||||||
:orgURL="recipeDetails.orgURL"
|
:orgURL="recipeDetails.orgURL"
|
||||||
|
:nutrition="recipeDetails.nutrition"
|
||||||
/>
|
/>
|
||||||
<VJsoneditor
|
<VJsoneditor
|
||||||
@error="logError()"
|
@error="logError()"
|
||||||
|
@ -151,6 +152,7 @@ export default {
|
||||||
methods: {
|
methods: {
|
||||||
getImageFile(fileObject) {
|
getImageFile(fileObject) {
|
||||||
this.fileObject = fileObject;
|
this.fileObject = fileObject;
|
||||||
|
this.saveImage();
|
||||||
},
|
},
|
||||||
async getRecipeDetails() {
|
async getRecipeDetails() {
|
||||||
this.recipeDetails = await api.recipes.requestDetails(this.currentRecipe);
|
this.recipeDetails = await api.recipes.requestDetails(this.currentRecipe);
|
||||||
|
@ -172,19 +174,21 @@ export default {
|
||||||
return this.$refs.recipeEditor.validateRecipe();
|
return this.$refs.recipeEditor.validateRecipe();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async saveImage() {
|
||||||
|
if (this.fileObject) {
|
||||||
|
await api.recipes.updateImage(this.recipeDetails.slug, this.fileObject);
|
||||||
|
}
|
||||||
|
this.imageKey += 1;
|
||||||
|
},
|
||||||
async saveRecipe() {
|
async saveRecipe() {
|
||||||
if (this.validateRecipe()) {
|
if (this.validateRecipe()) {
|
||||||
let slug = await api.recipes.update(this.recipeDetails);
|
let slug = await api.recipes.update(this.recipeDetails);
|
||||||
|
|
||||||
if (this.fileObject) {
|
if (this.fileObject) {
|
||||||
await api.recipes.updateImage(
|
this.saveImage();
|
||||||
this.recipeDetails.slug,
|
|
||||||
this.fileObject
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.form = false;
|
this.form = false;
|
||||||
this.imageKey += 1;
|
|
||||||
if (slug != this.recipeDetails.slug) {
|
if (slug != this.recipeDetails.slug) {
|
||||||
this.$router.push(`/recipe/${slug}`);
|
this.$router.push(`/recipe/${slug}`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,7 @@
|
||||||
<v-col>
|
<v-col>
|
||||||
<h3 class="pl-2 text-center headline">Category Filter</h3>
|
<h3 class="pl-2 text-center headline">Category Filter</h3>
|
||||||
<FilterSelector class="mb-1" @update="updateCatParams" />
|
<FilterSelector class="mb-1" @update="updateCatParams" />
|
||||||
<CategorySelector
|
<CategoryTagSelector
|
||||||
:solo="true"
|
:solo="true"
|
||||||
:dense="false"
|
:dense="false"
|
||||||
v-model="includeCategories"
|
v-model="includeCategories"
|
||||||
|
@ -38,11 +38,13 @@
|
||||||
<v-col>
|
<v-col>
|
||||||
<h3 class="pl-2 text-center headline">Tag Filter</h3>
|
<h3 class="pl-2 text-center headline">Tag Filter</h3>
|
||||||
<FilterSelector class="mb-1" @update="updateTagParams" />
|
<FilterSelector class="mb-1" @update="updateTagParams" />
|
||||||
<TagSelector
|
|
||||||
|
<CategoryTagSelector
|
||||||
:solo="true"
|
:solo="true"
|
||||||
:dense="false"
|
:dense="false"
|
||||||
v-model="includeTags"
|
v-model="includeTags"
|
||||||
:return-object="false"
|
:return-object="false"
|
||||||
|
:tag-selector="true"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
@ -74,16 +76,14 @@
|
||||||
import Fuse from "fuse.js";
|
import Fuse from "fuse.js";
|
||||||
import RecipeCard from "@/components/Recipe/RecipeCard";
|
import RecipeCard from "@/components/Recipe/RecipeCard";
|
||||||
import CategorySidebar from "@/components/UI/CategorySidebar";
|
import CategorySidebar from "@/components/UI/CategorySidebar";
|
||||||
import CategorySelector from "@/components/FormHelpers/CategorySelector";
|
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
|
||||||
import TagSelector from "@/components/FormHelpers/TagSelector";
|
|
||||||
import FilterSelector from "./FilterSelector.vue";
|
import FilterSelector from "./FilterSelector.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
RecipeCard,
|
RecipeCard,
|
||||||
CategorySidebar,
|
CategorySidebar,
|
||||||
CategorySelector,
|
CategoryTagSelector,
|
||||||
TagSelector,
|
|
||||||
FilterSelector,
|
FilterSelector,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|
|
@ -35,6 +35,10 @@ const state = {
|
||||||
name: "German",
|
name: "German",
|
||||||
value: "de",
|
value: "de",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Português",
|
||||||
|
value: "pt-PT",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -53,8 +57,8 @@ const actions = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getters = {
|
const getters = {
|
||||||
getActiveLang: (state) => state.lang,
|
getActiveLang: state => state.lang,
|
||||||
getAllLangs: (state) => state.allLangs,
|
getAllLangs: state => state.allLangs,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
|
@ -1,16 +1,19 @@
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.logger import logger
|
|
||||||
|
from mealie.core import root_logger
|
||||||
|
|
||||||
# import utils.startup as startup
|
# import utils.startup as startup
|
||||||
from mealie.core.config import APP_VERSION, settings
|
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.groups import groups
|
||||||
from mealie.routes.mealplans import mealplans
|
from mealie.routes.mealplans import mealplans
|
||||||
from mealie.routes.recipe import all_recipe_routes, category_routes, recipe_crud_routes, tag_routes
|
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.site_settings import all_settings
|
||||||
from mealie.routes.users import users
|
from mealie.routes.users import users
|
||||||
|
|
||||||
|
logger = root_logger.get_logger()
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Mealie",
|
title="Mealie",
|
||||||
description="A place for all your recipes",
|
description="A place for all your recipes",
|
||||||
|
@ -26,6 +29,7 @@ def start_scheduler():
|
||||||
|
|
||||||
def api_routers():
|
def api_routers():
|
||||||
# Authentication
|
# Authentication
|
||||||
|
app.include_router(utility_routes.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(groups.router)
|
app.include_router(groups.router)
|
||||||
# Recipes
|
# Recipes
|
||||||
|
@ -33,7 +37,6 @@ def api_routers():
|
||||||
app.include_router(category_routes.router)
|
app.include_router(category_routes.router)
|
||||||
app.include_router(tag_routes.router)
|
app.include_router(tag_routes.router)
|
||||||
app.include_router(recipe_crud_routes.router)
|
app.include_router(recipe_crud_routes.router)
|
||||||
|
|
||||||
# Meal Routes
|
# Meal Routes
|
||||||
app.include_router(mealplans.router)
|
app.include_router(mealplans.router)
|
||||||
# Settings Routes
|
# Settings Routes
|
||||||
|
@ -50,6 +53,13 @@ api_routers()
|
||||||
start_scheduler()
|
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():
|
def main():
|
||||||
|
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
|
@ -60,11 +70,11 @@ def main():
|
||||||
reload_dirs=["mealie"],
|
reload_dirs=["mealie"],
|
||||||
debug=True,
|
debug=True,
|
||||||
log_level="info",
|
log_level="info",
|
||||||
|
log_config=None,
|
||||||
workers=1,
|
workers=1,
|
||||||
forwarded_allow_ips="*",
|
forwarded_allow_ips="*",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
logger.info("-----SYSTEM STARTUP-----")
|
|
||||||
main()
|
main()
|
||||||
|
|
|
@ -3,16 +3,19 @@ import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
import dotenv
|
||||||
from pydantic import BaseSettings, Field, validator
|
from pydantic import BaseSettings, Field, validator
|
||||||
|
|
||||||
APP_VERSION = "v0.4.1"
|
APP_VERSION = "v0.4.2"
|
||||||
DB_VERSION = "v0.4.0"
|
DB_VERSION = "v0.4.0"
|
||||||
|
|
||||||
CWD = Path(__file__).parent
|
CWD = Path(__file__).parent
|
||||||
BASE_DIR = CWD.parent.parent
|
BASE_DIR = CWD.parent.parent
|
||||||
|
|
||||||
ENV = BASE_DIR.joinpath(".env")
|
ENV = BASE_DIR.joinpath(".env")
|
||||||
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:
|
def determine_data_dir(production: bool) -> Path:
|
||||||
|
@ -40,7 +43,6 @@ def determine_secrets(data_dir: Path, production: bool) -> str:
|
||||||
|
|
||||||
# General
|
# General
|
||||||
DATA_DIR = determine_data_dir(PRODUCTION)
|
DATA_DIR = determine_data_dir(PRODUCTION)
|
||||||
LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
|
|
||||||
|
|
||||||
|
|
||||||
class AppDirectories:
|
class AppDirectories:
|
||||||
|
@ -84,7 +86,7 @@ app_dirs = AppDirectories(CWD, DATA_DIR)
|
||||||
|
|
||||||
class AppSettings(BaseSettings):
|
class AppSettings(BaseSettings):
|
||||||
global DATA_DIR
|
global DATA_DIR
|
||||||
PRODUCTION: bool = Field(False, env="ENV")
|
PRODUCTION: bool = Field(True, env="PRODUCTION")
|
||||||
IS_DEMO: bool = False
|
IS_DEMO: bool = False
|
||||||
API_PORT: int = 9000
|
API_PORT: int = 9000
|
||||||
API_DOCS: bool = True
|
API_DOCS: bool = True
|
||||||
|
@ -127,5 +129,3 @@ class AppSettings(BaseSettings):
|
||||||
|
|
||||||
|
|
||||||
settings = AppSettings()
|
settings = AppSettings()
|
||||||
|
|
||||||
print(settings.dict())
|
|
||||||
|
|
43
mealie/core/root_logger.py
Normal file
43
mealie/core/root_logger.py
Normal 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()
|
|
@ -1,9 +1,10 @@
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from mealie.schema.user import UserInDB
|
from pathlib import Path
|
||||||
|
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
from mealie.core.config import settings
|
from mealie.core.config import settings
|
||||||
from mealie.db.database import db
|
from mealie.db.database import db
|
||||||
|
from mealie.schema.user import UserInDB
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
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)
|
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:
|
def authenticate_user(session, email: str, password: str) -> UserInDB:
|
||||||
user: UserInDB = db.users.get(session, email, "email")
|
user: UserInDB = db.users.get(session, email, "email")
|
||||||
if not user:
|
if not user:
|
||||||
|
|
|
@ -9,7 +9,8 @@ from mealie.db.models.users import User
|
||||||
from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse
|
from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse
|
||||||
from mealie.schema.meal import MealPlanInDB
|
from mealie.schema.meal import MealPlanInDB
|
||||||
from mealie.schema.recipe import Recipe
|
from mealie.schema.recipe import Recipe
|
||||||
from mealie.schema.settings import CustomPageOut, SiteSettings as SiteSettingsSchema
|
from mealie.schema.settings import CustomPageOut
|
||||||
|
from mealie.schema.settings import SiteSettings as SiteSettingsSchema
|
||||||
from mealie.schema.sign_up import SignUpOut
|
from mealie.schema.sign_up import SignUpOut
|
||||||
from mealie.schema.theme import SiteTheme
|
from mealie.schema.theme import SiteTheme
|
||||||
from mealie.schema.user import GroupInDB, UserInDB
|
from mealie.schema.user import GroupInDB, UserInDB
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from fastapi.logger import logger
|
from mealie.core import root_logger
|
||||||
from mealie.core.config import settings
|
from mealie.core.config import settings
|
||||||
from mealie.core.security import get_password_hash
|
from mealie.core.security import get_password_hash
|
||||||
from mealie.db.database import db
|
from mealie.db.database import db
|
||||||
|
@ -7,6 +7,8 @@ from mealie.schema.settings import SiteSettings
|
||||||
from mealie.schema.theme import SiteTheme
|
from mealie.schema.theme import SiteTheme
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = root_logger.get_logger("init_db")
|
||||||
|
|
||||||
|
|
||||||
def init_db(db: Session = None) -> None:
|
def init_db(db: Session = None) -> None:
|
||||||
if not db:
|
if not db:
|
||||||
|
@ -48,10 +50,13 @@ def default_user_init(session: Session):
|
||||||
db.users.create(session, default_user)
|
db.users.create(session, default_user)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def main():
|
||||||
if sql_exists:
|
if sql_exists:
|
||||||
print("Database Exists")
|
print("Database Exists")
|
||||||
exit()
|
|
||||||
else:
|
else:
|
||||||
print("Database Doesn't Exists, Initializing...")
|
print("Database Doesn't Exists, Initializing...")
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import sqlalchemy.orm as orm
|
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 mealie.db.models.model_base import SqlAlchemyBase
|
||||||
from slugify import slugify
|
from slugify import slugify
|
||||||
from sqlalchemy.orm import validates
|
from sqlalchemy.orm import validates
|
||||||
|
|
||||||
|
logger = root_logger.get_logger()
|
||||||
|
|
||||||
site_settings2categories = sa.Table(
|
site_settings2categories = sa.Table(
|
||||||
"site_settings2categoories",
|
"site_settings2categoories",
|
||||||
SqlAlchemyBase.metadata,
|
SqlAlchemyBase.metadata,
|
||||||
|
@ -59,8 +61,8 @@ class Category(SqlAlchemyBase):
|
||||||
test_slug = slugify(name)
|
test_slug = slugify(name)
|
||||||
result = session.query(Category).filter(Category.slug == test_slug).one_or_none()
|
result = session.query(Category).filter(Category.slug == test_slug).one_or_none()
|
||||||
if result:
|
if result:
|
||||||
logger.info("Category exists, associating recipe")
|
logger.debug("Category exists, associating recipe")
|
||||||
return result
|
return result
|
||||||
else:
|
else:
|
||||||
logger.info("Category doesn't exists, creating tag")
|
logger.debug("Category doesn't exists, creating tag")
|
||||||
return Category(name=name)
|
return Category(name=name)
|
||||||
|
|
|
@ -60,7 +60,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||||
|
|
||||||
@validates("name")
|
@validates("name")
|
||||||
def validate_name(self, key, name):
|
def validate_name(self, key, name):
|
||||||
assert not name == ""
|
assert name != ""
|
||||||
return name
|
return name
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
@ -92,11 +92,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||||
self.image = image
|
self.image = image
|
||||||
self.recipeCuisine = recipeCuisine
|
self.recipeCuisine = recipeCuisine
|
||||||
|
|
||||||
if self.nutrition:
|
self.nutrition = Nutrition(**nutrition) if self.nutrition else Nutrition()
|
||||||
self.nutrition = Nutrition(**nutrition)
|
|
||||||
else:
|
|
||||||
self.nutrition = Nutrition()
|
|
||||||
|
|
||||||
self.tools = [Tool(tool=x) for x in tools] if tools else []
|
self.tools = [Tool(tool=x) for x in tools] if tools else []
|
||||||
|
|
||||||
self.recipeYield = recipeYield
|
self.recipeYield = recipeYield
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import sqlalchemy.orm as orm
|
import sqlalchemy.orm as orm
|
||||||
|
from mealie.core import root_logger
|
||||||
from mealie.db.models.model_base import SqlAlchemyBase
|
from mealie.db.models.model_base import SqlAlchemyBase
|
||||||
from fastapi.logger import logger
|
|
||||||
from slugify import slugify
|
from slugify import slugify
|
||||||
from sqlalchemy.orm import validates
|
from sqlalchemy.orm import validates
|
||||||
|
|
||||||
|
logger = root_logger.get_logger()
|
||||||
|
|
||||||
recipes2tags = sa.Table(
|
recipes2tags = sa.Table(
|
||||||
"recipes2tags",
|
"recipes2tags",
|
||||||
SqlAlchemyBase.metadata,
|
SqlAlchemyBase.metadata,
|
||||||
|
@ -25,7 +27,7 @@ class Tag(SqlAlchemyBase):
|
||||||
assert name != ""
|
assert name != ""
|
||||||
return name
|
return name
|
||||||
|
|
||||||
def __init__(self, name) -> None:
|
def __init__(self, name, session=None) -> None:
|
||||||
self.name = name.strip()
|
self.name = name.strip()
|
||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
|
|
||||||
|
@ -35,8 +37,8 @@ class Tag(SqlAlchemyBase):
|
||||||
result = session.query(Tag).filter(Tag.slug == test_slug).one_or_none()
|
result = session.query(Tag).filter(Tag.slug == test_slug).one_or_none()
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
logger.info("Tag exists, associating recipe")
|
logger.debug("Tag exists, associating recipe")
|
||||||
return result
|
return result
|
||||||
else:
|
else:
|
||||||
logger.info("Tag doesn't exists, creating tag")
|
logger.debug("Tag doesn't exists, creating tag")
|
||||||
return Tag(name=name)
|
return Tag(name=name)
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import operator
|
import operator
|
||||||
import shutil
|
import shutil
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||||
from mealie.core.config import app_dirs
|
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.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.backup import BackupJob, ImportJob, Imports, LocalBackup
|
||||||
from mealie.schema.snackbar import SnackResponse
|
from mealie.schema.snackbar import SnackResponse
|
||||||
from mealie.services.backups import imports
|
from mealie.services.backups import imports
|
||||||
|
@ -68,13 +70,10 @@ def upload_backup_file(archive: UploadFile = File(...)):
|
||||||
|
|
||||||
@router.get("/{file_name}/download")
|
@router.get("/{file_name}/download")
|
||||||
async def download_backup_file(file_name: str):
|
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)
|
file = app_dirs.BACKUP_DIR.joinpath(file_name)
|
||||||
|
|
||||||
if file.is_file:
|
return {"fileToken": create_file_token(file)}
|
||||||
return FileResponse(file, media_type="application/octet-stream", filename=file_name)
|
|
||||||
else:
|
|
||||||
return SnackResponse.error("No File Found")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{file_name}/import", status_code=200)
|
@router.post("/{file_name}/import", status_code=200)
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from mealie.core.config import APP_VERSION, LOGGER_FILE, app_dirs, settings
|
from mealie.core.config import APP_VERSION, 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.routes.deps import get_current_user
|
||||||
from mealie.schema.debug import AppInfo, DebugInfo
|
from mealie.schema.debug import AppInfo, DebugInfo
|
||||||
|
|
||||||
|
@ -36,10 +38,8 @@ async def get_mealie_version():
|
||||||
|
|
||||||
@router.get("/last-recipe-json")
|
@router.get("/last-recipe-json")
|
||||||
async def get_last_recipe_json(current_user=Depends(get_current_user)):
|
async def get_last_recipe_json(current_user=Depends(get_current_user)):
|
||||||
""" Doc Str """
|
""" Returns a token to download a file """
|
||||||
|
return {"fileToken": create_file_token(app_dirs.DEBUG_DIR.joinpath("last_recipe.json"))}
|
||||||
with open(app_dirs.DEBUG_DIR.joinpath("last_recipe.json"), "r") as f:
|
|
||||||
return json.loads(f.read())
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/log/{num}")
|
@router.get("/log/{num}")
|
||||||
|
@ -50,6 +50,12 @@ async def get_log(num: int, current_user=Depends(get_current_user)):
|
||||||
return log_text
|
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):
|
def tail(f, lines=20):
|
||||||
total_lines_wanted = lines
|
total_lines_wanted = lines
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from jose import JWTError, jwt
|
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)
|
token_data = TokenData(username=username)
|
||||||
except JWTError:
|
except JWTError:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
|
|
||||||
user = db.users.get(session, token_data.username, "email")
|
user = db.users.get(session, token_data.username, "email")
|
||||||
if user is None:
|
if user is None:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
return user
|
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
|
||||||
|
|
|
@ -79,7 +79,7 @@ def get_today(session: Session = Depends(generate_session), current_user: UserIn
|
||||||
|
|
||||||
|
|
||||||
@router.get("/today/image", tags=["Meal Plan"])
|
@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.
|
Returns the image for todays meal-plan.
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -8,15 +8,14 @@ from mealie.db.db_setup import generate_session
|
||||||
from mealie.routes.deps import get_current_user
|
from mealie.routes.deps import get_current_user
|
||||||
from mealie.schema.migration import MigrationFile, Migrations
|
from mealie.schema.migration import MigrationFile, Migrations
|
||||||
from mealie.schema.snackbar import SnackResponse
|
from mealie.schema.snackbar import SnackResponse
|
||||||
from mealie.services.migrations.chowdown import chowdown_migrate as chowdow_migrate
|
from mealie.services.migrations import migration
|
||||||
from mealie.services.migrations.nextcloud import migrate as nextcloud_migrate
|
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/migrations", tags=["Migration"], dependencies=[Depends(get_current_user)])
|
router = APIRouter(prefix="/api/migrations", tags=["Migration"], dependencies=[Depends(get_current_user)])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=List[Migrations])
|
@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 """
|
""" Returns a list of avaiable directories that can be imported into Mealie """
|
||||||
response_data = []
|
response_data = []
|
||||||
migration_dirs = [
|
migration_dirs = [
|
||||||
|
@ -36,23 +35,18 @@ def get_avaiable_nextcloud_imports():
|
||||||
return response_data
|
return response_data
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{type}/{file_name}/import")
|
@router.post("/{import_type}/{file_name}/import")
|
||||||
def import_nextcloud_directory(type: str, file_name: str, session: Session = Depends(generate_session)):
|
def import_migration(import_type: migration.Migration, file_name: str, session: Session = Depends(generate_session)):
|
||||||
""" Imports all the recipes in a given directory """
|
""" Imports all the recipes in a given directory """
|
||||||
file_path = app_dirs.MIGRATION_DIR.joinpath(type, file_name)
|
file_path = app_dirs.MIGRATION_DIR.joinpath(import_type.value, file_name)
|
||||||
if type == "nextcloud":
|
return migration.migrate(import_type, file_path, session)
|
||||||
return nextcloud_migrate(session, file_path)
|
|
||||||
elif type == "chowdown":
|
|
||||||
return chowdow_migrate(session, file_path)
|
|
||||||
else:
|
|
||||||
return SnackResponse.error("Incorrect Migration Type Selected")
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{type}/{file_name}/delete")
|
@router.delete("/{import_type}/{file_name}/delete")
|
||||||
def delete_migration_data(type: str, file_name: str):
|
def delete_migration_data(import_type: migration.Migration, file_name: str):
|
||||||
""" Removes migration data from the file system """
|
""" 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():
|
if remove_path.is_file():
|
||||||
remove_path.unlink()
|
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()}")
|
return SnackResponse.error(f"Migration Data Remove: {remove_path.absolute()}")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{type}/upload")
|
@router.post("/{import_type}/upload")
|
||||||
def upload_nextcloud_zipfile(type: str, archive: UploadFile = File(...)):
|
def upload_nextcloud_zipfile(import_type: migration.Migration, archive: UploadFile = File(...)):
|
||||||
""" Upload a .zip File to later be imported into Mealie """
|
""" 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)
|
dir.mkdir(parents=True, exist_ok=True)
|
||||||
dest = dir.joinpath(archive.filename)
|
dest = dir.joinpath(archive.filename)
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
|
import shutil
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
import requests
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException
|
from fastapi import APIRouter, Depends, File, Form, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from mealie.db.database import db
|
from mealie.db.database import db
|
||||||
|
@ -7,7 +9,7 @@ from mealie.db.db_setup import generate_session
|
||||||
from mealie.routes.deps import get_current_user
|
from mealie.routes.deps import get_current_user
|
||||||
from mealie.schema.recipe import Recipe, RecipeURLIn
|
from mealie.schema.recipe import Recipe, RecipeURLIn
|
||||||
from mealie.schema.snackbar import SnackResponse
|
from mealie.schema.snackbar import SnackResponse
|
||||||
from mealie.services.image.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 mealie.services.scraper.scraper import create_from_url
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
|
@ -61,6 +63,9 @@ def update_recipe(
|
||||||
|
|
||||||
recipe: Recipe = db.recipes.update(session, recipe_slug, data.dict())
|
recipe: Recipe = db.recipes.update(session, recipe_slug, data.dict())
|
||||||
|
|
||||||
|
if recipe_slug != recipe.slug:
|
||||||
|
rename_image(original_slug=recipe_slug, new_slug=recipe.slug)
|
||||||
|
|
||||||
return recipe.slug
|
return recipe.slug
|
||||||
|
|
||||||
|
|
||||||
|
@ -117,3 +122,16 @@ def update_recipe_image(
|
||||||
db.recipes.update_image(session, recipe_slug, extension)
|
db.recipes.update_image(session, recipe_slug, extension)
|
||||||
|
|
||||||
return response
|
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")
|
||||||
|
|
|
@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
|
||||||
from mealie.db.database import db
|
from mealie.db.database import db
|
||||||
from mealie.db.db_setup import generate_session
|
from mealie.db.db_setup import generate_session
|
||||||
from mealie.routes.deps import get_current_user
|
from mealie.routes.deps import get_current_user
|
||||||
from mealie.schema.category import RecipeTagResponse
|
from mealie.schema.category import RecipeTagResponse, TagIn
|
||||||
from mealie.schema.snackbar import SnackResponse
|
from mealie.schema.snackbar import SnackResponse
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
|
@ -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"])
|
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)
|
@router.get("/{tag}", response_model=RecipeTagResponse)
|
||||||
def get_all_recipes_by_tag(tag: str, session: Session = Depends(generate_session)):
|
def get_all_recipes_by_tag(tag: str, session: Session = Depends(generate_session)):
|
||||||
""" Returns a list of recipes associated with the provided tag. """
|
""" Returns a list of recipes associated with the provided tag. """
|
||||||
|
|
|
@ -4,7 +4,7 @@ from datetime import timedelta
|
||||||
from fastapi import APIRouter, Depends, File, UploadFile
|
from fastapi import APIRouter, Depends, File, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from mealie.core import security
|
from mealie.core import security
|
||||||
from mealie.core.config import settings, app_dirs
|
from mealie.core.config import app_dirs, settings
|
||||||
from mealie.core.security import get_password_hash, verify_password
|
from mealie.core.security import get_password_hash, verify_password
|
||||||
from mealie.db.database import db
|
from mealie.db.database import db
|
||||||
from mealie.db.db_setup import generate_session
|
from mealie.db.db_setup import generate_session
|
||||||
|
|
20
mealie/routes/utility_routes.py
Normal file
20
mealie/routes/utility_routes.py
Normal 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")
|
|
@ -1,14 +1,32 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
# Initialize Database Prerun
|
# Get Reload Arg `run.sh reload` for dev server
|
||||||
python mealie/db/init_db.py
|
ARG1=${1:-production}
|
||||||
python mealie/services/image/minify.py
|
|
||||||
|
|
||||||
## 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
|
# TODO
|
||||||
|
# Migrations
|
||||||
|
# Set Port from ENV Variable
|
||||||
|
|
||||||
## Web Server
|
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
|
caddy start --config ./Caddyfile
|
||||||
|
|
||||||
# Start API
|
# Start API
|
||||||
uvicorn mealie.app:app --host 0.0.0.0 --port 9000
|
uvicorn mealie.app:app --host 0.0.0.0 --port 9000
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,10 @@ class RecipeCategoryResponse(CategoryBase):
|
||||||
schema_extra = {"example": {"id": 1, "name": "dinner", "recipes": [{}]}}
|
schema_extra = {"example": {"id": 1, "name": "dinner", "recipes": [{}]}}
|
||||||
|
|
||||||
|
|
||||||
|
class TagIn(CategoryIn):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class TagBase(CategoryBase):
|
class TagBase(CategoryBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ class AppInfo(CamelModel):
|
||||||
version: str
|
version: str
|
||||||
demo_status: bool
|
demo_status: bool
|
||||||
|
|
||||||
|
|
||||||
class DebugInfo(AppInfo):
|
class DebugInfo(AppInfo):
|
||||||
api_port: int
|
api_port: int
|
||||||
api_docs: bool
|
api_docs: bool
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
from mealie.schema.restore import RecipeImport
|
||||||
from pydantic.main import BaseModel
|
from pydantic.main import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
@ -23,3 +24,7 @@ class MigrationFile(BaseModel):
|
||||||
class Migrations(BaseModel):
|
class Migrations(BaseModel):
|
||||||
type: str
|
type: str
|
||||||
files: List[MigrationFile] = []
|
files: List[MigrationFile] = []
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationImport(RecipeImport):
|
||||||
|
pass
|
||||||
|
|
|
@ -4,13 +4,16 @@ from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
from fastapi.logger import logger
|
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
|
from mealie.core import root_logger
|
||||||
from mealie.core.config import app_dirs
|
from mealie.core.config import app_dirs
|
||||||
from mealie.db.database import db
|
from mealie.db.database import db
|
||||||
from mealie.db.db_setup import create_session
|
from mealie.db.db_setup import create_session
|
||||||
|
from pathvalidate import sanitize_filename
|
||||||
from pydantic.main import BaseModel
|
from pydantic.main import BaseModel
|
||||||
|
|
||||||
|
logger = root_logger.get_logger()
|
||||||
|
|
||||||
|
|
||||||
class ExportDatabase:
|
class ExportDatabase:
|
||||||
def __init__(self, tag=None, templates=None) -> None:
|
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"))
|
ExportDatabase._write_json_file(items, out_dir.joinpath(f"{folder_name}.json"))
|
||||||
else:
|
else:
|
||||||
for item in items:
|
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
|
@staticmethod
|
||||||
def _write_json_file(data: Union[dict, list], out_file: Path):
|
def _write_json_file(data: Union[dict, list], out_file: Path):
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
import shutil
|
import shutil
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from fastapi.logger import logger
|
from mealie.core import root_logger
|
||||||
from mealie.core.config import app_dirs
|
from mealie.core.config import app_dirs
|
||||||
from mealie.services.image import minify
|
from mealie.services.image import minify
|
||||||
|
|
||||||
|
logger = root_logger.get_logger()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ImageOptions:
|
class ImageOptions:
|
||||||
|
@ -29,7 +30,6 @@ def read_image(recipe_slug: str, image_type: str = "original") -> Path:
|
||||||
Returns:
|
Returns:
|
||||||
Path: [description]
|
Path: [description]
|
||||||
"""
|
"""
|
||||||
print(image_type)
|
|
||||||
recipe_slug = recipe_slug.split(".")[0] # Incase of File Name
|
recipe_slug = recipe_slug.split(".")[0] # Incase of File Name
|
||||||
recipe_image_dir = app_dirs.IMG_DIR.joinpath(recipe_slug)
|
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
|
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:
|
try:
|
||||||
delete_image(recipe_slug)
|
delete_image(recipe_slug)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
image_dir = Path(app_dirs.IMG_DIR.joinpath(f"{recipe_slug}"))
|
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(".", "")
|
extension = extension.replace(".", "")
|
||||||
image_path = image_dir.joinpath(f"original.{extension}")
|
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:
|
with open(image_path, "ab") as f:
|
||||||
f.write(file_data)
|
f.write(file_data)
|
||||||
else:
|
else:
|
||||||
with open(image_path, "ab") as f:
|
with open(image_path, "ab") as f:
|
||||||
shutil.copyfileobj(file_data, f)
|
shutil.copyfileobj(file_data, f)
|
||||||
|
|
||||||
minify.migrate_images()
|
print(image_path)
|
||||||
|
minify.minify_image(image_path)
|
||||||
|
|
||||||
return 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)
|
write_image(slug, r.raw, filename.suffix)
|
||||||
|
|
||||||
filename.unlink()
|
filename.unlink(missing_ok=True)
|
||||||
|
|
||||||
return slug
|
return slug
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,33 @@
|
||||||
import shutil
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from mealie.core import root_logger
|
||||||
from mealie.core.config import app_dirs
|
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
|
"""Minifies an image in it's original file format. Quality is lost
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
@ -13,6 +35,11 @@ def minify_image(image_file: Path, min_dest: Path, tiny_dest: Path):
|
||||||
min_dest (Path): FULL Destination File Path
|
min_dest (Path): FULL Destination File Path
|
||||||
tiny_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:
|
try:
|
||||||
img = Image.open(image_file)
|
img = Image.open(image_file)
|
||||||
basewidth = 720
|
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 = crop_center(img)
|
||||||
tiny_image.save(tiny_dest, quality=70)
|
tiny_image.save(tiny_dest, quality=70)
|
||||||
|
|
||||||
except:
|
except Exception:
|
||||||
shutil.copy(image_file, min_dest)
|
shutil.copy(image_file, min_dest)
|
||||||
shutil.copy(image_file, tiny_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):
|
def crop_center(pil_img, crop_width=300, crop_height=300):
|
||||||
img_width, img_height = pil_img.size
|
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"]:
|
for unit in ["B", "kB", "MB", "GB", "TB", "PB"]:
|
||||||
if size < 1024.0 or unit == "PiB":
|
if size < 1024.0 or unit == "PiB":
|
||||||
break
|
break
|
||||||
|
@ -56,31 +92,46 @@ def move_all_images():
|
||||||
continue
|
continue
|
||||||
new_folder = app_dirs.IMG_DIR.joinpath(image_file.stem)
|
new_folder = app_dirs.IMG_DIR.joinpath(image_file.stem)
|
||||||
new_folder.mkdir(parents=True, exist_ok=True)
|
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():
|
def migrate_images():
|
||||||
print("Checking for Images to Minify...")
|
logger.info("Checking for Images to Minify...")
|
||||||
|
|
||||||
move_all_images()
|
move_all_images()
|
||||||
|
|
||||||
# Minify Loop
|
|
||||||
for image in app_dirs.IMG_DIR.glob("*/original.*"):
|
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():
|
minify_image(image)
|
||||||
continue
|
|
||||||
|
|
||||||
minify_image(image, min_dest, tiny_dest)
|
logger.info("Finished Minification Check")
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
migrate_images()
|
migrate_images()
|
||||||
|
validate_slugs_in_database()
|
||||||
|
|
173
mealie/services/migrations/_migration_base.py
Normal file
173
mealie/services/migrations/_migration_base.py
Normal 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)
|
|
@ -1,92 +1,46 @@
|
||||||
import shutil
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import yaml
|
|
||||||
from fastapi.logger import logger
|
|
||||||
from mealie.core.config import app_dirs
|
from mealie.core.config import app_dirs
|
||||||
from mealie.db.database import db
|
from mealie.schema.migration import MigrationImport
|
||||||
from mealie.schema.recipe import Recipe
|
from mealie.services.migrations import helpers
|
||||||
from mealie.utils.unzip import unpack_zip
|
from mealie.services.migrations._migration_base import MigrationAlias, MigrationBase
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
try:
|
|
||||||
from yaml import CLoader as Loader
|
class ChowdownMigration(MigrationBase):
|
||||||
except ImportError:
|
key_aliases: Optional[list[MigrationAlias]] = [
|
||||||
from yaml import Loader
|
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:
|
def migrate(session: Session, zip_path: Path) -> list[MigrationImport]:
|
||||||
"""Parse through the yaml file to try and pull out the relavent information.
|
cd_migration = ChowdownMigration(migration_file=zip_path, session=session)
|
||||||
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.
|
|
||||||
|
|
||||||
Args:
|
with cd_migration.temp_dir as dir:
|
||||||
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:
|
|
||||||
chow_dir = next(Path(dir).iterdir())
|
chow_dir = next(Path(dir).iterdir())
|
||||||
image_dir = app_dirs.TEMP_DIR.joinpath(chow_dir, "images")
|
image_dir = app_dirs.TEMP_DIR.joinpath(chow_dir, "images")
|
||||||
recipe_dir = app_dirs.TEMP_DIR.joinpath(chow_dir, "_recipes")
|
recipe_dir = app_dirs.TEMP_DIR.joinpath(chow_dir, "_recipes")
|
||||||
|
|
||||||
failed_recipes = []
|
recipes_as_dicts = [y for x in recipe_dir.glob("*.md") if (y := ChowdownMigration.yaml_reader(x)) is not None]
|
||||||
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)
|
|
||||||
|
|
||||||
failed_images = []
|
recipes = [cd_migration.clean_recipe_dictionary(x) for x in recipes_as_dicts]
|
||||||
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}
|
|
||||||
|
|
||||||
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
|
||||||
|
|
12
mealie/services/migrations/helpers.py
Normal file
12
mealie/services/migrations/helpers.py
Normal 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 != ""]
|
49
mealie/services/migrations/migration.py
Normal file
49
mealie/services/migrations/migration.py
Normal 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
|
|
@ -1,90 +1,69 @@
|
||||||
import json
|
from dataclasses import dataclass
|
||||||
import logging
|
|
||||||
import shutil
|
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from mealie.core.config import app_dirs
|
from mealie.schema.migration import MigrationImport
|
||||||
from mealie.db.database import db
|
from mealie.services.migrations import helpers
|
||||||
from mealie.schema.recipe import Recipe
|
from mealie.services.migrations._migration_base import MigrationAlias, MigrationBase
|
||||||
from mealie.services.scraper.cleaner import Cleaner
|
from slugify import slugify
|
||||||
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
|
|
||||||
def process_selection(selection: Path) -> Path:
|
@dataclass
|
||||||
if selection.is_dir():
|
class NextcloudDir:
|
||||||
return selection
|
name: str
|
||||||
elif selection.suffix == ".zip":
|
recipe: dict
|
||||||
with zipfile.ZipFile(selection, "r") as zip_ref:
|
image: Optional[Path]
|
||||||
nextcloud_dir = app_dirs.TEMP_DIR.joinpath("nextcloud")
|
|
||||||
nextcloud_dir.mkdir(exist_ok=False, parents=True)
|
@property
|
||||||
zip_ref.extractall(nextcloud_dir)
|
def slug(self):
|
||||||
return nextcloud_dir
|
return slugify(self.recipe.get("name"))
|
||||||
else:
|
|
||||||
|
@classmethod
|
||||||
|
def from_dir(cls, dir: Path):
|
||||||
|
try:
|
||||||
|
json_file = next(dir.glob("*.json"))
|
||||||
|
except StopIteration:
|
||||||
return None
|
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
|
||||||
|
|
||||||
def import_recipes(recipe_dir: Path) -> Recipe:
|
return cls(name=dir.name, recipe=NextcloudMigration.json_reader(json_file), image=image_file)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def prep():
|
class NextcloudMigration(MigrationBase):
|
||||||
try:
|
key_aliases: Optional[list[MigrationAlias]] = [
|
||||||
shutil.rmtree(app_dirs.TEMP_DIR)
|
MigrationAlias(key="tags", alias="keywords", func=helpers.split_by_comma),
|
||||||
except:
|
MigrationAlias(key="orgURL", alias="url", func=None),
|
||||||
pass
|
]
|
||||||
app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True)
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup():
|
def migrate(session: Session, zip_path: Path) -> list[MigrationImport]:
|
||||||
shutil.rmtree(app_dirs.TEMP_DIR)
|
|
||||||
|
|
||||||
|
nc_migration = NextcloudMigration(migration_file=zip_path, session=session)
|
||||||
|
|
||||||
def migrate(session, selection: str):
|
with nc_migration.temp_dir as dir:
|
||||||
prep()
|
potential_recipe_dirs = NextcloudMigration.glob_walker(dir, glob_str="**/[!.]*.json", return_parent=True)
|
||||||
app_dirs.MIGRATION_DIR.mkdir(exist_ok=True)
|
|
||||||
selection = app_dirs.MIGRATION_DIR.joinpath(selection)
|
|
||||||
|
|
||||||
nextcloud_dir = process_selection(selection)
|
# 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}
|
||||||
|
|
||||||
successful_imports = []
|
all_recipes = []
|
||||||
failed_imports = []
|
for _, nc_dir in nextcloud_dirs.items():
|
||||||
for dir in nextcloud_dir.iterdir():
|
recipe = nc_migration.clean_recipe_dictionary(nc_dir.recipe)
|
||||||
if dir.is_dir():
|
all_recipes.append(recipe)
|
||||||
|
|
||||||
try:
|
nc_migration.import_recipes_to_database(all_recipes)
|
||||||
recipe = import_recipes(dir)
|
|
||||||
db.recipes.create(session, recipe.dict())
|
|
||||||
|
|
||||||
successful_imports.append(recipe.name)
|
for report in nc_migration.migration_report:
|
||||||
except:
|
|
||||||
logging.error(f"Failed Nextcloud Import: {dir.name}")
|
|
||||||
logging.exception("")
|
|
||||||
failed_imports.append(dir.name)
|
|
||||||
|
|
||||||
cleanup()
|
if report.status:
|
||||||
|
nc_dir: NextcloudDir = nextcloud_dirs[report.slug]
|
||||||
|
if nc_dir.image:
|
||||||
|
NextcloudMigration.import_image(nc_dir.image, nc_dir.slug)
|
||||||
|
|
||||||
return {"successful": successful_imports, "failed": failed_imports}
|
return nc_migration.migration_report
|
||||||
|
|
|
@ -1,15 +1,18 @@
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from mealie.core import root_logger
|
||||||
from mealie.db.database import db
|
from mealie.db.database import db
|
||||||
from mealie.db.db_setup import create_session
|
from mealie.db.db_setup import create_session
|
||||||
from fastapi.logger import logger
|
|
||||||
from mealie.schema.user import GroupInDB
|
from mealie.schema.user import GroupInDB
|
||||||
from mealie.services.backups.exports import auto_backup_job
|
from mealie.services.backups.exports import auto_backup_job
|
||||||
from mealie.services.scheduler.global_scheduler import scheduler
|
from mealie.services.scheduler.global_scheduler import scheduler
|
||||||
from mealie.services.scheduler.scheduler_utils import Cron, cron_parser
|
from mealie.services.scheduler.scheduler_utils import Cron, cron_parser
|
||||||
from mealie.utils.post_webhooks import post_webhooks
|
from mealie.utils.post_webhooks import post_webhooks
|
||||||
|
|
||||||
|
logger = root_logger.get_logger()
|
||||||
|
|
||||||
# TODO Fix Scheduler
|
# TODO Fix Scheduler
|
||||||
|
|
||||||
|
|
||||||
@scheduler.scheduled_job(trigger="interval", minutes=30)
|
@scheduler.scheduled_job(trigger="interval", minutes=30)
|
||||||
def update_webhook_schedule():
|
def update_webhook_schedule():
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -128,8 +128,10 @@ class Cleaner:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def ingredient(ingredients: list) -> str:
|
def ingredient(ingredients: list) -> str:
|
||||||
|
if ingredients:
|
||||||
return [Cleaner.html(html.unescape(ing)) for ing in ingredients]
|
return [Cleaner.html(html.unescape(ing)) for ing in ingredients]
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def yield_amount(yld) -> str:
|
def yield_amount(yld) -> str:
|
||||||
|
|
|
@ -3,15 +3,17 @@ from typing import List
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import scrape_schema_recipe
|
import scrape_schema_recipe
|
||||||
|
from mealie.core import root_logger
|
||||||
from mealie.core.config import app_dirs
|
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.schema.recipe import Recipe
|
||||||
|
from mealie.services.image.image import scrape_image
|
||||||
from mealie.services.scraper import open_graph
|
from mealie.services.scraper import open_graph
|
||||||
from mealie.services.scraper.cleaner import Cleaner
|
from mealie.services.scraper.cleaner import Cleaner
|
||||||
|
|
||||||
LAST_JSON = app_dirs.DEBUG_DIR.joinpath("last_recipe.json")
|
LAST_JSON = app_dirs.DEBUG_DIR.joinpath("last_recipe.json")
|
||||||
|
|
||||||
|
logger = root_logger.get_logger()
|
||||||
|
|
||||||
|
|
||||||
def create_from_url(url: str) -> Recipe:
|
def create_from_url(url: str) -> Recipe:
|
||||||
"""Main entry point for generating a recipe from a URL. Pass in a URL and
|
"""Main entry point for generating a recipe from a URL. Pass in a URL and
|
||||||
|
|
25
poetry.lock
generated
25
poetry.lock
generated
|
@ -606,6 +606,17 @@ category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathvalidate"
|
||||||
|
version = "2.4.1"
|
||||||
|
description = "pathvalidate is a Python library to sanitize/validate a string such as filenames/file-paths/etc."
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.5"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
test = ["allpairspy", "click", "faker", "pytest (>=6.0.1)", "pytest-discord (>=0.0.6)", "pytest-md-report (>=0.0.12)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pillow"
|
name = "pillow"
|
||||||
version = "8.2.0"
|
version = "8.2.0"
|
||||||
|
@ -1164,7 +1175,7 @@ python-versions = "*"
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.9"
|
python-versions = "^3.9"
|
||||||
content-hash = "a81463b941cfdbc0e32e215644b172ec1111d5ada27864292d299d7d64fae4cf"
|
content-hash = "bfdb4d3d5d69e53f16b315f993b712a703058d3f59e24644681ccc9062cf5143"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
aiofiles = [
|
aiofiles = [
|
||||||
|
@ -1611,6 +1622,10 @@ pathspec = [
|
||||||
{file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"},
|
{file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"},
|
||||||
{file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"},
|
{file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"},
|
||||||
]
|
]
|
||||||
|
pathvalidate = [
|
||||||
|
{file = "pathvalidate-2.4.1-py3-none-any.whl", hash = "sha256:f5dde7efeeb4262784c5e1331e02752d07c1ec3ee5ea42683fe211155652b808"},
|
||||||
|
{file = "pathvalidate-2.4.1.tar.gz", hash = "sha256:3c9bd94c7ec23e9cfb211ffbe356ae75f979d6c099a2c745ee9490f524f32468"},
|
||||||
|
]
|
||||||
pillow = [
|
pillow = [
|
||||||
{file = "Pillow-8.2.0-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:dc38f57d8f20f06dd7c3161c59ca2c86893632623f33a42d592f097b00f720a9"},
|
{file = "Pillow-8.2.0-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:dc38f57d8f20f06dd7c3161c59ca2c86893632623f33a42d592f097b00f720a9"},
|
||||||
{file = "Pillow-8.2.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a013cbe25d20c2e0c4e85a9daf438f85121a4d0344ddc76e33fd7e3965d9af4b"},
|
{file = "Pillow-8.2.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a013cbe25d20c2e0c4e85a9daf438f85121a4d0344ddc76e33fd7e3965d9af4b"},
|
||||||
|
@ -1762,18 +1777,26 @@ pyyaml = [
|
||||||
{file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"},
|
{file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"},
|
||||||
{file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"},
|
{file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"},
|
||||||
{file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"},
|
{file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"},
|
||||||
|
{file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"},
|
||||||
|
{file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"},
|
||||||
{file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"},
|
{file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"},
|
||||||
{file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"},
|
{file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"},
|
||||||
{file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"},
|
{file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"},
|
||||||
{file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"},
|
{file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"},
|
||||||
|
{file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"},
|
||||||
|
{file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"},
|
||||||
{file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"},
|
{file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"},
|
||||||
{file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"},
|
{file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"},
|
||||||
{file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"},
|
{file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"},
|
||||||
{file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"},
|
{file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"},
|
||||||
|
{file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"},
|
||||||
|
{file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"},
|
||||||
{file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"},
|
{file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"},
|
||||||
{file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"},
|
{file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"},
|
||||||
{file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"},
|
{file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"},
|
||||||
{file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"},
|
{file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"},
|
||||||
|
{file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"},
|
||||||
|
{file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"},
|
||||||
{file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"},
|
{file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"},
|
||||||
{file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"},
|
{file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"},
|
||||||
{file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"},
|
{file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"},
|
||||||
|
|
|
@ -31,6 +31,7 @@ python-jose = "^3.2.0"
|
||||||
passlib = "^1.7.4"
|
passlib = "^1.7.4"
|
||||||
lxml = "4.6.2"
|
lxml = "4.6.2"
|
||||||
Pillow = "^8.2.0"
|
Pillow = "^8.2.0"
|
||||||
|
pathvalidate = "^2.4.1"
|
||||||
|
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
|
|
|
@ -9,7 +9,6 @@ from tests.app_routes import AppRoutes
|
||||||
from tests.test_config import TEST_CHOWDOWN_DIR, TEST_NEXTCLOUD_DIR
|
from tests.test_config import TEST_CHOWDOWN_DIR, TEST_NEXTCLOUD_DIR
|
||||||
|
|
||||||
|
|
||||||
# Chowdown
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def chowdown_zip():
|
def chowdown_zip():
|
||||||
zip = TEST_CHOWDOWN_DIR.joinpath("test_chowdown-gh-pages.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
|
assert response.status_code == 200
|
||||||
|
|
||||||
report = json.loads(response.content)
|
reports = json.loads(response.content)
|
||||||
assert report["failed"] == []
|
|
||||||
|
|
||||||
expected_slug = "roasted-okra"
|
for report in reports:
|
||||||
|
assert report.get("status") is True
|
||||||
recipe_url = api_routes.recipes_recipe_slug(expected_slug)
|
|
||||||
response = api_client.get(recipe_url)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_chowdown_migration_data(api_client: TestClient, api_routes: AppRoutes, chowdown_zip: Path, token):
|
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
|
assert response.status_code == 200
|
||||||
|
|
||||||
report = json.loads(response.content)
|
reports = json.loads(response.content)
|
||||||
assert report["failed"] == []
|
for report in reports:
|
||||||
|
assert report.get("status") is True
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete__nextcloud_migration_data(api_client: TestClient, api_routes: AppRoutes, nextcloud_zip: Path, token):
|
def test_delete__nextcloud_migration_data(api_client: TestClient, api_routes: AppRoutes, nextcloud_zip: Path, token):
|
||||||
|
|
|
@ -80,7 +80,7 @@ def test_cleaner_instructions(instructions):
|
||||||
def test_html_with_recipe_data():
|
def test_html_with_recipe_data():
|
||||||
path = TEST_RAW_HTML.joinpath("healthy_pasta_bake_60759.html")
|
path = TEST_RAW_HTML.joinpath("healthy_pasta_bake_60759.html")
|
||||||
url = "https://www.bbc.co.uk/food/recipes/healthy_pasta_bake_60759"
|
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["name"]) > 10
|
||||||
assert len(recipe_data["slug"]) > 10
|
assert len(recipe_data["slug"]) > 10
|
||||||
|
|
|
@ -29,7 +29,7 @@ def test_non_default_settings(monkeypatch):
|
||||||
monkeypatch.setenv("DEFAULT_GROUP", "Test Group")
|
monkeypatch.setenv("DEFAULT_GROUP", "Test Group")
|
||||||
monkeypatch.setenv("DEFAULT_PASSWORD", "Test Password")
|
monkeypatch.setenv("DEFAULT_PASSWORD", "Test Password")
|
||||||
monkeypatch.setenv("API_PORT", "8000")
|
monkeypatch.setenv("API_PORT", "8000")
|
||||||
monkeypatch.setenv("API_DOCS", False)
|
monkeypatch.setenv("API_DOCS", "False")
|
||||||
|
|
||||||
app_settings = AppSettings()
|
app_settings = AppSettings()
|
||||||
|
|
||||||
|
|
|
@ -1,39 +1,39 @@
|
||||||
from pathlib import Path
|
# import shutil
|
||||||
|
# from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
# import pytest
|
||||||
from mealie.core.config import app_dirs
|
# from mealie.core.config import app_dirs
|
||||||
from mealie.schema.recipe import Recipe
|
# 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
|
||||||
from tests.test_config import TEST_NEXTCLOUD_DIR
|
|
||||||
|
|
||||||
CWD = Path(__file__).parent
|
# CWD = Path(__file__).parent
|
||||||
TEST_NEXTCLOUD_DIR
|
# TEST_NEXTCLOUD_DIR
|
||||||
TEMP_NEXTCLOUD = app_dirs.TEMP_DIR.joinpath("nextcloud")
|
# TEMP_NEXTCLOUD = app_dirs.TEMP_DIR.joinpath("nextcloud")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
# @pytest.mark.parametrize(
|
||||||
"file_name,final_path",
|
# "file_name,final_path",
|
||||||
[("nextcloud.zip", TEMP_NEXTCLOUD)],
|
# [("nextcloud.zip", TEMP_NEXTCLOUD)],
|
||||||
)
|
# )
|
||||||
def test_zip_extraction(file_name: str, final_path: Path):
|
# def test_zip_extraction(file_name: str, final_path: Path):
|
||||||
prep()
|
# prep()
|
||||||
zip = TEST_NEXTCLOUD_DIR.joinpath(file_name)
|
# zip = TEST_NEXTCLOUD_DIR.joinpath(file_name)
|
||||||
dir = process_selection(zip)
|
# dir = process_selection(zip)
|
||||||
|
|
||||||
assert dir == final_path
|
# assert dir == final_path
|
||||||
cleanup()
|
# cleanup()
|
||||||
assert dir.exists() is False
|
# assert dir.exists() is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
# @pytest.mark.parametrize(
|
||||||
"recipe_dir",
|
# "recipe_dir",
|
||||||
[
|
# [
|
||||||
TEST_NEXTCLOUD_DIR.joinpath("Air Fryer Shrimp"),
|
# TEST_NEXTCLOUD_DIR.joinpath("Air Fryer Shrimp"),
|
||||||
TEST_NEXTCLOUD_DIR.joinpath("Chicken Parmigiana"),
|
# TEST_NEXTCLOUD_DIR.joinpath("Chicken Parmigiana"),
|
||||||
TEST_NEXTCLOUD_DIR.joinpath("Skillet Shepherd's Pie"),
|
# TEST_NEXTCLOUD_DIR.joinpath("Skillet Shepherd's Pie"),
|
||||||
],
|
# ],
|
||||||
)
|
# )
|
||||||
def test_nextcloud_migration(recipe_dir: Path):
|
# def test_nextcloud_migration(recipe_dir: Path):
|
||||||
recipe = import_recipes(recipe_dir)
|
# recipe = import_recipes(recipe_dir)
|
||||||
assert isinstance(recipe, Recipe)
|
# assert isinstance(recipe, Recipe)
|
||||||
app_dirs.IMG_DIR.joinpath(recipe.image).unlink(missing_ok=True)
|
# shutil.rmtree(app_dirs.IMG_DIR.joinpath(recipe.image), ignore_errors=True)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue