mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -04:00
v0.4.2 (#310)
* fix links * actually fix #238 * Feature/mkdocs version bump (#240) * fix links (#239) Co-authored-by: hay-kot <hay-kot@pm.me> * fix #238 * bump mkdocs version * light/dark toggle * light/dark mode css * API_DOCS defaults to True * disable build on push for master Co-authored-by: hay-kot <hay-kot@pm.me> * Feature/recipe viewer (#244) * fix dialog placement * markdown support in ingredients * fix line render issue * fix tag rendering bug * change ingredients to text area * no slug error * add tag pages * remove console.logs Co-authored-by: hay-kot <hay-kot@pm.me> * changelog v0.4.1 * bug/backup-download (#245) * fix blocked download * + download blocked Co-authored-by: hay-kot <hay-kot@pm.me> * Feature/meal planner (#246) * fixes duplicate recipes in meal-plan #221 * add quick week option * scope css * add mealplanner info Co-authored-by: hay-kot <hay-kot@pm.me> * Nextcloud Import Bugs - #248 (#250) * parses datetime properly + clean category - #248 * add default credentials to docs Co-authored-by: hay-kot <hay-kot@pm.me> * Add bulk import examples to docs. (#252) * Add bulk import examples to docs. * Update api-usage.md * Add Python example for bulk import. * Change IP address in API example. * Refactor/app settings (#251) * fix env setup bugs * remove unused import * fix layout issues * changelog Co-authored-by: hay-kot <hay-kot@pm.me> * env setup fixes * Feature/about api (#253) * fix settings * app info cleanup Co-authored-by: hay-kot <hay-kot@pm.me> * Feature/image minify (#256) * fix settings * app info cleanup * bottom-bar experiment * remove dup key * type hints * add dependency * updated image with query parameters * read image options * add image minification * add image minification step * alt image routes * add image minification * set mobile bar to top Co-authored-by: hay-kot <hay-kot@pm.me> * Feature/additional endpoints (#257) * new recipe summary route * add categories to cards * add pillow * show tags instead of categories * additional debug info * add todays meal image url * about page * fix reactive tag * changelog + docs * bump version Co-authored-by: hay-kot <hay-kot@pm.me> * add pillow dependencies (#258) Co-authored-by: hay-kot <hay-kot@pm.me> * Feature/search page (#259) * add pillow dependencies * advanced search page * advanced search apge * remove extra dependencies * add pre-run script Co-authored-by: hay-kot <hay-kot@pm.me> * no image assignment * advanced search * fix docker dev build * Do not force theme settings on login form (#260) * Fix docker dev db persistence (#264) * Fix docker dev db persistence * Make run.sh the only startup script for prod + dev Credits to @hay-kot for run.sh script logic * Restore dev backend initialization in non-docker setup * Make run.sh POSIX-friendly * Allow dev backend to auto-reload in Docker * Frontend Refactor + Bug Fixes * merge category and tag selector * unifiy category selector * add hint * spacing * fix nextcloud migration * simplify email validator #261 * formatting * cleanup * auto-gen * format * update run script * unified category/tag selector * rename component * Add advanced search link * remove old code * convert keywords to tags * add proper behavior on rename * proper image name association on rename * fix test cleanup * changelog * set docker comppand * minify on migration Co-authored-by: hay-kot <hay-kot@pm.me> * bug-fixes/category-tag-creator (#266) * fix category labels * set loader for migration * v0.4.1 Co-authored-by: hay-kot <hay-kot@pm.me> * Hot Fix (#269) * fix category labels * set loader for migration * v0.4.1 * reorganize API docs Co-authored-by: hay-kot <hay-kot@pm.me> * Fix some pytests (#265) * Fix encoding issue in cleaner unit test * Add VS Code task to run pytests * Fix FileExistsError when running Windows * Add Portuguese Translation (#232) * Add Portuguese Translation * add portuguese translation option * formatting * add missing } * specify absolute path * Feature/migration-rewrite (#278) * start * migration rewrite * update name * convert chowdown to new methods * refactor/remove duplicate code * refactor to unify logger + log to file * remove toolbox * Display report on UI Co-authored-by: hay-kot <hay-kot@pm.me> * refactor/image-minification (#285) * refactor image minification calls * remove nusiance logs Co-authored-by: hay-kot <hay-kot@pm.me> * feature/debug-info (#286) * rename 'ENV' to 'PRODUCTION' and default to true * set env PRODUCTION * refactor file download process * add last_recipe.json and log downloads * changelog + version bump * set env on workflows * bump version Co-authored-by: hay-kot <hay-kot@pm.me> * Basic nutrition editor (#288) * Basic nutrition editor * fix no image on scrape * nutrition display * add recipe images * update by url * new upload options Co-authored-by: hay-kot <hay-kot@pm.me> * Sanitize recipe backup filenames (#287) Fixes #275 * fix page creation fixes #290 * Display categories in sidebar if no pages set (#292) Fixes #291 * Enrich page title with context (#296) - Static pages have their own titles - The name of the recipe is displayed when viewing it * fix: translates phrase for locale de (#298) Co-authored-by: Jonas Schubert <jonas.schubert.1990@web.de> * Fix ingredient checkbox click (#305) Fixes #304 v-list-item already flips the checkbox * Localize custom pages and search page (#299) * Localize custom pages and search page * Fix FR translation for step * fixes #306 * fixes #297 * Update changelog * generate docs Co-authored-by: hay-kot <hay-kot@pm.me> Co-authored-by: Nat <nathanynath@yahoo.fr> Co-authored-by: sephrat <34862846+sephrat@users.noreply.github.com> Co-authored-by: Pedro Mata Rodrigues <pmmatarodrigues@gmail.com> Co-authored-by: JonasSchubert <jonas.schubert.projects@web.de> Co-authored-by: Jonas Schubert <jonas.schubert.1990@web.de>
This commit is contained in:
parent
e11577f786
commit
0f5a564ff3
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:
|
||||||
#----------------------------------------------
|
#----------------------------------------------
|
||||||
|
@ -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/
|
||||||
|
|
||||||
@ -48,6 +48,7 @@ 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
|
||||||
|
|
||||||
|
34
docs/docs/changelog/v0.4.2.md
Normal file
34
docs/docs/changelog/v0.4.2.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
- Fixed Initialization script (v0.4.1a Hot Fix) - Closes #274
|
||||||
|
- Fixed nested list error on recipe scrape - Closes #306
|
||||||
|
- Fixed ingredient checkboxes - Closes #304
|
||||||
|
- Removed link on recent - Closes #297
|
||||||
|
- Categories sidebar is auto generated if no pages are created - Closes #291
|
||||||
|
- Fix tag issues on creating custom pages - Closes #290
|
||||||
|
- Validate paths on export - Closes #275
|
||||||
|
- Walk Nextcloud import directory - Closes #254
|
||||||
|
|
||||||
|
## General Improvements
|
||||||
|
- 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"
|
||||||
|
- 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 now be added directly from a URL - [See #117 for details](https://github.com/hay-kot/mealie/issues/117)
|
File diff suppressed because one or more lines are too long
@ -77,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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
|
@ -17,13 +17,13 @@
|
|||||||
<v-text-field
|
<v-text-field
|
||||||
autofocus
|
autofocus
|
||||||
v-model="page.name"
|
v-model="page.name"
|
||||||
label="Page Name"
|
:label="$t('settings.page-name')"
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
<CategoryTagSelector
|
<CategoryTagSelector
|
||||||
v-model="page.categories"
|
v-model="page.categories"
|
||||||
ref="categoryFormSelector"
|
ref="categoryFormSelector"
|
||||||
@mounted="catMounted = true"
|
@mounted="catMounted = true"
|
||||||
tag-selector="false"
|
:tag-selector="false"
|
||||||
/>
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
|
@ -3,10 +3,10 @@
|
|||||||
<CreatePageDialog ref="createDialog" @refresh-page="getPages" />
|
<CreatePageDialog ref="createDialog" @refresh-page="getPages" />
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<h2 class="mt-1 mb-1 ">
|
<h2 class="mt-1 mb-1 ">
|
||||||
Custom Pages
|
{{$t('settings.custom-pages')}}
|
||||||
<span>
|
<span>
|
||||||
<v-btn color="success" @click="newPage" small class="ml-3">
|
<v-btn color="success" @click="newPage" small class="ml-3">
|
||||||
Create
|
{{$t('general.create')}}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
@ -41,11 +41,11 @@
|
|||||||
|
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-btn text small color="error" @click="deletePage(item.id)">
|
<v-btn text small color="error" @click="deletePage(item.id)">
|
||||||
Delete
|
{{$t('general.delete')}}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-spacer> </v-spacer>
|
<v-spacer> </v-spacer>
|
||||||
<v-btn small text color="success" @click="editPage(index)">
|
<v-btn small text color="success" @click="editPage(index)">
|
||||||
Edit
|
{{$t('general.edit')}}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
@ -55,7 +55,7 @@
|
|||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<v-btn color="success" @click="savePages">
|
<v-btn color="success" @click="savePages">
|
||||||
Save
|
{{$t('general.save')}}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
@ -76,8 +76,8 @@ export default {
|
|||||||
customPages: [],
|
customPages: [],
|
||||||
newPageData: {
|
newPageData: {
|
||||||
create: true,
|
create: true,
|
||||||
title: "New Page",
|
title: this.$t('settings.new-page'),
|
||||||
buttonText: "Create",
|
buttonText: this.$t('general.create'),
|
||||||
data: {
|
data: {
|
||||||
name: "",
|
name: "",
|
||||||
categories: [],
|
categories: [],
|
||||||
@ -86,8 +86,8 @@ export default {
|
|||||||
},
|
},
|
||||||
editPageData: {
|
editPageData: {
|
||||||
create: false,
|
create: false,
|
||||||
title: "Edit Page",
|
title: this.$t('settings.edit-page'),
|
||||||
buttonText: "Update",
|
buttonText: this.$t('general.update'),
|
||||||
data: {},
|
data: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
@ -67,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,
|
||||||
@ -76,6 +78,7 @@ export default {
|
|||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
UploadBtn,
|
UploadBtn,
|
||||||
|
MigrationDialog,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -90,7 +93,8 @@ export default {
|
|||||||
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.$emit("imported", response.successful, response.failed);
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
},
|
},
|
||||||
readableTime(timestamp) {
|
readableTime(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>
|
@ -90,7 +90,7 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
inputLabel() {
|
inputLabel() {
|
||||||
if (!this.showLabel) return null;
|
if (!this.showLabel) return null;
|
||||||
return this.tagSelector ? "Tags" : "Categories";
|
return this.tagSelector ? this.$t('recipe.tags') : this.$t('recipe.categories');
|
||||||
},
|
},
|
||||||
activeItems() {
|
activeItems() {
|
||||||
let ItemObjects = [];
|
let ItemObjects = [];
|
||||||
|
@ -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"
|
||||||
@ -165,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>
|
||||||
|
|
||||||
@ -222,17 +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 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,
|
CategoryTagSelector,
|
||||||
|
NutritionEditor,
|
||||||
|
ImageUploadBtn,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
value: Object,
|
value: Object,
|
||||||
@ -251,12 +251,8 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
uploadImage() {
|
uploadImage(fileObject) {
|
||||||
this.$emit("upload", this.fileObject);
|
this.$emit("upload", fileObject);
|
||||||
},
|
|
||||||
async updateImage() {
|
|
||||||
const slug = this.value.slug;
|
|
||||||
api.recipes.updateImage(slug, this.fileObject);
|
|
||||||
},
|
},
|
||||||
toggleDisabled(stepIndex) {
|
toggleDisabled(stepIndex) {
|
||||||
if (this.disabledSteps.includes(stepIndex)) {
|
if (this.disabledSteps.includes(stepIndex)) {
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
v-model="ingredient.checked"
|
v-model="ingredient.checked"
|
||||||
class="pt-0 my-auto py-auto"
|
class="pt-0 my-auto py-auto"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
:readonly="true"
|
||||||
>
|
>
|
||||||
</v-checkbox>
|
</v-checkbox>
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
<v-row v-if="title != null">
|
<v-row v-if="title != null">
|
||||||
<v-col>
|
<v-col>
|
||||||
<v-btn-toggle group>
|
<v-btn-toggle group>
|
||||||
<v-btn text :to="`/recipes/${title.toLowerCase()}`">
|
<v-btn text>
|
||||||
{{ title.toUpperCase() }}
|
{{ title.toUpperCase() }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-btn-toggle>
|
</v-btn-toggle>
|
||||||
|
@ -54,22 +54,11 @@ export default {
|
|||||||
{
|
{
|
||||||
icon: "mdi-magnify",
|
icon: "mdi-magnify",
|
||||||
to: "/search",
|
to: "/search",
|
||||||
title: "search",
|
title: this.$t('search.search'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
|
||||||
allCategories() {
|
|
||||||
return this.$store.getters.getCategories;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
allCategories() {
|
|
||||||
this.buildSidebar();
|
|
||||||
},
|
|
||||||
showSidebar() {},
|
|
||||||
},
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.buildSidebar();
|
this.buildSidebar();
|
||||||
this.mobile = this.viewScale();
|
this.mobile = this.viewScale();
|
||||||
@ -81,14 +70,27 @@ export default {
|
|||||||
this.links = [];
|
this.links = [];
|
||||||
this.links.push(...this.baseLinks);
|
this.links.push(...this.baseLinks);
|
||||||
const pages = await api.siteSettings.getPages();
|
const pages = await api.siteSettings.getPages();
|
||||||
pages.sort((a, b) => a.position - b.position);
|
if(pages.length > 0) {
|
||||||
pages.forEach(async element => {
|
pages.sort((a, b) => a.position - b.position);
|
||||||
this.links.push({
|
pages.forEach(async element => {
|
||||||
title: element.name,
|
this.links.push({
|
||||||
to: `/pages/${element.slug}`,
|
title: element.name,
|
||||||
icon: "mdi-tag",
|
to: `/pages/${element.slug}`,
|
||||||
|
icon: "mdi-tag",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
else {
|
||||||
|
const categories = await api.categories.getAll();
|
||||||
|
categories.forEach(async element => {
|
||||||
|
this.links.push({
|
||||||
|
title: element.name,
|
||||||
|
to: `/recipes/category/${element.slug}`,
|
||||||
|
icon: "mdi-tag",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
viewScale() {
|
viewScale() {
|
||||||
switch (this.$vuetify.breakpoint.name) {
|
switch (this.$vuetify.breakpoint.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);
|
||||||
|
|
||||||
|
@ -93,7 +93,7 @@
|
|||||||
"groups": "Gruppen",
|
"groups": "Gruppen",
|
||||||
"could-not-validate-credentials": "Anmeldeinformationen konnten nicht validiert werden",
|
"could-not-validate-credentials": "Anmeldeinformationen konnten nicht validiert werden",
|
||||||
"login": "Anmeldung",
|
"login": "Anmeldung",
|
||||||
"groups-can-only-be-set-by-administrators": "Groups can only be set by administrators",
|
"groups-can-only-be-set-by-administrators": "Gruppen können nur durch einen Administrator gesetzt werden",
|
||||||
"upload-photo": "Foto hochladen",
|
"upload-photo": "Foto hochladen",
|
||||||
"reset-password": "Passwort zurücksetzen",
|
"reset-password": "Passwort zurücksetzen",
|
||||||
"current-password": "Aktuelles Passwort",
|
"current-password": "Aktuelles Passwort",
|
||||||
|
@ -48,7 +48,8 @@
|
|||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"current-parenthesis": "(Current)",
|
"current-parenthesis": "(Current)",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"groups": "Groups"
|
"groups": "Groups",
|
||||||
|
"about": "About"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"home-page": "Home Page",
|
"home-page": "Home Page",
|
||||||
@ -145,7 +146,16 @@
|
|||||||
"delete-confirmation": "Are you sure you want to delete this recipe?"
|
"delete-confirmation": "Are you sure you want to delete this recipe?"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search-mealie": "Search Mealie"
|
"search-mealie": "Search Mealie",
|
||||||
|
"search-placeholder": "Search...",
|
||||||
|
"max-results": "Max Results",
|
||||||
|
"category-filter": "Category Filter",
|
||||||
|
"tag-filter": "Tag Filter",
|
||||||
|
"include": "Include",
|
||||||
|
"exclude": "Exclude",
|
||||||
|
"and": "And",
|
||||||
|
"or": "Or",
|
||||||
|
"search": "Search"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"general-settings": "General Settings",
|
"general-settings": "General Settings",
|
||||||
@ -215,7 +225,11 @@
|
|||||||
"site-settings": "Site Settings",
|
"site-settings": "Site Settings",
|
||||||
"manage-users": "Manage Users",
|
"manage-users": "Manage Users",
|
||||||
"migrations": "Migrations",
|
"migrations": "Migrations",
|
||||||
"profile": "Profile"
|
"profile": "Profile",
|
||||||
|
"custom-pages": "Custom Pages",
|
||||||
|
"new-page": "New Page",
|
||||||
|
"edit-page": "Edit Page",
|
||||||
|
"page-name": "Page Name"
|
||||||
},
|
},
|
||||||
"migration": {
|
"migration": {
|
||||||
"recipe-migration": "Recipe Migration",
|
"recipe-migration": "Recipe Migration",
|
||||||
|
@ -46,7 +46,10 @@
|
|||||||
"token": "Jeton",
|
"token": "Jeton",
|
||||||
"field-required": "Champ obligatoire",
|
"field-required": "Champ obligatoire",
|
||||||
"apply": "Appliquer",
|
"apply": "Appliquer",
|
||||||
"current-parenthesis": "(Actuel)"
|
"current-parenthesis": "(Actuel)",
|
||||||
|
"groups": "Groupes",
|
||||||
|
"users": "Utilisateurs",
|
||||||
|
"about": "À propos"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"home-page": "Accueil",
|
"home-page": "Accueil",
|
||||||
@ -120,7 +123,7 @@
|
|||||||
"categories": "Catégories",
|
"categories": "Catégories",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"instructions": "Instructions",
|
"instructions": "Instructions",
|
||||||
"step-index": "Etape: {step}",
|
"step-index": "Étape : {step}",
|
||||||
"recipe-name": "Nom de la recette",
|
"recipe-name": "Nom de la recette",
|
||||||
"servings": "Portions",
|
"servings": "Portions",
|
||||||
"ingredient": "Ingrédient",
|
"ingredient": "Ingrédient",
|
||||||
@ -143,7 +146,16 @@
|
|||||||
"delete-confirmation": "Êtes-vous sûr(e) de vouloir supprimer cette recette ?"
|
"delete-confirmation": "Êtes-vous sûr(e) de vouloir supprimer cette recette ?"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search-mealie": "Rechercher dans Mealie"
|
"search-mealie": "Rechercher dans Mealie",
|
||||||
|
"search-placeholder": "Rechercher...",
|
||||||
|
"and": "Et",
|
||||||
|
"category-filter": "Filtre par catégories",
|
||||||
|
"exclude": "Exclure",
|
||||||
|
"include": "Inclure",
|
||||||
|
"max-results": "Résultats max",
|
||||||
|
"or": "Ou",
|
||||||
|
"tag-filter": "Filtre par tags",
|
||||||
|
"search": "Rechercher"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"general-settings": "Paramètres généraux",
|
"general-settings": "Paramètres généraux",
|
||||||
@ -185,7 +197,7 @@
|
|||||||
},
|
},
|
||||||
"webhooks": {
|
"webhooks": {
|
||||||
"meal-planner-webhooks": "Webhooks du planificateur de repas",
|
"meal-planner-webhooks": "Webhooks du planificateur de repas",
|
||||||
"the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at": "Les liens dans cette liste recevront les webhooks contenant les recettes pour le plan de menu du jour défini. Actuellement, les webhooks s'executeront à <strong>{ time }</strong>",
|
"the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at": "Les liens dans cette liste recevront les webhooks contenant les recettes pour le plan de menu du jour défini. Actuellement, les webhooks s'exécuteront à",
|
||||||
"test-webhooks": "Tester les webhooks",
|
"test-webhooks": "Tester les webhooks",
|
||||||
"webhook-url": "Lien du webhook"
|
"webhook-url": "Lien du webhook"
|
||||||
},
|
},
|
||||||
@ -213,7 +225,11 @@
|
|||||||
"manage-users": "Utilisateurs",
|
"manage-users": "Utilisateurs",
|
||||||
"migrations": "Migrations",
|
"migrations": "Migrations",
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"site-settings": "Paramètres site"
|
"site-settings": "Paramètres site",
|
||||||
|
"custom-pages": "Pages personnalisées",
|
||||||
|
"edit-page": "Modifier la page",
|
||||||
|
"new-page": "Nouvelle page",
|
||||||
|
"page-name": "Nom de la page"
|
||||||
},
|
},
|
||||||
"migration": {
|
"migration": {
|
||||||
"recipe-migration": "Migrer les recettes",
|
"recipe-migration": "Migrer les recettes",
|
||||||
|
@ -18,6 +18,20 @@ const router = new VueRouter({
|
|||||||
mode: process.env.NODE_ENV === "production" ? "history" : "hash",
|
mode: process.env.NODE_ENV === "production" ? "history" : "hash",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DEFAULT_TITLE = 'Mealie';
|
||||||
|
const TITLE_SEPARATOR = '🍴';
|
||||||
|
const TITLE_SUFFIX = " " + TITLE_SEPARATOR + " " + DEFAULT_TITLE;
|
||||||
|
router.afterEach( (to) => {
|
||||||
|
Vue.nextTick( async () => {
|
||||||
|
if(typeof to.meta.title === 'function' ) {
|
||||||
|
const title = await to.meta.title(to);
|
||||||
|
document.title = title + TITLE_SUFFIX;
|
||||||
|
} else {
|
||||||
|
document.title = to.meta.title ? to.meta.title + TITLE_SUFFIX : DEFAULT_TITLE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const vueApp = new Vue({
|
const vueApp = new Vue({
|
||||||
vuetify,
|
vuetify,
|
||||||
store,
|
store,
|
||||||
|
@ -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: [],
|
||||||
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
@ -10,11 +10,11 @@
|
|||||||
mandatory
|
mandatory
|
||||||
>
|
>
|
||||||
<v-btn :value="false">
|
<v-btn :value="false">
|
||||||
Include
|
{{$t('search.include')}}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn :value="true">
|
<v-btn :value="true">
|
||||||
Exclude
|
{{$t('search.exclude')}}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-btn-toggle>
|
</v-btn-toggle>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
@ -28,10 +28,10 @@
|
|||||||
mandatory
|
mandatory
|
||||||
>
|
>
|
||||||
<v-btn :value="false">
|
<v-btn :value="false">
|
||||||
And
|
{{$t('search.and')}}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn :value="true">
|
<v-btn :value="true">
|
||||||
Or
|
{{$t('search.or')}}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-btn-toggle>
|
</v-btn-toggle>
|
||||||
</v-toolbar>
|
</v-toolbar>
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
v-model="searchString"
|
v-model="searchString"
|
||||||
outlined
|
outlined
|
||||||
color="primary accent-3"
|
color="primary accent-3"
|
||||||
placeholder="Placeholder"
|
:placeholder="$t('search.search-placeholder')"
|
||||||
append-icon="mdi-magnify"
|
append-icon="mdi-magnify"
|
||||||
>
|
>
|
||||||
</v-text-field>
|
</v-text-field>
|
||||||
@ -16,7 +16,7 @@
|
|||||||
<v-col cols="12" md="2" sm="12">
|
<v-col cols="12" md="2" sm="12">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
class="mt-0 pt-0"
|
class="mt-0 pt-0"
|
||||||
label="Max Results"
|
:label="$t('search.max-results')"
|
||||||
v-model="maxResults"
|
v-model="maxResults"
|
||||||
type="number"
|
type="number"
|
||||||
outlined
|
outlined
|
||||||
@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
<v-row dense class="mt-0 flex-row align-center justify-space-around">
|
<v-row dense class="mt-0 flex-row align-center justify-space-around">
|
||||||
<v-col>
|
<v-col>
|
||||||
<h3 class="pl-2 text-center headline">Category Filter</h3>
|
<h3 class="pl-2 text-center headline">{{$t('search.category-filter')}}</h3>
|
||||||
<FilterSelector class="mb-1" @update="updateCatParams" />
|
<FilterSelector class="mb-1" @update="updateCatParams" />
|
||||||
<CategoryTagSelector
|
<CategoryTagSelector
|
||||||
:solo="true"
|
:solo="true"
|
||||||
@ -36,7 +36,7 @@
|
|||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col>
|
<v-col>
|
||||||
<h3 class="pl-2 text-center headline">Tag Filter</h3>
|
<h3 class="pl-2 text-center headline">{{$t('search.tag-filter')}}</h3>
|
||||||
<FilterSelector class="mb-1" @update="updateTagParams" />
|
<FilterSelector class="mb-1" @update="updateTagParams" />
|
||||||
|
|
||||||
<CategoryTagSelector
|
<CategoryTagSelector
|
||||||
|
@ -8,6 +8,7 @@ import ManageUsers from "@/pages/Admin/ManageUsers";
|
|||||||
import Settings from "@/pages/Admin/Settings";
|
import Settings from "@/pages/Admin/Settings";
|
||||||
import About from "@/pages/Admin/About";
|
import About from "@/pages/Admin/About";
|
||||||
import { store } from "../store";
|
import { store } from "../store";
|
||||||
|
import i18n from '@/i18n.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
path: "/admin",
|
path: "/admin",
|
||||||
@ -25,35 +26,59 @@ export default {
|
|||||||
{
|
{
|
||||||
path: "profile",
|
path: "profile",
|
||||||
component: Profile,
|
component: Profile,
|
||||||
|
meta: {
|
||||||
|
title: i18n.t('settings.profile'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: "backups",
|
path: "backups",
|
||||||
component: Backup,
|
component: Backup,
|
||||||
|
meta: {
|
||||||
|
title: i18n.t('settings.backup-and-exports'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "themes",
|
path: "themes",
|
||||||
component: Theme,
|
component: Theme,
|
||||||
|
meta: {
|
||||||
|
title: i18n.t('general.themes'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "meal-planner",
|
path: "meal-planner",
|
||||||
component: MealPlanner,
|
component: MealPlanner,
|
||||||
|
meta: {
|
||||||
|
title: i18n.t('meal-plan.meal-planner'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "migrations",
|
path: "migrations",
|
||||||
component: Migration,
|
component: Migration,
|
||||||
|
meta: {
|
||||||
|
title: i18n.t('settings.migrations'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "manage-users",
|
path: "manage-users",
|
||||||
component: ManageUsers,
|
component: ManageUsers,
|
||||||
|
meta: {
|
||||||
|
title: i18n.t('settings.manage-users'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "settings",
|
path: "settings",
|
||||||
component: Settings,
|
component: Settings,
|
||||||
|
meta: {
|
||||||
|
title: i18n.t('settings.site-settings'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "about",
|
path: "about",
|
||||||
component: About,
|
component: About,
|
||||||
|
meta: {
|
||||||
|
title: i18n.t('general.about'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
@ -15,6 +15,7 @@ import ThisWeek from "@/pages/MealPlan/ThisWeek";
|
|||||||
import { api } from "@/api";
|
import { api } from "@/api";
|
||||||
import Admin from "./admin";
|
import Admin from "./admin";
|
||||||
import { store } from "../store";
|
import { store } from "../store";
|
||||||
|
import i18n from '@/i18n.js';
|
||||||
|
|
||||||
export const routes = [
|
export const routes = [
|
||||||
{ path: "/", name: "home", component: HomePage },
|
{ path: "/", name: "home", component: HomePage },
|
||||||
@ -31,15 +32,43 @@ export const routes = [
|
|||||||
{ path: "/sign-up", redirect: "/" },
|
{ path: "/sign-up", redirect: "/" },
|
||||||
{ path: "/sign-up/:token", component: SignUpPage },
|
{ path: "/sign-up/:token", component: SignUpPage },
|
||||||
{ path: "/debug", component: Debug },
|
{ path: "/debug", component: Debug },
|
||||||
{ path: "/search", component: SearchPage },
|
{
|
||||||
|
path: "/search",
|
||||||
|
component: SearchPage,
|
||||||
|
meta: {
|
||||||
|
title: i18n.t('search.search'),
|
||||||
|
},
|
||||||
|
},
|
||||||
{ path: "/recipes/all", component: AllRecipes },
|
{ path: "/recipes/all", component: AllRecipes },
|
||||||
{ path: "/pages/:customPage", component: CustomPage },
|
{ path: "/pages/:customPage", component: CustomPage },
|
||||||
{ path: "/recipes/tag/:tag", component: TagPage },
|
{ path: "/recipes/tag/:tag", component: TagPage },
|
||||||
{ path: "/recipes/category/:category", component: CategoryPage },
|
{ path: "/recipes/category/:category", component: CategoryPage },
|
||||||
{ path: "/recipe/:recipe", component: ViewRecipe },
|
{
|
||||||
|
path: "/recipe/:recipe",
|
||||||
|
component: ViewRecipe,
|
||||||
|
meta: {
|
||||||
|
title: async route => {
|
||||||
|
const recipe = await api.recipes.requestDetails(route.params.recipe);
|
||||||
|
return recipe.name;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
{ path: "/new/", component: NewRecipe },
|
{ path: "/new/", component: NewRecipe },
|
||||||
{ path: "/meal-plan/planner", component: Planner },
|
{
|
||||||
{ path: "/meal-plan/this-week", component: ThisWeek },
|
path: "/meal-plan/planner",
|
||||||
|
component: Planner,
|
||||||
|
meta: {
|
||||||
|
title: i18n.t('meal-plan.meal-planner'),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/meal-plan/this-week",
|
||||||
|
component: ThisWeek,
|
||||||
|
meta: {
|
||||||
|
title: i18n.t('meal-plan.dinner-this-week'),
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
Admin,
|
Admin,
|
||||||
{
|
{
|
||||||
path: "/meal-plan/today",
|
path: "/meal-plan/today",
|
||||||
|
@ -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
|
||||||
|
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:
|
||||||
|
@ -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:
|
||||||
@ -47,6 +49,7 @@ def default_user_init(session: Session):
|
|||||||
logger.info("Generating Default User")
|
logger.info("Generating Default User")
|
||||||
db.users.create(session, default_user)
|
db.users.create(session, default_user)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
if sql_exists:
|
if sql_exists:
|
||||||
print("Database Exists")
|
print("Database Exists")
|
||||||
@ -54,5 +57,6 @@ def main():
|
|||||||
print("Database Doesn't Exists, Initializing...")
|
print("Database Doesn't Exists, Initializing...")
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
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)
|
||||||
|
@ -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()
|
||||||
|
|
||||||
recipes2tags = sa.Table(
|
recipes2tags = sa.Table(
|
||||||
"recipes2tags",
|
"recipes2tags",
|
||||||
SqlAlchemyBase.metadata,
|
SqlAlchemyBase.metadata,
|
||||||
@ -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
|
||||||
|
@ -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, rename_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
|
||||||
|
|
||||||
@ -120,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")
|
||||||
|
@ -19,6 +19,7 @@ async def get_all_recipe_tags(session: Session = Depends(generate_session)):
|
|||||||
""" Returns a list of available tags in the database """
|
""" Returns a list of available tags in the database """
|
||||||
return db.tags.get_all_limit_columns(session, ["slug", "name"])
|
return db.tags.get_all_limit_columns(session, ["slug", "name"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
async def create_recipe_tag(
|
async def create_recipe_tag(
|
||||||
tag: TagIn, session: Session = Depends(generate_session), current_user=Depends(get_current_user)
|
tag: TagIn, session: Session = Depends(generate_session), current_user=Depends(get_current_user)
|
||||||
|
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")
|
@ -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:
|
||||||
@ -50,25 +51,28 @@ def rename_image(original_slug, new_slug) -> Path:
|
|||||||
return new_path
|
return new_path
|
||||||
|
|
||||||
|
|
||||||
def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path.name:
|
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
|
||||||
|
|
||||||
@ -105,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,15 +1,33 @@
|
|||||||
import shutil
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
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.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 PIL import Image
|
from PIL import Image
|
||||||
from sqlalchemy.orm.session import Session
|
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:
|
||||||
@ -17,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
|
||||||
@ -32,6 +55,12 @@ def minify_image(image_file: Path, min_dest: Path, tiny_dest: Path):
|
|||||||
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
|
||||||
@ -45,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
|
||||||
@ -89,24 +121,13 @@ def validate_slugs_in_database(session: Session = None):
|
|||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
org_size = sizeof_fmt(image.stat().st_size)
|
|
||||||
dest_size = sizeof_fmt(min_dest.stat().st_size)
|
|
||||||
tiny_size = sizeof_fmt(tiny_dest.stat().st_size)
|
|
||||||
logger.info(f"{image.name} Minified: {org_size} -> {dest_size} -> {tiny_size}")
|
|
||||||
|
|
||||||
logger.info("Finished Minification Check")
|
logger.info("Finished Minification Check")
|
||||||
|
|
||||||
|
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,94 +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.services.image.minify import migrate_images
|
from mealie.services.migrations._migration_base import MigrationAlias, MigrationBase
|
||||||
from mealie.utils.unzip import unpack_zip
|
|
||||||
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}
|
|
||||||
|
|
||||||
migrate_images()
|
cd_migration.import_recipes_to_database(recipes)
|
||||||
return report
|
|
||||||
|
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,97 +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.image import minify
|
from slugify import slugify
|
||||||
from mealie.services.scraper.cleaner import Cleaner
|
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:
|
|
||||||
return None
|
@classmethod
|
||||||
|
def from_dir(cls, dir: Path):
|
||||||
|
try:
|
||||||
|
json_file = next(dir.glob("*.json"))
|
||||||
|
except StopIteration:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try: # TODO: There's got to be a better way to do this.
|
||||||
|
image_file = next(dir.glob("full.*"))
|
||||||
|
except StopIteration:
|
||||||
|
image_file = None
|
||||||
|
|
||||||
|
return cls(name=dir.name, recipe=NextcloudMigration.json_reader(json_file), image=image_file)
|
||||||
|
|
||||||
|
|
||||||
def clean_nextcloud_tags(nextcloud_tags: str):
|
class NextcloudMigration(MigrationBase):
|
||||||
if not isinstance(nextcloud_tags, str):
|
key_aliases: Optional[list[MigrationAlias]] = [
|
||||||
return None
|
MigrationAlias(key="tags", alias="keywords", func=helpers.split_by_comma),
|
||||||
|
MigrationAlias(key="orgURL", alias="url", func=None),
|
||||||
return [x.title().lstrip() for x in nextcloud_tags.split(",") if x != ""]
|
]
|
||||||
|
|
||||||
|
|
||||||
def import_recipes(recipe_dir: Path) -> Recipe:
|
def migrate(session: Session, zip_path: Path) -> list[MigrationImport]:
|
||||||
image = False
|
|
||||||
|
|
||||||
for file in recipe_dir.glob("full.*"):
|
nc_migration = NextcloudMigration(migration_file=zip_path, session=session)
|
||||||
image = file
|
|
||||||
break
|
|
||||||
|
|
||||||
for file in recipe_dir.glob("*.json"):
|
with nc_migration.temp_dir as dir:
|
||||||
recipe_file = file
|
potential_recipe_dirs = NextcloudMigration.glob_walker(dir, glob_str="**/[!.]*.json", return_parent=True)
|
||||||
break
|
|
||||||
|
|
||||||
with open(recipe_file, "r") as f:
|
# nextcloud_dirs = [NextcloudDir.from_dir(x) for x in potential_recipe_dirs]
|
||||||
recipe_dict = json.loads(f.read())
|
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}
|
||||||
|
|
||||||
recipe_data = Cleaner.clean(recipe_dict)
|
all_recipes = []
|
||||||
|
for _, nc_dir in nextcloud_dirs.items():
|
||||||
|
recipe = nc_migration.clean_recipe_dictionary(nc_dir.recipe)
|
||||||
|
all_recipes.append(recipe)
|
||||||
|
|
||||||
image_name = recipe_data["slug"]
|
nc_migration.import_recipes_to_database(all_recipes)
|
||||||
recipe_data["image"] = recipe_data["slug"]
|
|
||||||
recipe_data["tags"] = clean_nextcloud_tags(recipe_data.get("keywords"))
|
|
||||||
|
|
||||||
recipe = Recipe(**recipe_data)
|
for report in nc_migration.migration_report:
|
||||||
|
|
||||||
if image:
|
if report.status:
|
||||||
shutil.copy(image, app_dirs.IMG_DIR.joinpath(image_name + image.suffix))
|
nc_dir: NextcloudDir = nextcloud_dirs[report.slug]
|
||||||
|
if nc_dir.image:
|
||||||
|
NextcloudMigration.import_image(nc_dir.image, nc_dir.slug)
|
||||||
|
|
||||||
return recipe
|
return nc_migration.migration_report
|
||||||
|
|
||||||
|
|
||||||
def prep():
|
|
||||||
shutil.rmtree(app_dirs.TEMP_DIR, ignore_errors=True)
|
|
||||||
app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True)
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup():
|
|
||||||
shutil.rmtree(app_dirs.TEMP_DIR)
|
|
||||||
|
|
||||||
|
|
||||||
def migrate(session, selection: str):
|
|
||||||
prep()
|
|
||||||
app_dirs.MIGRATION_DIR.mkdir(exist_ok=True)
|
|
||||||
selection = app_dirs.MIGRATION_DIR.joinpath(selection)
|
|
||||||
|
|
||||||
nextcloud_dir = process_selection(selection)
|
|
||||||
|
|
||||||
successful_imports = []
|
|
||||||
failed_imports = []
|
|
||||||
for dir in nextcloud_dir.iterdir():
|
|
||||||
if dir.is_dir():
|
|
||||||
|
|
||||||
try:
|
|
||||||
recipe = import_recipes(dir)
|
|
||||||
db.recipes.create(session, recipe.dict())
|
|
||||||
|
|
||||||
successful_imports.append(recipe.name)
|
|
||||||
except Exception:
|
|
||||||
session.rollback()
|
|
||||||
logging.error(f"Failed Nextcloud Import: {dir.name}")
|
|
||||||
logging.exception("")
|
|
||||||
failed_imports.append(dir.name)
|
|
||||||
|
|
||||||
cleanup()
|
|
||||||
minify.migrate_images()
|
|
||||||
|
|
||||||
return {"successful": successful_imports, "failed": failed_imports}
|
|
||||||
|
@ -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():
|
||||||
"""
|
"""
|
||||||
|
@ -26,9 +26,9 @@ class Cleaner:
|
|||||||
recipe_data["description"] = Cleaner.html(recipe_data.get("description", ""))
|
recipe_data["description"] = Cleaner.html(recipe_data.get("description", ""))
|
||||||
|
|
||||||
# Times
|
# Times
|
||||||
recipe_data["prepTime"] = Cleaner.time(recipe_data.get("prepTime", None))
|
recipe_data["prepTime"] = Cleaner.time(recipe_data.get("prepTime"))
|
||||||
recipe_data["performTime"] = Cleaner.time(recipe_data.get("performTime", None))
|
recipe_data["performTime"] = Cleaner.time(recipe_data.get("performTime"))
|
||||||
recipe_data["totalTime"] = Cleaner.time(recipe_data.get("totalTime", None))
|
recipe_data["totalTime"] = Cleaner.time(recipe_data.get("totalTime"))
|
||||||
recipe_data["recipeCategory"] = Cleaner.category(recipe_data.get("recipeCategory", []))
|
recipe_data["recipeCategory"] = Cleaner.category(recipe_data.get("recipeCategory", []))
|
||||||
|
|
||||||
recipe_data["recipeYield"] = Cleaner.yield_amount(recipe_data.get("recipeYield"))
|
recipe_data["recipeYield"] = Cleaner.yield_amount(recipe_data.get("recipeYield"))
|
||||||
@ -70,6 +70,9 @@ class Cleaner:
|
|||||||
if not instructions:
|
if not instructions:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
if isinstance(instructions[0], list):
|
||||||
|
instructions = instructions[0]
|
||||||
|
|
||||||
# One long string split by (possibly multiple) new lines
|
# One long string split by (possibly multiple) new lines
|
||||||
if isinstance(instructions, str):
|
if isinstance(instructions, str):
|
||||||
return [{"text": Cleaner._instruction(line)} for line in instructions.splitlines() if line]
|
return [{"text": Cleaner._instruction(line)} for line in instructions.splitlines() if line]
|
||||||
@ -128,8 +131,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,encoding="utf8").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,40 +1,39 @@
|
|||||||
import shutil
|
# import shutil
|
||||||
from pathlib import Path
|
# 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)
|
||||||
shutil.rmtree(app_dirs.IMG_DIR.joinpath(recipe.image), ignore_errors=True)
|
# shutil.rmtree(app_dirs.IMG_DIR.joinpath(recipe.image), ignore_errors=True)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user