Compare commits

..

24 Commits

Author SHA1 Message Date
Hayden
56eb0bca71 release: prep beta 3 (#1349)
* docs: add tag documentation

* bump docker-compose tags

* minor language changes

* beta3 changelog

* auto-gen API docs

* bump version

* bump recipe-scrapers
2022-06-07 11:19:24 -08:00
Hayden
eca8a96509 fix image display - closes #1189 (#1348) 2022-06-05 11:44:00 -08:00
Hayden
7eb80d18d2 feat: attached images by drag and drop for recipe steps (#1341)
* add drag and drop support for recipe steps

* fix recipe assets dialog state

* add attr support for markdown editor

* add persistent hint for recipe text editor
2022-06-05 11:28:38 -08:00
Benjamin Pabst
37a673b34d Update postgres to use most recent version (#1347) 2022-06-05 10:56:01 -08:00
Benjamin Pabst
3e7b8d4b71 Update to use most recent version (#1346) 2022-06-05 10:55:33 -08:00
Jurjen de Jonge
abb114c375 security: delay server response whenever username is non existing (#1338)
* Delay server response whenever username is non existing

* utilize hasher to achieve constant timing

Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
2022-06-04 10:27:30 -08:00
Hayden
12f480eb75 refactor: unify recipe-organizer components (#1340)
* use generic context menu

* implement organizer stores

* add basic organizer types

* refactor selectors to apply for all organizers

* remove legacy organizer composables
2022-06-03 20:12:32 -08:00
Hayden
bc175d4ca9 New translations en-US.json (Ukrainian) (#1339) 2022-06-03 19:49:58 -08:00
Hayden
f78c5eb359 New Crowdin updates (#1329)
* New translations en-US.json (German)

* New translations en-US.json (German)

* New translations en-US.json (Ukrainian)
2022-06-02 09:12:15 -08:00
Hayden
5a0c034391 fix: shopping list label editor (#1333)
* remove head props

* lazily compute itemsByLabel with watcher on fetch

* remove unused import
2022-06-02 09:12:05 -08:00
Hayden
52fbf6b833 feat: add unit abbreviation support (#1332)
* add 'use-abbreviation' db column

* type generation

* add view and edit elements

* check for use_abbreviation to display

* fix: alembic version check

* test: add use_abbreviation prop tests
2022-06-01 11:59:50 -08:00
dependabot[bot]
592b1de39d fix(deps): bump @vue/composition-api from 1.6.1 to 1.6.2 in /frontend (#1275)
Bumps [@vue/composition-api](https://github.com/vuejs/composition-api) from 1.6.1 to 1.6.2.
- [Release notes](https://github.com/vuejs/composition-api/releases)
- [Changelog](https://github.com/vuejs/composition-api/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vuejs/composition-api/compare/v1.6.1...v1.6.2)

---
updated-dependencies:
- dependency-name: "@vue/composition-api"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-31 08:48:57 -08:00
dependabot[bot]
f29d5f1dff chore(deps-dev): bump @babel/eslint-parser in /frontend (#1290)
Bumps [@babel/eslint-parser](https://github.com/babel/babel/tree/HEAD/eslint/babel-eslint-parser) from 7.15.4 to 7.18.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.18.2/eslint/babel-eslint-parser)

---
updated-dependencies:
- dependency-name: "@babel/eslint-parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-31 08:48:33 -08:00
dependabot[bot]
738ef0aaa7 chore(deps-dev): bump @types/sortablejs in /frontend (#1287)
Bumps [@types/sortablejs](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/sortablejs) from 1.10.7 to 1.13.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/sortablejs)

---
updated-dependencies:
- dependency-name: "@types/sortablejs"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-31 08:48:01 -08:00
Hayden
f1fdec5afe add security.md 2022-05-31 08:29:45 -08:00
Hayden
4c594a48dc wip: pagination-repository (#1316)
* bump mypy

* add pagination + refactor generic repo

* add pagination test

* remove all query object
2022-05-30 10:30:54 -08:00
Hayden
00f144a622 chore: init git-cliff config 2022-05-29 17:34:41 -08:00
Hayden
d2a9f7ca24 fix: consoldate stores to fix mismatched state 2022-05-29 17:34:41 -08:00
Hayden
f831791db2 feat: default unit fractions to True 2022-05-29 17:34:41 -08:00
Hayden
c3bdfe7b3b fix: printer page refs (#1314) 2022-05-29 09:15:38 -08:00
Hayden
3542bb0927 fix: bad import path (#1313)
* fix bad import

* add eslint rule for incorrect imports
2022-05-29 09:09:36 -08:00
Philipp Fischbeck
e898c80f59 fix: update issue links in v1.0.0beta-2 changelog (#1312) 2022-05-29 08:51:11 -08:00
James Addison
27c5cfc56b Fixup: render homepage social media link images at 32x32 size (#1310) 2022-05-29 08:50:44 -08:00
Hayden
369cda0a61 New Crowdin updates (#1308)
* New translations en-US.json (Italian)

* New translations en-US.json (Italian)
2022-05-29 08:50:18 -08:00
101 changed files with 1741 additions and 1561 deletions

View File

@@ -52,6 +52,7 @@
"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"
"docker-compose.yml": "Dockerfile, .dockerignore, docker-compose.dev.yml, docker-compose.yml",
"README.md": "LICENSE, SECURITY.md"
}
}

9
SECURITY.md Normal file
View File

@@ -0,0 +1,9 @@
# Security Policy
## Supported Versions
Since this software is still considered beta/WIP support is always only given for the latest version. Security patches are only available for the latest version and not back-ported to older versions.
## Reporting a Vulnerability
For general security vulnerabilities you're welcome to open a GitHub issues or contribute a fix. If you feel the vulnerability should not be disclosed you can open a generic issue on GitHub and email to the details to [ob92oy0sl@mozmail.com](mailto:ob92oy0sl@mozmail.com) which is monitored by the maintainer.

View File

@@ -0,0 +1,30 @@
"""Add use_abbreviation column to ingredients
Revision ID: ab0bae02578f
Revises: 09dfc897ad62
Create Date: 2022-06-01 11:12:06.748383
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "ab0bae02578f"
down_revision = "09dfc897ad62"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("ingredient_units", sa.Column("use_abbreviation", sa.Boolean(), nullable=True))
op.execute("UPDATE ingredient_units SET use_abbreviation = FALSE WHERE use_abbreviation IS NULL")
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("ingredient_units", "use_abbreviation")
# ### end Alembic commands ###

64
cliff.toml Normal file
View File

@@ -0,0 +1,64 @@
# configuration file for git-cliff (0.1.0)
[changelog]
# changelog header
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
# template for the changelog body
# https://tera.netlify.app/docs/#introduction
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}\n
"""
# remove the leading and trailing whitespace from the template
trim = true
# changelog footer
footer = """
<!-- generated by git-cliff -->
"""
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = true
# regex for preprocessing the commit messages
commit_preprocessors = [
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/hay-kot/mealie/issues/${2}))"},
]
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "Features"},
{ message = "^fix", group = "Bug Fixes"},
{ message = "^doc", group = "Documentation"},
{ message = "^perf", group = "Performance"},
{ message = "^refactor", group = "Refactor"},
{ message = "^style", group = "Styling"},
{ message = "^test", group = "Testing"},
{ message = "^chore\\(release\\): prepare for", skip = true},
{ message = "^chore", group = "Miscellaneous Tasks"},
{ body = ".*security", group = "Security"},
]
# filter out the commits that are not matched by commit parsers
filter_commits = false
# glob pattern for matching git tags
tag_pattern = "v[0-9]*"
# regex for skipping tags
skip_tags = "v0.1.0-beta.1"
# regex for ignoring tags
ignore_tags = ""
# sort the tags chronologically
date_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "oldest"

View File

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

View File

@@ -0,0 +1,36 @@
### Bug Fixes
- Update issue links in v1.0.0beta-2 changelog ([#1312](https://github.com/hay-kot/mealie/issues/1312))
- Bad import path ([#1313](https://github.com/hay-kot/mealie/issues/1313))
- Printer page refs ([#1314](https://github.com/hay-kot/mealie/issues/1314))
- Consolidate stores to fix mismatched state
- Bump @vue/composition-api from 1.6.1 to 1.6.2 in /frontend ([#1275](https://github.com/hay-kot/mealie/issues/1275))
- Shopping list label editor ([#1333](https://github.com/hay-kot/mealie/issues/1333))
### Features
- Default unit fractions to True
- Add unit abbreviation support ([#1332](https://github.com/hay-kot/mealie/issues/1332))
- Attached images by drag and drop for recipe steps ([#1341](https://github.com/hay-kot/mealie/issues/1341))
### Docs
- Render homepage social media link images at 32x32 size ([#1310](https://github.com/hay-kot/mealie/issues/1310))
### Miscellaneous Tasks
- Init git-cliff config
- Bump @types/sortablejs in /frontend ([#1287](https://github.com/hay-kot/mealie/issues/1287))
- Bump @babel/eslint-parser in /frontend ([#1290](https://github.com/hay-kot/mealie/issues/1290))
### Refactor
- Unify recipe-organizer components ([#1340](https://github.com/hay-kot/mealie/issues/1340))
### Security
- Delay server response whenever username is non existing ([#1338](https://github.com/hay-kot/mealie/issues/1338))
### Wip
- Pagination-repository ([#1316](https://github.com/hay-kot/mealie/issues/1316))

View File

@@ -1,12 +1,20 @@
# Frequently Asked Questions
## Is it Safe to Upgrade Mealie?
Yes. If you are using the v1 branches (including beta), you can upgrade to the latest version of Mealie without performing a site Export/Restore. This process was required in previous versions of Mealie, however we've automated the database migration process to make it easier to upgrade. Not that if you were using the v0.5.x version, you CANNOT upgrade to the latest version automatically. You must follow the migration instructions in the documentation.
**Links**
- [Migration From v0.5.x](./migrating-to-mealie-v1.md)
## How can I change the theme?
You can change the theme by settings the environment variables on the frontend container.
You can change the theme by settings the environment variables on the frontend container.
Links:
- [Frontend Theme](/mealie/documentation/getting-started/installation/frontend-config#themeing)
- [Frontend Theme](./installation/frontend-config#themeing)
## How can I change the language?
@@ -14,29 +22,29 @@ Languages need to be set on the frontend and backend containers as ENV variables
Links
- [Frontend Config](/mealie/documentation/getting-started/installation/frontend-config/)
- [Backend Config](/mealie/documentation/getting-started/installation/backend-config/)
- [Frontend Config](./installation/frontend-config/)
- [Backend Config](./installation/backend-config/)
## How can I change the Login Session Timeout?
Login session can be configured by setting the `TOKEN_TIME` variable on the backend container.
- [Backend Config](/mealie/documentation/getting-started/installation/backend-config/)
- [Backend Config](./installation/backend-config/)
## Can I serve Mealie on a subpath?
No. Due to limitations from the Javascript Framework, mealie doesn't support serving Mealie on a subpath.
## Can I install Mealie without docker?
## Can I install Mealie without docker?
Yes, you can install Mealie on your local machine. HOWEVER, it is recommended that you don't. Managing non-system versions of python, node, and npm is a pain. Moreover updating and upgrading your system with this configuration is unsupported and will likely require manual interventions. If you insist on installing Mealie on your local machine, you can use the links below to help guide your path.
- [Advanced Installation](/mealie/documentation/getting-started/installation/advanced/)
- [Advanced Installation](./installation/advanced/)
## How I can attach an Image or Video to a Recipe?
## How I can attach an Image or Video to a Recipe?
Yes. Mealie's Recipe Steps and other fields support the markdown syntax and therefor supports images and videos. To attach an image to the recipe, you can upload it as an asset and use the provided copy button to generate the html image tag required to render the image. For videos, Mealie provides no way to host videos. You'll need to host your videos with another provider and embed them in your recipe. Generally, the video provider will provide a link to the video and the html tag required to render the video. For example, youtube provides the following link that works inside a step. You can adjust the width and height attributes as necessary to ensure a fit.
```html
<iframe width="560" height="315" src="https://www.youtube.com/embed/nAUwKeO93bY" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
```
```

View File

@@ -85,6 +85,17 @@ These backups are just plain .zip files that you can download from the UI or acc
## Appendix
### Docker Tags
`mealie:frontend-v1.0.0beta-x` **and** `mealie:api-v1.0.0beta-x`
These are the tags for the latest beta release of the frontend docker-container. These are currently considered the latest and most stable releases and the recommended way of using Mealie.
`mealie:frontend-nightly`**and** `mealie:api-nightly`
The nightly build are the latest and greatest builds that are built directly off of every commit to the `mealie-next` branch and as such may contain bugs. These are great to help the community catch bugs before they hit the stable release or if you like living on the edge.
### Docker Diagram
While the docker-compose file should work without modification, some users want to tailor it to their installation. This diagram shows network and volume architecture for the default setup. You can use this to help you customize your configuration.

View File

@@ -10,7 +10,7 @@
version: "3.7"
services:
mealie-frontend:
image: hkotel/mealie:frontend-v1.0.0-beta-1
image: hkotel/mealie:frontend-v1.0.0beta-3
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-v1.0.0-beta-1
image: hkotel/mealie:api-v1.0.0beta-3
container_name: mealie-api
depends_on:
- postgres

View File

@@ -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-v1.0.0-beta-1
image: hkotel/mealie:frontend-v1.0.0beta-3
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-v1.0.0-beta-1
image: hkotel/mealie:api-v1.0.0beta-3
container_name: mealie-api
volumes:
- mealie-data:/app/data/

View File

@@ -5,16 +5,12 @@
You should likely find bugs, errors, and unfinished pages within the application. To find the current status of the release you can checkout the [project on github](https://github.com/hay-kot/mealie/projects/7) or reach out on discord.
You should also be aware that Mealie v1 Beta does not have the backup/export feature available. This is the next priority for Mealie v1
and is currently being worked out.
Mealie is a self hosted recipe manager and meal planner with a RestAPI backend and a reactive frontend application built in Vue for a pleasant user experience for the whole family. Easily add recipes into your database by providing the url and Mealie will automatically import the relevant data or add a family recipe with the UI editor. Mealie also provides an API for interactions from 3rd party applications.
Mealie is a self hosted recipe manager and meal planner with a RestAPI backend and a reactive frontend application built in Vue for a pleasant user experience for the whole family. Easily add recipes into your database by providing the url and Mealie will automatically import the relevant data or add a family recipe with the UI editor. Mealie also provides an API for interactions from 3rd party applications.
[Remember to join the Discord](https://discord.gg/QuStdQGSGK)
## Key Features
- 🔍 Fuzzy search
- 🏷️ Tag recipes with categories or tags to flexible sorting
@@ -40,15 +36,15 @@ Mealie is a self hosted recipe manager and meal planner with a RestAPI backend a
## FAQ
### Why An API?
An API allows integration into applications like [Home Assistant](https://www.home-assistant.io/) that can act as notification engines to provide custom notifications based of Meal Plan data to remind you to defrost the chicken, marinade the steak, or start the CrockPot. Additionally, you can access nearly any backend service via the API giving you total control to extend the application. To explore the API spin up your server and navigate to http://yourserver.com/docs for interactive API documentation.
An API allows integration into applications like [Home Assistant](https://www.home-assistant.io/) that can act as notification engines to provide custom notifications based of Meal Plan data to remind you to defrost the chicken, marinade the steak, or start the CrockPot. Additionally, you can access nearly any backend service via the API giving you total control to extend the application. To explore the API spin up your server and navigate to http://yourserver.com/docs for interactive API documentation.
### Why a Database?
Some users of static-site generator applications like ChowDown have expressed concerns about their data being stuck in a database. Considering this is a new project it is a valid concern to be worried about your data. Mealie specifically addresses this concern by provided automatic daily backups that export your data in json, plain-text markdown files, and/or custom Jinja2 templates. **This puts you in controls of how your data is represented** when exported from Mealie, which means you can easily migrate to any other service provided Mealie doesn't work for you.
Some users of static-site generator applications like ChowDown have expressed concerns about their data being stuck in a database. Considering this is a new project it is a valid concern to be worried about your data. Mealie specifically addresses this concern by provided automatic daily backups that export your data in json, plain-text markdown files, and/or custom Jinja2 templates. **This puts you in controls of how your data is represented** when exported from Mealie, which means you can easily migrate to any other service provided Mealie doesn't work for you.
As to why we need a database?
- **Developer Experience:** Without a database a lot of the work to maintain your data is taken on by the developer instead of a battle tested platform for storing data.
- **Multi User Support:** With a solid database as backend storage for your data Mealie can better support multi-user sites and avoid read/write access errors when multiple actions are taken at the same time.
- **Developer Experience:** Without a database a lot of the work to maintain your data is taken on by the developer instead of a battle tested platform for storing data.
- **Multi User Support:** With a solid database as backend storage for your data Mealie can better support multi-user sites and avoid read/write access errors when multiple actions are taken at the same time.
## Built With
@@ -68,7 +64,6 @@ As to why we need a database?
Contributions are what make the open source community such an amazing place to learn, develop, and create. Any contributions you make are **greatly appreciated**. See the [Contributors Guide](../../contributors/non-coders.md) for help getting started.
If you are not a coder, you can still contribute financially. Financial contributions help me prioritize working on this project over others and help me to know that there is a real demand for project development.
If you are not a coder, you can still contribute financially. Financial contributions help me prioritize working on this project over others and help me to know that there is a real demand for project development.
<a href="https://www.buymeacoffee.com/haykot" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-green.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>

File diff suppressed because one or more lines are too long

View File

@@ -382,7 +382,7 @@
target="_blank"
title="github.com"
>
<svg viewBox="0 0 480 512" xmlns="http://www.w3.org/2000/svg">
<svg style="width: 32px; height: 32px" viewBox="0 0 480 512" xmlns="http://www.w3.org/2000/svg">
<path
d="M186.1 328.7c0 20.9-10.9 55.1-36.7 55.1s-36.7-34.2-36.7-55.1 10.9-55.1 36.7-55.1 36.7 34.2 36.7 55.1zM480 278.2c0 31.9-3.2 65.7-17.5 95-37.9 76.6-142.1 74.8-216.7 74.8-75.8 0-186.2 2.7-225.6-74.8-14.6-29-20.2-63.1-20.2-95 0-41.9 13.9-81.5 41.5-113.6-5.2-15.8-7.7-32.4-7.7-48.8 0-21.5 4.9-32.3 14.6-51.8 45.3 0 74.3 9 108.8 36 29-6.9 58.8-10 88.7-10 27 0 54.2 2.9 80.4 9.2 34-26.7 63-35.2 107.8-35.2 9.8 19.5 14.6 30.3 14.6 51.8 0 16.4-2.6 32.7-7.7 48.2 27.5 32.4 39 72.3 39 114.2zm-64.3 50.5c0-43.9-26.7-82.6-73.5-82.6-18.9 0-37 3.4-56 6-14.9 2.3-29.8 3.2-45.1 3.2-15.2 0-30.1-.9-45.1-3.2-18.7-2.6-37-6-56-6-46.8 0-73.5 38.7-73.5 82.6 0 87.8 80.4 101.3 150.4 101.3h48.2c70.3 0 150.6-13.4 150.6-101.3zm-82.6-55.1c-25.8 0-36.7 34.2-36.7 55.1s10.9 55.1 36.7 55.1 36.7-34.2 36.7-55.1-10.9-55.1-36.7-55.1z"
></path>
@@ -395,7 +395,7 @@
target="_blank"
title="twitter.com"
>
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<svg style="width: 32px; height: 32px" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<path
d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"
></path>
@@ -408,7 +408,7 @@
target="_blank"
title="www.linkedin.com"
>
<svg viewBox="0 0 448 512" xmlns="http://www.w3.org/2000/svg">
<svg style="width: 32px; height: 32px" viewBox="0 0 448 512" xmlns="http://www.w3.org/2000/svg">
<path
d="M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z"
></path>

View File

@@ -88,6 +88,7 @@ nav:
- Improving Ingredient Parser: "contributors/guides/ingredient-parser.md"
- Change Log:
- v1.0.0beta-3: "changelog/v1.0.0beta-3.md"
- 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"

View File

@@ -52,6 +52,8 @@ module.exports = {
"ts-ignore": "allow-with-description",
},
],
"no-restricted-imports": ["error", { paths: ["@vue/reactivity", "@vue/runtime-dom", "@vue/composition-api"] }],
// TODO Gradually activate all rules
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-member-access": "off",

View File

@@ -5,8 +5,8 @@
<v-select v-model="inputEntryType" :items="MEAL_TYPE_OPTIONS" label="Meal Type"></v-select>
</div>
<RecipeCategoryTagSelector v-model="inputCategories" />
<RecipeCategoryTagSelector v-model="inputTags" :tag-selector="true" />
<RecipeOrganizerSelector v-model="inputCategories" selector-type="categories" />
<RecipeOrganizerSelector v-model="inputTags" selector-type="tags" />
{{ inputDay === "unset" ? "This rule will apply to all days" : `This rule applies on ${inputDay}s` }}
{{ inputEntryType === "unset" ? "for all meal types" : ` and for ${inputEntryType} meal types` }}
@@ -15,7 +15,8 @@
<script lang="ts">
import { defineComponent, computed } from "@nuxtjs/composition-api";
import RecipeCategoryTagSelector from "~/components/Domain/Recipe/RecipeCategoryTagSelector.vue";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import { RecipeTag, RecipeCategory } from "~/types/api-types/group";
const MEAL_TYPE_OPTIONS = [
{ text: "Breakfast", value: "breakfast" },
@@ -38,7 +39,7 @@ const MEAL_DAY_OPTIONS = [
export default defineComponent({
components: {
RecipeCategoryTagSelector,
RecipeOrganizerSelector,
},
props: {
day: {
@@ -50,11 +51,11 @@ export default defineComponent({
default: "unset",
},
categories: {
type: Array,
type: Array as () => RecipeCategory[],
default: () => [],
},
tags: {
type: Array,
type: Array as () => RecipeTag[],
default: () => [],
},
showHelp: {

View File

@@ -45,7 +45,7 @@
@submit="addAsset"
>
<template #activator>
<BaseButton v-if="edit" small create @click="newAssetDialog = true" />
<BaseButton v-if="edit" small create @click="state.newAssetDialog = true" />
</template>
<v-card-text class="pt-4">
<v-text-field v-model="state.newAsset.name" dense :label="$t('general.name')"></v-text-field>

View File

@@ -1,111 +0,0 @@
<template>
<div>
<slot>
<v-btn icon class="mt-n1" @click="dialog = true">
<v-icon :color="color">{{ $globals.icons.create }}</v-icon>
</v-btn>
</slot>
<v-dialog v-model="dialog" width="500">
<v-card>
<v-app-bar dense dark color="primary mb-2">
<v-icon large left class="mt-1">
{{ $globals.icons.tags }}
</v-icon>
<v-toolbar-title class="headline">
{{ title }}
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-title> </v-card-title>
<v-form @submit.prevent="select">
<v-card-text>
<v-text-field
v-model="itemName"
dense
:label="inputLabel"
:rules="[rules.required]"
autofocus
></v-text-field>
</v-card-text>
<v-card-actions>
<BaseButton cancel @click="dialog = false" />
<v-spacer></v-spacer>
<BaseButton type="submit" create :disabled="!itemName" />
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, reactive, toRefs, watch } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
const CREATED_ITEM_EVENT = "created-item";
export default defineComponent({
props: {
value: {
type: String,
default: "",
},
color: {
type: String,
default: null,
},
tagDialog: {
type: Boolean,
default: true,
},
},
setup(props, context) {
const title = computed(() => props.tagDialog ? "Create a Tag" : "Create a Category");
const inputLabel = computed(() => props.tagDialog ? "Tag Name" : "Category Name");
const rules = {
required: (val: string) => !!val || "A Name is Required",
};
const state = reactive({
dialog: false,
itemName: "",
});
watch(() => state.dialog, (val: boolean) => {
if (!val) state.itemName = "";
});
const api = useUserApi();
async function select() {
const newItem = await (async () => {
if (props.tagDialog) {
const { data } = await api.tags.createOne({ name: state.itemName });
return data;
} else {
const { data } = await api.categories.createOne({ name: state.itemName });
return data;
}
})();
console.log(newItem);
context.emit(CREATED_ITEM_EVENT, newItem);
state.dialog = false;
}
return {
...toRefs(state),
title,
inputLabel,
rules,
select,
};
},
});
</script>
<style></style>

View File

@@ -1,164 +0,0 @@
//TODO: Prevent fetching Categories/Tags multiple time when selector is on page multiple times
<template>
<v-autocomplete
v-model="selected"
:items="activeItems"
:value="value"
:label="inputLabel"
chips
deletable-chips
:dense="dense"
item-text="name"
persistent-hint
multiple
:hide-details="hideDetails"
:hint="hint"
:solo="solo"
:return-object="returnObject"
:prepend-inner-icon="$globals.icons.tags"
v-bind="$attrs"
@input="emitChange"
>
<template #selection="data">
<v-chip
v-if="showSelected"
:key="data.index"
:small="dense"
class="ma-1"
:input-value="data.selected"
close
label
color="accent"
dark
@click:close="removeByIndex(data.index)"
>
{{ data.item.name || data.item }}
</v-chip>
</template>
<template #append-outer>
<RecipeCategoryTagDialog v-if="showAdd" :tag-dialog="tagSelector" @created-item="pushToItem" />
</template>
</v-autocomplete>
</template>
<script lang="ts">
import { computed, defineComponent, onMounted, reactive, toRefs, useContext, watch } from "@nuxtjs/composition-api";
import RecipeCategoryTagDialog from "./RecipeCategoryTagDialog.vue";
import { useTags, useCategories } from "~/composables/recipes";
import { RecipeCategory, RecipeTag } from "~/types/api-types/user";
const MOUNTED_EVENT = "mounted";
export default defineComponent({
components: {
RecipeCategoryTagDialog,
},
props: {
value: {
type: Array as () => (RecipeTag | RecipeCategory | string)[],
required: true,
},
solo: {
type: Boolean,
default: false,
},
dense: {
type: Boolean,
default: true,
},
returnObject: {
type: Boolean,
default: true,
},
tagSelector: {
type: Boolean,
default: false,
},
hint: {
type: String,
default: null,
},
showAdd: {
type: Boolean,
default: false,
},
showLabel: {
type: Boolean,
default: true,
},
showSelected: {
type: Boolean,
default: true,
},
hideDetails: {
type: Boolean,
default: false,
},
},
setup(props, context) {
const { allTags, useAsyncGetAll: getAllTags } = useTags();
const { allCategories, useAsyncGetAll: getAllCategories } = useCategories();
getAllCategories();
getAllTags();
const state = reactive({
selected: props.value,
});
watch(
() => props.value,
(val) => {
state.selected = val;
}
);
const { i18n } = useContext();
const inputLabel = computed(() => {
if (!props.showLabel) return null;
return props.tagSelector ? i18n.t("tag.tags") : i18n.t("recipe.categories");
});
const activeItems = computed(() => {
let itemObjects: RecipeTag[] | RecipeCategory[] | null;
if (props.tagSelector) itemObjects = allTags.value;
else {
itemObjects = allCategories.value;
}
if (props.returnObject) return itemObjects;
else {
return itemObjects?.map((x: RecipeTag | RecipeCategory) => x.name);
}
});
function emitChange() {
context.emit("input", state.selected);
}
// TODO Is this needed?
onMounted(() => {
context.emit(MOUNTED_EVENT);
});
function removeByIndex(index: number) {
state.selected.splice(index, 1);
}
function pushToItem(createdItem: RecipeTag | RecipeCategory) {
// TODO: Remove excessive get calls
getAllCategories();
getAllTags();
state.selected.push(createdItem);
}
return {
...toRefs(state),
inputLabel,
activeItems,
emitChange,
removeByIndex,
pushToItem,
};
},
});
</script>

View File

@@ -1,207 +0,0 @@
<template>
<div class="text-center">
<BaseDialog
v-model="ItemDeleteDialog"
:title="`Delete ${itemName}`"
color="error"
:icon="$globals.icons.alertCircle"
@confirm="deleteItem()"
>
<v-card-text> Are you sure you want to delete this {{ itemName }}? </v-card-text>
</BaseDialog>
<v-menu
offset-y
left
:bottom="!menuTop"
:nudge-bottom="!menuTop ? '5' : '0'"
:top="menuTop"
:nudge-top="menuTop ? '5' : '0'"
allow-overflow
close-delay="125"
open-on-hover
content-class="d-print-none"
>
<template #activator="{ on, attrs }">
<v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent>
<v-icon>{{ icon }}</v-icon>
</v-btn>
</template>
<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">
{{ item.icon }}
</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, useContext } from "@nuxtjs/composition-api";
import colors from "vuetify/lib/util/colors";
import { useUserApi } from "~/composables/api";
export interface ContextMenuIncludes {
delete: boolean;
}
export interface ContextMenuItem {
title: string;
icon: string;
color: string | undefined;
event: string;
}
const ItemTypes = {
tag: "tags",
category: "categories",
tool: "tools",
};
export default defineComponent({
props: {
itemType: {
type: String as () => string,
required: true,
},
useItems: {
type: Object as () => ContextMenuIncludes,
default: () => ({
delete: true,
}),
},
// Append items are added at the end of the useItems list
appendItems: {
type: Array as () => ContextMenuItem[],
default: () => [],
},
// Append items are added at the beginning of the useItems list
leadingItems: {
type: Array as () => ContextMenuItem[],
default: () => [],
},
menuTop: {
type: Boolean,
default: true,
},
fab: {
type: Boolean,
default: false,
},
color: {
type: String,
default: colors.grey.darken2,
},
slug: {
type: String,
required: true,
},
menuIcon: {
type: String,
default: null,
},
name: {
required: true,
type: String,
},
id: {
required: true,
type: String,
},
},
setup(props, context) {
const api = useUserApi();
const state = reactive({
ItemDeleteDialog: false,
loading: false,
menuItems: [] as ContextMenuItem[],
itemName: "tag",
});
const { i18n, $globals } = useContext();
let apiRoute = "tags" as "tags" | "categories" | "tools";
switch (props.itemType) {
case ItemTypes.tag:
state.itemName = "tag";
apiRoute = "tags";
break;
case ItemTypes.category:
state.itemName = "category";
apiRoute = "categories";
break;
case ItemTypes.tool:
state.itemName = "tool";
apiRoute = "tools";
break;
default:
break;
}
// ===========================================================================
// Context Menu Setup
const defaultItems: { [key: string]: ContextMenuItem } = {
delete: {
title: i18n.t("general.delete") as string,
icon: $globals.icons.delete,
color: undefined,
event: "delete",
},
};
// Get Default Menu Items Specified in Props
for (const [key, value] of Object.entries(props.useItems)) {
if (value) {
const item = defaultItems[key];
if (item) {
state.menuItems.push(item);
}
}
}
// Add leading and Apppending Items
state.menuItems = [...props.leadingItems, ...state.menuItems, ...props.appendItems];
const icon = props.menuIcon || $globals.icons.dotsVertical;
async function deleteItem() {
await api[apiRoute].deleteOne(props.id);
context.emit("delete", props.id);
}
// Note: Print is handled as an event in the parent component
const eventHandlers: { [key: string]: () => void } = {
delete: () => {
state.ItemDeleteDialog = true;
},
};
function contextMenuEventHandler(eventKey: string) {
const handler = eventHandlers[eventKey];
if (handler && typeof handler === "function") {
handler();
state.loading = false;
return;
}
context.emit(eventKey);
state.loading = false;
}
return {
...toRefs(state),
contextMenuEventHandler,
deleteItem,
icon,
};
},
});
</script>

View File

@@ -1,123 +0,0 @@
<template>
<div v-if="items">
<v-app-bar color="transparent" flat class="mt-n1 rounded">
<v-icon large left>
{{ icon }}
</v-icon>
<v-toolbar-title class="headline"> {{ headline }} </v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<section v-for="(itms, key, idx) in itemsSorted" :key="'header' + idx" :class="idx === 1 ? null : 'my-4'">
<BaseCardSectionTitle :title="key"> </BaseCardSectionTitle>
<v-row>
<v-col v-for="(item, index) in itms" :key="'cat' + index" cols="12" :sm="12" :md="6" :lg="4" :xl="3">
<v-card class="left-border" hover :to="`/recipes/${itemType}/${item.slug}`">
<v-card-actions>
<v-icon>
{{ icon }}
</v-icon>
<v-card-title class="py-1">
{{ item.name }}
</v-card-title>
<v-spacer></v-spacer>
<RecipeCategoryTagToolContextMenu
:id="item.id"
:item-type="itemType"
:slug="item.slug"
:name="item.name"
:use-items="{
delete: true,
}"
@delete="$emit('delete', item.id)"
/>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</section>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, useContext, computed, useMeta } from "@nuxtjs/composition-api";
import RecipeCategoryTagToolContextMenu from "./RecipeCategoryTagToolContextMenu.vue";
type ItemType = "tags" | "categories" | "tools";
const ItemTypes = {
tag: "tags",
category: "categories",
tool: "tools",
};
interface GenericItem {
id: string;
name: string;
slug: string;
}
export default defineComponent({
components: { RecipeCategoryTagToolContextMenu },
props: {
itemType: {
type: String as () => ItemType,
required: true,
},
items: {
type: Array as () => GenericItem[],
required: true,
},
},
setup(props) {
const { i18n, $globals } = useContext();
const state = reactive({
headline: "tags",
icon: $globals.icons.tags,
});
switch (props.itemType) {
case ItemTypes.tag:
state.headline = i18n.t("tag.tags") as string;
break;
case ItemTypes.category:
state.headline = i18n.t("category.categories") as string;
break;
case ItemTypes.tool:
state.headline = i18n.t("tool.tools") as string;
state.icon = $globals.icons.potSteam;
break;
default:
break;
}
useMeta(() => ({
title: state.headline,
}));
const itemsSorted = computed(() => {
const byLetter: { [key: string]: Array<GenericItem> } = {};
if (!props.items) return byLetter;
props.items.forEach((item) => {
const letter = item.name[0].toUpperCase();
if (!byLetter[letter]) {
byLetter[letter] = [];
}
byLetter[letter].push(item);
});
return byLetter;
});
return {
...toRefs(state),
itemsSorted,
};
},
// Needed for useMeta
head: {},
});
</script>

View File

@@ -118,7 +118,7 @@
<script lang="ts">
import { computed, defineComponent, reactive, ref, toRefs } from "@nuxtjs/composition-api";
import { useFoods, useUnits } from "~/composables/recipes";
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
import { validators } from "~/composables/use-validators";
import { RecipeIngredient } from "~/types/api-types/recipe";
@@ -136,24 +136,28 @@ export default defineComponent({
setup(props) {
// ==================================================
// Foods
const { foods, workingFoodData, actions: foodActions } = useFoods();
const foodStore = useFoodStore();
const foodData = useFoodData();
const foodSearch = ref("");
async function createAssignFood() {
workingFoodData.name = foodSearch.value;
await foodActions.createOne();
props.value.food = foods.value?.find((food) => food.name === foodSearch.value);
foodData.data.name = foodSearch.value;
await foodStore.actions.createOne(foodData.data);
props.value.food = foodStore.foods.value?.find((food) => food.name === foodSearch.value);
foodData.reset();
}
// ==================================================
// Units
const { units, workingUnitData, actions: unitActions } = useUnits();
const unitStore = useUnitStore();
const unitsData = useUnitData();
const unitSearch = ref("");
async function createAssignUnit() {
workingUnitData.name = unitSearch.value;
await unitActions.createOne();
props.value.unit = units.value?.find((unit) => unit.name === unitSearch.value);
unitsData.data.name = unitSearch.value;
await unitStore.actions.createOne(unitsData.data);
props.value.unit = unitStore.units.value?.find((unit) => unit.name === unitSearch.value);
unitsData.reset();
}
const state = reactive({
@@ -226,22 +230,22 @@ export default defineComponent({
}
return {
...toRefs(state),
quantityFilter,
toggleOriginalText,
contextMenuOptions,
handleUnitEnter,
handleFoodEnter,
...toRefs(state),
createAssignFood,
createAssignUnit,
foods,
foods: foodStore.foods,
foodSearch,
toggleTitle,
unitActions,
units,
unitActions: unitStore.actions,
units: unitStore.units,
unitSearch,
validators,
workingUnitData,
workingUnitData: unitsData.data,
};
},
});

View File

@@ -168,12 +168,26 @@
</v-icon>
</v-fade-transition>
</v-card-title>
<v-card-text v-if="edit">
<!-- Content -->
<v-card-text
v-if="edit"
:class="{
blur: imageUploadMode,
}"
@drop.stop.prevent="handleImageDrop(index, $event)"
>
<MarkdownEditor
v-model="value[index]['text']"
class="mb-2"
:preview.sync="previewStates[index]"
:display-preview="false"
:textarea="{
hint: 'Attach images by dragging & dropping them into the editor',
persistentHint: true,
}"
/>
<div
v-for="ing in step.ingredientReferences"
:key="ing.referenceId"
@@ -199,10 +213,11 @@
import draggable from "vuedraggable";
// @ts-ignore vue-markdown has no types
import VueMarkdown from "@adapttive/vue-markdown";
import { ref, toRefs, reactive, defineComponent, watch, onMounted } from "@nuxtjs/composition-api";
import { RecipeStep, IngredientReferences, RecipeIngredient } from "~/types/api-types/recipe";
import { ref, toRefs, reactive, defineComponent, watch, onMounted, useContext } from "@nuxtjs/composition-api";
import { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset } from "~/types/api-types/recipe";
import { parseIngredientText } from "~/composables/recipes";
import { uuid4 } from "~/composables/use-utils";
import { uuid4, detectServerBaseUrl } from "~/composables/use-utils";
import { useUserApi, useStaticRoutes } from "~/composables/api";
interface MergerHistory {
target: number;
@@ -237,9 +252,26 @@ export default defineComponent({
type: Boolean,
default: false,
},
recipeId: {
type: String,
default: "",
},
recipeSlug: {
type: String,
default: "",
},
assets: {
type: Array as () => RecipeAsset[],
required: true,
},
},
setup(props, context) {
const { req } = useContext();
const BASE_URL = detectServerBaseUrl(req);
console.log("Base URL", BASE_URL);
const state = reactive({
dialog: false,
disabledSteps: [] as number[],
@@ -368,7 +400,7 @@ export default defineComponent({
}
function autoSetReferences() {
// Ingore matching blacklisted words when auto-linking - This is kind of a cludgey implementation. We're blacklisting common words but
// Ignore matching blacklisted words when auto-linking - This is kind of a cludgey implementation. We're blacklisting common words but
// other common phrases trigger false positives and I'm not sure how else to approach this. In the future I maybe look at looking directly
// at the food variable and seeing if the food is in the instructions, but I still need to support those who don't want to provide the value
// and only use the "notes" feature.
@@ -493,7 +525,59 @@ export default defineComponent({
const drag = ref(false);
// ===============================================================
// Image Uploader
const api = useUserApi();
const { recipeAssetPath } = useStaticRoutes();
const imageUploadMode = ref(false);
function toggleDragMode() {
console.log("Toggling Drag Mode");
imageUploadMode.value = !imageUploadMode.value;
}
onMounted(() => {
if (props.assets === undefined) {
context.emit("update:assets", []);
}
});
async function handleImageDrop(index: number, e: DragEvent) {
if (!e.dataTransfer) {
return;
}
// Check if the file is an image
const file = e.dataTransfer.files[0];
if (!file || !file.type.startsWith("image/")) {
return;
}
const { data } = await api.recipes.createAsset(props.recipeSlug, {
name: file.name,
icon: "mdi-file-image",
file,
extension: file.name.split(".").pop() || "",
});
if (!data) {
return; // TODO: Handle error
}
context.emit("update:assets", [...props.assets, data]);
const assetUrl = BASE_URL + recipeAssetPath(props.recipeId, data.fileName as string);
const text = `<img src="${assetUrl}" height="100%" width="100%"/>`;
props.value[index].text += text;
}
return {
// Image Uploader
toggleDragMode,
handleImageDrop,
imageUploadMode,
// Rest
drag,
togglePreviewState,
toggleCollapseSection,
@@ -553,4 +637,21 @@ export default defineComponent({
.list-group-item i {
cursor: pointer;
}
.blur {
filter: blur(2px);
}
.upload-overlay {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1;
}
</style>

View File

@@ -0,0 +1,152 @@
<template>
<div>
<v-dialog v-model="dialog" width="500">
<v-card>
<v-app-bar dense dark color="primary mb-2">
<v-icon large left class="mt-1">
{{ itemType === Organizer.Tool ? $globals.icons.potSteam : $globals.icons.tags }}
</v-icon>
<v-toolbar-title class="headline">
{{ properties.title }}
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-title> </v-card-title>
<v-form @submit.prevent="select">
<v-card-text>
<v-text-field
v-model="name"
dense
:label="properties.label"
:rules="[rules.required]"
autofocus
></v-text-field>
<v-checkbox v-if="itemType === Organizer.Tool" v-model="onHand" label="On Hand"></v-checkbox>
</v-card-text>
<v-card-actions>
<BaseButton cancel @click="dialog = false" />
<v-spacer></v-spacer>
<BaseButton type="submit" create :disabled="!name" />
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, reactive, toRefs, watch } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
import { useCategoryStore, useTagStore, useToolStore } from "~/composables/store";
import { RecipeOrganizer, Organizer } from "~/types/recipe/organizers";
const CREATED_ITEM_EVENT = "created-item";
export default defineComponent({
props: {
value: {
type: Boolean,
default: false,
},
color: {
type: String,
default: null,
},
tagDialog: {
type: Boolean,
default: true,
},
itemType: {
type: String as () => RecipeOrganizer,
default: "category",
},
},
setup(props, context) {
const state = reactive({
name: "",
onHand: false,
});
const dialog = computed({
get() {
return props.value;
},
set(value) {
context.emit("input", value);
},
});
watch(
() => props.value,
(val: boolean) => {
if (!val) state.name = "";
}
);
const userApi = useUserApi();
const store = (() => {
switch (props.itemType) {
case Organizer.Tag:
return useTagStore();
case Organizer.Tool:
return useToolStore();
default:
return useCategoryStore();
}
})();
const properties = computed(() => {
switch (props.itemType) {
case Organizer.Tag:
return {
title: "Create a Tag",
label: "Tag Name",
api: userApi.tags,
};
case Organizer.Tool:
return {
title: "Create a Tool",
label: "Tool Name",
api: userApi.tools,
};
default:
return {
title: "Create a Category",
label: "Category Name",
api: userApi.categories,
};
}
});
const rules = {
required: (val: string) => !!val || "A Name is Required",
};
async function select() {
if (store) {
// @ts-ignore - only property really required is the name
await store.actions.createOne({ name: state.name });
}
const newItem = store.items.value.find((item) => item.name === state.name);
context.emit(CREATED_ITEM_EVENT, newItem);
dialog.value = false;
}
return {
Organizer,
...toRefs(state),
dialog,
properties,
rules,
select,
};
},
});
</script>
<style></style>

View File

@@ -0,0 +1,139 @@
<template>
<div v-if="items">
<RecipeOrganizerDialog v-model="dialog" :item-type="itemType" />
<BaseDialog
v-if="deleteTarget"
v-model="deleteDialog"
:title="`Delete ${deleteTarget.name}`"
color="error"
:icon="$globals.icons.alertCircle"
@confirm="deleteOne()"
>
<v-card-text> Are you sure you want to delete this {{ deleteTarget.name }}? </v-card-text>
</BaseDialog>
<v-app-bar color="transparent" flat class="mt-n1 rounded align-center">
<v-icon large left>
{{ icon }}
</v-icon>
<v-toolbar-title class="headline">
<slot name="title">
{{ headline }}
</slot>
</v-toolbar-title>
<v-spacer></v-spacer>
<BaseButton create @click="dialog = true" />
</v-app-bar>
<section v-for="(itms, key, idx) in itemsSorted" :key="'header' + idx" :class="idx === 1 ? null : 'my-4'">
<BaseCardSectionTitle :title="key"> </BaseCardSectionTitle>
<v-row>
<v-col v-for="(item, index) in itms" :key="'cat' + index" cols="12" :sm="12" :md="6" :lg="4" :xl="3">
<v-card class="left-border" hover :to="`/recipes/${itemType}/${item.slug}`">
<v-card-actions>
<v-icon>
{{ icon }}
</v-icon>
<v-card-title class="py-1">
{{ item.name }}
</v-card-title>
<v-spacer></v-spacer>
<ContextMenu :items="[presets.delete]" @delete="confirmDelete(item)" />
</v-card-actions>
</v-card>
</v-col>
</v-row>
</section>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, ref } from "@nuxtjs/composition-api";
import { useContextPresets } from "~/composables/use-context-presents";
import RecipeOrganizerDialog from "~/components/Domain/Recipe/RecipeOrganizerDialog.vue";
import { RecipeOrganizer } from "~/types/recipe/organizers";
interface GenericItem {
id?: string;
name: string;
slug: string;
}
export default defineComponent({
components: {
RecipeOrganizerDialog,
},
props: {
items: {
type: Array as () => GenericItem[],
required: true,
},
icon: {
type: String,
required: true,
},
itemType: {
type: String as () => RecipeOrganizer,
required: true,
},
},
setup(props, { emit }) {
// =================================================================
// Sorted Items
const itemsSorted = computed(() => {
const byLetter: { [key: string]: Array<GenericItem> } = {};
if (!props.items) return byLetter;
props.items.forEach((item) => {
const letter = item.name[0].toUpperCase();
if (!byLetter[letter]) {
byLetter[letter] = [];
}
byLetter[letter].push(item);
});
for (const key in byLetter) {
byLetter[key] = byLetter[key].sort((a, b) => {
return a.name.localeCompare(b.name);
});
}
return byLetter;
});
// =================================================================
// Context Menu
const presets = useContextPresets();
const deleteTarget = ref<GenericItem | null>(null);
const deleteDialog = ref(false);
function confirmDelete(item: GenericItem) {
deleteTarget.value = item;
deleteDialog.value = true;
}
function deleteOne() {
if (!deleteTarget.value) {
return;
}
emit("delete", deleteTarget.value.id);
}
const dialog = ref(false);
return {
dialog,
confirmDelete,
deleteOne,
deleteDialog,
deleteTarget,
presets,
itemsSorted,
};
},
// Needed for useMeta
head: {},
});
</script>

View File

@@ -1,14 +1,14 @@
<template>
<v-autocomplete
v-model="selected"
:items="items"
:items="storeItem"
:value="value"
:label="label"
chips
deletable-chips
item-text="name"
multiple
:prepend-inner-icon="$globals.icons.tags"
:prepend-inner-icon="selectorType === Organizer.Tool ? $globals.icons.potSteam : $globals.icons.tags"
return-object
v-bind="inputAttrs"
>
@@ -17,6 +17,7 @@
:key="data.index"
class="ma-1"
:input-value="data.selected"
small
close
label
color="accent"
@@ -26,41 +27,55 @@
{{ data.item.name || data.item }}
</v-chip>
</template>
<template v-if="showAdd" #append-outer>
<v-btn icon @click="dialog = true">
<v-icon>
{{ $globals.icons.create }}
</v-icon>
</v-btn>
<RecipeOrganizerDialog v-model="dialog" :item-type="selectorType" @created-item="appendCreated" />
</template>
</v-autocomplete>
</template>
<script lang="ts">
import { defineComponent, useContext } from "@nuxtjs/composition-api";
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import { computed, onMounted } from "vue-demi";
import RecipeOrganizerDialog from "./RecipeOrganizerDialog.vue";
import { RecipeCategory, RecipeTag } from "~/types/api-types/user";
import { RecipeTool } from "~/types/api-types/admin";
type OrganizerType = "tag" | "category" | "tool";
import { useTagStore } from "~/composables/store/use-tag-store";
import { useCategoryStore, useToolStore } from "~/composables/store";
import { Organizer, RecipeOrganizer } from "~/types/recipe/organizers";
export default defineComponent({
components: {
RecipeOrganizerDialog,
},
props: {
value: {
type: Array as () => (RecipeTag | RecipeCategory | RecipeTool)[] | undefined,
type: Array as () => (RecipeTag | RecipeCategory | RecipeTool | string)[] | undefined,
required: true,
},
/**
* The type of organizer to use.
*/
selectorType: {
type: String as () => OrganizerType,
required: true,
},
/**
* List of items that are available to be chosen from
*/
items: {
type: Array as () => (RecipeTag | RecipeCategory | RecipeTool)[],
type: String as () => RecipeOrganizer,
required: true,
},
inputAttrs: {
type: Object as () => Record<string, any>,
default: () => ({}),
},
returnObject: {
type: Boolean,
default: true,
},
showAdd: {
type: Boolean,
default: true,
},
},
setup(props, context) {
@@ -81,27 +96,62 @@ export default defineComponent({
const label = computed(() => {
switch (props.selectorType) {
case "tag":
case Organizer.Tag:
return i18n.t("tag.tags");
case "category":
case Organizer.Category:
return i18n.t("category.categories");
case "tool":
return "Tools";
case Organizer.Tool:
return i18n.t("tool.tools");
default:
return "Organizer";
}
});
// ===========================================================================
// Store & Items Setup
const store = (() => {
switch (props.selectorType) {
case Organizer.Tag:
return useTagStore();
case Organizer.Tool:
return useToolStore();
default:
return useCategoryStore();
}
})();
const items = computed(() => {
if (!props.returnObject) {
return store.items.value.map((item) => item.name);
}
return store.items.value;
});
function removeByIndex(index: number) {
if (selected.value === undefined) {
return;
}
const newSelected = selected.value.filter((_, i) => i !== index);
selected.value = [...newSelected];
}
function appendCreated(item: RecipeTag | RecipeCategory | RecipeTool) {
console.log(item);
if (selected.value === undefined) {
return;
}
selected.value = [...selected.value, item];
}
const dialog = ref(false);
return {
Organizer,
appendCreated,
dialog,
storeItem: items,
label,
selected,
removeByIndex,

View File

@@ -19,16 +19,12 @@
<div class="ingredient-grid">
<div class="ingredient-col-1">
<ul>
<li v-for="(text, index) in splitIngredients.value.firstHalf" :key="index">
{{ text }}
</li>
<li v-for="(text, index) in splitIngredients.firstHalf" :key="index" v-html="text" />
</ul>
</div>
<div class="ingredient-col-2">
<ul>
<li v-for="(text, index) in splitIngredients.value.secondHalf" :key="index">
{{ text }}
</li>
<li v-for="(text, index) in splitIngredients.secondHalf" :key="index" v-html="text" />
</ul>
</div>
</div>
@@ -57,10 +53,9 @@
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { defineComponent, computed } 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";

View File

@@ -122,8 +122,5 @@ export default defineComponent({
listItem,
};
},
head: {
title: "vbase-nuxt",
},
});
</script>
</script>

View File

@@ -0,0 +1,56 @@
<template>
<v-menu
offset-y
left
:bottom="!menuTop"
:nudge-bottom="!menuTop ? '5' : '0'"
:top="menuTop"
:nudge-top="menuTop ? '5' : '0'"
allow-overflow
close-delay="125"
open-on-hover
content-class="d-print-none"
>
<template #activator="{ on, attrs }">
<v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent>
<v-icon>{{ $globals.icons.dotsVertical }}</v-icon>
</v-btn>
</template>
<v-list dense>
<v-list-item v-for="(item, index) in items" :key="index" @click="$emit(item.event)">
<v-list-item-icon>
<v-icon :color="item.color ? item.color : undefined">
{{ item.icon }}
</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { ContextMenuItem } from "~/composables/use-context-presents";
export default defineComponent({
props: {
items: {
type: Array as () => ContextMenuItem[],
required: true,
},
menuTop: {
type: Boolean,
default: true,
},
fab: {
type: Boolean,
default: false,
},
color: {
type: String,
default: "grey darken-2",
},
},
});
</script>

View File

@@ -5,7 +5,7 @@
:buttons="[
{
icon: previewState ? $globals.icons.edit : $globals.icons.eye,
text: previewState ? $t('general.edit') : 'Preview Markdown',
text: previewState ? $tc('general.edit') : 'Preview Markdown',
event: 'toggle',
},
]"
@@ -14,14 +14,15 @@
</div>
<v-textarea
v-if="!previewState"
v-bind="textarea"
v-model="inputVal"
:class="label == '' ? '' : 'mt-5'"
:label="label"
auto-grow
dense
rows="4"
></v-textarea>
<VueMarkdown v-else :source="value"> </VueMarkdown>
/>
<VueMarkdown v-else :source="value" />
</div>
</template>
@@ -53,6 +54,10 @@ export default defineComponent({
type: Boolean,
default: true,
},
textarea: {
type: Object,
default: () => ({}),
},
},
setup(props, context) {
const fallbackPreview = ref(false);
@@ -84,5 +89,3 @@ export default defineComponent({
},
});
</script>

View File

@@ -11,15 +11,15 @@ export const useStaticRoutes = () => {
// Methods to Generate reference urls for assets/images *
function recipeImage(recipeId: string, version = "", key = 1) {
return `${fullBase}/media/recipes/${recipeId}/images/original.webp?&rnd=${key}&version=${version}`;
return `${fullBase}/media/recipes/${recipeId}/images/original.webp?rnd=${key}&version=${version}`;
}
function recipeSmallImage(recipeId: string, version = "", key = 1) {
return `${fullBase}/media/recipes/${recipeId}/images/min-original.webp?&rnd=${key}&version=${version}`;
return `${fullBase}/media/recipes/${recipeId}/images/min-original.webp?rnd=${key}&version=${version}`;
}
function recipeTinyImage(recipeId: string, version = "", key = 1) {
return `${fullBase}/media/recipes/${recipeId}/images/tiny-original.webp?&rnd=${key}&version=${version}`;
return `${fullBase}/media/recipes/${recipeId}/images/tiny-original.webp?rnd=${key}&version=${version}`;
}
function recipeAssetPath(recipeId: string, assetName: string) {

View File

@@ -0,0 +1,95 @@
import { Ref, useAsync } from "@nuxtjs/composition-api";
import { useAsyncKey } from "../use-utils";
import { BaseCRUDAPI } from "~/api/_base";
type BoundT = {
id?: string | number;
};
interface StoreActions<T extends BoundT> {
getAll(): Ref<T[] | null>;
refresh(): Promise<void>;
createOne(createData: T): Promise<void>;
updateOne(updateData: T): Promise<void>;
deleteOne(id: string | number): Promise<void>;
}
/**
* useStoreActions is a factory function that returns a set of methods
* that can be reused to manage the state of a data store without using
* Vuex. This is primarily used for basic CRUD operations that required
* a lot of refreshing hooks to be called on operations
*/
export function useStoreActions<T extends BoundT>(
api: BaseCRUDAPI<unknown, T, unknown>,
allRef: Ref<T[] | null> | null,
loading: Ref<boolean>
): StoreActions<T> {
function getAll() {
loading.value = true;
const allItems = useAsync(async () => {
const { data } = await api.getAll();
if (allRef) {
allRef.value = data;
}
return data ?? [];
}, useAsyncKey());
loading.value = false;
return allItems;
}
async function refresh() {
loading.value = true;
const { data } = await api.getAll();
if (data && allRef) {
allRef.value = data;
}
loading.value = false;
}
async function createOne(createData: T) {
loading.value = true;
const { data } = await api.createOne(createData);
if (data && allRef?.value) {
allRef.value.push(data);
} else {
refresh();
}
loading.value = false;
}
async function updateOne(updateData: T) {
if (!updateData.id) {
return;
}
loading.value = true;
const { data } = await api.updateOne(updateData.id, updateData);
if (data && allRef?.value) {
refresh();
}
loading.value = false;
}
async function deleteOne(id: string | number) {
loading.value = true;
const { response } = await api.deleteOne(id);
if (response && allRef?.value) {
refresh();
}
loading.value = false;
}
return {
getAll,
refresh,
createOne,
updateOne,
deleteOne,
};
}

View File

@@ -1,9 +1,6 @@
export { useFraction } from "./use-fraction";
export { useRecipe } from "./use-recipe";
export { useFoods } from "./use-recipe-foods";
export { useUnits } from "./use-recipe-units";
export { useRecipes, recentRecipes, allRecipes, useLazyRecipes, useSorter } from "./use-recipes";
export { useTags, useCategories, allCategories, allTags } from "./use-tags-categories";
export { parseIngredientText } from "./use-recipe-ingredients";
export { useRecipeSearch } from "./use-recipe-search";
export { useTools } from "./use-recipe-tools";

View File

@@ -1,104 +0,0 @@
import { useAsync, ref, reactive, Ref } from "@nuxtjs/composition-api";
import { useAsyncKey } from "../use-utils";
import { useUserApi } from "~/composables/api";
import { VForm } from "~/types/vuetify";
import { IngredientFood } from "~/types/api-types/recipe";
let foodStore: Ref<IngredientFood[] | null> | null = null;
export const useFoods = function () {
const api = useUserApi();
const loading = ref(false);
const deleteTargetId = ref(0);
const validForm = ref(true);
const workingFoodData = reactive<IngredientFood>({
id: "",
name: "",
description: "",
labelId: undefined,
});
const actions = {
getAll() {
loading.value = true;
const units = useAsync(async () => {
const { data } = await api.foods.getAll();
return data;
}, useAsyncKey());
loading.value = false;
return units;
},
async refreshAll() {
loading.value = true;
const { data } = await api.foods.getAll();
if (data && foodStore) {
foodStore.value = data;
}
loading.value = false;
},
async createOne(domForm: VForm | null = null) {
if (domForm && !domForm.validate()) {
validForm.value = false;
return;
}
loading.value = true;
const { data } = await api.foods.createOne(workingFoodData);
if (data && foodStore?.value) {
foodStore.value.push(data);
return data;
} else {
this.refreshAll();
}
domForm?.reset();
validForm.value = true;
this.resetWorking();
loading.value = false;
},
async updateOne() {
if (!workingFoodData.id) {
return;
}
loading.value = true;
console.log(workingFoodData);
const { data } = await api.foods.updateOne(workingFoodData.id, workingFoodData);
if (data && foodStore?.value) {
this.refreshAll();
}
loading.value = false;
},
async deleteOne(id: string | number) {
loading.value = true;
const { data } = await api.foods.deleteOne(id);
if (data && foodStore?.value) {
this.refreshAll();
}
},
resetWorking() {
workingFoodData.id = "";
workingFoodData.name = "";
workingFoodData.description = "";
workingFoodData.labelId = undefined;
},
setWorking(item: IngredientFood) {
workingFoodData.id = item.id;
workingFoodData.name = item.name;
workingFoodData.description = item.description || "";
workingFoodData.labelId = item.labelId;
},
flushStore() {
foodStore = null;
},
};
if (!foodStore) {
foodStore = actions.getAll();
}
return { foods: foodStore, workingFoodData, deleteTargetId, actions, validForm };
};

View File

@@ -19,6 +19,8 @@ export function parseIngredientText(ingredient: RecipeIngredient, disableAmount:
let returnQty = "";
let unitDisplay = unit?.name;
// casting to number is required as sometimes quantity is a string
if (quantity && Number(quantity) !== 0) {
console.log("Using Quantity", quantity, typeof quantity);
@@ -34,8 +36,12 @@ export function parseIngredientText(ingredient: RecipeIngredient, disableAmount:
} else {
returnQty = (quantity * scale).toString();
}
if (unit?.useAbbreviation && unit.abbreviation) {
unitDisplay = unit.abbreviation;
}
}
const text = `${returnQty} ${unit?.name || " "} ${food?.name || " "} ${note || " "}`.replace(/ {2,}/g, " ");
const text = `${returnQty} ${unitDisplay || " "} ${food?.name || " "} ${note || " "}`.replace(/ {2,}/g, " ");
return sanitizeIngredientHTML(text);
}

View File

@@ -1,104 +0,0 @@
import { useAsync, ref, reactive, Ref } from "@nuxtjs/composition-api";
import { useAsyncKey } from "../use-utils";
import { useUserApi } from "~/composables/api";
import { VForm } from "~/types/vuetify";
import { IngredientUnit } from "~/types/api-types/recipe";
let unitStore: Ref<IngredientUnit[] | null> | null = null;
export const useUnits = function () {
const api = useUserApi();
const loading = ref(false);
const deleteTargetId = ref(0);
const validForm = ref(true);
const workingUnitData: IngredientUnit = reactive({
id: "",
name: "",
fraction: true,
abbreviation: "",
description: "",
});
const actions = {
getAll() {
loading.value = true;
const units = useAsync(async () => {
const { data } = await api.units.getAll();
return data;
}, useAsyncKey());
loading.value = false;
return units;
},
async refreshAll() {
loading.value = true;
const { data } = await api.units.getAll();
if (data && unitStore) {
unitStore.value = data;
}
loading.value = false;
},
async createOne(domForm: VForm | null = null) {
if (domForm && !domForm.validate()) {
validForm.value = false;
return;
}
loading.value = true;
const { data } = await api.units.createOne(workingUnitData);
if (data && unitStore?.value) {
unitStore.value.push(data);
} else {
this.refreshAll();
}
domForm?.reset();
validForm.value = true;
this.resetWorking();
loading.value = false;
},
async updateOne() {
if (!workingUnitData.id) {
return;
}
loading.value = true;
const { data } = await api.units.updateOne(workingUnitData.id, workingUnitData);
if (data && unitStore?.value) {
this.refreshAll();
}
loading.value = false;
},
async deleteOne(id: string | number) {
loading.value = true;
const { data } = await api.units.deleteOne(id);
if (data && unitStore?.value) {
this.refreshAll();
}
},
resetWorking() {
workingUnitData.id = "";
workingUnitData.name = "";
workingUnitData.abbreviation = "";
workingUnitData.description = "";
},
setWorking(item: IngredientUnit) {
workingUnitData.id = item.id;
workingUnitData.name = item.name;
workingUnitData.fraction = item.fraction;
workingUnitData.abbreviation = item.abbreviation;
workingUnitData.description = item.description;
},
flushStore() {
unitStore = null;
},
};
if (!unitStore) {
unitStore = actions.getAll();
}
return { units: unitStore, workingUnitData, deleteTargetId, actions, validForm };
};

View File

@@ -1,65 +0,0 @@
import { Ref, ref, useAsync } from "@nuxtjs/composition-api";
import { useUserApi } from "../api";
import { useAsyncKey } from "../use-utils";
import { CategoriesAPI } from "~/api/class-interfaces/organizer-categories";
import { TagsAPI } from "~/api/class-interfaces/organizer-tags";
import { RecipeTag, RecipeCategory } from "~/types/api-types/recipe";
export const allCategories = ref<RecipeCategory[] | null>([]);
export const allTags = ref<RecipeTag[] | null>([]);
function baseTagsCategories(
reference: Ref<RecipeCategory[] | null> | Ref<RecipeTag[] | null>,
api: TagsAPI | CategoriesAPI
) {
function useAsyncGetAll() {
useAsync(async () => {
await refreshItems();
}, useAsyncKey());
}
async function refreshItems() {
const { data } = await api.getAll();
// @ts-ignore hotfix
reference.value = data;
}
async function createOne(payload: { name: string }) {
const { data } = await api.createOne(payload);
if (data) {
refreshItems();
}
}
async function deleteOne(slug: string) {
const { data } = await api.deleteOne(slug);
if (data) {
refreshItems();
}
}
async function updateOne(slug: string, payload: { name: string }) {
// @ts-ignore // TODO: Fix Typescript Issue - Unsure how to fix this while also keeping mixins
const { data } = await api.updateOne(slug, payload);
if (data) {
refreshItems();
}
}
return { useAsyncGetAll, refreshItems, createOne, deleteOne, updateOne };
}
export const useTags = function () {
const api = useUserApi();
return {
allTags,
...baseTagsCategories(allTags, api.tags),
};
};
export const useCategories = function () {
const api = useUserApi();
return {
allCategories,
...baseTagsCategories(allCategories, api.categories),
};
};

View File

@@ -0,0 +1,6 @@
export { useFoodStore, useFoodData } from "./use-food-store";
export { useUnitStore, useUnitData } from "./use-unit-store";
export { useLabelStore, useLabelData } from "./use-label-store";
export { useToolStore, useToolData } from "./use-tool-store";
export { useCategoryStore, useCategoryData } from "./use-category-store";
export { useTagStore, useTagData } from "./use-tag-store";

View File

@@ -0,0 +1,47 @@
import { reactive, ref, Ref } from "@nuxtjs/composition-api";
import { useStoreActions } from "../partials/use-actions-factory";
import { useUserApi } from "~/composables/api";
import { RecipeCategory } from "~/types/api-types/admin";
const categoryStore: Ref<RecipeCategory[]> = ref([]);
export function useCategoryData() {
const data = reactive({
id: "",
name: "",
slug: undefined,
});
function reset() {
data.id = "";
data.name = "";
data.slug = undefined;
}
return {
data,
reset,
};
}
export function useCategoryStore() {
const api = useUserApi();
const loading = ref(false);
const actions = {
...useStoreActions<RecipeCategory>(api.categories, categoryStore, loading),
flushStore() {
categoryStore.value = [];
},
};
if (!categoryStore.value || categoryStore.value?.length === 0) {
actions.getAll();
}
return {
items: categoryStore,
actions,
loading,
};
}

View File

@@ -0,0 +1,50 @@
import { ref, reactive, Ref } from "@nuxtjs/composition-api";
import { useStoreActions } from "../partials/use-actions-factory";
import { useUserApi } from "~/composables/api";
import { IngredientFood } from "~/types/api-types/recipe";
let foodStore: Ref<IngredientFood[] | null> | null = null;
/**
* useFoodData returns a template reactive object
* for managing the creation of units. It also provides a
* function to reset the data back to the initial state.
*/
export const useFoodData = function () {
const data: IngredientFood = reactive({
id: "",
name: "",
description: "",
labelId: undefined,
});
function reset() {
data.id = "";
data.name = "";
data.description = "";
data.labelId = undefined;
}
return {
data,
reset,
};
};
export const useFoodStore = function () {
const api = useUserApi();
const loading = ref(false);
const actions = {
...useStoreActions(api.foods, foodStore, loading),
flushStore() {
foodStore = null;
},
};
if (!foodStore) {
foodStore = actions.getAll();
}
return { foods: foodStore, actions };
};

View File

@@ -0,0 +1,49 @@
import { reactive, ref, Ref } from "@nuxtjs/composition-api";
import { useStoreActions } from "../partials/use-actions-factory";
import { MultiPurposeLabelOut } from "~/types/api-types/labels";
import { useUserApi } from "~/composables/api";
let labelStore: Ref<MultiPurposeLabelOut[] | null> | null = null;
export function useLabelData() {
const data = reactive({
groupId: "",
id: "",
name: "",
color: "",
});
function reset() {
data.groupId = "";
data.id = "";
data.name = "";
data.color = "";
}
return {
data,
reset,
};
}
export function useLabelStore() {
const api = useUserApi();
const loading = ref(false);
const actions = {
...useStoreActions<MultiPurposeLabelOut>(api.multiPurposeLabels, labelStore, loading),
flushStore() {
labelStore = null;
},
};
if (!labelStore) {
labelStore = actions.getAll();
}
return {
labels: labelStore,
actions,
loading,
};
}

View File

@@ -0,0 +1,47 @@
import { reactive, ref, Ref } from "@nuxtjs/composition-api";
import { useStoreActions } from "../partials/use-actions-factory";
import { useUserApi } from "~/composables/api";
import { RecipeTag } from "~/types/api-types/admin";
const items: Ref<RecipeTag[]> = ref([]);
export function useTagData() {
const data = reactive({
id: "",
name: "",
slug: undefined,
});
function reset() {
data.id = "";
data.name = "";
data.slug = undefined;
}
return {
data,
reset,
};
}
export function useTagStore() {
const api = useUserApi();
const loading = ref(false);
const actions = {
...useStoreActions<RecipeTag>(api.tags, items, loading),
flushStore() {
items.value = [];
},
};
if (!items.value || items.value?.length === 0) {
actions.getAll();
}
return {
items,
actions,
loading,
};
}

View File

@@ -0,0 +1,49 @@
import { reactive, ref, Ref } from "@nuxtjs/composition-api";
import { useStoreActions } from "../partials/use-actions-factory";
import { useUserApi } from "~/composables/api";
import { RecipeTool } from "~/types/api-types/recipe";
const toolStore: Ref<RecipeTool[]> = ref([]);
export function useToolData() {
const data = reactive({
id: "",
name: "",
slug: undefined,
onHand: false,
});
function reset() {
data.id = "";
data.name = "";
data.slug = undefined;
data.onHand = false;
}
return {
data,
reset,
};
}
export function useToolStore() {
const api = useUserApi();
const loading = ref(false);
const actions = {
...useStoreActions<RecipeTool>(api.tools, toolStore, loading),
flushStore() {
toolStore.value = [];
},
};
if (!toolStore.value || toolStore.value?.length === 0) {
actions.getAll();
}
return {
items: toolStore,
actions,
loading,
};
}

View File

@@ -0,0 +1,52 @@
import { ref, reactive, Ref } from "@nuxtjs/composition-api";
import { useStoreActions } from "../partials/use-actions-factory";
import { useUserApi } from "~/composables/api";
import { IngredientUnit } from "~/types/api-types/recipe";
let unitStore: Ref<IngredientUnit[] | null> | null = null;
/**
* useUnitData returns a template reactive object
* for managing the creation of units. It also provides a
* function to reset the data back to the initial state.
*/
export const useUnitData = function () {
const data: IngredientUnit = reactive({
id: "",
name: "",
fraction: true,
abbreviation: "",
description: "",
});
function reset() {
data.id = "";
data.name = "";
data.fraction = true;
data.abbreviation = "";
data.description = "";
}
return {
data,
reset,
};
};
export const useUnitStore = function () {
const api = useUserApi();
const loading = ref(false);
const actions = {
...useStoreActions<IngredientUnit>(api.units, unitStore, loading),
flushStore() {
unitStore = null;
},
};
if (!unitStore) {
unitStore = actions.getAll();
}
return { units: unitStore, actions };
};

View File

@@ -0,0 +1,30 @@
import { useContext } from "@nuxtjs/composition-api";
export interface ContextMenuItem {
title: string;
icon: string;
event: string;
color?: string;
}
export function useContextPresets(): { [key: string]: ContextMenuItem } {
const { $globals, i18n } = useContext();
return {
delete: {
title: i18n.tc("general.delete"),
icon: $globals.icons.delete,
event: "delete",
},
edit: {
title: i18n.tc("general.edit"),
icon: $globals.icons.edit,
event: "edit",
},
save: {
title: i18n.tc("general.save"),
icon: $globals.icons.save,
event: "save",
},
};
}

View File

@@ -1,21 +1,21 @@
{
"short": {
"month": "short",
"day": "numeric",
"weekday": "long"
"month": "kurz",
"day": "numerisch",
"weekday": "lang"
},
"medium": {
"month": "long",
"day": "numeric",
"weekday": "long",
"year": "numeric"
"month": "lang",
"day": "numerisch",
"weekday": "lang",
"year": "numerisch"
},
"long": {
"year": "numeric",
"month": "long",
"day": "numeric",
"weekday": "long",
"hour": "numeric",
"minute": "numeric"
"year": "numerisch",
"month": "lang",
"day": "numerisch",
"weekday": "lang",
"hour": "numerisch",
"minute": "numerisch"
}
}

View File

@@ -521,7 +521,7 @@
"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": "Unione di {food1} in {food2}",
"seed-dialog-text": "Inizializza il database con alimenti in base alla tua lingua locale. Questo creerà oltre 200 alimenti comuni che possono essere utilizzati per organizzare il tuo database. Gli alimenti sono tradotti grazie al contributo della comunità di utenti.",
"seed-dialog-warning": "You have already have some items in your database. This action will not reconcile duplicates, you will have to manage them manually."
"seed-dialog-warning": "Hai già alcuni elementi nel tuo database. Questa azione non riconcilierà i duplicati, dovrai gestirli manualmente."
},
"units": {
"seed-dialog-text": "Seed the database with common units based on your local language."
@@ -534,11 +534,11 @@
"user-registration": "Registrazione Utente",
"join-a-group": "Unisciti a un Gruppo",
"create-a-new-group": "Crea un Nuovo Gruppo",
"provide-registration-token-description": "Please provide the registration token associated with the group that you'd like to join. You'll need to obtain this from an existing group member.",
"provide-registration-token-description": "Fornisci il token di registrazione associato al gruppo a cui desideri partecipare. Dovrai ottenerlo da un membro di gruppo esistente.",
"group-details": "Dettagli del Guppo",
"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.",
"use-seed-data-description": "Mealie viene fornito con una raccolta di alimenti, unità ed etichette che possono essere utilizzate per popolare il tuo gruppo con dati utili per organizzare le tue ricette.",
"account-details": "Dettagli dell'Account"
},
"validation": {

View File

@@ -516,7 +516,7 @@
"read-the-docs": "Документація"
},
"data-pages": {
"seed-data": "Seed Data",
"seed-data": "Дані Seed",
"foods": {
"merge-dialog-text": "Об'єднання вибраних страв об'єднає вихідну страву та цільову страву до однієї страви. Вихідна страва буде видалена і всі посилання на вихідну страву будуть посилатися на цільову страву.",
"merge-food-example": "Об'єднання {food1} в {food2}",

View File

@@ -21,7 +21,7 @@
"@nuxtjs/i18n": "^7.0.3",
"@nuxtjs/proxy": "^2.1.0",
"@nuxtjs/pwa": "^3.3.5",
"@vue/composition-api": "^1.0.5",
"@vue/composition-api": "^1.6.2",
"@vueuse/core": "^8.5.0",
"core-js": "^3.15.1",
"date-fns": "^2.23.0",
@@ -33,7 +33,7 @@
"vuetify": "^2.6.6"
},
"devDependencies": {
"@babel/eslint-parser": "^7.14.7",
"@babel/eslint-parser": "^7.18.2",
"@nuxt/types": "^2.15.7",
"@nuxt/typescript-build": "^2.1.0",
"@nuxtjs/composition-api": "^0.32.0",
@@ -41,7 +41,7 @@
"@nuxtjs/eslint-module": "^3.0.2",
"@nuxtjs/google-fonts": "^1.3.0",
"@nuxtjs/vuetify": "^1.12.1",
"@types/sortablejs": "^1.10.7",
"@types/sortablejs": "^1.13.0",
"@vue/runtime-dom": "^3.2.36",
"eslint": "^8.16.0",
"eslint-config-prettier": "^8.3.0",

View File

@@ -36,14 +36,9 @@
<v-card-text v-if="cookbooks">
<v-text-field v-model="cookbooks[index].name" label="Cookbook Name"></v-text-field>
<v-textarea v-model="cookbooks[index].description" auto-grow :rows="2" label="Description"></v-textarea>
<RecipeOrganizerSelector
v-model="cookbooks[index].categories"
:items="allCategories || []"
selector-type="category"
/>
<RecipeOrganizerSelector v-model="cookbooks[index].tags" :items="allTags || []" selector-type="tag" />
<RecipeOrganizerSelector v-model="cookbooks[index].tools" :items="tools || []" selector-type="tool" />
<RecipeOrganizerSelector v-model="cookbooks[index].categories" selector-type="categories" />
<RecipeOrganizerSelector v-model="cookbooks[index].tags" selector-type="tags" />
<RecipeOrganizerSelector v-model="cookbooks[index].tools" selector-type="tools" />
<v-switch v-model="cookbooks[index].public" hide-details single-line>
<template #label>
Public Cookbook
@@ -102,26 +97,15 @@ import { defineComponent } from "@nuxtjs/composition-api";
import draggable from "vuedraggable";
import { useCookbooks } from "@/composables/use-group-cookbooks";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import { useCategories, useTags, useTools } from "~/composables/recipes";
export default defineComponent({
components: { draggable, RecipeOrganizerSelector },
setup() {
const { cookbooks, actions } = useCookbooks();
const { tools } = useTools();
const { allCategories, useAsyncGetAll: getAllCategories } = useCategories();
const { allTags, useAsyncGetAll: getAllTags } = useTags();
getAllCategories();
getAllTags();
return {
allCategories,
allTags,
cookbooks,
actions,
tools,
};
},
head() {

View File

@@ -48,7 +48,7 @@
</template>
</v-autocomplete>
<v-alert v-if="foods.length > 0" type="error" class="mb-0 text-body-2">
<v-alert v-if="foods && foods.length > 0" type="error" class="mb-0 text-body-2">
{{ $t("data-pages.foods.seed-dialog-warning") }}
</v-alert>
</v-card-text>
@@ -96,7 +96,7 @@
<CrudTable
:table-config="tableConfig"
:headers.sync="tableHeaders"
:data="foods"
:data="foods || []"
:bulk-actions="[]"
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@@ -130,8 +130,8 @@ import { validators } from "~/composables/use-validators";
import { useUserApi } from "~/composables/api";
import { IngredientFood } from "~/types/api-types/recipe";
import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabel.vue";
import { MultiPurposeLabelSummary } from "~/types/api-types/labels";
import { useLocales } from "~/composables/use-locales";
import { useFoodStore, useLabelStore } from "~/composables/store";
export default defineComponent({
components: { MultiPurposeLabel },
@@ -163,32 +163,32 @@ export default defineComponent({
show: true,
},
];
const foods = ref<IngredientFood[]>([]);
async function refreshFoods() {
const { data } = await userApi.foods.getAll();
foods.value = data ?? [];
}
onMounted(() => {
refreshFoods();
});
const foodStore = useFoodStore();
// ===============================================================
// Food Editor
const editDialog = ref(false);
const editTarget = ref<IngredientFood | null>(null);
function editEventHandler(item: IngredientFood) {
editTarget.value = item;
editDialog.value = true;
}
async function editSaveFood() {
if (!editTarget.value) {
return;
}
const { data } = await userApi.foods.updateOne(editTarget.value.id, editTarget.value);
if (data) {
refreshFoods();
}
await foodStore.actions.updateOne(editTarget.value);
editDialog.value = false;
}
// ===============================================================
// Food Delete
const deleteDialog = ref(false);
const deleteTarget = ref<IngredientFood | null>(null);
function deleteEventHandler(item: IngredientFood) {
@@ -200,10 +200,7 @@ export default defineComponent({
return;
}
const { data } = await userApi.foods.deleteOne(deleteTarget.value.id);
if (data) {
refreshFoods();
}
await foodStore.actions.deleteOne(deleteTarget.value.id);
deleteDialog.value = false;
}
@@ -226,19 +223,14 @@ export default defineComponent({
const { data } = await userApi.foods.merge(fromFood.value.id, toFood.value.id);
if (data) {
refreshFoods();
foodStore.actions.refresh();
}
}
// ============================================================
// Labels
const allLabels = ref([] as MultiPurposeLabelSummary[]);
async function refreshLabels() {
const { data } = await userApi.multiPurposeLabels.getAll();
allLabels.value = data ?? [];
}
const { labels: allLabels } = useLabelStore();
// ============================================================
// Seed
@@ -260,15 +252,14 @@ export default defineComponent({
const { data } = await userApi.seeders.foods({ locale: locale.value });
if (data) {
refreshFoods();
foodStore.actions.refresh();
}
}
refreshLabels();
return {
tableConfig,
tableHeaders,
foods,
foods: foodStore.foods,
allLabels,
validators,
// Edit

View File

@@ -73,7 +73,7 @@
</template>
</v-autocomplete>
<v-alert v-if="labels.length > 0" type="error" class="mb-0 text-body-2">
<v-alert v-if="labels && labels.length > 0" type="error" class="mb-0 text-body-2">
{{ $t("data-pages.foods.seed-dialog-warning") }}
</v-alert>
</v-card-text>
@@ -84,7 +84,7 @@
<CrudTable
:table-config="tableConfig"
:headers.sync="tableHeaders"
:data="labels"
:data="labels || []"
:bulk-actions="[]"
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@@ -118,6 +118,7 @@ import { useUserApi } from "~/composables/api";
import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabel.vue";
import { MultiPurposeLabelSummary } from "~/types/api-types/labels";
import { useLocales } from "~/composables/use-locales";
import { useLabelData, useLabelStore } from "~/composables/store";
export default defineComponent({
components: { MultiPurposeLabel },
@@ -149,31 +150,14 @@ export default defineComponent({
// ============================================================
// Labels
const labels = ref([] as MultiPurposeLabelSummary[]);
async function refreshLabels() {
const { data } = await userApi.multiPurposeLabels.getAll();
labels.value = data ?? [];
}
const labelData = useLabelData();
const labelStore = useLabelStore();
// Create
const createLabelData = ref({
groupId: "",
id: "",
name: "",
color: "",
});
async function createLabel() {
await userApi.multiPurposeLabels.createOne(createLabelData.value);
createLabelData.value = {
groupId: "",
id: "",
name: "",
color: "",
};
refreshLabels();
await labelStore.actions.createOne(labelData.data);
labelData.reset();
state.createDialog = false;
}
@@ -190,10 +174,7 @@ export default defineComponent({
if (!deleteTarget.value) {
return;
}
const { data } = await userApi.multiPurposeLabels.deleteOne(deleteTarget.value.id);
if (data) {
refreshLabels();
}
await labelStore.actions.deleteOne(deleteTarget.value.id);
state.deleteDialog = false;
}
@@ -214,15 +195,10 @@ export default defineComponent({
if (!editLabel.value) {
return;
}
const { data } = await userApi.multiPurposeLabels.updateOne(editLabel.value.id, editLabel.value);
if (data) {
refreshLabels();
}
await labelStore.actions.updateOne(editLabel.value);
state.editDialog = false;
}
refreshLabels();
// ============================================================
// Seed
@@ -243,7 +219,7 @@ export default defineComponent({
const { data } = await userApi.seeders.labels({ locale: locale.value });
if (data) {
refreshLabels();
labelStore.actions.refresh();
}
}
@@ -251,7 +227,7 @@ export default defineComponent({
state,
tableConfig,
tableHeaders,
labels,
labels: labelStore.labels,
validators,
deleteEventHandler,
@@ -260,7 +236,7 @@ export default defineComponent({
editEventHandler,
editSaveLabel,
createLabel,
createLabelData,
createLabelData: labelData.data,
// Seed
seedDatabase,

View File

@@ -22,10 +22,10 @@
@submit="dialog.callback"
>
<v-card-text v-if="dialog.mode == MODES.tag">
<RecipeCategoryTagSelector v-model="toSetTags" :tag-selector="true" />
<RecipeOrganizerSelector v-model="toSetTags" selector-type="tags" />
</v-card-text>
<v-card-text v-else-if="dialog.mode == MODES.category">
<RecipeCategoryTagSelector v-model="toSetCategories" />
<RecipeOrganizerSelector v-model="toSetCategories" selector-type="categories" />
</v-card-text>
<v-card-text v-else-if="dialog.mode == MODES.delete">
<p class="h4">Are you sure you want to delete the following recipes? This action cannot be undone.</p>
@@ -149,7 +149,7 @@
<script lang="ts">
import { defineComponent, reactive, ref, useContext, onMounted } from "@nuxtjs/composition-api";
import RecipeDataTable from "~/components/Domain/Recipe/RecipeDataTable.vue";
import RecipeCategoryTagSelector from "~/components/Domain/Recipe/RecipeCategoryTagSelector.vue";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import { useUserApi } from "~/composables/api";
import { useRecipes, allRecipes } from "~/composables/recipes";
import { Recipe } from "~/types/api-types/recipe";
@@ -165,7 +165,7 @@ const MODES = {
};
export default defineComponent({
components: { RecipeDataTable, RecipeCategoryTagSelector, GroupExportData },
components: { RecipeDataTable, RecipeOrganizerSelector, GroupExportData },
scrollToTop: true,
setup() {
const { getAllRecipes, refreshRecipes } = useRecipes(true, true);

View File

@@ -6,8 +6,15 @@
Combining the selected units will merge the Source Unit and Target Unit into a single unit. The
<strong> Source Unit will be deleted </strong> and all of the references to the Source Unit will be updated to
point to the Target Unit.
<v-autocomplete v-model="fromUnit" return-object :items="units" item-text="name" label="Source Unit" />
<v-autocomplete v-model="toUnit" return-object :items="units" item-text="name" label="Target Unit" />
<v-autocomplete v-model="fromUnit" return-object :items="units" item-text="id" label="Source Unit">
<template #selection="{ item }"> {{ item.name }}</template>
<template #item="{ item }"> {{ item.name }} </template>
</v-autocomplete>
<v-autocomplete v-model="toUnit" return-object :items="units" item-text="id" label="Target Unit">
<template #selection="{ item }"> {{ item.name }}</template>
<template #item="{ item }"> {{ item.name }} </template>
</v-autocomplete>
<template v-if="canMerge && fromUnit && toUnit">
<div class="text-center">Merging {{ fromUnit.name }} into {{ toUnit.name }}</div>
@@ -29,6 +36,7 @@
<v-text-field v-model="editTarget.abbreviation" label="Abbreviation"></v-text-field>
<v-text-field v-model="editTarget.description" label="Description"></v-text-field>
<v-checkbox v-model="editTarget.fraction" hide-details label="Display as Fraction"></v-checkbox>
<v-checkbox v-model="editTarget.useAbbreviation" hide-details label="Use Abbreviation"></v-checkbox>
</v-form>
</v-card-text>
</BaseDialog>
@@ -77,7 +85,7 @@
</template>
</v-autocomplete>
<v-alert v-if="units.length > 0" type="error" class="mb-0 text-body-2">
<v-alert v-if="units && units.length > 0" type="error" class="mb-0 text-body-2">
{{ $t("data-pages.foods.seed-dialog-warning") }}
</v-alert>
</v-card-text>
@@ -88,7 +96,7 @@
<CrudTable
:table-config="tableConfig"
:headers.sync="tableHeaders"
:data="units"
:data="units || []"
:bulk-actions="[]"
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@@ -99,6 +107,11 @@
Combine
</BaseButton>
</template>
<template #item.useAbbreviation="{ item }">
<v-icon :color="item.useAbbreviation ? 'success' : undefined">
{{ item.useAbbreviation ? $globals.icons.check : $globals.icons.close }}
</v-icon>
</template>
<template #item.fraction="{ item }">
<v-icon :color="item.fraction ? 'success' : undefined">
{{ item.fraction ? $globals.icons.check : $globals.icons.close }}
@@ -120,8 +133,8 @@ import type { LocaleObject } from "@nuxtjs/i18n";
import { validators } from "~/composables/use-validators";
import { useUserApi } from "~/composables/api";
import { IngredientUnit } from "~/types/api-types/recipe";
import { MultiPurposeLabelSummary } from "~/types/api-types/labels";
import { useLocales } from "~/composables/use-locales";
import { useUnitStore } from "~/composables/store";
export default defineComponent({
setup() {
@@ -146,10 +159,15 @@ export default defineComponent({
value: "abbreviation",
show: true,
},
{
text: "Use Abbv.",
value: "useAbbreviation",
show: true,
},
{
text: "Description",
value: "description",
show: true,
show: false,
},
{
text: "Fraction",
@@ -157,47 +175,39 @@ export default defineComponent({
show: true,
},
];
const units = ref<IngredientUnit[]>([]);
async function refreshUnits() {
const { data } = await userApi.units.getAll();
units.value = data ?? [];
}
onMounted(() => {
refreshUnits();
});
const { units, actions: unitActions } = useUnitStore();
// Edit Units
const editDialog = ref(false);
const editTarget = ref<IngredientUnit | null>(null);
function editEventHandler(item: IngredientUnit) {
editTarget.value = item;
editDialog.value = true;
}
async function editSaveUnit() {
if (!editTarget.value) {
return;
}
const { data } = await userApi.units.updateOne(editTarget.value.id, editTarget.value);
if (data) {
refreshUnits();
}
await unitActions.updateOne(editTarget.value);
editDialog.value = false;
}
// Delete Units
const deleteDialog = ref(false);
const deleteTarget = ref<IngredientUnit | null>(null);
function deleteEventHandler(item: IngredientUnit) {
deleteTarget.value = item;
deleteDialog.value = true;
}
async function deleteUnit() {
if (!deleteTarget.value) {
return;
}
const { data } = await userApi.units.deleteOne(deleteTarget.value.id);
if (data) {
refreshUnits();
}
await unitActions.deleteOne(deleteTarget.value.id);
deleteDialog.value = false;
}
@@ -220,22 +230,10 @@ export default defineComponent({
const { data } = await userApi.units.merge(fromUnit.value.id, toUnit.value.id);
if (data) {
refreshUnits();
unitActions.refresh();
}
}
// ============================================================
// Labels
const allLabels = ref([] as MultiPurposeLabelSummary[]);
async function refreshLabels() {
const { data } = await userApi.multiPurposeLabels.getAll();
allLabels.value = data ?? [];
}
refreshLabels();
// ============================================================
// Seed
@@ -256,7 +254,7 @@ export default defineComponent({
const { data } = await userApi.seeders.units({ locale: locale.value });
if (data) {
refreshUnits();
unitActions.refresh();
}
}
@@ -264,7 +262,6 @@ export default defineComponent({
tableConfig,
tableHeaders,
units,
allLabels,
validators,
// Edit
editDialog,

View File

@@ -239,7 +239,7 @@
hide-details
class="pt-0 my-auto py-auto"
color="secondary"
@change="updateTool(recipe.tools[index])"
@change="toolStore.actions.updateOne(recipe.tools[index])"
>
</v-checkbox>
<v-list-item-content>
@@ -256,12 +256,12 @@
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeCategoryTagSelector
<RecipeOrganizerSelector
v-if="form"
v-model="recipe.recipeCategory"
:return-object="true"
:show-add="true"
:show-label="false"
selector-type="categories"
/>
<RecipeChips v-else :items="recipe.recipeCategory" />
</v-card-text>
@@ -274,13 +274,12 @@
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeCategoryTagSelector
<RecipeOrganizerSelector
v-if="form"
v-model="recipe.tags"
:return-object="true"
:show-add="true"
:tag-selector="true"
:show-label="false"
selector-type="tags"
/>
<RecipeChips v-else :items="recipe.tags" url-prefix="tags" />
</v-card-text>
@@ -291,7 +290,7 @@
<v-card-title class="py-2"> Required Tools </v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text class="pt-0">
<RecipeTools v-model="recipe.tools" :edit="form" />
<RecipeOrganizerSelector v-model="recipe.tools" selector-type="tools" />
</v-card-text>
</v-card>
@@ -320,6 +319,9 @@
:ingredients="recipe.recipeIngredient"
:disable-amount="recipe.settings.disableAmount"
:edit="form"
:recipe-id="recipe.id"
:recipe-slug="recipe.slug"
:assets.sync="recipe.assets"
/>
<div v-if="form" class="d-flex">
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
@@ -344,12 +346,12 @@
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeCategoryTagSelector
<RecipeOrganizerSelector
v-if="form"
v-model="recipe.recipeCategory"
:return-object="true"
:show-add="true"
:show-label="false"
selector-type="categories"
/>
<RecipeChips v-else :items="recipe.recipeCategory" />
</v-card-text>
@@ -362,14 +364,14 @@
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeCategoryTagSelector
<RecipeOrganizerSelector
v-if="form"
v-model="recipe.tags"
:return-object="true"
:show-add="true"
:tag-selector="true"
:show-label="false"
selector-type="tags"
/>
<RecipeChips v-else :items="recipe.tags" url-prefix="tags" />
</v-card-text>
</v-card>
@@ -484,7 +486,7 @@ import VueMarkdown from "@adapttive/vue-markdown";
import draggable from "vuedraggable";
import { invoke, until, useWakeLock } from "@vueuse/core";
import { onUnmounted } from "vue-demi";
import RecipeCategoryTagSelector from "@/components/Domain/Recipe/RecipeCategoryTagSelector.vue";
import RecipeOrganizerSelector from "@/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import RecipeDialogBulkAdd from "@/components/Domain/Recipe//RecipeDialogBulkAdd.vue";
import { useUserApi, useStaticRoutes } from "~/composables/api";
import { validators } from "~/composables/use-validators";
@@ -503,9 +505,10 @@ import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientE
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
import RecipeTools from "~/components/Domain/Recipe/RecipeTools.vue";
import RecipeComments from "~/components/Domain/Recipe/RecipeComments.vue";
import { Recipe, RecipeTool } from "~/types/api-types/recipe";
import { Recipe } from "~/types/api-types/recipe";
import { uuid4, deepCopy } from "~/composables/use-utils";
import { useRouteQuery } from "~/composables/use-router";
import { useToolStore } from "~/composables/store";
export default defineComponent({
components: {
@@ -516,7 +519,7 @@ export default defineComponent({
return import(/* webpackChunkName: "RecipeAssets" */ "~/components/Domain/Recipe/RecipeAssets.vue");
}
},
RecipeCategoryTagSelector,
RecipeOrganizerSelector,
RecipeChips,
RecipeComments,
RecipeDialogBulkAdd,
@@ -758,18 +761,7 @@ export default defineComponent({
// ===============================================================
// Recipe Tools
async function updateTool(tool: RecipeTool) {
if (tool.id === undefined) return;
const { response } = await api.tools.updateOne(tool.id, tool);
if (response?.status === 200) {
console.log("Update Successful");
}
}
// ===============================================================
// Recipe API Extras
const toolStore = useToolStore();
const apiNewKey = ref("");
@@ -864,13 +856,13 @@ export default defineComponent({
deleteRecipe,
printRecipe,
closeEditor,
updateTool,
updateRecipe,
uploadImage,
validators,
recipeImage,
addIngredient,
removeApiExtra,
toolStore,
};
},
head: {},

View File

@@ -6,7 +6,7 @@
<div>
Mealie can use natural language processing to attempt to parse and create units, and foods for your Recipe
ingredients. This is experimental and may not work as expected. If you choose to not use the parsed results
you can seleect cancel and your changes will not be saved.
you can select cancel and your changes will not be saved.
</div>
</v-alert>
@@ -14,7 +14,7 @@
To use the ingredient parser, click the "Parse All" button and the process will start. When the processed
ingredients are available, you can look through the items and verify that they were parsed correctly. The models
confidence score is displayed on the right of the title item. This is an average of all scores and may not be
wholey accurate.
wholely accurate.
<div class="my-4">
Alerts will be displayed if a matching foods or unit is found but does not exists in the database.
@@ -84,11 +84,18 @@
import { defineComponent, ref, useRoute, useRouter } from "@nuxtjs/composition-api";
import { invoke, until } from "@vueuse/core";
import { Parser } from "~/api/class-interfaces/recipes/recipe";
import { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, ParsedIngredient } from "~/types/api-types/recipe";
import {
CreateIngredientFood,
CreateIngredientUnit,
IngredientFood,
IngredientUnit,
ParsedIngredient,
} from "~/types/api-types/recipe";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import { useUserApi } from "~/composables/api";
import { useFoods, useRecipe, useUnits } from "~/composables/recipes";
import { useRecipe } from "~/composables/recipes";
import { RecipeIngredient } from "~/types/api-types/admin";
import { useFoodData, useFoodStore, useUnitStore } from "~/composables/store";
interface Error {
ingredientIndex: number;
@@ -182,8 +189,9 @@ export default defineComponent({
// =========================================================
// Food and Ingredient Logic
const { foods, workingFoodData, actions } = useFoods();
const { units } = useUnits();
const foodStore = useFoodStore();
const foodData = useFoodData();
const { units } = useUnitStore();
const errors = ref<Error[]>([]);
@@ -201,16 +209,17 @@ export default defineComponent({
if (!food) {
return false;
}
if (foods.value && food?.name) {
return foods.value.some((f) => f.name === food.name);
if (foodStore.foods.value && food?.name) {
return foodStore.foods.value.some((f) => f.name === food.name);
}
return false;
}
async function createFood(food: CreateIngredientFood, index: number) {
workingFoodData.name = food.name;
await actions.createOne();
foodData.data.name = food.name;
await foodStore.actions.createOne(foodData.data);
errors.value[index].foodError = false;
foodData.reset();
}
// =========================================================
@@ -219,16 +228,16 @@ export default defineComponent({
let ingredients = parsedIng.value.map((ing) => {
return {
...ing.ingredient,
originalText: ing.input
originalText: ing.input,
} as RecipeIngredient;
});
ingredients = ingredients.map((ing) => {
if (!foods.value || !units.value) {
if (!foodStore.foods.value || !units.value) {
return ing;
}
// Get food from foods
ing.food = foods.value.find((f) => f.name === ing.food?.name);
ing.food = foodStore.foods.value.find((f) => f.name === ing.food?.name);
// Get unit from units
ing.unit = units.value.find((u) => u.name === ing.unit?.name);
@@ -252,8 +261,8 @@ export default defineComponent({
saveAll,
createFood,
errors,
actions,
workingFoodData,
actions: foodStore.actions,
workingFoodData: foodData,
isError,
panels,
asPercentage,

View File

@@ -1,6 +1,6 @@
<template>
<div>
<div flat>
<div>
<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
@@ -38,8 +38,7 @@
<v-col cols="12" xs="12" sm="6">
<RecipeOrganizerSelector
v-model="bulkUrls[idx].categories"
:items="allCategories || []"
selector-type="category"
selector-type="categories"
:input-attrs="{
filled: true,
singleLine: true,
@@ -54,8 +53,7 @@
<v-col cols="12" xs="12" sm="6">
<RecipeOrganizerSelector
v-model="bulkUrls[idx].tags"
:items="allTags || []"
selector-type="tag"
selector-type="tags"
:input-attrs="{
filled: true,
singleLine: true,
@@ -109,7 +107,6 @@ 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";
@@ -152,12 +149,6 @@ export default defineComponent({
fetchReports();
}
const { allTags, useAsyncGetAll: getAllTags } = useTags();
const { allCategories, useAsyncGetAll: getAllCategories } = useCategories();
getAllTags();
getAllCategories();
// =========================================================
// Reports
@@ -189,8 +180,6 @@ export default defineComponent({
assignUrls,
reports,
deleteReport,
allTags,
allCategories,
bulkCreate,
bulkUrls,
lockBulkImport,

View File

@@ -1,44 +1,36 @@
<template>
<v-container>
<RecipeCategoryTagToolPage v-if="categories" :items="categories" item-type="categories" @delete="removeCat" />
<RecipeOrganizerPage
v-if="items"
:items="items"
:icon="$globals.icons.tags"
item-type="categories"
@delete="actions.deleteOne"
>
<template #title> {{ $tc("category.categories") }} </template>
</RecipeOrganizerPage>
</v-container>
</template>
<script lang="ts">
import { defineComponent, useAsync } from "@nuxtjs/composition-api";
import RecipeCategoryTagToolPage from "~/components/Domain/Recipe/RecipeCategoryTagToolPage.vue";
import { useUserApi } from "~/composables/api";
import { useAsyncKey } from "~/composables/use-utils";
import { defineComponent } from "@nuxtjs/composition-api";
import RecipeOrganizerPage from "~/components/Domain/Recipe/RecipeOrganizerPage.vue";
import { useCategoryStore } from "~/composables/store";
export default defineComponent({
components: {
RecipeCategoryTagToolPage,
RecipeOrganizerPage,
},
setup() {
const userApi = useUserApi();
const categories = useAsync(async () => {
const { data } = await userApi.categories.getAll();
if (data) {
return data;
}
}, useAsyncKey());
function removeCat(id: string) {
if (categories.value) {
for (let i = 0; i < categories.value.length; i++) {
if (categories.value[i].id === id) {
categories.value.splice(i, 1);
break;
}
}
}
}
const { items, actions } = useCategoryStore();
return {
categories,
removeCat,
items,
actions,
};
},
head: {
title: "Tags",
},
});
</script>

View File

@@ -1,44 +1,36 @@
<template>
<v-container>
<RecipeCategoryTagToolPage v-if="tools" :items="tools" item-type="tags" @delete="removeTag" />
<RecipeOrganizerPage
v-if="items"
:items="items"
:icon="$globals.icons.tags"
item-type="tags"
@delete="actions.deleteOne"
>
<template #title> Tags </template>
</RecipeOrganizerPage>
</v-container>
</template>
<script lang="ts">
import { defineComponent, useAsync } from "@nuxtjs/composition-api";
import RecipeCategoryTagToolPage from "~/components/Domain/Recipe/RecipeCategoryTagToolPage.vue";
import { useUserApi } from "~/composables/api";
import { useAsyncKey } from "~/composables/use-utils";
import { defineComponent } from "@nuxtjs/composition-api";
import RecipeOrganizerPage from "~/components/Domain/Recipe/RecipeOrganizerPage.vue";
import { useTagStore } from "~/composables/store";
export default defineComponent({
components: {
RecipeCategoryTagToolPage,
RecipeOrganizerPage,
},
setup() {
const userApi = useUserApi();
const tools = useAsync(async () => {
const { data } = await userApi.tags.getAll();
if (data) {
return data;
}
}, useAsyncKey());
function removeTag(id: string) {
if (tools.value) {
for (let i = 0; i < tools.value.length; i++) {
if (tools.value[i].id === id) {
tools.value.splice(i, 1);
break;
}
}
}
}
const { items, actions } = useTagStore();
return {
tools,
removeTag,
items,
actions,
};
},
head: {
title: "Tags",
},
});
</script>

View File

@@ -1,44 +1,38 @@
<template>
<v-container>
<RecipeCategoryTagToolPage v-if="tools" :items="tools" item-type="tools" @delete="removeTool" />
<RecipeOrganizerPage
v-if="tools"
:icon="$globals.icons.potSteam"
:items="tools"
item-type="tools"
@delete="actions.deleteOne"
>
<template #title> Tools </template>
</RecipeOrganizerPage>
</v-container>
</template>
<script lang="ts">
import { defineComponent, useAsync } from "@nuxtjs/composition-api";
import RecipeCategoryTagToolPage from "~/components/Domain/Recipe/RecipeCategoryTagToolPage.vue";
import { useUserApi } from "~/composables/api";
import { useAsyncKey } from "~/composables/use-utils";
import { defineComponent, ref } from "@nuxtjs/composition-api";
import RecipeOrganizerPage from "~/components/Domain/Recipe/RecipeOrganizerPage.vue";
import { useToolStore } from "~/composables/store";
export default defineComponent({
components: {
RecipeCategoryTagToolPage,
RecipeOrganizerPage,
},
setup() {
const userApi = useUserApi();
const tools = useAsync(async () => {
const { data } = await userApi.tools.getAll();
if (data) {
return data;
}
}, useAsyncKey());
function removeTool(id: string) {
if (tools.value) {
for (let i = 0; i < tools.value.length; i++) {
if (tools.value[i].id === id) {
tools.value.splice(i, 1);
break;
}
}
}
}
const toolStore = useToolStore();
const dialog = ref(false);
return {
tools,
removeTool,
dialog,
tools: toolStore.items,
actions: toolStore.actions,
};
},
head: {
title: "Tools",
},
});
</script>

View File

@@ -35,23 +35,30 @@
<v-expand-transition>
<v-row v-show="state" dense class="my-0 dense flex-row align-center justify-space-around">
<v-col cols="12" class="d-flex flex-wrap flex-md-nowrap justify-center" style="gap: 0.8rem">
<RecipeCategoryTagSelector
<RecipeOrganizerSelector
v-model="includeCategories"
hide-details
:solo="true"
:dense="false"
:input-attrs="{
solo: true,
hideDetails: true,
dense: false,
}"
:show-add="false"
:return-object="false"
selector-type="categories"
/>
<RecipeSearchFilterSelector class="mb-1" @update="updateCatParams" />
</v-col>
<v-col cols="12" class="d-flex flex-wrap flex-md-nowrap justify-center" style="gap: 0.8rem">
<RecipeCategoryTagSelector
<RecipeOrganizerSelector
v-model="includeTags"
hide-details
:solo="true"
:dense="false"
:input-attrs="{
solo: true,
hideDetails: true,
dense: false,
}"
:show-add="false"
:return-object="false"
:tag-selector="true"
selector-type="tags"
/>
<RecipeSearchFilterSelector class="mb-1" @update="updateTagParams" />
</v-col>
@@ -106,12 +113,13 @@
import Fuse from "fuse.js";
import { defineComponent, toRefs, computed, reactive } from "@nuxtjs/composition-api";
import RecipeSearchFilterSelector from "~/components/Domain/Recipe/RecipeSearchFilterSelector.vue";
import RecipeCategoryTagSelector from "~/components/Domain/Recipe/RecipeCategoryTagSelector.vue";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useRecipes, allRecipes, useFoods } from "~/composables/recipes";
import { useRecipes, allRecipes } from "~/composables/recipes";
import { RecipeSummary } from "~/types/api-types/recipe";
import { useRouteQuery } from "~/composables/use-router";
import { RecipeTag } from "~/types/api-types/user";
import { useFoodStore } from "~/composables/store";
interface GenericFilter {
exclude: boolean;
@@ -120,7 +128,7 @@ interface GenericFilter {
export default defineComponent({
components: {
RecipeCategoryTagSelector,
RecipeOrganizerSelector,
RecipeSearchFilterSelector,
RecipeCardSection,
},
@@ -259,7 +267,7 @@ export default defineComponent({
state.foodFilter = params;
}
const { foods } = useFoods();
const { foods } = useFoodStore();
return {
...toRefs(state),

View File

@@ -34,7 +34,7 @@
:key="imageKey"
:max-width="enableLandscape ? null : '50%'"
:height="hideImage ? '50' : imageHeight"
:src="recipeImage(recipe.slug, imageKey)"
:src="recipeImage(recipe.id, imageKey)"
class="d-print-none"
@error="hideImage = true"
>
@@ -197,6 +197,9 @@
:disable-amount="recipe.settings.disableAmount"
:edit="form"
public
:assets="recipe.assets"
:recipe-id="recipe.id"
:recipe-slug="recipe.slug"
/>
<!-- TODO: Somehow fix duplicate code for mobile/desktop -->
@@ -323,7 +326,6 @@ export default defineComponent({
const { data } = await api.recipes.getShared(id);
if (data) {
if (data && data !== undefined) {
console.log("Computed Meta. RefKey=");
const imageURL = data.id ? recipeImage(data.id) : undefined;
title.value = data.name;

View File

@@ -187,7 +187,7 @@
<script lang="ts">
import draggable from "vuedraggable";
import { defineComponent, useAsync, useRoute, computed, ref } from "@nuxtjs/composition-api";
import { defineComponent, useAsync, useRoute, computed, ref, watch } from "@nuxtjs/composition-api";
import { useToggle } from "@vueuse/core";
import { useCopyList } from "~/composables/use-copy";
import { useUserApi } from "~/composables/api";
@@ -367,11 +367,13 @@ export default defineComponent({
return labels;
});
const itemsByLabel = computed(() => {
const items: { [prop: string]: ShoppingListItemCreate[] } = {};
const itemsByLabel = ref<{ [key: string]: ShoppingListItemOut[] }>({});
function updateItemsByLabel() {
const items: { [prop: string]: ShoppingListItemOut[] } = {};
const noLabel = {
"No Label": [] as ShoppingListItemCreate[],
"No Label": [] as ShoppingListItemOut[],
};
shoppingList.value?.listItems?.forEach((item) => {
@@ -394,7 +396,11 @@ export default defineComponent({
items["No Label"] = noLabel["No Label"];
}
return items;
itemsByLabel.value = items;
}
watch(shoppingList, () => {
updateItemsByLabel();
});
async function refreshLabels() {

View File

@@ -133,6 +133,7 @@ export interface IngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
}
export interface CreateIngredientUnit {
@@ -140,6 +141,7 @@ export interface CreateIngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
}
export interface IngredientFood {
name: string;

View File

@@ -112,6 +112,7 @@ export interface IngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
}
export interface CreateIngredientUnit {
@@ -119,6 +120,7 @@ export interface CreateIngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
}
export interface IngredientFood {
name: string;

View File

@@ -207,6 +207,7 @@ export interface IngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
}
export interface ReadGroupPreferences {
@@ -287,6 +288,7 @@ export interface CreateIngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
}
export interface CreateIngredientFood {
name: string;

View File

@@ -148,6 +148,7 @@ export interface IngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
}
export interface CreateIngredientUnit {
@@ -155,6 +156,7 @@ export interface CreateIngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
}
export interface IngredientFood {
name: string;

View File

@@ -49,6 +49,7 @@ export interface CreateIngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
}
export interface CreateRecipe {
name: string;
@@ -117,6 +118,7 @@ export interface IngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
}
export interface IngredientsRequest {
@@ -340,6 +342,7 @@ export interface SaveIngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
groupId: string;
}
export interface ScrapeRecipe {

View File

@@ -164,6 +164,7 @@ export interface IngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
}
export interface CreateIngredientUnit {
@@ -171,6 +172,7 @@ export interface CreateIngredientUnit {
description?: string;
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
}
export interface IngredientFood {
name: string;

View File

@@ -0,0 +1,7 @@
export type RecipeOrganizer = "categories" | "tags" | "tools";
export enum Organizer {
Category = "categories",
Tag = "tags",
Tool = "tools",
}

View File

@@ -120,10 +120,10 @@
json5 "^2.2.1"
semver "^6.3.0"
"@babel/eslint-parser@^7.14.7":
version "7.15.4"
resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.15.4.tgz#46385943726291fb3e8db99522c8099b15684387"
integrity sha512-hPMIAmGNbmQzXJIo2P43Zj9UhRmGev5f9nqdBFOWNGDGh6XKmjby79woBvg6y0Jur6yRfQBneDbUQ8ZVc1krFw==
"@babel/eslint-parser@^7.18.2":
version "7.18.2"
resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.18.2.tgz#e14dee36c010edfb0153cf900c2b0815e82e3245"
integrity sha512-oFQYkE8SuH14+uR51JVAmdqwKYXGRjEXx7s+WiagVjqQ+HPE+nnwyF2qlVG8evUsUHmPcA+6YXMEDbIhEyQc5A==
dependencies:
eslint-scope "^5.1.1"
eslint-visitor-keys "^2.1.0"
@@ -2206,10 +2206,10 @@
"@types/mime" "^1"
"@types/node" "*"
"@types/sortablejs@^1.10.7":
version "1.10.7"
resolved "https://registry.yarnpkg.com/@types/sortablejs/-/sortablejs-1.10.7.tgz#ab9039c85429f0516955ec6dbc0bb20139417b15"
integrity sha512-lGCwwgpj8zW/ZmaueoPVSP7nnc9t8VqVWXS+ASX3eoUUENmiazv0rlXyTRludXzuX9ALjPsMqBu85TgJNWbTOg==
"@types/sortablejs@^1.13.0":
version "1.13.0"
resolved "https://registry.yarnpkg.com/@types/sortablejs/-/sortablejs-1.13.0.tgz#870223438f8f2cd81157b128a4c0261adbcaa946"
integrity sha512-C3064MH72iEfeGCYEGCt7FCxXoAXaMPG0QPnstcxvPmbl54erpISu06d++FY37Smja64iWy5L8wOyHHBghWbJQ==
"@types/source-list-map@*":
version "0.1.2"
@@ -2519,14 +2519,7 @@
optionalDependencies:
prettier "^1.18.2 || ^2.0.0"
"@vue/composition-api@^1.0.5":
version "1.4.3"
resolved "https://registry.yarnpkg.com/@vue/composition-api/-/composition-api-1.4.3.tgz#c80eb8c692e16ebfcdab4af5344a6e3ff8ccc38e"
integrity sha512-Qp4rMbESO05/7/Imck027X5lPhbmMX/mtYSDvIMJ14PS4KHY/4GllnQbPEfsBEe1LECFE6HWx2k7HYgcuYNvpg==
dependencies:
tslib "^2.3.1"
"@vue/composition-api@^1.4.6":
"@vue/composition-api@^1.4.6", "@vue/composition-api@^1.6.2":
version "1.6.2"
resolved "https://registry.yarnpkg.com/@vue/composition-api/-/composition-api-1.6.2.tgz#8e65cf60cd919f23649951ed14a67d43cd32131d"
integrity sha512-g6pCOaAB5Z987EwCBIDanRlramrgnWfrrYS8F/0axn2AL8EvyfAebDcLTSr48hac0G0rqC9ODp5LWaiupzl6Tw==
@@ -9794,20 +9787,13 @@ semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
semver@^7.0.0, semver@^7.3.6, semver@^7.3.7:
semver@^7.0.0, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.6, semver@^7.3.7:
version "7.3.7"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
dependencies:
lru-cache "^6.0.0"
semver@^7.3.2, semver@^7.3.4, semver@^7.3.5:
version "7.3.5"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
dependencies:
lru-cache "^6.0.0"
send@0.17.2:
version "0.17.2"
resolved "https://registry.yarnpkg.com/send/-/send-0.17.2.tgz#926622f76601c41808012c8bf1688fe3906f7820"

View File

@@ -79,15 +79,21 @@ def authenticate_user(session, email: str, password: str) -> PrivateUser | bool:
settings = get_app_settings()
db = get_repositories(session)
user: PrivateUser = db.users.get(email, "email", any_case=True)
user = db.users.get_one(email, "email", any_case=True)
if not user:
user = db.users.get(email, "username", any_case=True)
user = db.users.get_one(email, "username", any_case=True)
if settings.LDAP_AUTH_ENABLED and (not user or user.password == "LDAP"):
return user_from_ldap(db, session, email, password)
if not user or not verify_password(password, user.password):
if not user:
# To prevent user enumeration we perform the verify_password computation to ensure
# server side time is relatively constant and not vulnerable to timing attacks.
verify_password("abc123cba321", "$2b$12$JdHtJOlkPFwyxdjdygEzPOtYmdQF5/R5tHxw5Tq8pxjubyLqdIX5i")
return False
elif not verify_password(password, user.password):
return False
return user

View File

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

View File

@@ -18,7 +18,8 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
name = Column(String)
description = Column(String)
abbreviation = Column(String)
fraction = Column(Boolean)
use_abbreviation = Column(Boolean, default=False)
fraction = Column(Boolean, default=True)
ingredients = orm.relationship("RecipeIngredient", back_populates="unit")
@auto_init()

View File

@@ -13,10 +13,10 @@
"email-conflict-error": "Ця електронна адреса вже використовується"
},
"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} створено",
"generic-updated": "{name} оновлено",
"generic-created-with-url": "{name} створено, {url}",
"generic-updated-with-url": "{name} оновлено, {url}",
"generic-deleted": "{name} створено"
}
}

View File

@@ -10,11 +10,11 @@ class RepositoryFood(RepositoryGeneric[IngredientFood, IngredientFoodModel]):
def merge(self, from_food: UUID4, to_food: UUID4) -> IngredientFood | None:
from_model: IngredientFoodModel = (
self.session.query(self.sql_model).filter_by(**self._filter_builder(**{"id": from_food})).one()
self.session.query(self.model).filter_by(**self._filter_builder(**{"id": from_food})).one()
)
to_model: IngredientFoodModel = (
self.session.query(self.sql_model).filter_by(**self._filter_builder(**{"id": to_food})).one()
self.session.query(self.model).filter_by(**self._filter_builder(**{"id": to_food})).one()
)
to_model.ingredients += from_model.ingredients

View File

@@ -1,90 +1,75 @@
from collections.abc import Callable
from typing import Any, Generic, TypeVar, Union
from pydantic import UUID4, BaseModel
from sqlalchemy import func
from sqlalchemy.orm import load_only
from sqlalchemy.orm.session import Session
T = TypeVar("T", bound=BaseModel)
D = TypeVar("D")
from mealie.core.root_logger import get_logger
from mealie.schema.response.pagination import OrderDirection, PaginationBase, PaginationQuery
Schema = TypeVar("Schema", bound=BaseModel)
Model = TypeVar("Model")
class RepositoryGeneric(Generic[T, D]):
class RepositoryGeneric(Generic[Schema, Model]):
"""A Generic BaseAccess Model method to perform common operations on the database
Args:
Generic ([T]): Represents the Pydantic Model
Generic ([D]): Represents the SqlAlchemyModel Model
Generic ([Schema]): Represents the Pydantic Model
Generic ([Model]): Represents the SqlAlchemyModel Model
"""
def __init__(self, session: Session, primary_key: str, sql_model: type[D], schema: type[T]) -> None:
user_id: UUID4 = None
group_id: UUID4 = None
def __init__(self, session: Session, primary_key: str, sql_model: type[Model], schema: type[Schema]) -> None:
self.session = session
self.primary_key = primary_key
self.sql_model = sql_model
self.model = sql_model
self.schema = schema
self.observers: list = []
self.limit_by_group = False
self.user_id: UUID4 = None
self.logger = get_logger()
self.limit_by_user = False
self.group_id: UUID4 = None
def subscribe(self, func: Callable) -> None:
self.observers.append(func)
def by_user(self, user_id: UUID4) -> "RepositoryGeneric[T, D]":
self.limit_by_user = True
def by_user(self, user_id: UUID4) -> "RepositoryGeneric[Schema, Model]":
self.user_id = user_id
return self
def by_group(self, group_id: UUID4) -> "RepositoryGeneric[T, D]":
self.limit_by_group = True
def by_group(self, group_id: UUID4) -> "RepositoryGeneric[Schema, Model]":
self.group_id = group_id
return self
def _log_exception(self, e: Exception) -> None:
self.logger.error(f"Error processing query for Repo model={self.model.__name__} schema={self.schema.__name__}")
self.logger.error(e)
def _query(self):
return self.session.query(self.model)
def _filter_builder(self, **kwargs) -> dict[str, Any]:
dct = {}
if self.limit_by_user:
if self.user_id:
dct["user_id"] = self.user_id
if self.limit_by_group:
if self.group_id:
dct["group_id"] = self.group_id
return {**dct, **kwargs}
# TODO: Run Observer in Async Background Task
def update_observers(self) -> None:
if self.observers:
for observer in self.observers:
observer()
def get_all(self, limit: int = None, order_by: str = None, start=0, override=None) -> list[Schema]:
# sourcery skip: remove-unnecessary-cast
eff_schema = override or self.schema
def get_all(self, limit: int = None, order_by: str = None, start=0, override_schema=None) -> list[T]:
eff_schema = override_schema or self.schema
fltr = self._filter_builder()
filter = self._filter_builder()
q = self._query().filter_by(**fltr)
order_attr = None
if order_by:
order_attr = getattr(self.sql_model, str(order_by))
order_attr = order_attr.desc()
if order_attr := getattr(self.model, str(order_by)):
order_attr = order_attr.desc()
q = q.order_by(order_attr)
return [
eff_schema.from_orm(x)
for x in self.session.query(self.sql_model)
.order_by(order_attr)
.filter_by(**filter)
.offset(start)
.limit(limit)
.all()
]
return [
eff_schema.from_orm(x)
for x in self.session.query(self.sql_model).filter_by(**filter).offset(start).limit(limit).all()
]
return [eff_schema.from_orm(x) for x in q.offset(start).limit(limit).all()]
def multi_query(
self,
@@ -93,55 +78,21 @@ class RepositoryGeneric(Generic[T, D]):
limit: int = None,
override_schema=None,
order_by: str = None,
) -> list[T]:
) -> list[Schema]:
# sourcery skip: remove-unnecessary-cast
eff_schema = override_schema or self.schema
filer = self._filter_builder(**query_by)
fltr = self._filter_builder(**query_by)
q = self._query().filter_by(**fltr)
order_attr = None
if order_by:
order_attr = getattr(self.sql_model, str(order_by))
order_attr = order_attr.desc()
if order_attr := getattr(self.model, str(order_by)):
order_attr = order_attr.desc()
q = q.order_by(order_attr)
return [
eff_schema.from_orm(x)
for x in self.session.query(self.sql_model)
.filter_by(**filer)
.order_by(order_attr)
.offset(start)
.limit(limit)
.all()
]
return [eff_schema.from_orm(x) for x in q.offset(start).limit(limit).all()]
def get_all_limit_columns(self, fields: list[str], limit: int = None) -> list[D]:
"""Queries the database for the selected model. Restricts return responses to the
keys specified under "fields"
Args:
session (Session): Database Session Object
fields (list[str]): list of column names to query
limit (int): A limit of values to return
Returns:
list[SqlAlchemyBase]: Returns a list of ORM objects
"""
return self.session.query(self.sql_model).options(load_only(*fields)).limit(limit).all()
def get_all_primary_keys(self) -> list[str]:
"""Queries the database of the selected model and returns a list
of all primary_key values
Args:
session (Session): Database Session object
Returns:
list[str]:
"""
results = self.session.query(self.sql_model).options(load_only(str(self.primary_key)))
results_as_dict = [x.dict() for x in results]
return [x.get(self.primary_key) for x in results_as_dict]
def _query_one(self, match_value: str | int | UUID4, match_key: str = None) -> D:
def _query_one(self, match_value: str | int | UUID4, match_key: str = None) -> Model:
"""
Query the sql database for one item an return the sql alchemy model
object. If no match key is provided the primary_key attribute will be used.
@@ -150,18 +101,18 @@ class RepositoryGeneric(Generic[T, D]):
match_key = self.primary_key
fltr = self._filter_builder(**{match_key: match_value})
return self.session.query(self.sql_model).filter_by(**fltr).one()
return self._query().filter_by(**fltr).one()
def get_one(self, value: str | int | UUID4, key: str = None, any_case=False, override_schema=None) -> T | None:
def get_one(self, value: str | int | UUID4, key: str = None, any_case=False, override_schema=None) -> Schema | None:
key = key or self.primary_key
q = self.session.query(self.sql_model)
q = self.session.query(self.model)
if any_case:
search_attr = getattr(self.sql_model, key)
q = q.filter(func.lower(search_attr) == str(value).lower()).filter_by(**self._filter_builder())
search_attr = getattr(self.model, key)
q = q.where(func.lower(search_attr) == str(value).lower()).filter_by(**self._filter_builder())
else:
q = self.session.query(self.sql_model).filter_by(**self._filter_builder(**{key: value}))
q = q.filter_by(**self._filter_builder(**{key: value}))
result = q.one_or_none()
@@ -173,32 +124,20 @@ class RepositoryGeneric(Generic[T, D]):
def get(
self, match_value: str | int | UUID4, match_key: str = None, limit=1, any_case=False, override_schema=None
) -> T | list[T] | None:
"""Retrieves an entry from the database by matching a key/value pair. If no
key is provided the class objects primary key will be used to match against.
Args:
match_value (str): A value used to match against the key/value in the database
match_key (str, optional): They key to match the value against. Defaults to None.
limit (int, optional): A limit to returned responses. Defaults to 1.
Returns:
dict or list[dict]:
"""
) -> Schema | list[Schema] | None:
self.logger.info("DEPRECATED: use get_one or get_all instead")
match_key = match_key or self.primary_key
if any_case:
search_attr = getattr(self.sql_model, match_key)
search_attr = getattr(self.model, match_key)
result = (
self.session.query(self.sql_model)
self.session.query(self.model)
.filter(func.lower(search_attr) == match_value.lower()) # type: ignore
.limit(limit)
.all()
)
else:
result = self.session.query(self.sql_model).filter_by(**{match_key: match_value}).limit(limit).all()
result = self.session.query(self.model).filter_by(**{match_key: match_value}).limit(limit).all()
eff_schema = override_schema or self.schema
@@ -210,28 +149,29 @@ class RepositoryGeneric(Generic[T, D]):
return [eff_schema.from_orm(x) for x in result]
def create(self, document: T | BaseModel | dict) -> T:
"""Creates a new database entry for the given SQL Alchemy Model.
Args:
session (Session): A Database Session
document (dict): A python dictionary representing the data structure
Returns:
dict: A dictionary representation of the database entry
"""
document = document if isinstance(document, dict) else document.dict()
new_document = self.sql_model(session=self.session, **document) # type: ignore
def create(self, data: Schema | BaseModel | dict) -> Schema:
data = data if isinstance(data, dict) else data.dict()
new_document = self.model(session=self.session, **data) # type: ignore
self.session.add(new_document)
self.session.commit()
self.session.refresh(new_document)
if self.observers:
self.update_observers()
return self.schema.from_orm(new_document)
def update(self, match_value: str | int | UUID4, new_data: dict | BaseModel) -> T:
def create_many(self, data: list[Schema | dict]) -> list[Schema]:
new_documents = []
for document in data:
document = document if isinstance(document, dict) else document.dict()
new_document = self.model(session=self.session, **document) # type: ignore
new_documents.append(new_document)
self.session.add_all(new_documents)
self.session.commit()
self.session.refresh(new_documents)
return [self.schema.from_orm(x) for x in new_documents]
def update(self, match_value: str | int | UUID4, new_data: dict | BaseModel) -> Schema:
"""Update a database entry.
Args:
session (Session): Database Session
@@ -246,30 +186,23 @@ class RepositoryGeneric(Generic[T, D]):
entry = self._query_one(match_value=match_value)
entry.update(session=self.session, **new_data) # type: ignore
if self.observers:
self.update_observers()
self.session.commit()
return self.schema.from_orm(entry)
def patch(self, match_value: str | int | UUID4, new_data: dict | BaseModel) -> T | None:
def patch(self, match_value: str | int | UUID4, new_data: dict | BaseModel) -> Schema:
new_data = new_data if isinstance(new_data, dict) else new_data.dict()
entry = self._query_one(match_value=match_value)
if not entry:
# TODO: Should raise exception
return None
entry_as_dict = self.schema.from_orm(entry).dict()
entry_as_dict.update(new_data)
return self.update(match_value, entry_as_dict)
def delete(self, value, match_key: str | None = None) -> T:
def delete(self, value, match_key: str | None = None) -> Schema:
match_key = match_key or self.primary_key
result = self.session.query(self.sql_model).filter_by(**{match_key: value}).one()
result = self._query().filter_by(**{match_key: value}).one()
results_as_model = self.schema.from_orm(result)
try:
@@ -279,23 +212,17 @@ class RepositoryGeneric(Generic[T, D]):
self.session.rollback()
raise e
if self.observers:
self.update_observers()
return results_as_model
def delete_all(self) -> None:
self.session.query(self.sql_model).delete()
self._query().delete()
self.session.commit()
if self.observers:
self.update_observers()
def count_all(self, match_key=None, match_value=None) -> int:
if None in [match_key, match_value]:
return self.session.query(self.sql_model).count()
return self._query().count()
else:
return self.session.query(self.sql_model).filter_by(**{match_key: match_value}).count()
return self._query().filter_by(**{match_key: match_value}).count()
def _count_attribute(
self,
@@ -303,27 +230,57 @@ class RepositoryGeneric(Generic[T, D]):
attr_match: str = None,
count=True,
override_schema=None,
) -> Union[int, list[T]]:
) -> Union[int, list[Schema]]: # sourcery skip: assign-if-exp
eff_schema = override_schema or self.schema
# attr_filter = getattr(self.sql_model, attribute_name)
q = self._query().filter(attribute_name == attr_match)
if count:
return self.session.query(self.sql_model).filter(attribute_name == attr_match).count() # noqa: 711
return q.count()
else:
return [
eff_schema.from_orm(x)
for x in self.session.query(self.sql_model).filter(attribute_name == attr_match).all() # noqa: 711
]
return [eff_schema.from_orm(x) for x in q.all()]
def create_many(self, documents: list[T | dict]) -> list[T]:
new_documents = []
for document in documents:
document = document if isinstance(document, dict) else document.dict()
new_document = self.sql_model(session=self.session, **document) # type: ignore
new_documents.append(new_document)
def pagination(self, pagination: PaginationQuery, override=None) -> PaginationBase[Schema]:
"""
pagination is a method to interact with the filtered database table and return a paginated result
using the PaginationBase that provides several data points that are needed to manage pagination
on the client side. This method does utilize the _filter_build method to ensure that the results
are filtered by the user and group id when applicable.
self.session.add_all(new_documents)
self.session.commit()
self.session.refresh(new_documents)
NOTE: When you provide an override you'll need to manually type the result of this method
as the override, as the type system, is not able to infer the result of this method.
"""
eff_schema = override or self.schema
return [self.schema.from_orm(x) for x in new_documents]
q = self.session.query(self.model)
fltr = self._filter_builder()
q = q.filter_by(**fltr)
count = q.count()
if pagination.order_by:
if order_attr := getattr(self.model, pagination.order_by, None):
if pagination.order_direction == OrderDirection.asc:
order_attr = order_attr.asc()
elif pagination.order_direction == OrderDirection.desc:
order_attr = order_attr.desc()
q = q.order_by(order_attr)
q = q.limit(pagination.per_page).offset((pagination.page - 1) * pagination.per_page)
try:
data = q.all()
except Exception as e:
self._log_exception(e)
self.session.rollback()
raise e
return PaginationBase(
page=pagination.page,
per_page=pagination.per_page,
total=count,
total_pages=int(count / pagination.per_page) + 1,
data=[eff_schema.from_orm(s) for s in data],
)

View File

@@ -16,7 +16,7 @@ from .repository_generic import RepositoryGeneric
class RepositoryGroup(RepositoryGeneric[GroupInDB, Group]):
def get_by_name(self, name: str, limit=1) -> Union[GroupInDB, Group, None]:
dbgroup = self.session.query(self.sql_model).filter_by(**{"name": name}).one_or_none()
dbgroup = self.session.query(self.model).filter_by(**{"name": name}).one_or_none()
if dbgroup is None:
return None
return self.schema.from_orm(dbgroup)

View File

@@ -44,11 +44,11 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
eff_schema = override_schema or self.schema
if order_by:
order_attr = getattr(self.sql_model, str(order_by))
order_attr = getattr(self.model, str(order_by))
return [
eff_schema.from_orm(x)
for x in self.session.query(self.sql_model)
for x in self.session.query(self.model)
.join(RecipeSettings)
.filter(RecipeSettings.public == True) # noqa: 711
.order_by(order_attr.desc())
@@ -59,7 +59,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
return [
eff_schema.from_orm(x)
for x in self.session.query(self.sql_model)
for x in self.session.query(self.model)
.join(RecipeSettings)
.filter(RecipeSettings.public == True) # noqa: 711
.offset(start)

View File

@@ -10,11 +10,11 @@ class RepositoryUnit(RepositoryGeneric[IngredientUnit, IngredientUnitModel]):
def merge(self, from_unit: UUID4, to_unit: UUID4) -> IngredientUnit | None:
from_model: IngredientUnitModel = (
self.session.query(self.sql_model).filter_by(**self._filter_builder(**{"id": from_unit})).one()
self.session.query(self.model).filter_by(**self._filter_builder(**{"id": from_unit})).one()
)
to_model: IngredientUnitModel = (
self.session.query(self.sql_model).filter_by(**self._filter_builder(**{"id": to_unit})).one()
self.session.query(self.model).filter_by(**self._filter_builder(**{"id": to_unit})).one()
)
to_model.ingredients += from_model.ingredients

View File

@@ -18,7 +18,7 @@ class RepositoryUsers(RepositoryGeneric[PrivateUser, User]):
return self.schema.from_orm(entry)
def create(self, user: PrivateUser | dict):
def create(self, user: PrivateUser | dict): # type: ignore
new_user = super().create(user)
# Select Random Image
@@ -42,4 +42,4 @@ class RepositoryUsers(RepositoryGeneric[PrivateUser, User]):
dbuser = self.session.query(User).filter(User.username == username).one_or_none()
if dbuser is None:
return None
return self.schema.from_orm(dbuser)
return self.schema.from_orm(dbuser) # type: ignore

View File

@@ -80,7 +80,7 @@
"eggplant": "Aubergine",
"endive": "Endivie",
"fats": "Fette",
"speck": "speck",
"speck": "Speck",
"fava-beans": "Ackerbohnen",
"fiddlehead": "Farnspitzen",
"fish": "Fisch",
@@ -88,7 +88,7 @@
"cod": "Kabeljau",
"salt-cod": "Stockfisch",
"salmon": "Lachs",
"skate": "skate",
"skate": "Rochen",
"stockfish": "Stockfisch",
"trout": "Forelle",
"tuna": "Thunfisch",
@@ -214,7 +214,7 @@
"vanilla": "Vanille",
"vegetables": "Gemüse",
"fiddlehead-fern": "Farnspitze",
"ful": "ful",
"ful": "Bohnen",
"watercress": "Brunnenkresse",
"watermelon": "Wassermelone",
"xanthan-gum": "Xanthan",

View File

@@ -41,7 +41,7 @@ class AdminUserManagementRoutes(BaseAdminController):
@router.get("", response_model=list[GroupInDB])
def get_all(self, q: GetAll = Depends(GetAll)):
return self.repo.get_all(start=q.start, limit=q.limit, override_schema=GroupInDB)
return self.repo.get_all(start=q.start, limit=q.limit, override=GroupInDB)
@router.post("", response_model=GroupInDB, status_code=status.HTTP_201_CREATED)
def create_one(self, data: GroupBase):

View File

@@ -34,7 +34,7 @@ class AdminUserManagementRoutes(BaseAdminController):
@router.get("", response_model=list[UserOut])
def get_all(self, q: GetAll = Depends(GetAll)):
return self.repo.get_all(start=q.start, limit=q.limit, override_schema=UserOut)
return self.repo.get_all(start=q.start, limit=q.limit, override=UserOut)
@router.post("", response_model=UserOut, status_code=201)
def create_one(self, data: UserIn):

View File

@@ -49,6 +49,7 @@ class MealieAuthToken(BaseModel):
@public_router.post("/token")
def get_token(data: CustomOAuth2Form = Depends(), session: Session = Depends(generate_session)):
email = data.username
password = data.password

View File

@@ -43,7 +43,7 @@ class RecipeCommentRoutes(BaseUserController):
@router.get("", response_model=list[RecipeCommentOut])
def get_all(self, q: GetAll = Depends(GetAll)):
return self.repo.get_all(start=q.start, limit=q.limit, override_schema=RecipeCommentOut)
return self.repo.get_all(start=q.start, limit=q.limit, override=RecipeCommentOut)
@router.post("", response_model=RecipeCommentOut, status_code=201)
def create_one(self, data: RecipeCommentCreate):

View File

@@ -37,7 +37,7 @@ class MultiPurposeLabelsController(BaseUserController):
@router.get("", response_model=list[MultiPurposeLabelSummary])
def get_all(self, q: GetAll = Depends(GetAll)):
return self.repo.get_all(start=q.start, limit=q.limit, override_schema=MultiPurposeLabelSummary)
return self.repo.get_all(start=q.start, limit=q.limit, override=MultiPurposeLabelSummary)
@router.post("", response_model=MultiPurposeLabelOut)
def create_one(self, data: MultiPurposeLabelCreate):

View File

@@ -24,7 +24,7 @@ class GroupMealplanConfigController(BaseUserController):
@router.get("", response_model=list[PlanRulesOut])
def get_all(self):
return self.repo.get_all(override_schema=PlanRulesOut)
return self.repo.get_all(override=PlanRulesOut)
@router.post("", response_model=PlanRulesOut, status_code=201)
def create_one(self, data: PlanRulesCreate):

View File

@@ -114,7 +114,7 @@ class ShoppingListController(BaseUserController):
@router.get("", response_model=list[ShoppingListSummary])
def get_all(self, q: GetAll = Depends(GetAll)):
return self.repo.get_all(start=q.start, limit=q.limit, override_schema=ShoppingListSummary)
return self.repo.get_all(start=q.start, limit=q.limit, override=ShoppingListSummary)
@router.post("", response_model=ShoppingListOut, status_code=201)
def create_one(self, data: ShoppingListCreate):

View File

@@ -25,7 +25,7 @@ class ReadWebhookController(BaseUserController):
@router.get("", response_model=list[ReadWebhook])
def get_all(self, q: GetAll = Depends(GetAll)):
return self.repo.get_all(start=q.start, limit=q.limit, override_schema=ReadWebhook)
return self.repo.get_all(start=q.start, limit=q.limit, override=ReadWebhook)
@router.post("", response_model=ReadWebhook, status_code=201)
def create_one(self, data: CreateWebhook):

View File

@@ -43,7 +43,7 @@ class RecipeCategoryController(BaseUserController):
@router.get("", response_model=list[RecipeCategory])
def get_all(self):
"""Returns a list of available categories in the database"""
return self.repo.get_all(override_schema=RecipeCategory)
return self.repo.get_all(override=RecipeCategory)
@router.post("", status_code=201)
def create_one(self, category: CategoryIn):

View File

@@ -32,7 +32,7 @@ class TagController(BaseUserController):
@router.get("")
async def get_all(self):
"""Returns a list of available tags in the database"""
return self.repo.get_all(override_schema=RecipeTag)
return self.repo.get_all(override=RecipeTag)
@router.get("/empty")
def get_empty_tags(self):

View File

@@ -26,7 +26,7 @@ class RecipeToolController(BaseUserController):
@router.get("", response_model=list[RecipeTool])
def get_all(self, q: GetAll = Depends(GetAll)):
return self.repo.get_all(start=q.start, limit=q.limit, override_schema=RecipeTool)
return self.repo.get_all(start=q.start, limit=q.limit, override=RecipeTool)
@router.post("", response_model=RecipeTool, status_code=201)
def create_one(self, data: RecipeToolCreate):

View File

@@ -26,7 +26,7 @@ class RecipeSharedController(BaseUserController):
if recipe_id:
return self.repo.multi_query({"recipe_id": recipe_id}, override_schema=RecipeShareTokenSummary)
else:
return self.repo.get_all(override_schema=RecipeShareTokenSummary)
return self.repo.get_all(override=RecipeShareTokenSummary)
@router.post("", response_model=RecipeShareToken, status_code=201)
def create_one(self, data: RecipeShareTokenCreate) -> RecipeShareToken:

View File

@@ -34,6 +34,7 @@ class IngredientFood(CreateIngredientFood):
class CreateIngredientUnit(UnitFoodBase):
fraction: bool = True
abbreviation: str = ""
use_abbreviation: bool = False
class SaveIngredientUnit(CreateIngredientUnit):

View File

@@ -0,0 +1,27 @@
import enum
from typing import Generic, TypeVar
from pydantic import BaseModel
from pydantic.generics import GenericModel
DataT = TypeVar("DataT", bound=BaseModel)
class OrderDirection(str, enum.Enum):
asc = "asc"
desc = "desc"
class PaginationQuery(BaseModel):
page: int = 1
order_by: str = "created_at"
order_direction: OrderDirection = OrderDirection.desc
per_page: int = 50
class PaginationBase(GenericModel, Generic[DataT]):
page: int = 1
per_page: int = 10
total: int = 0
total_pages: int = 0
data: list[DataT]

58
poetry.lock generated
View File

@@ -724,7 +724,7 @@ python-versions = ">=3.6"
[[package]]
name = "mypy"
version = "0.940"
version = "0.960"
description = "Optional static typing for Python"
category = "dev"
optional = false
@@ -732,7 +732,7 @@ python-versions = ">=3.6"
[package.dependencies]
mypy-extensions = ">=0.4.3"
tomli = ">=1.1.0"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typing-extensions = ">=3.10"
[package.extras]
@@ -1188,7 +1188,7 @@ rdflib = ">=5.0.0"
[[package]]
name = "recipe-scrapers"
version = "14.1.0"
version = "14.3.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 = "45c28207b80dd8ecd82030410c132be32e8f2e46925c92641d4dd1626fec7786"
content-hash = "3a4e90f6b5b8a7ff46824949e7269f0d95905aa0351ff8478f096c97113ce855"
[metadata.files]
aiofiles = [
@@ -2087,29 +2087,29 @@ mkdocs-material-extensions = [
{file = "mkdocs_material_extensions-1.0.3-py3-none-any.whl", hash = "sha256:a82b70e533ce060b2a5d9eb2bc2e1be201cf61f901f93704b4acf6e3d5983a44"},
]
mypy = [
{file = "mypy-0.940-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0fdc9191a49c77ab5fa0439915d405e80a1118b163ab03cd2a530f346b12566a"},
{file = "mypy-0.940-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1903c92ff8642d521b4627e51a67e49f5be5aedb1fb03465b3aae4c3338ec491"},
{file = "mypy-0.940-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:471af97c35a32061883b0f8a3305ac17947fd42ce962ca9e2b0639eb9141492f"},
{file = "mypy-0.940-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:13677cb8b050f03b5bb2e8bf7b2668cd918b001d56c2435082bbfc9d5f730f42"},
{file = "mypy-0.940-cp310-cp310-win_amd64.whl", hash = "sha256:2efd76893fb8327eca7e942e21b373e6f3c5c083ff860fb1e82ddd0462d662bd"},
{file = "mypy-0.940-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8fe1bfab792e4300f80013edaf9949b34e4c056a7b2531b5ef3a0fb9d598ae2"},
{file = "mypy-0.940-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2dba92f58610d116f68ec1221fb2de2a346d081d17b24a784624389b17a4b3f9"},
{file = "mypy-0.940-cp36-cp36m-win_amd64.whl", hash = "sha256:712affcc456de637e774448c73e21c84dfa5a70bcda34e9b0be4fb898a9e8e07"},
{file = "mypy-0.940-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8aaf18d0f8bc3ffba56d32a85971dfbd371a5be5036da41ac16aefec440eff17"},
{file = "mypy-0.940-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:51be997c1922e2b7be514a5215d1e1799a40832c0a0dee325ba8794f2c48818f"},
{file = "mypy-0.940-cp37-cp37m-win_amd64.whl", hash = "sha256:628f5513268ebbc563750af672ccba5eef7f92d2d90154233edd498dfb98ca4e"},
{file = "mypy-0.940-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:68038d514ae59d5b2f326be502a359160158d886bd153fc2489dbf7a03c44c96"},
{file = "mypy-0.940-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b2fa5f2d597478ccfe1f274f8da2f50ea1e63da5a7ae2342c5b3b2f3e57ec340"},
{file = "mypy-0.940-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b1a116c451b41e35afc09618f454b5c2704ba7a4e36f9ff65014fef26bb6075b"},
{file = "mypy-0.940-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f66f2309cdbb07e95e60e83fb4a8272095bd4ea6ee58bf9a70d5fb304ec3e3f"},
{file = "mypy-0.940-cp38-cp38-win_amd64.whl", hash = "sha256:3ac14949677ae9cb1adc498c423b194ad4d25b13322f6fe889fb72b664c79121"},
{file = "mypy-0.940-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6eab2bcc2b9489b7df87d7c20743b66d13254ad4d6430e1dfe1a655d51f0933d"},
{file = "mypy-0.940-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0b52778a018559a256c819ee31b2e21e10b31ddca8705624317253d6d08dbc35"},
{file = "mypy-0.940-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d9d7647505bf427bc7931e8baf6cacf9be97e78a397724511f20ddec2a850752"},
{file = "mypy-0.940-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a0e5657ccaedeb5fdfda59918cc98fc6d8a8e83041bc0cec347a2ab6915f9998"},
{file = "mypy-0.940-cp39-cp39-win_amd64.whl", hash = "sha256:83f66190e3c32603217105913fbfe0a3ef154ab6bbc7ef2c989f5b2957b55840"},
{file = "mypy-0.940-py3-none-any.whl", hash = "sha256:a168da06eccf51875fdff5f305a47f021f23f300e2b89768abdac24538b1f8ec"},
{file = "mypy-0.940.tar.gz", hash = "sha256:71bec3d2782d0b1fecef7b1c436253544d81c1c0e9ca58190aed9befd8f081c5"},
{file = "mypy-0.960-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3a3e525cd76c2c4f90f1449fd034ba21fcca68050ff7c8397bb7dd25dd8b8248"},
{file = "mypy-0.960-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7a76dc4f91e92db119b1be293892df8379b08fd31795bb44e0ff84256d34c251"},
{file = "mypy-0.960-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffdad80a92c100d1b0fe3d3cf1a4724136029a29afe8566404c0146747114382"},
{file = "mypy-0.960-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7d390248ec07fa344b9f365e6ed9d205bd0205e485c555bed37c4235c868e9d5"},
{file = "mypy-0.960-cp310-cp310-win_amd64.whl", hash = "sha256:925aa84369a07846b7f3b8556ccade1f371aa554f2bd4fb31cb97a24b73b036e"},
{file = "mypy-0.960-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:239d6b2242d6c7f5822163ee082ef7a28ee02e7ac86c35593ef923796826a385"},
{file = "mypy-0.960-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f1ba54d440d4feee49d8768ea952137316d454b15301c44403db3f2cb51af024"},
{file = "mypy-0.960-cp36-cp36m-win_amd64.whl", hash = "sha256:cb7752b24528c118a7403ee955b6a578bfcf5879d5ee91790667c8ea511d2085"},
{file = "mypy-0.960-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:826a2917c275e2ee05b7c7b736c1e6549a35b7ea5a198ca457f8c2ebea2cbecf"},
{file = "mypy-0.960-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3eabcbd2525f295da322dff8175258f3fc4c3eb53f6d1929644ef4d99b92e72d"},
{file = "mypy-0.960-cp37-cp37m-win_amd64.whl", hash = "sha256:f47322796c412271f5aea48381a528a613f33e0a115452d03ae35d673e6064f8"},
{file = "mypy-0.960-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2c7f8bb9619290836a4e167e2ef1f2cf14d70e0bc36c04441e41487456561409"},
{file = "mypy-0.960-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fbfb873cf2b8d8c3c513367febde932e061a5f73f762896826ba06391d932b2a"},
{file = "mypy-0.960-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc537885891382e08129d9862553b3d00d4be3eb15b8cae9e2466452f52b0117"},
{file = "mypy-0.960-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:481f98c6b24383188c928f33dd2f0776690807e12e9989dd0419edd5c74aa53b"},
{file = "mypy-0.960-cp38-cp38-win_amd64.whl", hash = "sha256:29dc94d9215c3eb80ac3c2ad29d0c22628accfb060348fd23d73abe3ace6c10d"},
{file = "mypy-0.960-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:33d53a232bb79057f33332dbbb6393e68acbcb776d2f571ba4b1d50a2c8ba873"},
{file = "mypy-0.960-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8d645e9e7f7a5da3ec3bbcc314ebb9bb22c7ce39e70367830eb3c08d0140b9ce"},
{file = "mypy-0.960-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:85cf2b14d32b61db24ade8ac9ae7691bdfc572a403e3cb8537da936e74713275"},
{file = "mypy-0.960-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a85a20b43fa69efc0b955eba1db435e2ffecb1ca695fe359768e0503b91ea89f"},
{file = "mypy-0.960-cp39-cp39-win_amd64.whl", hash = "sha256:0ebfb3f414204b98c06791af37a3a96772203da60636e2897408517fcfeee7a8"},
{file = "mypy-0.960-py3-none-any.whl", hash = "sha256:bfd4f6536bd384c27c392a8b8f790fd0ed5c0cf2f63fc2fed7bce56751d53026"},
{file = "mypy-0.960.tar.gz", hash = "sha256:d4fccf04c1acf750babd74252e0f2db6bd2ac3aa8fe960797d9f3ef41cf2bfd4"},
]
mypy-extensions = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
@@ -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-14.1.0-py3-none-any.whl", hash = "sha256:fc4bf1d5bd142e63a81b1b734a874e2fd6686b22287b41a9bc1355c2004bd5f7"},
{file = "recipe_scrapers-14.1.0.tar.gz", hash = "sha256:5c6931dc13cdb458f7ce52c2fae6a63348ee826a9e0f71ba7679a3d3f7a9257b"},
{file = "recipe_scrapers-14.3.0-py3-none-any.whl", hash = "sha256:ff3344b741999671ec0aa74482f10aaac63ffc95b02fc6efbc853b3e3cfe6805"},
{file = "recipe_scrapers-14.3.0.tar.gz", hash = "sha256:9954e01af9cfe2b3c08e3103ef8b4aae4c24257be2cb37711430365ffe57e055"},
]
requests = [
{file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},

View File

@@ -30,12 +30,12 @@ passlib = "^1.7.4"
lxml = "^4.7.1"
Pillow = "^8.2.0"
apprise = "^0.9.6"
recipe-scrapers = "^14.1.0"
recipe-scrapers = "^14.3.0"
psycopg2-binary = {version = "^2.9.1", optional = true}
gunicorn = "^20.1.0"
emails = "^0.6"
python-ldap = "^3.3.1"
pydantic = "^1.9.0"
pydantic = "^1.9.1"
tzdata = "^2021.5"
pyhumps = "^3.5.3"
@@ -52,7 +52,7 @@ isort = "^5.9.3"
flake8-print = "^4.0.0"
black = "^21.12b0"
coveragepy-lcov = "^0.1.1"
mypy = "^0.940"
mypy = "^0.960"
types-python-slugify = "^5.0.3"
types-PyYAML = "^6.0.4"
types-requests = "^2.27.12"

View File

@@ -9,17 +9,19 @@ from tests.utils.fixture_schemas import TestUser
class Routes:
base = "/api/units"
@staticmethod
def item(item_id: int) -> str:
return f"{Routes.base}/{item_id}"
@pytest.fixture(scope="function")
def unit(api_client: TestClient, unique_user: TestUser) -> dict:
def unit(api_client: TestClient, unique_user: TestUser):
data = CreateIngredientUnit(
name=random_string(10),
description=random_string(10),
fraction=random_bool(),
abbreviation=random_string(3) + ".",
abbreviation=f"{random_string(3)}.",
use_abbreviation=random_bool(),
).dict(by_alias=True)
response = api_client.post(Routes.base, json=data, headers=unique_user.token)
@@ -52,6 +54,7 @@ def test_read_unit(api_client: TestClient, unit: dict, unique_user: TestUser):
assert as_json["description"] == unit["description"]
assert as_json["fraction"] == unit["fraction"]
assert as_json["abbreviation"] == unit["abbreviation"]
assert as_json["useAbbreviation"] == unit["useAbbreviation"]
def test_update_unit(api_client: TestClient, unit: dict, unique_user: TestUser):
@@ -60,8 +63,10 @@ def test_update_unit(api_client: TestClient, unit: dict, unique_user: TestUser):
"name": random_string(10),
"description": random_string(10),
"fraction": not unit["fraction"],
"abbreviation": random_string(3) + ".",
"abbreviation": f"{random_string(3)}.",
"useAbbreviation": not unit["useAbbreviation"],
}
response = api_client.put(Routes.item(unit["id"]), json=update_data, headers=unique_user.token)
assert response.status_code == 200
as_json = response.json()
@@ -71,14 +76,15 @@ def test_update_unit(api_client: TestClient, unit: dict, unique_user: TestUser):
assert as_json["description"] == update_data["description"]
assert as_json["fraction"] == update_data["fraction"]
assert as_json["abbreviation"] == update_data["abbreviation"]
assert as_json["useAbbreviation"] == update_data["useAbbreviation"]
def test_delete_unit(api_client: TestClient, unit: dict, unique_user: TestUser):
id = unit["id"]
item_id = unit["id"]
response = api_client.delete(Routes.item(id), headers=unique_user.token)
response = api_client.delete(Routes.item(item_id), headers=unique_user.token)
assert response.status_code == 200
response = api_client.get(Routes.item(id), headers=unique_user.token)
response = api_client.get(Routes.item(item_id), headers=unique_user.token)
assert response.status_code == 404

View File

@@ -0,0 +1,38 @@
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.response.pagination import PaginationQuery
from mealie.services.seeder.seeder_service import SeederService
from tests.utils.fixture_schemas import TestUser
def test_repository_pagination(database: AllRepositories, unique_user: TestUser):
group = database.groups.get_one(unique_user.group_id)
seeder = SeederService(database, None, group)
seeder.seed_foods("en-US")
foods_repo = database.ingredient_foods.by_group(unique_user.group_id) # type: ignore
query = PaginationQuery(
page=1,
order_by="id",
per_page=10,
)
seen = []
for _ in range(10):
results = foods_repo.pagination(query)
assert len(results.data) == 10
for result in results.data:
assert result.id not in seen
seen += [result.id for result in results.data]
query.page += 1
results = foods_repo.pagination(query)
for result in results.data:
assert result.id not in seen

Some files were not shown because too many files have changed in this diff Show More