Compare commits

...

28 Commits

Author SHA1 Message Date
Hayden
b981cf62bf chore: bump version (#1307)
* bump version

* add release notes
2022-05-28 17:13:36 -08:00
Hayden
ee93d77ace Update stale.yml
exclude tasks
2022-05-28 17:04:40 -08:00
Hayden
3dcfcc1fa9 feat: rewrite print implementation to support new ing (#1305) 2022-05-28 17:00:37 -08:00
Hayden
80f1a9add8 New Crowdin updates (#1304)
* New translations en-US.json (French)

* New translations en-US.json (German)

* New translations en-US.json (German)

* New translations en-US.json (German)
2022-05-28 16:38:59 -08:00
Hayden
137bf9de91 bump recipe scrapers version (#1303) 2022-05-28 16:38:47 -08:00
Drumstickx
1534f0df77 docs: docker-compose.dev.yml is currently not functional (#1300) 2022-05-27 16:17:47 -08:00
Drumstickx
d751e3b35b docs: add references for VSCode dev containers (#1299) 2022-05-27 16:17:39 -08:00
Hayden
07bf5be3ec improve touch support with icon and handle (#1302) 2022-05-27 16:17:25 -08:00
Hayden
a96f94a149 fix: add touch support for mealplanner delete (#1298)
* add touch support for mealplanner delete

* remove click.stop
2022-05-26 19:31:54 -08:00
Hayden
78a8204b58 New translations en-US.json (German) (#1296) 2022-05-26 09:01:17 -08:00
Hayden
649e34f66e add touch support for mealplanner (#1295) 2022-05-25 20:13:13 -08:00
Hayden
010aafa69b feat: add reports to bulk recipe import (url) (#1294)
* remove unused docker and caddy configs

* add experimental nested configs

* switch to nest under docker-compose

* remove v-card

* bulk parser backend re-implementation

* refactor UI for bulk importer

* remove migration specific report text
2022-05-25 19:33:58 -08:00
Hayden
d66d6c55ae fix recipe assets build (#1286) 2022-05-25 11:50:45 -08:00
Hayden
7609715d9e remove explicity typescript version (#1285)
* remove explicity typescript version

* i hate javascript
2022-05-25 10:14:24 -08:00
Hayden
921fceddea chore: update dev dependencies (#1282)
* update dev dependencies

* upgrade eslint

* resolve several errors

* resolve eslint errors
2022-05-25 09:38:21 -08:00
Hayden
01f3fef21f New Crowdin updates (#1284)
* New translations en-US.json (German)

* New translations en-US.json (German)
2022-05-25 09:08:41 -08:00
Philipp Fischbeck
8f7c7c39bb refactor: split up recipe create page (#1283)
* refactor: split up recipe create page

* add flat card

Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
2022-05-25 09:08:32 -08:00
Hayden
30d19c6503 fix: bad dev dependency (#1281)
* revert bad dev dependency

* fix concurrent builds
2022-05-24 21:19:50 -08:00
Hayden
ea503a0235 add conccureny config (#1280) 2022-05-24 20:24:33 -08:00
dependabot[bot]
c05c123880 fix(deps): bump @nuxtjs/auth-next in /frontend (#1265)
Bumps [@nuxtjs/auth-next](https://github.com/nuxt-community/auth-module) from 5.0.0-1624817847.21691f1 to 5.0.0-1648802546.c9880dc.
- [Release notes](https://github.com/nuxt-community/auth-module/releases)
- [Changelog](https://github.com/nuxt-community/auth-module/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/nuxt-community/auth-module/commits)

---
updated-dependencies:
- dependency-name: "@nuxtjs/auth-next"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-24 20:02:43 -08:00
dependabot[bot]
6f45de6167 chore(deps-dev): bump vue2-script-setup-transform in /frontend (#1263)
Bumps [vue2-script-setup-transform](https://github.com/antfu/vue2-script-setup-transform) from 0.2.6 to 0.3.5.
- [Release notes](https://github.com/antfu/vue2-script-setup-transform/releases)
- [Commits](https://github.com/antfu/vue2-script-setup-transform/compare/v0.2.6...v0.3.5)

---
updated-dependencies:
- dependency-name: vue2-script-setup-transform
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-24 20:02:00 -08:00
dependabot[bot]
d634e2fbe1 chore(deps-dev): bump nuxt-vite from 0.1.3 to 0.3.5 in /frontend (#1260)
Bumps [nuxt-vite](https://github.com/nuxt/vite) from 0.1.3 to 0.3.5.
- [Release notes](https://github.com/nuxt/vite/releases)
- [Changelog](https://github.com/nuxt/vite/blob/main/CHANGELOG.md)
- [Commits](https://github.com/nuxt/vite/compare/v0.1.3...v0.3.5)

---
updated-dependencies:
- dependency-name: nuxt-vite
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-24 20:00:50 -08:00
dependabot[bot]
43a566339a chore(deps-dev): bump @vue/runtime-dom in /frontend (#1259)
Bumps [@vue/runtime-dom](https://github.com/vuejs/core/tree/HEAD/packages/runtime-dom) from 3.2.35 to 3.2.36.
- [Release notes](https://github.com/vuejs/core/releases)
- [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vuejs/core/commits/v3.2.36/packages/runtime-dom)

---
updated-dependencies:
- dependency-name: "@vue/runtime-dom"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-24 20:00:16 -08:00
dependabot[bot]
cc284a0ceb chore(deps-dev): bump eslint-plugin-nuxt in /frontend (#1258)
Bumps [eslint-plugin-nuxt](https://github.com/nuxt/eslint-plugin-nuxt) from 2.0.0 to 3.2.0.
- [Release notes](https://github.com/nuxt/eslint-plugin-nuxt/releases)
- [Changelog](https://github.com/nuxt/eslint-plugin-nuxt/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nuxt/eslint-plugin-nuxt/compare/v2.0.0...v3.2.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-nuxt
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-24 19:59:49 -08:00
dependabot[bot]
3c19105d8b fix(deps): bump isomorphic-dompurify from 0.18.0 to 0.19.0 in /frontend (#1257)
Bumps [isomorphic-dompurify](https://github.com/kkomelin/isomorphic-dompurify) from 0.18.0 to 0.19.0.
- [Release notes](https://github.com/kkomelin/isomorphic-dompurify/releases)
- [Commits](https://github.com/kkomelin/isomorphic-dompurify/compare/v0.18.0...v0.19.0)

---
updated-dependencies:
- dependency-name: isomorphic-dompurify
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-24 19:58:14 -08:00
Hayden
b8ee1a4bd8 fix #1270 migration not capture settings (#1272) 2022-05-24 09:55:15 -08:00
Hayden
c30ffbc851 update readme 2022-05-24 09:51:39 -08:00
Hayden
3ddbc033b2 chore: github stalebot changes (#1271)
* add exempt labels

* update tags for docker-compose
2022-05-24 08:30:07 -08:00
65 changed files with 3234 additions and 2380 deletions

6
.github/stale.yml vendored
View File

@@ -6,8 +6,12 @@ daysUntilClose: 7
exemptLabels: exemptLabels:
- pinned - pinned
- security - security
- early-stages
- "bug: confirmed"
- feedback
- task
# Label to use when marking an issue as stale # Label to use when marking an issue as stale
staleLabel: wontfix staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable # Comment to post when marking an issue as stale. Set to `false` to disable
markComment: > markComment: >
This issue has been automatically marked as stale because it has not had This issue has been automatically marked as stale because it has not had

View File

@@ -5,6 +5,10 @@ on:
branches: branches:
- mealie-next - mealie-next
concurrency:
group: backend-nightly-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -5,6 +5,10 @@ on:
branches: branches:
- mealie-next - mealie-next
concurrency:
group: frontend-nightly-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -46,5 +46,12 @@
"python.linting.mypyEnabled": true, "python.linting.mypyEnabled": true,
"python.sortImports.path": "${workspaceFolder}/.venv/bin/isort", "python.sortImports.path": "${workspaceFolder}/.venv/bin/isort",
"search.mode": "reuseEditor", "search.mode": "reuseEditor",
"python.testing.unittestArgs": ["-v", "-s", "./tests", "-p", "test_*.py"] "python.testing.unittestArgs": ["-v", "-s", "./tests", "-p", "test_*.py"],
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"package.json": "package-lock.json, yarn.lock, .eslintrc.js, tsconfig.json, .prettierrc, .editorconfig",
"pyproject.toml": "poetry.lock, alembic.ini, .pylintrc, .flake8",
"netlify.toml": "runtime.txt",
"docker-compose.yml": "Dockerfile, .dockerignore, docker-compose.dev.yml, docker-compose.yml"
}
} }

View File

@@ -5,12 +5,6 @@
[![MIT License][license-shield]][license-url] [![MIT License][license-shield]][license-url]
[![Docker Pulls][docker-pull]][docker-pull] [![Docker Pulls][docker-pull]][docker-pull]
[![CodeFactor](https://www.codefactor.io/repository/github/hay-kot/mealie/badge)](https://www.codefactor.io/repository/github/hay-kot/mealie) [![CodeFactor](https://www.codefactor.io/repository/github/hay-kot/mealie/badge)](https://www.codefactor.io/repository/github/hay-kot/mealie)
[![Docker Build Production](https://github.com/hay-kot/mealie/actions/workflows/dockerbuild.release.yml/badge.svg)](https://github.com/hay-kot/mealie/actions/workflows/dockerbuild.release.yml)
[![Project Tests Production](https://github.com/hay-kot/mealie/actions/workflows/test-all.yml/badge.svg)](https://github.com/hay-kot/mealie/actions/workflows/test-all.yml)
[![Docker Build Dev](https://github.com/hay-kot/mealie/actions/workflows/dockerbuild.dev.yml/badge.svg?branch=dev)](https://github.com/hay-kot/mealie/actions/workflows/dockerbuild.dev.yml)
[![Project Tests Dev](https://github.com/hay-kot/mealie/actions/workflows/test-all.yml/badge.svg?branch=dev)](https://github.com/hay-kot/mealie/actions/workflows/test-all.yml)
<!-- PROJECT LOGO --> <!-- PROJECT LOGO -->
<br /> <br />
@@ -26,20 +20,14 @@
<p align="center"> <p align="center">
A Place for All Your Recipes A Place for All Your Recipes
<br /> <br />
<a href="https://hay-kot.github.io/mealie/"><strong>Explore the docs »</strong></a> <a href="https://nightly.mealie.io"><strong>Explore the docs »</strong></a>
<a href="https://github.com/hay-kot/mealie"> <a href="https://github.com/hay-kot/mealie">
</a> </a>
<br /> <br />
<a href="https://mealie-demo.hay-kot.dev/">View Demo</a> <a href="https://demo.mealie.io/">View Demo</a>
· ·
<a href="https://github.com/hay-kot/mealie/issues">Report Bug</a> <a href="https://github.com/hay-kot/mealie/issues">Report Bug</a>
· ·
<a href="https://hay-kot.github.io/mealie/api/redoc/">API</a>
·
<a href="https://github.com/hay-kot/mealie/issues">
Request Feature
</a>
·
<a href="https://hub.docker.com/r/hkotel/mealie"> Docker Hub <a href="https://hub.docker.com/r/hkotel/mealie"> Docker Hub
</a> </a>
</p> </p>
@@ -63,7 +51,7 @@ Mealie is a self hosted recipe manager and meal planner with a RestAPI backend a
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**. If you're going to be working on the code-base you'll want to use the nightly documentation to ensure you get the latest information. Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**. If you're going to be working on the code-base you'll want to use the nightly documentation to ensure you get the latest information.
- See the [Contributors Guide](https://nightly.mealie.io/contributors/developers-guide/code-contributions/) for help getting started. - See the [Contributors Guide](https://nightly.mealie.io/contributors/developers-guide/code-contributions/) for help getting started.
- We use VSCode Dev Contains to make it easy for contributors to get started! - We use [VSCode Dev Containers](https://code.visualstudio.com/docs/remote/containers) to make it easy for contributors to get started!
If you are not a coder, you can still contribute financially. financial contributions help me prioritize working on this project over others and helps me know that there is a real demand for project development. If you are not a coder, you can still contribute financially. financial contributions help me prioritize working on this project over others and helps me know that there is a real demand for project development.

View File

@@ -1,3 +1,4 @@
# WARNING: currently not functional, see #756, #1072
# Use root/example as user/password credentials # Use root/example as user/password credentials
version: "3.4" version: "3.4"
services: services:

View File

@@ -1,15 +0,0 @@
{
auto_https off
}
:80 {
root * /srv
encode gzip
uri strip_suffix /
handle {
try_files {path} {path}/ /index.html
file_server
}
}

View File

@@ -1,10 +0,0 @@
FROM python:3.8-slim as build-stage
WORKDIR /app
RUN pip install --no-cache-dir mkdocs mkdocs-material
COPY . .
RUN mkdocs build
FROM caddy:alpine
WORKDIR /app
COPY ./Caddyfile /etc/caddy/Caddyfile
COPY --from=build-stage /app/site /srv

View File

@@ -1,11 +0,0 @@
version: "3"
services:
wiki:
container_name: mealie-docs
image: mealie-docs
ports:
- 8888:80
build:
context: .
dockerfile: Dockerfile
restart: always

View File

@@ -0,0 +1,29 @@
### Bug Fixes
- Bump isomorphic-dompurify from 0.18.0 to 0.19.0 in /frontend ([#1257](https://github.com/orhun/git-cliff/issues/1257))
- Bump @nuxtjs/auth-next in /frontend ([#1265](https://github.com/orhun/git-cliff/issues/1265))
- Bad dev dependency ([#1281](https://github.com/orhun/git-cliff/issues/1281))
- Add touch support for mealplanner delete ([#1298](https://github.com/orhun/git-cliff/issues/1298))
### Documentation
- Add references for VSCode dev containers ([#1299](https://github.com/orhun/git-cliff/issues/1299))
- Docker-compose.dev.yml is currently not functional ([#1300](https://github.com/orhun/git-cliff/issues/1300))
### Features
- Add reports to bulk recipe import (url) ([#1294](https://github.com/orhun/git-cliff/issues/1294))
- Rewrite print implementation to support new ing ([#1305](https://github.com/orhun/git-cliff/issues/1305))
### Miscellaneous Tasks
- Github stalebot changes ([#1271](https://github.com/orhun/git-cliff/issues/1271))
- Bump eslint-plugin-nuxt in /frontend ([#1258](https://github.com/orhun/git-cliff/issues/1258))
- Bump @vue/runtime-dom in /frontend ([#1259](https://github.com/orhun/git-cliff/issues/1259))
- Bump nuxt-vite from 0.1.3 to 0.3.5 in /frontend ([#1260](https://github.com/orhun/git-cliff/issues/1260))
- Bump vue2-script-setup-transform in /frontend ([#1263](https://github.com/orhun/git-cliff/issues/1263))
- Update dev dependencies ([#1282](https://github.com/orhun/git-cliff/issues/1282))
### Refactor
- Split up recipe create page ([#1283](https://github.com/orhun/git-cliff/issues/1283))

View File

@@ -6,7 +6,7 @@
After reading through the [Code Contributions Guide](../developers-guide/code-contributions.md) and forking the repo you can start working. This project is developed with :whale: docker and as such you will be greatly aided by using docker for development. It's not necessary but it is helpful. After reading through the [Code Contributions Guide](../developers-guide/code-contributions.md) and forking the repo you can start working. This project is developed with :whale: docker and as such you will be greatly aided by using docker for development. It's not necessary but it is helpful.
## With VS Code Dev Containers ## With [VSCode Dev Containers](https://code.visualstudio.com/docs/remote/containers)
Prerequisites Prerequisites
@@ -110,7 +110,7 @@ frontend 🎬 Start Mealie Frontend Development Server
frontend-build 🏗 Build Frontend in frontend/dist frontend-build 🏗 Build Frontend in frontend/dist
frontend-generate 🏗 Generate Code for Frontend frontend-generate 🏗 Generate Code for Frontend
frontend-lint 🧺 Run yarn lint frontend-lint 🧺 Run yarn lint
docker-dev 🐳 Build and Start Docker Development Stack docker-dev 🐳 Build and Start Docker Development Stack (currently not functional, see #756, #1072)
docker-prod 🐳 Build and Start Docker Production Stack docker-prod 🐳 Build and Start Docker Production Stack
``` ```

View File

@@ -8,7 +8,7 @@
Mealie offers two main ways to create recipes. You can use the integrated recipe-scraper to create recipes from hundreds of websites, or you can create recipes manually using the recipe editor. Mealie offers two main ways to create recipes. You can use the integrated recipe-scraper to create recipes from hundreds of websites, or you can create recipes manually using the recipe editor.
[Demo](https://beta.mealie.io/recipe/create?tab=url){ .md-button .md-button--primary .align-right } [Demo](https://beta.mealie.io/recipe/create/url){ .md-button .md-button--primary .align-right }
### Importing Recipes ### Importing Recipes

View File

@@ -10,7 +10,7 @@
version: "3.7" version: "3.7"
services: services:
mealie-frontend: mealie-frontend:
image: hkotel/mealie:frontend-nightly image: hkotel/mealie:frontend-v1.0.0-beta-1
container_name: mealie-frontend container_name: mealie-frontend
depends_on: depends_on:
- mealie-api - mealie-api
@@ -23,7 +23,7 @@ services:
volumes: volumes:
- mealie-data:/app/data/ # (3) - mealie-data:/app/data/ # (3)
mealie-api: mealie-api:
image: hkotel/mealie:api-nightly image: hkotel/mealie:api-v1.0.0-beta-1
container_name: mealie-api container_name: mealie-api
depends_on: depends_on:
- postgres - postgres

View File

@@ -12,7 +12,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
version: "3.7" version: "3.7"
services: services:
mealie-frontend: mealie-frontend:
image: hkotel/mealie:frontend-nightly image: hkotel/mealie:frontend-v1.0.0-beta-1
container_name: mealie-frontend container_name: mealie-frontend
environment: environment:
# Set Frontend ENV Variables Here # Set Frontend ENV Variables Here
@@ -23,7 +23,7 @@ services:
volumes: volumes:
- mealie-data:/app/data/ # (3) - mealie-data:/app/data/ # (3)
mealie-api: mealie-api:
image: hkotel/mealie:api-nightly image: hkotel/mealie:api-v1.0.0-beta-1
container_name: mealie-api container_name: mealie-api
volumes: volumes:
- mealie-data:/app/data/ - mealie-data:/app/data/

File diff suppressed because one or more lines are too long

View File

@@ -88,6 +88,7 @@ nav:
- Improving Ingredient Parser: "contributors/guides/ingredient-parser.md" - Improving Ingredient Parser: "contributors/guides/ingredient-parser.md"
- Change Log: - Change Log:
- v1.0.0beta-2: "changelog/v1.0.0beta-2.md"
- v1.0.0 Beta: "changelog/v1.0.0.md" - v1.0.0 Beta: "changelog/v1.0.0.md"
- v0.5.2 Misc Updates: "changelog/v0.5.2.md" - v0.5.2 Misc Updates: "changelog/v0.5.2.md"
- v0.5.1 Bug Fixes: "changelog/v0.5.1.md" - v0.5.1 Bug Fixes: "changelog/v0.5.1.md"

View File

@@ -1,5 +1,8 @@
module.exports = { module.exports = {
root: true, root: true,
settings: {
"import/ignore": ["@vueuse*"],
},
env: { env: {
browser: true, browser: true,
node: true, node: true,
@@ -35,6 +38,7 @@ module.exports = {
"vue/singleline-html-element-content-newline": "off", "vue/singleline-html-element-content-newline": "off",
"vue/multiline-html-element-content-newline": "off", "vue/multiline-html-element-content-newline": "off",
"vue/no-mutating-props": "off", "vue/no-mutating-props": "off",
"vue/no-v-text-v-html-on-component": "warn",
"vue/no-v-for-template-key-on-child": "off", "vue/no-v-for-template-key-on-child": "off",
"vue/valid-v-slot": [ "vue/valid-v-slot": [
"error", "error",

View File

@@ -28,9 +28,13 @@
</v-list-item-avatar> </v-list-item-avatar>
<v-list-item-content> <v-list-item-content>
<v-list-item-title v-text="item.title"></v-list-item-title> <v-list-item-title>
{{ item.title }}
</v-list-item-title>
<v-list-item-subtitle v-text="item.text"></v-list-item-subtitle> <v-list-item-subtitle>
{{ item.text }}
</v-list-item-subtitle>
<v-list-item-subtitle> <v-list-item-subtitle>
{{ $d(Date.parse(item.timeStamp), "long") }} {{ $d(Date.parse(item.timeStamp), "long") }}
</v-list-item-subtitle> </v-list-item-subtitle>
@@ -103,5 +107,4 @@ export default defineComponent({
}); });
</script> </script>
<style scoped> <style scoped></style>
</style>

View File

@@ -10,13 +10,17 @@
<v-list-item-icon class="ma-auto"> <v-list-item-icon class="ma-auto">
<v-tooltip bottom> <v-tooltip bottom>
<template #activator="{ on, attrs }"> <template #activator="{ on, attrs }">
<v-icon v-bind="attrs" v-on="on" v-text="getIconDefinition(item.icon).icon"></v-icon> <v-icon v-bind="attrs" v-on="on">
{{ getIconDefinition(item.icon).icon }}
</v-icon>
</template> </template>
<span>{{ getIconDefinition(item.icon).title }}</span> <span>{{ getIconDefinition(item.icon).title }}</span>
</v-tooltip> </v-tooltip>
</v-list-item-icon> </v-list-item-icon>
<v-list-item-content> <v-list-item-content>
<v-list-item-title class="pl-2" v-text="item.name"></v-list-item-title> <v-list-item-title class="pl-2">
{{ item.name }}
</v-list-item-title>
</v-list-item-content> </v-list-item-content>
<v-list-item-action> <v-list-item-action>
<v-btn v-if="!edit" color="primary" icon :href="assetURL(item.fileName)" target="_blank" top> <v-btn v-if="!edit" color="primary" icon :href="assetURL(item.fileName)" target="_blank" top>
@@ -35,21 +39,21 @@
<div class="d-flex ml-auto mt-2"> <div class="d-flex ml-auto mt-2">
<v-spacer></v-spacer> <v-spacer></v-spacer>
<BaseDialog <BaseDialog
v-model="newAssetDialog" v-model="state.newAssetDialog"
:title="$t('asset.new-asset')" :title="$tc('asset.new-asset')"
:icon="getIconDefinition(newAsset.icon).icon" :icon="getIconDefinition(state.newAsset.icon).icon"
@submit="addAsset" @submit="addAsset"
> >
<template #activator> <template #activator>
<BaseButton v-if="edit" small create @click="newAssetDialog = true" /> <BaseButton v-if="edit" small create @click="newAssetDialog = true" />
</template> </template>
<v-card-text class="pt-4"> <v-card-text class="pt-4">
<v-text-field v-model="newAsset.name" dense :label="$t('general.name')"></v-text-field> <v-text-field v-model="state.newAsset.name" dense :label="$t('general.name')"></v-text-field>
<div class="d-flex justify-space-between"> <div class="d-flex justify-space-between">
<v-select <v-select
v-model="newAsset.icon" v-model="state.newAsset.icon"
dense dense
:prepend-icon="getIconDefinition(newAsset.icon).icon" :prepend-icon="getIconDefinition(state.newAsset.icon).icon"
:items="iconOptions" :items="iconOptions"
item-text="title" item-text="title"
item-value="name" item-value="name"
@@ -66,7 +70,7 @@
</v-select> </v-select>
<AppButtonUpload :post="false" file-name="file" :text-btn="false" @uploaded="setFileObject" /> <AppButtonUpload :post="false" file-name="file" :text-btn="false" @uploaded="setFileObject" />
</div> </div>
{{ fileObject.name }} {{ state.fileObject.name }}
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
</div> </div>
@@ -74,9 +78,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, reactive, toRefs, useContext } from "@nuxtjs/composition-api"; import { defineComponent, reactive, useContext } from "@nuxtjs/composition-api";
import { useStaticRoutes, useUserApi } from "~/composables/api"; import { useStaticRoutes, useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { RecipeAsset } from "~/types/api-types/recipe";
const BASE_URL = window.location.origin; const BASE_URL = window.location.origin;
@@ -91,7 +96,7 @@ export default defineComponent({
required: true, required: true,
}, },
value: { value: {
type: Array, type: Array as () => RecipeAsset[],
required: true, required: true,
}, },
edit: { edit: {
@@ -181,7 +186,7 @@ export default defineComponent({
} }
return { return {
...toRefs(state), state,
addAsset, addAsset,
assetURL, assetURL,
assetEmbed, assetEmbed,

View File

@@ -29,7 +29,9 @@
<v-list dense> <v-list dense>
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)"> <v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
<v-list-item-icon> <v-list-item-icon>
<v-icon :color="item.color" v-text="item.icon"></v-icon> <v-icon :color="item.color">
{{ item.icon }}
</v-icon>
</v-list-item-icon> </v-list-item-icon>
<v-list-item-title>{{ item.title }}</v-list-item-title> <v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item> </v-list-item>

View File

@@ -81,7 +81,7 @@
<v-list dense> <v-list dense>
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)"> <v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
<v-list-item-icon> <v-list-item-icon>
<v-icon :color="item.color" v-text="item.icon"></v-icon> <v-icon :color="item.color"> {{ item.icon }} </v-icon>
</v-list-item-icon> </v-list-item-icon>
<v-list-item-title>{{ item.title }}</v-list-item-title> <v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item> </v-list-item>
@@ -307,7 +307,7 @@ export default defineComponent({
} }
// Note: Print is handled as an event in the parent component // Note: Print is handled as an event in the parent component
const eventHandlers: { [key: string]: () => void } = { const eventHandlers: { [key: string]: () => void | Promise<any> } = {
delete: () => { delete: () => {
state.recipeDeleteDialog = true; state.recipeDeleteDialog = true;
}, },

View File

@@ -32,7 +32,9 @@
<img src="https://i.pravatar.cc/300" alt="John" /> <img src="https://i.pravatar.cc/300" alt="John" />
</v-list-item-avatar> </v-list-item-avatar>
<v-list-item-content> <v-list-item-content>
<v-list-item-title v-text="getMember(item.userId)"></v-list-item-title> <v-list-item-title>
{{ getMember(item.userId) }}
</v-list-item-title>
</v-list-item-content> </v-list-item-content>
</v-list-item> </v-list-item>
</template> </template>
@@ -95,31 +97,30 @@ export default defineComponent({
context.emit(INPUT_EVENT, value); context.emit(INPUT_EVENT, value);
} }
const show = props.showHeaders;
const headers = computed(() => { const headers = computed(() => {
const hdrs = []; const hdrs = [];
if (show.id) { if (props.showHeaders.id) {
hdrs.push({ text: "Id", value: "id" }); hdrs.push({ text: "Id", value: "id" });
} }
if (show.owner) { if (props.showHeaders.owner) {
hdrs.push({ text: "Owner", value: "userId", align: "center" }); hdrs.push({ text: "Owner", value: "userId", align: "center" });
} }
hdrs.push({ text: "Name", value: "name" }); hdrs.push({ text: "Name", value: "name" });
if (show.categories) { if (props.showHeaders.categories) {
hdrs.push({ text: "Categories", value: "recipeCategory" }); hdrs.push({ text: "Categories", value: "recipeCategory" });
} }
if (show.tags) { if (props.showHeaders.tags) {
hdrs.push({ text: "Tags", value: "tags" }); hdrs.push({ text: "Tags", value: "tags" });
} }
if (show.tools) { if (props.showHeaders.tools) {
hdrs.push({ text: "Tools", value: "tools" }); hdrs.push({ text: "Tools", value: "tools" });
} }
if (show.recipeYield) { if (props.showHeaders.recipeYield) {
hdrs.push({ text: "Yield", value: "recipeYield" }); hdrs.push({ text: "Yield", value: "recipeYield" });
} }
if (show.dateAdded) { if (props.showHeaders.dateAdded) {
hdrs.push({ text: "Date Added", value: "dateAdded" }); hdrs.push({ text: "Date Added", value: "dateAdded" });
} }

View File

@@ -57,8 +57,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, computed, toRefs, reactive, useContext } from "@nuxtjs/composition-api"; import { defineComponent, computed, toRefs, reactive, useContext } from "@nuxtjs/composition-api";
import { whenever } from "@vueuse/shared"; import { useClipboard, useShare, whenever } from "@vueuse/core";
import { useClipboard, useShare } from "@vueuse/core";
import { RecipeShareToken } from "~/types/api-types/recipe"; import { RecipeShareToken } from "~/types/api-types/recipe";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";

View File

@@ -134,8 +134,6 @@ export default defineComponent({
}, },
}, },
setup(props) { setup(props) {
const { value } = props;
// ================================================== // ==================================================
// Foods // Foods
const { foods, workingFoodData, actions: foodActions } = useFoods(); const { foods, workingFoodData, actions: foodActions } = useFoods();
@@ -144,7 +142,7 @@ export default defineComponent({
async function createAssignFood() { async function createAssignFood() {
workingFoodData.name = foodSearch.value; workingFoodData.name = foodSearch.value;
await foodActions.createOne(); await foodActions.createOne();
value.food = foods.value?.find((food) => food.name === foodSearch.value); props.value.food = foods.value?.find((food) => food.name === foodSearch.value);
} }
// ================================================== // ==================================================
@@ -155,7 +153,7 @@ export default defineComponent({
async function createAssignUnit() { async function createAssignUnit() {
workingUnitData.name = unitSearch.value; workingUnitData.name = unitSearch.value;
await unitActions.createOne(); await unitActions.createOne();
value.unit = units.value?.find((unit) => unit.name === unitSearch.value); props.value.unit = units.value?.find((unit) => unit.name === unitSearch.value);
} }
const state = reactive({ const state = reactive({
@@ -165,7 +163,7 @@ export default defineComponent({
function toggleTitle() { function toggleTitle() {
if (state.showTitle) { if (state.showTitle) {
value.title = ""; props.value.title = "";
} }
state.showTitle = !state.showTitle; state.showTitle = !state.showTitle;
} }
@@ -175,13 +173,21 @@ export default defineComponent({
} }
function handleUnitEnter() { function handleUnitEnter() {
if (value.unit === undefined || value.unit === null || !value.unit.name.includes(unitSearch.value)) { if (
props.value.unit === undefined ||
props.value.unit === null ||
!props.value.unit.name.includes(unitSearch.value)
) {
createAssignUnit(); createAssignUnit();
} }
} }
function handleFoodEnter() { function handleFoodEnter() {
if (value.food === undefined || value.food === null || !value.food.name.includes(foodSearch.value)) { if (
props.value.food === undefined ||
props.value.food === null ||
!props.value.food.name.includes(foodSearch.value)
) {
createAssignFood(); createAssignFood();
} }
} }
@@ -202,7 +208,7 @@ export default defineComponent({
// }); // });
// } // }
if (value.originalText) { if (props.value.originalText) {
options.push({ options.push({
text: "See Original Text", text: "See Original Text",
event: "toggle-original", event: "toggle-original",

View File

@@ -92,7 +92,7 @@
@click="toggleCollapseSection(index)" @click="toggleCollapseSection(index)"
> >
<v-toolbar-title v-if="!edit" class="headline"> <v-toolbar-title v-if="!edit" class="headline">
<v-app-bar-title v-text="step.title"> </v-app-bar-title> <v-app-bar-title> {{ step.title }} </v-app-bar-title>
</v-toolbar-title> </v-toolbar-title>
<v-text-field <v-text-field
v-if="edit" v-if="edit"

View File

@@ -1,68 +1,58 @@
<template> <template>
<div> <div class="print-container">
<div v-if="recipe" class="container print"> <section>
<div> <v-card-title class="headline pl-0">
<h1> <v-icon left color="primary">
<svg class="icon" viewBox="0 0 24 24"> {{ $globals.icons.primary }}
<path </v-icon>
fill="#E58325" {{ recipe.name }}
d="M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.36,10.22L13.16,10C12.38,9.23 12.38,7.97 13.16,7.19L17.5,2.82L18.43,3.74L15.19,7L16.15,7.94L19.39,4.69L20.31,5.61L17.06,8.85L18,9.81L21.26,6.56L22.18,7.5L17.81,11.84C17.03,12.62 15.77,12.62 15,11.84L14.78,11.64L13.41,13Z" </v-card-title>
/> <RecipeTimeCard :prep-time="recipe.prepTime" :total-time="recipe.totalTime" :perform-time="recipe.performTime" />
</svg> </section>
{{ recipe.name }}
</h1> <v-card-text class="px-0">
</div> <VueMarkdown :source="recipe.description" />
<div class="time-container"> </v-card-text>
<RecipeTimeCard
:prep-time="recipe.prepTime" <section>
:total-time="recipe.totalTime" <v-card-title class="headline pl-0"> {{ $t("recipe.ingredients") }} </v-card-title>
:perform-time="recipe.performTime" <div class="ingredient-grid">
/> <div class="ingredient-col-1">
</div> <ul>
<v-btn <li v-for="(text, index) in splitIngredients.value.firstHalf" :key="index">
v-if="recipe.recipeYield" {{ text }}
dense </li>
small </ul>
:hover="false"
type="label"
:ripple="false"
elevation="0"
color="secondary darken-1"
class="rounded-sm static"
>
{{ recipe.recipeYield }}
</v-btn>
<div>
<VueMarkdown :source="recipe.description"> </VueMarkdown>
<h2>{{ $t("recipe.ingredients") }}</h2>
<ul>
<li v-for="(ingredient, index) in recipe.recipeIngredient" :key="index">
<v-icon>
{{ $globals.icons.checkboxBlankOutline }}
</v-icon>
<p>{{ ingredient.note }}</p>
</li>
</ul>
</div>
<div>
<h2>{{ $t("recipe.instructions") }}</h2>
<div v-for="(step, index) in recipe.recipeInstructions" :key="index">
<h2 v-if="step.title">{{ step.title }}</h2>
<div class="ml-5">
<h3>{{ $t("recipe.step-index", { step: index + 1 }) }}</h3>
<VueMarkdown :source="step.text"> </VueMarkdown>
</div>
</div> </div>
<div class="ingredient-col-2">
<br /> <ul>
<v-divider v-if="recipe.notes.length > 0" class="mb-5 mt-0"></v-divider> <li v-for="(text, index) in splitIngredients.value.secondHalf" :key="index">
{{ text }}
<div v-for="(note, index) in recipe.notes" :key="index + 'note'"> </li>
<h3>{{ note.title }}</h3> </ul>
<VueMarkdown :source="note.text"> </VueMarkdown>
</div> </div>
</div> </div>
</div> </section>
<section>
<v-card-title class="headline pl-0">{{ $t("recipe.instructions") }}</v-card-title>
<div v-for="(step, index) in recipe.recipeInstructions" :key="index">
<h3 v-if="step.title" class="mb-2">{{ step.title }}</h3>
<div class="ml-5">
<h4>{{ $t("recipe.step-index", { step: index + 1 }) }}</h4>
<VueMarkdown :source="step.text" />
</div>
</div>
</section>
<v-divider v-if="hasNotes" class="grey my-4"></v-divider>
<section>
<div v-for="(note, index) in recipe.notes" :key="index + 'note'">
<h4>{{ note.title }}</h4>
<VueMarkdown :source="note.text" />
</div>
</section>
</div> </div>
</template> </template>
@@ -70,8 +60,16 @@
import { defineComponent } from "@nuxtjs/composition-api"; import { defineComponent } from "@nuxtjs/composition-api";
// @ts-ignore vue-markdown has no types // @ts-ignore vue-markdown has no types
import VueMarkdown from "@adapttive/vue-markdown"; import VueMarkdown from "@adapttive/vue-markdown";
import { computed } from "@vue/reactivity";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue"; import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import { Recipe } from "~/types/api-types/recipe"; import { Recipe } from "~/types/api-types/recipe";
import { parseIngredientText } from "~/composables/recipes";
type SplitIngredients = {
firstHalf: string[];
secondHalf: string[];
};
export default defineComponent({ export default defineComponent({
components: { components: {
RecipeTimeCard, RecipeTimeCard,
@@ -83,6 +81,36 @@ export default defineComponent({
required: true, required: true,
}, },
}, },
setup(props) {
const splitIngredients = computed<SplitIngredients>(() => {
const firstHalf = props.recipe.recipeIngredient
?.slice(0, Math.ceil(props.recipe.recipeIngredient.length / 2))
.map((ingredient) => {
return parseIngredientText(ingredient, props.recipe?.settings?.disableAmount || false);
});
const secondHalf = props.recipe.recipeIngredient
?.slice(Math.ceil(props.recipe.recipeIngredient.length / 2))
.map((ingredient) => {
return parseIngredientText(ingredient, props.recipe?.settings?.disableAmount || false);
});
return {
firstHalf: firstHalf || [],
secondHalf: secondHalf || [],
};
});
const hasNotes = computed(() => {
return props.recipe.notes && props.recipe.notes.length > 0;
});
return {
hasNotes,
splitIngredients,
parseIngredientText,
};
},
}); });
</script> </script>
@@ -90,26 +118,46 @@ export default defineComponent({
@media print { @media print {
body, body,
html { html {
margin-top: -40px !important; margin-top: 0 !important;
}
.print-container {
display: block !important;
}
.v-main {
display: block;
}
.v-main__wrap {
position: absolute;
top: 0;
left: 0;
} }
} }
</style>
h1 { <style scoped>
margin-top: 0 !important; .print-container {
display: -webkit-box; display: none;
display: flex; background-color: white;
font-size: 2rem; color: black !important;
letter-spacing: -0.015625em;
font-weight: 300;
padding: 0;
} }
h2 { p {
margin-bottom: 0.25rem; padding-bottom: 0 !important;
margin-bottom: 0 !important;
} }
h3 { .v-card__text {
margin-bottom: 0.25rem; padding-bottom: 0;
margin-bottom: 0;
}
.ingredient-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 1rem;
} }
ul { ul {
@@ -117,51 +165,7 @@ ul {
} }
li { li {
display: -webkit-box; list-style-type: none;
display: -webkit-flex; margin-bottom: 0.25rem;
margin-left: 0;
margin-bottom: 0.5rem;
}
li p {
margin-left: 0.25rem;
margin-bottom: 0 !important;
}
p {
margin: 0;
font-size: 1rem;
letter-spacing: 0.03125em;
font-weight: 400;
}
.icon {
margin-top: auto;
margin-bottom: auto;
margin-right: 0.5rem;
height: 3rem;
width: 3rem;
}
.time-container {
display: flex;
justify-content: left;
}
.time-chip {
border-radius: 0.25rem;
border-color: black;
border: 1px;
border-top: 1px;
}
.print {
display: none;
}
@media print {
.print {
display: initial;
}
} }
</style> </style>

View File

@@ -145,6 +145,8 @@ import { AutoFormItems } from "~/types/auto-forms";
const BLUR_EVENT = "blur"; const BLUR_EVENT = "blur";
type ValidatorKey = keyof typeof validators;
export default defineComponent({ export default defineComponent({
name: "AutoForm", name: "AutoForm",
props: { props: {
@@ -178,7 +180,7 @@ export default defineComponent({
}, },
}, },
setup(props, context) { setup(props, context) {
function rulesByKey(keys?: string[] | null) { function rulesByKey(keys?: ValidatorKey[] | null) {
if (keys === undefined || keys === null) { if (keys === undefined || keys === null) {
return []; return [];
} }
@@ -193,7 +195,7 @@ export default defineComponent({
return list; return list;
} }
const defaultRules = computed(() => rulesByKey(props.globalRules)); const defaultRules = computed(() => rulesByKey(props.globalRules as ValidatorKey[]));
function removeByIndex(list: never[], index: number) { function removeByIndex(list: never[], index: number) {
// Removes the item at the index // Removes the item at the index

View File

@@ -10,7 +10,7 @@
class="text-start v-card--material__heading mb-n6 mt-n10 pa-7" class="text-start v-card--material__heading mb-n6 mt-n10 pa-7"
dark dark
> >
<v-icon v-if="icon" size="40" v-text="icon" /> <v-icon v-if="icon" size="40"> {{ icon }} </v-icon>
<div v-if="text" class="headline font-weight-thin" v-text="text" /> <div v-if="text" class="headline font-weight-thin" v-text="text" />
</v-sheet> </v-sheet>
</slot> </slot>

View File

@@ -2,18 +2,10 @@
<BaseDialog v-model="dialog" :icon="$globals.icons.translate" :title="$tc('language-dialog.choose-language')"> <BaseDialog v-model="dialog" :icon="$globals.icons.translate" :title="$tc('language-dialog.choose-language')">
<v-card-text> <v-card-text>
{{ $t("language-dialog.select-description") }} {{ $t("language-dialog.select-description") }}
<v-autocomplete <v-autocomplete v-model="locale" :items="locales" item-text="name" class="my-3" hide-details outlined offset>
v-model="locale"
:items="locales"
item-text="name"
class="my-3"
hide-details
outlined
offset
>
<template #item="{ item }"> <template #item="{ item }">
<v-list-item-content> <v-list-item-content>
<v-list-item-title v-text="item.name"></v-list-item-title> <v-list-item-title> {{ item.name }} </v-list-item-title>
<v-list-item-subtitle> {{ item.progress }}% {{ $tc("language-dialog.translated") }} </v-list-item-subtitle> <v-list-item-subtitle> {{ item.progress }}% {{ $tc("language-dialog.translated") }} </v-list-item-subtitle>
</v-list-item-content> </v-list-item-content>
</template> </template>

View File

@@ -7,7 +7,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, watch } from "@nuxtjs/composition-api"; import { defineComponent, watch } from "@nuxtjs/composition-api";
import { useToggle } from "@vueuse/shared"; import { useToggle } from "@vueuse/core";
export default defineComponent({ export default defineComponent({
props: { props: {
@@ -34,4 +34,3 @@ export default defineComponent({
}, },
}); });
</script> </script>

View File

@@ -1,6 +1,6 @@
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useContext } from "@nuxtjs/composition-api"; import { useContext } from "@nuxtjs/composition-api";
import { NuxtAxiosInstance } from "@nuxtjs/axios"; import type { NuxtAxiosInstance } from "@nuxtjs/axios";
import { AdminAPI, Api } from "~/api"; import { AdminAPI, Api } from "~/api";
import { ApiRequestInstance, RequestResponse } from "~/types/api"; import { ApiRequestInstance, RequestResponse } from "~/types/api";
import { PublicApi } from "~/api/public-api"; import { PublicApi } from "~/api/public-api";

View File

@@ -3,7 +3,7 @@
"about": "Über", "about": "Über",
"about-mealie": "Über Mealie", "about-mealie": "Über Mealie",
"api-docs": "API Dokumentation", "api-docs": "API Dokumentation",
"api-port": "API Port", "api-port": "API-Port",
"application-mode": "Anwendungsmodus", "application-mode": "Anwendungsmodus",
"database-type": "Datenbanktyp", "database-type": "Datenbanktyp",
"database-url": "Datenbank URL", "database-url": "Datenbank URL",
@@ -33,7 +33,7 @@
"show-assets": "Anhänge anzeigen" "show-assets": "Anhänge anzeigen"
}, },
"category": { "category": {
"categories": "Categories", "categories": "Kategorien",
"category-created": "Kategorie angelegt", "category-created": "Kategorie angelegt",
"category-creation-failed": "Anlegen der Kategorie fehlgeschlagen", "category-creation-failed": "Anlegen der Kategorie fehlgeschlagen",
"category-deleted": "Kategorie entfernt", "category-deleted": "Kategorie entfernt",
@@ -44,7 +44,7 @@
"uncategorized-count": "{count} nicht kategorisierte" "uncategorized-count": "{count} nicht kategorisierte"
}, },
"events": { "events": {
"apprise-url": "Apprise URL", "apprise-url": "Apprise-URL",
"database": "Datenbank", "database": "Datenbank",
"delete-event": "Ereignis löschen", "delete-event": "Ereignis löschen",
"new-notification-form-description": "Mealie verwendet die Apprise-Bibliothek, um Benachrichtigungen zu erzeugen. Sie bietet viele Optionen für Dienste an, die für Benachrichtigungen genutzt werden können. Werfe einen Blick in ihr Wiki für eine umfassende Anleitung zum Erstellen der URL für Ihren Dienst. Falls verfügbar, kann die Auswahl des Benachrichtigungstyps zusätzliche Funktionen enthalten.", "new-notification-form-description": "Mealie verwendet die Apprise-Bibliothek, um Benachrichtigungen zu erzeugen. Sie bietet viele Optionen für Dienste an, die für Benachrichtigungen genutzt werden können. Werfe einen Blick in ihr Wiki für eine umfassende Anleitung zum Erstellen der URL für Ihren Dienst. Falls verfügbar, kann die Auswahl des Benachrichtigungstyps zusätzliche Funktionen enthalten.",
@@ -66,7 +66,7 @@
"create": "Erstellen", "create": "Erstellen",
"created": "Erstellt", "created": "Erstellt",
"custom": "Benutzerdefiniert", "custom": "Benutzerdefiniert",
"dashboard": "Dashboard", "dashboard": "Übersicht",
"delete": "Löschen", "delete": "Löschen",
"disabled": "Deaktiviert", "disabled": "Deaktiviert",
"download": "Herunterladen", "download": "Herunterladen",
@@ -88,7 +88,7 @@
"image-upload-failed": "Das Bild konnte nicht hochgeladen werden", "image-upload-failed": "Das Bild konnte nicht hochgeladen werden",
"import": "Importieren", "import": "Importieren",
"json": "JSON", "json": "JSON",
"keyword": "Keyword", "keyword": "Schlüsselwort",
"link-copied": "Link kopiert", "link-copied": "Link kopiert",
"loading-recipes": "Lade Rezepte", "loading-recipes": "Lade Rezepte",
"monday": "Montag", "monday": "Montag",
@@ -118,7 +118,7 @@
"success-count": "Erfolgreich: {count}", "success-count": "Erfolgreich: {count}",
"sunday": "Sonntag", "sunday": "Sonntag",
"templates": "Vorlagen:", "templates": "Vorlagen:",
"test": "Test", "test": "Testen",
"themes": "Themen", "themes": "Themen",
"thursday": "Donnerstag", "thursday": "Donnerstag",
"token": "Token", "token": "Token",
@@ -131,10 +131,10 @@
"view": "Ansicht", "view": "Ansicht",
"wednesday": "Mittwoch", "wednesday": "Mittwoch",
"yes": "Ja", "yes": "Ja",
"foods": "Foods", "foods": "Speisen",
"units": "Units", "units": "Einheiten",
"back": "Back", "back": "Zurück",
"next": "Next" "next": "Weiter"
}, },
"group": { "group": {
"are-you-sure-you-want-to-delete-the-group": "Bist du dir sicher, dass du die Gruppe <b>{groupName}<b/> löschen möchtest?", "are-you-sure-you-want-to-delete-the-group": "Bist du dir sicher, dass du die Gruppe <b>{groupName}<b/> löschen möchtest?",
@@ -156,8 +156,8 @@
"user-group-created": "Benutzergruppe angelegt", "user-group-created": "Benutzergruppe angelegt",
"user-group-creation-failed": "Anlegen der Benutzergruppe fehlgeschlagen", "user-group-creation-failed": "Anlegen der Benutzergruppe fehlgeschlagen",
"settings": { "settings": {
"keep-my-recipes-private": "Keep My Recipes Private", "keep-my-recipes-private": "Meine Rezepte privat halten",
"keep-my-recipes-private-description": "Sets your group and all recipes defaults to private. You can always change this later." "keep-my-recipes-private-description": "Setzt Ihre Gruppe und alle Rezepte standardmäßig privat. Sie können dies später jederzeit ändern."
} }
}, },
"meal-plan": { "meal-plan": {
@@ -206,7 +206,7 @@
"error-details": "Mealie kann Rezepte nur von Webseiten importieren, die Id+json oder Mikrodaten enthalten. Die meisten großen Rezeptwebseiten unterstützen diese Datenstruktur. Wenn das Rezept nicht importiert werden kann, aber JSON-Daten im Log vorhanden sind, melde es bitte mit der URL und diesen Daten auf GitHub.", "error-details": "Mealie kann Rezepte nur von Webseiten importieren, die Id+json oder Mikrodaten enthalten. Die meisten großen Rezeptwebseiten unterstützen diese Datenstruktur. Wenn das Rezept nicht importiert werden kann, aber JSON-Daten im Log vorhanden sind, melde es bitte mit der URL und diesen Daten auf GitHub.",
"error-title": "Anscheinend konnten wir nichts finden", "error-title": "Anscheinend konnten wir nichts finden",
"from-url": "Von URL", "from-url": "Von URL",
"github-issues": "GitHub Issues", "github-issues": "GitHub Fehlermeldungen",
"google-ld-json-info": "Google ld+json Info", "google-ld-json-info": "Google ld+json Info",
"must-be-a-valid-url": "Muss eine gültige URL sein", "must-be-a-valid-url": "Muss eine gültige URL sein",
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Füge deine Rezeptdaten ein. Jede Zeile wird als Eintrag in einer Liste dargestellt", "paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Füge deine Rezeptdaten ein. Jede Zeile wird als Eintrag in einer Liste dargestellt",
@@ -216,9 +216,9 @@
"upload-individual-zip-file": "Lade eine individuelle .zip-Datei hoch, die von einer anderen Mealie-Instanz exportiert wird.", "upload-individual-zip-file": "Lade eine individuelle .zip-Datei hoch, die von einer anderen Mealie-Instanz exportiert wird.",
"url-form-hint": "Kopiere einen Link von deiner Lieblingsrezept-Website und füge ihn ein", "url-form-hint": "Kopiere einen Link von deiner Lieblingsrezept-Website und füge ihn ein",
"view-scraped-data": "Gesammelte Daten anzeigen", "view-scraped-data": "Gesammelte Daten anzeigen",
"trim-whitespace-description": "Trim leading and trailing whitespace as well as blank lines", "trim-whitespace-description": "Leerzeichen am Anfang und Ende sowie leere Zeilen entfernen",
"trim-prefix-description": "Trim first character from each line", "trim-prefix-description": "Erste Zeichen aus jeder Zeile entfernen",
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns" "split-by-numbered-line-description": "Absätze nach dem Schema '1)' oder '1.' aufzuteilen versuchen"
}, },
"page": { "page": {
"404-page-not-found": "404 Seite nicht gefunden", "404-page-not-found": "404 Seite nicht gefunden",
@@ -291,7 +291,7 @@
"title": "Titel", "title": "Titel",
"total-time": "Gesamtzeit", "total-time": "Gesamtzeit",
"unable-to-delete-recipe": "Rezept kann nicht gelöscht werden", "unable-to-delete-recipe": "Rezept kann nicht gelöscht werden",
"no-recipe": "No Recipe" "no-recipe": "Kein Rezept"
}, },
"search": { "search": {
"advanced-search": "Erweiterte Suche", "advanced-search": "Erweiterte Suche",
@@ -396,7 +396,7 @@
"webhooks": { "webhooks": {
"test-webhooks": "Teste Webhooks", "test-webhooks": "Teste Webhooks",
"the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at": "Die unten stehenden URL's erhalten Webhooks welche die Rezeptdaten für den Menüplan am geplanten Tag enthalten. Derzeit werden die Webhooks ausgeführt um", "the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at": "Die unten stehenden URL's erhalten Webhooks welche die Rezeptdaten für den Menüplan am geplanten Tag enthalten. Derzeit werden die Webhooks ausgeführt um",
"webhook-url": "Webhook URL", "webhook-url": "Webhook-URL",
"webhooks-caps": "WEBHOOKS", "webhooks-caps": "WEBHOOKS",
"webhooks": "Webhooks" "webhooks": "Webhooks"
} }
@@ -413,9 +413,9 @@
}, },
"sidebar": { "sidebar": {
"all-recipes": "Alle Rezepte", "all-recipes": "Alle Rezepte",
"backups": "Backups", "backups": "Sicherungen",
"categories": "Kategorien", "categories": "Kategorien",
"cookbooks": "Cookbooks", "cookbooks": "Kochbücher",
"dashboard": "Übersicht", "dashboard": "Übersicht",
"home-page": "Startseite", "home-page": "Startseite",
"manage-users": "Benutzer", "manage-users": "Benutzer",
@@ -425,7 +425,7 @@
"site-settings": "Einstellungen", "site-settings": "Einstellungen",
"tags": "Schlagworte", "tags": "Schlagworte",
"toolbox": "Werkzeuge", "toolbox": "Werkzeuge",
"language": "Language" "language": "Sprache"
}, },
"signup": { "signup": {
"error-signing-up": "Fehler beim Registrieren", "error-signing-up": "Fehler beim Registrieren",
@@ -448,7 +448,7 @@
"untagged-count": "{count} ohne Schlagworte" "untagged-count": "{count} ohne Schlagworte"
}, },
"tool": { "tool": {
"tools": "Tools" "tools": "Werkzeuge"
}, },
"user": { "user": {
"admin": "Admin", "admin": "Admin",
@@ -467,7 +467,7 @@
"error-cannot-delete-super-user": "Fehler! Super Benutzer kann nicht gelöscht werden", "error-cannot-delete-super-user": "Fehler! Super Benutzer kann nicht gelöscht werden",
"existing-password-does-not-match": "Bestehendes Passwort stimmt nicht überein", "existing-password-does-not-match": "Bestehendes Passwort stimmt nicht überein",
"full-name": "Vollständiger Name", "full-name": "Vollständiger Name",
"invite-only": "Invite Only", "invite-only": "Nur auf Einladung",
"link-id": "Linkkennung", "link-id": "Linkkennung",
"link-name": "Linkname", "link-name": "Linkname",
"login": "Anmeldung", "login": "Anmeldung",
@@ -480,8 +480,8 @@
"password-reset-failed": "Zurücksetzen des Passworts fehlgeschlagen", "password-reset-failed": "Zurücksetzen des Passworts fehlgeschlagen",
"password-updated": "Passwort aktualisiert", "password-updated": "Passwort aktualisiert",
"password": "Passwort", "password": "Passwort",
"password-strength": "Password is {strength}", "password-strength": "Das Passwort ist {strength}",
"register": "Register", "register": "Registrieren",
"reset-password": "Passwort zurücksetzen", "reset-password": "Passwort zurücksetzen",
"sign-in": "Einloggen", "sign-in": "Einloggen",
"total-mealplans": "Alle Essenspläne", "total-mealplans": "Alle Essenspläne",
@@ -505,45 +505,45 @@
"webhooks-enabled": "Webhooks aktiviert", "webhooks-enabled": "Webhooks aktiviert",
"you-are-not-allowed-to-create-a-user": "Sie sind nicht berechtigt, einen Benutzer anzulegen", "you-are-not-allowed-to-create-a-user": "Sie sind nicht berechtigt, einen Benutzer anzulegen",
"you-are-not-allowed-to-delete-this-user": "Sie sind nicht berechtigt, diesen Benutzer zu entfernen", "you-are-not-allowed-to-delete-this-user": "Sie sind nicht berechtigt, diesen Benutzer zu entfernen",
"enable-advanced-content": "Enable Advanced Content", "enable-advanced-content": "Erweiterten Inhalt aktivieren",
"enable-advanced-content-description": "Enables advanced features like Recipe Scaling, API keys, Webhooks, and Data Management. Don't worry, you can always change this later" "enable-advanced-content-description": "Aktiviert zusätzliche Funktionen wie Rezept-Skalierung, API-Schlüssel, Webhooks und Datenverwaltung. Keine Sorge, das kann später noch geändert werden."
}, },
"language-dialog": { "language-dialog": {
"translated": "translated", "translated": "übersetzt",
"choose-language": "Choose Language", "choose-language": "Sprache wählen",
"select-description": "Choose the language for the Mealie UI. The setting only applies to you, not other users.", "select-description": "Wählen Sie die Sprache für die Mealie-Benutzeroberfläche. Die Einstellung gilt nur für Sie, nicht für andere Benutzer.",
"how-to-contribute-description": "Is something not translated yet, mistranslated, or your language missing from the list? {read-the-docs-link} on how to contribute!", "how-to-contribute-description": "Ist etwas noch nicht übersetzt, falsch oder deine Sprache fehlt in der Liste? {read-the-docs-link} wie man beiträgt!",
"read-the-docs": "Read the docs" "read-the-docs": "Dokumentation lesen"
}, },
"data-pages": { "data-pages": {
"seed-data": "Seed Data", "seed-data": "Musterdaten",
"foods": { "foods": {
"merge-dialog-text": "Combining the selected foods will merge the source food and target food into a single food. The source food will be deleted and all of the references to the source food will be updated to point to the target food.", "merge-dialog-text": "Zusammenführen der ausgewählten Lebensmittel führt diese zusammen in ein einzelnes Lebensmittel. Die Ausgangslebensmittel werden gelöscht und alle Verweise werden auf das zusammengeführte Lebensmittel angepasst.",
"merge-food-example": "Merging {food1} into {food2}", "merge-food-example": "{food1} wird zu {food2} zusammengeführt",
"seed-dialog-text": "Seed the database with foods based on your local language. This will create 200+ common foods that can be used to organize your database. Foods are translated via a community effort.", "seed-dialog-text": "Füllt die Datenbank mit Lebensmitteln basierend auf Ihrer Landessprache. Dadurch werden mehr als 200 gängige Lebensmittel eingetragen, die verwendet werden können, um die Datenbank zu organisieren. Die Speisen werden über einen Gemeinschaftsanstrengung übersetzt.",
"seed-dialog-warning": "You have already have some items in your database. This action will not reconcile duplicates, you will have to manage them manually." "seed-dialog-warning": "Sie haben bereits einige Elemente in Ihrer Datenbank. Diese Aktion wird Duplikate nicht ausgleichen, Sie müssen sie manuell verwalten."
}, },
"units": { "units": {
"seed-dialog-text": "Seed the database with common units based on your local language." "seed-dialog-text": "Füllt die Datenbank mit gängigen Maßeinheiten basierend auf Ihrer Sprache."
}, },
"labels": { "labels": {
"seed-dialog-text": "Seed the database with common labels based on your local language." "seed-dialog-text": "Füllt die Datenbank mit gängigen Etiketten basierend auf Ihrer Sprache."
} }
}, },
"user-registration": { "user-registration": {
"user-registration": "User Registration", "user-registration": "Benutzerregistrierung",
"join-a-group": "Join a Group", "join-a-group": "Gruppe beitreten",
"create-a-new-group": "Create a New Group", "create-a-new-group": "Neue Gruppe erstellen",
"provide-registration-token-description": "Please provide the registration token associated with the group that you'd like to join. You'll need to obtain this from an existing group member.", "provide-registration-token-description": "Bitte gib den Registrierungstoken für die Gruppe ein, der du beitreten möchtest. Du kannst ihn von einem bestehenden Gruppenmitglied erhalten.",
"group-details": "Group Details", "group-details": "Gruppendetails",
"group-details-description": "Before you create an account you'll need to create a group. Your group will only contain you, but you'll be able to invite others later. Members in your group can share meal plans, shopping lists, recipes, and more!", "group-details-description": "Bevor Sie ein Konto erstellen, müssen Sie eine Gruppe erstellen. Ihre Gruppe wird nur Sie enthalten, aber Sie können andere später einladen. Mitglieder in Ihrer Gruppe können Essenspläne, Einkaufslisten, Rezepte und vieles mehr teilen!",
"use-seed-data": "Use Seed Data", "use-seed-data": "Musterdaten",
"use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes.", "use-seed-data-description": "Mealie enthält eine Sammlung von Lebensmitteln, Maßeinheiten und Etiketten, die verwendet werden können, um deine Gruppe mit hilfreichen Daten für die Organisation deiner Rezepte zu füllen.",
"account-details": "Account Details" "account-details": "Kontoinformationen"
}, },
"validation": { "validation": {
"group-name-is-taken": "Group name is taken", "group-name-is-taken": "Gruppenname ist schon vergeben",
"username-is-taken": "Username is taken", "username-is-taken": "Benutzername ist schon vergeben",
"email-is-taken": "Email is taken" "email-is-taken": "E-Mail-Adresse ist schon vergeben"
} }
} }

View File

@@ -509,8 +509,8 @@
"enable-advanced-content-description": "Enables advanced features like Recipe Scaling, API keys, Webhooks, and Data Management. Don't worry, you can always change this later" "enable-advanced-content-description": "Enables advanced features like Recipe Scaling, API keys, Webhooks, and Data Management. Don't worry, you can always change this later"
}, },
"language-dialog": { "language-dialog": {
"translated": "translated", "translated": "traduit",
"choose-language": "Choose Language", "choose-language": "Choisir la langue",
"select-description": "Choose the language for the Mealie UI. The setting only applies to you, not other users.", "select-description": "Choose the language for the Mealie UI. The setting only applies to you, not other users.",
"how-to-contribute-description": "Is something not translated yet, mistranslated, or your language missing from the list? {read-the-docs-link} on how to contribute!", "how-to-contribute-description": "Is something not translated yet, mistranslated, or your language missing from the list? {read-the-docs-link} on how to contribute!",
"read-the-docs": "Read the docs" "read-the-docs": "Read the docs"
@@ -532,18 +532,18 @@
}, },
"user-registration": { "user-registration": {
"user-registration": "User Registration", "user-registration": "User Registration",
"join-a-group": "Join a Group", "join-a-group": "Rejoindre un groupe",
"create-a-new-group": "Create a New Group", "create-a-new-group": "Créer un nouveau groupe",
"provide-registration-token-description": "Please provide the registration token associated with the group that you'd like to join. You'll need to obtain this from an existing group member.", "provide-registration-token-description": "Please provide the registration token associated with the group that you'd like to join. You'll need to obtain this from an existing group member.",
"group-details": "Group Details", "group-details": "Détails du groupe",
"group-details-description": "Before you create an account you'll need to create a group. Your group will only contain you, but you'll be able to invite others later. Members in your group can share meal plans, shopping lists, recipes, and more!", "group-details-description": "Before you create an account you'll need to create a group. Your group will only contain you, but you'll be able to invite others later. Members in your group can share meal plans, shopping lists, recipes, and more!",
"use-seed-data": "Use Seed Data", "use-seed-data": "Use Seed Data",
"use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes.", "use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes.",
"account-details": "Account Details" "account-details": "Détails du compte"
}, },
"validation": { "validation": {
"group-name-is-taken": "Group name is taken", "group-name-is-taken": "Group name is taken",
"username-is-taken": "Username is taken", "username-is-taken": "Nom d'utilisateur déjà utilisé",
"email-is-taken": "Email is taken" "email-is-taken": "Email is taken"
} }
} }

View File

@@ -25,11 +25,17 @@
<v-divider v-if="item.divider" :key="index" class="mx-2"></v-divider> <v-divider v-if="item.divider" :key="index" class="mx-2"></v-divider>
<v-list-item v-else :key="item.title" :to="item.to" exact> <v-list-item v-else :key="item.title" :to="item.to" exact>
<v-list-item-avatar> <v-list-item-avatar>
<v-icon v-text="item.icon"></v-icon> <v-icon>
{{ item.icon }}
</v-icon>
</v-list-item-avatar> </v-list-item-avatar>
<v-list-item-content> <v-list-item-content>
<v-list-item-title v-text="item.title"></v-list-item-title> <v-list-item-title>
<v-list-item-subtitle v-text="item.subtitle"></v-list-item-subtitle> {{ item.title }}
</v-list-item-title>
<v-list-item-subtitle>
{{ item.subtitle }}
</v-list-item-subtitle>
</v-list-item-content> </v-list-item-content>
</v-list-item> </v-list-item>
</template> </template>
@@ -116,7 +122,7 @@ export default defineComponent({
icon: this.$globals.icons.link, icon: this.$globals.icons.link,
title: "Import", title: "Import",
subtitle: "Import a recipe by URL", subtitle: "Import a recipe by URL",
to: "/recipe/create?tab=url", to: "/recipe/create/url",
restricted: true, restricted: true,
}, },
{ divider: true }, { divider: true },
@@ -124,7 +130,7 @@ export default defineComponent({
icon: this.$globals.icons.edit, icon: this.$globals.icons.edit,
title: "Create", title: "Create",
subtitle: "Create a recipe manually", subtitle: "Create a recipe manually",
to: "/recipe/create?tab=new", to: "/recipe/create/new",
restricted: true, restricted: true,
}, },
{ divider: true }, { divider: true },

View File

@@ -22,38 +22,35 @@
"@nuxtjs/proxy": "^2.1.0", "@nuxtjs/proxy": "^2.1.0",
"@nuxtjs/pwa": "^3.3.5", "@nuxtjs/pwa": "^3.3.5",
"@vue/composition-api": "^1.0.5", "@vue/composition-api": "^1.0.5",
"@vueuse/core": "^6.8.0", "@vueuse/core": "^8.5.0",
"core-js": "^3.15.1", "core-js": "^3.15.1",
"date-fns": "^2.23.0", "date-fns": "^2.23.0",
"isomorphic-dompurify": "^0.18.0",
"fuse.js": "^6.5.3", "fuse.js": "^6.5.3",
"isomorphic-dompurify": "^0.19.0",
"nuxt": "^2.15.8", "nuxt": "^2.15.8",
"v-jsoneditor": "^1.4.5", "v-jsoneditor": "^1.4.5",
"vuedraggable": "^2.24.3", "vuedraggable": "^2.24.3",
"vuetify": "^2.5.5" "vuetify": "^2.6.6"
}, },
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.14.7", "@babel/eslint-parser": "^7.14.7",
"@nuxt/types": "^2.15.7", "@nuxt/types": "^2.15.7",
"@nuxt/typescript-build": "^2.1.0", "@nuxt/typescript-build": "^2.1.0",
"@nuxtjs/composition-api": "^0.31.0", "@nuxtjs/composition-api": "^0.32.0",
"@nuxtjs/eslint-config-typescript": "^6.0.1", "@nuxtjs/eslint-config-typescript": "^10.0.0",
"@nuxtjs/eslint-module": "^3.0.2", "@nuxtjs/eslint-module": "^3.0.2",
"@nuxtjs/google-fonts": "^1.3.0", "@nuxtjs/google-fonts": "^1.3.0",
"@nuxtjs/vuetify": "^1.12.1", "@nuxtjs/vuetify": "^1.12.1",
"@types/sortablejs": "^1.10.7", "@types/sortablejs": "^1.10.7",
"@vue/runtime-dom": "^3.2.9", "@vue/runtime-dom": "^3.2.36",
"eslint": "^7.29.0", "eslint": "^8.16.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-nuxt": "^2.0.0", "eslint-plugin-nuxt": "^3.2.0",
"eslint-plugin-prettier": "^3.4.0", "eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-vue": "^7.12.1", "eslint-plugin-vue": "^9.0.1",
"lint-staged": "^10.5.4", "lint-staged": "^12.4.2",
"nuxt-vite": "^0.1.1", "nuxt-vite": "0.2.3",
"prettier": "^2.3.2", "prettier": "^2.3.2",
"vue2-script-setup-transform": "^0.2.0" "vue2-script-setup-transform": "^0.3.5"
},
"resolutions": {
"vite": "2.3.8"
} }
} }

View File

@@ -40,7 +40,7 @@
> >
<template #item="{ item }"> <template #item="{ item }">
<v-list-item-content> <v-list-item-content>
<v-list-item-title v-text="item.name"></v-list-item-title> <v-list-item-title> {{ item.name }} </v-list-item-title>
<v-list-item-subtitle> <v-list-item-subtitle>
{{ item.progress }}% {{ $tc("language-dialog.translated") }} {{ item.progress }}% {{ $tc("language-dialog.translated") }}
</v-list-item-subtitle> </v-list-item-subtitle>

View File

@@ -65,7 +65,7 @@
> >
<template #item="{ item }"> <template #item="{ item }">
<v-list-item-content> <v-list-item-content>
<v-list-item-title v-text="item.name"></v-list-item-title> <v-list-item-title> {{ item.name }} </v-list-item-title>
<v-list-item-subtitle> <v-list-item-subtitle>
{{ item.progress }}% {{ $tc("language-dialog.translated") }} {{ item.progress }}% {{ $tc("language-dialog.translated") }}
</v-list-item-subtitle> </v-list-item-subtitle>

View File

@@ -315,8 +315,10 @@ export default defineComponent({
title: "Tag Recipes", title: "Tag Recipes",
mode: MODES.tag, mode: MODES.tag,
tag: "", tag: "",
// eslint-disable-next-line @typescript-eslint/no-empty-function callback: () => {
callback: () => {}, // Stub function to be overwritten
return Promise.resolve();
},
icon: $globals.icons.tags, icon: $globals.icons.tags,
}); });

View File

@@ -69,7 +69,7 @@
> >
<template #item="{ item }"> <template #item="{ item }">
<v-list-item-content> <v-list-item-content>
<v-list-item-title v-text="item.name"></v-list-item-title> <v-list-item-title> {{ item.name }} </v-list-item-title>
<v-list-item-subtitle> <v-list-item-subtitle>
{{ item.progress }}% {{ $tc("language-dialog.translated") }} {{ item.progress }}% {{ $tc("language-dialog.translated") }}
</v-list-item-subtitle> </v-list-item-subtitle>

View File

@@ -95,6 +95,7 @@
<template v-if="edit"> <template v-if="edit">
<draggable <draggable
tag="div" tag="div"
handle=".handle"
:value="plan.meals" :value="plan.meals"
group="meals" group="meals"
:data-index="index" :data-index="index"
@@ -102,7 +103,13 @@
style="min-height: 150px" style="min-height: 150px"
@end="onMoveCallback" @end="onMoveCallback"
> >
<v-card v-for="mealplan in plan.meals" :key="mealplan.id" v-model="hover[mealplan.id]" class="my-1"> <v-card
v-for="mealplan in plan.meals"
:key="mealplan.id"
v-model="hover[mealplan.id]"
class="my-1"
:class="{ handle: $vuetify.breakpoint.smAndUp }"
>
<v-list-item :to="edit || !mealplan.recipe ? null : `/recipe/${mealplan.recipe.slug}`"> <v-list-item :to="edit || !mealplan.recipe ? null : `/recipe/${mealplan.recipe.slug}`">
<v-list-item-avatar :rounded="false"> <v-list-item-avatar :rounded="false">
<RecipeCardImage <RecipeCardImage
@@ -126,7 +133,13 @@
</v-list-item-content> </v-list-item-content>
</v-list-item> </v-list-item>
<v-divider class="mx-2"></v-divider> <v-divider class="mx-2"></v-divider>
<div class="py-2 px-2 d-flex"> <div class="py-2 px-2 d-flex" style="align-items: center">
<v-btn small icon :class="{ handle: !$vuetify.breakpoint.smAndUp }">
<v-icon>
{{ $globals.icons.arrowUpDown }}
</v-icon>
</v-btn>
<v-menu offset-y> <v-menu offset-y>
<template #activator="{ on, attrs }"> <template #activator="{ on, attrs }">
<v-chip v-bind="attrs" label small color="accent" v-on="on" @click.prevent> <v-chip v-bind="attrs" label small color="accent" v-on="on" @click.prevent>
@@ -146,8 +159,8 @@
</v-list-item> </v-list-item>
</v-list> </v-list>
</v-menu> </v-menu>
<v-spacer></v-spacer>
<v-btn color="error" small icon @click="actions.deleteOne(mealplan.id)"> <v-btn class="ml-auto" small icon @click="actions.deleteOne(mealplan.id)">
<v-icon>{{ $globals.icons.delete }}</v-icon> <v-icon>{{ $globals.icons.delete }}</v-icon>
</v-btn> </v-btn>
</div> </div>
@@ -291,10 +304,12 @@ export default defineComponent({
} }
function onMoveCallback(evt: SortableEvent) { function onMoveCallback(evt: SortableEvent) {
const supportedEvents = ["drop", "touchend"];
// Adapted From https://github.com/SortableJS/Vue.Draggable/issues/1029 // Adapted From https://github.com/SortableJS/Vue.Draggable/issues/1029
const ogEvent: DragEvent = (evt as any).originalEvent; const ogEvent: DragEvent = (evt as any).originalEvent;
if (ogEvent && ogEvent.type !== "drop") { if (ogEvent && ogEvent.type in supportedEvents) {
// The drop was cancelled, unsure if anything needs to be done? // The drop was cancelled, unsure if anything needs to be done?
console.log("Cancel Move Event"); console.log("Cancel Move Event");
} else { } else {

View File

@@ -4,9 +4,7 @@
<template #header> <template #header>
<v-img max-height="200" max-width="200" class="mb-2" :src="require('~/static/svgs/data-reports.svg')"></v-img> <v-img max-height="200" max-width="200" class="mb-2" :src="require('~/static/svgs/data-reports.svg')"></v-img>
</template> </template>
<template #title> Recipe Data Migrations</template> <template #title> Report </template>
Recipes can be migrated from another supported application to Mealie. This is a great way to get started with
Mealie.
</BasePageTitle> </BasePageTitle>
<v-container v-if="report"> <v-container v-if="report">
<BaseCardSectionTitle :title="report.name"> </BaseCardSectionTitle> <BaseCardSectionTitle :title="report.name"> </BaseCardSectionTitle>
@@ -31,8 +29,9 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, useRoute, reactive, toRefs, onMounted } from "@nuxtjs/composition-api"; import { defineComponent, useRoute, ref, onMounted } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { ReportOut } from "~/types/api-types/reports";
export default defineComponent({ export default defineComponent({
setup() { setup() {
@@ -41,16 +40,11 @@ export default defineComponent({
const api = useUserApi(); const api = useUserApi();
const state = reactive({ const report = ref<ReportOut | null>(null);
report: {},
});
async function getReport() { async function getReport() {
const { data } = await api.groupReports.getOne(id); const { data } = await api.groupReports.getOne(id);
report.value = data ?? null;
if (data) {
state.report = data;
}
} }
onMounted(async () => { onMounted(async () => {
@@ -64,7 +58,7 @@ export default defineComponent({
]; ];
return { return {
...toRefs(state), report,
id, id,
itemHeaders, itemHeaders,
}; };
@@ -72,5 +66,4 @@ export default defineComponent({
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped></style>
</style>

View File

@@ -454,12 +454,13 @@
> >
<v-switch v-model="wakeLock" small label="Keep Screen Awake" /> <v-switch v-model="wakeLock" small label="Keep Screen Awake" />
</div> </div>
<RecipeComments <RecipeComments
v-if="recipe && !recipe.settings.disableComments && !form" v-if="recipe && !recipe.settings.disableComments && !form"
v-model="recipe.comments" v-model="recipe.comments"
:slug="recipe.slug" :slug="recipe.slug"
:recipe-id="recipe.id" :recipe-id="recipe.id"
class="px-1 my-4" class="px-1 my-4 d-print-none"
/> />
<RecipePrintView v-if="recipe" :recipe="recipe" /> <RecipePrintView v-if="recipe" :recipe="recipe" />
</v-container> </v-container>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<v-container class="narrow-container flex-column pa-0"> <v-container class="flex-column">
<BasePageTitle divider> <BasePageTitle divider>
<template #header> <template #header>
<v-img max-height="175" max-width="175" :src="require('~/static/svgs/recipes-create.svg')"></v-img> <v-img max-height="175" max-width="175" :src="require('~/static/svgs/recipes-create.svg')"></v-img>
@@ -9,311 +9,17 @@
Select one of the various ways to create a recipe Select one of the various ways to create a recipe
<template #content> <template #content>
<div class="ml-auto"> <div class="ml-auto">
<BaseOverflowButton v-model="tab" rounded :items="tabs"> </BaseOverflowButton> <BaseOverflowButton v-model="subpage" rounded :items="subpages"> </BaseOverflowButton>
</div> </div>
</template> </template>
</BasePageTitle> </BasePageTitle>
<section> <section>
<v-tabs-items v-model="tab" class="mt-2"> <NuxtChild />
<!-- Create From URL -->
<v-tab-item value="url" eager>
<v-form ref="domUrlForm" @submit.prevent="createByUrl(recipeUrl, importKeywordsAsTags)">
<v-card flat>
<v-card-title class="headline"> Scrape Recipe </v-card-title>
<v-card-text>
Scrape a recipe by url. Provide the url for the site you want to scrape, and Mealie will attempt to
scrape the recipe from that site and add it to your collection.
<v-text-field
v-model="recipeUrl"
:label="$t('new-recipe.recipe-url')"
:prepend-inner-icon="$globals.icons.link"
validate-on-blur
autofocus
filled
clearable
class="rounded-lg mt-2"
rounded
:rules="[validators.url]"
:hint="$t('new-recipe.url-form-hint')"
persistent-hint
></v-text-field>
<v-checkbox v-model="importKeywordsAsTags" label="Import original keywords as tags">
</v-checkbox>
</v-card-text>
<v-card-actions class="justify-center">
<div style="width: 250px">
<BaseButton :disabled="recipeUrl === null" rounded block type="submit" :loading="loading" />
</div>
</v-card-actions>
</v-card>
</v-form>
<v-expand-transition>
<v-alert v-show="error" color="error" class="mt-6 white--text">
<v-card-title class="ma-0 pa-0">
<v-icon left color="white" x-large> {{ $globals.icons.robot }} </v-icon>
{{ $t("new-recipe.error-title") }}
</v-card-title>
<v-divider class="my-3 mx-2"></v-divider>
<p>
{{ $t("new-recipe.error-details") }}
</p>
<div class="d-flex row justify-space-around my-3 force-white">
<a
class="dark"
href="https://developers.google.com/search/docs/data-types/recipe"
target="_blank"
rel="noreferrer nofollow"
>
{{ $t("new-recipe.google-ld-json-info") }}
</a>
<a href="https://github.com/hay-kot/mealie/issues" target="_blank" rel="noreferrer nofollow">
{{ $t("new-recipe.github-issues") }}
</a>
<a href="https://schema.org/Recipe" target="_blank" rel="noreferrer nofollow">
{{ $t("new-recipe.recipe-markup-specification") }}
</a>
</div>
</v-alert>
</v-expand-transition>
</v-tab-item>
<!-- Create By Name -->
<v-tab-item value="new" eager>
<v-card flat>
<v-card-title class="headline"> Create Recipe </v-card-title>
<v-card-text>
Create a recipe by providing the name. All recipes must have unique names.
<v-form ref="domCreateByName">
<v-text-field
v-model="newRecipeName"
:label="$t('recipe.recipe-name')"
:prepend-inner-icon="$globals.icons.primary"
validate-on-blur
autofocus
filled
clearable
class="rounded-lg mt-2"
rounded
:rules="[validators.required]"
hint="New recipe names must be unique"
persistent-hint
></v-text-field>
</v-form>
</v-card-text>
<v-card-actions class="justify-center">
<div style="width: 250px">
<BaseButton
:disabled="newRecipeName === ''"
rounded
block
:loading="loading"
@click="createByName(newRecipeName)"
/>
</div>
</v-card-actions>
</v-card>
</v-tab-item>
<!-- Create By Zip -->
<v-tab-item value="zip" eager>
<v-form>
<v-card>
<v-card-title class="headline"> Import from Zip </v-card-title>
<v-card-text>
Import a single recipe that was exported from another Mealie instance.
<v-file-input
v-model="newRecipeZip"
accept=".zip"
label=".zip"
filled
clearable
class="rounded-lg mt-2"
rounded
truncate-length="100"
hint=".zip files must have been exported from Mealie"
persistent-hint
prepend-icon=""
:prepend-inner-icon="$globals.icons.zip"
>
</v-file-input>
</v-card-text>
<v-card-actions class="justify-center">
<div style="width: 250px">
<BaseButton
:disabled="newRecipeZip === null"
large
rounded
block
:loading="loading"
@click="createByZip"
/>
</div>
</v-card-actions>
</v-card>
</v-form>
</v-tab-item>
<!-- Create By Zip -->
<v-tab-item value="debug" eager>
<v-form ref="domUrlForm" @submit.prevent="debugUrl(recipeUrl)">
<v-card flat>
<v-card-title class="headline"> Recipe Debugger </v-card-title>
<v-card-text>
Grab the URL of the recipe you want to debug and paste it here. The URL will be scraped by the recipe
scraper and the results will be displayed. If you don't see any data returned, the site you are trying
to scrape is not supported by Mealie or it's scraper library.
<v-text-field
v-model="recipeUrl"
:label="$t('new-recipe.recipe-url')"
validate-on-blur
:prepend-inner-icon="$globals.icons.link"
autofocus
filled
clearable
rounded
class="rounded-lg mt-2"
:rules="[validators.url]"
:hint="$t('new-recipe.url-form-hint')"
persistent-hint
></v-text-field>
</v-card-text>
<v-card-actions class="justify-center">
<div style="width: 250px">
<BaseButton
:disabled="recipeUrl === null"
rounded
block
type="submit"
color="info"
:loading="loading"
>
<template #icon>
{{ $globals.icons.robot }}
</template>
Debug
</BaseButton>
</div>
</v-card-actions>
</v-card>
</v-form>
</v-tab-item>
<v-tab-item value="bulk" eager>
<v-card flat>
<v-card-title class="headline"> Recipe Bulk Importer </v-card-title>
<v-card-text>
The Bulk recipe importer allows you to import multiple recipes at once by queing the sites on the
backend and running the task in the background. This can be useful when initially migrating to Mealie,
or when you want to import a large number of recipes.
</v-card-text>
</v-card>
</v-tab-item>
</v-tabs-items>
</section>
<v-divider class="mt-5"></v-divider>
</v-container>
<v-container tag="section">
<!-- Debug Extras -->
<section v-if="debugData && tab === 'debug'">
<v-checkbox v-model="debugTreeView" label="Tree View"></v-checkbox>
<LazyRecipeJsonEditor
v-model="debugData"
class="primary"
:options="{
mode: debugTreeView ? 'tree' : 'code',
search: false,
indentation: 4,
mainMenuBar: false,
}"
height="700px"
/>
</section>
<!-- Debug Extras -->
<section v-else-if="tab === 'bulk'" class="mt-2">
<v-row v-for="(bulkUrl, idx) in bulkUrls" :key="'bulk-url' + idx" class="my-1" dense>
<v-col cols="12" xs="12" sm="12" md="12">
<v-text-field
v-model="bulkUrls[idx].url"
:label="$t('new-recipe.recipe-url')"
dense
single-line
validate-on-blur
autofocus
filled
hide-details
clearable
:prepend-inner-icon="$globals.icons.link"
rounded
class="rounded-lg"
>
<template #append>
<v-btn color="error" icon x-small @click="bulkUrls.splice(idx, 1)">
<v-icon>
{{ $globals.icons.delete }}
</v-icon>
</v-btn>
</template>
</v-text-field>
</v-col>
<v-col cols="12" xs="12" sm="6">
<RecipeOrganizerSelector
v-model="bulkUrls[idx].categories"
:items="allCategories || []"
selector-type="category"
:input-attrs="{
filled: true,
singleLine: true,
dense: true,
rounded: true,
class: 'rounded-lg',
hideDetails: true,
clearable: true,
}"
/>
</v-col>
<v-col cols="12" xs="12" sm="6">
<RecipeOrganizerSelector
v-model="bulkUrls[idx].tags"
:items="allTags || []"
selector-type="tag"
:input-attrs="{
filled: true,
singleLine: true,
dense: true,
rounded: true,
class: 'rounded-lg',
hideDetails: true,
clearable: true,
}"
/>
</v-col>
</v-row>
<v-card-actions class="justify-end">
<BaseButton
delete
@click="
bulkUrls = [];
lockBulkImport = false;
"
>
Clear
</BaseButton>
<v-spacer></v-spacer>
<BaseButton color="info" @click="bulkUrls.push({ url: '', categories: [], tags: [] })">
<template #icon> {{ $globals.icons.createAlt }} </template> New
</BaseButton>
<BaseButton :disabled="bulkUrls.length === 0 || lockBulkImport" @click="bulkCreate">
<template #icon> {{ $globals.icons.check }} </template> Submit
</BaseButton>
</v-card-actions>
</section> </section>
</v-container> </v-container>
<AdvancedOnly> <AdvancedOnly>
<v-container class="narrow-container d-flex justify-end"> <v-container class="d-flex justify-end">
<v-btn outlined rounded to="/group/migrations"> Looking For Migrations? </v-btn> <v-btn outlined rounded to="/group/migrations"> Looking For Migrations? </v-btn>
</v-container> </v-container>
</AdvancedOnly> </AdvancedOnly>
@@ -321,39 +27,16 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { import { defineComponent, useRouter, useContext, computed, useRoute } from "@nuxtjs/composition-api";
defineComponent,
reactive,
toRefs,
ref,
useRouter,
useContext,
computed,
useRoute,
} from "@nuxtjs/composition-api";
import { AxiosResponse } from "axios";
import { onMounted } from "vue-demi";
import { useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators";
import { Recipe } from "~/types/api-types/recipe";
import { alert } from "~/composables/use-toast";
import { VForm } from "~/types/vuetify";
import { MenuItem } from "~/components/global/BaseOverflowButton.vue"; import { MenuItem } from "~/components/global/BaseOverflowButton.vue";
import AdvancedOnly from "~/components/global/AdvancedOnly.vue"; import AdvancedOnly from "~/components/global/AdvancedOnly.vue";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import { useCategories, useTags } from "~/composables/recipes";
export default defineComponent({ export default defineComponent({
components: { AdvancedOnly, RecipeOrganizerSelector }, components: { AdvancedOnly },
setup() { setup() {
const state = reactive({
error: false,
loading: false,
});
const { $globals } = useContext(); const { $globals } = useContext();
const tabs: MenuItem[] = [ const subpages: MenuItem[] = [
{ {
icon: $globals.icons.link, icon: $globals.icons.link,
text: "Import with URL", text: "Import with URL",
@@ -381,185 +64,21 @@ export default defineComponent({
}, },
]; ];
const api = useUserApi();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
function handleResponse(response: AxiosResponse<string> | null, edit = false) { const subpage = computed({
if (response?.status !== 201) { set(subpage: string) {
state.error = true; router.push({ path: `/recipe/create/${subpage}`, query: route.value.query });
state.loading = false;
return;
}
router.push(`/recipe/${response.data}?edit=${edit.toString()}`);
}
const tab = computed({
set(tab: string) {
router.replace({ query: { ...route.value.query, tab } });
}, },
get() { get() {
return route.value.query.tab as string; return route.value.path.split("/").pop() ?? "url";
}, },
}); });
const recipeUrl = computed({
set(recipe_import_url: string | null) {
if (recipe_import_url !== null) {
recipe_import_url = recipe_import_url.trim();
router.replace({ query: { ...route.value.query, recipe_import_url } });
}
},
get() {
return route.value.query.recipe_import_url as string | null;
},
});
const importKeywordsAsTags = computed({
get() {
return route.value.query.import_keywords_as_tags === "1";
},
set(keywordsAsTags: boolean) {
let import_keywords_as_tags = "0"
if (keywordsAsTags) {
import_keywords_as_tags = "1"
}
router.replace({query: {...route.value.query, import_keywords_as_tags}})
}
});
onMounted(() => {
if (!recipeUrl.value) {
return;
}
if (recipeUrl.value.includes("https")) {
createByUrl(recipeUrl.value, importKeywordsAsTags.value);
}
});
// ===================================================
// Recipe Debug URL Scraper
const debugTreeView = ref(false);
const debugData = ref<Recipe | null>(null);
async function debugUrl(url: string | null) {
if (url === null) {
return;
}
state.loading = true;
const { data } = await api.recipes.testCreateOneUrl(url);
state.loading = false;
debugData.value = data;
}
// ===================================================
// Recipe URL Import
const domUrlForm = ref<VForm | null>(null);
async function createByUrl(url: string, importKeywordsAsTags: boolean) {
if (url === null) {
return;
}
if (!domUrlForm.value?.validate() || url === "") {
console.log("Invalid URL", url);
return;
}
state.loading = true;
const { response } = await api.recipes.createOneByUrl(url, importKeywordsAsTags);
handleResponse(response);
}
// ===================================================
// Recipe Create By Name
const newRecipeName = ref("");
const domCreateByName = ref<VForm | null>(null);
async function createByName(name: string) {
if (!domCreateByName.value?.validate() || name === "") {
return;
}
const { response } = await api.recipes.createOne({ name });
// TODO createOne claims to return a Recipe, but actually the API only returns a string
// @ts-ignore See above
handleResponse(response, true);
}
// ===================================================
// Recipe Import From Zip File
const newRecipeZip = ref<File | null>(null);
const newRecipeZipFileName = "archive";
async function createByZip() {
if (!newRecipeZip.value) {
return;
}
const formData = new FormData();
formData.append(newRecipeZipFileName, newRecipeZip.value);
const { response } = await api.upload.file("/api/recipes/create-from-zip", formData);
handleResponse(response);
}
// ===================================================
// Bulk Importer
const bulkUrls = ref([{ url: "", categories: [], tags: [] }]);
const lockBulkImport = ref(false);
async function bulkCreate() {
if (bulkUrls.value.length === 0) {
return;
}
const { response } = await api.recipes.createManyByUrl({ imports: bulkUrls.value });
if (response?.status === 202) {
alert.success("Bulk Import process has started");
lockBulkImport.value = true;
} else {
alert.error("Bulk import process has failed");
}
}
const { allTags, useAsyncGetAll: getAllTags } = useTags();
const { allCategories, useAsyncGetAll: getAllCategories } = useCategories();
getAllTags();
getAllCategories();
return { return {
allTags, subpages,
allCategories, subpage,
tab,
recipeUrl,
importKeywordsAsTags,
bulkCreate,
bulkUrls,
lockBulkImport,
debugTreeView,
tabs,
domCreateByName,
domUrlForm,
newRecipeName,
newRecipeZip,
debugUrl,
debugData,
createByName,
createByUrl,
createByZip,
...toRefs(state),
validators,
}; };
}, },
head() { head() {
@@ -569,9 +88,3 @@ export default defineComponent({
}, },
}); });
</script> </script>
<style>
.force-white > a {
color: white !important;
}
</style>

View File

@@ -0,0 +1,201 @@
<template>
<div>
<div flat>
<v-card-title class="headline"> Recipe Bulk Importer </v-card-title>
<v-card-text>
The Bulk recipe importer allows you to import multiple recipes at once by queing the sites on the backend and
running the task in the background. This can be useful when initially migrating to Mealie, or when you want to
import a large number of recipes.
</v-card-text>
</div>
<section class="mt-2">
<v-row v-for="(_, idx) in bulkUrls" :key="'bulk-url' + idx" class="my-1" dense>
<v-col cols="12" xs="12" sm="12" md="12">
<v-text-field
v-model="bulkUrls[idx].url"
:label="$t('new-recipe.recipe-url')"
dense
single-line
validate-on-blur
autofocus
filled
hide-details
clearable
:prepend-inner-icon="$globals.icons.link"
rounded
class="rounded-lg"
>
<template #append>
<v-btn style="margin-top: -2px" icon small @click="bulkUrls.splice(idx, 1)">
<v-icon>
{{ $globals.icons.delete }}
</v-icon>
</v-btn>
</template>
</v-text-field>
</v-col>
<template v-if="showCatTags">
<v-col cols="12" xs="12" sm="6">
<RecipeOrganizerSelector
v-model="bulkUrls[idx].categories"
:items="allCategories || []"
selector-type="category"
:input-attrs="{
filled: true,
singleLine: true,
dense: true,
rounded: true,
class: 'rounded-lg',
hideDetails: true,
clearable: true,
}"
/>
</v-col>
<v-col cols="12" xs="12" sm="6">
<RecipeOrganizerSelector
v-model="bulkUrls[idx].tags"
:items="allTags || []"
selector-type="tag"
:input-attrs="{
filled: true,
singleLine: true,
dense: true,
rounded: true,
class: 'rounded-lg',
hideDetails: true,
clearable: true,
}"
/>
</v-col>
</template>
</v-row>
<v-card-actions class="justify-end">
<BaseButton
delete
@click="
bulkUrls = [];
lockBulkImport = false;
"
>
Clear
</BaseButton>
<v-spacer></v-spacer>
<BaseButton class="mr-1" color="info" @click="bulkUrls.push({ url: '', categories: [], tags: [] })">
<template #icon> {{ $globals.icons.createAlt }} </template>
New
</BaseButton>
<RecipeDialogBulkAdd v-model="bulkDialog" @bulk-data="assignUrls" />
</v-card-actions>
<div class="px-1">
<v-checkbox v-model="showCatTags" hide-details label="Set Categories and Tags " />
</div>
<v-card-actions class="justify-end">
<BaseButton :disabled="bulkUrls.length === 0 || lockBulkImport" @click="bulkCreate">
<template #icon> {{ $globals.icons.check }} </template>
Submit
</BaseButton>
</v-card-actions>
</section>
<section class="mt-12">
<BaseCardSectionTitle title="Bulk Imports"> </BaseCardSectionTitle>
<ReportTable :items="reports" @delete="deleteReport" />
</section>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, ref } from "@nuxtjs/composition-api";
import { whenever } from "@vueuse/shared";
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import { useCategories, useTags } from "~/composables/recipes";
import { ReportSummary } from "~/types/api-types/reports";
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
export default defineComponent({
components: { RecipeOrganizerSelector, RecipeDialogBulkAdd },
setup() {
const state = reactive({
error: false,
loading: false,
showCatTags: false,
bulkDialog: false,
});
whenever(
() => !state.showCatTags,
() => {
console.log("showCatTags changed");
}
);
const api = useUserApi();
const bulkUrls = ref([{ url: "", categories: [], tags: [] }]);
const lockBulkImport = ref(false);
async function bulkCreate() {
if (bulkUrls.value.length === 0) {
return;
}
const { response } = await api.recipes.createManyByUrl({ imports: bulkUrls.value });
if (response?.status === 202) {
alert.success("Bulk Import process has started");
lockBulkImport.value = true;
} else {
alert.error("Bulk import process has failed");
}
fetchReports();
}
const { allTags, useAsyncGetAll: getAllTags } = useTags();
const { allCategories, useAsyncGetAll: getAllCategories } = useCategories();
getAllTags();
getAllCategories();
// =========================================================
// Reports
const reports = ref<ReportSummary[]>([]);
async function fetchReports() {
const { data } = await api.groupReports.getAll("bulk_import");
reports.value = data ?? [];
}
async function deleteReport(id: string) {
console.log(id);
const { response } = await api.groupReports.deleteOne(id);
if (response?.status === 200) {
fetchReports();
} else {
alert.error("Report deletion failed");
}
}
fetchReports();
function assignUrls(urls: string[]) {
bulkUrls.value = urls.map((url) => ({ url, categories: [], tags: [] }));
}
return {
assignUrls,
reports,
deleteReport,
allTags,
allCategories,
bulkCreate,
bulkUrls,
lockBulkImport,
...toRefs(state),
};
},
});
</script>

View File

@@ -0,0 +1,110 @@
<template>
<div>
<v-form ref="domUrlForm" @submit.prevent="debugUrl(recipeUrl)">
<div>
<v-card-title class="headline"> Recipe Debugger </v-card-title>
<v-card-text>
Grab the URL of the recipe you want to debug and paste it here. The URL will be scraped by the recipe scraper
and the results will be displayed. If you don't see any data returned, the site you are trying to scrape is
not supported by Mealie or it's scraper library.
<v-text-field
v-model="recipeUrl"
:label="$t('new-recipe.recipe-url')"
validate-on-blur
:prepend-inner-icon="$globals.icons.link"
autofocus
filled
clearable
rounded
class="rounded-lg mt-2"
:rules="[validators.url]"
:hint="$t('new-recipe.url-form-hint')"
persistent-hint
></v-text-field>
</v-card-text>
<v-card-actions class="justify-center">
<div style="width: 250px">
<BaseButton :disabled="recipeUrl === null" rounded block type="submit" color="info" :loading="loading">
<template #icon>
{{ $globals.icons.robot }}
</template>
Debug
</BaseButton>
</div>
</v-card-actions>
</div>
</v-form>
<section v-if="debugData">
<v-checkbox v-model="debugTreeView" label="Tree View"></v-checkbox>
<LazyRecipeJsonEditor
v-model="debugData"
class="primary"
:options="{
mode: debugTreeView ? 'tree' : 'code',
search: false,
indentation: 4,
mainMenuBar: false,
}"
height="700px"
/>
</section>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, ref, useRouter, computed, useRoute } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators";
import { Recipe } from "~/types/api-types/recipe";
export default defineComponent({
setup() {
const state = reactive({
error: false,
loading: false,
});
const api = useUserApi();
const route = useRoute();
const router = useRouter();
const recipeUrl = computed({
set(recipe_import_url: string | null) {
if (recipe_import_url !== null) {
recipe_import_url = recipe_import_url.trim();
router.replace({ query: { ...route.value.query, recipe_import_url } });
}
},
get() {
return route.value.query.recipe_import_url as string | null;
},
});
const debugTreeView = ref(false);
const debugData = ref<Recipe | null>(null);
async function debugUrl(url: string | null) {
if (url === null) {
return;
}
state.loading = true;
const { data } = await api.recipes.testCreateOneUrl(url);
state.loading = false;
debugData.value = data;
}
return {
recipeUrl,
debugTreeView,
debugUrl,
debugData,
...toRefs(state),
validators,
};
},
});
</script>

View File

@@ -0,0 +1,18 @@
<template>
<div></div>
</template>
<script lang="ts">
import { defineComponent, onMounted, useRouter } from "@nuxtjs/composition-api";
export default defineComponent({
setup() {
const router = useRouter();
onMounted(() => {
// Force redirect to first valid page
router.replace("/recipe/create/url");
});
return {};
},
});
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div>
<v-card-title class="headline"> Create Recipe </v-card-title>
<v-card-text>
Create a recipe by providing the name. All recipes must have unique names.
<v-form ref="domCreateByName">
<v-text-field
v-model="newRecipeName"
:label="$t('recipe.recipe-name')"
:prepend-inner-icon="$globals.icons.primary"
validate-on-blur
autofocus
filled
clearable
class="rounded-lg mt-2"
rounded
:rules="[validators.required]"
hint="New recipe names must be unique"
persistent-hint
></v-text-field>
</v-form>
</v-card-text>
<v-card-actions class="justify-center">
<div style="width: 250px">
<BaseButton
:disabled="newRecipeName === ''"
rounded
block
:loading="loading"
@click="createByName(newRecipeName)"
/>
</div>
</v-card-actions>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, ref, useRouter } from "@nuxtjs/composition-api";
import { AxiosResponse } from "axios";
import { useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators";
import { VForm } from "~/types/vuetify";
export default defineComponent({
setup() {
const state = reactive({
error: false,
loading: false,
});
const api = useUserApi();
const router = useRouter();
function handleResponse(response: AxiosResponse<string> | null, edit = false) {
if (response?.status !== 201) {
state.error = true;
state.loading = false;
return;
}
router.push(`/recipe/${response.data}?edit=${edit.toString()}`);
}
const newRecipeName = ref("");
const domCreateByName = ref<VForm | null>(null);
async function createByName(name: string) {
if (!domCreateByName.value?.validate() || name === "") {
return;
}
const { response } = await api.recipes.createOne({ name });
// TODO createOne claims to return a Recipe, but actually the API only returns a string
// @ts-ignore See above
handleResponse(response, true);
}
return {
domCreateByName,
newRecipeName,
createByName,
...toRefs(state),
validators,
};
},
});
</script>

View File

@@ -0,0 +1,159 @@
<template>
<div>
<v-form ref="domUrlForm" @submit.prevent="createByUrl(recipeUrl, importKeywordsAsTags)">
<div>
<v-card-title class="headline"> Scrape Recipe </v-card-title>
<v-card-text>
Scrape a recipe by url. Provide the url for the site you want to scrape, and Mealie will attempt to scrape the
recipe from that site and add it to your collection.
<v-text-field
v-model="recipeUrl"
:label="$t('new-recipe.recipe-url')"
:prepend-inner-icon="$globals.icons.link"
validate-on-blur
autofocus
filled
clearable
class="rounded-lg mt-2"
rounded
:rules="[validators.url]"
:hint="$t('new-recipe.url-form-hint')"
persistent-hint
></v-text-field>
<v-checkbox v-model="importKeywordsAsTags" label="Import original keywords as tags"> </v-checkbox>
</v-card-text>
<v-card-actions class="justify-center">
<div style="width: 250px">
<BaseButton :disabled="recipeUrl === null" rounded block type="submit" :loading="loading" />
</div>
</v-card-actions>
</div>
</v-form>
<v-expand-transition>
<v-alert v-show="error" color="error" class="mt-6 white--text">
<v-card-title class="ma-0 pa-0">
<v-icon left color="white" x-large> {{ $globals.icons.robot }} </v-icon>
{{ $t("new-recipe.error-title") }}
</v-card-title>
<v-divider class="my-3 mx-2"></v-divider>
<p>
{{ $t("new-recipe.error-details") }}
</p>
<div class="d-flex row justify-space-around my-3 force-white">
<a
class="dark"
href="https://developers.google.com/search/docs/data-types/recipe"
target="_blank"
rel="noreferrer nofollow"
>
{{ $t("new-recipe.google-ld-json-info") }}
</a>
<a href="https://github.com/hay-kot/mealie/issues" target="_blank" rel="noreferrer nofollow">
{{ $t("new-recipe.github-issues") }}
</a>
<a href="https://schema.org/Recipe" target="_blank" rel="noreferrer nofollow">
{{ $t("new-recipe.recipe-markup-specification") }}
</a>
</div>
</v-alert>
</v-expand-transition>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, ref, useRouter, computed, useRoute } from "@nuxtjs/composition-api";
import { AxiosResponse } from "axios";
import { onMounted } from "vue-demi";
import { useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators";
import { VForm } from "~/types/vuetify";
export default defineComponent({
setup() {
const state = reactive({
error: false,
loading: false,
});
const api = useUserApi();
const route = useRoute();
const router = useRouter();
function handleResponse(response: AxiosResponse<string> | null, edit = false) {
if (response?.status !== 201) {
state.error = true;
state.loading = false;
return;
}
router.push(`/recipe/${response.data}?edit=${edit.toString()}`);
}
const recipeUrl = computed({
set(recipe_import_url: string | null) {
if (recipe_import_url !== null) {
recipe_import_url = recipe_import_url.trim();
router.replace({ query: { ...route.value.query, recipe_import_url } });
}
},
get() {
return route.value.query.recipe_import_url as string | null;
},
});
const importKeywordsAsTags = computed({
get() {
return route.value.query.import_keywords_as_tags === "1";
},
set(keywordsAsTags: boolean) {
let import_keywords_as_tags = "0";
if (keywordsAsTags) {
import_keywords_as_tags = "1";
}
router.replace({ query: { ...route.value.query, import_keywords_as_tags } });
},
});
onMounted(() => {
if (!recipeUrl.value) {
return;
}
if (recipeUrl.value.includes("https")) {
createByUrl(recipeUrl.value, importKeywordsAsTags.value);
}
});
const domUrlForm = ref<VForm | null>(null);
async function createByUrl(url: string, importKeywordsAsTags: boolean) {
if (url === null) {
return;
}
if (!domUrlForm.value?.validate() || url === "") {
console.log("Invalid URL", url);
return;
}
state.loading = true;
const { response } = await api.recipes.createOneByUrl(url, importKeywordsAsTags);
handleResponse(response);
}
return {
recipeUrl,
importKeywordsAsTags,
domUrlForm,
createByUrl,
...toRefs(state),
validators,
};
},
});
</script>
<style>
.force-white > a {
color: white !important;
}
</style>

View File

@@ -0,0 +1,78 @@
<template>
<v-form>
<div>
<v-card-title class="headline"> Import from Zip </v-card-title>
<v-card-text>
Import a single recipe that was exported from another Mealie instance.
<v-file-input
v-model="newRecipeZip"
accept=".zip"
label=".zip"
filled
clearable
class="rounded-lg mt-2"
rounded
truncate-length="100"
hint=".zip files must have been exported from Mealie"
persistent-hint
prepend-icon=""
:prepend-inner-icon="$globals.icons.zip"
>
</v-file-input>
</v-card-text>
<v-card-actions class="justify-center">
<div style="width: 250px">
<BaseButton :disabled="newRecipeZip === null" large rounded block :loading="loading" @click="createByZip" />
</div>
</v-card-actions>
</div>
</v-form>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, ref, useRouter } from "@nuxtjs/composition-api";
import { AxiosResponse } from "axios";
import { useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators";
export default defineComponent({
setup() {
const state = reactive({
error: false,
loading: false,
});
const api = useUserApi();
const router = useRouter();
function handleResponse(response: AxiosResponse<string> | null, edit = false) {
if (response?.status !== 201) {
state.error = true;
state.loading = false;
return;
}
router.push(`/recipe/${response.data}?edit=${edit.toString()}`);
}
const newRecipeZip = ref<File | null>(null);
const newRecipeZipFileName = "archive";
async function createByZip() {
if (!newRecipeZip.value) {
return;
}
const formData = new FormData();
formData.append(newRecipeZipFileName, newRecipeZip.value);
const { response } = await api.upload.file("/api/recipes/create-from-zip", formData);
handleResponse(response);
}
return {
newRecipeZip,
createByZip,
...toRefs(state),
validators,
};
},
});
</script>

View File

@@ -216,7 +216,7 @@ export default defineComponent({
if (searchString.value.trim() === "") { if (searchString.value.trim() === "") {
return filteredRecipes.value; return filteredRecipes.value;
} }
const result = fuse.value.search(searchString.value.trim()); const result = fuse.value.search(searchString.value.trim() as string);
return result.map((x) => x.item); return result.map((x) => x.item);
}); });

View File

@@ -1,16 +1,16 @@
import { Plugin } from "@nuxt/types"; import { Plugin } from "@nuxt/types";
import { NuxtAxiosInstance } from "@nuxtjs/axios"; import type { NuxtAxiosInstance } from "@nuxtjs/axios";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
const toastPlugin: Plugin = ({ $axios }: { $axios: NuxtAxiosInstance }) => { const toastPlugin: Plugin = ({ $axios }: { $axios: NuxtAxiosInstance }) => {
$axios.onResponse((response) => { $axios.onResponse((response) => {
if (response?.data?.message) { if (response?.data?.message) {
alert.info(response.data.message); alert.info(response.data.message as string);
} }
}); });
$axios.onError((error) => { $axios.onError((error) => {
if (error.response?.data?.detail?.message) { if (error.response?.data?.detail?.message) {
alert.error(error.response.data.detail.message); alert.error(error.response.data.detail.message as string);
} }
}); });
}; };

View File

@@ -17,7 +17,15 @@
"~/*": ["./*"], "~/*": ["./*"],
"@/*": ["./*"] "@/*": ["./*"]
}, },
"types": ["@nuxt/types", "@nuxtjs/axios", "@types/node", "@nuxtjs/i18n", "@nuxtjs/auth-next", "@nuxtjs/vuetify", "@types/sortablejs"] "types": [
"@nuxt/types",
"@nuxtjs/axios",
"@types/node",
"@nuxtjs/i18n",
"@nuxtjs/auth-next",
"@nuxtjs/vuetify",
"@types/sortablejs"
]
}, },
"include": ["**/*", ".eslintrc.js"], "include": ["**/*", ".eslintrc.js"],
"exclude": ["node_modules", ".nuxt", "dist"], "exclude": ["node_modules", ".nuxt", "dist"],

View File

@@ -5,7 +5,7 @@
/* Do not modify it by hand - just update the pydantic models and then re-run the script /* Do not modify it by hand - just update the pydantic models and then re-run the script
*/ */
export type ReportCategory = "backup" | "restore" | "migration"; export type ReportCategory = "backup" | "restore" | "migration" | "bulk_import";
export type ReportSummaryStatus = "in-progress" | "success" | "failure" | "partial"; export type ReportSummaryStatus = "in-progress" | "success" | "failure" | "partial";
export interface ReportCreate { export interface ReportCreate {

File diff suppressed because it is too large Load Diff

View File

@@ -7,10 +7,9 @@ import dotenv
from mealie.core.settings import app_settings_constructor from mealie.core.settings import app_settings_constructor
from .settings import AppDirectories, AppSettings from .settings import AppDirectories, AppSettings
from .settings.static import APP_VERSION, DB_VERSION from .settings.static import APP_VERSION
APP_VERSION APP_VERSION
DB_VERSION
CWD = Path(__file__).parent CWD = Path(__file__).parent
BASE_DIR = CWD.parent.parent BASE_DIR = CWD.parent.parent

View File

@@ -1,7 +1,6 @@
from pathlib import Path from pathlib import Path
APP_VERSION = "v1.0.0b" APP_VERSION = "v1.0.0beta-2"
DB_VERSION = "v1.0.0b"
CWD = Path(__file__).parent CWD = Path(__file__).parent
BASE_DIR = CWD.parent.parent.parent BASE_DIR = CWD.parent.parent.parent

View File

@@ -13,10 +13,10 @@
"email-conflict-error": "Diese E-Mail-Adresse wird bereits verwendet" "email-conflict-error": "Diese E-Mail-Adresse wird bereits verwendet"
}, },
"notifications": { "notifications": {
"generic-created": "{name} was created", "generic-created": "{name} wurde erstellt",
"generic-updated": "{name} was updated", "generic-updated": "{name} wurde aktualisiert",
"generic-created-with-url": "{name} has been created, {url}", "generic-created-with-url": "{name} wurde erstellt, {url}",
"generic-updated-with-url": "{name} has been updated, {url}", "generic-updated-with-url": "{name} wurde aktualisiert, {url}",
"generic-deleted": "{name} has been created" "generic-deleted": "{name} wurde erstellt"
} }
} }

View File

@@ -42,7 +42,7 @@
"liter": { "liter": {
"name": "Liter", "name": "Liter",
"description": "", "description": "",
"abbreviation": "l" "abbreviation": "L"
}, },
"pound": { "pound": {
"name": "Pfund", "name": "Pfund",

View File

@@ -1,6 +1,6 @@
from functools import cached_property from functools import cached_property
from fastapi import APIRouter from fastapi import APIRouter, HTTPException
from pydantic import UUID4 from pydantic import UUID4
from mealie.core.exceptions import mealie_registered_exceptions from mealie.core.exceptions import mealie_registered_exceptions
@@ -8,6 +8,7 @@ from mealie.routes._base.base_controllers import BaseUserController
from mealie.routes._base.controller import controller from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.mixins import HttpRepo
from mealie.schema.reports.reports import ReportCategory, ReportCreate, ReportOut, ReportSummary from mealie.schema.reports.reports import ReportCategory, ReportCreate, ReportOut, ReportSummary
from mealie.schema.response.responses import ErrorResponse, SuccessResponse
router = APIRouter(prefix="/groups/reports", tags=["Groups: Reports"]) router = APIRouter(prefix="/groups/reports", tags=["Groups: Reports"])
@@ -39,6 +40,10 @@ class GroupReportsController(BaseUserController):
def get_one(self, item_id: UUID4): def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id) return self.mixins.get_one(item_id)
@router.delete("/{item_id}", status_code=204) @router.delete("/{item_id}", status_code=200)
def delete_one(self, item_id: UUID4): def delete_one(self, item_id: UUID4):
self.mixins.delete_one(item_id) # type: ignore try:
self.mixins.delete_one(item_id) # type: ignore
return SuccessResponse.respond("Report deleted.")
except Exception as ex:
raise HTTPException(500, ErrorResponse.respond("Failed to delete report")) from ex

View File

@@ -9,7 +9,6 @@ from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from slugify import slugify from slugify import slugify
from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse from starlette.responses import FileResponse
from mealie.core import exceptions from mealie.core import exceptions
@@ -17,7 +16,6 @@ from mealie.core.dependencies import temporary_zip_path
from mealie.core.dependencies.dependencies import temporary_dir, validate_recipe_token from mealie.core.dependencies.dependencies import temporary_dir, validate_recipe_token
from mealie.core.security import create_recipe_slug_token from mealie.core.security import create_recipe_slug_token
from mealie.pkgs import cache from mealie.pkgs import cache
from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_recipes import RepositoryRecipes from mealie.repos.repository_recipes import RepositoryRecipes
from mealie.routes._base import BaseUserController, controller from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.mixins import HttpRepo
@@ -29,17 +27,16 @@ from mealie.schema.recipe.recipe_asset import RecipeAsset
from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest
from mealie.schema.recipe.request_helpers import RecipeZipTokenResponse, UpdateImageResponse from mealie.schema.recipe.request_helpers import RecipeZipTokenResponse, UpdateImageResponse
from mealie.schema.response.responses import ErrorResponse from mealie.schema.response.responses import ErrorResponse
from mealie.schema.server.tasks import ServerTaskNames
from mealie.services import urls from mealie.services import urls
from mealie.services.event_bus_service.event_bus_service import EventBusService from mealie.services.event_bus_service.event_bus_service import EventBusService
from mealie.services.event_bus_service.message_types import EventTypes from mealie.services.event_bus_service.message_types import EventTypes
from mealie.services.recipe.recipe_data_service import RecipeDataService from mealie.services.recipe.recipe_data_service import RecipeDataService
from mealie.services.recipe.recipe_service import RecipeService from mealie.services.recipe.recipe_service import RecipeService
from mealie.services.recipe.template_service import TemplateService from mealie.services.recipe.template_service import TemplateService
from mealie.services.scraper.recipe_bulk_scraper import RecipeBulkScraperService
from mealie.services.scraper.scraped_extras import ScraperContext from mealie.services.scraper.scraped_extras import ScraperContext
from mealie.services.scraper.scraper import create_from_url from mealie.services.scraper.scraper import create_from_url
from mealie.services.scraper.scraper_strategies import RecipeScraperPackage from mealie.services.scraper.scraper_strategies import RecipeScraperPackage
from mealie.services.server_tasks.background_executory import BackgroundExecutor
class BaseRecipeController(BaseUserController): class BaseRecipeController(BaseUserController):
@@ -172,39 +169,11 @@ class RecipeController(BaseRecipeController):
@router.post("/create-url/bulk", status_code=202) @router.post("/create-url/bulk", status_code=202)
def parse_recipe_url_bulk(self, bulk: CreateRecipeByUrlBulk, bg_tasks: BackgroundTasks): def parse_recipe_url_bulk(self, bulk: CreateRecipeByUrlBulk, bg_tasks: BackgroundTasks):
"""Takes in a URL and attempts to scrape data and load it into the database""" """Takes in a URL and attempts to scrape data and load it into the database"""
bg_executor = BackgroundExecutor(self.group.id, self.repos, bg_tasks) bulk_scraper = RecipeBulkScraperService(self.service, self.repos, self.group)
report_id = bulk_scraper.get_report_id()
bg_tasks.add_task(bulk_scraper.scrape, bulk)
def bulk_import_func(task_id: int, session: Session) -> None: return {"reportId": report_id}
database = get_repositories(session)
task = database.server_tasks.get_one(task_id)
task.append_log("test task has started")
for b in bulk.imports:
try:
recipe, _ = create_from_url(b.url)
if b.tags:
recipe.tags = b.tags
if b.categories:
recipe.recipe_category = b.categories
self.service.create_one(recipe)
task.append_log(f"INFO: Created recipe from url: {b.url}")
except Exception as e:
task.append_log(f"Error: Failed to create recipe from url: {b.url}")
task.append_log(f"Error: {e}")
self.deps.logger.error(f"Failed to create recipe from url: {b.url}")
self.deps.logger.error(e)
database.server_tasks.update(task.id, task)
task.set_finished()
database.server_tasks.update(task.id, task)
bg_executor.dispatch(ServerTaskNames.bulk_recipe_import, bulk_import_func)
return {"details": "task has been started"}
@router.post("/test-scrape-url") @router.post("/test-scrape-url")
def test_parse_recipe_url(self, url: ScrapeRecipeTest): def test_parse_recipe_url(self, url: ScrapeRecipeTest):

View File

@@ -11,6 +11,7 @@ class ReportCategory(str, enum.Enum):
backup = "backup" backup = "backup"
restore = "restore" restore = "restore"
migration = "migration" migration = "migration"
bulk_import = "bulk_import"
class ReportSummaryStatus(str, enum.Enum): class ReportSummaryStatus(str, enum.Enum):

View File

@@ -1,3 +1,4 @@
import contextlib
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
@@ -6,6 +7,7 @@ from pydantic import UUID4
from mealie.core import root_logger from mealie.core import root_logger
from mealie.repos.all_repositories import AllRepositories from mealie.repos.all_repositories import AllRepositories
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe_settings import RecipeSettings
from mealie.schema.reports.reports import ( from mealie.schema.reports.reports import (
ReportCategory, ReportCategory,
ReportCreate, ReportCreate,
@@ -25,7 +27,7 @@ class BaseMigrator(BaseService):
key_aliases: list[MigrationAlias] key_aliases: list[MigrationAlias]
report_entries: list[ReportEntryCreate] report_entries: list[ReportEntryCreate]
report_id: int report_id: UUID4
report: ReportOut report: ReportOut
helpers: DatabaseMigrationHelpers helpers: DatabaseMigrationHelpers
@@ -111,7 +113,19 @@ class BaseMigrator(BaseService):
return_vars = [] return_vars = []
group = self.db.groups.get_one(self.group_id)
default_settings = RecipeSettings(
public=group.preferences.recipe_public,
show_nutrition=group.preferences.recipe_show_nutrition,
show_assets=group.preferences.recipe_show_assets,
landscape_view=group.preferences.recipe_landscape_view,
disable_comments=group.preferences.recipe_disable_comments,
disable_amount=group.preferences.recipe_disable_amount,
)
for recipe in validated_recipes: for recipe in validated_recipes:
recipe.settings = default_settings
recipe.user_id = self.user_id recipe.user_id = self.user_id
recipe.group_id = self.group_id recipe.group_id = self.group_id
@@ -125,7 +139,7 @@ class BaseMigrator(BaseService):
if self.add_migration_tag: if self.add_migration_tag:
recipe.tags.append(migration_tag) recipe.tags.append(migration_tag)
exception = "" exception: str | Exception = ""
status = False status = False
try: try:
recipe = self.db.recipes.create(recipe) recipe = self.db.recipes.create(recipe)
@@ -189,10 +203,8 @@ class BaseMigrator(BaseService):
""" """
recipe_dict = self.rewrite_alias(recipe_dict) recipe_dict = self.rewrite_alias(recipe_dict)
try: with contextlib.suppress(KeyError):
del recipe_dict["id"] del recipe_dict["id"]
except KeyError:
pass
recipe_dict = cleaner.clean(recipe_dict, url=recipe_dict.get("org_url", None)) recipe_dict = cleaner.clean(recipe_dict, url=recipe_dict.get("org_url", None))

View File

@@ -0,0 +1,104 @@
from pydantic import UUID4
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe import CreateRecipeByUrlBulk
from mealie.schema.reports.reports import ReportCategory, ReportCreate, ReportEntryCreate, ReportSummaryStatus
from mealie.schema.user.user import GroupInDB
from mealie.services._base_service import BaseService
from mealie.services.recipe.recipe_service import RecipeService
from mealie.services.scraper.scraper import create_from_url
class RecipeBulkScraperService(BaseService):
report_entries: list[ReportEntryCreate]
def __init__(self, service: RecipeService, repos: AllRepositories, group: GroupInDB) -> None:
self.service = service
self.repos = repos
self.group = group
self.report_entries = []
super().__init__()
def get_report_id(self) -> UUID4:
import_report = ReportCreate(
name="Bulk Import",
category=ReportCategory.bulk_import,
status=ReportSummaryStatus.in_progress,
group_id=self.group.id,
)
self.report = self.repos.group_reports.create(import_report)
return self.report.id
def _add_error_entry(self, message: str, exception: str = "") -> None:
self.report_entries.append(
ReportEntryCreate(
report_id=self.report.id,
success=False,
message=message,
exception=exception,
)
)
def _save_all_entries(self) -> None:
is_success = True
is_failure = True
for entry in self.report_entries:
if is_failure and entry.success:
is_failure = False
if is_success and not entry.success:
is_success = False
self.repos.group_report_entries.create(entry)
if is_success:
self.report.status = ReportSummaryStatus.success
if is_failure:
self.report.status = ReportSummaryStatus.failure
if not is_success and not is_failure:
self.report.status = ReportSummaryStatus.partial
self.repos.group_reports.update(self.report.id, self.report)
def scrape(self, urls: CreateRecipeByUrlBulk) -> None:
if self.report is None:
self.get_report_id()
for b in urls.imports:
try:
recipe, _ = create_from_url(b.url)
except Exception as e:
self.service.logger.error(f"failed to scrape url during bulk url import {b.url}")
self.service.logger.exception(e)
self._add_error_entry(f"failed to scrape url {b.url}", str(e))
break
if b.tags:
recipe.tags = b.tags
if b.categories:
recipe.recipe_category = b.categories
try:
self.service.create_one(recipe)
except Exception as e:
self.service.logger.error(f"Failed to save recipe to database during bulk url import {b.url}")
self.service.logger.exception(e)
self._add_error_entry("Failed to save recipe to database during bulk url import", str(e))
self.report_entries.append(
ReportEntryCreate(
report_id=self.report.id,
success=True,
message=f"Successfully imported recipe {recipe.name}",
exception="",
)
)
self._save_all_entries()

8
poetry.lock generated
View File

@@ -1188,7 +1188,7 @@ rdflib = ">=5.0.0"
[[package]] [[package]]
name = "recipe-scrapers" name = "recipe-scrapers"
version = "13.33.0" version = "14.1.0"
description = "Python package, scraping recipes from all over the internet" description = "Python package, scraping recipes from all over the internet"
category = "main" category = "main"
optional = false optional = false
@@ -1545,7 +1545,7 @@ pgsql = ["psycopg2-binary"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "a84820195816740ce9200a8a9bf1cc8568cc30c1f97264cc2506b1f6289d1883" content-hash = "45c28207b80dd8ecd82030410c132be32e8f2e46925c92641d4dd1626fec7786"
[metadata.files] [metadata.files]
aiofiles = [ aiofiles = [
@@ -2437,8 +2437,8 @@ rdflib-jsonld = [
{file = "rdflib_jsonld-0.6.2-py2.py3-none-any.whl", hash = "sha256:011afe67672353ca9978ab9a4bee964dff91f14042f2d8a28c22a573779d2f8b"}, {file = "rdflib_jsonld-0.6.2-py2.py3-none-any.whl", hash = "sha256:011afe67672353ca9978ab9a4bee964dff91f14042f2d8a28c22a573779d2f8b"},
] ]
recipe-scrapers = [ recipe-scrapers = [
{file = "recipe_scrapers-13.33.0-py3-none-any.whl", hash = "sha256:c350ee2407167ec62327a1db9e8864f49a51cae06907689f9095885444549293"}, {file = "recipe_scrapers-14.1.0-py3-none-any.whl", hash = "sha256:fc4bf1d5bd142e63a81b1b734a874e2fd6686b22287b41a9bc1355c2004bd5f7"},
{file = "recipe_scrapers-13.33.0.tar.gz", hash = "sha256:be1742077bca55638392446b55bf7d2e80a9f9a9625285dc30efd02c763461ec"}, {file = "recipe_scrapers-14.1.0.tar.gz", hash = "sha256:5c6931dc13cdb458f7ce52c2fae6a63348ee826a9e0f71ba7679a3d3f7a9257b"},
] ]
requests = [ requests = [
{file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},

View File

@@ -30,7 +30,7 @@ passlib = "^1.7.4"
lxml = "^4.7.1" lxml = "^4.7.1"
Pillow = "^8.2.0" Pillow = "^8.2.0"
apprise = "^0.9.6" apprise = "^0.9.6"
recipe-scrapers = "^13.33.0" recipe-scrapers = "^14.1.0"
psycopg2-binary = {version = "^2.9.1", optional = true} psycopg2-binary = {version = "^2.9.1", optional = true}
gunicorn = "^20.1.0" gunicorn = "^20.1.0"
emails = "^0.6" emails = "^0.6"