* 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:
Hayden 2021-04-17 12:57:47 -08:00 committed by GitHub
parent e11577f786
commit 0f5a564ff3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
68 changed files with 3357 additions and 2521 deletions

View File

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

View File

@ -20,7 +20,7 @@ RUN apk add --no-cache libxml2-dev \
zlib-dev
ENV ENV True
ENV PRODUCTION true
EXPOSE 80
WORKDIR /app/
@ -48,6 +48,7 @@ COPY ./dev/data/templates /app/data/templates
COPY --from=build-stage /app/dist /app/dist
VOLUME [ "/app/data/" ]
RUN chmod +x /app/mealie/run.sh
CMD /app/mealie/run.sh

View File

@ -2,6 +2,8 @@ FROM python:3
WORKDIR /app/
ENV PRODUCTION false
RUN apt-get update -y && \
apt-get install -y python-pip python-dev

View 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

View File

@ -77,6 +77,7 @@ nav:
- Guidelines: "contributors/developers-guide/general-guidelines.md"
- Development Road Map: "roadmap.md"
- Change Log:
- v0.4.2 Backend/Migrations: "changelog/v0.4.2.md"
- v0.4.1 Frontend/UI: "changelog/v0.4.1.md"
- v0.4.0 Authentication: "changelog/v0.4.0.md"
- v0.3.0 Improvements: "changelog/v0.3.0.md"

View File

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

View File

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

View File

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

View File

@ -37,14 +37,7 @@
<v-divider></v-divider>
<v-card-actions>
<v-btn
color="accent"
text
:loading="downloading"
@click="downloadFile(`/api/backups/${name}/download`)"
>
{{ $t("general.download") }}
</v-btn>
<TheDownloadBtn :download-url="downloadUrl" />
<v-spacer></v-spacer>
<v-btn color="error" text @click="raiseEvent('delete')">
{{ $t("general.delete") }}
@ -66,9 +59,10 @@
<script>
import ImportOptions from "@/components/Admin/Backup/ImportOptions";
import axios from "axios";
import TheDownloadBtn from "@/components/UI/TheDownloadBtn.vue";
import { backupURLs } from "@/api/backup";
export default {
components: { ImportOptions },
components: { ImportOptions, TheDownloadBtn },
props: {
name: {
default: "Backup Name",
@ -92,6 +86,11 @@ export default {
downloading: false,
};
},
computed: {
downloadUrl() {
return backupURLs.downloadBackup(this.name);
},
},
methods: {
updateOptions(options) {
this.options = options;
@ -116,23 +115,6 @@ export default {
this.close();
this.$emit(event, eventData);
},
async downloadFile(downloadURL) {
this.downloading = true;
const response = await axios({
url: downloadURL,
method: "GET",
responseType: "blob", // important
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", `${this.name}.zip`);
document.body.appendChild(link);
link.click();
this.downloading = false;
},
},
};
</script>

View File

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

View File

@ -17,13 +17,13 @@
<v-text-field
autofocus
v-model="page.name"
label="Page Name"
:label="$t('settings.page-name')"
></v-text-field>
<CategoryTagSelector
v-model="page.categories"
ref="categoryFormSelector"
@mounted="catMounted = true"
tag-selector="false"
:tag-selector="false"
/>
</v-card-text>

View File

@ -3,10 +3,10 @@
<CreatePageDialog ref="createDialog" @refresh-page="getPages" />
<v-card-text>
<h2 class="mt-1 mb-1 ">
Custom Pages
{{$t('settings.custom-pages')}}
<span>
<v-btn color="success" @click="newPage" small class="ml-3">
Create
{{$t('general.create')}}
</v-btn>
</span>
</h2>
@ -41,11 +41,11 @@
<v-card-actions>
<v-btn text small color="error" @click="deletePage(item.id)">
Delete
{{$t('general.delete')}}
</v-btn>
<v-spacer> </v-spacer>
<v-btn small text color="success" @click="editPage(index)">
Edit
{{$t('general.edit')}}
</v-btn>
</v-card-actions>
</v-card>
@ -55,7 +55,7 @@
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="success" @click="savePages">
Save
{{$t('general.save')}}
</v-btn>
</v-card-actions>
</v-card>
@ -76,8 +76,8 @@ export default {
customPages: [],
newPageData: {
create: true,
title: "New Page",
buttonText: "Create",
title: this.$t('settings.new-page'),
buttonText: this.$t('general.create'),
data: {
name: "",
categories: [],
@ -86,8 +86,8 @@ export default {
},
editPageData: {
create: false,
title: "Edit Page",
buttonText: "Update",
title: this.$t('settings.edit-page'),
buttonText: this.$t('general.update'),
data: {},
},
};

View File

@ -1,5 +1,6 @@
<template>
<v-card outlined class="my-2" :loading="loading">
<MigrationDialog ref="migrationDialog" />
<v-card-title>
{{ title }}
<v-spacer></v-spacer>
@ -67,6 +68,7 @@
import UploadBtn from "../../UI/UploadBtn";
import utils from "@/utils";
import { api } from "@/api";
import MigrationDialog from "@/components/Admin/Migration/MigrationDialog.vue";
export default {
props: {
folder: String,
@ -76,6 +78,7 @@ export default {
},
components: {
UploadBtn,
MigrationDialog,
},
data() {
return {
@ -90,7 +93,8 @@ export default {
async importMigration(file_name) {
this.loading = true;
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;
},
readableTime(timestamp) {

View File

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

View File

@ -90,7 +90,7 @@ export default {
computed: {
inputLabel() {
if (!this.showLabel) return null;
return this.tagSelector ? "Tags" : "Categories";
return this.tagSelector ? this.$t('recipe.tags') : this.$t('recipe.categories');
},
activeItems() {
let ItemObjects = [];

View File

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

View File

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

View File

@ -2,16 +2,12 @@
<v-form ref="form">
<v-card-text>
<v-row dense>
<v-col cols="3"></v-col>
<v-col>
<v-file-input
v-model="fileObject"
:label="$t('general.image-file')"
truncate-length="30"
@change="uploadImage"
></v-file-input>
</v-col>
<v-col cols="3"></v-col>
<ImageUploadBtn
class="mt-2"
@upload="uploadImage"
:slug="value.slug"
@refresh="$emit('upload')"
/>
</v-row>
<v-row dense>
<v-col>
@ -92,7 +88,7 @@
auto-grow
solo
dense
rows="2"
rows="1"
>
<v-icon
class="mr-n1"
@ -165,6 +161,7 @@
<v-btn class="mt-1" color="secondary" fab dark small @click="addNote">
<v-icon>mdi-plus</v-icon>
</v-btn>
<NutritionEditor v-model="value.nutrition" :edit="true" />
<ExtrasEditor :extras="value.extras" @save="saveExtras" />
</v-col>
@ -222,17 +219,20 @@
<script>
import draggable from "vuedraggable";
import { api } from "@/api";
import utils from "@/utils";
import BulkAdd from "./BulkAdd";
import ExtrasEditor from "./ExtrasEditor";
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
import NutritionEditor from "./NutritionEditor";
import ImageUploadBtn from "./ImageUploadBtn.vue";
export default {
components: {
BulkAdd,
ExtrasEditor,
draggable,
CategoryTagSelector,
NutritionEditor,
ImageUploadBtn,
},
props: {
value: Object,
@ -251,12 +251,8 @@ export default {
};
},
methods: {
uploadImage() {
this.$emit("upload", this.fileObject);
},
async updateImage() {
const slug = this.value.slug;
api.recipes.updateImage(slug, this.fileObject);
uploadImage(fileObject) {
this.$emit("upload", fileObject);
},
toggleDisabled(stepIndex) {
if (this.disabledSteps.includes(stepIndex)) {

View File

@ -12,6 +12,7 @@
v-model="ingredient.checked"
class="pt-0 my-auto py-auto"
color="secondary"
:readonly="true"
>
</v-checkbox>

View File

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

View File

@ -5,7 +5,7 @@
<v-row v-if="title != null">
<v-col>
<v-btn-toggle group>
<v-btn text :to="`/recipes/${title.toLowerCase()}`">
<v-btn text>
{{ title.toUpperCase() }}
</v-btn>
</v-btn-toggle>

View File

@ -54,22 +54,11 @@ export default {
{
icon: "mdi-magnify",
to: "/search",
title: "search",
title: this.$t('search.search'),
},
],
};
},
computed: {
allCategories() {
return this.$store.getters.getCategories;
},
},
watch: {
allCategories() {
this.buildSidebar();
},
showSidebar() {},
},
mounted() {
this.buildSidebar();
this.mobile = this.viewScale();
@ -81,14 +70,27 @@ export default {
this.links = [];
this.links.push(...this.baseLinks);
const pages = await api.siteSettings.getPages();
pages.sort((a, b) => a.position - b.position);
pages.forEach(async element => {
this.links.push({
title: element.name,
to: `/pages/${element.slug}`,
icon: "mdi-tag",
if(pages.length > 0) {
pages.sort((a, b) => a.position - b.position);
pages.forEach(async element => {
this.links.push({
title: element.name,
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() {
switch (this.$vuetify.breakpoint.name) {

View File

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

View File

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

View File

@ -93,7 +93,7 @@
"groups": "Gruppen",
"could-not-validate-credentials": "Anmeldeinformationen konnten nicht validiert werden",
"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",
"reset-password": "Passwort zurücksetzen",
"current-password": "Aktuelles Passwort",

View File

@ -48,7 +48,8 @@
"apply": "Apply",
"current-parenthesis": "(Current)",
"users": "Users",
"groups": "Groups"
"groups": "Groups",
"about": "About"
},
"page": {
"home-page": "Home Page",
@ -145,7 +146,16 @@
"delete-confirmation": "Are you sure you want to delete this recipe?"
},
"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": {
"general-settings": "General Settings",
@ -215,7 +225,11 @@
"site-settings": "Site Settings",
"manage-users": "Manage Users",
"migrations": "Migrations",
"profile": "Profile"
"profile": "Profile",
"custom-pages": "Custom Pages",
"new-page": "New Page",
"edit-page": "Edit Page",
"page-name": "Page Name"
},
"migration": {
"recipe-migration": "Recipe Migration",

View File

@ -46,7 +46,10 @@
"token": "Jeton",
"field-required": "Champ obligatoire",
"apply": "Appliquer",
"current-parenthesis": "(Actuel)"
"current-parenthesis": "(Actuel)",
"groups": "Groupes",
"users": "Utilisateurs",
"about": "À propos"
},
"page": {
"home-page": "Accueil",
@ -120,7 +123,7 @@
"categories": "Catégories",
"tags": "Tags",
"instructions": "Instructions",
"step-index": "Etape: {step}",
"step-index": "Étape : {step}",
"recipe-name": "Nom de la recette",
"servings": "Portions",
"ingredient": "Ingrédient",
@ -143,7 +146,16 @@
"delete-confirmation": "Êtes-vous sûr(e) de vouloir supprimer cette recette ?"
},
"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": {
"general-settings": "Paramètres généraux",
@ -185,7 +197,7 @@
},
"webhooks": {
"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",
"webhook-url": "Lien du webhook"
},
@ -213,7 +225,11 @@
"manage-users": "Utilisateurs",
"migrations": "Migrations",
"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": {
"recipe-migration": "Migrer les recettes",

View File

@ -18,6 +18,20 @@ const router = new VueRouter({
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({
vuetify,
store,

View File

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

View File

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

View File

@ -10,11 +10,11 @@
mandatory
>
<v-btn :value="false">
Include
{{$t('search.include')}}
</v-btn>
<v-btn :value="true">
Exclude
{{$t('search.exclude')}}
</v-btn>
</v-btn-toggle>
<v-spacer></v-spacer>
@ -28,10 +28,10 @@
mandatory
>
<v-btn :value="false">
And
{{$t('search.and')}}
</v-btn>
<v-btn :value="true">
Or
{{$t('search.or')}}
</v-btn>
</v-btn-toggle>
</v-toolbar>

View File

@ -8,7 +8,7 @@
v-model="searchString"
outlined
color="primary accent-3"
placeholder="Placeholder"
:placeholder="$t('search.search-placeholder')"
append-icon="mdi-magnify"
>
</v-text-field>
@ -16,7 +16,7 @@
<v-col cols="12" md="2" sm="12">
<v-text-field
class="mt-0 pt-0"
label="Max Results"
:label="$t('search.max-results')"
v-model="maxResults"
type="number"
outlined
@ -26,7 +26,7 @@
<v-row dense class="mt-0 flex-row align-center justify-space-around">
<v-col>
<h3 class="pl-2 text-center headline">Category Filter</h3>
<h3 class="pl-2 text-center headline">{{$t('search.category-filter')}}</h3>
<FilterSelector class="mb-1" @update="updateCatParams" />
<CategoryTagSelector
:solo="true"
@ -36,7 +36,7 @@
/>
</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" />
<CategoryTagSelector

View File

@ -8,6 +8,7 @@ import ManageUsers from "@/pages/Admin/ManageUsers";
import Settings from "@/pages/Admin/Settings";
import About from "@/pages/Admin/About";
import { store } from "../store";
import i18n from '@/i18n.js';
export default {
path: "/admin",
@ -25,35 +26,59 @@ export default {
{
path: "profile",
component: Profile,
meta: {
title: i18n.t('settings.profile'),
},
},
{
path: "backups",
component: Backup,
meta: {
title: i18n.t('settings.backup-and-exports'),
},
},
{
path: "themes",
component: Theme,
meta: {
title: i18n.t('general.themes'),
},
},
{
path: "meal-planner",
component: MealPlanner,
meta: {
title: i18n.t('meal-plan.meal-planner'),
},
},
{
path: "migrations",
component: Migration,
meta: {
title: i18n.t('settings.migrations'),
},
},
{
path: "manage-users",
component: ManageUsers,
meta: {
title: i18n.t('settings.manage-users'),
},
},
{
path: "settings",
component: Settings,
meta: {
title: i18n.t('settings.site-settings'),
},
},
{
path: "about",
component: About,
meta: {
title: i18n.t('general.about'),
},
},
],
};

View File

@ -15,6 +15,7 @@ import ThisWeek from "@/pages/MealPlan/ThisWeek";
import { api } from "@/api";
import Admin from "./admin";
import { store } from "../store";
import i18n from '@/i18n.js';
export const routes = [
{ path: "/", name: "home", component: HomePage },
@ -31,15 +32,43 @@ export const routes = [
{ path: "/sign-up", redirect: "/" },
{ path: "/sign-up/:token", component: SignUpPage },
{ path: "/debug", component: Debug },
{ path: "/search", component: SearchPage },
{
path: "/search",
component: SearchPage,
meta: {
title: i18n.t('search.search'),
},
},
{ path: "/recipes/all", component: AllRecipes },
{ path: "/pages/:customPage", component: CustomPage },
{ path: "/recipes/tag/:tag", component: TagPage },
{ 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: "/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,
{
path: "/meal-plan/today",

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
from fastapi.logger import logger
from mealie.core import root_logger
from mealie.core.config import settings
from mealie.core.security import get_password_hash
from mealie.db.database import db
@ -7,6 +7,8 @@ from mealie.schema.settings import SiteSettings
from mealie.schema.theme import SiteTheme
from sqlalchemy.orm import Session
logger = root_logger.get_logger("init_db")
def init_db(db: Session = None) -> None:
if not db:
@ -47,6 +49,7 @@ def default_user_init(session: Session):
logger.info("Generating Default User")
db.users.create(session, default_user)
def main():
if sql_exists:
print("Database Exists")
@ -54,5 +57,6 @@ def main():
print("Database Doesn't Exists, Initializing...")
init_db()
if __name__ == "__main__":
main()
main()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,6 +19,7 @@ async def get_all_recipe_tags(session: Session = Depends(generate_session)):
""" Returns a list of available tags in the database """
return db.tags.get_all_limit_columns(session, ["slug", "name"])
@router.post("")
async def create_recipe_tag(
tag: TagIn, session: Session = Depends(generate_session), current_user=Depends(get_current_user)

View File

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

View File

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

View File

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

View File

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

View File

@ -1,13 +1,14 @@
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Union
import requests
from fastapi.logger import logger
from mealie.core import root_logger
from mealie.core.config import app_dirs
from mealie.services.image import minify
logger = root_logger.get_logger()
@dataclass
class ImageOptions:
@ -50,25 +51,28 @@ def rename_image(original_slug, new_slug) -> 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:
delete_image(recipe_slug)
except:
pass
image_dir = Path(app_dirs.IMG_DIR.joinpath(f"{recipe_slug}"))
image_dir.mkdir()
image_dir.mkdir(exist_ok=True, parents=True)
extension = extension.replace(".", "")
image_path = image_dir.joinpath(f"original.{extension}")
if isinstance(file_data, bytes):
if isinstance(file_data, Path):
shutil.copy2(file_data, image_path)
elif isinstance(file_data, bytes):
with open(image_path, "ab") as f:
f.write(file_data)
else:
with open(image_path, "ab") as f:
shutil.copyfileobj(file_data, f)
minify.migrate_images()
print(image_path)
minify.minify_image(image_path)
return image_path
@ -105,7 +109,7 @@ def scrape_image(image_url: str, slug: str) -> Path:
write_image(slug, r.raw, filename.suffix)
filename.unlink()
filename.unlink(missing_ok=True)
return slug

View File

@ -1,15 +1,33 @@
import shutil
from dataclasses import dataclass
from pathlib import Path
from fastapi.logger import logger
from mealie.core import root_logger
from mealie.core.config import app_dirs
from mealie.db.database import db
from mealie.db.db_setup import create_session
from PIL import Image
from sqlalchemy.orm.session import Session
logger = root_logger.get_logger()
def minify_image(image_file: Path, min_dest: Path, tiny_dest: Path):
@dataclass
class ImageSizes:
org: str
min: str
tiny: str
def get_image_sizes(org_img: Path, min_img: Path, tiny_img: Path) -> ImageSizes:
return ImageSizes(
org=sizeof_fmt(org_img),
min=sizeof_fmt(min_img),
tiny=sizeof_fmt(tiny_img),
)
def minify_image(image_file: Path) -> ImageSizes:
"""Minifies an image in it's original file format. Quality is lost
Args:
@ -17,6 +35,11 @@ def minify_image(image_file: Path, min_dest: Path, tiny_dest: Path):
min_dest (Path): FULL Destination File Path
tiny_dest (Path): FULL Destination File Path
"""
min_dest = image_file.parent.joinpath(f"min-original{image_file.suffix}")
tiny_dest = image_file.parent.joinpath(f"tiny-original{image_file.suffix}")
if min_dest.exists() and tiny_dest.exists():
return
try:
img = Image.open(image_file)
basewidth = 720
@ -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, tiny_dest)
image_sizes = get_image_sizes(image_file, min_dest, tiny_dest)
logger.info(f"{image_file.name} Minified: {image_sizes.org} -> {image_sizes.min} -> {image_sizes.tiny}")
return image_sizes
def crop_center(pil_img, crop_width=300, crop_height=300):
img_width, img_height = pil_img.size
@ -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"]:
if size < 1024.0 or unit == "PiB":
break
@ -89,24 +121,13 @@ def validate_slugs_in_database(session: Session = None):
def migrate_images():
print("Checking for Images to Minify...")
logger.info("Checking for Images to Minify...")
move_all_images()
# Minify Loop
for image in app_dirs.IMG_DIR.glob("*/original.*"):
min_dest = image.parent.joinpath(f"min-original{image.suffix}")
tiny_dest = image.parent.joinpath(f"tiny-original{image.suffix}")
if min_dest.exists() and tiny_dest.exists():
continue
minify_image(image, 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}")
minify_image(image)
logger.info("Finished Minification Check")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,9 +26,9 @@ class Cleaner:
recipe_data["description"] = Cleaner.html(recipe_data.get("description", ""))
# Times
recipe_data["prepTime"] = Cleaner.time(recipe_data.get("prepTime", None))
recipe_data["performTime"] = Cleaner.time(recipe_data.get("performTime", None))
recipe_data["totalTime"] = Cleaner.time(recipe_data.get("totalTime", None))
recipe_data["prepTime"] = Cleaner.time(recipe_data.get("prepTime"))
recipe_data["performTime"] = Cleaner.time(recipe_data.get("performTime"))
recipe_data["totalTime"] = Cleaner.time(recipe_data.get("totalTime"))
recipe_data["recipeCategory"] = Cleaner.category(recipe_data.get("recipeCategory", []))
recipe_data["recipeYield"] = Cleaner.yield_amount(recipe_data.get("recipeYield"))
@ -70,6 +70,9 @@ class Cleaner:
if not instructions:
return []
if isinstance(instructions[0], list):
instructions = instructions[0]
# One long string split by (possibly multiple) new lines
if isinstance(instructions, str):
return [{"text": Cleaner._instruction(line)} for line in instructions.splitlines() if line]
@ -128,8 +131,10 @@ class Cleaner:
@staticmethod
def ingredient(ingredients: list) -> str:
return [Cleaner.html(html.unescape(ing)) for ing in ingredients]
if ingredients:
return [Cleaner.html(html.unescape(ing)) for ing in ingredients]
else:
return []
@staticmethod
def yield_amount(yld) -> str:

View File

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

4121
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -80,7 +80,7 @@ def test_cleaner_instructions(instructions):
def test_html_with_recipe_data():
path = TEST_RAW_HTML.joinpath("healthy_pasta_bake_60759.html")
url = "https://www.bbc.co.uk/food/recipes/healthy_pasta_bake_60759"
recipe_data = extract_recipe_from_html(open(path,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["slug"]) > 10

View File

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

View File

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