mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-24 08:43:11 -05:00
Compare commits
28 Commits
v1.0.0-bet
...
v1.0.0beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b981cf62bf | ||
|
|
ee93d77ace | ||
|
|
3dcfcc1fa9 | ||
|
|
80f1a9add8 | ||
|
|
137bf9de91 | ||
|
|
1534f0df77 | ||
|
|
d751e3b35b | ||
|
|
07bf5be3ec | ||
|
|
a96f94a149 | ||
|
|
78a8204b58 | ||
|
|
649e34f66e | ||
|
|
010aafa69b | ||
|
|
d66d6c55ae | ||
|
|
7609715d9e | ||
|
|
921fceddea | ||
|
|
01f3fef21f | ||
|
|
8f7c7c39bb | ||
|
|
30d19c6503 | ||
|
|
ea503a0235 | ||
|
|
c05c123880 | ||
|
|
6f45de6167 | ||
|
|
d634e2fbe1 | ||
|
|
43a566339a | ||
|
|
cc284a0ceb | ||
|
|
3c19105d8b | ||
|
|
b8ee1a4bd8 | ||
|
|
c30ffbc851 | ||
|
|
3ddbc033b2 |
6
.github/stale.yml
vendored
6
.github/stale.yml
vendored
@@ -6,8 +6,12 @@ daysUntilClose: 7
|
||||
exemptLabels:
|
||||
- pinned
|
||||
- security
|
||||
- early-stages
|
||||
- "bug: confirmed"
|
||||
- feedback
|
||||
- task
|
||||
# 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
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
|
||||
4
.github/workflows/backend-docker-nightly.yml
vendored
4
.github/workflows/backend-docker-nightly.yml
vendored
@@ -5,6 +5,10 @@ on:
|
||||
branches:
|
||||
- mealie-next
|
||||
|
||||
concurrency:
|
||||
group: backend-nightly-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -5,6 +5,10 @@ on:
|
||||
branches:
|
||||
- mealie-next
|
||||
|
||||
concurrency:
|
||||
group: frontend-nightly-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@@ -46,5 +46,12 @@
|
||||
"python.linting.mypyEnabled": true,
|
||||
"python.sortImports.path": "${workspaceFolder}/.venv/bin/isort",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
18
README.md
18
README.md
@@ -5,12 +5,6 @@
|
||||
[![MIT License][license-shield]][license-url]
|
||||
[![Docker Pulls][docker-pull]][docker-pull]
|
||||
[](https://www.codefactor.io/repository/github/hay-kot/mealie)
|
||||
[](https://github.com/hay-kot/mealie/actions/workflows/dockerbuild.release.yml)
|
||||
[](https://github.com/hay-kot/mealie/actions/workflows/test-all.yml)
|
||||
[](https://github.com/hay-kot/mealie/actions/workflows/dockerbuild.dev.yml)
|
||||
[](https://github.com/hay-kot/mealie/actions/workflows/test-all.yml)
|
||||
|
||||
|
||||
|
||||
<!-- PROJECT LOGO -->
|
||||
<br />
|
||||
@@ -26,20 +20,14 @@
|
||||
<p align="center">
|
||||
A Place for All Your Recipes
|
||||
<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>
|
||||
<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://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>
|
||||
</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.
|
||||
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# WARNING: currently not functional, see #756, #1072
|
||||
# Use root/example as user/password credentials
|
||||
version: "3.4"
|
||||
services:
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
auto_https off
|
||||
}
|
||||
|
||||
:80 {
|
||||
root * /srv
|
||||
encode gzip
|
||||
uri strip_suffix /
|
||||
|
||||
handle {
|
||||
try_files {path} {path}/ /index.html
|
||||
file_server
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,11 +0,0 @@
|
||||
version: "3"
|
||||
services:
|
||||
wiki:
|
||||
container_name: mealie-docs
|
||||
image: mealie-docs
|
||||
ports:
|
||||
- 8888:80
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: always
|
||||
29
docs/docs/changelog/v1.0.0beta-2.md
Normal file
29
docs/docs/changelog/v1.0.0beta-2.md
Normal 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))
|
||||
@@ -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.
|
||||
|
||||
## With VS Code Dev Containers
|
||||
## With [VSCode Dev Containers](https://code.visualstudio.com/docs/remote/containers)
|
||||
|
||||
Prerequisites
|
||||
|
||||
@@ -110,7 +110,7 @@ frontend 🎬 Start Mealie Frontend Development Server
|
||||
frontend-build 🏗 Build Frontend in frontend/dist
|
||||
frontend-generate 🏗 Generate Code for Frontend
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
[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
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
version: "3.7"
|
||||
services:
|
||||
mealie-frontend:
|
||||
image: hkotel/mealie:frontend-nightly
|
||||
image: hkotel/mealie:frontend-v1.0.0-beta-1
|
||||
container_name: mealie-frontend
|
||||
depends_on:
|
||||
- mealie-api
|
||||
@@ -23,7 +23,7 @@ services:
|
||||
volumes:
|
||||
- mealie-data:/app/data/ # (3)
|
||||
mealie-api:
|
||||
image: hkotel/mealie:api-nightly
|
||||
image: hkotel/mealie:api-v1.0.0-beta-1
|
||||
container_name: mealie-api
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
@@ -12,7 +12,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
||||
version: "3.7"
|
||||
services:
|
||||
mealie-frontend:
|
||||
image: hkotel/mealie:frontend-nightly
|
||||
image: hkotel/mealie:frontend-v1.0.0-beta-1
|
||||
container_name: mealie-frontend
|
||||
environment:
|
||||
# Set Frontend ENV Variables Here
|
||||
@@ -23,7 +23,7 @@ services:
|
||||
volumes:
|
||||
- mealie-data:/app/data/ # (3)
|
||||
mealie-api:
|
||||
image: hkotel/mealie:api-nightly
|
||||
image: hkotel/mealie:api-v1.0.0-beta-1
|
||||
container_name: mealie-api
|
||||
volumes:
|
||||
- mealie-data:/app/data/
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -88,6 +88,7 @@ nav:
|
||||
- Improving Ingredient Parser: "contributors/guides/ingredient-parser.md"
|
||||
|
||||
- Change Log:
|
||||
- v1.0.0beta-2: "changelog/v1.0.0beta-2.md"
|
||||
- v1.0.0 Beta: "changelog/v1.0.0.md"
|
||||
- v0.5.2 Misc Updates: "changelog/v0.5.2.md"
|
||||
- v0.5.1 Bug Fixes: "changelog/v0.5.1.md"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
settings: {
|
||||
"import/ignore": ["@vueuse*"],
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
@@ -35,6 +38,7 @@ module.exports = {
|
||||
"vue/singleline-html-element-content-newline": "off",
|
||||
"vue/multiline-html-element-content-newline": "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/valid-v-slot": [
|
||||
"error",
|
||||
|
||||
@@ -28,9 +28,13 @@
|
||||
</v-list-item-avatar>
|
||||
|
||||
<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>
|
||||
{{ $d(Date.parse(item.timeStamp), "long") }}
|
||||
</v-list-item-subtitle>
|
||||
@@ -103,5 +107,4 @@ export default defineComponent({
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -10,13 +10,17 @@
|
||||
<v-list-item-icon class="ma-auto">
|
||||
<v-tooltip bottom>
|
||||
<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>
|
||||
<span>{{ getIconDefinition(item.icon).title }}</span>
|
||||
</v-tooltip>
|
||||
</v-list-item-icon>
|
||||
<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-action>
|
||||
<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">
|
||||
<v-spacer></v-spacer>
|
||||
<BaseDialog
|
||||
v-model="newAssetDialog"
|
||||
:title="$t('asset.new-asset')"
|
||||
:icon="getIconDefinition(newAsset.icon).icon"
|
||||
v-model="state.newAssetDialog"
|
||||
:title="$tc('asset.new-asset')"
|
||||
:icon="getIconDefinition(state.newAsset.icon).icon"
|
||||
@submit="addAsset"
|
||||
>
|
||||
<template #activator>
|
||||
<BaseButton v-if="edit" small create @click="newAssetDialog = true" />
|
||||
</template>
|
||||
<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">
|
||||
<v-select
|
||||
v-model="newAsset.icon"
|
||||
v-model="state.newAsset.icon"
|
||||
dense
|
||||
:prepend-icon="getIconDefinition(newAsset.icon).icon"
|
||||
:prepend-icon="getIconDefinition(state.newAsset.icon).icon"
|
||||
:items="iconOptions"
|
||||
item-text="title"
|
||||
item-value="name"
|
||||
@@ -66,7 +70,7 @@
|
||||
</v-select>
|
||||
<AppButtonUpload :post="false" file-name="file" :text-btn="false" @uploaded="setFileObject" />
|
||||
</div>
|
||||
{{ fileObject.name }}
|
||||
{{ state.fileObject.name }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
@@ -74,9 +78,10 @@
|
||||
</template>
|
||||
|
||||
<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 { alert } from "~/composables/use-toast";
|
||||
import { RecipeAsset } from "~/types/api-types/recipe";
|
||||
|
||||
const BASE_URL = window.location.origin;
|
||||
|
||||
@@ -91,7 +96,7 @@ export default defineComponent({
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: Array,
|
||||
type: Array as () => RecipeAsset[],
|
||||
required: true,
|
||||
},
|
||||
edit: {
|
||||
@@ -181,7 +186,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
state,
|
||||
addAsset,
|
||||
assetURL,
|
||||
assetEmbed,
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<v-list dense>
|
||||
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
|
||||
<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-title>{{ item.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<v-list dense>
|
||||
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
|
||||
<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-title>{{ item.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
@@ -307,7 +307,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
// Note: Print is handled as an event in the parent component
|
||||
const eventHandlers: { [key: string]: () => void } = {
|
||||
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
||||
delete: () => {
|
||||
state.recipeDeleteDialog = true;
|
||||
},
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
<img src="https://i.pravatar.cc/300" alt="John" />
|
||||
</v-list-item-avatar>
|
||||
<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>
|
||||
</template>
|
||||
@@ -95,31 +97,30 @@ export default defineComponent({
|
||||
context.emit(INPUT_EVENT, value);
|
||||
}
|
||||
|
||||
const show = props.showHeaders;
|
||||
const headers = computed(() => {
|
||||
const hdrs = [];
|
||||
|
||||
if (show.id) {
|
||||
if (props.showHeaders.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: "Name", value: "name" });
|
||||
if (show.categories) {
|
||||
if (props.showHeaders.categories) {
|
||||
hdrs.push({ text: "Categories", value: "recipeCategory" });
|
||||
}
|
||||
|
||||
if (show.tags) {
|
||||
if (props.showHeaders.tags) {
|
||||
hdrs.push({ text: "Tags", value: "tags" });
|
||||
}
|
||||
if (show.tools) {
|
||||
if (props.showHeaders.tools) {
|
||||
hdrs.push({ text: "Tools", value: "tools" });
|
||||
}
|
||||
if (show.recipeYield) {
|
||||
if (props.showHeaders.recipeYield) {
|
||||
hdrs.push({ text: "Yield", value: "recipeYield" });
|
||||
}
|
||||
if (show.dateAdded) {
|
||||
if (props.showHeaders.dateAdded) {
|
||||
hdrs.push({ text: "Date Added", value: "dateAdded" });
|
||||
}
|
||||
|
||||
|
||||
@@ -57,8 +57,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, toRefs, reactive, useContext } from "@nuxtjs/composition-api";
|
||||
import { whenever } from "@vueuse/shared";
|
||||
import { useClipboard, useShare } from "@vueuse/core";
|
||||
import { useClipboard, useShare, whenever } from "@vueuse/core";
|
||||
import { RecipeShareToken } from "~/types/api-types/recipe";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
|
||||
@@ -134,8 +134,6 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { value } = props;
|
||||
|
||||
// ==================================================
|
||||
// Foods
|
||||
const { foods, workingFoodData, actions: foodActions } = useFoods();
|
||||
@@ -144,7 +142,7 @@ export default defineComponent({
|
||||
async function createAssignFood() {
|
||||
workingFoodData.name = foodSearch.value;
|
||||
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() {
|
||||
workingUnitData.name = unitSearch.value;
|
||||
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({
|
||||
@@ -165,7 +163,7 @@ export default defineComponent({
|
||||
|
||||
function toggleTitle() {
|
||||
if (state.showTitle) {
|
||||
value.title = "";
|
||||
props.value.title = "";
|
||||
}
|
||||
state.showTitle = !state.showTitle;
|
||||
}
|
||||
@@ -175,13 +173,21 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -202,7 +208,7 @@ export default defineComponent({
|
||||
// });
|
||||
// }
|
||||
|
||||
if (value.originalText) {
|
||||
if (props.value.originalText) {
|
||||
options.push({
|
||||
text: "See Original Text",
|
||||
event: "toggle-original",
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
@click="toggleCollapseSection(index)"
|
||||
>
|
||||
<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-text-field
|
||||
v-if="edit"
|
||||
|
||||
@@ -1,68 +1,58 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="recipe" class="container print">
|
||||
<div>
|
||||
<h1>
|
||||
<svg class="icon" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="#E58325"
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
{{ recipe.name }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="time-container">
|
||||
<RecipeTimeCard
|
||||
:prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
:perform-time="recipe.performTime"
|
||||
/>
|
||||
</div>
|
||||
<v-btn
|
||||
v-if="recipe.recipeYield"
|
||||
dense
|
||||
small
|
||||
: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 class="print-container">
|
||||
<section>
|
||||
<v-card-title class="headline pl-0">
|
||||
<v-icon left color="primary">
|
||||
{{ $globals.icons.primary }}
|
||||
</v-icon>
|
||||
{{ recipe.name }}
|
||||
</v-card-title>
|
||||
<RecipeTimeCard :prep-time="recipe.prepTime" :total-time="recipe.totalTime" :perform-time="recipe.performTime" />
|
||||
</section>
|
||||
|
||||
<v-card-text class="px-0">
|
||||
<VueMarkdown :source="recipe.description" />
|
||||
</v-card-text>
|
||||
|
||||
<section>
|
||||
<v-card-title class="headline pl-0"> {{ $t("recipe.ingredients") }} </v-card-title>
|
||||
<div class="ingredient-grid">
|
||||
<div class="ingredient-col-1">
|
||||
<ul>
|
||||
<li v-for="(text, index) in splitIngredients.value.firstHalf" :key="index">
|
||||
{{ text }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<v-divider v-if="recipe.notes.length > 0" class="mb-5 mt-0"></v-divider>
|
||||
|
||||
<div v-for="(note, index) in recipe.notes" :key="index + 'note'">
|
||||
<h3>{{ note.title }}</h3>
|
||||
<VueMarkdown :source="note.text"> </VueMarkdown>
|
||||
<div class="ingredient-col-2">
|
||||
<ul>
|
||||
<li v-for="(text, index) in splitIngredients.value.secondHalf" :key="index">
|
||||
{{ text }}
|
||||
</li>
|
||||
</ul>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -70,8 +60,16 @@
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
// @ts-ignore vue-markdown has no types
|
||||
import VueMarkdown from "@adapttive/vue-markdown";
|
||||
import { computed } from "@vue/reactivity";
|
||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||
import { Recipe } from "~/types/api-types/recipe";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
|
||||
type SplitIngredients = {
|
||||
firstHalf: string[];
|
||||
secondHalf: string[];
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeTimeCard,
|
||||
@@ -83,6 +81,36 @@ export default defineComponent({
|
||||
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>
|
||||
|
||||
@@ -90,26 +118,46 @@ export default defineComponent({
|
||||
@media print {
|
||||
body,
|
||||
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 {
|
||||
margin-top: 0 !important;
|
||||
display: -webkit-box;
|
||||
display: flex;
|
||||
font-size: 2rem;
|
||||
letter-spacing: -0.015625em;
|
||||
font-weight: 300;
|
||||
padding: 0;
|
||||
<style scoped>
|
||||
.print-container {
|
||||
display: none;
|
||||
background-color: white;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 0.25rem;
|
||||
p {
|
||||
padding-bottom: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 0.25rem;
|
||||
.v-card__text {
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ingredient-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
@@ -117,51 +165,7 @@ ul {
|
||||
}
|
||||
|
||||
li {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
margin-left: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
list-style-type: none;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -145,6 +145,8 @@ import { AutoFormItems } from "~/types/auto-forms";
|
||||
|
||||
const BLUR_EVENT = "blur";
|
||||
|
||||
type ValidatorKey = keyof typeof validators;
|
||||
|
||||
export default defineComponent({
|
||||
name: "AutoForm",
|
||||
props: {
|
||||
@@ -178,7 +180,7 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
function rulesByKey(keys?: string[] | null) {
|
||||
function rulesByKey(keys?: ValidatorKey[] | null) {
|
||||
if (keys === undefined || keys === null) {
|
||||
return [];
|
||||
}
|
||||
@@ -193,7 +195,7 @@ export default defineComponent({
|
||||
return list;
|
||||
}
|
||||
|
||||
const defaultRules = computed(() => rulesByKey(props.globalRules));
|
||||
const defaultRules = computed(() => rulesByKey(props.globalRules as ValidatorKey[]));
|
||||
|
||||
function removeByIndex(list: never[], index: number) {
|
||||
// Removes the item at the index
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
class="text-start v-card--material__heading mb-n6 mt-n10 pa-7"
|
||||
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" />
|
||||
</v-sheet>
|
||||
</slot>
|
||||
|
||||
@@ -2,18 +2,10 @@
|
||||
<BaseDialog v-model="dialog" :icon="$globals.icons.translate" :title="$tc('language-dialog.choose-language')">
|
||||
<v-card-text>
|
||||
{{ $t("language-dialog.select-description") }}
|
||||
<v-autocomplete
|
||||
v-model="locale"
|
||||
:items="locales"
|
||||
item-text="name"
|
||||
class="my-3"
|
||||
hide-details
|
||||
outlined
|
||||
offset
|
||||
>
|
||||
<v-autocomplete v-model="locale" :items="locales" item-text="name" class="my-3" hide-details outlined offset>
|
||||
<template #item="{ item }">
|
||||
<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-content>
|
||||
</template>
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
<slot v-bind="{ state, toggle }"></slot>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, watch } from "@nuxtjs/composition-api";
|
||||
import { useToggle } from "@vueuse/shared";
|
||||
import { useToggle } from "@vueuse/core";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -34,4 +34,3 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useContext } from "@nuxtjs/composition-api";
|
||||
import { NuxtAxiosInstance } from "@nuxtjs/axios";
|
||||
import type { NuxtAxiosInstance } from "@nuxtjs/axios";
|
||||
import { AdminAPI, Api } from "~/api";
|
||||
import { ApiRequestInstance, RequestResponse } from "~/types/api";
|
||||
import { PublicApi } from "~/api/public-api";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"about": "Über",
|
||||
"about-mealie": "Über Mealie",
|
||||
"api-docs": "API Dokumentation",
|
||||
"api-port": "API Port",
|
||||
"api-port": "API-Port",
|
||||
"application-mode": "Anwendungsmodus",
|
||||
"database-type": "Datenbanktyp",
|
||||
"database-url": "Datenbank URL",
|
||||
@@ -33,7 +33,7 @@
|
||||
"show-assets": "Anhänge anzeigen"
|
||||
},
|
||||
"category": {
|
||||
"categories": "Categories",
|
||||
"categories": "Kategorien",
|
||||
"category-created": "Kategorie angelegt",
|
||||
"category-creation-failed": "Anlegen der Kategorie fehlgeschlagen",
|
||||
"category-deleted": "Kategorie entfernt",
|
||||
@@ -44,7 +44,7 @@
|
||||
"uncategorized-count": "{count} nicht kategorisierte"
|
||||
},
|
||||
"events": {
|
||||
"apprise-url": "Apprise URL",
|
||||
"apprise-url": "Apprise-URL",
|
||||
"database": "Datenbank",
|
||||
"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.",
|
||||
@@ -66,7 +66,7 @@
|
||||
"create": "Erstellen",
|
||||
"created": "Erstellt",
|
||||
"custom": "Benutzerdefiniert",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard": "Übersicht",
|
||||
"delete": "Löschen",
|
||||
"disabled": "Deaktiviert",
|
||||
"download": "Herunterladen",
|
||||
@@ -88,7 +88,7 @@
|
||||
"image-upload-failed": "Das Bild konnte nicht hochgeladen werden",
|
||||
"import": "Importieren",
|
||||
"json": "JSON",
|
||||
"keyword": "Keyword",
|
||||
"keyword": "Schlüsselwort",
|
||||
"link-copied": "Link kopiert",
|
||||
"loading-recipes": "Lade Rezepte",
|
||||
"monday": "Montag",
|
||||
@@ -118,7 +118,7 @@
|
||||
"success-count": "Erfolgreich: {count}",
|
||||
"sunday": "Sonntag",
|
||||
"templates": "Vorlagen:",
|
||||
"test": "Test",
|
||||
"test": "Testen",
|
||||
"themes": "Themen",
|
||||
"thursday": "Donnerstag",
|
||||
"token": "Token",
|
||||
@@ -131,10 +131,10 @@
|
||||
"view": "Ansicht",
|
||||
"wednesday": "Mittwoch",
|
||||
"yes": "Ja",
|
||||
"foods": "Foods",
|
||||
"units": "Units",
|
||||
"back": "Back",
|
||||
"next": "Next"
|
||||
"foods": "Speisen",
|
||||
"units": "Einheiten",
|
||||
"back": "Zurück",
|
||||
"next": "Weiter"
|
||||
},
|
||||
"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?",
|
||||
@@ -156,8 +156,8 @@
|
||||
"user-group-created": "Benutzergruppe angelegt",
|
||||
"user-group-creation-failed": "Anlegen der Benutzergruppe fehlgeschlagen",
|
||||
"settings": {
|
||||
"keep-my-recipes-private": "Keep My Recipes Private",
|
||||
"keep-my-recipes-private-description": "Sets your group and all recipes defaults to private. You can always change this later."
|
||||
"keep-my-recipes-private": "Meine Rezepte privat halten",
|
||||
"keep-my-recipes-private-description": "Setzt Ihre Gruppe und alle Rezepte standardmäßig privat. Sie können dies später jederzeit ändern."
|
||||
}
|
||||
},
|
||||
"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-title": "Anscheinend konnten wir nichts finden",
|
||||
"from-url": "Von URL",
|
||||
"github-issues": "GitHub Issues",
|
||||
"github-issues": "GitHub Fehlermeldungen",
|
||||
"google-ld-json-info": "Google ld+json Info",
|
||||
"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",
|
||||
@@ -216,9 +216,9 @@
|
||||
"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",
|
||||
"view-scraped-data": "Gesammelte Daten anzeigen",
|
||||
"trim-whitespace-description": "Trim leading and trailing whitespace as well as blank lines",
|
||||
"trim-prefix-description": "Trim first character from each line",
|
||||
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns"
|
||||
"trim-whitespace-description": "Leerzeichen am Anfang und Ende sowie leere Zeilen entfernen",
|
||||
"trim-prefix-description": "Erste Zeichen aus jeder Zeile entfernen",
|
||||
"split-by-numbered-line-description": "Absätze nach dem Schema '1)' oder '1.' aufzuteilen versuchen"
|
||||
},
|
||||
"page": {
|
||||
"404-page-not-found": "404 Seite nicht gefunden",
|
||||
@@ -291,7 +291,7 @@
|
||||
"title": "Titel",
|
||||
"total-time": "Gesamtzeit",
|
||||
"unable-to-delete-recipe": "Rezept kann nicht gelöscht werden",
|
||||
"no-recipe": "No Recipe"
|
||||
"no-recipe": "Kein Rezept"
|
||||
},
|
||||
"search": {
|
||||
"advanced-search": "Erweiterte Suche",
|
||||
@@ -396,7 +396,7 @@
|
||||
"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",
|
||||
"webhook-url": "Webhook URL",
|
||||
"webhook-url": "Webhook-URL",
|
||||
"webhooks-caps": "WEBHOOKS",
|
||||
"webhooks": "Webhooks"
|
||||
}
|
||||
@@ -413,9 +413,9 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"all-recipes": "Alle Rezepte",
|
||||
"backups": "Backups",
|
||||
"backups": "Sicherungen",
|
||||
"categories": "Kategorien",
|
||||
"cookbooks": "Cookbooks",
|
||||
"cookbooks": "Kochbücher",
|
||||
"dashboard": "Übersicht",
|
||||
"home-page": "Startseite",
|
||||
"manage-users": "Benutzer",
|
||||
@@ -425,7 +425,7 @@
|
||||
"site-settings": "Einstellungen",
|
||||
"tags": "Schlagworte",
|
||||
"toolbox": "Werkzeuge",
|
||||
"language": "Language"
|
||||
"language": "Sprache"
|
||||
},
|
||||
"signup": {
|
||||
"error-signing-up": "Fehler beim Registrieren",
|
||||
@@ -448,7 +448,7 @@
|
||||
"untagged-count": "{count} ohne Schlagworte"
|
||||
},
|
||||
"tool": {
|
||||
"tools": "Tools"
|
||||
"tools": "Werkzeuge"
|
||||
},
|
||||
"user": {
|
||||
"admin": "Admin",
|
||||
@@ -467,7 +467,7 @@
|
||||
"error-cannot-delete-super-user": "Fehler! Super Benutzer kann nicht gelöscht werden",
|
||||
"existing-password-does-not-match": "Bestehendes Passwort stimmt nicht überein",
|
||||
"full-name": "Vollständiger Name",
|
||||
"invite-only": "Invite Only",
|
||||
"invite-only": "Nur auf Einladung",
|
||||
"link-id": "Linkkennung",
|
||||
"link-name": "Linkname",
|
||||
"login": "Anmeldung",
|
||||
@@ -480,8 +480,8 @@
|
||||
"password-reset-failed": "Zurücksetzen des Passworts fehlgeschlagen",
|
||||
"password-updated": "Passwort aktualisiert",
|
||||
"password": "Passwort",
|
||||
"password-strength": "Password is {strength}",
|
||||
"register": "Register",
|
||||
"password-strength": "Das Passwort ist {strength}",
|
||||
"register": "Registrieren",
|
||||
"reset-password": "Passwort zurücksetzen",
|
||||
"sign-in": "Einloggen",
|
||||
"total-mealplans": "Alle Essenspläne",
|
||||
@@ -505,45 +505,45 @@
|
||||
"webhooks-enabled": "Webhooks aktiviert",
|
||||
"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",
|
||||
"enable-advanced-content": "Enable Advanced Content",
|
||||
"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": "Erweiterten Inhalt aktivieren",
|
||||
"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": {
|
||||
"translated": "translated",
|
||||
"choose-language": "Choose Language",
|
||||
"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!",
|
||||
"read-the-docs": "Read the docs"
|
||||
"translated": "übersetzt",
|
||||
"choose-language": "Sprache wählen",
|
||||
"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": "Ist etwas noch nicht übersetzt, falsch oder deine Sprache fehlt in der Liste? {read-the-docs-link} wie man beiträgt!",
|
||||
"read-the-docs": "Dokumentation lesen"
|
||||
},
|
||||
"data-pages": {
|
||||
"seed-data": "Seed Data",
|
||||
"seed-data": "Musterdaten",
|
||||
"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-food-example": "Merging {food1} into {food2}",
|
||||
"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-warning": "You have already have some items in your database. This action will not reconcile duplicates, you will have to manage them manually."
|
||||
"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": "{food1} wird zu {food2} zusammengeführt",
|
||||
"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": "Sie haben bereits einige Elemente in Ihrer Datenbank. Diese Aktion wird Duplikate nicht ausgleichen, Sie müssen sie manuell verwalten."
|
||||
},
|
||||
"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": {
|
||||
"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",
|
||||
"join-a-group": "Join a Group",
|
||||
"create-a-new-group": "Create a New Group",
|
||||
"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-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-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"
|
||||
"user-registration": "Benutzerregistrierung",
|
||||
"join-a-group": "Gruppe beitreten",
|
||||
"create-a-new-group": "Neue Gruppe erstellen",
|
||||
"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": "Gruppendetails",
|
||||
"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": "Musterdaten",
|
||||
"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": "Kontoinformationen"
|
||||
},
|
||||
"validation": {
|
||||
"group-name-is-taken": "Group name is taken",
|
||||
"username-is-taken": "Username is taken",
|
||||
"email-is-taken": "Email is taken"
|
||||
"group-name-is-taken": "Gruppenname ist schon vergeben",
|
||||
"username-is-taken": "Benutzername ist schon vergeben",
|
||||
"email-is-taken": "E-Mail-Adresse ist schon vergeben"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
"language-dialog": {
|
||||
"translated": "translated",
|
||||
"choose-language": "Choose Language",
|
||||
"translated": "traduit",
|
||||
"choose-language": "Choisir la langue",
|
||||
"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!",
|
||||
"read-the-docs": "Read the docs"
|
||||
@@ -532,18 +532,18 @@
|
||||
},
|
||||
"user-registration": {
|
||||
"user-registration": "User Registration",
|
||||
"join-a-group": "Join a Group",
|
||||
"create-a-new-group": "Create a New Group",
|
||||
"join-a-group": "Rejoindre un groupe",
|
||||
"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.",
|
||||
"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!",
|
||||
"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.",
|
||||
"account-details": "Account Details"
|
||||
"account-details": "Détails du compte"
|
||||
},
|
||||
"validation": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,17 @@
|
||||
<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-avatar>
|
||||
<v-icon v-text="item.icon"></v-icon>
|
||||
<v-icon>
|
||||
{{ item.icon }}
|
||||
</v-icon>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title v-text="item.title"></v-list-item-title>
|
||||
<v-list-item-subtitle v-text="item.subtitle"></v-list-item-subtitle>
|
||||
<v-list-item-title>
|
||||
{{ item.title }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ item.subtitle }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</template>
|
||||
@@ -116,7 +122,7 @@ export default defineComponent({
|
||||
icon: this.$globals.icons.link,
|
||||
title: "Import",
|
||||
subtitle: "Import a recipe by URL",
|
||||
to: "/recipe/create?tab=url",
|
||||
to: "/recipe/create/url",
|
||||
restricted: true,
|
||||
},
|
||||
{ divider: true },
|
||||
@@ -124,7 +130,7 @@ export default defineComponent({
|
||||
icon: this.$globals.icons.edit,
|
||||
title: "Create",
|
||||
subtitle: "Create a recipe manually",
|
||||
to: "/recipe/create?tab=new",
|
||||
to: "/recipe/create/new",
|
||||
restricted: true,
|
||||
},
|
||||
{ divider: true },
|
||||
|
||||
@@ -22,38 +22,35 @@
|
||||
"@nuxtjs/proxy": "^2.1.0",
|
||||
"@nuxtjs/pwa": "^3.3.5",
|
||||
"@vue/composition-api": "^1.0.5",
|
||||
"@vueuse/core": "^6.8.0",
|
||||
"@vueuse/core": "^8.5.0",
|
||||
"core-js": "^3.15.1",
|
||||
"date-fns": "^2.23.0",
|
||||
"isomorphic-dompurify": "^0.18.0",
|
||||
"fuse.js": "^6.5.3",
|
||||
"isomorphic-dompurify": "^0.19.0",
|
||||
"nuxt": "^2.15.8",
|
||||
"v-jsoneditor": "^1.4.5",
|
||||
"vuedraggable": "^2.24.3",
|
||||
"vuetify": "^2.5.5"
|
||||
"vuetify": "^2.6.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "^7.14.7",
|
||||
"@nuxt/types": "^2.15.7",
|
||||
"@nuxt/typescript-build": "^2.1.0",
|
||||
"@nuxtjs/composition-api": "^0.31.0",
|
||||
"@nuxtjs/eslint-config-typescript": "^6.0.1",
|
||||
"@nuxtjs/composition-api": "^0.32.0",
|
||||
"@nuxtjs/eslint-config-typescript": "^10.0.0",
|
||||
"@nuxtjs/eslint-module": "^3.0.2",
|
||||
"@nuxtjs/google-fonts": "^1.3.0",
|
||||
"@nuxtjs/vuetify": "^1.12.1",
|
||||
"@types/sortablejs": "^1.10.7",
|
||||
"@vue/runtime-dom": "^3.2.9",
|
||||
"eslint": "^7.29.0",
|
||||
"@vue/runtime-dom": "^3.2.36",
|
||||
"eslint": "^8.16.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-nuxt": "^2.0.0",
|
||||
"eslint-plugin-prettier": "^3.4.0",
|
||||
"eslint-plugin-vue": "^7.12.1",
|
||||
"lint-staged": "^10.5.4",
|
||||
"nuxt-vite": "^0.1.1",
|
||||
"eslint-plugin-nuxt": "^3.2.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-vue": "^9.0.1",
|
||||
"lint-staged": "^12.4.2",
|
||||
"nuxt-vite": "0.2.3",
|
||||
"prettier": "^2.3.2",
|
||||
"vue2-script-setup-transform": "^0.2.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"vite": "2.3.8"
|
||||
"vue2-script-setup-transform": "^0.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<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>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<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>
|
||||
|
||||
@@ -315,8 +315,10 @@ export default defineComponent({
|
||||
title: "Tag Recipes",
|
||||
mode: MODES.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,
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<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>
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
<template v-if="edit">
|
||||
<draggable
|
||||
tag="div"
|
||||
handle=".handle"
|
||||
:value="plan.meals"
|
||||
group="meals"
|
||||
:data-index="index"
|
||||
@@ -102,7 +103,13 @@
|
||||
style="min-height: 150px"
|
||||
@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-avatar :rounded="false">
|
||||
<RecipeCardImage
|
||||
@@ -126,7 +133,13 @@
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<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>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-chip v-bind="attrs" label small color="accent" v-on="on" @click.prevent>
|
||||
@@ -146,8 +159,8 @@
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</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-btn>
|
||||
</div>
|
||||
@@ -291,10 +304,12 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
function onMoveCallback(evt: SortableEvent) {
|
||||
const supportedEvents = ["drop", "touchend"];
|
||||
|
||||
// Adapted From https://github.com/SortableJS/Vue.Draggable/issues/1029
|
||||
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?
|
||||
console.log("Cancel Move Event");
|
||||
} else {
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
<template #header>
|
||||
<v-img max-height="200" max-width="200" class="mb-2" :src="require('~/static/svgs/data-reports.svg')"></v-img>
|
||||
</template>
|
||||
<template #title> Recipe Data Migrations</template>
|
||||
Recipes can be migrated from another supported application to Mealie. This is a great way to get started with
|
||||
Mealie.
|
||||
<template #title> Report </template>
|
||||
</BasePageTitle>
|
||||
<v-container v-if="report">
|
||||
<BaseCardSectionTitle :title="report.name"> </BaseCardSectionTitle>
|
||||
@@ -31,8 +29,9 @@
|
||||
</template>
|
||||
|
||||
<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 { ReportOut } from "~/types/api-types/reports";
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
@@ -41,16 +40,11 @@ export default defineComponent({
|
||||
|
||||
const api = useUserApi();
|
||||
|
||||
const state = reactive({
|
||||
report: {},
|
||||
});
|
||||
const report = ref<ReportOut | null>(null);
|
||||
|
||||
async function getReport() {
|
||||
const { data } = await api.groupReports.getOne(id);
|
||||
|
||||
if (data) {
|
||||
state.report = data;
|
||||
}
|
||||
report.value = data ?? null;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -64,7 +58,7 @@ export default defineComponent({
|
||||
];
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
report,
|
||||
id,
|
||||
itemHeaders,
|
||||
};
|
||||
@@ -72,5 +66,4 @@ export default defineComponent({
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -454,12 +454,13 @@
|
||||
>
|
||||
<v-switch v-model="wakeLock" small label="Keep Screen Awake" />
|
||||
</div>
|
||||
|
||||
<RecipeComments
|
||||
v-if="recipe && !recipe.settings.disableComments && !form"
|
||||
v-model="recipe.comments"
|
||||
:slug="recipe.slug"
|
||||
:recipe-id="recipe.id"
|
||||
class="px-1 my-4"
|
||||
class="px-1 my-4 d-print-none"
|
||||
/>
|
||||
<RecipePrintView v-if="recipe" :recipe="recipe" />
|
||||
</v-container>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-container class="narrow-container flex-column pa-0">
|
||||
<v-container class="flex-column">
|
||||
<BasePageTitle divider>
|
||||
<template #header>
|
||||
<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
|
||||
<template #content>
|
||||
<div class="ml-auto">
|
||||
<BaseOverflowButton v-model="tab" rounded :items="tabs"> </BaseOverflowButton>
|
||||
<BaseOverflowButton v-model="subpage" rounded :items="subpages"> </BaseOverflowButton>
|
||||
</div>
|
||||
</template>
|
||||
</BasePageTitle>
|
||||
|
||||
<section>
|
||||
<v-tabs-items v-model="tab" class="mt-2">
|
||||
<!-- 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>
|
||||
<NuxtChild />
|
||||
</section>
|
||||
</v-container>
|
||||
|
||||
<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-container>
|
||||
</AdvancedOnly>
|
||||
@@ -321,39 +27,16 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
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 { defineComponent, useRouter, useContext, computed, useRoute } from "@nuxtjs/composition-api";
|
||||
import { MenuItem } from "~/components/global/BaseOverflowButton.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({
|
||||
components: { AdvancedOnly, RecipeOrganizerSelector },
|
||||
components: { AdvancedOnly },
|
||||
setup() {
|
||||
const state = reactive({
|
||||
error: false,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const { $globals } = useContext();
|
||||
|
||||
const tabs: MenuItem[] = [
|
||||
const subpages: MenuItem[] = [
|
||||
{
|
||||
icon: $globals.icons.link,
|
||||
text: "Import with URL",
|
||||
@@ -381,185 +64,21 @@ export default defineComponent({
|
||||
},
|
||||
];
|
||||
|
||||
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 tab = computed({
|
||||
set(tab: string) {
|
||||
router.replace({ query: { ...route.value.query, tab } });
|
||||
const subpage = computed({
|
||||
set(subpage: string) {
|
||||
router.push({ path: `/recipe/create/${subpage}`, query: route.value.query });
|
||||
},
|
||||
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 {
|
||||
allTags,
|
||||
allCategories,
|
||||
tab,
|
||||
recipeUrl,
|
||||
importKeywordsAsTags,
|
||||
bulkCreate,
|
||||
bulkUrls,
|
||||
lockBulkImport,
|
||||
debugTreeView,
|
||||
tabs,
|
||||
domCreateByName,
|
||||
domUrlForm,
|
||||
newRecipeName,
|
||||
newRecipeZip,
|
||||
debugUrl,
|
||||
debugData,
|
||||
createByName,
|
||||
createByUrl,
|
||||
createByZip,
|
||||
...toRefs(state),
|
||||
validators,
|
||||
subpages,
|
||||
subpage,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
@@ -569,9 +88,3 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.force-white > a {
|
||||
color: white !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
201
frontend/pages/recipe/create/bulk.vue
Normal file
201
frontend/pages/recipe/create/bulk.vue
Normal 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>
|
||||
110
frontend/pages/recipe/create/debug.vue
Normal file
110
frontend/pages/recipe/create/debug.vue
Normal 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>
|
||||
18
frontend/pages/recipe/create/index.vue
Normal file
18
frontend/pages/recipe/create/index.vue
Normal 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>
|
||||
82
frontend/pages/recipe/create/new.vue
Normal file
82
frontend/pages/recipe/create/new.vue
Normal 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>
|
||||
159
frontend/pages/recipe/create/url.vue
Normal file
159
frontend/pages/recipe/create/url.vue
Normal 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>
|
||||
78
frontend/pages/recipe/create/zip.vue
Normal file
78
frontend/pages/recipe/create/zip.vue
Normal 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>
|
||||
@@ -216,7 +216,7 @@ export default defineComponent({
|
||||
if (searchString.value.trim() === "") {
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Plugin } from "@nuxt/types";
|
||||
import { NuxtAxiosInstance } from "@nuxtjs/axios";
|
||||
import type { NuxtAxiosInstance } from "@nuxtjs/axios";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
|
||||
const toastPlugin: Plugin = ({ $axios }: { $axios: NuxtAxiosInstance }) => {
|
||||
$axios.onResponse((response) => {
|
||||
if (response?.data?.message) {
|
||||
alert.info(response.data.message);
|
||||
alert.info(response.data.message as string);
|
||||
}
|
||||
});
|
||||
$axios.onError((error) => {
|
||||
if (error.response?.data?.detail?.message) {
|
||||
alert.error(error.response.data.detail.message);
|
||||
alert.error(error.response.data.detail.message as string);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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"],
|
||||
"exclude": ["node_modules", ".nuxt", "dist"],
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
/* 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 interface ReportCreate {
|
||||
|
||||
3509
frontend/yarn.lock
3509
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,9 @@ import dotenv
|
||||
from mealie.core.settings import app_settings_constructor
|
||||
|
||||
from .settings import AppDirectories, AppSettings
|
||||
from .settings.static import APP_VERSION, DB_VERSION
|
||||
from .settings.static import APP_VERSION
|
||||
|
||||
APP_VERSION
|
||||
DB_VERSION
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
BASE_DIR = CWD.parent.parent
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from pathlib import Path
|
||||
|
||||
APP_VERSION = "v1.0.0b"
|
||||
DB_VERSION = "v1.0.0b"
|
||||
APP_VERSION = "v1.0.0beta-2"
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
BASE_DIR = CWD.parent.parent.parent
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
"email-conflict-error": "Diese E-Mail-Adresse wird bereits verwendet"
|
||||
},
|
||||
"notifications": {
|
||||
"generic-created": "{name} was created",
|
||||
"generic-updated": "{name} was updated",
|
||||
"generic-created-with-url": "{name} has been created, {url}",
|
||||
"generic-updated-with-url": "{name} has been updated, {url}",
|
||||
"generic-deleted": "{name} has been created"
|
||||
"generic-created": "{name} wurde erstellt",
|
||||
"generic-updated": "{name} wurde aktualisiert",
|
||||
"generic-created-with-url": "{name} wurde erstellt, {url}",
|
||||
"generic-updated-with-url": "{name} wurde aktualisiert, {url}",
|
||||
"generic-deleted": "{name} wurde erstellt"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"liter": {
|
||||
"name": "Liter",
|
||||
"description": "",
|
||||
"abbreviation": "l"
|
||||
"abbreviation": "L"
|
||||
},
|
||||
"pound": {
|
||||
"name": "Pfund",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from functools import cached_property
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import UUID4
|
||||
|
||||
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.mixins import HttpRepo
|
||||
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"])
|
||||
|
||||
@@ -39,6 +40,10 @@ class GroupReportsController(BaseUserController):
|
||||
def get_one(self, item_id: UUID4):
|
||||
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):
|
||||
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
|
||||
|
||||
@@ -9,7 +9,6 @@ from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from slugify import slugify
|
||||
from sqlalchemy.orm.session import Session
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
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.security import create_recipe_slug_token
|
||||
from mealie.pkgs import cache
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.repos.repository_recipes import RepositoryRecipes
|
||||
from mealie.routes._base import BaseUserController, controller
|
||||
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.request_helpers import RecipeZipTokenResponse, UpdateImageResponse
|
||||
from mealie.schema.response.responses import ErrorResponse
|
||||
from mealie.schema.server.tasks import ServerTaskNames
|
||||
from mealie.services import urls
|
||||
from mealie.services.event_bus_service.event_bus_service import EventBusService
|
||||
from mealie.services.event_bus_service.message_types import EventTypes
|
||||
from mealie.services.recipe.recipe_data_service import RecipeDataService
|
||||
from mealie.services.recipe.recipe_service import RecipeService
|
||||
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.scraper import create_from_url
|
||||
from mealie.services.scraper.scraper_strategies import RecipeScraperPackage
|
||||
from mealie.services.server_tasks.background_executory import BackgroundExecutor
|
||||
|
||||
|
||||
class BaseRecipeController(BaseUserController):
|
||||
@@ -172,39 +169,11 @@ class RecipeController(BaseRecipeController):
|
||||
@router.post("/create-url/bulk", status_code=202)
|
||||
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"""
|
||||
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:
|
||||
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"}
|
||||
return {"reportId": report_id}
|
||||
|
||||
@router.post("/test-scrape-url")
|
||||
def test_parse_recipe_url(self, url: ScrapeRecipeTest):
|
||||
|
||||
@@ -11,6 +11,7 @@ class ReportCategory(str, enum.Enum):
|
||||
backup = "backup"
|
||||
restore = "restore"
|
||||
migration = "migration"
|
||||
bulk_import = "bulk_import"
|
||||
|
||||
|
||||
class ReportSummaryStatus(str, enum.Enum):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
@@ -6,6 +7,7 @@ from pydantic import UUID4
|
||||
from mealie.core import root_logger
|
||||
from mealie.repos.all_repositories import AllRepositories
|
||||
from mealie.schema.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe_settings import RecipeSettings
|
||||
from mealie.schema.reports.reports import (
|
||||
ReportCategory,
|
||||
ReportCreate,
|
||||
@@ -25,7 +27,7 @@ class BaseMigrator(BaseService):
|
||||
key_aliases: list[MigrationAlias]
|
||||
|
||||
report_entries: list[ReportEntryCreate]
|
||||
report_id: int
|
||||
report_id: UUID4
|
||||
report: ReportOut
|
||||
|
||||
helpers: DatabaseMigrationHelpers
|
||||
@@ -111,7 +113,19 @@ class BaseMigrator(BaseService):
|
||||
|
||||
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:
|
||||
recipe.settings = default_settings
|
||||
|
||||
recipe.user_id = self.user_id
|
||||
recipe.group_id = self.group_id
|
||||
@@ -125,7 +139,7 @@ class BaseMigrator(BaseService):
|
||||
if self.add_migration_tag:
|
||||
recipe.tags.append(migration_tag)
|
||||
|
||||
exception = ""
|
||||
exception: str | Exception = ""
|
||||
status = False
|
||||
try:
|
||||
recipe = self.db.recipes.create(recipe)
|
||||
@@ -189,10 +203,8 @@ class BaseMigrator(BaseService):
|
||||
"""
|
||||
recipe_dict = self.rewrite_alias(recipe_dict)
|
||||
|
||||
try:
|
||||
with contextlib.suppress(KeyError):
|
||||
del recipe_dict["id"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
recipe_dict = cleaner.clean(recipe_dict, url=recipe_dict.get("org_url", None))
|
||||
|
||||
|
||||
104
mealie/services/scraper/recipe_bulk_scraper.py
Normal file
104
mealie/services/scraper/recipe_bulk_scraper.py
Normal 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
8
poetry.lock
generated
@@ -1188,7 +1188,7 @@ rdflib = ">=5.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "recipe-scrapers"
|
||||
version = "13.33.0"
|
||||
version = "14.1.0"
|
||||
description = "Python package, scraping recipes from all over the internet"
|
||||
category = "main"
|
||||
optional = false
|
||||
@@ -1545,7 +1545,7 @@ pgsql = ["psycopg2-binary"]
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.10"
|
||||
content-hash = "a84820195816740ce9200a8a9bf1cc8568cc30c1f97264cc2506b1f6289d1883"
|
||||
content-hash = "45c28207b80dd8ecd82030410c132be32e8f2e46925c92641d4dd1626fec7786"
|
||||
|
||||
[metadata.files]
|
||||
aiofiles = [
|
||||
@@ -2437,8 +2437,8 @@ rdflib-jsonld = [
|
||||
{file = "rdflib_jsonld-0.6.2-py2.py3-none-any.whl", hash = "sha256:011afe67672353ca9978ab9a4bee964dff91f14042f2d8a28c22a573779d2f8b"},
|
||||
]
|
||||
recipe-scrapers = [
|
||||
{file = "recipe_scrapers-13.33.0-py3-none-any.whl", hash = "sha256:c350ee2407167ec62327a1db9e8864f49a51cae06907689f9095885444549293"},
|
||||
{file = "recipe_scrapers-13.33.0.tar.gz", hash = "sha256:be1742077bca55638392446b55bf7d2e80a9f9a9625285dc30efd02c763461ec"},
|
||||
{file = "recipe_scrapers-14.1.0-py3-none-any.whl", hash = "sha256:fc4bf1d5bd142e63a81b1b734a874e2fd6686b22287b41a9bc1355c2004bd5f7"},
|
||||
{file = "recipe_scrapers-14.1.0.tar.gz", hash = "sha256:5c6931dc13cdb458f7ce52c2fae6a63348ee826a9e0f71ba7679a3d3f7a9257b"},
|
||||
]
|
||||
requests = [
|
||||
{file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
|
||||
|
||||
@@ -30,7 +30,7 @@ passlib = "^1.7.4"
|
||||
lxml = "^4.7.1"
|
||||
Pillow = "^8.2.0"
|
||||
apprise = "^0.9.6"
|
||||
recipe-scrapers = "^13.33.0"
|
||||
recipe-scrapers = "^14.1.0"
|
||||
psycopg2-binary = {version = "^2.9.1", optional = true}
|
||||
gunicorn = "^20.1.0"
|
||||
emails = "^0.6"
|
||||
|
||||
Reference in New Issue
Block a user