Merge remote-tracking branch 'upstream/master'

This commit is contained in:
rastacalavera 2021-02-24 20:22:28 -06:00
commit 569aac14dd
92 changed files with 1806 additions and 521 deletions

View file

@ -1,3 +1,5 @@
*/node_modules
*/dist
*/data/db
*/data/db
*/mealie/test
*/mealie/.temp

View file

@ -0,0 +1,56 @@
name: Docker Build Production
on:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
steps:
#
# Get Release Version
#
- uses: oprypin/find-latest-tag@v1
with:
repository: hay-kot/mealie # The repository to scan.
releases-only: true # We know that all relevant tags have a GitHub release for them.
id: mealie_version # The step ID to refer to later.
#
# Checkout
#
- name: checkout code
uses: actions/checkout@v2
#
# Setup QEMU
#
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v1
with:
image: tonistiigi/binfmt:latest
platforms: all
#
# Setup Buildx
#
- name: install buildx
id: buildx
uses: docker/setup-buildx-action@v1
with:
install: true
#
# Login to Docker Hub
#
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
#
# Build
#
- name: build the image
run: |
docker build --push \
--tag hkotel/mealie:${{ steps.mealie_version.outputs.tag }} \
--platform linux/amd64,linux/arm/v7,linux/arm64 .

View file

@ -21,7 +21,7 @@ jobs:
- name: Set up python
uses: actions/setup-python@v2
with:
python-version: 3.8
python-version: 3.9
#----------------------------------------------
# ----- install & configure poetry -----
#----------------------------------------------

View file

@ -17,5 +17,6 @@
"i18n-ally.keystyle": "nested",
"cSpell.words": [
"performant"
]
],
"search.mode": "reuseEditor"
}

21
Caddyfile Normal file
View file

@ -0,0 +1,21 @@
{
auto_https off
}
:80 {
@proxied path /api/* /docs /openapi.json
root * /app/dist
encode gzip
uri strip_suffix /
handle @proxied {
reverse_proxy http://127.0.0.1:9000
}
handle {
try_files {path}.html {path} /
file_server
}
}

View file

@ -7,7 +7,7 @@ RUN npm run build
FROM python:3.9-alpine
RUN apk add --no-cache libxml2-dev libxslt-dev libxml2
RUN apk add --no-cache libxml2-dev libxslt-dev libxml2 caddy
ENV ENV prod
EXPOSE 80
WORKDIR /app
@ -30,9 +30,11 @@ RUN apk add --update --no-cache --virtual .build-deps \
COPY ./mealie /app
COPY ./Caddyfile /app
COPY ./app_data/templates /app/data/templates
RUN rm -rf /app/tests /app/.temp
COPY --from=build-stage /app/dist /app/dist
RUN rm -rf /app/test /app/.temp
VOLUME [ "/app/data/" ]
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "80"]
RUN chmod +x /app/run.sh
CMD /app/run.sh

View file

@ -63,6 +63,7 @@ Mealie also provides a secure API for interactions from 3rd party applications.
#### Meal Planner
- Random Meal plan generation based off categories
- Expose notes in the API to allow external applications to access relevant information for meal plans
- Shopping Lists
#### Database Import / Export
- Easily Import / Export your recipes from the UI
- Export recipes in into custom files using Jinja2 templates
@ -82,7 +83,7 @@ Contributions are what make the open source community such an amazing place to b
If you are not a coder, you can still contribute financially. financial contributions help me prioritize working on this project over others and helps me know that there is a real demand for project development.
<a href="https://www.buymeacoffee.com/haykot" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-green.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
<a href="https://www.buymeacoffee.com/haykot" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-green.png" alt="Buy Me A Coffee" style="height: 30px !important;width: 107px !important;" ></a>
<!-- LICENSE -->
## License

View file

@ -1,15 +1,45 @@
# Release Notes
## v0.3.0 - Draft!
## v0.3.0
### Bug Fixes
- Fixed open search on `/` when in input. - Closes #174
- Error when importing recipe: KeyError: '@type' - Closes #145
- Fixed Import Issue - bhg.com - Closes #138
- Scraper not working with recipe containing HowToSection - Closes #73
### Features and Improvements
- Improved Nextcloud Imports
- Improved Recipe Parser!
- Open search with `/` hotkey!
- Database and App version are now split
- Unified and improved snackbar notifications
- Recipe Viewer
- Categories, Tags, and Notes will not be displayed below the steps on smaller screens
- Recipe Editor
- New Category/Tag endpoints to filter all recipes by Category or Tag
- Category sidebar now has show/hide behavior on mobile
- Settings menu on mobile is improved
- **Meal Planner**
- You can now restrict recipe categories used for random meal-plan creation in the settings menu
- Recipe picker dialog will now display recipes when the search bar is empty
- Minor UI improvements
- **Shopping lists!** Shopping list can now be generated from a meal plan. Currently ingredients are split by recipes or there is a beta feature that attempts to sort them by similarity.
- **Recipe Viewer**
- Categories, Tags, and Notes will now be displayed below the steps on smaller screens
- **Recipe Editor**
- Text areas now auto grow to fit content
- Description, Steps, and Notes support Markdown! This includes inline html in Markdown.
- **Imports**
- A revamped dialog has been created to provide more information on restoring backups. Exceptions on the backend are now sent to the frontend and are easily viewable to see what went wrong when you restored a backup. This functionality will be ported over to the migrations in a future release.
## v0.2.1 - Hot Fixes!
### Features and Improvements
- Fixes upload image error when no photo was scrapped
- Fixes no last_recipe.json not updating
- Added markdown rendering for notes
- New notifications
- Minor UI improvements
- Recipe editor refactor
- Settings/Theme models refactor
### Development / Misc
- Added async file response for images, downloading files.

View file

@ -8,13 +8,11 @@ Don't know what an iOS shortcut is? Neither did I! Experienced iOS users may alr
A shortcut is a quick way to get one or more tasks done with your apps. The Shortcuts app lets you create your own shortcuts with multiple steps. For example, build a “Surf Time” shortcut that grabs the surf report, gives an ETA to the beach, and launches your surf music playlist.
`
Basically it is a visual scripting language that lets a user build an automation in a guided fashion. The automation can be [shared with anyone](https://www.icloud.com/shortcuts/6ae356d5fc644cfa8983a3c90f242fbb) but if it is a user creation, you'll have to jump through a few hoops to make an untrusted automation work on your device. In brasilikum's shortcut, you need to make changes for it to work. Recent updates to the project have changed some of the syntax and folder structure since its original creation.
![screenshot](/mealie/docs/docs/img/original.jpg)
Aside from putting in your host machine ip or domain, you must also change
```
api/recipe/create-url/
@ -28,4 +26,4 @@ api/recipes/create-url
**NOTICE** --> recipe is now pluar and there is no trailing "/" at the end of the string.
Having made those changes, you should now be able to share a website to the shortcut and have mealie grab all the necessary information!
Having made those changes, you should now be able to share a website to the shortcut and have mealie grab all the necessary information!

View file

@ -1,11 +1,13 @@
# Meal Planner
## Working with Meal Plans
In Mealie you can create a mealplan based off the calendar inputs on the meal planner page. There is no limit to how long or how short a meal plan is. You may also create duplicate meal plans for the same date range. After selecting your date range, click on the card for each day and search through recipes to find your choice. After selecting a recipe for all meals save the plan. You can also randomly generate meal plans.
In Mealie you can create a meal plan based off the calendar inputs on the meal planner page. There is no limit to how long or how short a meal plan is. You may also create duplicate meal plans for the same date range. After selecting your date range, click on the card for each day and search through recipes to find your choice. After selecting a recipe for all meals save the plan. You can also randomly generate meal plans.
To edit the meal in a meal plan simply select the edit button on the card in the timeline. Similarly, to delete a mealplan click the delete button on the card in the timeline. Currently there is no support to change the date range in a meal plan.
To edit the meal in a meal plan simply select the edit button on the card in the timeline. Similarly, to delete a meal plan click the delete button on the card in the timeline. Currently there is no support to change the date range in a meal plan.
!!! warning
In coming a future release recipes for meals will be restricted to specific categories.
## Shopping Lists
For any meal plan created you can view a breakdown of all the ingredients and use an experimental sort function to sort similarly ingredients. This is a very new feature and results of the auto sort may vary.
![](../gifs/meal-plan-demo-v2.gif)

View file

@ -19,7 +19,9 @@ Color themes can be created and set from the UI in the settings page. You can se
## Backups
Site backups can easily be taken and download from the UI. To import, simply select the backup you'd like to restore and check which items you'd like to import.
## Meal Planner Webhooks
## Meal Planner
In the meal planner section you can select categories to be used as apart of the random recipe selector in the meal plan creator.
Meal planner webhooks are post requests sent from Mealie to an external endpoint. The body of the message is the Recipe JSON of the scheduled meal. If no meal is schedule, no request is sent. The webhook functionality can be enabled or disabled as well as scheduled. Note that you must "Save Webhooks" prior to any changes taking affect server side.

File diff suppressed because one or more lines are too long

View file

@ -5585,10 +5585,17 @@
"integrity": "sha1-h0v2nG9ATCtdmcSBNBOZ/VWJJjM="
},
"fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npm.taobao.org/fast-levenshtein/download/fast-levenshtein-2.0.6.tgz",
"integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
"dev": true
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-3.0.0.tgz",
"integrity": "sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==",
"requires": {
"fastest-levenshtein": "^1.0.7"
}
},
"fastest-levenshtein": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.12.tgz",
"integrity": "sha512-On2N+BpYJ15xIC974QNVuYGMOlEVt4s0EOI3wwMqOmK1fdDY+FN/zltPV8vosq4ad4c/gJ1KHScUn/6AWIgiow=="
},
"faye-websocket": {
"version": "0.11.3",
@ -8334,6 +8341,14 @@
"prelude-ls": "~1.1.2",
"type-check": "~0.3.2",
"word-wrap": "~1.2.3"
},
"dependencies": {
"fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
"dev": true
}
}
},
"ora": {

View file

@ -13,6 +13,7 @@
"@smartweb/vue-flash-message": "^0.6.10",
"axios": "^0.21.1",
"core-js": "^3.8.2",
"fast-levenshtein": "^3.0.0",
"fuse.js": "^6.4.6",
"qs": "^6.9.6",
"v-jsoneditor": "^1.4.2",

View file

@ -8,7 +8,6 @@
<title> Mealie </title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css">
<link rel="stylesheet" href="./styles/global.css">
</head>
<body>
<noscript>

View file

@ -1,11 +0,0 @@
*::-webkit-scrollbar {
width: 0.25rem;
}
*::-webkit-scrollbar-track {
background: lightgray;
}
*::-webkit-scrollbar-thumb {
background: grey;
}

View file

@ -63,7 +63,7 @@ export default {
},
created() {
window.addEventListener("keyup", e => {
if (e.key == "/") {
if (e.key == "/" && !document.activeElement.id.startsWith('input') ) {
this.search = !this.search;
}
});
@ -132,6 +132,25 @@ export default {
background-color: var(--v-success-base) !important;
}
.notify-base {
color: white !important;
margin-right: 60px;
margin-bottom: -5px;
opacity: 0.9 !important;
}
*::-webkit-scrollbar {
width: 0.25rem;
}
*::-webkit-scrollbar-track {
background: lightgray;
}
*::-webkit-scrollbar-thumb {
background: grey;
}
.notify-base {
color: white !important;
margin-right: 60px;

View file

@ -8,9 +8,9 @@ const backupURLs = {
// Backup
available: `${backupBase}available`,
createBackup: `${backupBase}export/database`,
importBackup: (fileName) => `${backupBase}${fileName}/import`,
deleteBackup: (fileName) => `${backupBase}${fileName}/delete`,
downloadBackup: (fileName) => `${backupBase}${fileName}/download`,
importBackup: fileName => `${backupBase}${fileName}/import`,
deleteBackup: fileName => `${backupBase}${fileName}/delete`,
downloadBackup: fileName => `${backupBase}${fileName}/download`,
};
export default {

View file

@ -8,9 +8,10 @@ const mealPlanURLs = {
all: `${prefix}all`,
create: `${prefix}create`,
thisWeek: `${prefix}this-week`,
update: (planID) => `${prefix}${planID}`,
delete: (planID) => `${prefix}${planID}`,
update: planID => `${prefix}${planID}`,
delete: planID => `${prefix}${planID}`,
today: `${prefix}today`,
shopping: planID => `${prefix}${planID}/shopping-list`,
};
export default {
@ -43,4 +44,9 @@ export default {
let response = await apiReq.put(mealPlanURLs.update(id), body);
return response;
},
async shoppingList(id) {
let response = await apiReq.get(mealPlanURLs.shopping(id));
return response.data;
},
};

View file

@ -5,6 +5,7 @@ const prefix = baseURL + "debug";
const debugURLs = {
version: `${prefix}/version`,
lastRecipe: `${prefix}/last-recipe-json`,
};
export default {
@ -12,4 +13,8 @@ export default {
let response = await apiReq.get(debugURLs.version);
return response.data;
},
async getLastJson() {
let response = await apiReq.get(debugURLs.lastRecipe);
return response.data;
},
};

View file

@ -8,13 +8,14 @@ const prefix = baseURL + "recipes/";
const recipeURLs = {
allRecipes: baseURL + "recipes",
allRecipesByCategory: prefix + "category",
create: prefix + "create",
createByURL: prefix + "create-url",
recipe: (slug) => prefix + slug,
update: (slug) => prefix + slug,
delete: (slug) => prefix + slug,
recipeImage: (slug) => `${prefix}${slug}/image`,
updateImage: (slug) => `${prefix}${slug}/image`,
recipe: slug => prefix + slug,
update: slug => prefix + slug,
delete: slug => prefix + slug,
recipeImage: slug => `${prefix}${slug}/image`,
updateImage: slug => `${prefix}${slug}/image`,
};
export default {
@ -27,6 +28,14 @@ export default {
return response;
},
async getAllByCategory(categories) {
let response = await apiReq.post(
recipeURLs.allRecipesByCategory,
categories
);
return response.data;
},
async create(recipeData) {
let response = await apiReq.post(recipeURLs.create, recipeData);
return response.data;
@ -67,7 +76,7 @@ export default {
keys: recipeKeys,
num: num,
},
paramsSerializer: (params) => {
paramsSerializer: params => {
return qs.stringify(params, { arrayFormat: "repeat" });
},
});

View file

@ -0,0 +1,38 @@
<template>
<v-card>
<v-card-title>Last Scrapped JSON Data</v-card-title>
<v-card-text>
<VJsoneditor
@error="logError()"
v-model="lastRecipeJson"
height="1500px"
:options="jsonEditorOptions"
/>
</v-card-text>
</v-card>
</template>
<script>
import VJsoneditor from "v-jsoneditor";
import api from "@/api";
export default {
components: { VJsoneditor },
data() {
return {
lastRecipeJson: {},
jsonEditorOptions: {
mode: "code",
search: false,
mainMenuBar: false,
},
};
},
async mounted() {
this.lastRecipeJson = await api.meta.getLastJson();
},
};
</script>
<style>
</style>

View file

@ -0,0 +1,37 @@
<template>
<v-card>
<v-card-title>Last Scrapped JSON Data</v-card-title>
<v-card-text>
<VJsoneditor
@error="logError()"
v-model="lastRecipeJson"
height="1500px"
:options="jsonEditorOptions"
/>
</v-card-text>
</v-card>
</template>
<script>
import VJsoneditor from "v-jsoneditor";
export default {
components: { VJsoneditor },
data() {
return {
lastRecipeJson: "",
jsonEditorOptions: {
mode: "code",
search: false,
mainMenuBar: false,
},
};
},
async mounted() {
this.lastRecipeJson = "Hello \n 123 \n 567"
},
};
</script>
<style>
</style>

View file

@ -96,6 +96,7 @@ export default {
return {
isLoading: false,
meals: [],
items: [],
// Dates
startDate: null,
@ -117,11 +118,12 @@ export default {
}
},
},
async mounted() {
let settings = await api.settings.requestAll();
this.items = await api.recipes.getAllByCategory(settings.planCategories);
},
computed: {
items() {
return this.$store.getters.getRecentRecipes;
},
actualStartDate() {
return Date.parse(this.startDate);
},

View file

@ -0,0 +1,110 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" width="650">
<v-card>
<v-card-title class="headline">
Shopping List
<v-spacer></v-spacer>
<v-btn text color="accent" @click="group = !group">
Group (Beta)
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-card-text v-if="group == false">
<v-list
dense
v-for="(recipe, index) in ingredients"
:key="`${index}-recipe`"
>
<v-subheader>{{ recipe.name }} </v-subheader>
<v-divider></v-divider>
<v-list-item-group color="primary">
<v-list-item
v-for="(item, i) in recipe.recipeIngredient"
:key="i"
>
<v-list-item-content>
<v-list-item-title v-text="item"></v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-list>
</v-card-text>
<v-card-text v-else>
<v-list dense>
<v-list-item-group color="primary">
<v-list-item v-for="(item, i) in rawIngredients" :key="i">
<v-list-item-content>
<v-list-item-title v-text="item"></v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-list>
</v-card-text>
<v-divider></v-divider>
</v-card>
</v-dialog>
</div>
</template>
<script>
import api from "@/api";
const levenshtein = require("fast-levenshtein");
export default {
data() {
return {
dialog: false,
planID: 0,
ingredients: [],
rawIngredients: [],
group: false,
};
},
methods: {
openDialog: function(id) {
this.dialog = true;
this.planID = id;
this.getIngredients();
},
async getIngredients() {
this.ingredients = await api.mealPlans.shoppingList(this.planID);
this.getRawIngredients();
},
getRawIngredients() {
this.ingredients.forEach(element => {
this.rawIngredients.push(element.recipeIngredient);
});
this.rawIngredients = this.rawIngredients.flat();
this.rawIngredients = this.levenshteinFilter(this.rawIngredients);
},
levenshteinFilter(source, maximum = 5) {
let _source, matches, x, y;
_source = source.slice();
matches = [];
for (x = _source.length - 1; x >= 0; x--) {
let output = _source.splice(x, 1);
for (y = _source.length - 1; y >= 0; y--) {
if (levenshtein.get(output[0], _source[y]) <= maximum) {
output.push(_source[y]);
_source.splice(y, 1);
x--;
}
}
matches.push(output);
}
return matches.flat();
},
},
};
</script>
<style>
</style>

View file

@ -1,50 +1,58 @@
<template>
<v-card
color="accent"
class="custom-transparent"
class="custom-transparent d-flex justify-start align-center text-center "
tile
:width="`${timeCardWidth}`"
v-if="totalTime || prepTime || performTime"
>
<v-card-text
class="text-caption white--text"
v-if="totalTime || prepTime || performTime"
<v-card flat color="rgb(255, 0, 0, 0.0)">
<v-icon large color="white" class="mx-2"> mdi-clock-outline </v-icon>
</v-card>
<v-divider vertical color="white" class="py-1" v-if="totalTime">
</v-divider>
<v-card flat color="rgb(255, 0, 0, 0.0)" class=" my-2 " v-if="totalTime">
<v-card-text class="white--text">
<div>
<strong> {{ $t("recipe.total-time") }} </strong>
</div>
<div>{{ totalTime }}</div>
</v-card-text>
</v-card>
<v-divider vertical color="white" class="py-1" v-if="prepTime"> </v-divider>
<v-card
flat
color="rgb(255, 0, 0, 0.0)"
class="white--text my-2 "
v-if="prepTime"
>
<v-row align="center" dense>
<v-col :cols="iconColumn">
<v-icon large color="white"> mdi-clock-outline </v-icon>
</v-col>
<v-divider
vertical
color="white"
class="my-1"
v-if="totalTime"
></v-divider>
<v-col v-if="totalTime">
<div><strong> {{ $t("recipe.total-time") }} </strong></div>
<div>{{ totalTime }}</div>
</v-col>
<v-divider
vertical
color="white"
class="my-1"
v-if="prepTime"
></v-divider>
<v-col v-if="prepTime">
<div><strong> {{ $t("recipe.prep-time") }} </strong></div>
<div>{{ prepTime }}</div>
</v-col>
<v-divider
vertical
color="white"
class="my-1"
v-if="performTime"
></v-divider>
<v-col v-if="performTime">
<div><strong> {{ $t("recipe.perform-time") }} </strong></div>
<div>{{ performTime }}</div>
</v-col>
</v-row>
</v-card-text>
<v-card-text class="white--text">
<div>
<strong> {{ $t("recipe.prep-time") }} </strong>
</div>
<div>{{ prepTime }}</div>
</v-card-text>
</v-card>
<v-divider vertical color="white" class="my-1" v-if="performTime">
</v-divider>
<v-card
flat
color="rgb(255, 0, 0, 0.0)"
class="white--text py-2 "
v-if="performTime"
>
<v-card-text class="white--text">
<div>
<strong> {{ $t("recipe.perform-time") }} </strong>
</div>
<div>{{ performTime }}</div>
</v-card-text>
</v-card>
</v-card>
</template>
@ -59,7 +67,7 @@ export default {
timeLength() {
let times = [];
let timeArray = [this.totalTime, this.prepTime, this.performTime];
timeArray.forEach((element) => {
timeArray.forEach(element => {
if (element) {
times.push(element);
}
@ -83,10 +91,10 @@ export default {
},
timeCardWidth() {
let timeArray = [this.totalTime, this.prepTime, this.performTime];
let width = 120;
timeArray.forEach((element) => {
let width = 80;
timeArray.forEach(element => {
if (element) {
width += 70;
width += 95;
}
});

View file

@ -9,6 +9,7 @@
/>
<v-row>
<v-col
:cols="12"
:sm="6"
:md="6"
:lg="4"
@ -19,14 +20,14 @@
<v-card hover outlined @click="openDialog(backup)">
<v-card-text>
<v-row align="center">
<v-col cols="12" sm="2">
<v-icon large color="primary"> mdi-backup-restore </v-icon>
<v-col cols="2">
<v-icon large color="primary">mdi-backup-restore</v-icon>
</v-col>
<v-col cols="12" sm="10">
<div>
<v-col cols="10">
<div class="text-truncate">
<strong>{{ backup.name }}</strong>
</div>
<div>{{ readableTime(backup.date) }}</div>
<div class="text-truncate">{{ readableTime(backup.date) }}</div>
</v-col>
</v-row>
</v-card-text>
@ -68,10 +69,9 @@ export default {
this.$emit("loading");
let response = await api.backups.import(data.name, data);
let failed = response.data.failed;
let succesful = response.data.successful;
let importData = response.data;
this.$emit("finished", succesful, failed);
this.$emit("finished", importData);
},
deleteBackup(data) {
this.$emit("loading");

View file

@ -1,7 +1,23 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" width="500">
<v-dialog
v-model="dialog"
width="500"
:fullscreen="$vuetify.breakpoint.xsOnly"
>
<v-card>
<v-toolbar dark color="primary" v-show="$vuetify.breakpoint.xsOnly">
<v-btn icon dark @click="dialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-toolbar-title></v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-items>
<v-btn dark text @click="raiseEvent('import')">
{{ $t("general.import") }}
</v-btn>
</v-toolbar-items>
</v-toolbar>
<v-card-title> {{ name }} </v-card-title>
<v-card-subtitle class="mb-n3"> {{ date }} </v-card-subtitle>
<v-divider></v-divider>
@ -72,7 +88,12 @@
<v-btn color="error" text @click="raiseEvent('delete')">
{{ $t("general.delete") }}
</v-btn>
<v-btn color="success" outlined @click="raiseEvent('import')">
<v-btn
color="success"
outlined
@click="raiseEvent('import')"
v-show="$vuetify.breakpoint.smAndUp"
>
{{ $t("general.import") }}
</v-btn>
</v-card-actions>

View file

@ -0,0 +1,47 @@
<template>
<div>
<v-data-table
dense
:headers="dataHeaders"
:items="dataSet"
item-key="name"
class="elevation-1 mt-2"
show-expand
:expanded.sync="expanded"
:footer-props="{
'items-per-page-options': [100, 200, 300, 400, -1],
}"
:items-per-page="100"
>
<template v-slot:item.status="{ item }">
<div :class="item.status ? 'success--text' : 'error--text'">
{{ item.status ? "Imported" : "Failed" }}
</div>
</template>
<template v-slot:expanded-item="{ headers, item }">
<td :colspan="headers.length">
<div class="ma-2">
{{ item.exception }}
</div>
</td>
</template>
</v-data-table>
</div>
</template>
<script>
export default {
props: {
dataSet: Array,
dataHeaders: Array,
},
data: () => ({
singleExpand: false,
expanded: [],
}),
};
</script>
<style>
</style>

View file

@ -0,0 +1,152 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" width="70%">
<v-card>
<v-card-title> Import Summary </v-card-title>
<v-card-text>
<v-row class="mb-n9">
<v-card flat>
<v-card-text>
<div>
<h3>Recipes</h3>
</div>
<div class="success--text">
Success: {{ recipeNumbers.success }}
</div>
<div class="error--text">
Failed: {{ recipeNumbers.failure }}
</div>
</v-card-text>
</v-card>
<v-card flat>
<v-card-text>
<div>
<h3>Themes</h3>
</div>
<div class="success--text">
Success: {{ themeNumbers.success }}
</div>
<div class="error--text">
Failed: {{ themeNumbers.failure }}
</div>
</v-card-text>
</v-card>
<v-card flat>
<v-card-text>
<div>
<h3>Settings</h3>
</div>
<div class="success--text">
Success: {{ settingsNumbers.success }}
</div>
<div class="error--text">
Failed: {{ settingsNumbers.failure }}
</div>
</v-card-text>
</v-card>
</v-row>
</v-card-text>
<v-tabs v-model="tab">
<v-tab>Recipes</v-tab>
<v-tab>Themes</v-tab>
<v-tab>Settings</v-tab>
</v-tabs>
<v-tabs-items v-model="tab">
<v-tab-item>
<v-card flat>
<DataTable :data-headers="recipeHeaders" :data-set="recipeData" />
</v-card>
</v-tab-item>
<v-tab-item>
<v-card>
<DataTable
:data-headers="recipeHeaders"
:data-set="themeData"
/> </v-card
></v-tab-item>
<v-tab-item>
<v-card
><DataTable
:data-headers="recipeHeaders"
:data-set="settingsData"
/>
</v-card>
</v-tab-item>
</v-tabs-items>
</v-card>
</v-dialog>
</div>
</template>
<script>
import DataTable from "./DataTable";
export default {
components: {
DataTable,
},
data: () => ({
tab: null,
dialog: false,
recipeData: [],
themeData: [],
settingsData: [],
recipeHeaders: [
{
text: "Status",
value: "status",
},
{
text: "Name",
align: "start",
sortable: true,
value: "name",
},
{ text: "Exception", value: "data-table-expand", align: "center" },
],
allDataTables: [],
}),
computed: {
recipeNumbers() {
let numbers = { success: 0, failure: 0 };
this.recipeData.forEach(element => {
if (element.status) {
numbers.success++;
} else numbers.failure++;
});
return numbers;
},
settingsNumbers() {
let numbers = { success: 0, failure: 0 };
this.settingsData.forEach(element => {
if (element.status) {
numbers.success++;
} else numbers.failure++;
});
return numbers;
},
themeNumbers() {
let numbers = { success: 0, failure: 0 };
this.themeData.forEach(element => {
if (element.status) {
numbers.success++;
} else numbers.failure++;
});
return numbers;
},
},
methods: {
open(importData) {
this.recipeData = importData.recipeImports;
this.themeData = importData.themeReport;
this.settingsData = importData.settingsReport;
this.dialog = true;
},
},
};
</script>
<style>
</style>

View file

@ -8,7 +8,7 @@
v-model="tag"
></v-text-field>
</v-card-text>
<v-card-actions class="mt-n9">
<v-card-actions class="mt-n9 flex-wrap">
<v-switch v-model="fullBackup" :label="switchLabel"></v-switch>
<v-spacer></v-spacer>
<v-btn color="success" text @click="createBackup()">

View file

@ -7,7 +7,7 @@
<v-card-text>
<v-row>
<v-col cols="12" md="6" ss="12">
<v-col cols="12" md="6" sm="12">
<NewBackupCard @created="processFinished" />
</v-col>
<v-col cols="12" md="6" sm="12">
@ -41,6 +41,7 @@
:failed-header="$t('settings.backup.failed-imports')"
:failed="failedImports"
/>
<ImportSummaryDialog ref="report" :import-data="importData" />
</v-card-text>
</v-card>
</template>
@ -48,6 +49,7 @@
<script>
import api from "@/api";
import SuccessFailureAlert from "../../UI/SuccessFailureAlert";
import ImportSummaryDialog from "./ImportSummaryDialog";
import UploadBtn from "../../UI/UploadBtn";
import AvailableBackupCard from "./AvailableBackupCard";
import NewBackupCard from "./NewBackupCard";
@ -58,6 +60,7 @@ export default {
UploadBtn,
AvailableBackupCard,
NewBackupCard,
ImportSummaryDialog,
},
data() {
return {
@ -65,6 +68,7 @@ export default {
successfulImports: [],
backupLoading: false,
availableBackups: [],
importData: [],
};
},
mounted() {
@ -87,12 +91,10 @@ export default {
this.backupLoading = false;
}
},
processFinished(successful = null, failed = null) {
processFinished(data) {
this.getAvailableBackups();
this.backupLoading = false;
this.successfulImports = successful;
this.failedImports = failed;
this.$refs.report.open();
this.$refs.report.open(data);
},
},
};

View file

@ -3,12 +3,12 @@
<v-card-text>
<h2 class="mt-1 mb-1">Home Page</h2>
<v-row align="center" justify="center" dense class="mb-n7 pb-n5">
<v-col sm="2">
<v-col cols="12" sm="3" md="2">
<v-switch v-model="showRecent" label="Show Recent"></v-switch>
</v-col>
<v-col>
<v-col cols="12" sm="5" md="5">
<v-slider
class="pt-4"
class="pt-sm-4"
label="Card Per Section"
v-model="showLimit"
max="30"
@ -24,7 +24,7 @@
</v-card-text>
<v-card-text>
<v-row>
<v-col>
<v-col cols="12" sm="6">
<v-card outlined min-height="250">
<v-card-text class="pt-2 pb-1">
<h3>Homepage Categories</h3>
@ -64,7 +64,7 @@
</v-list>
</v-card>
</v-col>
<v-col>
<v-col cols="12" sm="6">
<v-card outlined min-height="250px">
<v-card-text class="pt-2 pb-1">
<h3>

View file

@ -11,12 +11,10 @@
</span>
</v-card-title>
<v-divider></v-divider>
<HomePageSettings />
<v-divider></v-divider>
<v-card-text>
<h2 class="mt-1 mb-4">{{ $t("settings.language") }}</h2>
<v-row>
<v-col cols="3">
<v-col sm="3">
<v-select
dense
v-model="selectedLang"
@ -30,6 +28,8 @@
</v-row>
</v-card-text>
<v-divider></v-divider>
<HomePageSettings />
<v-divider></v-divider>
</v-card>
</template>
@ -59,6 +59,9 @@ export default {
this.langOptions = this.$store.getters.getAllLangs;
this.selectedLang = this.$store.getters.getActiveLang;
},
removeCategory(index) {
this.value.categories.splice(index, 1);
},
},
};
</script>

View file

@ -1,9 +1,49 @@
<template>
<v-card>
<v-card-title class="headline">
{{ $t("settings.webhooks.meal-planner-webhooks") }}
{{ $t("meal-plan.meal-planner") }}
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<h2 class="mt-1">{{ $t("recipe.categories") }}</h2>
<v-row>
<v-col sm="12" md="6">
<v-select
outlined
:flat="isFlat"
elavation="0"
v-model="planCategories"
:items="categories"
item-text="name"
item-value="name"
multiple
chips
hint="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-divider> </v-divider>
<v-card-text>
<h2 class="mt-1 mb-4">
{{ $t("settings.webhooks.meal-planner-webhooks") }}
</h2>
<p>
{{
$t(
@ -68,11 +108,20 @@ export default {
webhooks: [],
enabled: false,
time: "",
planCategories: [],
};
},
mounted() {
this.getSiteSettings();
},
computed: {
categories() {
return this.$store.getters.getCategories;
},
isFlat() {
return this.planCategories ? true : false;
},
},
methods: {
saveTime(value) {
this.time = value;
@ -83,6 +132,7 @@ export default {
this.name = settings.name;
this.time = settings.webhooks.webhookTime;
this.enabled = settings.webhooks.enabled;
this.planCategories = settings.planCategories;
},
addWebhook() {
this.webhooks.push(" ");
@ -93,6 +143,7 @@ export default {
saveWebhooks() {
const body = {
name: this.name,
planCategories: this.planCategories,
webhooks: {
webhookURLs: this.webhooks,
webhookTime: this.time,
@ -104,6 +155,9 @@ export default {
testWebhooks() {
api.settings.testWebhooks();
},
removeCategory(index) {
this.planCategories.splice(index, 1);
},
},
};
</script>

View file

@ -21,14 +21,16 @@
>
<v-card-text>
<v-row align="center">
<v-col cols="12" sm="2">
<v-icon large color="primary"> mdi-import </v-icon>
<v-col cols="2">
<v-icon large color="primary">mdi-import</v-icon>
</v-col>
<v-col cols="12" sm="10">
<div>
<v-col cols="10">
<div class="text-truncate">
<strong>{{ migration.name }}</strong>
</div>
<div>{{ readableTime(migration.date) }}</div>
<div class="text-truncate">
{{ readableTime(migration.date) }}
</div>
</v-col>
</v-row>
</v-card-text>

View file

@ -17,7 +17,8 @@
<v-row dense>
<v-col
:sm="12"
:cols="12"
:sm="6"
:md="6"
:lg="4"
:xl="3"

View file

@ -3,7 +3,13 @@
<div class="text-center">
<h3>{{ buttonText }}</h3>
</div>
<v-text-field v-model="color" hide-details class="ma-0 pa-0" solo>
<v-text-field
v-model="color"
hide-details
class="ma-0 pa-0"
solo
v-show="$vuetify.breakpoint.mdAndUp"
>
<template v-slot:append>
<v-menu
v-model="menu"
@ -17,12 +23,27 @@
</template>
<v-card>
<v-card-text class="pa-0">
<v-color-picker v-model="color" flat show-swatches />
<v-color-picker v-model="color" flat mode="hexa" show-swatches />
</v-card-text>
</v-card>
</v-menu>
</template>
</v-text-field>
<div class="text-center" v-show="$vuetify.breakpoint.smAndDown">
<v-menu
v-model="menu"
top
nudge-bottom="105"
nudge-left="16"
:close-on-content-click="false"
>
<template v-slot:activator="{ on, attrs }">
<v-chip label :color="`${color}`" dark v-bind="attrs" v-on="on">
{{ color }}
</v-chip>
</template>
</v-menu>
</div>
</div>
</template>
@ -36,7 +57,7 @@ export default {
return {
dialog: false,
swatches: false,
color: "#1976D2",
color: this.value || "#1976D2",
mask: "!#XXXXXXXX",
menu: false,
};

View file

@ -14,7 +14,7 @@
}}
</p>
<v-row dense align="center">
<v-col cols="12">
<v-col cols="6">
<v-btn-toggle
v-model="selectedDarkMode"
color="primary "
@ -22,12 +22,25 @@
@change="setStoresDarkMode"
>
<v-btn value="system">
{{ $t("settings.theme.default-to-system") }}
<v-icon>mdi-desktop-tower-monitor</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.default-to-system") }}
</span>
</v-btn>
<v-btn value="light"> {{ $t("settings.theme.light") }} </v-btn>
<v-btn value="light">
<v-icon>mdi-white-balance-sunny</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.light") }}
</span>
</v-btn>
<v-btn value="dark"> {{ $t("settings.theme.dark") }} </v-btn>
<v-btn value="dark">
<v-icon>mdi-weather-night</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.dark") }}
</span>
</v-btn>
</v-btn-toggle>
</v-col>
</v-row></v-card-text
@ -45,7 +58,7 @@
<v-form ref="form" lazy-validation>
<v-row dense align="center">
<v-col md="4" sm="3">
<v-col cols="12" md="4" sm="3">
<v-select
:label="$t('settings.theme.saved-color-theme')"
:items="availableThemes"

View file

@ -7,10 +7,14 @@
</v-card-title>
<v-card-text>
<v-form>
<v-form ref="urlForm">
<v-text-field
v-model="recipeURL"
:label="$t('new-recipe.recipe-url')"
required
validate-on-blur
autofocus
:rules="[isValidWebUrl]"
></v-text-field>
</v-form>
@ -64,18 +68,20 @@ export default {
methods: {
async createRecipe() {
this.processing = true;
let response = await api.recipes.createByURL(this.recipeURL);
if (response.status !== 201) {
this.error = true;
this.processing = false;
return;
}
if (this.$refs.urlForm.validate()) {
this.processing = true;
let response = await api.recipes.createByURL(this.recipeURL);
if (response.status !== 201) {
this.error = true;
this.processing = false;
return;
}
this.addRecipe = false;
this.processing = false;
this.recipeURL = "";
this.$router.push(`/recipe/${response.data}`);
this.addRecipe = false;
this.processing = false;
this.recipeURL = "";
this.$router.push(`/recipe/${response.data}`);
}
},
navCreate() {
@ -89,6 +95,10 @@ export default {
this.recipeURL = "";
this.processing = false;
},
isValidWebUrl(url) {
let regEx = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/gm;
return regEx.test(url) ? true : "Must be a Valid URL";
},
},
};
</script>

View file

@ -1,20 +1,43 @@
<template>
<v-navigation-drawer width="175px" clipped app permanent expand-on-hover>
<v-list nav dense>
<v-list-item v-for="nav in links" :key="nav.title" link :to="nav.to">
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ nav.title | titleCase }}</v-list-item-title>
</v-list-item>
</v-list>
</v-navigation-drawer>
<div>
<v-btn
class="mt-9 ml-n1"
fixed
left
bottom
fab
small
color="primary"
@click="showSidebar = !showSidebar"
>
<v-icon>mdi-tag</v-icon></v-btn
>
<v-navigation-drawer
:value="mobile ? showSidebar : true"
v-model="showSidebar"
width="175px"
clipped
app
>
<v-list nav dense>
<v-list-item v-for="nav in links" :key="nav.title" link :to="nav.to">
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ nav.title | titleCase }}</v-list-item-title>
</v-list-item>
</v-list>
</v-navigation-drawer>
</div>
</template>
<script>
export default {
data() {
return {
showSidebar: false,
mobile: false,
links: [],
baseLinks: [
{
@ -39,16 +62,20 @@ export default {
allCategories() {
this.buildSidebar();
},
showSidebar() {
},
},
mounted() {
this.buildSidebar();
this.mobile = this.viewScale();
this.showSidebar = !this.viewScale();
},
methods: {
async buildSidebar() {
this.links = [];
this.links.push(...this.baseLinks);
this.allCategories.forEach(async (element) => {
this.allCategories.forEach(async element => {
this.links.push({
title: element.name,
to: `/recipes/${element.slug}`,
@ -56,6 +83,16 @@ export default {
});
});
},
viewScale() {
switch (this.$vuetify.breakpoint.name) {
case "xs":
return true;
case "sm":
return true;
default:
return false;
}
},
},
};
</script>

View file

@ -6,8 +6,24 @@
:to="route ? `/recipe/${slug}` : ''"
@click="$emit('click')"
>
<v-img height="200" :src="getImage(image)"></v-img>
<v-card-title class="my-n3 mb-n6">{{ name | truncate(30) }}</v-card-title>
<v-img height="200" :src="getImage(image)">
<v-expand-transition v-if="description">
<div
v-if="hover"
class="d-flex transition-fast-in-fast-out secondary v-card--reveal "
style="height: 100%;"
>
<v-card-text class="v-card--text-show white--text">
{{ description | truncate(300) }}
</v-card-text>
</div>
</v-expand-transition>
</v-img>
<v-card-title class="my-n3 mb-n6 ">
<div class="headerClass">
{{ name }}
</div>
</v-card-title>
<v-card-actions class="">
<v-row dense align="center">
@ -23,16 +39,7 @@
></v-rating>
</v-col>
<v-col></v-col>
<v-col align="end">
<v-tooltip top color="secondary" max-width="400" open-delay="50">
<template v-slot:activator="{ on, attrs }">
<v-btn color="secondary" v-on="on" v-bind="attrs" text>{{
$t("recipe.description")
}}</v-btn>
</template>
<span>{{ description }}</span>
</v-tooltip>
</v-col>
<v-col align="end"> </v-col>
</v-row>
</v-card-actions>
</v-card>
@ -61,4 +68,21 @@ export default {
</script>
<style>
.v-card--reveal {
align-items: center;
bottom: 0;
justify-content: center;
opacity: 0.8;
position: absolute;
width: 100%;
}
.v-card--text-show {
opacity: 1 !important;
}
.headerClass {
white-space: nowrap;
word-break: normal;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -57,6 +57,7 @@ export default {
return {
searchSlug: "",
search: " ",
data: [],
result: [],
autoResults: [],
isDark: false,
@ -67,27 +68,30 @@ export default {
distance: 100,
maxPatternLength: 32,
minMatchCharLength: 1,
keys: ["name", "slug"],
keys: ["name", "slug", "description"],
},
};
},
mounted() {
this.isDark = this.$store.getters.getIsDark;
this.data = this.$store.getters.getRecentRecipes;
},
computed: {
data() {
return this.$store.getters.getRecentRecipes;
},
fuse() {
return new Fuse(this.data, this.options);
},
},
watch: {
search() {
if (this.search.trim() === "") this.result = this.list;
else this.result = this.fuse.search(this.search.trim());
try {
this.result = this.fuse.search(this.search.trim());
} catch {
this.result = this.data
.map(x => ({ item: x }))
.sort((a, b) => (a.name > b.name ? 1 : -1));
}
this.$emit("results", this.result);
if (this.showResults === true) {
this.autoResults = this.result;
}

View file

@ -1,6 +1,6 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" min-height="700" max-width="1000">
<v-dialog v-model="dialog" height="100%" max-width="1200">
<v-card min-height="725" height="100%">
<v-card-text>
<v-card-title></v-card-title>
@ -22,7 +22,7 @@
:md="6"
:lg="4"
:xl="3"
v-for="item in searchResults.slice(0, 10)"
v-for="item in searchResults.slice(0, 24)"
:key="item.item.name"
>
<RecipeCard

View file

@ -1,7 +1,21 @@
<template>
<v-dialog v-model="dialog" max-width="900px">
<v-dialog
v-model="dialog"
max-width="900px"
:fullscreen="$vuetify.breakpoint.xsOnly"
>
<v-card>
<v-card-title> {{ title }} </v-card-title>
<v-toolbar dark color="primary" v-show="$vuetify.breakpoint.xsOnly">
<v-btn icon dark @click="dialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-toolbar-title>{{ title }}</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-items></v-toolbar-items>
</v-toolbar>
<v-card-title v-show="$vuetify.breakpoint.smAndUp">
{{ title }}
</v-card-title>
<v-card-text class="mt-3">
<v-row>
<v-col>

View file

@ -0,0 +1,158 @@
{
"404": {
"page-not-found": "404 Seite nicht gefunden",
"take-me-home": "Zurück"
},
"new-recipe": {
"from-url": "Von URL",
"recipe-url": "Rezept URL",
"error-message": "Ein Fehler ist beim import der URL aufgetreten. Überprüfe das Log sowie debug/last_recipe.json um zu sehen was schief gelaufen ist.",
"bulk-add": "Massenimport",
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Füge deine Rezeptdaten ein. Jede Zeile wird als Eintrag in einer Liste dargestellt"
},
"general": {
"upload": "Hochladen",
"submit": "Einfügen",
"name": "Name",
"settings": "Einstellungen",
"close": "Schließen",
"save": "Speichern",
"image-file": "Bilddatei",
"update": "Aktualisieren",
"edit": "Bearbeiten",
"delete": "Löschen",
"select": "Auswählen",
"random": "Zufall",
"new": "Neu",
"create": "Erstellen",
"cancel": "Abbrechen",
"ok": "OK",
"enabled": "Aktiviert",
"download": "Herunterladen",
"import": "Importieren",
"options": "Optionen",
"templates": "Vorlagen",
"recipes": "Rezepte",
"themes": "Themen",
"confirm": "Besstätigen"
},
"login": {
"stay-logged-in": "Eingeloggt bleiben?",
"email": "Email",
"password": "Passwort",
"sign-in": "Einloggen",
"sign-up": "Registrieren"
},
"meal-plan": {
"dinner-this-week": "Essen diese Woche",
"dinner-today": "Heutiges Essen",
"planner": "Planer",
"edit-meal-plan": "Essensplan bearbeiten",
"meal-plans": "Essenspläne",
"create-a-new-meal-plan": "Neuen Essensplan erstellen",
"start-date": "Start-Datum",
"end-date": "End-Datum"
},
"recipe": {
"description": "Beschreibung",
"ingredients": "Zutaten",
"categories": "Kategorien",
"tags": "Markierungen",
"instructions": "Anweisungen",
"step-index": "Schritt: {step}",
"recipe-name": "Rezeptname",
"servings": "Portionen",
"ingredient": "Zutat",
"notes": "Notizen",
"note": "Notiz",
"original-url": "Original URL",
"view-recipe": "Rezept anschauen",
"title": "Titel",
"total-time": "Gesamtzeit",
"prep-time": "Vorbereitungszeit",
"perform-time": "Kochzeit",
"api-extras": "API Extras",
"object-key": "Objektschlüssel",
"object-value": "Objektwert",
"new-key-name": "Neuer Schlüsselname",
"add-key": "Schlüssel hinzufügen",
"key-name-required": "Schlüsselname benötigt",
"no-white-space-allowed": "Keine Leerschritte erlaubt",
"delete-recipe": "Rezept löschen",
"delete-confirmation": "Bist du sicher das du dieses Rezept löschen möchtest?"
},
"search": {
"search-mealie": "Suche Mealie"
},
"settings": {
"general-settings": "Einstellungen",
"local-api": "Lokale API",
"language": "Sprache",
"add-a-new-theme": "Neues Thema hinzufügen",
"set-new-time": "Neue Zeit einstellen",
"current": "Version:",
"latest": "Neuste",
"explore-the-docs": "Stöbern",
"contribute": "Beitragen",
"backup-and-exports": "Sicherungen",
"backup-info": "Sicherungen werden im standard JSON Format in das Dateisystem exportiert mitsamt sämtlicher Bilder. In deinem Sicherungsorder findest du eine ZIP Datei welche sämtliche JSON's deiner Rezepte und die Bilder aus der Datenbank enthält. Solltest du eine Markdown Datei auswählen werden diese ebenfalls im ZIP gespeichert. Um eine Sicherung zurückzuspielen muss die entsprechende ZIP Datei im Sicherungsorder liegen. Automatische Sicherungen finden jeden Tag um 3 Uhr früh statt.",
"available-backups": "Verfügbare Sicherungen",
"theme": {
"theme-name": "Themenname",
"theme-settings": "Themeneinstellungen",
"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": "Wähle ein Thema aus der Dropdown-Liste oder erstelle ein neues. Beachte das das Standard Thema auf alle Benutzer angewandt wird die keine Einstellung für ein Thema getroffen haben.",
"dark-mode": "Dunkler Modus",
"theme-is-required": "Thema wird benötigt",
"primary": "primär",
"secondary": "sekundär",
"accent": "betonen",
"success": "Erfolg",
"info": "Information",
"warning": "Warnung",
"error": "Fehler",
"default-to-system": "Standardeinstellung",
"light": "Hell",
"dark": "Dunkel",
"theme": "Thema",
"saved-color-theme": "Buntes Thema gespeichert",
"delete-theme": "Thema löschen",
"are-you-sure-you-want-to-delete-this-theme": "Bist du sicher das du dieses Thema löschen möchtest?",
"choose-how-mealie-looks-to-you-set-your-theme-preference-to-follow-your-system-settings-or-choose-to-use-the-light-or-dark-theme": "Entscheide wie Mealie für dich aussehen soll. Wähle Systemthema oder ob es Hell oder Dunkel dargestellt werden soll",
"theme-name-is-required": "Theme Name is required."
},
"webhooks": {
"meal-planner-webhooks": "Meal Planner Webhooks",
"the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at": "Die unten stehenden URL's erhalten webhooks die die Rezeptdaten enthalten für den Menüplan am geplanten Tag. Derzeit werden die webhooks ausgeführt um",
"test-webhooks": "Teste Webhooks",
"webhook-url": "Webhook URL"
},
"new-version-available": "Eine neue Version von Mealie steht zur verfügung, <a {aContents}> Schau ins Repository </a>",
"backup": {
"import-recipes": "Rezepte importieren",
"import-themes": "Themen importieren",
"import-settings": "Einstellungen importieren",
"create-heading": "Sicherung erstellen",
"backup-tag": "Sicherungsmarkierung",
"full-backup": "Komplettsicherungen",
"partial-backup": "Teilsicherungen",
"backup-restore-report": "Sicherungs/Widerherstellungsbericht",
"successfully-imported": "Erfolgreich importiert",
"failed-imports": "Import fehlgeschlagen"
}
},
"migration": {
"recipe-migration": "Rezepte übertragen",
"failed-imports": "Fehlgeschlagene Importe",
"migration-report": "Übertragungsbericht",
"successful-imports": "Erfolgreiche Importe",
"no-migration-data-available": "Keine Übertragungsdaten verfügbar",
"nextcloud": {
"title": "Nextcloud Cookbook",
"description": "Übertrage Daten aus einer Nextcloud Cookbook Instanz"
},
"chowdown": {
"title": "Chowdown",
"description": "Übertrage Daten aus Chowdown"
}
}
}

View file

@ -44,7 +44,9 @@
"sign-up": "Sign up"
},
"meal-plan": {
"shopping-list": "Shopping List",
"dinner-this-week": "Dinner This Week",
"meal-planner": "Meal Planner",
"dinner-today": "Dinner Today",
"planner": "Planner",
"edit-meal-plan": "Edit Meal Plan",

View file

@ -0,0 +1,158 @@
{
"404": {
"page-not-found": "404 Strony nie odnaleziono",
"take-me-home": "Powrót na stronę główną"
},
"new-recipe": {
"from-url": "Z odnośnika",
"recipe-url": "Odnośnik przepisu",
"error-message": "Wygląda na to, że wystąpił błąd. Sprawdź log i debug/last_recipe.json aby zasięgnąć po więcej informacji.",
"bulk-add": "Dodanie zbiorcze",
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Przeklej zawartość przepisu. Każda indywidualna linia traktowana będzie jako pozycja na liście"
},
"general": {
"upload": "Wrzuć",
"submit": "Zatwierdź",
"name": "Nazwa",
"settings": "Ustawienia",
"close": "Zamknij",
"save": "Zapisz",
"image-file": "Plik obrazu",
"update": "Uaktualnij",
"edit": "Edytuj",
"delete": "Usuń",
"select": "Zaznacz",
"random": "Losowa",
"new": "Nowa",
"create": "Utwórz",
"cancel": "Anuluj",
"ok": "OK",
"enabled": "Włączone",
"download": "Pobierz",
"import": "Importuj",
"options": "Opcje",
"templates": "Szablony",
"recipes": "Przepisy",
"themes": "Motywy",
"confirm": "Potwierdź"
},
"login": {
"stay-logged-in": "Pozostań zalogowany",
"email": "Email",
"password": "Hasło",
"sign-in": "Zaloguj się",
"sign-up": "Zarejestruj się"
},
"meal-plan": {
"dinner-this-week": "Obiad w tym tygodniu",
"dinner-today": "Obiad dziś",
"planner": "Planer",
"edit-meal-plan": "Edytuj plan posiłku",
"meal-plans": "Plany posiłku",
"create-a-new-meal-plan": "Utwórz nowy plan posiłku",
"start-date": "Data rozpoczęcia",
"end-date": "Data zakończenia"
},
"recipe": {
"description": "Opis",
"ingredients": "Składniki",
"categories": "Kategorie",
"tags": "Etykiety",
"instructions": "Instrukcje",
"step-index": "Krok: {step}",
"recipe-name": "Nazwa przepisu",
"servings": "Porcje",
"ingredient": "Składnik",
"notes": "Notatki",
"note": "Notatka",
"original-url": "Oryginalny odnośnik",
"view-recipe": "Wyświetl przepis",
"title": "Tytuł",
"total-time": "Czas całkowity",
"prep-time": "Czas przyrządzania",
"perform-time": "Czas gotowania",
"api-extras": "Dodatki API",
"object-key": "Klucz obiektu",
"object-value": "Wartość obiektu",
"new-key-name": "Nazwa nowego klucza",
"add-key": "Dodaj klucz",
"key-name-required": "Nazwa klucza jest wymagana",
"no-white-space-allowed": "Znaki niedrukowalne są niedozwolone",
"delete-recipe": "Usuń przepis",
"delete-confirmation": "Czy jesteś pewien, że chcesz usunąć ten przepis?"
},
"search": {
"search-mealie": "Przeszukaj Mealie"
},
"settings": {
"general-settings": "Ustawienia główne",
"local-api": "Lokalne API",
"language": "Język",
"add-a-new-theme": "Dodaj nowy motyw",
"set-new-time": "Ustaw nowy czas",
"current": "Wersja:",
"latest": "Najnowsza",
"explore-the-docs": "Zobacz dokumentację",
"contribute": "Wspomóż",
"backup-and-exports": "Kopie zapasowe",
"backup-info": "Kopie zapasowe zapisywane są w standardowym formacie JSON wraz ze zdjęciami w systemie plików. W katalogu kopii zapasowej znajdziesz plik z rozszerzeniem .zip zawierający wszystkie przepisy i zdjęcia z bazy danych. Jeśli oznaczone zostały pliki markdown, one także znajdą się w pliku .zip. Aby zaimportować kopię, musi ona znajdować się w folderze kopii zapasowych. Kopie automatyczne tworzone są codziennie o godzinie 03:00.",
"available-backups": "Dostępne kopie zapsowe",
"theme": {
"theme-name": "Nazwa motywu",
"theme-settings": "Ustawienia motywu",
"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": "Wybierz motyw z rozwijanej listy bądź stwórz nowy. Domyślny motyw zostanie użyty dla wszystkich użytkowników którzy nie wybrali własnej preferencji.",
"dark-mode": "Ciemny motyw",
"theme-is-required": "Motyw jest wymagany",
"primary": "Pierwszorzędny",
"secondary": "Drugorzędny",
"accent": "Akcent",
"success": "Powodzenie",
"info": "Informacja",
"warning": "Ostrzeżenie",
"error": "Błąd",
"default-to-system": "Domyślny dla systemu",
"light": "Jasny",
"dark": "Ciemny",
"theme": "Motyw",
"saved-color-theme": "Zapisany kolor motywu",
"delete-theme": "Usuń motyw",
"are-you-sure-you-want-to-delete-this-theme": "Czy jesteś pewien, że chcesz usunąć ten motyw?",
"choose-how-mealie-looks-to-you-set-your-theme-preference-to-follow-your-system-settings-or-choose-to-use-the-light-or-dark-theme": "Wybierz jak Mealie ma dla Ciebie wyglądać. Dostępne opcje to podążanie za odcieniem systemowym, bądź motyw jasny lub ciemny.",
"theme-name-is-required": "Nazwa motywu jest wymagana."
},
"webhooks": {
"meal-planner-webhooks": "Webhooki planera posiłków",
"the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at": "Odnośniki poniżej otrzymają webhook zawierający dane o przepisie dla danego dnia. Aktualnie webhooki zostanę wykonane o",
"test-webhooks": "Testuj webhooki",
"webhook-url": "Odnośnik webhooka"
},
"new-version-available": "Dostępna jest nowa wersja Mealie, <a {aContents}> sprawdź repozytorium </a>",
"backup": {
"import-recipes": "Wgraj przepisy",
"import-themes": "Wgraj motywy",
"import-settings": "Wgraj ustawienia",
"create-heading": "Utwórz kopię zapasową",
"backup-tag": "Etykieta kopii zapasowej",
"full-backup": "Pełna kopia zapasowa",
"partial-backup": "Częściowa kopia zapasowa",
"backup-restore-report": "Raport przywrócenia kopii zapasowej",
"successfully-imported": "Import zakończony suckesem",
"failed-imports": "Importy nieudane"
}
},
"migration": {
"recipe-migration": "Przenoszenie przepisów",
"failed-imports": "Importy udane",
"migration-report": "Raport przenosin",
"successful-imports": "Importy nieudane",
"no-migration-data-available": "Brak danych do przeniesienia",
"nextcloud": {
"title": "Nextcloud Cookbook",
"description": "Przenieś dane z Nextcloud Cookbook"
},
"chowdown": {
"title": "Chowdown",
"description": "Przenieś dane z Chowdown"
}
}
}

View file

@ -0,0 +1,20 @@
<template>
<div>
<LastRecipe />
<LogFile class="mt-2" />
</div>
</template>
<script>
import LastRecipe from "@/components/Debug/LastRecipe";
import LogFile from "@/components/Debug/LogFile";
export default {
components: {
LastRecipe,
LogFile,
},
};
</script>
<style>
</style>

View file

@ -6,6 +6,7 @@
@updated="planUpdated"
/>
<NewMeal v-else @created="requestMeals" class="mb-5" />
<ShoppingListDialog ref="shoppingList" />
<v-card class="my-2">
<v-card-title class="headline">
@ -49,6 +50,14 @@
</v-list-item-group>
</v-list>
<v-card-actions class="mt-n5">
<v-btn
color="accent lighten-2"
class="mx-0"
text
@click="openShoppingList(mealplan.uid)"
>
{{ $t("meal-plan.shopping-list") }}
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="accent lighten-2"
@ -78,11 +87,13 @@ import api from "@/api";
import utils from "@/utils";
import NewMeal from "../components/MealPlan/MealPlanNew";
import EditPlan from "../components/MealPlan/MealPlanEditor";
import ShoppingListDialog from "../components/MealPlan/ShoppingListDialog";
export default {
components: {
NewMeal,
EditPlan,
ShoppingListDialog,
},
data: () => ({
plannedMeals: [],
@ -122,6 +133,9 @@ export default {
api.mealPlans.delete(id);
this.requestMeals();
},
openShoppingList(id) {
this.$refs.shoppingList.openDialog(id);
},
},
};
</script>

View file

@ -16,7 +16,7 @@
<General />
<Theme class="mt-2" />
<Backup class="mt-2" />
<Webhooks class="mt-2" />
<MealPlanner class="mt-2" />
<Migration class="mt-2" />
<p class="text-center my-2">
{{ $t("settings.current") }}
@ -41,7 +41,7 @@
<script>
import Backup from "../components/Settings/Backup";
import General from "../components/Settings/General";
import Webhooks from "../components/Settings/Webhook";
import MealPlanner from "../components/Settings/MealPlanner";
import Theme from "../components/Settings/Theme";
import Migration from "../components/Settings/Migration";
import api from "@/api";
@ -50,7 +50,7 @@ import axios from "axios";
export default {
components: {
Backup,
Webhooks,
MealPlanner,
Theme,
Migration,
General,

View file

@ -7,12 +7,14 @@ import SettingsPage from "./pages/SettingsPage";
import AllRecipesPage from "./pages/AllRecipesPage";
import CategoryPage from "./pages/CategoryPage";
import MeaplPlanPage from "./pages/MealPlanPage";
import Debug from "./pages/Debug";
import MealPlanThisWeekPage from "./pages/MealPlanThisWeekPage";
import api from "@/api";
export const routes = [
{ path: "/", component: HomePage },
{ path: "/mealie", component: HomePage },
{ path: "/debug", component: Debug },
{ path: "/search", component: SearchPage },
{ path: "/recipes/all", component: AllRecipesPage },
{ path: "/recipes/:category", component: CategoryPage },

View file

@ -15,7 +15,7 @@ const mutations = {
state.showLimit = payload;
},
setCategories(state, payload) {
state.categories = payload;
state.categories = payload.sort((a, b) => (a.name > b.name ? 1 : -1));
},
setHomeCategories(state, payload) {
state.homeCategories = payload;

View file

@ -15,6 +15,10 @@ const state = {
name: "French",
value: "fr",
},
{
name: "Polish",
value: "pl",
},
{
name: "Swedish",
value: "sv",

View file

@ -29,6 +29,7 @@ const store = new Vuex.Store({
// All Recipe Data Store
recentRecipes: [],
allRecipes: [],
mealPlanCategories: [],
},
mutations: {
@ -44,6 +45,10 @@ const store = new Vuex.Store({
setRecentRecipes(state, payload) {
state.recentRecipes = payload;
},
setMealPlanCategories(state, payload) {
state.mealPlanCategories = payload;
},
},
actions: {
@ -69,6 +74,7 @@ const store = new Vuex.Store({
getSnackType: state => state.snackType,
getRecentRecipes: state => state.recentRecipes,
getMealPlanCategories: state => state.mealPlanCategories,
},
});

View file

@ -1,6 +1,6 @@
module.exports = {
transpileDependencies: ["vuetify"],
publicPath: process.env.NODE_ENV === "production" ? "/static/" : "/",
publicPath: process.env.NODE_ENV === "production" ? "/" : "/",
outputDir: process.env.NODE_ENV === "production" ? "./dist" : "../mealie/web",
devServer: {
proxy: {
@ -12,10 +12,10 @@ module.exports = {
},
pluginOptions: {
i18n: {
locale: 'en',
fallbackLocale: 'en',
localeDir: 'locales',
enableInSFC: true
}
}
locale: "en",
fallbackLocale: "en",
localeDir: "locales",
enableInSFC: true,
},
},
};

View file

@ -1,16 +1,14 @@
import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
# import utils.startup as startup
from app_config import APP_VERSION, PORT, PRODUCTION, WEB_PATH, docs_url, redoc_url
from app_config import APP_VERSION, PORT, PRODUCTION, docs_url, redoc_url
from routes import (
backup_routes,
debug_routes,
meal_routes,
migration_routes,
setting_routes,
static_routes,
theme_routes,
)
from routes.recipe import (
@ -31,10 +29,6 @@ app = FastAPI(
)
def mount_static_files():
app.mount("/static", StaticFiles(directory=WEB_PATH, html=True))
def start_scheduler():
import services.scheduler.scheduled_jobs
@ -62,24 +56,8 @@ def api_routers():
app.include_router(debug_routes.router)
if PRODUCTION:
mount_static_files()
api_routers()
# API 404 Catch all CALL AFTER ROUTERS
@app.get("/api/{full_path:path}", status_code=404, include_in_schema=False)
def invalid_api():
return None
app.include_router(static_routes.router)
# Generate API Documentation
# if not PRODUCTION:
# generate_api_docs(app)
start_scheduler()
init_settings()
@ -92,6 +70,7 @@ if __name__ == "__main__":
port=PORT,
reload=True,
debug=True,
log_level="info",
workers=1,
forwarded_allow_ips="*",
)

View file

@ -16,8 +16,8 @@ ENV = CWD.joinpath(".env")
dotenv.load_dotenv(ENV)
# General
APP_VERSION = "v0.2.1"
DB_VERSION = "v0.2.0"
APP_VERSION = "v0.3.0"
DB_VERSION = "v0.2.1"
PRODUCTION = os.environ.get("ENV")
PORT = int(os.getenv("mealie_port", 9000))
API = os.getenv("api_docs", True)

View file

@ -15,7 +15,7 @@ from db.sql.theme_models import SiteThemeModel
class _Recipes(BaseDocument):
def __init__(self) -> None:
self.primary_key = "slug"
self.sql_model = RecipeModel
self.sql_model: RecipeModel = RecipeModel
def update_image(self, session: Session, slug: str, extension: str = None) -> str:
entry: RecipeModel = self._query_one(session, match_value=slug)
@ -48,15 +48,6 @@ class _Settings(BaseDocument):
self.primary_key = "name"
self.sql_model = SiteSettingsModel
def create(self, session: Session, main: dict, webhooks: dict) -> str:
new_settings = self.sql_model(main.get("name"), webhooks)
session.add(new_settings)
return_data = new_settings.dict()
session.commit()
return return_data
class _Themes(BaseDocument):
def __init__(self) -> None:

View file

@ -100,11 +100,17 @@ class BaseDocument:
match_key = self.primary_key
result = (
session.query(self.sql_model).filter_by(**{match_key: match_value}).one()
session.query(self.sql_model)
.filter_by(**{match_key: match_value})
.limit(limit)
.all()
)
db_entry = result.dict()
db_entries = [x.dict() for x in result]
return db_entry
if limit == 1:
return db_entries[0]
return db_entries
def create(self, session: Session, document: dict) -> dict:
"""Creates a new database entry for the given SQL Alchemy Model.

View file

@ -85,6 +85,13 @@ class Category(SqlAlchemyBase):
"recipes": [x.dict() for x in self.recipes],
}
def dict_no_recipes(self):
return {
"id": self.id,
"slug": self.slug,
"name": self.name,
}
class Tag(SqlAlchemyBase):
__tablename__ = "tags"

View file

@ -1,27 +1,54 @@
import sqlalchemy as sa
import sqlalchemy.orm as orm
from db.sql.model_base import BaseMixins, SqlAlchemyBase
from db.sql.recipe_models import Category
class SiteSettingsModel(SqlAlchemyBase):
class SiteSettingsModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "site_settings"
name = sa.Column(sa.String, primary_key=True)
planCategories = orm.relationship(
"MealCategory", uselist=True, cascade="all, delete"
)
webhooks = orm.relationship("WebHookModel", uselist=False, cascade="all, delete")
def __init__(self, name: str = None, webhooks: dict = None, session=None) -> None:
def __init__(
self, name: str = None, webhooks: dict = None, planCategories=[], session=None
) -> None:
self.name = name
self.planCategories = [MealCategory(cat) for cat in planCategories]
self.webhooks = WebHookModel(**webhooks)
def update(self, session, name, webhooks: dict) -> dict:
def update(self, session, name, webhooks: dict, planCategories=[]) -> dict:
self._sql_remove_list(session, [MealCategory], self.name)
self.name = name
self.planCategories = [MealCategory(x) for x in planCategories]
self.webhooks.update(session=session, **webhooks)
return
def dict(self):
data = {"name": self.name, "webhooks": self.webhooks.dict()}
data = {
"name": self.name,
"planCategories": [cat.to_str() for cat in self.planCategories],
"webhooks": self.webhooks.dict(),
}
return data
class MealCategory(SqlAlchemyBase):
__tablename__ = "meal_plan_categories"
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("site_settings.name"))
def __init__(self, name) -> None:
self.name = name
def to_str(self):
return self.name
class WebHookModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "webhook_settings"
id = sa.Column(sa.Integer, primary_key=True)

View file

View file

@ -1,4 +1,4 @@
from typing import List
from typing import List, Optional
from pydantic.main import BaseModel
from services.recipe_services import Recipe
@ -8,7 +8,7 @@ class RecipeCategoryResponse(BaseModel):
id: int
name: str
slug: str
recipes: List[Recipe]
recipes: Optional[List[Recipe]]
class Config:
schema_extra = {"example": {"id": 1, "name": "dinner", "recipes": [{}]}}

View file

@ -0,0 +1,20 @@
from typing import Optional
from pydantic.main import BaseModel
class RecipeImport(BaseModel):
name: Optional[str]
slug: str
status: bool
exception: Optional[str]
class ThemeImport(BaseModel):
name: str
status: bool
exception: Optional[str]
class SettingsImport(BaseModel):
name: str
status: bool
exception: Optional[str]

View file

@ -11,12 +11,14 @@ class Webhooks(BaseModel):
class SiteSettings(BaseModel):
name: str = "main"
planCategories: list[str] = []
webhooks: Webhooks
class Config:
schema_extra = {
"example": {
"name": "main",
"planCategories": ["dinner", "lunch"],
"webhooks": {
"webhookTime": "00:00",
"webhookURLs": ["https://mywebhookurl.com/webhook"],

View file

@ -22,7 +22,7 @@ async def get_last_recipe_json():
return json.loads(f.read())
@router.get("/log/{num}", response_class=HTMLResponse)
@router.get("/log/{num}")
async def get_log(num: int):
""" Doc Str """
with open(LOGGER_FILE, "rb") as f:
@ -53,4 +53,4 @@ def tail(f, lines=20):
block_end_byte -= BLOCK_SIZE
block_number -= 1
all_read_text = b"".join(reversed(blocks))
return b"<br/>".join(all_read_text.splitlines()[-total_lines_wanted:])
return b"/n".join(all_read_text.splitlines()[-total_lines_wanted:])

View file

@ -1,5 +1,6 @@
from typing import List
from db.database import db
from db.db_setup import generate_session
from fastapi import APIRouter, Depends, HTTPException
from services.meal_services import MealPlan
@ -16,6 +17,21 @@ def get_all_meals(session: Session = Depends(generate_session)):
return MealPlan.get_all(session)
@router.get("/{id}/shopping-list")
def get_shopping_list(id: str, session: Session = Depends(generate_session)):
#! Refactor into Single Database Call
mealplan = db.meals.get(session, id)
slugs = [x.get("slug") for x in mealplan.get("meals")]
recipes = [db.recipes.get(session, x) for x in slugs]
ingredients = [
{"name": x.get("name"), "recipeIngredient": x.get("recipeIngredient")}
for x in recipes
]
return ingredients
@router.post("/create")
def set_meal_plan(data: MealPlan, session: Session = Depends(generate_session)):
""" Creates a meal plan database entry """

View file

@ -4,6 +4,7 @@ from db.database import db
from db.db_setup import generate_session
from fastapi import APIRouter, Depends, Query
from models.recipe_models import AllRecipeRequest
from slugify import slugify
from sqlalchemy.orm.session import Session
router = APIRouter(tags=["Query All Recipes"])
@ -69,3 +70,25 @@ def get_all_recipes_post(
"""
return db.recipes.get_all_limit_columns(session, body.properties, body.limit)
@router.post("/api/recipes/category")
def filter_by_category(categories: list, session: Session = Depends(generate_session)):
""" pass a list of categories and get a list of recipes associated with those categories """
#! This should be refactored into a single database call, but I couldn't figure it out
in_category = [
db.categories.get(session, slugify(cat), limit=1) for cat in categories
]
in_category = [cat.get("recipes") for cat in in_category]
in_category = [item for sublist in in_category for item in sublist]
return in_category
@router.post("/api/recipes/tag")
async def filter_by_tags(tags: list, session: Session = Depends(generate_session)):
""" pass a list of tags and get a list of recipes associated with those tags"""
#! This should be refactored into a single database call, but I couldn't figure it out
in_tags = [db.tags.get(session, slugify(tag), limit=1) for tag in tags]
in_tags = [tag.get("recipes") for tag in in_tags]
in_tags = [item for sublist in in_tags for item in sublist]
return in_tags

View file

@ -3,6 +3,7 @@ from db.db_setup import generate_session
from fastapi import APIRouter, Depends
from models.category_models import RecipeCategoryResponse
from sqlalchemy.orm.session import Session
from utils.snackbar import SnackResponse
from utils.snackbar import SnackResponse
@ -26,14 +27,15 @@ def get_all_recipes_by_category(
return db.categories.get(session, category)
@router.delete("/{category}")
async def delete_recipe_category(
category: str, session: Session = Depends(generate_session)
):
""" Removes a recipe category from the database. Deleting a
"""Removes a recipe category from the database. Deleting a
category does not impact a recipe. The category will be removed
from any recipes that contain it """
from any recipes that contain it"""
db.categories.delete(session, category)
return SnackResponse(f"Category Deleted: {category}")
return SnackResponse.error(f"Category Deleted: {category}")

View file

@ -1,10 +1,11 @@
from db.db_setup import generate_session
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query
from fastapi import APIRouter, Depends, File, Form, HTTPException
from fastapi.logger import logger
from fastapi.responses import FileResponse
from models.recipe_models import RecipeURLIn
from services.image_services import read_image, write_image
from services.recipe_services import Recipe
from services.scrape_services import create_from_url
from services.scraper.scraper import create_from_url
from sqlalchemy.orm.session import Session
from utils.snackbar import SnackResponse
@ -27,6 +28,7 @@ def parse_recipe_url(url: RecipeURLIn, db: Session = Depends(generate_session)):
""" Takes in a URL and attempts to scrape data and load it into the database """
recipe = create_from_url(url.url)
recipe.save_to_db(db)
return recipe.slug
@ -69,8 +71,10 @@ def delete_recipe(recipe_slug: str, db: Session = Depends(generate_session)):
async def get_recipe_img(recipe_slug: str):
""" Takes in a recipe slug, returns the static image """
recipe_image = read_image(recipe_slug)
return FileResponse(recipe_image)
if recipe_image:
return FileResponse(recipe_image)
else:
return
@router.put("/{recipe_slug}/image")

View file

@ -2,6 +2,7 @@ from db.database import db
from db.db_setup import generate_session
from fastapi import APIRouter, Depends
from sqlalchemy.orm.session import Session
from utils.snackbar import SnackResponse
from utils.snackbar import SnackResponse
@ -13,7 +14,7 @@ router = APIRouter(
)
@router.get("/all")
@router.get("/")
async def get_all_recipe_tags(session: Session = Depends(generate_session)):
""" Returns a list of available tags in the database """
return db.tags.get_all_primary_keys(session)

View file

@ -19,7 +19,6 @@ def get_main_settings(session: Session = Depends(generate_session)):
except:
default_settings_init(session)
data = db.settings.get(session, "main")
return data

View file

@ -1,24 +0,0 @@
from pathlib import Path
from fastapi import APIRouter, responses
from fastapi.responses import FileResponse
CWD = Path(__file__).parent
WEB_PATH = CWD.parent.joinpath("dist")
BASE_HTML = WEB_PATH.joinpath("index.html")
router = APIRouter(include_in_schema=False)
@router.get("/favicon.ico")
def facivon():
return responses.RedirectResponse(url="/mealie/favicon.ico")
@router.get("/")
async def root():
return FileResponse(BASE_HTML)
@router.get("/{full_path:path}")
async def root_plus(full_path):
return FileResponse(BASE_HTML)

10
mealie/run.sh Normal file
View file

@ -0,0 +1,10 @@
#!/bin/sh
## Migrations
# TODO
## Web Server
caddy start --config ./Caddyfile
## Start API
uvicorn app:app --host 0.0.0.0 --port 9000

View file

@ -1,15 +1,14 @@
import json
import shutil
import zipfile
from logging import error
from pathlib import Path
from typing import List
from app_config import BACKUP_DIR, IMG_DIR, TEMP_DIR
from db.database import db
from models.import_models import RecipeImport, SettingsImport, ThemeImport
from models.theme_models import SiteTheme
from services.recipe_services import Recipe
from services.settings_services import SiteSettings
from sqlalchemy.orm.session import Session
from utils.logger import logger
@ -57,23 +56,29 @@ class ImportDatabase:
raise Exception("Import file does not exist")
def run(self):
report = {}
recipe_report = []
settings_report = []
theme_report = []
if self.imp_recipes:
report = self.import_recipes()
recipe_report = self.import_recipes()
if self.imp_settings:
self.import_settings()
settings_report = self.import_settings()
if self.imp_themes:
self.import_themes()
theme_report = self.import_themes()
self.clean_up()
return report if report else None
return {
"recipeImports": recipe_report,
"settingsReport": settings_report,
"themeReport": theme_report,
}
def import_recipes(self):
recipe_dir: Path = self.import_dir.joinpath("recipes")
imports = []
successful_imports = []
failed_imports = []
for recipe in recipe_dir.glob("*.json"):
with open(recipe, "r") as f:
@ -82,16 +87,27 @@ class ImportDatabase:
try:
recipe_obj = Recipe(**recipe_dict)
recipe_obj.save_to_db(self.session)
import_status = RecipeImport(
name=recipe_obj.name, slug=recipe_obj.slug, status=True
)
imports.append(import_status)
successful_imports.append(recipe.stem)
logger.info(f"Imported: {recipe.stem}")
except Exception as inst:
logger.error(inst)
logger.info(f"Failed Import: {recipe.stem}")
failed_imports.append(recipe.stem)
import_status = RecipeImport(
name=recipe.stem,
slug=recipe.stem,
status=False,
exception=str(inst),
)
imports.append(import_status)
self._import_images(successful_imports)
return {"successful": successful_imports, "failed": failed_imports}
return imports
@staticmethod
def _recipe_migration(recipe_dict: dict) -> dict:
@ -130,7 +146,7 @@ class ImportDatabase:
def import_themes(self):
themes_file = self.import_dir.joinpath("themes", "themes.json")
theme_imports = []
with open(themes_file, "r") as f:
themes: list[dict] = json.loads(f.read())
for theme in themes:
@ -138,17 +154,38 @@ class ImportDatabase:
continue
new_theme = SiteTheme(**theme)
try:
db.themes.create(self.session, new_theme.dict())
except:
theme_imports.append(ThemeImport(name=new_theme.name, status=True))
except Exception as inst:
logger.info(f"Unable Import Theme {new_theme.name}")
theme_imports.append(
ThemeImport(name=new_theme.name, status=False, exception=str(inst))
)
return theme_imports
def import_settings(self):
settings_file = self.import_dir.joinpath("settings", "settings.json")
settings_imports = []
with open(settings_file, "r") as f:
settings: dict = json.loads(f.read())
db.settings.update(self.session, settings.get("name"), settings)
name = settings.get("name")
try:
db.settings.update(self.session, name, settings)
import_status = SettingsImport(name=name, status=True)
except Exception as inst:
import_status = SettingsImport(
name=name, status=False, exception=str(inst)
)
settings_imports.append(import_status)
return settings_imports
def clean_up(self):
shutil.rmtree(TEMP_DIR)

View file

@ -3,6 +3,7 @@ from pathlib import Path
import requests
from app_config import IMG_DIR
from utils.logger import logger
def read_image(recipe_slug: str) -> Path:
@ -48,6 +49,7 @@ def scrape_image(image_url: str, slug: str) -> Path:
try:
r = requests.get(image_url, stream=True)
except:
logger.exception("Fatal Image Request Exception")
return None
if r.status_code == 200:

View file

@ -1,5 +1,4 @@
from datetime import date, timedelta
from pathlib import Path
from typing import List, Optional
from db.database import db

View file

@ -6,7 +6,7 @@ from pathlib import Path
from app_config import IMG_DIR, MIGRATION_DIR, TEMP_DIR
from services.recipe_services import Recipe
from services.scrape_services import normalize_data, process_recipe_data
from services.scraper.cleaner import Cleaner
from app_config import IMG_DIR, TEMP_DIR
@ -28,13 +28,13 @@ def import_recipes(recipe_dir: Path) -> Recipe:
for file in recipe_dir.glob("full.*"):
image = file
recipe_file = recipe_dir.joinpath("recipe.json")
for file in recipe_dir.glob("*.json"):
recipe_file = file
with open(recipe_file, "r") as f:
recipe_dict = json.loads(f.read())
recipe_dict = process_recipe_data(recipe_dict)
recipe_data = normalize_data(recipe_dict)
recipe_data = Cleaner.clean(recipe_dict)
image_name = None
if image:
@ -81,6 +81,7 @@ def migrate(session, selection: str):
successful_imports.append(recipe.name)
except:
logging.error(f"Failed Nextcloud Import: {dir.name}")
logging.exception('')
failed_imports.append(dir.name)
cleanup()

View file

@ -38,8 +38,8 @@ class Recipe(BaseModel):
tags: Optional[List[str]] = []
dateAdded: Optional[datetime.date]
notes: Optional[List[RecipeNote]] = []
rating: Optional[int]
orgURL: Optional[str]
rating: Optional[int] = 0
orgURL: Optional[str] = ""
extras: Optional[dict] = {}
class Config:

View file

@ -1,211 +0,0 @@
import html
import json
import re
from typing import List, Tuple
import extruct
import requests
import scrape_schema_recipe
from app_config import DEBUG_DIR
from slugify import slugify
from utils.logger import logger
from w3lib.html import get_base_url
from services.image_services import scrape_image
from services.recipe_services import Recipe
LAST_JSON = DEBUG_DIR.joinpath("last_recipe.json")
def cleanhtml(raw_html):
cleanr = re.compile("<.*?>")
cleantext = re.sub(cleanr, "", raw_html)
return cleantext
def normalize_image_url(image) -> str:
if type(image) == list:
return image[0]
elif type(image) == dict:
return image["url"]
elif type(image) == str:
return image
else:
raise Exception(f"Unrecognised image URL format: {image}")
def normalize_instructions(instructions) -> List[dict]:
# One long string split by (possibly multiple) new lines
if type(instructions) == str:
return [
{"text": normalize_instruction(line)}
for line in instructions.splitlines()
if line
]
# Plain strings in a list
elif type(instructions) == list and type(instructions[0]) == str:
return [{"text": normalize_instruction(step)} for step in instructions]
# Dictionaries (let's assume it's a HowToStep) in a list
elif type(instructions) == list and type(instructions[0]) == dict:
return [
{"text": normalize_instruction(step["text"])}
for step in instructions
if step["@type"] == "HowToStep"
]
else:
raise Exception(f"Unrecognised instruction format: {instructions}")
def normalize_instruction(line) -> str:
l = cleanhtml(line.strip())
# Some sites erroneously escape their strings on multiple levels
while not l == (l := html.unescape(l)):
pass
return l
def normalize_ingredient(ingredients: list) -> str:
return [cleanhtml(html.unescape(ing)) for ing in ingredients]
def normalize_yield(yld) -> str:
if type(yld) == list:
return yld[-1]
else:
return yld
def normalize_time(time_entry) -> str:
if type(time_entry) == type(None):
return None
elif type(time_entry) != str:
return str(time_entry)
def normalize_data(recipe_data: dict) -> dict:
recipe_data["totalTime"] = normalize_time(recipe_data.get("totalTime"))
recipe_data["description"] = cleanhtml(recipe_data.get("description", ""))
recipe_data["prepTime"] = normalize_time(recipe_data.get("prepTime"))
recipe_data["performTime"] = normalize_time(recipe_data.get("performTime"))
recipe_data["recipeYield"] = normalize_yield(recipe_data.get("recipeYield"))
recipe_data["recipeIngredient"] = normalize_ingredient(
recipe_data.get("recipeIngredient")
)
recipe_data["recipeInstructions"] = normalize_instructions(
recipe_data["recipeInstructions"]
)
recipe_data["image"] = normalize_image_url(recipe_data["image"])
return recipe_data
def process_recipe_data(new_recipe: dict, url=None) -> dict:
slug = slugify(new_recipe["name"])
mealie_tags = {
"slug": slug,
"orgURL": url,
"categories": [],
"tags": [],
"dateAdded": None,
"notes": [],
"extras": [],
}
new_recipe.update(mealie_tags)
return new_recipe
def extract_recipe_from_html(html: str, url: str) -> dict:
scraped_recipes: List[dict] = scrape_schema_recipe.loads(html, python_objects=True)
dump_last_json(scraped_recipes)
if not scraped_recipes:
scraped_recipes: List[dict] = scrape_schema_recipe.scrape_url(
url, python_objects=True
)
if scraped_recipes:
new_recipe: dict = scraped_recipes[0]
logger.info(f"Recipe Scraped From Web: {new_recipe}")
if not new_recipe:
return "fail" # TODO: Return Better Error Here
new_recipe = process_recipe_data(new_recipe, url=url)
new_recipe = normalize_data(new_recipe)
else:
new_recipe = basic_recipe_from_opengraph(html, url)
logger.info(f"Recipe Scraped from opengraph metadata: {new_recipe}")
return new_recipe
def download_image_for_recipe(recipe: dict) -> dict:
try:
img_path = scrape_image(recipe.get("image"), recipe.get("slug"))
recipe["image"] = img_path.name
except:
recipe["image"] = None
return recipe
def og_field(properties: dict, field_name: str) -> str:
return next((val for name, val in properties if name == field_name), None)
def og_fields(properties: List[Tuple[str, str]], field_name: str) -> List[str]:
return list({val for name, val in properties if name == field_name})
def basic_recipe_from_opengraph(html: str, url: str) -> dict:
base_url = get_base_url(html, url)
data = extruct.extract(html, base_url=base_url)
try:
properties = data["opengraph"][0]["properties"]
except:
return
return {
"name": og_field(properties, "og:title"),
"description": og_field(properties, "og:description"),
"image": og_field(properties, "og:image"),
"recipeYield": "",
# FIXME: If recipeIngredient is an empty list, mongodb's data verification fails.
"recipeIngredient": ["Could not detect ingredients"],
# FIXME: recipeInstructions is allowed to be empty but message this is added for user sanity.
"recipeInstructions": [{"text": "Could not detect instructions"}],
"slug": slugify(og_field(properties, "og:title")),
"orgURL": og_field(properties, "og:url"),
"categories": [],
"tags": og_fields(properties, "og:article:tag"),
"dateAdded": None,
"notes": [],
"extras": [],
}
def dump_last_json(recipe_data: dict):
with open(LAST_JSON, "w") as f:
f.write(json.dumps(recipe_data, indent=4, default=str))
return
def process_recipe_url(url: str) -> dict:
r = requests.get(url)
new_recipe = extract_recipe_from_html(r.text, url)
new_recipe = download_image_for_recipe(new_recipe)
return new_recipe
def create_from_url(url: str) -> Recipe:
recipe_data = process_recipe_url(url)
recipe = Recipe(**recipe_data)
return recipe

View file

@ -0,0 +1,149 @@
import html
import re
from typing import List
from slugify import slugify
class Cleaner:
"""A Namespace for utility function to clean recipe data extracted
from a url and returns a dictionary that is ready for import into
the database. Cleaner.clean is the main entrypoint
"""
@staticmethod
def clean(recipe_data: dict, url=None) -> dict:
"""Main entrypoint to clean a recipe extracted from the web
and format the data into an accectable format for the database
Args:
recipe_data (dict): raw recipe dicitonary
Returns:
dict: cleaned recipe dictionary
"""
recipe_data["totalTime"] = Cleaner.time(recipe_data.get("totalTime"))
recipe_data["description"] = Cleaner.html(recipe_data.get("description", ""))
recipe_data["prepTime"] = Cleaner.time(recipe_data.get("prepTime"))
recipe_data["performTime"] = Cleaner.time(recipe_data.get("performTime"))
recipe_data["recipeYield"] = Cleaner.yield_amount(
recipe_data.get("recipeYield")
)
recipe_data["recipeIngredient"] = Cleaner.ingredient(
recipe_data.get("recipeIngredient")
)
recipe_data["recipeInstructions"] = Cleaner.instructions(
recipe_data["recipeInstructions"]
)
recipe_data["image"] = Cleaner.image(recipe_data["image"])
recipe_data["slug"] = slugify(recipe_data["name"])
recipe_data["orgURL"] = url
return recipe_data
@staticmethod
def html(raw_html):
cleanr = re.compile("<.*?>")
cleantext = re.sub(cleanr, "", raw_html)
return cleantext
@staticmethod
def image(image) -> str:
if type(image) == list:
return image[0]
elif type(image) == dict:
return image["url"]
elif type(image) == str:
return image
else:
raise Exception(f"Unrecognised image URL format: {image}")
@staticmethod
def instructions(instructions) -> List[dict]:
if not instructions:
return []
# One long string split by (possibly multiple) new lines
if type(instructions) == str:
return [
{"text": Cleaner._instruction(line)}
for line in instructions.splitlines()
if line
]
# Plain strings in a list
elif type(instructions) == list and type(instructions[0]) == str:
return [{"text": Cleaner._instruction(step)} for step in instructions]
# Dictionaries (let's assume it's a HowToStep) in a list
elif type(instructions) == list and type(instructions[0]) == dict:
# Try List of Dictionary without "@type" or "type"
if not instructions[0].get("@type", False) and not instructions[0].get(
"type", False
):
return [
{"text": Cleaner._instruction(step["text"])}
for step in instructions
]
try:
# If HowToStep is under HowToSection
sectionSteps = []
for step in instructions:
if step["@type"] == "HowToSection":
[sectionSteps.append(item) for item in step["itemListELement"]]
if len(sectionSteps) > 0:
return [
{"text": Cleaner._instruction(step["text"])}
for step in sectionSteps
if step["@type"] == "HowToStep"
]
return [
{"text": Cleaner._instruction(step["text"])}
for step in instructions
if step["@type"] == "HowToStep"
]
except Exception as e:
# Not "@type", try "type"
try:
return [
{"text": Cleaner._instruction(step["properties"]["text"])}
for step in instructions
if step["type"].find("HowToStep") > -1
]
except:
pass
else:
raise Exception(f"Unrecognised instruction format: {instructions}")
@staticmethod
def _instruction(line) -> str:
l = Cleaner.html(line.strip())
# Some sites erroneously escape their strings on multiple levels
while not l == (l := html.unescape(l)):
pass
return l
@staticmethod
def ingredient(ingredients: list) -> str:
return [Cleaner.html(html.unescape(ing)) for ing in ingredients]
@staticmethod
def yield_amount(yld) -> str:
if type(yld) == list:
return yld[-1]
else:
return yld
@staticmethod
def time(time_entry) -> str:
if type(time_entry) == type(None):
return None
elif type(time_entry) != str:
return str(time_entry)

View file

@ -0,0 +1,43 @@
from typing import Tuple
import extruct
from app_config import DEBUG_DIR
from slugify import slugify
from w3lib.html import get_base_url
LAST_JSON = DEBUG_DIR.joinpath("last_recipe.json")
def og_field(properties: dict, field_name: str) -> str:
return next((val for name, val in properties if name == field_name), None)
def og_fields(properties: list[Tuple[str, str]], field_name: str) -> list[str]:
return list({val for name, val in properties if name == field_name})
def basic_recipe_from_opengraph(html: str, url: str) -> dict:
base_url = get_base_url(html, url)
data = extruct.extract(html, base_url=base_url)
try:
properties = data["opengraph"][0]["properties"]
except:
return
return {
"name": og_field(properties, "og:title"),
"description": og_field(properties, "og:description"),
"image": og_field(properties, "og:image"),
"recipeYield": "",
# FIXME: If recipeIngredient is an empty list, mongodb's data verification fails.
"recipeIngredient": ["Could not detect ingredients"],
# FIXME: recipeInstructions is allowed to be empty but message this is added for user sanity.
"recipeInstructions": [{"text": "Could not detect instructions"}],
"slug": slugify(og_field(properties, "og:title")),
"orgURL": og_field(properties, "og:url"),
"categories": [],
"tags": og_fields(properties, "og:article:tag"),
"dateAdded": None,
"notes": [],
"extras": [],
}

View file

@ -0,0 +1,84 @@
import json
from typing import List
import requests
import scrape_schema_recipe
from app_config import DEBUG_DIR
from services.image_services import scrape_image
from services.recipe_services import Recipe
from services.scraper import open_graph
from services.scraper.cleaner import Cleaner
from utils.logger import logger
LAST_JSON = DEBUG_DIR.joinpath("last_recipe.json")
def create_from_url(url: str) -> Recipe:
"""Main entry point for generating a recipe from a URL. Pass in a URL and
a Recipe object will be returned if successful.
Args:
url (str): a valid string representing a URL
Returns:
Recipe: Recipe Object
"""
r = requests.get(url)
new_recipe = extract_recipe_from_html(r.text, url)
new_recipe = Cleaner.clean(new_recipe)
new_recipe = download_image_for_recipe(new_recipe)
recipe = Recipe(**new_recipe)
return recipe
def extract_recipe_from_html(html: str, url: str) -> dict:
try:
scraped_recipes: List[dict] = scrape_schema_recipe.loads(
html, python_objects=True
)
dump_last_json(scraped_recipes)
if not scraped_recipes:
scraped_recipes: List[dict] = scrape_schema_recipe.scrape_url(
url, python_objects=True
)
except Exception as e:
# trying without python_objects
scraped_recipes: List[dict] = scrape_schema_recipe.loads(html)
dump_last_json(scraped_recipes)
if not scraped_recipes:
scraped_recipes: List[dict] = scrape_schema_recipe.scrape_url(url)
if scraped_recipes:
new_recipe: dict = scraped_recipes[0]
logger.info(f"Recipe Scraped From Web: {new_recipe}")
if not new_recipe:
return "fail" # TODO: Return Better Error Here
new_recipe = Cleaner.clean(new_recipe, url)
else:
new_recipe = open_graph.basic_recipe_from_opengraph(html, url)
logger.info(f"Recipe Scraped from opengraph metadata: {new_recipe}")
return new_recipe
def download_image_for_recipe(recipe: dict) -> dict:
try:
img_path = scrape_image(recipe.get("image"), recipe.get("slug"))
recipe["image"] = img_path.name
except:
recipe["image"] = "no image"
return recipe
def dump_last_json(recipe_data: dict):
with open(LAST_JSON, "w") as f:
f.write(json.dumps(recipe_data, indent=4, default=str))
return

View file

@ -1,7 +1,8 @@
from db.database import db
from db.db_setup import create_session, sql_exists
from db.db_setup import create_session
from models.settings_models import SiteSettings, Webhooks
from sqlalchemy.orm.session import Session
from utils.logger import logger
def default_settings_init(session: Session = None):
@ -10,7 +11,7 @@ def default_settings_init(session: Session = None):
try:
webhooks = Webhooks()
default_entry = SiteSettings(name="main", webhooks=webhooks)
document = db.settings.create(session, default_entry.dict(), webhooks.dict())
document = db.settings.create(session, default_entry.dict())
logger.info(f"Created Site Settings: \n {document}")
except:
pass

View file

@ -13,6 +13,7 @@ from tests.utils.routes import (
def default_settings():
return {
"name": "main",
"planCategories": [],
"webhooks": {"webhookTime": "00:00", "webhookURLs": [], "enabled": False},
}

View file

@ -2,11 +2,8 @@ import json
import re
import pytest
from services.scrape_services import (
extract_recipe_from_html,
normalize_data,
normalize_instructions,
)
from services.scraper.cleaner import Cleaner
from services.scraper.scraper import extract_recipe_from_html
from tests.test_config import TEST_RAW_HTML, TEST_RAW_RECIPES
# https://github.com/django/django/blob/stable/1.3.x/django/core/validators.py#L45
@ -42,7 +39,7 @@ url_validation_regex = re.compile(
],
)
def test_normalize_data(json_file, num_steps):
recipe_data = normalize_data(json.load(open(TEST_RAW_RECIPES.joinpath(json_file))))
recipe_data = Cleaner.clean(json.load(open(TEST_RAW_RECIPES.joinpath(json_file))))
assert len(recipe_data["recipeInstructions"]) == num_steps
@ -58,7 +55,7 @@ def test_normalize_data(json_file, num_steps):
],
)
def test_normalize_instructions(instructions):
assert normalize_instructions(instructions) == [
assert Cleaner.instructions(instructions) == [
{"text": "A"},
{"text": "B"},
{"text": "C"},

View file

@ -31,11 +31,12 @@ HTML_TEMPLATE = """<!DOCTYPE html>
</html>
"""
HTML_PATH = DATA_DIR.parent.joinpath("docs/docs/html/api.html")
def generate_api_docs(app):
out_dir = DATA_DIR.joinpath(".temp")
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir.joinpath("index.html")
with open(out_path, "w") as fd:
out_path.parent.mkdir(exist_ok=True)
with open(HTML_PATH, "w") as fd:
print(HTML_TEMPLATE % json.dumps(app.openapi()), file=fd)

View file

@ -1,6 +1,5 @@
from pathlib import Path
from app_config import REQUIRED_DIRS
from services.settings_services import default_theme_init
CWD = Path(__file__).parent
@ -10,8 +9,5 @@ def post_start():
default_theme_init()
if __name__ == "__main__":
pass