mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-01-18 19:30:03 -05:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6de0d0b3d | ||
|
|
dce6d86cbf | ||
|
|
3539385429 | ||
|
|
e97f1f805b | ||
|
|
83edff1c78 | ||
|
|
efb72b1859 | ||
|
|
5afa611ec3 | ||
|
|
82cc9e11f7 | ||
|
|
3fc120236d | ||
|
|
e32bae4575 | ||
|
|
327da02fc8 | ||
|
|
c8cd68b4f0 | ||
|
|
f31b76e2ff | ||
|
|
426f91fb50 | ||
|
|
f194a6d8c8 | ||
|
|
6e4f9a234b | ||
|
|
76eccdff8c | ||
|
|
a7330f11e6 | ||
|
|
d993ddf600 | ||
|
|
54f994defc | ||
|
|
db4789099a | ||
|
|
172698afce | ||
|
|
8f9d602004 | ||
|
|
d3b574ea84 | ||
|
|
4f5a0bf9f5 | ||
|
|
d965ceaff6 | ||
|
|
bcd0fcc920 | ||
|
|
085c489b05 | ||
|
|
af46a6ce33 | ||
|
|
b1f81b4b95 | ||
|
|
622c1b11f5 | ||
|
|
7ada42a791 | ||
|
|
ea4adfa335 | ||
|
|
365d77e599 | ||
|
|
0ef8c52c6a | ||
|
|
d419acd61e |
@@ -12,7 +12,7 @@ repos:
|
||||
exclude: ^tests/data/
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.7.2
|
||||
rev: v0.8.0
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
|
||||
@@ -8,7 +8,6 @@ Create Date: 2024-10-20 09:47:46.844436
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""add recipe yield quantity
|
||||
|
||||
Revision ID: b1020f328e98
|
||||
Revises: 3897397b4631
|
||||
Create Date: 2024-10-23 15:50:59.888793
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import orm
|
||||
|
||||
from alembic import op
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
from mealie.services.scraper.cleaner import clean_yield
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b1020f328e98"
|
||||
down_revision: str | None = "3897397b4631"
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
# Intermediate table definitions
|
||||
class SqlAlchemyBase(orm.DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class RecipeModel(SqlAlchemyBase):
|
||||
__tablename__ = "recipes"
|
||||
|
||||
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
recipe_yield: orm.Mapped[str | None] = orm.mapped_column(sa.String)
|
||||
recipe_yield_quantity: orm.Mapped[float] = orm.mapped_column(sa.Float, index=True, default=0)
|
||||
recipe_servings: orm.Mapped[float] = orm.mapped_column(sa.Float, index=True, default=0)
|
||||
|
||||
|
||||
def parse_recipe_yields():
|
||||
bind = op.get_bind()
|
||||
session = orm.Session(bind=bind)
|
||||
|
||||
for recipe in session.query(RecipeModel).all():
|
||||
try:
|
||||
recipe.recipe_servings, recipe.recipe_yield_quantity, recipe.recipe_yield = clean_yield(recipe.recipe_yield)
|
||||
except Exception:
|
||||
recipe.recipe_servings = 0
|
||||
recipe.recipe_yield_quantity = 0
|
||||
|
||||
session.commit()
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("recipes", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("recipe_yield_quantity", sa.Float(), nullable=False, server_default="0"))
|
||||
batch_op.create_index(batch_op.f("ix_recipes_recipe_yield_quantity"), ["recipe_yield_quantity"], unique=False)
|
||||
batch_op.add_column(sa.Column("recipe_servings", sa.Float(), nullable=False, server_default="0"))
|
||||
batch_op.create_index(batch_op.f("ix_recipes_recipe_servings"), ["recipe_servings"], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
parse_recipe_yields()
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("recipes", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_recipes_recipe_servings"))
|
||||
batch_op.drop_column("recipe_servings")
|
||||
batch_op.drop_index(batch_op.f("ix_recipes_recipe_yield_quantity"))
|
||||
batch_op.drop_column("recipe_yield_quantity")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Template
|
||||
@@ -64,7 +65,112 @@ def generate_global_components_types() -> None:
|
||||
# Pydantic To Typescript Generator
|
||||
|
||||
|
||||
def generate_typescript_types() -> None:
|
||||
def generate_typescript_types() -> None: # noqa: C901
|
||||
def contains_number(s: str) -> bool:
|
||||
return bool(re.search(r"\d", s))
|
||||
|
||||
def remove_numbers(s: str) -> str:
|
||||
return re.sub(r"\d", "", s)
|
||||
|
||||
def extract_type_name(line: str) -> str:
|
||||
# Looking for "export type EnumName = enumVal1 | enumVal2 | ..."
|
||||
if not (line.startswith("export type") and "=" in line):
|
||||
return ""
|
||||
|
||||
return line.split(" ")[2]
|
||||
|
||||
def extract_property_type_name(line: str) -> str:
|
||||
# Looking for " fieldName: FieldType;" or " fieldName: FieldType & string;"
|
||||
if not (line.startswith(" ") and ":" in line):
|
||||
return ""
|
||||
|
||||
return line.split(":")[1].strip().split(";")[0]
|
||||
|
||||
def extract_interface_name(line: str) -> str:
|
||||
# Looking for "export interface InterfaceName {"
|
||||
if not (line.startswith("export interface") and "{" in line):
|
||||
return ""
|
||||
|
||||
return line.split(" ")[2]
|
||||
|
||||
def is_comment_line(line: str) -> bool:
|
||||
s = line.strip()
|
||||
return s.startswith("/*") or s.startswith("*")
|
||||
|
||||
def clean_output_file(file: Path) -> None:
|
||||
"""
|
||||
json2ts generates duplicate types off of our enums and appends a number to the end of the type name.
|
||||
Our Python code (hopefully) doesn't have any duplicate enum names, or types with numbers in them,
|
||||
so we can safely remove the numbers.
|
||||
|
||||
To do this, we read the output line-by-line and replace any type names that contain numbers with
|
||||
the same type name, but without the numbers.
|
||||
|
||||
Note: the issue arrises from the JSON package json2ts, not the Python package pydantic2ts,
|
||||
otherwise we could just fix pydantic2ts.
|
||||
"""
|
||||
|
||||
# First pass: build a map of type names to their numberless counterparts and lines to skip
|
||||
replacement_map = {}
|
||||
lines_to_skip = set()
|
||||
wait_for_semicolon = False
|
||||
wait_for_close_bracket = False
|
||||
skip_comments = False
|
||||
with open(file) as f:
|
||||
for i, line in enumerate(f.readlines()):
|
||||
if wait_for_semicolon:
|
||||
if ";" in line:
|
||||
wait_for_semicolon = False
|
||||
lines_to_skip.add(i)
|
||||
continue
|
||||
if wait_for_close_bracket:
|
||||
if "}" in line:
|
||||
wait_for_close_bracket = False
|
||||
lines_to_skip.add(i)
|
||||
continue
|
||||
|
||||
if type_name := extract_type_name(line):
|
||||
if not contains_number(type_name):
|
||||
continue
|
||||
|
||||
replacement_map[type_name] = remove_numbers(type_name)
|
||||
if ";" not in line:
|
||||
wait_for_semicolon = True
|
||||
lines_to_skip.add(i)
|
||||
|
||||
elif type_name := extract_interface_name(line):
|
||||
if not contains_number(type_name):
|
||||
continue
|
||||
|
||||
replacement_map[type_name] = remove_numbers(type_name)
|
||||
if "}" not in line:
|
||||
wait_for_close_bracket = True
|
||||
lines_to_skip.add(i)
|
||||
|
||||
elif skip_comments and is_comment_line(line):
|
||||
lines_to_skip.add(i)
|
||||
|
||||
# we've passed the opening comments and empty line at the header
|
||||
elif not skip_comments and not line.strip():
|
||||
skip_comments = True
|
||||
|
||||
# Second pass: rewrite or remove lines as needed.
|
||||
# We have to do two passes here because definitions don't always appear in the same order as their usage.
|
||||
lines = []
|
||||
with open(file) as f:
|
||||
for i, line in enumerate(f.readlines()):
|
||||
if i in lines_to_skip:
|
||||
continue
|
||||
|
||||
if type_name := extract_property_type_name(line):
|
||||
if type_name in replacement_map:
|
||||
line = line.replace(type_name, replacement_map[type_name])
|
||||
|
||||
lines.append(line)
|
||||
|
||||
with open(file, "w") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
def path_to_module(path: Path):
|
||||
str_path: str = str(path)
|
||||
|
||||
@@ -98,9 +204,10 @@ def generate_typescript_types() -> None:
|
||||
try:
|
||||
path_as_module = path_to_module(module)
|
||||
generate_typescript_defs(path_as_module, str(out_path), exclude=("MealieModel")) # type: ignore
|
||||
except Exception as e:
|
||||
clean_output_file(out_path)
|
||||
except Exception:
|
||||
failed_modules.append(module)
|
||||
log.error(f"Module Error: {e}")
|
||||
log.exception(f"Module Error: {module}")
|
||||
|
||||
log.debug("\n📁 Skipped Directories:")
|
||||
for skipped_dir in skipped_dirs:
|
||||
|
||||
@@ -24,7 +24,7 @@ Make sure the url and port (`http://mealie:9000` ) matches your installation's a
|
||||
|
||||
```yaml
|
||||
rest:
|
||||
- resource: "http://mealie:9000/api/groups/mealplans/today"
|
||||
- resource: "http://mealie:9000/api/households/mealplans/today"
|
||||
method: GET
|
||||
headers:
|
||||
Authorization: Bearer <<API_TOKEN>>
|
||||
|
||||
@@ -79,7 +79,7 @@ Mealie's Recipe Steps and other fields support markdown syntax and therefore sup
|
||||
If your account has been locked by bad password attempts, you can use an administrator account to unlock another account. Alternatively, you can unlock all accounts via a script within the container.
|
||||
|
||||
```shell
|
||||
docker exec -it mealie-next bash
|
||||
docker exec -it mealie bash
|
||||
|
||||
python /app/mealie/scripts/reset_locked_users.py
|
||||
```
|
||||
@@ -89,7 +89,7 @@ python /app/mealie/scripts/reset_locked_users.py
|
||||
You can change your password by going to the user profile page and clicking the "Change Password" button. Alternatively you can use the following script to change your password via the CLI if you are locked out of your account.
|
||||
|
||||
```shell
|
||||
docker exec -it mealie-next bash
|
||||
docker exec -it mealie bash
|
||||
|
||||
python /app/mealie/scripts/change_password.py
|
||||
```
|
||||
|
||||
@@ -95,7 +95,7 @@ Use this only when mealie is run without a webserver or reverse proxy.
|
||||
For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.md)
|
||||
|
||||
| Variables | Default | Description |
|
||||
| ------------------------------------------------- | :-----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
|---------------------------------------------------|:-------:|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| OIDC_AUTH_ENABLED | False | Enables authentication via OpenID Connect |
|
||||
| OIDC_SIGNUP_ENABLED | True | Enables new users to be created when signing in for the first time with OIDC |
|
||||
| OIDC_CONFIGURATION_URL | None | The URL to the OIDC configuration of your provider. This is usually something like https://auth.example.com/.well-known/openid-configuration |
|
||||
@@ -107,6 +107,7 @@ For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.md)
|
||||
| OIDC_PROVIDER_NAME | OAuth | The provider name is shown in SSO login button. "Login with <OIDC_PROVIDER_NAME\>" |
|
||||
| OIDC_REMEMBER_ME | False | Because redirects bypass the login screen, you cant extend your session by clicking the "Remember Me" checkbox. By setting this value to true, a session will be extended as if "Remember Me" was checked |
|
||||
| OIDC_USER_CLAIM | email | This is the claim which Mealie will use to look up an existing user by (e.g. "email", "preferred_username") |
|
||||
| OIDC_NAME_CLAIM | name | This is the claim which Mealie will use for the users Full Name |
|
||||
| OIDC_GROUPS_CLAIM | groups | Optional if not using `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP`. This is the claim Mealie will request from your IdP and will use to compare to `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP` to allow the user to log in to Mealie or is set as an admin. **Your IdP must be configured to grant this claim** |
|
||||
| OIDC_SCOPES_OVERRIDE | None | Advanced configuration used to override the scopes requested from the IdP. **Most users won't need to change this**. At a minimum, 'openid profile email' are required. |
|
||||
| OIDC_TLS_CACERTFILE | None | File path to Certificate Authority used to verify server certificate (e.g. `/path/to/ca.crt`) |
|
||||
|
||||
@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
|
||||
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
||||
|
||||
1. Take a backup just in case!
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v2.1.0`
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v2.2.0`
|
||||
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
||||
4. Restart the container
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v2.1.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v2.2.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
@@ -24,8 +24,6 @@ services:
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
TZ: America/Anchorage
|
||||
MAX_WORKERS: 1
|
||||
WEB_CONCURRENCY: 1
|
||||
BASE_URL: https://mealie.yourdomain.com
|
||||
# Database Settings
|
||||
DB_ENGINE: postgres
|
||||
|
||||
@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v2.1.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v2.2.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
@@ -28,8 +28,6 @@ services:
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
TZ: America/Anchorage
|
||||
MAX_WORKERS: 1
|
||||
WEB_CONCURRENCY: 1
|
||||
BASE_URL: https://mealie.yourdomain.com
|
||||
|
||||
volumes:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -57,6 +57,11 @@ export default defineComponent({
|
||||
label: i18n.tc("tag.tags"),
|
||||
type: Organizer.Tag,
|
||||
},
|
||||
{
|
||||
name: "recipe_ingredient.food.id",
|
||||
label: i18n.tc("recipe.ingredients"),
|
||||
type: Organizer.Food,
|
||||
},
|
||||
{
|
||||
name: "tools.id",
|
||||
label: i18n.tc("tool.tools"),
|
||||
|
||||
@@ -118,6 +118,11 @@ export default defineComponent({
|
||||
label: i18n.tc("tag.tags"),
|
||||
type: Organizer.Tag,
|
||||
},
|
||||
{
|
||||
name: "recipe_ingredient.food.id",
|
||||
label: i18n.tc("recipe.ingredients"),
|
||||
type: Organizer.Food,
|
||||
},
|
||||
{
|
||||
name: "tools.id",
|
||||
label: i18n.tc("tool.tools"),
|
||||
|
||||
@@ -51,8 +51,6 @@
|
||||
<v-text-field
|
||||
v-model="newMealdate"
|
||||
:label="$t('general.date')"
|
||||
:hint="$t('recipe.date-format-hint')"
|
||||
persistent-hint
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="attrs"
|
||||
readonly
|
||||
|
||||
@@ -63,6 +63,8 @@ interface ShowHeaders {
|
||||
tags: boolean;
|
||||
categories: boolean;
|
||||
tools: boolean;
|
||||
recipeServings: boolean;
|
||||
recipeYieldQuantity: boolean;
|
||||
recipeYield: boolean;
|
||||
dateAdded: boolean;
|
||||
}
|
||||
@@ -93,6 +95,8 @@ export default defineComponent({
|
||||
owner: false,
|
||||
tags: true,
|
||||
categories: true,
|
||||
recipeServings: true,
|
||||
recipeYieldQuantity: true,
|
||||
recipeYield: true,
|
||||
dateAdded: true,
|
||||
};
|
||||
@@ -127,8 +131,14 @@ export default defineComponent({
|
||||
if (props.showHeaders.tools) {
|
||||
hdrs.push({ text: i18n.t("tool.tools"), value: "tools" });
|
||||
}
|
||||
if (props.showHeaders.recipeServings) {
|
||||
hdrs.push({ text: i18n.t("recipe.servings"), value: "recipeServings" });
|
||||
}
|
||||
if (props.showHeaders.recipeYieldQuantity) {
|
||||
hdrs.push({ text: i18n.t("recipe.yield"), value: "recipeYieldQuantity" });
|
||||
}
|
||||
if (props.showHeaders.recipeYield) {
|
||||
hdrs.push({ text: i18n.t("recipe.yield"), value: "recipeYield" });
|
||||
hdrs.push({ text: i18n.t("recipe.yield-text"), value: "recipeYield" });
|
||||
}
|
||||
if (props.showHeaders.dateAdded) {
|
||||
hdrs.push({ text: i18n.t("general.date-added"), value: "dateAdded" });
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<template>
|
||||
<div v-if="value && value.length > 0">
|
||||
<div class="d-flex justify-start">
|
||||
<div v-if="!isCookMode" class="d-flex justify-start" >
|
||||
<h2 class="mb-2 mt-1">{{ $t("recipe.ingredients") }}</h2>
|
||||
<AppButtonCopy btn-class="ml-auto" :copy-text="ingredientCopyText" />
|
||||
</div>
|
||||
<div>
|
||||
<div v-for="(ingredient, index) in value" :key="'ingredient' + index">
|
||||
<h3 v-if="showTitleEditor[index]" class="mt-2">{{ ingredient.title }}</h3>
|
||||
<v-divider v-if="showTitleEditor[index]"></v-divider>
|
||||
<v-list-item dense @click="toggleChecked(index)">
|
||||
<template v-if="!isCookMode">
|
||||
<h3 v-if="showTitleEditor[index]" class="mt-2">{{ ingredient.title }}</h3>
|
||||
<v-divider v-if="showTitleEditor[index]"></v-divider>
|
||||
</template>
|
||||
<v-list-item dense @click.stop="toggleChecked(index)">
|
||||
<v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary" />
|
||||
<v-list-item-content :key="ingredient.quantity">
|
||||
<RecipeIngredientListItem :ingredient="ingredient" :disable-amount="disableAmount" :scale="scale" />
|
||||
@@ -40,6 +42,10 @@ export default defineComponent({
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
isCookMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
function validateTitle(title?: string) {
|
||||
|
||||
@@ -86,12 +86,6 @@
|
||||
</BaseDialog>
|
||||
</div>
|
||||
<div>
|
||||
<div class="d-flex justify-center flex-wrap">
|
||||
<BaseButton :small="$vuetify.breakpoint.smAndDown" @click="madeThisDialog = true">
|
||||
<template #icon> {{ $globals.icons.chefHat }} </template>
|
||||
{{ $t('recipe.made-this') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div class="d-flex justify-center flex-wrap">
|
||||
<v-chip
|
||||
label
|
||||
@@ -105,6 +99,12 @@
|
||||
{{ $t('recipe.last-made-date', { date: value ? new Date(value).toLocaleDateString($i18n.locale) : $t("general.never") } ) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="d-flex justify-center flex-wrap mt-1">
|
||||
<BaseButton :small="$vuetify.breakpoint.smAndDown" @click="madeThisDialog = true">
|
||||
<template #icon> {{ $globals.icons.chefHat }} </template>
|
||||
{{ $t('recipe.made-this') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -125,7 +125,7 @@ export default defineComponent({
|
||||
},
|
||||
recipe: {
|
||||
type: Object as () => Recipe,
|
||||
default: null,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
|
||||
@@ -1,75 +1,136 @@
|
||||
<template>
|
||||
<v-container :class="{ 'pa-0': $vuetify.breakpoint.smAndDown }">
|
||||
<v-card :flat="$vuetify.breakpoint.smAndDown" class="d-print-none">
|
||||
<RecipePageHeader
|
||||
<div>
|
||||
<v-container v-show="!isCookMode" key="recipe-page" :class="{ 'pa-0': $vuetify.breakpoint.smAndDown }">
|
||||
<v-card :flat="$vuetify.breakpoint.smAndDown" class="d-print-none">
|
||||
<RecipePageHeader
|
||||
:recipe="recipe"
|
||||
:recipe-scale="scale"
|
||||
:landscape="landscape"
|
||||
@save="saveRecipe"
|
||||
@delete="deleteRecipe"
|
||||
/>
|
||||
<LazyRecipeJsonEditor v-if="isEditJSON" v-model="recipe" class="mt-10" :options="EDITOR_OPTIONS" />
|
||||
<v-card-text v-else>
|
||||
<!--
|
||||
This is where most of the main content is rendered. Some components include state for both Edit and View modes
|
||||
which is why some have explicit v-if statements and others use the composition API to determine and manage
|
||||
the shared state internally.
|
||||
|
||||
The global recipe object is shared down the tree of components and _is_ mutated by child components. This is
|
||||
some-what of a hack of the system and goes against the principles of Vue, but it _does_ seem to work and streamline
|
||||
a significant amount of prop management. When we move to Vue 3 and have access to some of the newer API's the plan to update this
|
||||
data management and mutation system we're using.
|
||||
-->
|
||||
<RecipePageInfoEditor v-if="isEditMode" :recipe="recipe" :landscape="landscape" />
|
||||
<RecipePageEditorToolbar v-if="isEditForm" :recipe="recipe" />
|
||||
<RecipePageIngredientEditor v-if="isEditForm" :recipe="recipe" />
|
||||
<RecipePageScale :recipe="recipe" :scale.sync="scale" />
|
||||
|
||||
<!--
|
||||
This section contains the 2 column layout for the recipe steps and other content.
|
||||
-->
|
||||
<v-row>
|
||||
<!--
|
||||
The left column is conditionally rendered based on cook mode.
|
||||
-->
|
||||
<v-col v-if="!isCookMode || isEditForm" cols="12" sm="12" md="4" lg="4">
|
||||
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" />
|
||||
<RecipePageOrganizers v-if="$vuetify.breakpoint.mdAndUp" :recipe="recipe" />
|
||||
</v-col>
|
||||
<v-divider v-if="$vuetify.breakpoint.mdAndUp && !isCookMode" class="my-divider" :vertical="true" />
|
||||
|
||||
<!--
|
||||
the right column is always rendered, but it's layout width is determined by where the left column is
|
||||
rendered.
|
||||
-->
|
||||
<v-col cols="12" sm="12" :md="8 + (isCookMode ? 1 : 0) * 4" :lg="8 + (isCookMode ? 1 : 0) * 4">
|
||||
<RecipePageInstructions
|
||||
v-model="recipe.recipeInstructions"
|
||||
:assets.sync="recipe.assets"
|
||||
:recipe="recipe"
|
||||
:scale="scale"
|
||||
/>
|
||||
<div v-if="isEditForm" class="d-flex">
|
||||
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
|
||||
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.add") }}</BaseButton>
|
||||
</div>
|
||||
<div v-if="!$vuetify.breakpoint.mdAndUp">
|
||||
<RecipePageOrganizers :recipe="recipe" />
|
||||
</div>
|
||||
<RecipeNotes v-model="recipe.notes" :edit="isEditForm" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<RecipePageFooter :recipe="recipe" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<WakelockSwitch/>
|
||||
<RecipePageComments
|
||||
v-if="!recipe.settings.disableComments && !isEditForm && !isCookMode"
|
||||
:recipe="recipe"
|
||||
:recipe-scale="scale"
|
||||
:landscape="landscape"
|
||||
@save="saveRecipe"
|
||||
@delete="deleteRecipe"
|
||||
class="px-1 my-4 d-print-none"
|
||||
/>
|
||||
<LazyRecipeJsonEditor v-if="isEditJSON" v-model="recipe" class="mt-10" :options="EDITOR_OPTIONS" />
|
||||
<v-card-text v-else>
|
||||
<!--
|
||||
This is where most of the main content is rendered. Some components include state for both Edit and View modes
|
||||
which is why some have explicit v-if statements and others use the composition API to determine and manage
|
||||
the shared state internally.
|
||||
<RecipePrintContainer :recipe="recipe" :scale="scale" />
|
||||
</v-container>
|
||||
<!-- Cook mode displayes two columns with ingredients and instructions side by side, each being scrolled individually, allowing to view both at the same timer -->
|
||||
<v-sheet v-show="isCookMode && !hasLinkedIngredients" key="cookmode" :style="{height: $vuetify.breakpoint.smAndUp ? 'calc(100vh - 48px)' : ''}"> <!-- the calc is to account for the toolbar a more dynamic solution could be needed -->
|
||||
<v-row style="height: 100%;" no-gutters class="overflow-hidden">
|
||||
<v-col cols="12" sm="5" class="overflow-y-auto pl-4 pr-3 py-2" style="height: 100%;">
|
||||
<div class="d-flex align-center">
|
||||
<RecipePageScale :recipe="recipe" :scale.sync="scale" />
|
||||
</div>
|
||||
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" :is-cook-mode="isCookMode" />
|
||||
<v-divider></v-divider>
|
||||
</v-col>
|
||||
<v-col class="overflow-y-auto py-2" style="height: 100%;" cols="12" sm="7">
|
||||
<RecipePageInstructions
|
||||
v-model="recipe.recipeInstructions"
|
||||
class="overflow-y-hidden px-4"
|
||||
:assets.sync="recipe.assets"
|
||||
:recipe="recipe"
|
||||
:scale="scale"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
The global recipe object is shared down the tree of components and _is_ mutated by child components. This is
|
||||
some-what of a hack of the system and goes against the principles of Vue, but it _does_ seem to work and streamline
|
||||
a significant amount of prop management. When we move to Vue 3 and have access to some of the newer API's the plan to update this
|
||||
data management and mutation system we're using.
|
||||
-->
|
||||
<RecipePageEditorToolbar v-if="isEditForm" :recipe="recipe" />
|
||||
<RecipePageTitleContent :recipe="recipe" :landscape="landscape" />
|
||||
<RecipePageIngredientEditor v-if="isEditForm" :recipe="recipe" />
|
||||
<RecipePageScale :recipe="recipe" :scale.sync="scale" :landscape="landscape" />
|
||||
</v-sheet>
|
||||
<v-sheet v-show="isCookMode && hasLinkedIngredients">
|
||||
<div class="mt-2 px-2 px-md-4">
|
||||
<RecipePageScale :recipe="recipe" :scale.sync="scale"/>
|
||||
</div>
|
||||
<RecipePageInstructions
|
||||
v-model="recipe.recipeInstructions"
|
||||
class="overflow-y-hidden mt-n5 px-2 px-md-4"
|
||||
:assets.sync="recipe.assets"
|
||||
:recipe="recipe"
|
||||
:scale="scale"
|
||||
/>
|
||||
|
||||
<!--
|
||||
This section contains the 2 column layout for the recipe steps and other content.
|
||||
-->
|
||||
<v-row>
|
||||
<!--
|
||||
The left column is conditionally rendered based on cook mode.
|
||||
-->
|
||||
<v-col v-if="!isCookMode || isEditForm" cols="12" sm="12" md="4" lg="4">
|
||||
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" />
|
||||
<RecipePageOrganizers v-if="$vuetify.breakpoint.mdAndUp" :recipe="recipe" />
|
||||
</v-col>
|
||||
<v-divider v-if="$vuetify.breakpoint.mdAndUp && !isCookMode" class="my-divider" :vertical="true" />
|
||||
|
||||
<!--
|
||||
the right column is always rendered, but it's layout width is determined by where the left column is
|
||||
rendered.
|
||||
-->
|
||||
<v-col cols="12" sm="12" :md="8 + (isCookMode ? 1 : 0) * 4" :lg="8 + (isCookMode ? 1 : 0) * 4">
|
||||
<RecipePageInstructions
|
||||
v-model="recipe.recipeInstructions"
|
||||
:assets.sync="recipe.assets"
|
||||
:recipe="recipe"
|
||||
<div v-if="notLinkedIngredients.length > 0" class="px-2 px-md-4 pb-4 ">
|
||||
<v-divider></v-divider>
|
||||
<v-card flat>
|
||||
<v-card-title>{{ $t('recipe.not-linked-ingredients') }}</v-card-title>
|
||||
<RecipeIngredients
|
||||
:value="notLinkedIngredients"
|
||||
:scale="scale"
|
||||
/>
|
||||
<div v-if="isEditForm" class="d-flex">
|
||||
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
|
||||
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.add") }}</BaseButton>
|
||||
</div>
|
||||
<div v-if="!$vuetify.breakpoint.mdAndUp">
|
||||
<RecipePageOrganizers :recipe="recipe" />
|
||||
</div>
|
||||
<RecipeNotes v-model="recipe.notes" :edit="isEditForm" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<RecipePageFooter :recipe="recipe" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<WakelockSwitch/>
|
||||
<RecipePageComments
|
||||
v-if="!recipe.settings.disableComments && !isEditForm && !isCookMode"
|
||||
:recipe="recipe"
|
||||
class="px-1 my-4 d-print-none"
|
||||
/>
|
||||
<RecipePrintContainer :recipe="recipe" :scale="scale" />
|
||||
</v-container>
|
||||
:disable-amount="recipe.settings.disableAmount"
|
||||
:is-cook-mode="isCookMode">
|
||||
|
||||
</RecipeIngredients>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-sheet>
|
||||
<v-btn
|
||||
v-if="isCookMode"
|
||||
fab
|
||||
small
|
||||
color="primary"
|
||||
style="position: fixed; right: 12px; top: 60px;"
|
||||
@click="toggleCookMode()"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -84,6 +145,7 @@ import {
|
||||
useRoute,
|
||||
} from "@nuxtjs/composition-api";
|
||||
import { invoke, until } from "@vueuse/core";
|
||||
import RecipeIngredients from "../RecipeIngredients.vue";
|
||||
import RecipePageEditorToolbar from "./RecipePageParts/RecipePageEditorToolbar.vue";
|
||||
import RecipePageFooter from "./RecipePageParts/RecipePageFooter.vue";
|
||||
import RecipePageHeader from "./RecipePageParts/RecipePageHeader.vue";
|
||||
@@ -92,7 +154,7 @@ import RecipePageIngredientToolsView from "./RecipePageParts/RecipePageIngredien
|
||||
import RecipePageInstructions from "./RecipePageParts/RecipePageInstructions.vue";
|
||||
import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue";
|
||||
import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
|
||||
import RecipePageTitleContent from "./RecipePageParts/RecipePageTitleContent.vue";
|
||||
import RecipePageInfoEditor from "./RecipePageParts/RecipePageInfoEditor.vue";
|
||||
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import RecipePrintContainer from "~/components/Domain/Recipe/RecipePrintContainer.vue";
|
||||
@@ -123,7 +185,7 @@ export default defineComponent({
|
||||
RecipePageHeader,
|
||||
RecipePrintContainer,
|
||||
RecipePageComments,
|
||||
RecipePageTitleContent,
|
||||
RecipePageInfoEditor,
|
||||
RecipePageEditorToolbar,
|
||||
RecipePageIngredientEditor,
|
||||
RecipePageOrganizers,
|
||||
@@ -133,6 +195,7 @@ export default defineComponent({
|
||||
RecipeNotes,
|
||||
RecipePageInstructions,
|
||||
RecipePageFooter,
|
||||
RecipeIngredients,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
@@ -151,6 +214,11 @@ export default defineComponent({
|
||||
const { pageMode, editMode, setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode } =
|
||||
usePageState(props.recipe.slug);
|
||||
const { deactivateNavigationWarning } = useNavigationWarning();
|
||||
const notLinkedIngredients = computed(() => {
|
||||
return props.recipe.recipeIngredient.filter((ingredient) => {
|
||||
return !props.recipe.recipeInstructions.some((step) => step.ingredientReferences?.map((ref) => ref.referenceId).includes(ingredient.referenceId));
|
||||
})
|
||||
})
|
||||
|
||||
/** =============================================================
|
||||
* Recipe Snapshot on Mount
|
||||
@@ -176,11 +244,14 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
deactivateNavigationWarning();
|
||||
toggleCookMode()
|
||||
|
||||
clearPageState(props.recipe.slug || "");
|
||||
console.debug("reset RecipePage state during unmount");
|
||||
});
|
||||
|
||||
const hasLinkedIngredients = computed(() => {
|
||||
return props.recipe.recipeInstructions.some((step) => step.ingredientReferences && step.ingredientReferences.length > 0);
|
||||
})
|
||||
/** =============================================================
|
||||
* Set State onMounted
|
||||
*/
|
||||
@@ -278,6 +349,8 @@ export default defineComponent({
|
||||
saveRecipe,
|
||||
deleteRecipe,
|
||||
addStep,
|
||||
hasLinkedIngredients,
|
||||
notLinkedIngredients
|
||||
};
|
||||
},
|
||||
head: {},
|
||||
|
||||
@@ -1,46 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex justify-end flex-wrap align-stretch">
|
||||
<v-card v-if="!landscape" width="50%" flat class="d-flex flex-column justify-center align-center">
|
||||
<v-card-text>
|
||||
<v-card-title class="headline pa-0 flex-column align-center">
|
||||
{{ recipe.name }}
|
||||
<RecipeRating :key="recipe.slug" :value="recipe.rating" :recipe-id="recipe.id" :slug="recipe.slug" />
|
||||
</v-card-title>
|
||||
<v-divider class="my-2"></v-divider>
|
||||
<SafeMarkdown :source="recipe.description" />
|
||||
<v-divider></v-divider>
|
||||
<div v-if="isOwnGroup" class="d-flex justify-center mt-5">
|
||||
<RecipeLastMade
|
||||
v-model="recipe.lastMade"
|
||||
:recipe="recipe"
|
||||
class="d-flex justify-center flex-wrap"
|
||||
:class="true ? undefined : 'force-bottom'"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex justify-center mt-5">
|
||||
<RecipeTimeCard
|
||||
class="d-flex justify-center flex-wrap"
|
||||
:class="true ? undefined : 'force-bottom'"
|
||||
:prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
:perform-time="recipe.performTime"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-img
|
||||
:key="imageKey"
|
||||
:max-width="landscape ? null : '50%'"
|
||||
min-height="50"
|
||||
:height="hideImage ? undefined : imageHeight"
|
||||
:src="recipeImageUrl"
|
||||
class="d-print-none"
|
||||
@error="hideImage = true"
|
||||
>
|
||||
</v-img>
|
||||
</div>
|
||||
<v-divider></v-divider>
|
||||
<RecipePageInfoCard :recipe="recipe" :recipe-scale="recipeScale" :landscape="landscape" />
|
||||
<v-divider />
|
||||
<RecipeActionMenu
|
||||
:recipe="recipe"
|
||||
:slug="recipe.slug"
|
||||
@@ -65,10 +26,8 @@
|
||||
import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useRecipePermissions } from "~/composables/recipes";
|
||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
||||
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
||||
import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue";
|
||||
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
|
||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
@@ -76,10 +35,8 @@ import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeTimeCard,
|
||||
RecipePageInfoCard,
|
||||
RecipeActionMenu,
|
||||
RecipeRating,
|
||||
RecipeLastMade,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex justify-end flex-wrap align-stretch">
|
||||
<RecipePageInfoCardImage v-if="landscape" :recipe="recipe" />
|
||||
<v-card
|
||||
:width="landscape ? '100%' : '50%'"
|
||||
flat
|
||||
class="d-flex flex-column justify-center align-center"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-card-title class="headline pa-0 flex-column align-center">
|
||||
{{ recipe.name }}
|
||||
<RecipeRating :key="recipe.slug" :value="recipe.rating" :recipe-id="recipe.id" :slug="recipe.slug" />
|
||||
</v-card-title>
|
||||
<v-divider class="my-2" />
|
||||
<SafeMarkdown :source="recipe.description" />
|
||||
<v-divider />
|
||||
<v-container class="d-flex flex-row flex-wrap justify-center align-center">
|
||||
<div class="mx-5">
|
||||
<v-row no-gutters class="mb-1">
|
||||
<v-col v-if="recipe.recipeYieldQuantity || recipe.recipeYield" cols="12" class="d-flex flex-wrap justify-center">
|
||||
<RecipeYield
|
||||
:yield-quantity="recipe.recipeYieldQuantity"
|
||||
:yield="recipe.recipeYield"
|
||||
:scale="recipeScale"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row no-gutters>
|
||||
<v-col cols="12" class="d-flex flex-wrap justify-center">
|
||||
<RecipeLastMade
|
||||
v-if="isOwnGroup"
|
||||
:value="recipe.lastMade"
|
||||
:recipe="recipe"
|
||||
:class="true ? undefined : 'force-bottom'"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
<div class="mx-5">
|
||||
<RecipeTimeCard
|
||||
stacked
|
||||
container-class="d-flex flex-wrap justify-center"
|
||||
:prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
:perform-time="recipe.performTime"
|
||||
/>
|
||||
</div>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<RecipePageInfoCardImage v-if="!landscape" :recipe="recipe" max-width="50%" class="my-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
||||
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||
import RecipeYield from "~/components/Domain/Recipe/RecipeYield.vue";
|
||||
import RecipePageInfoCardImage from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCardImage.vue";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeRating,
|
||||
RecipeLastMade,
|
||||
RecipeTimeCard,
|
||||
RecipeYield,
|
||||
RecipePageInfoCardImage,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
recipeScale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
landscape: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { $vuetify } = useContext();
|
||||
const useMobile = computed(() => $vuetify.breakpoint.smAndDown);
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
return {
|
||||
isOwnGroup,
|
||||
useMobile,
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<v-img
|
||||
:key="imageKey"
|
||||
:max-width="maxWidth"
|
||||
min-height="50"
|
||||
:height="hideImage ? undefined : imageHeight"
|
||||
:src="recipeImageUrl"
|
||||
class="d-print-none"
|
||||
@error="hideImage = true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
export default defineComponent({
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
maxWidth: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { $vuetify } = useContext();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey } = usePageState(props.recipe.slug);
|
||||
const { user } = usePageUser();
|
||||
|
||||
const recipeHousehold = ref<HouseholdSummary>();
|
||||
if (user) {
|
||||
const userApi = useUserApi();
|
||||
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
|
||||
recipeHousehold.value = data || undefined;
|
||||
});
|
||||
}
|
||||
|
||||
const hideImage = ref(false);
|
||||
const imageHeight = computed(() => {
|
||||
return $vuetify.breakpoint.xs ? "200" : "400";
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => recipeImageUrl.value,
|
||||
() => {
|
||||
hideImage.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
recipeImageUrl,
|
||||
imageKey,
|
||||
hideImage,
|
||||
imageHeight,
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-text-field
|
||||
v-model="recipe.name"
|
||||
class="my-3"
|
||||
:label="$t('recipe.recipe-name')"
|
||||
:rules="[validators.required]"
|
||||
/>
|
||||
<v-container class="ma-0 pa-0">
|
||||
<v-row>
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
v-model="recipeServings"
|
||||
type="number"
|
||||
:min="0"
|
||||
hide-spin-buttons
|
||||
dense
|
||||
:label="$t('recipe.servings')"
|
||||
@input="validateInput($event, 'recipeServings')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
v-model="recipeYieldQuantity"
|
||||
type="number"
|
||||
:min="0"
|
||||
hide-spin-buttons
|
||||
dense
|
||||
:label="$t('recipe.yield')"
|
||||
@input="validateInput($event, 'recipeYieldQuantity')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model="recipe.recipeYield"
|
||||
dense
|
||||
:label="$t('recipe.yield-text')"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<div class="d-flex flex-wrap" style="gap: 1rem">
|
||||
<v-text-field v-model="recipe.totalTime" :label="$t('recipe.total-time')" />
|
||||
<v-text-field v-model="recipe.prepTime" :label="$t('recipe.prep-time')" />
|
||||
<v-text-field v-model="recipe.performTime" :label="$t('recipe.perform-time')" />
|
||||
</div>
|
||||
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const recipeServings = computed<number>({
|
||||
get() {
|
||||
return props.recipe.recipeServings;
|
||||
},
|
||||
set(val) {
|
||||
validateInput(val.toString(), "recipeServings");
|
||||
},
|
||||
});
|
||||
|
||||
const recipeYieldQuantity = computed<number>({
|
||||
get() {
|
||||
return props.recipe.recipeYieldQuantity;
|
||||
},
|
||||
set(val) {
|
||||
validateInput(val.toString(), "recipeYieldQuantity");
|
||||
},
|
||||
});
|
||||
|
||||
function validateInput(value: string | null, property: "recipeServings" | "recipeYieldQuantity") {
|
||||
if (!value) {
|
||||
props.recipe[property] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const number = parseFloat(value.replace(/[^0-9.]/g, ""));
|
||||
if (isNaN(number) || number <= 0) {
|
||||
props.recipe[property] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
props.recipe[property] = number;
|
||||
}
|
||||
|
||||
return {
|
||||
validators,
|
||||
recipeServings,
|
||||
recipeYieldQuantity,
|
||||
validateInput,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -4,6 +4,7 @@
|
||||
:value="recipe.recipeIngredient"
|
||||
:scale="scale"
|
||||
:disable-amount="recipe.settings.disableAmount"
|
||||
:is-cook-mode="isCookMode"
|
||||
/>
|
||||
<div v-if="!isEditMode && recipe.tools && recipe.tools.length > 0">
|
||||
<h2 class="mb-2 mt-4">{{ $t('tool.required-tools') }}</h2>
|
||||
@@ -46,6 +47,10 @@ export default defineComponent({
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isCookMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
@@ -65,8 +65,8 @@
|
||||
</v-dialog>
|
||||
|
||||
<div class="d-flex justify-space-between justify-start">
|
||||
<h2 class="mb-4 mt-1">{{ $t("recipe.instructions") }}</h2>
|
||||
<BaseButton v-if="!isEditForm && showCookMode" minor cancel color="primary" @click="toggleCookMode()">
|
||||
<h2 v-if="!isCookMode" class="mb-4 mt-1">{{ $t("recipe.instructions") }}</h2>
|
||||
<BaseButton v-if="!isEditForm && !isCookMode" minor cancel color="primary" @click="toggleCookMode()">
|
||||
<template #icon>
|
||||
{{ $globals.icons.primary }}
|
||||
</template>
|
||||
@@ -243,16 +243,31 @@
|
||||
</DropZone>
|
||||
<v-expand-transition>
|
||||
<div v-show="!isChecked(index) && !isEditForm" class="m-0 p-0">
|
||||
|
||||
<v-card-text class="markdown">
|
||||
<SafeMarkdown class="markdown" :source="step.text" />
|
||||
<div v-if="isCookMode && step.ingredientReferences && step.ingredientReferences.length > 0">
|
||||
<v-divider class="mb-2"></v-divider>
|
||||
<RecipeIngredientHtml
|
||||
v-for="ing in step.ingredientReferences"
|
||||
:key="ing.referenceId"
|
||||
:markup="getIngredientByRefId(ing.referenceId)"
|
||||
/>
|
||||
</div>
|
||||
<v-row>
|
||||
<v-col
|
||||
v-if="isCookMode && step.ingredientReferences && step.ingredientReferences.length > 0"
|
||||
cols="12"
|
||||
sm="5"
|
||||
>
|
||||
<div class="ml-n4">
|
||||
<RecipeIngredients
|
||||
:value="recipe.recipeIngredient.filter((ing) => {
|
||||
if(!step.ingredientReferences) return false
|
||||
return step.ingredientReferences.map((ref) => ref.referenceId).includes(ing.referenceId || '')
|
||||
})"
|
||||
:scale="scale"
|
||||
:disable-amount="recipe.settings.disableAmount"
|
||||
:is-cook-mode="isCookMode"
|
||||
/>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-divider v-if="isCookMode && step.ingredientReferences && step.ingredientReferences.length > 0 && $vuetify.breakpoint.smAndUp" vertical ></v-divider>
|
||||
<v-col>
|
||||
<SafeMarkdown class="markdown" :source="step.text" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
@@ -261,7 +276,7 @@
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</draggable>
|
||||
<v-divider class="mt-10 d-flex d-md-none"/>
|
||||
<v-divider v-if="!isCookMode" class="mt-10 d-flex d-md-none"/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -287,7 +302,7 @@ import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { useExtractIngredientReferences } from "~/composables/recipe-page/use-extract-ingredient-references";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import DropZone from "~/components/global/DropZone.vue";
|
||||
|
||||
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
|
||||
interface MergerHistory {
|
||||
target: number;
|
||||
source: number;
|
||||
@@ -300,6 +315,7 @@ export default defineComponent({
|
||||
draggable,
|
||||
RecipeIngredientHtml,
|
||||
DropZone,
|
||||
RecipeIngredients
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
|
||||
@@ -5,50 +5,32 @@
|
||||
<RecipeScaleEditButton
|
||||
v-model.number="scaleValue"
|
||||
v-bind="attrs"
|
||||
:recipe-yield="recipe.recipeYield"
|
||||
:scaled-yield="scaledYield"
|
||||
:basic-yield-num="basicYieldNum"
|
||||
:recipe-servings="recipeServings"
|
||||
:edit-scale="!recipe.settings.disableAmount && !isEditMode"
|
||||
v-on="on"
|
||||
/>
|
||||
</template>
|
||||
<span> {{ $t("recipe.edit-scale") }} </span>
|
||||
</v-tooltip>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<RecipeRating
|
||||
v-if="landscape && $vuetify.breakpoint.smAndUp"
|
||||
:key="recipe.slug"
|
||||
v-model="recipe.rating"
|
||||
:recipe-id="recipe.id"
|
||||
:slug="recipe.slug"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref } from "@nuxtjs/composition-api";
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
|
||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { useExtractRecipeYield, findMatch } from "~/composables/recipe-page/use-extract-recipe-yield";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeScaleEditButton,
|
||||
RecipeRating,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
landscape: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
@@ -57,6 +39,10 @@ export default defineComponent({
|
||||
setup(props, { emit }) {
|
||||
const { isEditMode } = usePageState(props.recipe.slug);
|
||||
|
||||
const recipeServings = computed<number>(() => {
|
||||
return props.recipe.recipeServings || props.recipe.recipeYieldQuantity || 1;
|
||||
});
|
||||
|
||||
const scaleValue = computed<number>({
|
||||
get() {
|
||||
return props.scale;
|
||||
@@ -66,17 +52,9 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
const scaledYield = computed(() => {
|
||||
return useExtractRecipeYield(props.recipe.recipeYield, scaleValue.value);
|
||||
});
|
||||
|
||||
const match = findMatch(props.recipe.recipeYield);
|
||||
const basicYieldNum = ref<number |null>(match ? match[1] : null);
|
||||
|
||||
return {
|
||||
recipeServings,
|
||||
scaleValue,
|
||||
scaledYield,
|
||||
basicYieldNum,
|
||||
isEditMode,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="!isEditMode && landscape">
|
||||
<v-card-title class="px-0 py-2 ma-0 headline">
|
||||
{{ recipe.name }}
|
||||
</v-card-title>
|
||||
<SafeMarkdown :source="recipe.description" />
|
||||
<div v-if="isOwnGroup" class="pb-2 d-flex justify-center flex-wrap">
|
||||
<RecipeLastMade
|
||||
v-model="recipe.lastMade"
|
||||
:recipe="recipe"
|
||||
class="d-flex justify-center flex-wrap"
|
||||
:class="true ? undefined : 'force-bottom'"
|
||||
/>
|
||||
</div>
|
||||
<div class="pb-2 d-flex justify-center flex-wrap">
|
||||
<RecipeTimeCard
|
||||
class="d-flex justify-center flex-wrap"
|
||||
:prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
:perform-time="recipe.performTime"
|
||||
/>
|
||||
<RecipeRating
|
||||
v-if="$vuetify.breakpoint.smAndDown"
|
||||
:key="recipe.slug"
|
||||
v-model="recipe.rating"
|
||||
:recipe-id="recipe.id"
|
||||
:slug="recipe.slug"
|
||||
/>
|
||||
</div>
|
||||
<v-divider></v-divider>
|
||||
</template>
|
||||
<template v-else-if="isEditMode">
|
||||
<v-text-field
|
||||
v-model="recipe.name"
|
||||
class="my-3"
|
||||
:label="$t('recipe.recipe-name')"
|
||||
:rules="[validators.required]"
|
||||
/>
|
||||
<v-text-field v-model="recipe.recipeYield" dense :label="$t('recipe.servings')" />
|
||||
<div class="d-flex flex-wrap" style="gap: 1rem">
|
||||
<v-text-field v-model="recipe.totalTime" :label="$t('recipe.total-time')" />
|
||||
<v-text-field v-model="recipe.prepTime" :label="$t('recipe.prep-time')" />
|
||||
<v-text-field v-model="recipe.performTime" :label="$t('recipe.perform-time')" />
|
||||
</div>
|
||||
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeRating,
|
||||
RecipeTimeCard,
|
||||
RecipeLastMade,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
landscape: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { user } = usePageUser();
|
||||
const { imageKey, isEditMode } = usePageState(props.recipe.slug);
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
return {
|
||||
user,
|
||||
imageKey,
|
||||
validators,
|
||||
isEditMode,
|
||||
isOwnGroup,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -18,7 +18,24 @@
|
||||
</v-icon>
|
||||
{{ recipe.name }}
|
||||
</v-card-title>
|
||||
<RecipeTimeCard :prep-time="recipe.prepTime" :total-time="recipe.totalTime" :perform-time="recipe.performTime" color="white" />
|
||||
<div v-if="recipeYield" class="d-flex justify-space-between align-center px-4 pb-2">
|
||||
<v-chip
|
||||
:small="$vuetify.breakpoint.smAndDown"
|
||||
label
|
||||
>
|
||||
<v-icon left>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-html="recipeYield"></span>
|
||||
</v-chip>
|
||||
</div>
|
||||
<RecipeTimeCard
|
||||
:prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
:perform-time="recipe.performTime"
|
||||
color="white"
|
||||
/>
|
||||
<v-card-text v-if="preferences.showDescription" class="px-0">
|
||||
<SafeMarkdown :source="recipe.description" />
|
||||
</v-card-text>
|
||||
@@ -30,9 +47,6 @@
|
||||
<!-- Ingredients -->
|
||||
<section>
|
||||
<v-card-title class="headline pl-0"> {{ $t("recipe.ingredients") }} </v-card-title>
|
||||
<div class="font-italic px-0 py-0">
|
||||
<SafeMarkdown :source="recipe.recipeYield" />
|
||||
</div>
|
||||
<div
|
||||
v-for="(ingredientSection, sectionIndex) in ingredientSections"
|
||||
:key="`ingredient-section-${sectionIndex}`"
|
||||
@@ -111,7 +125,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import DOMPurify from "dompurify";
|
||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||
import { useStaticRoutes } from "~/composables/api";
|
||||
import { Recipe, RecipeIngredient, RecipeStep} from "~/lib/api/types/recipe";
|
||||
@@ -119,6 +134,7 @@ import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
||||
import { parseIngredientText, useNutritionLabels } from "~/composables/recipes";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
|
||||
type IngredientSection = {
|
||||
@@ -151,13 +167,39 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const { i18n } = useContext();
|
||||
const preferences = useUserPrintPreferences();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey } = usePageState(props.recipe.slug);
|
||||
const {labels} = useNutritionLabels();
|
||||
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: ["strong", "sup"],
|
||||
});
|
||||
}
|
||||
|
||||
const servingsDisplay = computed(() => {
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale);
|
||||
return scaledAmountDisplay ? i18n.t("recipe.yields-amount-with-text", {
|
||||
amount: scaledAmountDisplay,
|
||||
text: props.recipe.recipeYield,
|
||||
}) as string : "";
|
||||
})
|
||||
|
||||
const yieldDisplay = computed(() => {
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeServings, props.scale);
|
||||
return scaledAmountDisplay ? i18n.t("recipe.serves-amount", { amount: scaledAmountDisplay }) as string : "";
|
||||
});
|
||||
|
||||
const recipeYield = computed(() => {
|
||||
if (servingsDisplay.value && yieldDisplay.value) {
|
||||
return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`);
|
||||
} else {
|
||||
return sanitizeHTML(yieldDisplay.value || servingsDisplay.value);
|
||||
}
|
||||
})
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
@@ -258,6 +300,7 @@ export default defineComponent({
|
||||
parseIngredientText,
|
||||
preferences,
|
||||
recipeImageUrl,
|
||||
recipeYield,
|
||||
ingredientSections,
|
||||
instructionSections,
|
||||
};
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="yieldDisplay">
|
||||
<div class="text-center d-flex align-center">
|
||||
<div>
|
||||
<v-menu v-model="menu" :disabled="!editScale" offset-y top nudge-top="6" :close-on-content-click="false">
|
||||
<v-menu v-model="menu" :disabled="!canEditScale" offset-y top nudge-top="6" :close-on-content-click="false">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-card class="pa-1 px-2" dark color="secondary darken-1" small v-bind="attrs" v-on="on">
|
||||
<span v-if="!recipeYield"> x {{ scale }} </span>
|
||||
<div v-else-if="!numberParsed && recipeYield">
|
||||
<span v-if="numerator === 1"> {{ recipeYield }} </span>
|
||||
<span v-else> {{ numerator }}x {{ scaledYield }} </span>
|
||||
</div>
|
||||
<span v-else> {{ scaledYield }} </span>
|
||||
<v-icon small class="mr-2">{{ $globals.icons.edit }}</v-icon>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-html="yieldDisplay"></span>
|
||||
|
||||
</v-card>
|
||||
</template>
|
||||
@@ -20,7 +17,7 @@
|
||||
</v-card-title>
|
||||
<v-card-text class="mt-n5">
|
||||
<div class="mt-4 d-flex align-center">
|
||||
<v-text-field v-model="numerator" type="number" :min="0" hide-spin-buttons />
|
||||
<v-text-field v-model="yieldQuantityEditorValue" type="number" :min="0" hide-spin-buttons @input="recalculateScale(yieldQuantityEditorValue)" />
|
||||
<v-tooltip right color="secondary darken-1">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn v-bind="attrs" icon class="mx-1" small v-on="on" @click="scale = 1">
|
||||
@@ -37,7 +34,7 @@
|
||||
</v-menu>
|
||||
</div>
|
||||
<BaseButtonGroup
|
||||
v-if="editScale"
|
||||
v-if="canEditScale"
|
||||
class="pl-2"
|
||||
:large="false"
|
||||
:buttons="[
|
||||
@@ -53,41 +50,36 @@
|
||||
event: 'increment',
|
||||
},
|
||||
]"
|
||||
@decrement="numerator--"
|
||||
@increment="numerator++"
|
||||
@decrement="recalculateScale(yieldQuantity - 1)"
|
||||
@increment="recalculateScale(yieldQuantity + 1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed, watch } from "@nuxtjs/composition-api";
|
||||
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
recipeYield: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
scaledYield: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
basicYieldNum: {
|
||||
value: {
|
||||
type: Number,
|
||||
default: null,
|
||||
required: true,
|
||||
},
|
||||
recipeServings: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
editScale: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const { i18n } = useContext();
|
||||
const menu = ref<boolean>(false);
|
||||
const canEditScale = computed(() => props.editScale && props.recipeServings > 0);
|
||||
|
||||
const scale = computed({
|
||||
get: () => props.value,
|
||||
@@ -97,24 +89,54 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
const numerator = ref<number>(props.basicYieldNum != null ? parseFloat(props.basicYieldNum.toFixed(3)) : 1);
|
||||
const denominator = props.basicYieldNum != null ? parseFloat(props.basicYieldNum.toFixed(32)) : 1;
|
||||
const numberParsed = !!props.basicYieldNum;
|
||||
function recalculateScale(newYield: number) {
|
||||
if (isNaN(newYield) || newYield <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
watch(() => numerator.value, () => {
|
||||
scale.value = parseFloat((numerator.value / denominator).toFixed(32));
|
||||
if (props.recipeServings <= 0) {
|
||||
scale.value = 1;
|
||||
} else {
|
||||
scale.value = newYield / props.recipeServings;
|
||||
}
|
||||
}
|
||||
|
||||
const recipeYieldAmount = computed(() => {
|
||||
return useScaledAmount(props.recipeServings, scale.value);
|
||||
});
|
||||
const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount);
|
||||
const yieldDisplay = computed(() => {
|
||||
return yieldQuantity.value ? i18n.t(
|
||||
"recipe.serves-amount", { amount: recipeYieldAmount.value.scaledAmountDisplay }
|
||||
) as string : "";
|
||||
});
|
||||
|
||||
// only update yield quantity when the menu opens, so we don't override the user's input
|
||||
const yieldQuantityEditorValue = ref(recipeYieldAmount.value.scaledAmount);
|
||||
watch(
|
||||
() => menu.value,
|
||||
() => {
|
||||
if (!menu.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
yieldQuantityEditorValue.value = recipeYieldAmount.value.scaledAmount;
|
||||
}
|
||||
)
|
||||
|
||||
const disableDecrement = computed(() => {
|
||||
return numerator.value <= 1;
|
||||
return recipeYieldAmount.value.scaledAmount <= 1;
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
menu,
|
||||
canEditScale,
|
||||
scale,
|
||||
numerator,
|
||||
recalculateScale,
|
||||
yieldDisplay,
|
||||
yieldQuantity,
|
||||
yieldQuantityEditorValue,
|
||||
disableDecrement,
|
||||
numberParsed,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-chip
|
||||
v-for="(time, index) in allTimes"
|
||||
:key="index"
|
||||
:small="$vuetify.breakpoint.smAndDown"
|
||||
label
|
||||
:color="color"
|
||||
class="ma-1"
|
||||
>
|
||||
<v-icon left>
|
||||
{{ $globals.icons.clockOutline }}
|
||||
</v-icon>
|
||||
{{ time.name }} |
|
||||
{{ time.value }}
|
||||
</v-chip>
|
||||
<div v-if="stacked">
|
||||
<v-container>
|
||||
<v-row v-for="(time, index) in allTimes" :key="`${index}-stacked`" no-gutters>
|
||||
<v-col cols="12" :class="containerClass">
|
||||
<v-chip
|
||||
:small="$vuetify.breakpoint.smAndDown"
|
||||
label
|
||||
:color="color"
|
||||
class="ma-1"
|
||||
>
|
||||
<v-icon left>
|
||||
{{ $globals.icons.clockOutline }}
|
||||
</v-icon>
|
||||
{{ time.name }} |
|
||||
{{ time.value }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</div>
|
||||
<div v-else>
|
||||
<v-container :class="containerClass">
|
||||
<v-chip
|
||||
v-for="(time, index) in allTimes"
|
||||
:key="index"
|
||||
:small="$vuetify.breakpoint.smAndDown"
|
||||
label
|
||||
:color="color"
|
||||
class="ma-1"
|
||||
>
|
||||
<v-icon left>
|
||||
{{ $globals.icons.clockOutline }}
|
||||
</v-icon>
|
||||
{{ time.name }} |
|
||||
{{ time.value }}
|
||||
</v-chip>
|
||||
</v-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -22,6 +44,10 @@ import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
stacked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
prepTime: {
|
||||
type: String,
|
||||
default: null,
|
||||
@@ -38,6 +64,10 @@ export default defineComponent({
|
||||
type: String,
|
||||
default: "accent custom-transparent"
|
||||
},
|
||||
containerClass: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { i18n } = useContext();
|
||||
|
||||
69
frontend/components/Domain/Recipe/RecipeYield.vue
Normal file
69
frontend/components/Domain/Recipe/RecipeYield.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div v-if="displayText" class="d-flex justify-space-between align-center">
|
||||
<v-chip
|
||||
:small="$vuetify.breakpoint.smAndDown"
|
||||
label
|
||||
:color="color"
|
||||
>
|
||||
<v-icon left>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-html="displayText"></span>
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
yieldQuantity: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
yield: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "accent custom-transparent"
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { i18n } = useContext();
|
||||
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: ["strong", "sup"],
|
||||
});
|
||||
}
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (!(props.yieldQuantity || props.yield)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale);
|
||||
|
||||
return i18n.t("recipe.yields-amount-with-text", {
|
||||
amount: scaledAmountDisplay,
|
||||
text: sanitizeHTML(props.yield),
|
||||
}) as string;
|
||||
});
|
||||
|
||||
return {
|
||||
displayText,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
215
frontend/components/Domain/User/UserInviteDialog.vue
Normal file
215
frontend/components/Domain/User/UserInviteDialog.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
v-model="inviteDialog"
|
||||
:title="$tc('profile.get-invite-link')"
|
||||
:icon="$globals.icons.accountPlusOutline"
|
||||
color="primary">
|
||||
<v-container>
|
||||
<v-form class="mt-5">
|
||||
<v-select
|
||||
v-if="groups && groups.length"
|
||||
v-model="selectedGroup"
|
||||
:items="groups"
|
||||
item-text="name"
|
||||
item-value="id"
|
||||
:return-object="false"
|
||||
filled
|
||||
:label="$tc('group.user-group')"
|
||||
:rules="[validators.required]" />
|
||||
<v-select
|
||||
v-if="households && households.length"
|
||||
v-model="selectedHousehold"
|
||||
:items="filteredHouseholds"
|
||||
item-text="name" item-value="id"
|
||||
:return-object="false" filled
|
||||
:label="$tc('household.user-household')"
|
||||
:rules="[validators.required]" />
|
||||
<v-row>
|
||||
<v-col cols="9">
|
||||
<v-text-field
|
||||
:label="$tc('profile.invite-link')"
|
||||
type="text" readonly filled
|
||||
:value="generatedSignupLink" />
|
||||
</v-col>
|
||||
<v-col cols="3" class="pl-1 mt-3">
|
||||
<AppButtonCopy
|
||||
:icon="false"
|
||||
color="info"
|
||||
:copy-text="generatedSignupLink"
|
||||
:disabled="generatedSignupLink" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-text-field
|
||||
v-model="sendTo"
|
||||
:label="$t('user.email')"
|
||||
:rules="[validators.email]"
|
||||
outlined
|
||||
@keydown.enter="sendInvite" />
|
||||
</v-form>
|
||||
</v-container>
|
||||
<template #custom-card-action>
|
||||
<BaseButton
|
||||
:disabled="!validEmail"
|
||||
:loading="loading"
|
||||
:icon="$globals.icons.email"
|
||||
@click="sendInvite">
|
||||
{{ $t("group.invite") }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext, ref, toRefs, reactive } from "@nuxtjs/composition-api";
|
||||
import { watchEffect } from "vue";
|
||||
import { useUserApi } from "@/composables/api";
|
||||
import BaseDialog from "~/components/global/BaseDialog.vue";
|
||||
import AppButtonCopy from "~/components/global/AppButtonCopy.vue";
|
||||
import BaseButton from "~/components/global/BaseButton.vue";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { GroupInDB } from "~/lib/api/types/user";
|
||||
import { HouseholdInDB } from "~/lib/api/types/household";
|
||||
import { useGroups } from "~/composables/use-groups";
|
||||
import { useAdminHouseholds } from "~/composables/use-households";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UserInviteDialog",
|
||||
components: {
|
||||
BaseDialog,
|
||||
AppButtonCopy,
|
||||
BaseButton,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const { $auth, i18n } = useContext();
|
||||
|
||||
const isAdmin = computed(() => $auth.user?.admin);
|
||||
const token = ref("");
|
||||
const selectedGroup = ref<string | null>(null);
|
||||
const selectedHousehold = ref<string | null>(null);
|
||||
const groups = ref<GroupInDB[]>([]);
|
||||
const households = ref<HouseholdInDB[]>([]);
|
||||
const api = useUserApi();
|
||||
|
||||
const fetchGroupsAndHouseholds = () => {
|
||||
if (isAdmin) {
|
||||
const groupsResponse = useGroups();
|
||||
const householdsResponse = useAdminHouseholds();
|
||||
watchEffect(() => {
|
||||
groups.value = groupsResponse.groups.value || [];
|
||||
households.value = householdsResponse.households.value || [];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const inviteDialog = computed<boolean>({
|
||||
get() {
|
||||
return props.value;
|
||||
},
|
||||
set(val) {
|
||||
context.emit("input", val);
|
||||
},
|
||||
});
|
||||
|
||||
async function getSignupLink(group: string | null = null, household: string | null = null) {
|
||||
const payload = (group && household) ? { uses: 1, group_id: group, household_id: household } : { uses: 1 };
|
||||
const { data } = await api.households.createInvitation(payload);
|
||||
if (data) {
|
||||
token.value = data.token;
|
||||
}
|
||||
}
|
||||
|
||||
const filteredHouseholds = computed(() => {
|
||||
if (!selectedGroup.value) return [];
|
||||
return households.value?.filter(household => household.groupId === selectedGroup.value);
|
||||
});
|
||||
|
||||
function constructLink(token: string) {
|
||||
return token ? `${window.location.origin}/register?token=${token}` : "";
|
||||
}
|
||||
|
||||
const generatedSignupLink = computed(() => {
|
||||
return constructLink(token.value);
|
||||
});
|
||||
|
||||
// =================================================
|
||||
// Email Invitation
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
sendTo: "",
|
||||
});
|
||||
|
||||
async function sendInvite() {
|
||||
state.loading = true;
|
||||
if (!token.value) {
|
||||
getSignupLink(selectedGroup.value, selectedHousehold.value);
|
||||
}
|
||||
const { data } = await api.email.sendInvitation({
|
||||
email: state.sendTo,
|
||||
token: token.value,
|
||||
});
|
||||
|
||||
if (data && data.success) {
|
||||
alert.success(i18n.tc("profile.email-sent"));
|
||||
} else {
|
||||
alert.error(i18n.tc("profile.error-sending-email"));
|
||||
}
|
||||
state.loading = false;
|
||||
inviteDialog.value = false;
|
||||
}
|
||||
|
||||
const validEmail = computed(() => {
|
||||
if (state.sendTo === "") {
|
||||
return false;
|
||||
}
|
||||
const valid = validators.email(state.sendTo);
|
||||
|
||||
// Explicit bool check because validators.email sometimes returns a string
|
||||
if (valid === true) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return {
|
||||
sendInvite,
|
||||
validators,
|
||||
validEmail,
|
||||
inviteDialog,
|
||||
getSignupLink,
|
||||
generatedSignupLink,
|
||||
selectedGroup,
|
||||
selectedHousehold,
|
||||
filteredHouseholds,
|
||||
groups,
|
||||
households,
|
||||
fetchGroupsAndHouseholds,
|
||||
...toRefs(state),
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: false,
|
||||
handler(val) {
|
||||
if (val && !this.isAdmin) {
|
||||
this.getSignupLink();
|
||||
}
|
||||
},
|
||||
},
|
||||
selectedHousehold(newVal) {
|
||||
if (newVal && this.selectedGroup) {
|
||||
this.getSignupLink(this.selectedGroup, this.selectedHousehold);
|
||||
}
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.fetchGroupsAndHouseholds();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -15,6 +15,7 @@
|
||||
:color="color"
|
||||
retain-focus-on-click
|
||||
:class="btnClass"
|
||||
:disabled="copyText !== '' ? false : true"
|
||||
@click="
|
||||
on.click;
|
||||
textToClipboard();
|
||||
|
||||
@@ -16,7 +16,7 @@ export default defineComponent({
|
||||
setup() {
|
||||
const { isSupported: wakeIsSupported, isActive, request, release } = useWakeLock();
|
||||
const wakeLock = computed({
|
||||
get: () => isActive,
|
||||
get: () => isActive.value,
|
||||
set: () => {
|
||||
if (isActive.value) {
|
||||
unlockScreen();
|
||||
@@ -27,13 +27,13 @@ export default defineComponent({
|
||||
});
|
||||
async function lockScreen() {
|
||||
if (wakeIsSupported) {
|
||||
console.log("Wake Lock Requested");
|
||||
console.debug("Wake Lock Requested");
|
||||
await request("screen");
|
||||
}
|
||||
}
|
||||
async function unlockScreen() {
|
||||
if (wakeIsSupported || isActive) {
|
||||
console.log("Wake Lock Released");
|
||||
console.debug("Wake Lock Released");
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { useExtractRecipeYield } from "./use-extract-recipe-yield";
|
||||
|
||||
describe("test use extract recipe yield", () => {
|
||||
test("when text empty return empty", () => {
|
||||
const result = useExtractRecipeYield(null, 1);
|
||||
expect(result).toStrictEqual("");
|
||||
});
|
||||
|
||||
test("when text matches nothing return text", () => {
|
||||
const val = "this won't match anything";
|
||||
const result = useExtractRecipeYield(val, 1);
|
||||
expect(result).toStrictEqual(val);
|
||||
|
||||
const resultScaled = useExtractRecipeYield(val, 5);
|
||||
expect(resultScaled).toStrictEqual(val);
|
||||
});
|
||||
|
||||
test("when text matches a mixed fraction, return a scaled fraction", () => {
|
||||
const val = "10 1/2 units";
|
||||
const result = useExtractRecipeYield(val, 1);
|
||||
expect(result).toStrictEqual(val);
|
||||
|
||||
const resultScaled = useExtractRecipeYield(val, 3);
|
||||
expect(resultScaled).toStrictEqual("31 1/2 units");
|
||||
|
||||
const resultScaledPartial = useExtractRecipeYield(val, 2.5);
|
||||
expect(resultScaledPartial).toStrictEqual("26 1/4 units");
|
||||
|
||||
const resultScaledInt = useExtractRecipeYield(val, 4);
|
||||
expect(resultScaledInt).toStrictEqual("42 units");
|
||||
});
|
||||
|
||||
test("when text matches a fraction, return a scaled fraction", () => {
|
||||
const val = "1/3 plates";
|
||||
const result = useExtractRecipeYield(val, 1);
|
||||
expect(result).toStrictEqual(val);
|
||||
|
||||
const resultScaled = useExtractRecipeYield(val, 2);
|
||||
expect(resultScaled).toStrictEqual("2/3 plates");
|
||||
|
||||
const resultScaledInt = useExtractRecipeYield(val, 3);
|
||||
expect(resultScaledInt).toStrictEqual("1 plates");
|
||||
|
||||
const resultScaledPartial = useExtractRecipeYield(val, 2.5);
|
||||
expect(resultScaledPartial).toStrictEqual("5/6 plates");
|
||||
|
||||
const resultScaledMixed = useExtractRecipeYield(val, 4);
|
||||
expect(resultScaledMixed).toStrictEqual("1 1/3 plates");
|
||||
});
|
||||
|
||||
test("when text matches a decimal, return a scaled, rounded decimal", () => {
|
||||
const val = "1.25 parts";
|
||||
const result = useExtractRecipeYield(val, 1);
|
||||
expect(result).toStrictEqual(val);
|
||||
|
||||
const resultScaled = useExtractRecipeYield(val, 2);
|
||||
expect(resultScaled).toStrictEqual("2.5 parts");
|
||||
|
||||
const resultScaledInt = useExtractRecipeYield(val, 4);
|
||||
expect(resultScaledInt).toStrictEqual("5 parts");
|
||||
|
||||
const resultScaledPartial = useExtractRecipeYield(val, 2.5);
|
||||
expect(resultScaledPartial).toStrictEqual("3.125 parts");
|
||||
|
||||
const roundedVal = "1.33333333333333333333 parts";
|
||||
const resultScaledRounded = useExtractRecipeYield(roundedVal, 2);
|
||||
expect(resultScaledRounded).toStrictEqual("2.667 parts");
|
||||
});
|
||||
|
||||
test("when text matches an int, return a scaled int", () => {
|
||||
const val = "5 bowls";
|
||||
const result = useExtractRecipeYield(val, 1);
|
||||
expect(result).toStrictEqual(val);
|
||||
|
||||
const resultScaled = useExtractRecipeYield(val, 2);
|
||||
expect(resultScaled).toStrictEqual("10 bowls");
|
||||
|
||||
const resultScaledPartial = useExtractRecipeYield(val, 2.5);
|
||||
expect(resultScaledPartial).toStrictEqual("12.5 bowls");
|
||||
|
||||
const resultScaledLarge = useExtractRecipeYield(val, 10);
|
||||
expect(resultScaledLarge).toStrictEqual("50 bowls");
|
||||
});
|
||||
|
||||
test("when text contains an invalid fraction, return the original string", () => {
|
||||
const valDivZero = "3/0 servings";
|
||||
const resultDivZero = useExtractRecipeYield(valDivZero, 3);
|
||||
expect(resultDivZero).toStrictEqual(valDivZero);
|
||||
|
||||
const valDivZeroMixed = "2 4/0 servings";
|
||||
const resultDivZeroMixed = useExtractRecipeYield(valDivZeroMixed, 6);
|
||||
expect(resultDivZeroMixed).toStrictEqual(valDivZeroMixed);
|
||||
});
|
||||
|
||||
test("when text contains a weird or small fraction, return the original string", () => {
|
||||
const valWeird = "2323231239087/134527431962272135 servings";
|
||||
const resultWeird = useExtractRecipeYield(valWeird, 5);
|
||||
expect(resultWeird).toStrictEqual(valWeird);
|
||||
|
||||
const valSmall = "1/20230225 lovable servings";
|
||||
const resultSmall = useExtractRecipeYield(valSmall, 12);
|
||||
expect(resultSmall).toStrictEqual(valSmall);
|
||||
});
|
||||
|
||||
test("when text contains multiple numbers, the first is parsed as the servings amount", () => {
|
||||
const val = "100 sets of 55 bowls";
|
||||
const result = useExtractRecipeYield(val, 3);
|
||||
expect(result).toStrictEqual("300 sets of 55 bowls");
|
||||
})
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useFraction } from "~/composables/recipes";
|
||||
|
||||
const matchMixedFraction = /(?:\d*\s\d*\d*|0)\/\d*\d*/;
|
||||
const matchFraction = /(?:\d*\d*|0)\/\d*\d*/;
|
||||
const matchDecimal = /(\d+.\d+)|(.\d+)/;
|
||||
const matchInt = /\d+/;
|
||||
|
||||
|
||||
|
||||
function extractServingsFromMixedFraction(fractionString: string): number | undefined {
|
||||
const mixedSplit = fractionString.split(/\s/);
|
||||
const wholeNumber = parseInt(mixedSplit[0]);
|
||||
const fraction = mixedSplit[1];
|
||||
|
||||
const fractionSplit = fraction.split("/");
|
||||
const numerator = parseInt(fractionSplit[0]);
|
||||
const denominator = parseInt(fractionSplit[1]);
|
||||
|
||||
if (denominator === 0) {
|
||||
return undefined; // if the denominator is zero, just give up
|
||||
}
|
||||
else {
|
||||
return wholeNumber + (numerator / denominator);
|
||||
}
|
||||
}
|
||||
|
||||
function extractServingsFromFraction(fractionString: string): number | undefined {
|
||||
const fractionSplit = fractionString.split("/");
|
||||
const numerator = parseInt(fractionSplit[0]);
|
||||
const denominator = parseInt(fractionSplit[1]);
|
||||
|
||||
if (denominator === 0) {
|
||||
return undefined; // if the denominator is zero, just give up
|
||||
}
|
||||
else {
|
||||
return numerator / denominator;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function findMatch(yieldString: string): [matchString: string, servings: number, isFraction: boolean] | null {
|
||||
if (!yieldString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mixedFractionMatch = yieldString.match(matchMixedFraction);
|
||||
if (mixedFractionMatch?.length) {
|
||||
const match = mixedFractionMatch[0];
|
||||
const servings = extractServingsFromMixedFraction(match);
|
||||
|
||||
// if the denominator is zero, return no match
|
||||
if (servings === undefined) {
|
||||
return null;
|
||||
} else {
|
||||
return [match, servings, true];
|
||||
}
|
||||
}
|
||||
|
||||
const fractionMatch = yieldString.match(matchFraction);
|
||||
if (fractionMatch?.length) {
|
||||
const match = fractionMatch[0]
|
||||
const servings = extractServingsFromFraction(match);
|
||||
|
||||
// if the denominator is zero, return no match
|
||||
if (servings === undefined) {
|
||||
return null;
|
||||
} else {
|
||||
return [match, servings, true];
|
||||
}
|
||||
}
|
||||
|
||||
const decimalMatch = yieldString.match(matchDecimal);
|
||||
if (decimalMatch?.length) {
|
||||
const match = decimalMatch[0];
|
||||
return [match, parseFloat(match), false];
|
||||
}
|
||||
|
||||
const intMatch = yieldString.match(matchInt);
|
||||
if (intMatch?.length) {
|
||||
const match = intMatch[0];
|
||||
return [match, parseInt(match), false];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatServings(servings: number, scale: number, isFraction: boolean): string {
|
||||
const val = servings * scale;
|
||||
if (Number.isInteger(val)) {
|
||||
return val.toString();
|
||||
} else if (!isFraction) {
|
||||
return (Math.round(val * 1000) / 1000).toString();
|
||||
}
|
||||
|
||||
// convert val into a fraction string
|
||||
const { frac } = useFraction();
|
||||
|
||||
let valString = "";
|
||||
const fraction = frac(val, 10, true);
|
||||
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
valString += fraction[0];
|
||||
}
|
||||
|
||||
if (fraction[1] > 0) {
|
||||
valString += ` ${fraction[1]}/${fraction[2]}`;
|
||||
}
|
||||
|
||||
return valString.trim();
|
||||
}
|
||||
|
||||
|
||||
export function useExtractRecipeYield(yieldString: string | null, scale: number): string {
|
||||
if (!yieldString) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const match = findMatch(yieldString);
|
||||
if (!match) {
|
||||
return yieldString;
|
||||
}
|
||||
|
||||
const [matchString, servings, isFraction] = match;
|
||||
|
||||
const formattedServings = formatServings(servings, scale, isFraction);
|
||||
if (!formattedServings) {
|
||||
return yieldString // this only happens with very weird or small fractions
|
||||
} else {
|
||||
return yieldString.replace(matchString, formatServings(servings, scale, isFraction));
|
||||
}
|
||||
}
|
||||
68
frontend/composables/recipes/use-scaled-amount.test.ts
Normal file
68
frontend/composables/recipes/use-scaled-amount.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { useScaledAmount } from "./use-scaled-amount";
|
||||
|
||||
describe("test use recipe yield", () => {
|
||||
function asFrac(numerator: number, denominator: number): string {
|
||||
return `<sup>${numerator}</sup><span>⁄</span><sub>${denominator}</sub>`;
|
||||
}
|
||||
|
||||
test("base case", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(3);
|
||||
expect(scaledAmount).toStrictEqual(3);
|
||||
expect(scaledAmountDisplay).toStrictEqual("3");
|
||||
});
|
||||
|
||||
test("base case scaled", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(3, 2);
|
||||
expect(scaledAmount).toStrictEqual(6);
|
||||
expect(scaledAmountDisplay).toStrictEqual("6");
|
||||
});
|
||||
|
||||
test("zero scale", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(3, 0);
|
||||
expect(scaledAmount).toStrictEqual(0);
|
||||
expect(scaledAmountDisplay).toStrictEqual("");
|
||||
});
|
||||
|
||||
test("zero quantity", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(0);
|
||||
expect(scaledAmount).toStrictEqual(0);
|
||||
expect(scaledAmountDisplay).toStrictEqual("");
|
||||
});
|
||||
|
||||
test("basic fraction", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(0.5);
|
||||
expect(scaledAmount).toStrictEqual(0.5);
|
||||
expect(scaledAmountDisplay).toStrictEqual(asFrac(1, 2));
|
||||
});
|
||||
|
||||
test("mixed fraction", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1.5);
|
||||
expect(scaledAmount).toStrictEqual(1.5);
|
||||
expect(scaledAmountDisplay).toStrictEqual(`1${asFrac(1, 2)}`);
|
||||
});
|
||||
|
||||
test("mixed fraction scaled", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1.5, 9);
|
||||
expect(scaledAmount).toStrictEqual(13.5);
|
||||
expect(scaledAmountDisplay).toStrictEqual(`13${asFrac(1, 2)}`);
|
||||
});
|
||||
|
||||
test("small scale", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1, 0.125);
|
||||
expect(scaledAmount).toStrictEqual(0.125);
|
||||
expect(scaledAmountDisplay).toStrictEqual(asFrac(1, 8));
|
||||
});
|
||||
|
||||
test("small qty", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(0.125);
|
||||
expect(scaledAmount).toStrictEqual(0.125);
|
||||
expect(scaledAmountDisplay).toStrictEqual(asFrac(1, 8));
|
||||
});
|
||||
|
||||
test("rounded decimal", () => {
|
||||
const { scaledAmount, scaledAmountDisplay } = useScaledAmount(1.3344559997);
|
||||
expect(scaledAmount).toStrictEqual(1.334);
|
||||
expect(scaledAmountDisplay).toStrictEqual(`1${asFrac(1, 3)}`);
|
||||
});
|
||||
});
|
||||
32
frontend/composables/recipes/use-scaled-amount.ts
Normal file
32
frontend/composables/recipes/use-scaled-amount.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useFraction } from "~/composables/recipes";
|
||||
|
||||
function formatQuantity(val: number): string {
|
||||
if (Number.isInteger(val)) {
|
||||
return val.toString();
|
||||
}
|
||||
|
||||
const { frac } = useFraction();
|
||||
|
||||
let valString = "";
|
||||
const fraction = frac(val, 10, true);
|
||||
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
valString += fraction[0];
|
||||
}
|
||||
|
||||
if (fraction[1] > 0) {
|
||||
valString += `<sup>${fraction[1]}</sup><span>⁄</span><sub>${fraction[2]}</sub>`;
|
||||
}
|
||||
|
||||
return valString.trim();
|
||||
}
|
||||
|
||||
export function useScaledAmount(amount: number, scale = 1) {
|
||||
const scaledAmount = Number(((amount || 0) * scale).toFixed(3));
|
||||
const scaledAmountDisplay = scaledAmount ? formatQuantity(scaledAmount) : "";
|
||||
|
||||
return {
|
||||
scaledAmount,
|
||||
scaledAmountDisplay,
|
||||
};
|
||||
}
|
||||
@@ -517,6 +517,7 @@
|
||||
"save-recipe-before-use": "Save recipe before use",
|
||||
"section-title": "Section Title",
|
||||
"servings": "Servings",
|
||||
"serves-amount": "Serves {amount}",
|
||||
"share-recipe-message": "I wanted to share my {0} recipe with you.",
|
||||
"show-nutrition-values": "Show Nutrition Values",
|
||||
"sodium-content": "Sodium",
|
||||
@@ -545,6 +546,8 @@
|
||||
"failed-to-add-recipe-to-mealplan": "Failed to add recipe to mealplan",
|
||||
"failed-to-add-to-list": "Failed to add to list",
|
||||
"yield": "Yield",
|
||||
"yields-amount-with-text": "Yields {amount} {text}",
|
||||
"yield-text": "Yield Text",
|
||||
"quantity": "Quantity",
|
||||
"choose-unit": "Choose Unit",
|
||||
"press-enter-to-create": "Press Enter to Create",
|
||||
@@ -640,7 +643,9 @@
|
||||
"recipe-debugger-use-openai-description": "Use OpenAI to parse the results instead of relying on the scraper library. When creating a recipe via URL, this is done automatically if the scraper library fails, but you may test it manually here.",
|
||||
"debug": "Debug",
|
||||
"tree-view": "Tree View",
|
||||
"recipe-servings": "Recipe Servings",
|
||||
"recipe-yield": "Recipe Yield",
|
||||
"recipe-yield-text": "Recipe Yield Text",
|
||||
"unit": "Unit",
|
||||
"upload-image": "Upload image",
|
||||
"screen-awake": "Keep Screen Awake",
|
||||
@@ -662,7 +667,8 @@
|
||||
"missing-food": "Create missing food: {food}",
|
||||
"no-food": "No Food"
|
||||
},
|
||||
"reset-servings-count": "Reset Servings Count"
|
||||
"reset-servings-count": "Reset Servings Count",
|
||||
"not-linked-ingredients": "Additional Ingredients"
|
||||
},
|
||||
"search": {
|
||||
"advanced-search": "Advanced Search",
|
||||
@@ -1278,6 +1284,7 @@
|
||||
"profile": {
|
||||
"welcome-user": "👋 Welcome, {0}!",
|
||||
"description": "Manage your profile, recipes, and group settings.",
|
||||
"invite-link": "Invite Link",
|
||||
"get-invite-link": "Get Invite Link",
|
||||
"get-public-link": "Get Public Link",
|
||||
"account-summary": "Account Summary",
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface RecipeSummary {
|
||||
name?: string | null;
|
||||
slug?: string;
|
||||
image?: unknown;
|
||||
recipeServings?: number;
|
||||
recipeYieldQuantity?: number;
|
||||
recipeYield?: string | null;
|
||||
totalTime?: string | null;
|
||||
prepTime?: string | null;
|
||||
@@ -160,6 +162,7 @@ export interface RecipeTool {
|
||||
name: string;
|
||||
slug: string;
|
||||
onHand?: boolean;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
export interface CustomPageImport {
|
||||
name: string;
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface CreateCookBook {
|
||||
slug?: string | null;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
queryFilterString: string;
|
||||
queryFilterString?: string;
|
||||
}
|
||||
export interface ReadCookBook {
|
||||
name: string;
|
||||
@@ -23,11 +23,11 @@ export interface ReadCookBook {
|
||||
slug?: string | null;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
queryFilterString: string;
|
||||
queryFilterString?: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
id: string;
|
||||
queryFilter: QueryFilterJSON;
|
||||
queryFilter?: QueryFilterJSON;
|
||||
}
|
||||
export interface QueryFilterJSON {
|
||||
parts?: QueryFilterJSONPart[];
|
||||
@@ -47,11 +47,11 @@ export interface RecipeCookBook {
|
||||
slug?: string | null;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
queryFilterString: string;
|
||||
queryFilterString?: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
id: string;
|
||||
queryFilter: QueryFilterJSON;
|
||||
queryFilter?: QueryFilterJSON;
|
||||
recipes: RecipeSummary[];
|
||||
}
|
||||
export interface RecipeSummary {
|
||||
@@ -62,6 +62,8 @@ export interface RecipeSummary {
|
||||
name?: string | null;
|
||||
slug?: string;
|
||||
image?: unknown;
|
||||
recipeServings?: number;
|
||||
recipeYieldQuantity?: number;
|
||||
recipeYield?: string | null;
|
||||
totalTime?: string | null;
|
||||
prepTime?: string | null;
|
||||
@@ -104,7 +106,7 @@ export interface SaveCookBook {
|
||||
slug?: string | null;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
queryFilterString: string;
|
||||
queryFilterString?: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
}
|
||||
@@ -114,7 +116,7 @@ export interface UpdateCookBook {
|
||||
slug?: string | null;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
queryFilterString: string;
|
||||
queryFilterString?: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
id: string;
|
||||
|
||||
@@ -26,12 +26,14 @@ export interface CreateHouseholdPreferences {
|
||||
}
|
||||
export interface CreateInviteToken {
|
||||
uses: number;
|
||||
groupId?: string | null;
|
||||
householdId?: string | null;
|
||||
}
|
||||
export interface CreateWebhook {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
webhookType?: WebhookType & string;
|
||||
webhookType?: WebhookType;
|
||||
scheduledTime: string;
|
||||
}
|
||||
export interface EmailInitationResponse {
|
||||
@@ -46,10 +48,6 @@ export interface GroupEventNotifierCreate {
|
||||
name: string;
|
||||
appriseUrl?: string | null;
|
||||
}
|
||||
/**
|
||||
* These events are in-sync with the EventTypes found in the EventBusService.
|
||||
* If you modify this, make sure to update the EventBusService as well.
|
||||
*/
|
||||
export interface GroupEventNotifierOptions {
|
||||
testMessage?: boolean;
|
||||
webhookTask?: boolean;
|
||||
@@ -204,7 +202,7 @@ export interface ReadWebhook {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
webhookType?: WebhookType & string;
|
||||
webhookType?: WebhookType;
|
||||
scheduledTime: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
@@ -263,7 +261,7 @@ export interface SaveWebhook {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
webhookType?: WebhookType & string;
|
||||
webhookType?: WebhookType;
|
||||
scheduledTime: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
@@ -486,9 +484,6 @@ export interface ShoppingListItemUpdate {
|
||||
} | null;
|
||||
recipeReferences?: (ShoppingListItemRecipeRefCreate | ShoppingListItemRecipeRefUpdate)[];
|
||||
}
|
||||
/**
|
||||
* Only used for bulk update operations where the shopping list item id isn't already supplied
|
||||
*/
|
||||
export interface ShoppingListItemUpdateBulk {
|
||||
quantity?: number;
|
||||
unit?: IngredientUnit | CreateIngredientUnit | null;
|
||||
@@ -509,9 +504,6 @@ export interface ShoppingListItemUpdateBulk {
|
||||
recipeReferences?: (ShoppingListItemRecipeRefCreate | ShoppingListItemRecipeRefUpdate)[];
|
||||
id: string;
|
||||
}
|
||||
/**
|
||||
* Container for bulk shopping list item changes
|
||||
*/
|
||||
export interface ShoppingListItemsCollectionOut {
|
||||
createdItems?: ShoppingListItemOut[];
|
||||
updatedItems?: ShoppingListItemOut[];
|
||||
@@ -565,6 +557,8 @@ export interface RecipeSummary {
|
||||
name?: string | null;
|
||||
slug?: string;
|
||||
image?: unknown;
|
||||
recipeServings?: number;
|
||||
recipeYieldQuantity?: number;
|
||||
recipeYield?: string | null;
|
||||
totalTime?: string | null;
|
||||
prepTime?: string | null;
|
||||
@@ -599,6 +593,7 @@ export interface RecipeTool {
|
||||
name: string;
|
||||
slug: string;
|
||||
onHand?: boolean;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
export interface ShoppingListRemoveRecipeParams {
|
||||
recipeDecrementQuantity?: number;
|
||||
|
||||
@@ -12,21 +12,16 @@ export type LogicalOperator = "AND" | "OR";
|
||||
export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE";
|
||||
export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<=";
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
export interface CreatePlanEntry {
|
||||
date: string;
|
||||
entryType?: PlanEntryType & string;
|
||||
entryType?: PlanEntryType;
|
||||
title?: string;
|
||||
text?: string;
|
||||
recipeId?: string | null;
|
||||
}
|
||||
export interface CreateRandomEntry {
|
||||
date: string;
|
||||
entryType?: PlanEntryType & string;
|
||||
entryType?: PlanEntryType;
|
||||
}
|
||||
export interface ListItem {
|
||||
title?: string | null;
|
||||
@@ -35,18 +30,18 @@ export interface ListItem {
|
||||
checked?: boolean;
|
||||
}
|
||||
export interface PlanRulesCreate {
|
||||
day?: PlanRulesDay & string;
|
||||
entryType?: PlanRulesType & string;
|
||||
queryFilterString: string;
|
||||
day?: PlanRulesDay;
|
||||
entryType?: PlanRulesType;
|
||||
queryFilterString?: string;
|
||||
}
|
||||
export interface PlanRulesOut {
|
||||
day?: PlanRulesDay & string;
|
||||
entryType?: PlanRulesType & string;
|
||||
queryFilterString: string;
|
||||
day?: PlanRulesDay;
|
||||
entryType?: PlanRulesType;
|
||||
queryFilterString?: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
id: string;
|
||||
queryFilter: QueryFilterJSON;
|
||||
queryFilter?: QueryFilterJSON;
|
||||
}
|
||||
export interface QueryFilterJSON {
|
||||
parts?: QueryFilterJSONPart[];
|
||||
@@ -61,21 +56,21 @@ export interface QueryFilterJSONPart {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
export interface PlanRulesSave {
|
||||
day?: PlanRulesDay & string;
|
||||
entryType?: PlanRulesType & string;
|
||||
queryFilterString: string;
|
||||
day?: PlanRulesDay;
|
||||
entryType?: PlanRulesType;
|
||||
queryFilterString?: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
}
|
||||
export interface ReadPlanEntry {
|
||||
date: string;
|
||||
entryType?: PlanEntryType & string;
|
||||
entryType?: PlanEntryType;
|
||||
title?: string;
|
||||
text?: string;
|
||||
recipeId?: string | null;
|
||||
id: number;
|
||||
groupId: string;
|
||||
userId?: string | null;
|
||||
userId: string;
|
||||
householdId: string;
|
||||
recipe?: RecipeSummary | null;
|
||||
}
|
||||
@@ -87,6 +82,8 @@ export interface RecipeSummary {
|
||||
name?: string | null;
|
||||
slug?: string;
|
||||
image?: unknown;
|
||||
recipeServings?: number;
|
||||
recipeYieldQuantity?: number;
|
||||
recipeYield?: string | null;
|
||||
totalTime?: string | null;
|
||||
prepTime?: string | null;
|
||||
@@ -125,12 +122,12 @@ export interface RecipeTool {
|
||||
}
|
||||
export interface SavePlanEntry {
|
||||
date: string;
|
||||
entryType?: PlanEntryType & string;
|
||||
entryType?: PlanEntryType;
|
||||
title?: string;
|
||||
text?: string;
|
||||
recipeId?: string | null;
|
||||
groupId: string;
|
||||
userId?: string | null;
|
||||
userId: string;
|
||||
}
|
||||
export interface ShoppingListIn {
|
||||
name: string;
|
||||
@@ -145,11 +142,11 @@ export interface ShoppingListOut {
|
||||
}
|
||||
export interface UpdatePlanEntry {
|
||||
date: string;
|
||||
entryType?: PlanEntryType & string;
|
||||
entryType?: PlanEntryType;
|
||||
title?: string;
|
||||
text?: string;
|
||||
recipeId?: string | null;
|
||||
id: number;
|
||||
groupId: string;
|
||||
userId?: string | null;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
@@ -6,215 +6,37 @@
|
||||
*/
|
||||
|
||||
export interface OpenAIIngredient {
|
||||
/**
|
||||
*
|
||||
* The input is simply the ingredient string you are processing as-is. It is forbidden to
|
||||
* modify this at all, you must provide the input exactly as you received it.
|
||||
*
|
||||
*/
|
||||
input: string;
|
||||
/**
|
||||
*
|
||||
* This value is a float between 0 - 100, where 100 is full confidence that the result is correct,
|
||||
* and 0 is no confidence that the result is correct. If you're unable to parse anything,
|
||||
* and you put the entire string in the notes, you should return 0 confidence. If you can easily
|
||||
* parse the string into each component, then you should return a confidence of 100. If you have to
|
||||
* guess which part is the unit and which part is the food, your confidence should be lower, such as 60.
|
||||
* Even if there is no unit or note, if you're able to determine the food, you may use a higher confidence.
|
||||
* If the entire ingredient consists of only a food, you can use a confidence of 100.
|
||||
*
|
||||
*/
|
||||
confidence?: number | null;
|
||||
/**
|
||||
*
|
||||
* The numerical representation of how much of this ingredient. For instance, if you receive
|
||||
* "3 1/2 grams of minced garlic", the quantity is "3 1/2". Quantity may be represented as a whole number
|
||||
* (integer), a float or decimal, or a fraction. You should output quantity in only whole numbers or
|
||||
* floats, converting fractions into floats. Floats longer than 10 decimal places should be
|
||||
* rounded to 10 decimal places.
|
||||
*
|
||||
*/
|
||||
quantity?: number | null;
|
||||
/**
|
||||
*
|
||||
* The unit of measurement for this ingredient. For instance, if you receive
|
||||
* "2 lbs chicken breast", the unit is "lbs" (short for "pounds").
|
||||
*
|
||||
*/
|
||||
unit?: string | null;
|
||||
/**
|
||||
*
|
||||
* The actual physical ingredient used in the recipe. For instance, if you receive
|
||||
* "3 cups of onions, chopped", the food is "onions".
|
||||
*
|
||||
*/
|
||||
food?: string | null;
|
||||
/**
|
||||
*
|
||||
* The rest of the text that represents more detail on how to prepare the ingredient.
|
||||
* Anything that is not one of the above should be the note. For instance, if you receive
|
||||
* "one can of butter beans, drained" the note would be "drained". If you receive
|
||||
* "3 cloves of garlic peeled and finely chopped", the note would be "peeled and finely chopped".
|
||||
*
|
||||
*/
|
||||
note?: string | null;
|
||||
}
|
||||
export interface OpenAIIngredients {
|
||||
ingredients?: OpenAIIngredient[];
|
||||
}
|
||||
export interface OpenAIRecipe {
|
||||
/**
|
||||
*
|
||||
* The name or title of the recipe. If you're unable to determine the name of the recipe, you should
|
||||
* make your best guess based upon the ingredients and instructions provided.
|
||||
*
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
*
|
||||
* A long description of the recipe. This should be a string that describes the recipe in a few words
|
||||
* or sentences. If the recipe doesn't have a description, you should return None.
|
||||
*
|
||||
*/
|
||||
description: string | null;
|
||||
/**
|
||||
*
|
||||
* The yield of the recipe. For instance, if the recipe makes 12 cookies, the yield is "12 cookies".
|
||||
* If the recipe makes 2 servings, the yield is "2 servings". Typically yield consists of a number followed
|
||||
* by the word "serving" or "servings", but it can be any string that describes the yield. If the yield
|
||||
* isn't specified, you should return None.
|
||||
*
|
||||
*/
|
||||
recipe_yield?: string | null;
|
||||
/**
|
||||
*
|
||||
* The total time it takes to make the recipe. This should be a string that describes a duration of time,
|
||||
* such as "1 hour and 30 minutes", "90 minutes", or "1.5 hours". If the recipe has multiple times, choose
|
||||
* the longest time. If the recipe doesn't specify a total time or duration, or it specifies a prep time or
|
||||
* perform time but not a total time, you should return None. Do not duplicate times between total time, prep
|
||||
* time and perform time.
|
||||
*
|
||||
*/
|
||||
total_time?: string | null;
|
||||
/**
|
||||
*
|
||||
* The time it takes to prepare the recipe. This should be a string that describes a duration of time,
|
||||
* such as "30 minutes", "1 hour", or "1.5 hours". If the recipe has a total time, the prep time should be
|
||||
* less than the total time. If the recipe doesn't specify a prep time, you should return None. If the recipe
|
||||
* supplies only one time, it should be the total time. Do not duplicate times between total time, prep
|
||||
* time and coperformok time.
|
||||
*
|
||||
*/
|
||||
prep_time?: string | null;
|
||||
/**
|
||||
*
|
||||
* The time it takes to cook the recipe. This should be a string that describes a duration of time,
|
||||
* such as "30 minutes", "1 hour", or "1.5 hours". If the recipe has a total time, the perform time should be
|
||||
* less than the total time. If the recipe doesn't specify a perform time, you should return None. If the
|
||||
* recipe specifies a cook time, active time, or other time besides total or prep, you should use that
|
||||
* time as the perform time. If the recipe supplies only one time, it should be the total time, and not the
|
||||
* perform time. Do not duplicate times between total time, prep time and perform time.
|
||||
*
|
||||
*/
|
||||
perform_time?: string | null;
|
||||
/**
|
||||
*
|
||||
* A list of ingredients used in the recipe. Ingredients should be inserted in the order they appear in the
|
||||
* recipe. If the recipe has no ingredients, you should return an empty list.
|
||||
*
|
||||
* Often times, but not always, ingredients are separated by line breaks. Use these as a guide to
|
||||
* separate ingredients.
|
||||
*
|
||||
*/
|
||||
ingredients?: OpenAIRecipeIngredient[];
|
||||
/**
|
||||
*
|
||||
* A list of ingredients used in the recipe. Ingredients should be inserted in the order they appear in the
|
||||
* recipe. If the recipe has no ingredients, you should return an empty list.
|
||||
*
|
||||
* Often times, but not always, instructions are separated by line breaks and/or separated by paragraphs.
|
||||
* Use these as a guide to separate instructions. They also may be separated by numbers or words, such as
|
||||
* "1.", "2.", "Step 1", "Step 2", "First", "Second", etc.
|
||||
*
|
||||
*/
|
||||
instructions?: OpenAIRecipeInstruction[];
|
||||
/**
|
||||
*
|
||||
* A list of notes found in the recipe. Notes should be inserted in the order they appear in the recipe.
|
||||
* They may appear anywhere on the recipe, though they are typically found under the instructions.
|
||||
*
|
||||
*/
|
||||
notes?: OpenAIRecipeNotes[];
|
||||
}
|
||||
export interface OpenAIRecipeIngredient {
|
||||
/**
|
||||
*
|
||||
* The title of the section of the recipe that the ingredient is found in. Recipes may not specify
|
||||
* ingredient sections, in which case this should be left blank.
|
||||
* Only the first item in the section should have this set,
|
||||
* whereas subsuquent items should have their titles left blank (unless they start a new section).
|
||||
*
|
||||
*/
|
||||
title?: string | null;
|
||||
/**
|
||||
*
|
||||
* The text of the ingredient. This should represent the entire ingredient, such as "1 cup of flour" or
|
||||
* "2 cups of onions, chopped". If the ingredient is completely blank, skip it and do not add the ingredient,
|
||||
* since this field is required.
|
||||
*
|
||||
* If the ingredient has no text, but has a title, include the title on the
|
||||
* next ingredient instead.
|
||||
*
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
export interface OpenAIRecipeInstruction {
|
||||
/**
|
||||
*
|
||||
* The title of the section of the recipe that the instruction is found in. Recipes may not specify
|
||||
* instruction sections, in which case this should be left blank.
|
||||
* Only the first instruction in the section should have this set,
|
||||
* whereas subsuquent instructions should have their titles left blank (unless they start a new section).
|
||||
*
|
||||
*/
|
||||
title?: string | null;
|
||||
/**
|
||||
*
|
||||
* The text of the instruction. This represents one step in the recipe, such as "Preheat the oven to 350",
|
||||
* or "Sauté the onions for 20 minutes". Sometimes steps can be longer, such as "Bring a large pot of lightly
|
||||
* salted water to a boil. Add ditalini pasta and cook for 8 minutes or until al dente; drain.".
|
||||
*
|
||||
* Sometimes, but not always, recipes will include their number in front of the text, such as
|
||||
* "1.", "2.", or "Step 1", "Step 2", or "First", "Second". In the case where they are directly numbered
|
||||
* ("1.", "2.", "Step one", "Step 1", "Step two", "Step 2", etc.), you should not include the number in
|
||||
* the text. However, if they use words ("First", "Second", etc.), then those should be included.
|
||||
*
|
||||
* If the instruction is completely blank, skip it and do not add the instruction, since this field is
|
||||
* required. If the ingredient has no text, but has a title, include the title on the next
|
||||
* instruction instead.
|
||||
*
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
export interface OpenAIRecipeNotes {
|
||||
/**
|
||||
*
|
||||
* The title of the note. Notes may not specify a title, and just have a body of text. In this case,
|
||||
* title should be left blank, and all content should go in the note text. If the note title is just
|
||||
* "note" or "info", you should ignore it and leave the title blank.
|
||||
*
|
||||
*/
|
||||
title?: string | null;
|
||||
/**
|
||||
*
|
||||
* The text of the note. This should represent the entire note, such as "This recipe is great for
|
||||
* a summer picnic" or "This recipe is a family favorite". They may also include additional prep
|
||||
* instructions such as "to make this recipe gluten free, use gluten free flour", or "you may prepare
|
||||
* the dough the night before and refrigerate it until ready to bake".
|
||||
*
|
||||
* If the note is completely blank, skip it and do not add the note, since this field is required.
|
||||
*
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
export interface OpenAIBase {}
|
||||
|
||||
@@ -116,7 +116,7 @@ export interface ExportBase {
|
||||
}
|
||||
export interface ExportRecipes {
|
||||
recipes: string[];
|
||||
exportType?: ExportTypes & string;
|
||||
exportType?: ExportTypes;
|
||||
}
|
||||
export interface IngredientConfidence {
|
||||
average?: number | null;
|
||||
@@ -150,14 +150,11 @@ export interface MultiPurposeLabelSummary {
|
||||
groupId: string;
|
||||
id: string;
|
||||
}
|
||||
/**
|
||||
* A list of ingredient references.
|
||||
*/
|
||||
export interface IngredientReferences {
|
||||
referenceId?: string | null;
|
||||
}
|
||||
export interface IngredientRequest {
|
||||
parser?: RegisteredParser & string;
|
||||
parser?: RegisteredParser;
|
||||
ingredient: string;
|
||||
}
|
||||
export interface IngredientUnit {
|
||||
@@ -181,7 +178,7 @@ export interface IngredientUnitAlias {
|
||||
name: string;
|
||||
}
|
||||
export interface IngredientsRequest {
|
||||
parser?: RegisteredParser & string;
|
||||
parser?: RegisteredParser;
|
||||
ingredients: string[];
|
||||
}
|
||||
export interface MergeFood {
|
||||
@@ -230,6 +227,8 @@ export interface Recipe {
|
||||
name?: string | null;
|
||||
slug?: string;
|
||||
image?: unknown;
|
||||
recipeServings?: number;
|
||||
recipeYieldQuantity?: number;
|
||||
recipeYield?: string | null;
|
||||
totalTime?: string | null;
|
||||
prepTime?: string | null;
|
||||
@@ -266,6 +265,7 @@ export interface RecipeTool {
|
||||
export interface RecipeStep {
|
||||
id?: string | null;
|
||||
title?: string | null;
|
||||
summary?: string | null;
|
||||
text: string;
|
||||
ingredientReferences?: IngredientReferences[];
|
||||
}
|
||||
@@ -306,6 +306,8 @@ export interface RecipeSummary {
|
||||
name?: string | null;
|
||||
slug?: string;
|
||||
image?: unknown;
|
||||
recipeServings?: number;
|
||||
recipeYieldQuantity?: number;
|
||||
recipeYield?: string | null;
|
||||
totalTime?: string | null;
|
||||
prepTime?: string | null;
|
||||
@@ -490,7 +492,7 @@ export interface ScrapeRecipeTest {
|
||||
url: string;
|
||||
useOpenAI?: boolean;
|
||||
}
|
||||
export interface SlugResponse { }
|
||||
export interface SlugResponse {}
|
||||
export interface TagIn {
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface ReportCreate {
|
||||
category: ReportCategory;
|
||||
groupId: string;
|
||||
name: string;
|
||||
status?: ReportSummaryStatus & string;
|
||||
status?: ReportSummaryStatus;
|
||||
}
|
||||
export interface ReportEntryCreate {
|
||||
reportId: string;
|
||||
@@ -35,7 +35,7 @@ export interface ReportOut {
|
||||
category: ReportCategory;
|
||||
groupId: string;
|
||||
name: string;
|
||||
status?: ReportSummaryStatus & string;
|
||||
status?: ReportSummaryStatus;
|
||||
id: string;
|
||||
entries?: ReportEntryOut[];
|
||||
}
|
||||
@@ -44,6 +44,6 @@ export interface ReportSummary {
|
||||
category: ReportCategory;
|
||||
groupId: string;
|
||||
name: string;
|
||||
status?: ReportSummaryStatus & string;
|
||||
status?: ReportSummaryStatus;
|
||||
id: string;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface PaginationQuery {
|
||||
perPage?: number;
|
||||
orderBy?: string | null;
|
||||
orderByNullPosition?: OrderByNullPosition | null;
|
||||
orderDirection?: OrderDirection & string;
|
||||
orderDirection?: OrderDirection;
|
||||
queryFilter?: string | null;
|
||||
paginationSeed?: string | null;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export interface ReadWebhook {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
webhookType?: WebhookType & string;
|
||||
webhookType?: WebhookType;
|
||||
scheduledTime: string;
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
@@ -110,7 +110,7 @@ export interface PrivateUser {
|
||||
username?: string | null;
|
||||
fullName?: string | null;
|
||||
email: string;
|
||||
authMethod?: AuthMethod & string;
|
||||
authMethod?: AuthMethod;
|
||||
admin?: boolean;
|
||||
group: string;
|
||||
household: string;
|
||||
@@ -175,7 +175,7 @@ export interface CreateWebhook {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
webhookType?: WebhookType & string;
|
||||
webhookType?: WebhookType;
|
||||
scheduledTime: string;
|
||||
}
|
||||
export interface UserBase {
|
||||
@@ -183,7 +183,7 @@ export interface UserBase {
|
||||
username?: string | null;
|
||||
fullName?: string | null;
|
||||
email: string;
|
||||
authMethod?: AuthMethod & string;
|
||||
authMethod?: AuthMethod;
|
||||
admin?: boolean;
|
||||
group?: string | null;
|
||||
household?: string | null;
|
||||
@@ -195,10 +195,10 @@ export interface UserBase {
|
||||
}
|
||||
export interface UserIn {
|
||||
id?: string | null;
|
||||
username?: string | null;
|
||||
fullName?: string | null;
|
||||
username: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
authMethod?: AuthMethod & string;
|
||||
authMethod?: AuthMethod;
|
||||
admin?: boolean;
|
||||
group?: string | null;
|
||||
household?: string | null;
|
||||
@@ -214,7 +214,7 @@ export interface UserOut {
|
||||
username?: string | null;
|
||||
fullName?: string | null;
|
||||
email: string;
|
||||
authMethod?: AuthMethod & string;
|
||||
authMethod?: AuthMethod;
|
||||
admin?: boolean;
|
||||
group: string;
|
||||
household: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mealie",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nuxt",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<v-container fluid>
|
||||
<UserInviteDialog v-model="inviteDialog" />
|
||||
<BaseDialog
|
||||
v-model="deleteDialog"
|
||||
:title="$tc('general.confirm')"
|
||||
@@ -22,6 +23,9 @@
|
||||
<BaseButton to="/admin/manage/users/create" class="mr-2">
|
||||
{{ $t("general.create") }}
|
||||
</BaseButton>
|
||||
<BaseButton class="mr-2" color="info" :icon="$globals.icons.link" @click="inviteDialog = true">
|
||||
{{ $t("group.invite") }}
|
||||
</BaseButton>
|
||||
|
||||
<BaseOverflowButton mode="event" :items="ACTIONS_OPTIONS" @unlock-all-users="unlockAllUsers">
|
||||
</BaseOverflowButton>
|
||||
@@ -69,12 +73,17 @@ import { useAdminApi } from "~/composables/api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { useUser, useAllUsers } from "~/composables/use-user";
|
||||
import { UserOut } from "~/lib/api/types/user";
|
||||
import UserInviteDialog from "~/components/Domain/User/UserInviteDialog.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
UserInviteDialog,
|
||||
},
|
||||
layout: "admin",
|
||||
setup() {
|
||||
const api = useAdminApi();
|
||||
const refUserDialog = ref();
|
||||
const inviteDialog = ref();
|
||||
const { $auth } = useContext();
|
||||
|
||||
const user = computed(() => $auth.user);
|
||||
@@ -99,6 +108,9 @@ export default defineComponent({
|
||||
deleteDialog: false,
|
||||
deleteTargetId: "",
|
||||
search: "",
|
||||
groups: [],
|
||||
households: [],
|
||||
sendTo: "",
|
||||
});
|
||||
|
||||
const { users, refreshAllUsers } = useAllUsers();
|
||||
@@ -154,6 +166,7 @@ export default defineComponent({
|
||||
deleteUser,
|
||||
loading,
|
||||
refUserDialog,
|
||||
inviteDialog,
|
||||
users,
|
||||
user,
|
||||
handleRowClick,
|
||||
|
||||
@@ -218,6 +218,8 @@ export default defineComponent({
|
||||
tags: true,
|
||||
tools: true,
|
||||
categories: true,
|
||||
recipeServings: false,
|
||||
recipeYieldQuantity: false,
|
||||
recipeYield: false,
|
||||
dateAdded: false,
|
||||
});
|
||||
@@ -228,7 +230,9 @@ export default defineComponent({
|
||||
tags: i18n.t("tag.tags"),
|
||||
categories: i18n.t("recipe.categories"),
|
||||
tools: i18n.t("tool.tools"),
|
||||
recipeYield: i18n.t("recipe.recipe-yield"),
|
||||
recipeServings: i18n.t("recipe.recipe-servings"),
|
||||
recipeYieldQuantity: i18n.t("recipe.recipe-yield"),
|
||||
recipeYield: i18n.t("recipe.recipe-yield-text"),
|
||||
dateAdded: i18n.t("general.date-added"),
|
||||
};
|
||||
|
||||
|
||||
@@ -9,44 +9,14 @@
|
||||
</p>
|
||||
<v-card flat color="transparent" width="100%" max-width="600px">
|
||||
<v-card-actions class="d-flex justify-center my-4">
|
||||
<v-btn v-if="user.canInvite" outlined rounded @click="getSignupLink()">
|
||||
<v-btn v-if="user.canInvite" outlined rounded @click="inviteDialog = true">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.createAlt }}
|
||||
</v-icon>
|
||||
{{ $t('profile.get-invite-link') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
<div v-show="generatedSignupLink !== ''">
|
||||
<v-card-text>
|
||||
<p class="text-center pb-0">
|
||||
{{ generatedSignupLink }}
|
||||
</p>
|
||||
<v-text-field v-model="sendTo" :label="$t('user.email')" :rules="[validators.email]"> </v-text-field>
|
||||
</v-card-text>
|
||||
<v-card-actions class="py-0 align-center" style="gap: 4px">
|
||||
<BaseButton cancel @click="generatedSignupLink = ''"> {{ $t("general.close") }} </BaseButton>
|
||||
<v-spacer></v-spacer>
|
||||
<AppButtonCopy :icon="false" color="info" :copy-text="generatedSignupLink" />
|
||||
<BaseButton color="info" :disabled="!validEmail" :loading="loading" @click="sendInvite">
|
||||
<template #icon>
|
||||
{{ $globals.icons.email }}
|
||||
</template>
|
||||
{{ $t("user.email") }}
|
||||
</BaseButton>
|
||||
</v-card-actions>
|
||||
</div>
|
||||
<div v-show="showPublicLink">
|
||||
<v-card-text>
|
||||
<p class="text-center pb-0">
|
||||
{{ publicLink }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions class="py-0 align-center" style="gap: 4px">
|
||||
<BaseButton cancel @click="showPublicLink = false"> {{ $t("general.close") }} </BaseButton>
|
||||
<v-spacer></v-spacer>
|
||||
<AppButtonCopy :icon="false" color="info" :copy-text="publicLink" />
|
||||
</v-card-actions>
|
||||
</div>
|
||||
<UserInviteDialog v-model="inviteDialog" />
|
||||
</v-card>
|
||||
</section>
|
||||
<section class="my-3">
|
||||
@@ -206,19 +176,19 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext, ref, toRefs, reactive, useAsync, useRoute } from "@nuxtjs/composition-api";
|
||||
import { computed, defineComponent, useContext, ref, useAsync, useRoute } from "@nuxtjs/composition-api";
|
||||
import UserProfileLinkCard from "@/components/Domain/User/UserProfileLinkCard.vue";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import UserAvatar from "@/components/Domain/User/UserAvatar.vue";
|
||||
import { useAsyncKey } from "~/composables/use-utils";
|
||||
import StatsCards from "~/components/global/StatsCards.vue";
|
||||
import { UserOut } from "~/lib/api/types/user";
|
||||
import UserInviteDialog from "~/components/Domain/User/UserInviteDialog.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UserProfile",
|
||||
components: {
|
||||
UserInviteDialog,
|
||||
UserProfileLinkCard,
|
||||
UserAvatar,
|
||||
StatsCards,
|
||||
@@ -233,61 +203,9 @@ export default defineComponent({
|
||||
// @ts-ignore $auth.user is typed as unknown, but it's a user
|
||||
const user = computed<UserOut | null>(() => $auth.user);
|
||||
|
||||
const showPublicLink = ref(false);
|
||||
const publicLink = ref("");
|
||||
|
||||
const generatedSignupLink = ref("");
|
||||
const token = ref("");
|
||||
const inviteDialog = ref(false);
|
||||
const api = useUserApi();
|
||||
|
||||
async function getSignupLink() {
|
||||
const { data } = await api.households.createInvitation({ uses: 1 });
|
||||
if (data) {
|
||||
token.value = data.token;
|
||||
generatedSignupLink.value = constructLink(data.token);
|
||||
showPublicLink.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function constructLink(token: string) {
|
||||
return `${window.location.origin}/register?token=${token}`;
|
||||
}
|
||||
|
||||
// =================================================
|
||||
// Email Invitation
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
sendTo: "",
|
||||
});
|
||||
|
||||
async function sendInvite() {
|
||||
state.loading = true;
|
||||
const { data } = await api.email.sendInvitation({
|
||||
email: state.sendTo,
|
||||
token: token.value,
|
||||
});
|
||||
|
||||
if (data && data.success) {
|
||||
alert.success(i18n.tc("profile.email-sent"));
|
||||
} else {
|
||||
alert.error(i18n.tc("profile.error-sending-email"));
|
||||
}
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
const validEmail = computed(() => {
|
||||
if (state.sendTo === "") {
|
||||
return false;
|
||||
}
|
||||
const valid = validators.email(state.sendTo);
|
||||
|
||||
// Explicit bool check because validators.email sometimes returns a string
|
||||
if (valid === true) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const stats = useAsync(async () => {
|
||||
const { data } = await api.households.statistics();
|
||||
|
||||
@@ -321,13 +239,15 @@ export default defineComponent({
|
||||
return iconText[key] ?? $globals.icons.primary;
|
||||
}
|
||||
|
||||
const statsTo = computed<{ [key: string]: string }>(() => { return {
|
||||
totalRecipes: `/g/${groupSlug.value}/`,
|
||||
totalUsers: "/household/members",
|
||||
totalCategories: `/g/${groupSlug.value}/recipes/categories`,
|
||||
totalTags: `/g/${groupSlug.value}/recipes/tags`,
|
||||
totalTools: `/g/${groupSlug.value}/recipes/tools`,
|
||||
}});
|
||||
const statsTo = computed<{ [key: string]: string }>(() => {
|
||||
return {
|
||||
totalRecipes: `/g/${groupSlug.value}/`,
|
||||
totalUsers: "/household/members",
|
||||
totalCategories: `/g/${groupSlug.value}/recipes/categories`,
|
||||
totalTags: `/g/${groupSlug.value}/recipes/tags`,
|
||||
totalTools: `/g/${groupSlug.value}/recipes/tools`,
|
||||
}
|
||||
});
|
||||
|
||||
function getStatsTo(key: string) {
|
||||
return statsTo.value[key] ?? "unknown";
|
||||
@@ -338,17 +258,9 @@ export default defineComponent({
|
||||
getStatsTitle,
|
||||
getStatsIcon,
|
||||
getStatsTo,
|
||||
inviteDialog,
|
||||
stats,
|
||||
user,
|
||||
constructLink,
|
||||
generatedSignupLink,
|
||||
showPublicLink,
|
||||
publicLink,
|
||||
getSignupLink,
|
||||
sendInvite,
|
||||
validators,
|
||||
validEmail,
|
||||
...toRefs(state),
|
||||
};
|
||||
},
|
||||
head() {
|
||||
|
||||
@@ -63,12 +63,14 @@ class OpenIDProvider(AuthProvider[UserInfo]):
|
||||
try:
|
||||
# some IdPs don't provide a username (looking at you Google), so if we don't have the claim,
|
||||
# we'll create the user with whatever the USER_CLAIM is (default email)
|
||||
username = claims.get("preferred_username", claims.get(settings.OIDC_USER_CLAIM))
|
||||
username = claims.get(
|
||||
"preferred_username", claims.get("username", claims.get(settings.OIDC_USER_CLAIM))
|
||||
)
|
||||
user = repos.users.create(
|
||||
{
|
||||
"username": username,
|
||||
"password": "OIDC",
|
||||
"full_name": claims.get("name"),
|
||||
"full_name": claims.get(settings.OIDC_NAME_CLAIM),
|
||||
"email": claims.get("email"),
|
||||
"admin": is_admin,
|
||||
"auth_method": AuthMethod.OIDC,
|
||||
@@ -96,7 +98,7 @@ class OpenIDProvider(AuthProvider[UserInfo]):
|
||||
def required_claims(self):
|
||||
settings = get_app_settings()
|
||||
|
||||
claims = {"name", "email", settings.OIDC_USER_CLAIM}
|
||||
claims = {settings.OIDC_NAME_CLAIM, "email", settings.OIDC_USER_CLAIM}
|
||||
if settings.OIDC_REQUIRES_GROUP_CLAIM:
|
||||
claims.add(settings.OIDC_GROUPS_CLAIM)
|
||||
return claims
|
||||
|
||||
@@ -332,6 +332,7 @@ class AppSettings(AppLoggingSettings):
|
||||
OIDC_PROVIDER_NAME: str = "OAuth"
|
||||
OIDC_REMEMBER_ME: bool = False
|
||||
OIDC_USER_CLAIM: str = "email"
|
||||
OIDC_NAME_CLAIM: str = "name"
|
||||
OIDC_GROUPS_CLAIM: str | None = "groups"
|
||||
OIDC_SCOPES_OVERRIDE: str | None = None
|
||||
OIDC_TLS_CACERTFILE: str | None = None
|
||||
|
||||
@@ -194,7 +194,7 @@ class SessionBuffer:
|
||||
self.shopping_list_ids.clear()
|
||||
|
||||
|
||||
session_buffer_context = ContextVar("session_buffer", default=SessionBuffer())
|
||||
session_buffer_context = ContextVar("session_buffer", default=SessionBuffer()) # noqa: B039
|
||||
|
||||
|
||||
@event.listens_for(ShoppingListItem, "after_insert")
|
||||
|
||||
@@ -89,7 +89,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
cook_time: Mapped[str | None] = mapped_column(sa.String)
|
||||
|
||||
recipe_yield: Mapped[str | None] = mapped_column(sa.String)
|
||||
recipeCuisine: Mapped[str | None] = mapped_column(sa.String)
|
||||
recipe_yield_quantity: Mapped[float] = mapped_column(sa.Float, index=True, default=0)
|
||||
recipe_servings: Mapped[float] = mapped_column(sa.Float, index=True, default=0)
|
||||
|
||||
assets: Mapped[list[RecipeAsset]] = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
|
||||
nutrition: Mapped[Nutrition] = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan")
|
||||
@@ -131,7 +132,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
notes: Mapped[list[Note]] = orm.relationship("Note", cascade="all, delete-orphan")
|
||||
org_url: Mapped[str | None] = mapped_column(sa.String)
|
||||
extras: Mapped[list[ApiExtras]] = orm.relationship("ApiExtras", cascade="all, delete-orphan")
|
||||
is_ocr_recipe: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||
|
||||
# Time Stamp Properties
|
||||
date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today)
|
||||
@@ -167,6 +167,10 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
},
|
||||
)
|
||||
|
||||
# Deprecated
|
||||
recipeCuisine: Mapped[str | None] = mapped_column(sa.String)
|
||||
is_ocr_recipe: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||
|
||||
@validates("name")
|
||||
def validate_name(self, _, name):
|
||||
assert name != ""
|
||||
|
||||
@@ -4,9 +4,18 @@
|
||||
},
|
||||
"recipe": {
|
||||
"unique-name-error": "Recipe names must be unique",
|
||||
"recipe-created": "Recipe Created",
|
||||
"recipe-defaults": {
|
||||
"ingredient-note": "1 Cup Flour",
|
||||
"step-text": "Recipe steps as well as other fields in the recipe page support markdown syntax.\n\n**Add a link**\n\n[My Link](https://demo.mealie.io)\n"
|
||||
},
|
||||
"servings-text": {
|
||||
"makes": "Makes",
|
||||
"serves": "Serves",
|
||||
"serving": "Serving",
|
||||
"servings": "Servings",
|
||||
"yield": "Yield",
|
||||
"yields": "Yields"
|
||||
}
|
||||
},
|
||||
"mealplan": {
|
||||
|
||||
@@ -29,3 +29,9 @@ def local_provider(accept_language: str | None = Header(None)) -> Translator:
|
||||
factory = _load_factory()
|
||||
accept_language = accept_language or "en-US"
|
||||
return factory.get(accept_language)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_all_translations(key: str) -> dict[str, str]:
|
||||
factory = _load_factory()
|
||||
return {locale: factory.get(locale).t(key) for locale in factory.supported_locales}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from dataclasses import dataclass, field
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
|
||||
from .json_provider import JsonProvider
|
||||
@@ -10,7 +11,7 @@ class InUseProvider:
|
||||
locks: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@dataclass
|
||||
class ProviderFactory:
|
||||
directory: Path
|
||||
fallback_locale: str = "en-US"
|
||||
@@ -22,6 +23,10 @@ class ProviderFactory:
|
||||
def fallback_file(self) -> Path:
|
||||
return self.directory / self.filename_format.format(locale=self.fallback_locale, format="json")
|
||||
|
||||
@cached_property
|
||||
def supported_locales(self) -> list[str]:
|
||||
return [path.stem for path in self.directory.glob(self.filename_format.format(locale="*", format="json"))]
|
||||
|
||||
def _load(self, locale: str) -> JsonProvider:
|
||||
filename = self.filename_format.format(locale=locale, format="json")
|
||||
path = self.directory / filename
|
||||
|
||||
@@ -24,15 +24,24 @@ class GroupInvitationsController(BaseUserController):
|
||||
return self.repos.group_invite_tokens.page_all(PaginationQuery(page=1, per_page=-1)).items
|
||||
|
||||
@router.post("", response_model=ReadInviteToken, status_code=status.HTTP_201_CREATED)
|
||||
def create_invite_token(self, uses: CreateInviteToken):
|
||||
def create_invite_token(self, body: CreateInviteToken):
|
||||
if not self.user.can_invite:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail="User is not allowed to create invite tokens",
|
||||
)
|
||||
|
||||
body.group_id = body.group_id or self.group_id
|
||||
body.household_id = body.household_id or self.household_id
|
||||
|
||||
if not self.user.admin and (body.group_id != self.group_id or body.household_id != self.household_id):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins can create invite tokens for other groups or households",
|
||||
)
|
||||
|
||||
token = SaveInviteToken(
|
||||
uses_left=uses.uses, group_id=self.group_id, household_id=self.household_id, token=url_safe_token()
|
||||
uses_left=body.uses, group_id=body.group_id, household_id=body.household_id, token=url_safe_token()
|
||||
)
|
||||
return self.repos.group_invite_tokens.create(token)
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ def content_with_meta(group_slug: str, recipe: Recipe) -> str:
|
||||
"prepTime": recipe.prep_time,
|
||||
"cookTime": recipe.cook_time,
|
||||
"totalTime": recipe.total_time,
|
||||
"recipeYield": recipe.recipe_yield,
|
||||
"recipeYield": recipe.recipe_yield_display,
|
||||
"recipeIngredient": ingredients,
|
||||
"recipeInstructions": [i.text for i in recipe.recipe_instructions] if recipe.recipe_instructions else [],
|
||||
"recipeCategory": [c.name for c in recipe.recipe_category] if recipe.recipe_category else [],
|
||||
|
||||
@@ -7,6 +7,8 @@ from mealie.schema._mealie import MealieModel
|
||||
|
||||
class CreateInviteToken(MealieModel):
|
||||
uses: int
|
||||
group_id: UUID | None = None
|
||||
household_id: UUID | None = None
|
||||
|
||||
|
||||
class SaveInviteToken(MealieModel):
|
||||
|
||||
@@ -91,6 +91,8 @@ class RecipeSummary(MealieModel):
|
||||
name: str | None = None
|
||||
slug: Annotated[str, Field(validate_default=True)] = ""
|
||||
image: Any | None = None
|
||||
recipe_servings: float = 0
|
||||
recipe_yield_quantity: float = 0
|
||||
recipe_yield: str | None = None
|
||||
|
||||
total_time: str | None = None
|
||||
@@ -122,6 +124,10 @@ class RecipeSummary(MealieModel):
|
||||
|
||||
return val
|
||||
|
||||
@property
|
||||
def recipe_yield_display(self) -> str:
|
||||
return f"{self.recipe_yield_quantity} {self.recipe_yield}".strip()
|
||||
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [
|
||||
|
||||
@@ -10,9 +10,15 @@ def main():
|
||||
if port is None:
|
||||
port = 9000
|
||||
|
||||
url = f"http://127.0.0.1:{port}/api/app/about"
|
||||
if all(os.getenv(x) for x in ["TLS_CERTIFICATE_PATH", "TLS_PRIVATE_KEY_PATH"]):
|
||||
proto = "https"
|
||||
else:
|
||||
proto = "http"
|
||||
|
||||
r = requests.get(url)
|
||||
url = f"{proto}://127.0.0.1:{port}/api/app/about"
|
||||
|
||||
# TLS certificate is likely not issued for 127.0.0.1 so don't verify
|
||||
r = requests.get(url, verify=False)
|
||||
|
||||
if r.status_code == 200:
|
||||
sys.exit(0)
|
||||
|
||||
@@ -268,6 +268,5 @@ class BaseMigrator(BaseService):
|
||||
with contextlib.suppress(KeyError):
|
||||
del recipe_dict["id"]
|
||||
|
||||
recipe_dict = cleaner.clean(recipe_dict, self.translator, url=recipe_dict.get("org_url", None))
|
||||
|
||||
return Recipe(**recipe_dict)
|
||||
recipe = cleaner.clean(recipe_dict, self.translator, url=recipe_dict.get("org_url", None))
|
||||
return recipe
|
||||
|
||||
@@ -92,10 +92,8 @@ class TandoorMigrator(BaseMigrator):
|
||||
recipe_data.pop("working_time", 0), recipe_data.pop("waiting_time", 0)
|
||||
)
|
||||
|
||||
serving_size = recipe_data.pop("servings", 0)
|
||||
serving_text = recipe_data.pop("servings_text", "")
|
||||
if serving_size and serving_text:
|
||||
recipe_data["recipeYield"] = f"{serving_size} {serving_text}"
|
||||
recipe_data["recipeYieldQuantity"] = recipe_data.pop("servings", 0)
|
||||
recipe_data["recipeYield"] = recipe_data.pop("servings_text", "")
|
||||
|
||||
try:
|
||||
recipe_image_path = next(source_dir.glob("image.*"))
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import re
|
||||
|
||||
compiled_match = re.compile(r"(.){1,6}\s\((.[^\(\)])+\)\s")
|
||||
compiled_search = re.compile(r"\((.[^\(])+\)")
|
||||
|
||||
|
||||
def move_parens_to_end(ing_str) -> str:
|
||||
"""
|
||||
Moves all parentheses in the string to the end of the string using Regex.
|
||||
If no parentheses are found, the string is returned unchanged.
|
||||
"""
|
||||
if re.match(compiled_match, ing_str):
|
||||
if match := re.search(compiled_search, ing_str):
|
||||
start = match.start()
|
||||
end = match.end()
|
||||
ing_str = ing_str[:start] + ing_str[end:] + " " + ing_str[start:end]
|
||||
|
||||
return ing_str
|
||||
|
||||
|
||||
def check_char(char, *eql) -> bool:
|
||||
"""Helper method to check if a characters matches any of the additional provided arguments"""
|
||||
return any(char == eql_char for eql_char in eql)
|
||||
@@ -3,7 +3,7 @@ import unicodedata
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from .._helpers import check_char, move_parens_to_end
|
||||
from ..parser_utils import check_char, move_parens_to_end
|
||||
|
||||
|
||||
class BruteParsedIngredient(BaseModel):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from mealie.services.parser_services.parser_utils import convert_vulgar_fractions_to_regular_fractions
|
||||
|
||||
replace_abbreviations = {
|
||||
"cup": " cup ",
|
||||
@@ -29,23 +30,6 @@ def remove_periods(string: str) -> str:
|
||||
return re.sub(r"(?<!\d)\.(?!\d)", "", string)
|
||||
|
||||
|
||||
def replace_fraction_unicode(string: str):
|
||||
# TODO: I'm not confident this works well enough for production needs some testing and/or refacorting
|
||||
# TODO: Breaks on multiple unicode fractions
|
||||
for c in string:
|
||||
try:
|
||||
name = unicodedata.name(c)
|
||||
except ValueError:
|
||||
continue
|
||||
if name.startswith("VULGAR FRACTION"):
|
||||
normalized = unicodedata.normalize("NFKC", c)
|
||||
numerator, _, denominator = normalized.partition("⁄") # _ = slash
|
||||
text = f" {numerator}/{denominator}"
|
||||
return string.replace(c, text).replace(" ", " ")
|
||||
|
||||
return string
|
||||
|
||||
|
||||
def wrap_or_clause(string: str):
|
||||
"""
|
||||
Attempts to wrap or clauses in ()
|
||||
@@ -75,7 +59,7 @@ def pre_process_string(string: str) -> str:
|
||||
|
||||
"""
|
||||
string = string.lower()
|
||||
string = replace_fraction_unicode(string)
|
||||
string = convert_vulgar_fractions_to_regular_fractions(string)
|
||||
string = remove_periods(string)
|
||||
string = replace_common_abbreviations(string)
|
||||
|
||||
|
||||
111
mealie/services/parser_services/parser_utils/string_utils.py
Normal file
111
mealie/services/parser_services/parser_utils/string_utils.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import re
|
||||
from fractions import Fraction
|
||||
|
||||
compiled_match = re.compile(r"(.){1,6}\s\((.[^\(\)])+\)\s")
|
||||
compiled_search = re.compile(r"\((.[^\(])+\)")
|
||||
|
||||
|
||||
def move_parens_to_end(ing_str) -> str:
|
||||
"""
|
||||
Moves all parentheses in the string to the end of the string using Regex.
|
||||
If no parentheses are found, the string is returned unchanged.
|
||||
"""
|
||||
if re.match(compiled_match, ing_str):
|
||||
if match := re.search(compiled_search, ing_str):
|
||||
start = match.start()
|
||||
end = match.end()
|
||||
ing_str = ing_str[:start] + ing_str[end:] + " " + ing_str[start:end]
|
||||
|
||||
return ing_str
|
||||
|
||||
|
||||
def check_char(char, *eql) -> bool:
|
||||
"""Helper method to check if a characters matches any of the additional provided arguments"""
|
||||
return any(char == eql_char for eql_char in eql)
|
||||
|
||||
|
||||
def convert_vulgar_fractions_to_regular_fractions(text: str) -> str:
|
||||
vulgar_fractions = {
|
||||
"¼": "1/4",
|
||||
"½": "1/2",
|
||||
"¾": "3/4",
|
||||
"⅐": "1/7",
|
||||
"⅑": "1/9",
|
||||
"⅒": "1/10",
|
||||
"⅓": "1/3",
|
||||
"⅔": "2/3",
|
||||
"⅕": "1/5",
|
||||
"⅖": "2/5",
|
||||
"⅗": "3/5",
|
||||
"⅘": "4/5",
|
||||
"⅙": "1/6",
|
||||
"⅚": "5/6",
|
||||
"⅛": "1/8",
|
||||
"⅜": "3/8",
|
||||
"⅝": "5/8",
|
||||
"⅞": "7/8",
|
||||
}
|
||||
|
||||
for vulgar_fraction, regular_fraction in vulgar_fractions.items():
|
||||
# if we don't add a space in front of the fraction, mixed fractions will be broken
|
||||
# e.g. "1½" -> "11/2"
|
||||
text = text.replace(vulgar_fraction, f" {regular_fraction}").strip()
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def extract_quantity_from_string(source_str: str) -> tuple[float, str]:
|
||||
"""
|
||||
Extracts a quantity from a string. The quantity can be a fraction, decimal, or integer.
|
||||
|
||||
Returns the quantity and the remaining string. If no quantity is found, returns the quantity as 0.
|
||||
"""
|
||||
|
||||
source_str = source_str.strip()
|
||||
if not source_str:
|
||||
return 0, ""
|
||||
|
||||
source_str = convert_vulgar_fractions_to_regular_fractions(source_str)
|
||||
|
||||
mixed_fraction_pattern = re.compile(r"(\d+)\s+(\d+)/(\d+)")
|
||||
fraction_pattern = re.compile(r"(\d+)/(\d+)")
|
||||
number_pattern = re.compile(r"\d+(\.\d+)?")
|
||||
|
||||
try:
|
||||
# Check for a mixed fraction (e.g. "1 1/2")
|
||||
match = mixed_fraction_pattern.search(source_str)
|
||||
if match:
|
||||
whole_number = int(match.group(1))
|
||||
numerator = int(match.group(2))
|
||||
denominator = int(match.group(3))
|
||||
quantity = whole_number + float(Fraction(numerator, denominator))
|
||||
remaining_str = source_str[: match.start()] + source_str[match.end() :]
|
||||
|
||||
remaining_str = remaining_str.strip()
|
||||
return quantity, remaining_str
|
||||
|
||||
# Check for a fraction (e.g. "1/2")
|
||||
match = fraction_pattern.search(source_str)
|
||||
if match:
|
||||
numerator = int(match.group(1))
|
||||
denominator = int(match.group(2))
|
||||
quantity = float(Fraction(numerator, denominator))
|
||||
remaining_str = source_str[: match.start()] + source_str[match.end() :]
|
||||
|
||||
remaining_str = remaining_str.strip()
|
||||
return quantity, remaining_str
|
||||
|
||||
# Check for a number (integer or float)
|
||||
match = number_pattern.search(source_str)
|
||||
if match:
|
||||
quantity = float(match.group())
|
||||
remaining_str = source_str[: match.start()] + source_str[match.end() :]
|
||||
|
||||
remaining_str = remaining_str.strip()
|
||||
return quantity, remaining_str
|
||||
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
|
||||
# If no match, return 0 and the original string
|
||||
return 0, source_str
|
||||
@@ -15,7 +15,7 @@ try:
|
||||
|
||||
_FIREFOX_UA = HEADERS["User-Agent"]
|
||||
except (ImportError, KeyError):
|
||||
_FIREFOX_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0"
|
||||
_FIREFOX_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/128.0"
|
||||
|
||||
|
||||
async def gather_with_concurrency(n, *coros, ignore_exceptions=False):
|
||||
|
||||
@@ -32,6 +32,7 @@ from mealie.schema.user.user import PrivateUser, UserRatingCreate
|
||||
from mealie.services._base_service import BaseService
|
||||
from mealie.services.openai import OpenAIDataInjection, OpenAILocalImage, OpenAIService
|
||||
from mealie.services.recipe.recipe_data_service import RecipeDataService
|
||||
from mealie.services.scraper import cleaner
|
||||
|
||||
from .template_service import TemplateService
|
||||
|
||||
@@ -189,7 +190,7 @@ class RecipeService(RecipeServiceBase):
|
||||
timeline_event_data = RecipeTimelineEventCreate(
|
||||
user_id=new_recipe.user_id,
|
||||
recipe_id=new_recipe.id,
|
||||
subject="Recipe Created",
|
||||
subject=self.t("recipe.recipe-created"),
|
||||
event_type=TimelineEventType.system,
|
||||
timestamp=new_recipe.created_at or datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -297,6 +298,7 @@ class RecipeService(RecipeServiceBase):
|
||||
recipe_data = await openai_recipe_service.build_recipe_from_images(
|
||||
local_images, translate_language=translate_language
|
||||
)
|
||||
recipe_data = cleaner.clean(recipe_data, self.translator)
|
||||
|
||||
recipe = self.create_one(recipe_data)
|
||||
data_service = RecipeDataService(recipe.id)
|
||||
|
||||
@@ -10,7 +10,9 @@ from datetime import datetime, timedelta
|
||||
from slugify import slugify
|
||||
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.lang.providers import Translator
|
||||
from mealie.lang.providers import Translator, get_all_translations
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from mealie.services.parser_services.parser_utils import extract_quantity_from_string
|
||||
|
||||
logger = get_logger("recipe-scraper")
|
||||
|
||||
@@ -33,33 +35,43 @@ MATCH_ERRONEOUS_WHITE_SPACE = re.compile(r"\n\s*\n")
|
||||
""" Matches multiple new lines and removes erroneous white space """
|
||||
|
||||
|
||||
def clean(recipe_data: dict, translator: Translator, url=None) -> dict:
|
||||
def clean(recipe_data: Recipe | dict, translator: Translator, url=None) -> Recipe:
|
||||
"""Main entrypoint to clean a recipe extracted from the web
|
||||
and format the data into an accectable format for the database
|
||||
|
||||
Args:
|
||||
recipe_data (dict): raw recipe dicitonary
|
||||
recipe_data (dict): raw recipe or recipe dictionary
|
||||
|
||||
Returns:
|
||||
dict: cleaned recipe dictionary
|
||||
"""
|
||||
if not isinstance(recipe_data, dict):
|
||||
# format the recipe like a scraped dictionary
|
||||
recipe_data_dict = recipe_data.model_dump(by_alias=True)
|
||||
recipe_data_dict["recipeIngredient"] = [ing.display for ing in recipe_data.recipe_ingredient]
|
||||
|
||||
recipe_data = recipe_data_dict
|
||||
|
||||
recipe_data["slug"] = slugify(recipe_data.get("name", ""))
|
||||
recipe_data["description"] = clean_string(recipe_data.get("description", ""))
|
||||
|
||||
# Times
|
||||
recipe_data["prepTime"] = clean_time(recipe_data.get("prepTime"), translator)
|
||||
recipe_data["performTime"] = clean_time(recipe_data.get("performTime"), translator)
|
||||
recipe_data["totalTime"] = clean_time(recipe_data.get("totalTime"), translator)
|
||||
|
||||
recipe_data["recipeServings"], recipe_data["recipeYieldQuantity"], recipe_data["recipeYield"] = clean_yield(
|
||||
recipe_data.get("recipeYield")
|
||||
)
|
||||
recipe_data["recipeCategory"] = clean_categories(recipe_data.get("recipeCategory", []))
|
||||
recipe_data["recipeYield"] = clean_yield(recipe_data.get("recipeYield"))
|
||||
recipe_data["recipeIngredient"] = clean_ingredients(recipe_data.get("recipeIngredient", []))
|
||||
recipe_data["recipeInstructions"] = clean_instructions(recipe_data.get("recipeInstructions", []))
|
||||
|
||||
recipe_data["image"] = clean_image(recipe_data.get("image"))[0]
|
||||
recipe_data["slug"] = slugify(recipe_data.get("name", ""))
|
||||
recipe_data["orgURL"] = url or recipe_data.get("orgURL")
|
||||
recipe_data["notes"] = clean_notes(recipe_data.get("notes"))
|
||||
recipe_data["rating"] = clean_int(recipe_data.get("rating"))
|
||||
|
||||
return recipe_data
|
||||
return Recipe(**recipe_data)
|
||||
|
||||
|
||||
def clean_string(text: str | list | int) -> str:
|
||||
@@ -316,7 +328,31 @@ def clean_notes(notes: typing.Any) -> list[dict] | None:
|
||||
return parsed_notes
|
||||
|
||||
|
||||
def clean_yield(yld: str | list[str] | None) -> str:
|
||||
@functools.lru_cache
|
||||
def _get_servings_options() -> set[str]:
|
||||
options: set[str] = set()
|
||||
for key in [
|
||||
"recipe.servings-text.makes",
|
||||
"recipe.servings-text.serves",
|
||||
"recipe.servings-text.serving",
|
||||
"recipe.servings-text.servings",
|
||||
"recipe.servings-text.yield",
|
||||
"recipe.servings-text.yields",
|
||||
]:
|
||||
options.update([t.strip().lower() for t in get_all_translations(key).values()])
|
||||
|
||||
return options
|
||||
|
||||
|
||||
def _is_serving_string(txt: str) -> bool:
|
||||
txt = txt.strip().lower()
|
||||
for option in _get_servings_options():
|
||||
if option in txt.strip().lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def clean_yield(yields: str | list[str] | None) -> tuple[float, float, str]:
|
||||
"""
|
||||
yield_amount attemps to parse out the yield amount from a recipe.
|
||||
|
||||
@@ -325,15 +361,34 @@ def clean_yield(yld: str | list[str] | None) -> str:
|
||||
- `["4 servings", "4 Pies"]` - returns the last value
|
||||
|
||||
Returns:
|
||||
float: The servings, if it can be parsed else 0
|
||||
float: The yield quantity, if it can be parsed else 0
|
||||
str: The yield amount, if it can be parsed else an empty string
|
||||
"""
|
||||
if not yld:
|
||||
return ""
|
||||
servings_qty: float = 0
|
||||
yld_qty: float = 0
|
||||
yld_str = ""
|
||||
|
||||
if isinstance(yld, list):
|
||||
return yld[-1]
|
||||
if not yields:
|
||||
return servings_qty, yld_qty, yld_str
|
||||
|
||||
return yld
|
||||
if not isinstance(yields, list):
|
||||
yields = [yields]
|
||||
|
||||
for yld in yields:
|
||||
if not yld:
|
||||
continue
|
||||
if not isinstance(yld, str):
|
||||
yld = str(yld)
|
||||
|
||||
qty, txt = extract_quantity_from_string(yld)
|
||||
if qty and _is_serving_string(yld):
|
||||
servings_qty = qty
|
||||
else:
|
||||
yld_qty = qty
|
||||
yld_str = txt
|
||||
|
||||
return servings_qty, yld_qty, yld_str
|
||||
|
||||
|
||||
def clean_time(time_entry: str | timedelta | None, translator: Translator) -> None | str:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.lang.providers import Translator
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from mealie.services.scraper import cleaner
|
||||
from mealie.services.scraper.scraped_extras import ScrapedExtras
|
||||
|
||||
from .scraper_strategies import (
|
||||
@@ -31,6 +33,7 @@ class RecipeScraper:
|
||||
|
||||
self.scrapers = scrapers
|
||||
self.translator = translator
|
||||
self.logger = get_logger()
|
||||
|
||||
async def scrape(self, url: str, html: str | None = None) -> tuple[Recipe, ScrapedExtras] | tuple[None, None]:
|
||||
"""
|
||||
@@ -41,9 +44,23 @@ class RecipeScraper:
|
||||
raw_html = html or await safe_scrape_html(url)
|
||||
for scraper_type in self.scrapers:
|
||||
scraper = scraper_type(url, self.translator, raw_html=raw_html)
|
||||
result = await scraper.parse()
|
||||
|
||||
if result is not None:
|
||||
return result
|
||||
try:
|
||||
result = await scraper.parse()
|
||||
except Exception:
|
||||
self.logger.exception(f"Failed to scrape HTML with {scraper.__class__.__name__}")
|
||||
result = None
|
||||
|
||||
if result is None or result[0] is None:
|
||||
continue
|
||||
|
||||
recipe_result, extras = result
|
||||
try:
|
||||
recipe = cleaner.clean(recipe_result, self.translator)
|
||||
except Exception:
|
||||
self.logger.exception(f"Failed to clean recipe data from {scraper.__class__.__name__}")
|
||||
continue
|
||||
|
||||
return recipe, extras
|
||||
|
||||
return None, None
|
||||
|
||||
@@ -26,7 +26,7 @@ try:
|
||||
|
||||
_FIREFOX_UA = HEADERS["User-Agent"]
|
||||
except (ImportError, KeyError):
|
||||
_FIREFOX_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0"
|
||||
_FIREFOX_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/128.0"
|
||||
|
||||
|
||||
SCRAPER_TIMEOUT = 15
|
||||
@@ -253,6 +253,18 @@ class RecipeScraperOpenAI(RecipeScraperPackage):
|
||||
rather than trying to scrape it directly.
|
||||
"""
|
||||
|
||||
def extract_json_ld_data_from_html(self, soup: bs4.BeautifulSoup) -> str:
|
||||
data_parts: list[str] = []
|
||||
for script in soup.find_all("script", type="application/ld+json"):
|
||||
try:
|
||||
script_data = script.string
|
||||
if script_data:
|
||||
data_parts.append(str(script_data))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return "\n\n".join(data_parts)
|
||||
|
||||
def find_image(self, soup: bs4.BeautifulSoup) -> str | None:
|
||||
# find the open graph image tag
|
||||
og_image = soup.find("meta", property="og:image")
|
||||
@@ -285,8 +297,10 @@ class RecipeScraperOpenAI(RecipeScraperPackage):
|
||||
soup = bs4.BeautifulSoup(html, "lxml")
|
||||
|
||||
text = soup.get_text(separator="\n", strip=True)
|
||||
text += self.extract_json_ld_data_from_html(soup)
|
||||
if not text:
|
||||
raise Exception("No text found in HTML")
|
||||
raise Exception("No text or ld+json data found in HTML")
|
||||
|
||||
try:
|
||||
image = self.find_image(soup)
|
||||
except Exception:
|
||||
|
||||
339
poetry.lock
generated
339
poetry.lock
generated
@@ -150,38 +150,36 @@ dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"]
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "4.2.0"
|
||||
version = "4.2.1"
|
||||
description = "Modern password hashing for your software and your servers"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "bcrypt-4.2.0-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:096a15d26ed6ce37a14c1ac1e48119660f21b24cba457f160a4b830f3fe6b5cb"},
|
||||
{file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c02d944ca89d9b1922ceb8a46460dd17df1ba37ab66feac4870f6862a1533c00"},
|
||||
{file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d84cf6d877918620b687b8fd1bf7781d11e8a0998f576c7aa939776b512b98d"},
|
||||
{file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1bb429fedbe0249465cdd85a58e8376f31bb315e484f16e68ca4c786dcc04291"},
|
||||
{file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:655ea221910bcac76ea08aaa76df427ef8625f92e55a8ee44fbf7753dbabb328"},
|
||||
{file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:1ee38e858bf5d0287c39b7a1fc59eec64bbf880c7d504d3a06a96c16e14058e7"},
|
||||
{file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:0da52759f7f30e83f1e30a888d9163a81353ef224d82dc58eb5bb52efcabc399"},
|
||||
{file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3698393a1b1f1fd5714524193849d0c6d524d33523acca37cd28f02899285060"},
|
||||
{file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:762a2c5fb35f89606a9fde5e51392dad0cd1ab7ae64149a8b935fe8d79dd5ed7"},
|
||||
{file = "bcrypt-4.2.0-cp37-abi3-win32.whl", hash = "sha256:5a1e8aa9b28ae28020a3ac4b053117fb51c57a010b9f969603ed885f23841458"},
|
||||
{file = "bcrypt-4.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:8f6ede91359e5df88d1f5c1ef47428a4420136f3ce97763e31b86dd8280fbdf5"},
|
||||
{file = "bcrypt-4.2.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:c52aac18ea1f4a4f65963ea4f9530c306b56ccd0c6f8c8da0c06976e34a6e841"},
|
||||
{file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3bbbfb2734f0e4f37c5136130405332640a1e46e6b23e000eeff2ba8d005da68"},
|
||||
{file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3413bd60460f76097ee2e0a493ccebe4a7601918219c02f503984f0a7ee0aebe"},
|
||||
{file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8d7bb9c42801035e61c109c345a28ed7e84426ae4865511eb82e913df18f58c2"},
|
||||
{file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3d3a6d28cb2305b43feac298774b997e372e56c7c7afd90a12b3dc49b189151c"},
|
||||
{file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:9c1c4ad86351339c5f320ca372dfba6cb6beb25e8efc659bedd918d921956bae"},
|
||||
{file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:27fe0f57bb5573104b5a6de5e4153c60814c711b29364c10a75a54bb6d7ff48d"},
|
||||
{file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8ac68872c82f1add6a20bd489870c71b00ebacd2e9134a8aa3f98a0052ab4b0e"},
|
||||
{file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cb2a8ec2bc07d3553ccebf0746bbf3d19426d1c6d1adbd4fa48925f66af7b9e8"},
|
||||
{file = "bcrypt-4.2.0-cp39-abi3-win32.whl", hash = "sha256:77800b7147c9dc905db1cba26abe31e504d8247ac73580b4aa179f98e6608f34"},
|
||||
{file = "bcrypt-4.2.0-cp39-abi3-win_amd64.whl", hash = "sha256:61ed14326ee023917ecd093ee6ef422a72f3aec6f07e21ea5f10622b735538a9"},
|
||||
{file = "bcrypt-4.2.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:39e1d30c7233cfc54f5c3f2c825156fe044efdd3e0b9d309512cc514a263ec2a"},
|
||||
{file = "bcrypt-4.2.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f4f4acf526fcd1c34e7ce851147deedd4e26e6402369304220250598b26448db"},
|
||||
{file = "bcrypt-4.2.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1ff39b78a52cf03fdf902635e4c81e544714861ba3f0efc56558979dd4f09170"},
|
||||
{file = "bcrypt-4.2.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:373db9abe198e8e2c70d12b479464e0d5092cc122b20ec504097b5f2297ed184"},
|
||||
{file = "bcrypt-4.2.0.tar.gz", hash = "sha256:cf69eaf5185fd58f268f805b505ce31f9b9fc2d64b376642164e9244540c1221"},
|
||||
{file = "bcrypt-4.2.1-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:1340411a0894b7d3ef562fb233e4b6ed58add185228650942bdc885362f32c17"},
|
||||
{file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1ee315739bc8387aa36ff127afc99120ee452924e0df517a8f3e4c0187a0f5f"},
|
||||
{file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dbd0747208912b1e4ce730c6725cb56c07ac734b3629b60d4398f082ea718ad"},
|
||||
{file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:aaa2e285be097050dba798d537b6efd9b698aa88eef52ec98d23dcd6d7cf6fea"},
|
||||
{file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:76d3e352b32f4eeb34703370e370997065d28a561e4a18afe4fef07249cb4396"},
|
||||
{file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:b7703ede632dc945ed1172d6f24e9f30f27b1b1a067f32f68bf169c5f08d0425"},
|
||||
{file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:89df2aea2c43be1e1fa066df5f86c8ce822ab70a30e4c210968669565c0f4685"},
|
||||
{file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:04e56e3fe8308a88b77e0afd20bec516f74aecf391cdd6e374f15cbed32783d6"},
|
||||
{file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cfdf3d7530c790432046c40cda41dfee8c83e29482e6a604f8930b9930e94139"},
|
||||
{file = "bcrypt-4.2.1-cp37-abi3-win32.whl", hash = "sha256:adadd36274510a01f33e6dc08f5824b97c9580583bd4487c564fc4617b328005"},
|
||||
{file = "bcrypt-4.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:8c458cd103e6c5d1d85cf600e546a639f234964d0228909d8f8dbeebff82d526"},
|
||||
{file = "bcrypt-4.2.1-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:8ad2f4528cbf0febe80e5a3a57d7a74e6635e41af1ea5675282a33d769fba413"},
|
||||
{file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:909faa1027900f2252a9ca5dfebd25fc0ef1417943824783d1c8418dd7d6df4a"},
|
||||
{file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cde78d385d5e93ece5479a0a87f73cd6fa26b171c786a884f955e165032b262c"},
|
||||
{file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:533e7f3bcf2f07caee7ad98124fab7499cb3333ba2274f7a36cf1daee7409d99"},
|
||||
{file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:687cf30e6681eeda39548a93ce9bfbb300e48b4d445a43db4298d2474d2a1e54"},
|
||||
{file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:041fa0155c9004eb98a232d54da05c0b41d4b8e66b6fc3cb71b4b3f6144ba837"},
|
||||
{file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f85b1ffa09240c89aa2e1ae9f3b1c687104f7b2b9d2098da4e923f1b7082d331"},
|
||||
{file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c6f5fa3775966cca251848d4d5393ab016b3afed251163c1436fefdec3b02c84"},
|
||||
{file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:807261df60a8b1ccd13e6599c779014a362ae4e795f5c59747f60208daddd96d"},
|
||||
{file = "bcrypt-4.2.1-cp39-abi3-win32.whl", hash = "sha256:b588af02b89d9fad33e5f98f7838bf590d6d692df7153647724a7f20c186f6bf"},
|
||||
{file = "bcrypt-4.2.1-cp39-abi3-win_amd64.whl", hash = "sha256:e84e0e6f8e40a242b11bce56c313edc2be121cec3e0ec2d76fce01f6af33c07c"},
|
||||
{file = "bcrypt-4.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:76132c176a6d9953cdc83c296aeaed65e1a708485fd55abf163e0d9f8f16ce0e"},
|
||||
{file = "bcrypt-4.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e158009a54c4c8bc91d5e0da80920d048f918c61a581f0a63e4e93bb556d362f"},
|
||||
{file = "bcrypt-4.2.1.tar.gz", hash = "sha256:6765386e3ab87f569b276988742039baab087b2cdb01e809d74e74503c2faafe"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@@ -419,73 +417,73 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.6.4"
|
||||
version = "7.6.8"
|
||||
description = "Code coverage measurement for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "coverage-7.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07"},
|
||||
{file = "coverage-7.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0"},
|
||||
{file = "coverage-7.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72"},
|
||||
{file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51"},
|
||||
{file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491"},
|
||||
{file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b"},
|
||||
{file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea"},
|
||||
{file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a"},
|
||||
{file = "coverage-7.6.4-cp310-cp310-win32.whl", hash = "sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa"},
|
||||
{file = "coverage-7.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172"},
|
||||
{file = "coverage-7.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b"},
|
||||
{file = "coverage-7.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25"},
|
||||
{file = "coverage-7.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546"},
|
||||
{file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b"},
|
||||
{file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e"},
|
||||
{file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718"},
|
||||
{file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db"},
|
||||
{file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522"},
|
||||
{file = "coverage-7.6.4-cp311-cp311-win32.whl", hash = "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf"},
|
||||
{file = "coverage-7.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19"},
|
||||
{file = "coverage-7.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2"},
|
||||
{file = "coverage-7.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117"},
|
||||
{file = "coverage-7.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613"},
|
||||
{file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27"},
|
||||
{file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52"},
|
||||
{file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2"},
|
||||
{file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1"},
|
||||
{file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5"},
|
||||
{file = "coverage-7.6.4-cp312-cp312-win32.whl", hash = "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17"},
|
||||
{file = "coverage-7.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08"},
|
||||
{file = "coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9"},
|
||||
{file = "coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba"},
|
||||
{file = "coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c"},
|
||||
{file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06"},
|
||||
{file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f"},
|
||||
{file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b"},
|
||||
{file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21"},
|
||||
{file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a"},
|
||||
{file = "coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e"},
|
||||
{file = "coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963"},
|
||||
{file = "coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f"},
|
||||
{file = "coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806"},
|
||||
{file = "coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11"},
|
||||
{file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3"},
|
||||
{file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a"},
|
||||
{file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc"},
|
||||
{file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70"},
|
||||
{file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef"},
|
||||
{file = "coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e"},
|
||||
{file = "coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1"},
|
||||
{file = "coverage-7.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9cb7fa111d21a6b55cbf633039f7bc2749e74932e3aa7cb7333f675a58a58bf3"},
|
||||
{file = "coverage-7.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11a223a14e91a4693d2d0755c7a043db43d96a7450b4f356d506c2562c48642c"},
|
||||
{file = "coverage-7.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a413a096c4cbac202433c850ee43fa326d2e871b24554da8327b01632673a076"},
|
||||
{file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00a1d69c112ff5149cabe60d2e2ee948752c975d95f1e1096742e6077affd376"},
|
||||
{file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f76846299ba5c54d12c91d776d9605ae33f8ae2b9d1d3c3703cf2db1a67f2c0"},
|
||||
{file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fe439416eb6380de434886b00c859304338f8b19f6f54811984f3420a2e03858"},
|
||||
{file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0294ca37f1ba500667b1aef631e48d875ced93ad5e06fa665a3295bdd1d95111"},
|
||||
{file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6f01ba56b1c0e9d149f9ac85a2f999724895229eb36bd997b61e62999e9b0901"},
|
||||
{file = "coverage-7.6.4-cp39-cp39-win32.whl", hash = "sha256:bc66f0bf1d7730a17430a50163bb264ba9ded56739112368ba985ddaa9c3bd09"},
|
||||
{file = "coverage-7.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:c481b47f6b5845064c65a7bc78bc0860e635a9b055af0df46fdf1c58cebf8e8f"},
|
||||
{file = "coverage-7.6.4-pp39.pp310-none-any.whl", hash = "sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e"},
|
||||
{file = "coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73"},
|
||||
{file = "coverage-7.6.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b39e6011cd06822eb964d038d5dff5da5d98652b81f5ecd439277b32361a3a50"},
|
||||
{file = "coverage-7.6.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:63c19702db10ad79151a059d2d6336fe0c470f2e18d0d4d1a57f7f9713875dcf"},
|
||||
{file = "coverage-7.6.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3985b9be361d8fb6b2d1adc9924d01dec575a1d7453a14cccd73225cb79243ee"},
|
||||
{file = "coverage-7.6.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:644ec81edec0f4ad17d51c838a7d01e42811054543b76d4ba2c5d6af741ce2a6"},
|
||||
{file = "coverage-7.6.8-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f188a2402f8359cf0c4b1fe89eea40dc13b52e7b4fd4812450da9fcd210181d"},
|
||||
{file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e19122296822deafce89a0c5e8685704c067ae65d45e79718c92df7b3ec3d331"},
|
||||
{file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:13618bed0c38acc418896005732e565b317aa9e98d855a0e9f211a7ffc2d6638"},
|
||||
{file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:193e3bffca48ad74b8c764fb4492dd875038a2f9925530cb094db92bb5e47bed"},
|
||||
{file = "coverage-7.6.8-cp310-cp310-win32.whl", hash = "sha256:3988665ee376abce49613701336544041f2117de7b7fbfe91b93d8ff8b151c8e"},
|
||||
{file = "coverage-7.6.8-cp310-cp310-win_amd64.whl", hash = "sha256:f56f49b2553d7dd85fd86e029515a221e5c1f8cb3d9c38b470bc38bde7b8445a"},
|
||||
{file = "coverage-7.6.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:86cffe9c6dfcfe22e28027069725c7f57f4b868a3f86e81d1c62462764dc46d4"},
|
||||
{file = "coverage-7.6.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d82ab6816c3277dc962cfcdc85b1efa0e5f50fb2c449432deaf2398a2928ab94"},
|
||||
{file = "coverage-7.6.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13690e923a3932e4fad4c0ebfb9cb5988e03d9dcb4c5150b5fcbf58fd8bddfc4"},
|
||||
{file = "coverage-7.6.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4be32da0c3827ac9132bb488d331cb32e8d9638dd41a0557c5569d57cf22c9c1"},
|
||||
{file = "coverage-7.6.8-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44e6c85bbdc809383b509d732b06419fb4544dca29ebe18480379633623baafb"},
|
||||
{file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:768939f7c4353c0fac2f7c37897e10b1414b571fd85dd9fc49e6a87e37a2e0d8"},
|
||||
{file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e44961e36cb13c495806d4cac67640ac2866cb99044e210895b506c26ee63d3a"},
|
||||
{file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ea8bb1ab9558374c0ab591783808511d135a833c3ca64a18ec927f20c4030f0"},
|
||||
{file = "coverage-7.6.8-cp311-cp311-win32.whl", hash = "sha256:629a1ba2115dce8bf75a5cce9f2486ae483cb89c0145795603d6554bdc83e801"},
|
||||
{file = "coverage-7.6.8-cp311-cp311-win_amd64.whl", hash = "sha256:fb9fc32399dca861584d96eccd6c980b69bbcd7c228d06fb74fe53e007aa8ef9"},
|
||||
{file = "coverage-7.6.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e683e6ecc587643f8cde8f5da6768e9d165cd31edf39ee90ed7034f9ca0eefee"},
|
||||
{file = "coverage-7.6.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1defe91d41ce1bd44b40fabf071e6a01a5aa14de4a31b986aa9dfd1b3e3e414a"},
|
||||
{file = "coverage-7.6.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7ad66e8e50225ebf4236368cc43c37f59d5e6728f15f6e258c8639fa0dd8e6d"},
|
||||
{file = "coverage-7.6.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fe47da3e4fda5f1abb5709c156eca207eacf8007304ce3019eb001e7a7204cb"},
|
||||
{file = "coverage-7.6.8-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:202a2d645c5a46b84992f55b0a3affe4f0ba6b4c611abec32ee88358db4bb649"},
|
||||
{file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4674f0daa1823c295845b6a740d98a840d7a1c11df00d1fd62614545c1583787"},
|
||||
{file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:74610105ebd6f33d7c10f8907afed696e79c59e3043c5f20eaa3a46fddf33b4c"},
|
||||
{file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cda8712145917105e07aab96388ae76e787270ec04bcb9d5cc786d7cbb8443"},
|
||||
{file = "coverage-7.6.8-cp312-cp312-win32.whl", hash = "sha256:9e89d5c8509fbd6c03d0dd1972925b22f50db0792ce06324ba069f10787429ad"},
|
||||
{file = "coverage-7.6.8-cp312-cp312-win_amd64.whl", hash = "sha256:379c111d3558272a2cae3d8e57e6b6e6f4fe652905692d54bad5ea0ca37c5ad4"},
|
||||
{file = "coverage-7.6.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b0c69f4f724c64dfbfe79f5dfb503b42fe6127b8d479b2677f2b227478db2eb"},
|
||||
{file = "coverage-7.6.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c15b32a7aca8038ed7644f854bf17b663bc38e1671b5d6f43f9a2b2bd0c46f63"},
|
||||
{file = "coverage-7.6.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63068a11171e4276f6ece913bde059e77c713b48c3a848814a6537f35afb8365"},
|
||||
{file = "coverage-7.6.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f4548c5ead23ad13fb7a2c8ea541357474ec13c2b736feb02e19a3085fac002"},
|
||||
{file = "coverage-7.6.8-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4b4299dd0d2c67caaaf286d58aef5e75b125b95615dda4542561a5a566a1e3"},
|
||||
{file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9ebfb2507751f7196995142f057d1324afdab56db1d9743aab7f50289abd022"},
|
||||
{file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c1b4474beee02ede1eef86c25ad4600a424fe36cff01a6103cb4533c6bf0169e"},
|
||||
{file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9fd2547e6decdbf985d579cf3fc78e4c1d662b9b0ff7cc7862baaab71c9cc5b"},
|
||||
{file = "coverage-7.6.8-cp313-cp313-win32.whl", hash = "sha256:8aae5aea53cbfe024919715eca696b1a3201886ce83790537d1c3668459c7146"},
|
||||
{file = "coverage-7.6.8-cp313-cp313-win_amd64.whl", hash = "sha256:ae270e79f7e169ccfe23284ff5ea2d52a6f401dc01b337efb54b3783e2ce3f28"},
|
||||
{file = "coverage-7.6.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de38add67a0af869b0d79c525d3e4588ac1ffa92f39116dbe0ed9753f26eba7d"},
|
||||
{file = "coverage-7.6.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b07c25d52b1c16ce5de088046cd2432b30f9ad5e224ff17c8f496d9cb7d1d451"},
|
||||
{file = "coverage-7.6.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62a66ff235e4c2e37ed3b6104d8b478d767ff73838d1222132a7a026aa548764"},
|
||||
{file = "coverage-7.6.8-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b9f848b28081e7b975a3626e9081574a7b9196cde26604540582da60235fdf"},
|
||||
{file = "coverage-7.6.8-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:093896e530c38c8e9c996901858ac63f3d4171268db2c9c8b373a228f459bbc5"},
|
||||
{file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a7b8ac36fd688c8361cbc7bf1cb5866977ece6e0b17c34aa0df58bda4fa18a4"},
|
||||
{file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:38c51297b35b3ed91670e1e4efb702b790002e3245a28c76e627478aa3c10d83"},
|
||||
{file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2e4e0f60cb4bd7396108823548e82fdab72d4d8a65e58e2c19bbbc2f1e2bfa4b"},
|
||||
{file = "coverage-7.6.8-cp313-cp313t-win32.whl", hash = "sha256:6535d996f6537ecb298b4e287a855f37deaf64ff007162ec0afb9ab8ba3b8b71"},
|
||||
{file = "coverage-7.6.8-cp313-cp313t-win_amd64.whl", hash = "sha256:c79c0685f142ca53256722a384540832420dff4ab15fec1863d7e5bc8691bdcc"},
|
||||
{file = "coverage-7.6.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ac47fa29d8d41059ea3df65bd3ade92f97ee4910ed638e87075b8e8ce69599e"},
|
||||
{file = "coverage-7.6.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:24eda3a24a38157eee639ca9afe45eefa8d2420d49468819ac5f88b10de84f4c"},
|
||||
{file = "coverage-7.6.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4c81ed2820b9023a9a90717020315e63b17b18c274a332e3b6437d7ff70abe0"},
|
||||
{file = "coverage-7.6.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd55f8fc8fa494958772a2a7302b0354ab16e0b9272b3c3d83cdb5bec5bd1779"},
|
||||
{file = "coverage-7.6.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f39e2f3530ed1626c66e7493be7a8423b023ca852aacdc91fb30162c350d2a92"},
|
||||
{file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:716a78a342679cd1177bc8c2fe957e0ab91405bd43a17094324845200b2fddf4"},
|
||||
{file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:177f01eeaa3aee4a5ffb0d1439c5952b53d5010f86e9d2667963e632e30082cc"},
|
||||
{file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:912e95017ff51dc3d7b6e2be158dedc889d9a5cc3382445589ce554f1a34c0ea"},
|
||||
{file = "coverage-7.6.8-cp39-cp39-win32.whl", hash = "sha256:4db3ed6a907b555e57cc2e6f14dc3a4c2458cdad8919e40b5357ab9b6db6c43e"},
|
||||
{file = "coverage-7.6.8-cp39-cp39-win_amd64.whl", hash = "sha256:428ac484592f780e8cd7b6b14eb568f7c85460c92e2a37cb0c0e5186e1a0d076"},
|
||||
{file = "coverage-7.6.8-pp39.pp310-none-any.whl", hash = "sha256:5c52a036535d12590c32c49209e79cabaad9f9ad8aa4cbd875b68c4d67a9cbce"},
|
||||
{file = "coverage-7.6.8.tar.gz", hash = "sha256:8b2b8503edb06822c86d82fa64a4a5cb0760bb8f31f26e138ec743f422f37cfc"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@@ -627,13 +625,13 @@ cli = ["requests"]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.115.4"
|
||||
version = "0.115.5"
|
||||
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "fastapi-0.115.4-py3-none-any.whl", hash = "sha256:0b504a063ffb3cf96a5e27dc1bc32c80ca743a2528574f9cdc77daa2d31b4742"},
|
||||
{file = "fastapi-0.115.4.tar.gz", hash = "sha256:db653475586b091cb8b2fec2ac54a680ac6a158e07406e1abae31679e8826349"},
|
||||
{file = "fastapi-0.115.5-py3-none-any.whl", hash = "sha256:596b95adbe1474da47049e802f9a65ab2ffa9c2b07e7efee70eb8a66c9f2f796"},
|
||||
{file = "fastapi-0.115.5.tar.gz", hash = "sha256:0e7a4d0dc0d01c68df21887cce0945e72d3c48b9f4f79dfe7a7d53aa08fbb289"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -841,51 +839,58 @@ trio = ["trio (>=0.22.0,<0.23.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.6.1"
|
||||
version = "0.6.4"
|
||||
description = "A collection of framework independent HTTP protocol utils."
|
||||
optional = false
|
||||
python-versions = ">=3.8.0"
|
||||
files = [
|
||||
{file = "httptools-0.6.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2f6c3c4cb1948d912538217838f6e9960bc4a521d7f9b323b3da579cd14532f"},
|
||||
{file = "httptools-0.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:00d5d4b68a717765b1fabfd9ca755bd12bf44105eeb806c03d1962acd9b8e563"},
|
||||
{file = "httptools-0.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:639dc4f381a870c9ec860ce5c45921db50205a37cc3334e756269736ff0aac58"},
|
||||
{file = "httptools-0.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e57997ac7fb7ee43140cc03664de5f268813a481dff6245e0075925adc6aa185"},
|
||||
{file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0ac5a0ae3d9f4fe004318d64b8a854edd85ab76cffbf7ef5e32920faef62f142"},
|
||||
{file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3f30d3ce413088a98b9db71c60a6ada2001a08945cb42dd65a9a9fe228627658"},
|
||||
{file = "httptools-0.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:1ed99a373e327f0107cb513b61820102ee4f3675656a37a50083eda05dc9541b"},
|
||||
{file = "httptools-0.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7a7ea483c1a4485c71cb5f38be9db078f8b0e8b4c4dc0210f531cdd2ddac1ef1"},
|
||||
{file = "httptools-0.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85ed077c995e942b6f1b07583e4eb0a8d324d418954fc6af913d36db7c05a5a0"},
|
||||
{file = "httptools-0.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b0bb634338334385351a1600a73e558ce619af390c2b38386206ac6a27fecfc"},
|
||||
{file = "httptools-0.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9ceb2c957320def533671fc9c715a80c47025139c8d1f3797477decbc6edd2"},
|
||||
{file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4f0f8271c0a4db459f9dc807acd0eadd4839934a4b9b892f6f160e94da309837"},
|
||||
{file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6a4f5ccead6d18ec072ac0b84420e95d27c1cdf5c9f1bc8fbd8daf86bd94f43d"},
|
||||
{file = "httptools-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:5cceac09f164bcba55c0500a18fe3c47df29b62353198e4f37bbcc5d591172c3"},
|
||||
{file = "httptools-0.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:75c8022dca7935cba14741a42744eee13ba05db00b27a4b940f0d646bd4d56d0"},
|
||||
{file = "httptools-0.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:48ed8129cd9a0d62cf4d1575fcf90fb37e3ff7d5654d3a5814eb3d55f36478c2"},
|
||||
{file = "httptools-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f58e335a1402fb5a650e271e8c2d03cfa7cea46ae124649346d17bd30d59c90"},
|
||||
{file = "httptools-0.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ad80d7176aa5788902f207a4e79885f0576134695dfb0fefc15b7a4648d503"},
|
||||
{file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9bb68d3a085c2174c2477eb3ffe84ae9fb4fde8792edb7bcd09a1d8467e30a84"},
|
||||
{file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b512aa728bc02354e5ac086ce76c3ce635b62f5fbc32ab7082b5e582d27867bb"},
|
||||
{file = "httptools-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:97662ce7fb196c785344d00d638fc9ad69e18ee4bfb4000b35a52efe5adcc949"},
|
||||
{file = "httptools-0.6.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8e216a038d2d52ea13fdd9b9c9c7459fb80d78302b257828285eca1c773b99b3"},
|
||||
{file = "httptools-0.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3e802e0b2378ade99cd666b5bffb8b2a7cc8f3d28988685dc300469ea8dd86cb"},
|
||||
{file = "httptools-0.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bd3e488b447046e386a30f07af05f9b38d3d368d1f7b4d8f7e10af85393db97"},
|
||||
{file = "httptools-0.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe467eb086d80217b7584e61313ebadc8d187a4d95bb62031b7bab4b205c3ba3"},
|
||||
{file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3c3b214ce057c54675b00108ac42bacf2ab8f85c58e3f324a4e963bbc46424f4"},
|
||||
{file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8ae5b97f690badd2ca27cbf668494ee1b6d34cf1c464271ef7bfa9ca6b83ffaf"},
|
||||
{file = "httptools-0.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:405784577ba6540fa7d6ff49e37daf104e04f4b4ff2d1ac0469eaa6a20fde084"},
|
||||
{file = "httptools-0.6.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:95fb92dd3649f9cb139e9c56604cc2d7c7bf0fc2e7c8d7fbd58f96e35eddd2a3"},
|
||||
{file = "httptools-0.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dcbab042cc3ef272adc11220517278519adf8f53fd3056d0e68f0a6f891ba94e"},
|
||||
{file = "httptools-0.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cf2372e98406efb42e93bfe10f2948e467edfd792b015f1b4ecd897903d3e8d"},
|
||||
{file = "httptools-0.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:678fcbae74477a17d103b7cae78b74800d795d702083867ce160fc202104d0da"},
|
||||
{file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e0b281cf5a125c35f7f6722b65d8542d2e57331be573e9e88bc8b0115c4a7a81"},
|
||||
{file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:95658c342529bba4e1d3d2b1a874db16c7cca435e8827422154c9da76ac4e13a"},
|
||||
{file = "httptools-0.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:7ebaec1bf683e4bf5e9fbb49b8cc36da482033596a415b3e4ebab5a4c0d7ec5e"},
|
||||
{file = "httptools-0.6.1.tar.gz", hash = "sha256:c6e26c30455600b95d94b1b836085138e82f177351454ee841c148f93a9bad5a"},
|
||||
{file = "httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0"},
|
||||
{file = "httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da"},
|
||||
{file = "httptools-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deee0e3343f98ee8047e9f4c5bc7cedbf69f5734454a94c38ee829fb2d5fa3c1"},
|
||||
{file = "httptools-0.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca80b7485c76f768a3bc83ea58373f8db7b015551117375e4918e2aa77ea9b50"},
|
||||
{file = "httptools-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90d96a385fa941283ebd231464045187a31ad932ebfa541be8edf5b3c2328959"},
|
||||
{file = "httptools-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:59e724f8b332319e2875efd360e61ac07f33b492889284a3e05e6d13746876f4"},
|
||||
{file = "httptools-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:c26f313951f6e26147833fc923f78f95604bbec812a43e5ee37f26dc9e5a686c"},
|
||||
{file = "httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069"},
|
||||
{file = "httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a"},
|
||||
{file = "httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975"},
|
||||
{file = "httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636"},
|
||||
{file = "httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721"},
|
||||
{file = "httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988"},
|
||||
{file = "httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17"},
|
||||
{file = "httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2"},
|
||||
{file = "httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44"},
|
||||
{file = "httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1"},
|
||||
{file = "httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2"},
|
||||
{file = "httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81"},
|
||||
{file = "httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f"},
|
||||
{file = "httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970"},
|
||||
{file = "httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660"},
|
||||
{file = "httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083"},
|
||||
{file = "httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3"},
|
||||
{file = "httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071"},
|
||||
{file = "httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5"},
|
||||
{file = "httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0"},
|
||||
{file = "httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8"},
|
||||
{file = "httptools-0.6.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:d3f0d369e7ffbe59c4b6116a44d6a8eb4783aae027f2c0b366cf0aa964185dba"},
|
||||
{file = "httptools-0.6.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:94978a49b8f4569ad607cd4946b759d90b285e39c0d4640c6b36ca7a3ddf2efc"},
|
||||
{file = "httptools-0.6.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40dc6a8e399e15ea525305a2ddba998b0af5caa2566bcd79dcbe8948181eeaff"},
|
||||
{file = "httptools-0.6.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab9ba8dcf59de5181f6be44a77458e45a578fc99c31510b8c65b7d5acc3cf490"},
|
||||
{file = "httptools-0.6.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:fc411e1c0a7dcd2f902c7c48cf079947a7e65b5485dea9decb82b9105ca71a43"},
|
||||
{file = "httptools-0.6.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:d54efd20338ac52ba31e7da78e4a72570cf729fac82bc31ff9199bedf1dc7440"},
|
||||
{file = "httptools-0.6.4-cp38-cp38-win_amd64.whl", hash = "sha256:df959752a0c2748a65ab5387d08287abf6779ae9165916fe053e68ae1fbdc47f"},
|
||||
{file = "httptools-0.6.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:85797e37e8eeaa5439d33e556662cc370e474445d5fab24dcadc65a8ffb04003"},
|
||||
{file = "httptools-0.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:db353d22843cf1028f43c3651581e4bb49374d85692a85f95f7b9a130e1b2cab"},
|
||||
{file = "httptools-0.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1ffd262a73d7c28424252381a5b854c19d9de5f56f075445d33919a637e3547"},
|
||||
{file = "httptools-0.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:703c346571fa50d2e9856a37d7cd9435a25e7fd15e236c397bf224afaa355fe9"},
|
||||
{file = "httptools-0.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aafe0f1918ed07b67c1e838f950b1c1fabc683030477e60b335649b8020e1076"},
|
||||
{file = "httptools-0.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0e563e54979e97b6d13f1bbc05a96109923e76b901f786a5eae36e99c01237bd"},
|
||||
{file = "httptools-0.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:b799de31416ecc589ad79dd85a0b2657a8fe39327944998dea368c1d4c9e55e6"},
|
||||
{file = "httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
test = ["Cython (>=0.29.24,<0.30.0)"]
|
||||
test = ["Cython (>=0.29.24)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
@@ -1464,13 +1469,13 @@ pyyaml = ">=5.1"
|
||||
|
||||
[[package]]
|
||||
name = "mkdocs-material"
|
||||
version = "9.5.44"
|
||||
version = "9.5.46"
|
||||
description = "Documentation that simply works"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "mkdocs_material-9.5.44-py3-none-any.whl", hash = "sha256:47015f9c167d58a5ff5e682da37441fc4d66a1c79334bfc08d774763cacf69ca"},
|
||||
{file = "mkdocs_material-9.5.44.tar.gz", hash = "sha256:f3a6c968e524166b3f3ed1fb97d3ed3e0091183b0545cedf7156a2a6804c56c0"},
|
||||
{file = "mkdocs_material-9.5.46-py3-none-any.whl", hash = "sha256:98f0a2039c62e551a68aad0791a8d41324ff90c03a6e6cea381a384b84908b83"},
|
||||
{file = "mkdocs_material-9.5.46.tar.gz", hash = "sha256:ae2043f4238e572f9a40e0b577f50400d6fc31e2fef8ea141800aebf3bd273d7"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1598,13 +1603,13 @@ signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "1.54.3"
|
||||
version = "1.55.1"
|
||||
description = "The official Python library for the openai API"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "openai-1.54.3-py3-none-any.whl", hash = "sha256:f18dbaf09c50d70c4185b892a2a553f80681d1d866323a2da7f7be2f688615d5"},
|
||||
{file = "openai-1.54.3.tar.gz", hash = "sha256:7511b74eeb894ac0b0253dc71f087a15d2e4d71d22d0088767205143d880cca6"},
|
||||
{file = "openai-1.55.1-py3-none-any.whl", hash = "sha256:d10d96a4f9dc5f05d38dea389119ec8dcd24bc9698293c8357253c601b4a77a5"},
|
||||
{file = "openai-1.55.1.tar.gz", hash = "sha256:471324321e7739214f16a544e801947a046d3c5d516fae8719a317234e4968d3"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2237,13 +2242,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.9.0"
|
||||
version = "2.10.0"
|
||||
description = "JSON Web Token implementation in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"},
|
||||
{file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"},
|
||||
{file = "PyJWT-2.10.0-py3-none-any.whl", hash = "sha256:543b77207db656de204372350926bed5a86201c4cbff159f623f79c7bb487a15"},
|
||||
{file = "pyjwt-2.10.0.tar.gz", hash = "sha256:7628a7eb7938959ac1b26e819a1df0fd3259505627b575e4bad6d08f76db695c"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@@ -2815,29 +2820,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.7.3"
|
||||
version = "0.8.0"
|
||||
description = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "ruff-0.7.3-py3-none-linux_armv6l.whl", hash = "sha256:34f2339dc22687ec7e7002792d1f50712bf84a13d5152e75712ac08be565d344"},
|
||||
{file = "ruff-0.7.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fb397332a1879b9764a3455a0bb1087bda876c2db8aca3a3cbb67b3dbce8cda0"},
|
||||
{file = "ruff-0.7.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37d0b619546103274e7f62643d14e1adcbccb242efda4e4bdb9544d7764782e9"},
|
||||
{file = "ruff-0.7.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d59f0c3ee4d1a6787614e7135b72e21024875266101142a09a61439cb6e38a5"},
|
||||
{file = "ruff-0.7.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:44eb93c2499a169d49fafd07bc62ac89b1bc800b197e50ff4633aed212569299"},
|
||||
{file = "ruff-0.7.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d0242ce53f3a576c35ee32d907475a8d569944c0407f91d207c8af5be5dae4e"},
|
||||
{file = "ruff-0.7.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6b6224af8b5e09772c2ecb8dc9f3f344c1aa48201c7f07e7315367f6dd90ac29"},
|
||||
{file = "ruff-0.7.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c50f95a82b94421c964fae4c27c0242890a20fe67d203d127e84fbb8013855f5"},
|
||||
{file = "ruff-0.7.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f3eff9961b5d2644bcf1616c606e93baa2d6b349e8aa8b035f654df252c8c67"},
|
||||
{file = "ruff-0.7.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8963cab06d130c4df2fd52c84e9f10d297826d2e8169ae0c798b6221be1d1d2"},
|
||||
{file = "ruff-0.7.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:61b46049d6edc0e4317fb14b33bd693245281a3007288b68a3f5b74a22a0746d"},
|
||||
{file = "ruff-0.7.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:10ebce7696afe4644e8c1a23b3cf8c0f2193a310c18387c06e583ae9ef284de2"},
|
||||
{file = "ruff-0.7.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3f36d56326b3aef8eeee150b700e519880d1aab92f471eefdef656fd57492aa2"},
|
||||
{file = "ruff-0.7.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5d024301109a0007b78d57ab0ba190087b43dce852e552734ebf0b0b85e4fb16"},
|
||||
{file = "ruff-0.7.3-py3-none-win32.whl", hash = "sha256:4ba81a5f0c5478aa61674c5a2194de8b02652f17addf8dfc40c8937e6e7d79fc"},
|
||||
{file = "ruff-0.7.3-py3-none-win_amd64.whl", hash = "sha256:588a9ff2fecf01025ed065fe28809cd5a53b43505f48b69a1ac7707b1b7e4088"},
|
||||
{file = "ruff-0.7.3-py3-none-win_arm64.whl", hash = "sha256:1713e2c5545863cdbfe2cbce21f69ffaf37b813bfd1fb3b90dc9a6f1963f5a8c"},
|
||||
{file = "ruff-0.7.3.tar.gz", hash = "sha256:e1d1ba2e40b6e71a61b063354d04be669ab0d39c352461f3d789cac68b54a313"},
|
||||
{file = "ruff-0.8.0-py3-none-linux_armv6l.whl", hash = "sha256:fcb1bf2cc6706adae9d79c8d86478677e3bbd4ced796ccad106fd4776d395fea"},
|
||||
{file = "ruff-0.8.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:295bb4c02d58ff2ef4378a1870c20af30723013f441c9d1637a008baaf928c8b"},
|
||||
{file = "ruff-0.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b1f1c76b47c18fa92ee78b60d2d20d7e866c55ee603e7d19c1e991fad933a9a"},
|
||||
{file = "ruff-0.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb0d4f250a7711b67ad513fde67e8870109e5ce590a801c3722580fe98c33a99"},
|
||||
{file = "ruff-0.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e55cce9aa93c5d0d4e3937e47b169035c7e91c8655b0974e61bb79cf398d49c"},
|
||||
{file = "ruff-0.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f4cd64916d8e732ce6b87f3f5296a8942d285bbbc161acee7fe561134af64f9"},
|
||||
{file = "ruff-0.8.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c5c1466be2a2ebdf7c5450dd5d980cc87c8ba6976fb82582fea18823da6fa362"},
|
||||
{file = "ruff-0.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2dabfd05b96b7b8f2da00d53c514eea842bff83e41e1cceb08ae1966254a51df"},
|
||||
{file = "ruff-0.8.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:facebdfe5a5af6b1588a1d26d170635ead6892d0e314477e80256ef4a8470cf3"},
|
||||
{file = "ruff-0.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87a8e86bae0dbd749c815211ca11e3a7bd559b9710746c559ed63106d382bd9c"},
|
||||
{file = "ruff-0.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85e654f0ded7befe2d61eeaf3d3b1e4ef3894469cd664ffa85006c7720f1e4a2"},
|
||||
{file = "ruff-0.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:83a55679c4cb449fa527b8497cadf54f076603cc36779b2170b24f704171ce70"},
|
||||
{file = "ruff-0.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:812e2052121634cf13cd6fddf0c1871d0ead1aad40a1a258753c04c18bb71bbd"},
|
||||
{file = "ruff-0.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:780d5d8523c04202184405e60c98d7595bdb498c3c6abba3b6d4cdf2ca2af426"},
|
||||
{file = "ruff-0.8.0-py3-none-win32.whl", hash = "sha256:5fdb6efecc3eb60bba5819679466471fd7d13c53487df7248d6e27146e985468"},
|
||||
{file = "ruff-0.8.0-py3-none-win_amd64.whl", hash = "sha256:582891c57b96228d146725975fbb942e1f30a0c4ba19722e692ca3eb25cc9b4f"},
|
||||
{file = "ruff-0.8.0-py3-none-win_arm64.whl", hash = "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6"},
|
||||
{file = "ruff-0.8.0.tar.gz", hash = "sha256:a7ccfe6331bf8c8dad715753e157457faf7351c2b69f62f32c165c2dbcbacd44"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3152,20 +3157,20 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.32.0"
|
||||
version = "0.32.1"
|
||||
description = "The lightning-fast ASGI server."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "uvicorn-0.32.0-py3-none-any.whl", hash = "sha256:60b8f3a5ac027dcd31448f411ced12b5ef452c646f76f02f8cc3f25d8d26fd82"},
|
||||
{file = "uvicorn-0.32.0.tar.gz", hash = "sha256:f78b36b143c16f54ccdb8190d0a26b5f1901fe5a3c777e1ab29f26391af8551e"},
|
||||
{file = "uvicorn-0.32.1-py3-none-any.whl", hash = "sha256:82ad92fd58da0d12af7482ecdb5f2470a04c9c9a53ced65b9bbb4a205377602e"},
|
||||
{file = "uvicorn-0.32.1.tar.gz", hash = "sha256:ee9519c246a72b1c084cea8d3b44ed6026e78a4a309cbedae9c37e4cb9fbb175"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
click = ">=7.0"
|
||||
colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""}
|
||||
h11 = ">=0.8"
|
||||
httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""}
|
||||
httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""}
|
||||
python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
|
||||
pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
|
||||
typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""}
|
||||
@@ -3174,7 +3179,7 @@ watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standar
|
||||
websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
|
||||
|
||||
[package.extras]
|
||||
standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"]
|
||||
standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
@@ -3416,4 +3421,4 @@ pgsql = ["psycopg2-binary"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.10"
|
||||
content-hash = "2a3b97688c700f6c01241c0559afa48bdf039399261e7cdd68eebad96dadb44f"
|
||||
content-hash = "4d6f1e1665de07327fd42e27bddcff9b5e6b3f7b64bcf8782490e2acdaaf2b9a"
|
||||
|
||||
@@ -3,7 +3,7 @@ authors = ["Hayden <hay-kot@pm.me>"]
|
||||
description = "A Recipe Manager"
|
||||
license = "AGPL"
|
||||
name = "mealie"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
start = "mealie.app:main"
|
||||
@@ -64,7 +64,7 @@ pylint = "^3.0.0"
|
||||
pytest = "^8.0.0"
|
||||
pytest-asyncio = "^0.24.0"
|
||||
rich = "^13.5.2"
|
||||
ruff = "^0.7.0"
|
||||
ruff = "^0.8.0"
|
||||
types-PyYAML = "^6.0.4"
|
||||
types-python-dateutil = "^2.8.18"
|
||||
types-python-slugify = "^6.0.0"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -31,6 +33,21 @@ def test_get_all_invitation(api_client: TestClient, unique_user: TestUser, invit
|
||||
assert item["token"] == invite
|
||||
|
||||
|
||||
def test_create_invitation(api_client: TestClient, unique_user: TestUser) -> None:
|
||||
# Create invitation for the same group as user
|
||||
r = api_client.post(api_routes.households_invitations, json={"uses": 1}, headers=unique_user.token)
|
||||
assert r.status_code == 201
|
||||
|
||||
# Create invitation for other group as user
|
||||
body = {
|
||||
"uses": 1,
|
||||
"groupId": str(uuid4()),
|
||||
"householdId": str(uuid4()),
|
||||
}
|
||||
r = api_client.post(api_routes.households_invitations, json=body, headers=unique_user.token)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def register_user(api_client: TestClient, invite: str):
|
||||
# Test User can Join Group
|
||||
registration = user_registration_factory()
|
||||
|
||||
@@ -40,7 +40,7 @@ test_cleaner_data = [
|
||||
def test_cleaner_clean(json_file: Path, num_steps):
|
||||
translator = local_provider()
|
||||
recipe_data = cleaner.clean(json.loads(json_file.read_text()), translator)
|
||||
assert len(recipe_data["recipeInstructions"]) == num_steps
|
||||
assert len(recipe_data.recipe_instructions or []) == num_steps
|
||||
|
||||
|
||||
def test_html_with_recipe_data():
|
||||
|
||||
@@ -275,22 +275,102 @@ yield_test_cases = (
|
||||
CleanerCase(
|
||||
test_id="empty string",
|
||||
input="",
|
||||
expected="",
|
||||
expected=(0, 0, ""),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="regular string",
|
||||
input="4 Batches",
|
||||
expected=(0, 4, "Batches"),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="regular serving string",
|
||||
input="4 Servings",
|
||||
expected=(4, 0, ""),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="regular string with whitespace",
|
||||
input="4 Batches ",
|
||||
expected=(0, 4, "Batches"),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="regular serving string with whitespace",
|
||||
input="4 Servings ",
|
||||
expected=(4, 0, ""),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="list of strings",
|
||||
input=["Makes 4 Batches", "4 Batches"],
|
||||
expected="4 Batches",
|
||||
input=["Serves 2", "4 Batches", "5 Batches"],
|
||||
expected=(2, 5, "Batches"),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="basic string",
|
||||
input="Makes a lot of Batches",
|
||||
expected=(0, 0, "Makes a lot of Batches"),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="basic serving string",
|
||||
input="Makes 4 Batches",
|
||||
expected="Makes 4 Batches",
|
||||
expected=(4, 0, ""),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="empty list",
|
||||
input=[],
|
||||
expected="",
|
||||
expected=(0, 0, ""),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="basic fraction",
|
||||
input="1/2 Batches",
|
||||
expected=(0, 0.5, "Batches"),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="mixed fraction",
|
||||
input="1 1/2 Batches",
|
||||
expected=(0, 1.5, "Batches"),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="improper fraction",
|
||||
input="11/2 Batches",
|
||||
expected=(0, 5.5, "Batches"),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="vulgar fraction",
|
||||
input="¾ Batches",
|
||||
expected=(0, 0.75, "Batches"),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="mixed vulgar fraction",
|
||||
input="2¾ Batches",
|
||||
expected=(0, 2.75, "Batches"),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="mixed vulgar fraction with space",
|
||||
input="2 ¾ Batches",
|
||||
expected=(0, 2.75, "Batches"),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="basic decimal",
|
||||
input="0.5 Batches",
|
||||
expected=(0, 0.5, "Batches"),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="text with numbers",
|
||||
input="6 Batches or 10 Batches",
|
||||
expected=(0, 6, "Batches or 10 Batches"),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="no qty",
|
||||
input="A Lot of Servings",
|
||||
expected=(0, 0, "A Lot of Servings"),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="invalid qty",
|
||||
input="1/0 Batches",
|
||||
expected=(0, 0, "1/0 Batches"),
|
||||
),
|
||||
CleanerCase(
|
||||
test_id="int as float",
|
||||
input="3.0 Batches",
|
||||
expected=(0, 3, "Batches"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user