Compare commits

..

2 Commits

Author SHA1 Message Date
Hayden
77d01adf15 fix: add --no-project flags to prevent mealie installation 2025-12-20 20:54:39 -06:00
Hayden
b0d85bc406 ci(docs): isolate docs dependencies to avoid python-ldap build
Add dedicated docs dependency group with only mkdocs-material to avoid
installing python-ldap during docs builds. python-ldap requires OpenLDAP
dev headers (libldap2-dev) which aren't available on standard CI runners.
2025-12-20 20:31:33 -06:00
387 changed files with 58745 additions and 61312 deletions

View File

@@ -1,10 +1,9 @@
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.224.2/containers/python-3/.devcontainer/base.Dockerfile
# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster
ARG VARIANT="3.12-bullseye"
FROM mcr.microsoft.com/devcontainers/python:${VARIANT}
# Remove outdated yarn GPG key, if it exists
RUN rm -f /etc/apt/sources.list.d/yarn.list /usr/share/keyrings/yarn-archive-keyring.gpg || true
# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
ARG NODE_VERSION="none"
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi

View File

@@ -1,113 +0,0 @@
name: Auto-merge l10n PRs
on:
pull_request:
types: [opened, synchronize, labeled]
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
runs-on: ubuntu-latest
if: contains(github.event.pull_request.labels.*.name, 'l10n')
steps:
- name: Validate PR author
env:
AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
if [[
"$AUTHOR" != "hay-kot" &&
"$AUTHOR" != "github-actions[bot]" &&
"$AUTHOR" != "mealie-actions[bot]"
]]; then
echo "::error::PR author must be hay-kot, github-actions[bot], or mealie-actions[bot] for auto-merge (got: $AUTHOR)"
exit 1
fi
echo "Author validated: $AUTHOR"
- name: Validate PR size
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
ADDITIONS=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json additions --jq '.additions')
DELETIONS=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json deletions --jq '.deletions')
TOTAL=$((ADDITIONS + DELETIONS))
echo "PR changes: +$ADDITIONS -$DELETIONS (total: $TOTAL lines)"
if [ "$TOTAL" -gt 400 ]; then
echo "::error::PR exceeds 400 line change limit ($TOTAL lines)"
exit 1
fi
- name: Validate file paths
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
FILES=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json files --jq '.files[].path')
for file in $FILES; do
# Check if file matches any allowed path
if [[ "$file" == "frontend/composables/use-locales/available-locales.ts" ]] || \
[[ "$file" =~ ^frontend/lang/ ]] || \
[[ "$file" =~ ^mealie/repos/seed/resources/[^/]+/locales/ ]]; then
continue
fi
# File doesn't match allowed paths
echo "::error::Invalid file path: $file"
echo "Only the following paths are allowed:"
echo " - frontend/composables/use-locales/available-locales.ts"
echo " - frontend/lang/"
echo " - mealie/repos/seed/resources/*/locales/"
exit 1
done
echo "All files are in allowed paths"
- name: Approve PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
APPROVED=$(gh pr view "$PR_NUMBER" \
--repo "$REPO" \
--json reviews \
--jq '.reviews[] | select(.state == "APPROVED") | .id' \
| wc -l)
if [ "$APPROVED" -gt 0 ]; then
echo "PR already approved"
exit 0
fi
gh pr review "$PR_NUMBER" \
--repo "$REPO" \
--approve \
--body "Auto-approved: l10n PR from trusted author with valid file paths"
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
- name: Enable auto-merge
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
gh pr merge "$PR_NUMBER" \
--repo "$REPO" \
--auto \
--squash

View File

@@ -15,17 +15,10 @@ jobs:
sync-locales:
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
@@ -112,7 +105,7 @@ jobs:
- Updated frontend locale files
- Generated from latest translation sources" \
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: No changes detected
if: steps.changes.outputs.has_changes == 'false'

View File

@@ -37,17 +37,6 @@ jobs:
- uses: depot/setup-action@v1
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
hkotel/mealie
ghcr.io/${{ github.repository }}
# Overwrite the image.version label with our tag
labels: |
org.opencontainers.image.version=${{ inputs.tag }}
- name: Retrieve Python package
uses: actions/download-artifact@v4
with:
@@ -68,6 +57,5 @@ jobs:
hkotel/mealie:${{ inputs.tag }}
ghcr.io/${{ github.repository }}:${{ inputs.tag }}
${{ inputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
COMMIT=${{ github.sha }}

View File

@@ -4,19 +4,14 @@ on:
pull_request:
branches:
- mealie-next
merge_group:
types: [checks_requested]
branches:
- mealie-next
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.merge_group.head_ref }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
pull-request-lint:
name: "Lint PR"
if: github.event_name == 'pull_request'
uses: ./.github/workflows/pull-request-lint.yml
backend-tests:
@@ -29,7 +24,6 @@ jobs:
container-scanning:
name: "Trivy Container Scanning"
if: github.event_name == 'pull_request'
uses: ./.github/workflows/partial-trivy-container-scanning.yml
code-ql:
@@ -53,10 +47,7 @@ jobs:
publish-image:
name: "Publish PR Image"
if: |
github.event_name == 'pull_request' &&
contains(github.event.pull_request.labels.*.name, 'build-image') &&
github.repository == 'mealie-recipes/mealie'
if: contains(github.event.pull_request.labels.*.name, 'build-image') && github.repository == 'mealie-recipes/mealie'
permissions:
contents: read
packages: write

View File

@@ -40,18 +40,12 @@ jobs:
shell: bash
run: pre-commit autoupdate --color=always
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
- name: Create Pull Request
id: create-pr
uses: peter-evans/create-pull-request@v6
# This doesn't currently work for us because it creates the PR but the workflows don't run.
# TODO: Provide a personal access token as a parameter here, that solves that problem.
# https://github.com/peter-evans/create-pull-request
with:
token: ${{ steps.app-token.outputs.token }}
commit-message: "Update pre-commit hooks"
branch: "fix/update-pre-commit-hooks"
labels: |
@@ -60,38 +54,3 @@ jobs:
base: mealie-next
title: "chore(auto): Update pre-commit hooks"
body: "Auto-generated by `.github/workflows/scheduled-checks.yml`"
- name: Approve PR
if: steps.create-pr.outputs.pull-request-number
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_NUMBER: ${{ steps.create-pr.outputs.pull-request-number }}
REPO: ${{ github.repository }}
run: |
APPROVED=$(gh pr view "$PR_NUMBER" \
--repo "$REPO" \
--json reviews \
--jq '.reviews[] | select(.state == "APPROVED") | .id' \
| wc -l)
if [ "$APPROVED" -gt 0 ]; then
echo "PR already approved"
exit 0
fi
gh pr review "$PR_NUMBER" \
--repo "$REPO" \
--approve \
--body "Auto-approved: Pre-commit hook updates"
- name: Enable auto-merge
if: steps.create-pr.outputs.pull-request-number
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.create-pr.outputs.pull-request-number }}
REPO: ${{ github.repository }}
run: |
gh pr merge "$PR_NUMBER" \
--repo "$REPO" \
--auto \
--squash

5
.gitignore vendored
View File

@@ -70,11 +70,8 @@ wheels/
.installed.cfg
*.egg
# packaged output - temporarily written here by `uv build`
/mealie-*
# frontend copied into Python module for packaging purposes
/mealie/frontend
/mealie/frontend/
# PyInstaller
# Usually these files are written by a python script from a template

View File

@@ -12,7 +12,7 @@ repos:
exclude: ^tests/data/
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.2
rev: v0.14.9
hooks:
- id: ruff
- id: ruff-format

View File

@@ -25,9 +25,16 @@ dotenv:
- .env
- .dev.env
tasks:
docs:gen:
desc: runs the API documentation generator
cmds:
- uv run python dev/code-generation/gen_docs_api.py
docs:
desc: runs the documentation server
dir: docs
deps:
- docs:gen
cmds:
- uv run python -m mkdocs serve
@@ -40,6 +47,8 @@ tasks:
sources:
- package.json
- yarn.lock
generates:
- node_modules/**
setup:py:
desc: setup python dependencies
@@ -52,18 +61,6 @@ tasks:
- pyproject.toml
- .pre-commit-config.yaml
setup:e2e:
desc: setup e2e test dependencies
dir: tests/e2e
run: once
cmds:
- yarn install
- yarn playwright install --with-deps
sources:
- package.json
- playwright.config.ts
- yarn.lock
setup:
desc: setup all dependencies
deps:
@@ -74,6 +71,7 @@ tasks:
desc: run code generators
cmds:
- uv run python dev/code-generation/main.py {{ .CLI_ARGS }}
- task: docs:gen
- task: py:format
dev:services:
@@ -181,21 +179,12 @@ tasks:
status:
- '{{ .SKIP_PACKAGE_DEPS | default "false"}}'
py:package:build:
internal: true
py:package:
desc: builds Python packages (sdist and wheel) in top-level dist directory
deps:
- py:package:deps
cmds:
- uv build --out-dir dist
sources:
- uv.lock
- pyproject.toml
- mealie/**
py:package:
desc: builds Python packages (sdist and wheel) in top-level dist directory
cmds:
- task: py:package:build
- task: py:package:generate-requirements
py:
@@ -226,12 +215,6 @@ tasks:
dir: frontend
cmds:
- yarn build
sources:
- "**"
- exclude: .nuxt/**
- exclude: .output/**
- exclude: dist/**
- exclude: node_modules/.cache/**
ui:generate:
desc: generates a static version of the frontend in frontend/dist
@@ -240,36 +223,18 @@ tasks:
- setup:ui
cmds:
- yarn generate
sources:
- "**"
- exclude: .nuxt/**
- exclude: .output/**
- exclude: dist/**
- exclude: node_modules/.cache/**
ui:lint:
desc: runs the frontend linter
dir: frontend
cmds:
- yarn lint --max-warnings=0
sources:
- "**"
- exclude: .nuxt/**
- exclude: .output/**
- exclude: dist/**
- exclude: node_modules/.cache/**
ui:test:
desc: runs the frontend tests
dir: frontend
cmds:
- yarn test
sources:
- "**"
- exclude: .nuxt/**
- exclude: .output/**
- exclude: dist/**
- exclude: node_modules/.cache/**
ui:check:
desc: runs all frontend checks
@@ -298,48 +263,3 @@ tasks:
dir: docker
cmds:
- docker compose -f docker-compose.yml -p mealie up -d --build
e2e:build-image:
desc: builds the e2e test docker image
deps:
- py:package
cmds:
- docker build --tag mealie:e2e --file docker/Dockerfile --build-context packages=dist .
sources:
- docker/Dockerfile
- dist/**
e2e:start-server:
desc: Builds the image and starts the containers for e2e testing
dir: tests/e2e/docker
deps:
- e2e:build-image
vars:
WAIT_UNTIL_HEALTHY: '{{if .WAIT_UNTIL_HEALTHY}}--wait{{else}}{{end}}'
cmds:
- docker compose up -d {{.WAIT_UNTIL_HEALTHY}}
e2e:stop-server:
desc: Shuts down the e2e testing containers
dir: tests/e2e/docker
cmds:
- docker compose down --volumes
e2e:test:
desc: runs the e2e tests
dir: tests/e2e
deps:
- setup:e2e
vars:
PREVENT_REPORT_OPEN: '{{if .PREVENT_REPORT_OPEN}}PLAYWRIGHT_HTML_OPEN=never{{else}}{{end}}'
cmds:
- '{{.PREVENT_REPORT_OPEN}} yarn playwright test'
e2e:
desc: runs the full e2e test suite
cmds:
- task: e2e:start-server
vars: { WAIT_UNTIL_HEALTHY: true }
- defer: { task: e2e:stop-server }
- task: e2e:test
vars: { PREVENT_REPORT_OPEN: true }

View File

@@ -0,0 +1,80 @@
import json
from datetime import UTC, datetime
from typing import Any
from fastapi import FastAPI
from mealie.app import app
from mealie.core.config import determine_data_dir
DATA_DIR = determine_data_dir()
"""Script to export the ReDoc documentation page into a standalone HTML file."""
HTML_TEMPLATE = """<!-- Custom HTML site displayed as the Home chapter -->
{% extends "main.html" %}
{% block tabs %}
{{ super() }}
<style>
body {
margin: 0;
padding: 0;
}
</style>
<div id="redoc-container"></div>
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"> </script>
<script>
var spec = MY_SPECIFIC_TEXT;
Redoc.init(spec, {}, document.getElementById("redoc-container"));
</script>
{% endblock %}
{% block content %}{% endblock %}
{% block footer %}{% endblock %}
"""
HTML_PATH = DATA_DIR.parent.parent.joinpath("docs/docs/overrides/api.html")
CONSTANT_DT = datetime(2025, 10, 24, 15, 53, 0, 0, tzinfo=UTC)
def normalize_timestamps(s: dict[str, Any]) -> dict[str, Any]:
field_format = s.get("format")
is_timestamp = field_format in ["date-time", "date", "time"]
has_default = s.get("default")
if not is_timestamp:
for k, v in s.items():
if isinstance(v, dict):
s[k] = normalize_timestamps(v)
elif isinstance(v, list):
s[k] = [normalize_timestamps(i) if isinstance(i, dict) else i for i in v]
return s
elif not has_default:
return s
if field_format == "date-time":
s["default"] = CONSTANT_DT.isoformat()
elif field_format == "date":
s["default"] = CONSTANT_DT.date().isoformat()
elif field_format == "time":
s["default"] = CONSTANT_DT.time().isoformat()
return s
def generate_api_docs(my_app: FastAPI):
openapi_schema = my_app.openapi()
openapi_schema = normalize_timestamps(openapi_schema)
with open(HTML_PATH, "w") as fd:
text = HTML_TEMPLATE.replace("MY_SPECIFIC_TEXT", json.dumps(openapi_schema))
fd.write(text)
if __name__ == "__main__":
generate_api_docs(app)

View File

@@ -1,7 +1,6 @@
import json
import os
import pathlib
import re
from dataclasses import dataclass
from pathlib import Path
import dotenv
@@ -11,7 +10,6 @@ from pydantic import ConfigDict
from requests import Response
from utils import CodeDest, CodeKeys, inject_inline, log
from mealie.lang.locale_config import LOCALE_CONFIG, LocalePluralFoodHandling, LocaleTextDirection
from mealie.schema._mealie import MealieModel
BASE = pathlib.Path(__file__).parent.parent.parent
@@ -19,6 +17,57 @@ BASE = pathlib.Path(__file__).parent.parent.parent
API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY") or os.environ.get("CROWDIN_API_KEY", "")
@dataclass
class LocaleData:
name: str
dir: str = "ltr"
LOCALE_DATA: dict[str, LocaleData] = {
"af-ZA": LocaleData(name="Afrikaans (Afrikaans)"),
"ar-SA": LocaleData(name="العربية (Arabic)", dir="rtl"),
"bg-BG": LocaleData(name="Български (Bulgarian)"),
"ca-ES": LocaleData(name="Català (Catalan)"),
"cs-CZ": LocaleData(name="Čeština (Czech)"),
"da-DK": LocaleData(name="Dansk (Danish)"),
"de-DE": LocaleData(name="Deutsch (German)"),
"el-GR": LocaleData(name="Ελληνικά (Greek)"),
"en-GB": LocaleData(name="British English"),
"en-US": LocaleData(name="American English"),
"es-ES": LocaleData(name="Español (Spanish)"),
"et-EE": LocaleData(name="Eesti (Estonian)"),
"fi-FI": LocaleData(name="Suomi (Finnish)"),
"fr-BE": LocaleData(name="Belge (Belgian)"),
"fr-CA": LocaleData(name="Français canadien (Canadian French)"),
"fr-FR": LocaleData(name="Français (French)"),
"gl-ES": LocaleData(name="Galego (Galician)"),
"he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"),
"hr-HR": LocaleData(name="Hrvatski (Croatian)"),
"hu-HU": LocaleData(name="Magyar (Hungarian)"),
"is-IS": LocaleData(name="Íslenska (Icelandic)"),
"it-IT": LocaleData(name="Italiano (Italian)"),
"ja-JP": LocaleData(name="日本語 (Japanese)"),
"ko-KR": LocaleData(name="한국어 (Korean)"),
"lt-LT": LocaleData(name="Lietuvių (Lithuanian)"),
"lv-LV": LocaleData(name="Latviešu (Latvian)"),
"nl-NL": LocaleData(name="Nederlands (Dutch)"),
"no-NO": LocaleData(name="Norsk (Norwegian)"),
"pl-PL": LocaleData(name="Polski (Polish)"),
"pt-BR": LocaleData(name="Português do Brasil (Brazilian Portuguese)"),
"pt-PT": LocaleData(name="Português (Portuguese)"),
"ro-RO": LocaleData(name="Română (Romanian)"),
"ru-RU": LocaleData(name="Pусский (Russian)"),
"sk-SK": LocaleData(name="Slovenčina (Slovak)"),
"sl-SI": LocaleData(name="Slovenščina (Slovenian)"),
"sr-SP": LocaleData(name="српски (Serbian)"),
"sv-SE": LocaleData(name="Svenska (Swedish)"),
"tr-TR": LocaleData(name="Türkçe (Turkish)"),
"uk-UA": LocaleData(name="Українська (Ukrainian)"),
"vi-VN": LocaleData(name="Tiếng Việt (Vietnamese)"),
"zh-CN": LocaleData(name="简体中文 (Chinese simplified)"),
"zh-TW": LocaleData(name="繁體中文 (Chinese traditional)"),
}
LOCALE_TEMPLATE = """// This Code is auto generated by gen_ts_locales.py
export const LOCALES = [{% for locale in locales %}
{
@@ -26,7 +75,6 @@ export const LOCALES = [{% for locale in locales %}
value: "{{ locale.locale }}",
progress: {{ locale.progress }},
dir: "{{ locale.dir }}",
pluralFoodHandling: "{{ locale.plural_food_handling }}",
},{% endfor %}
];
@@ -39,11 +87,10 @@ class TargetLanguage(MealieModel):
id: str
name: str
locale: str
dir: LocaleTextDirection = LocaleTextDirection.LTR
plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS
dir: str = "ltr"
threeLettersCode: str
twoLettersCode: str
progress: int = 0
progress: float = 0.0
class CrowdinApi:
@@ -70,15 +117,43 @@ class CrowdinApi:
def get_languages(self) -> list[TargetLanguage]:
response = self.get_project()
tls = response.json()["data"]["targetLanguages"]
return [TargetLanguage(**t) for t in tls]
def get_progress(self) -> dict[str, int]:
models = [TargetLanguage(**t) for t in tls]
models.insert(
0,
TargetLanguage(
id="en-US",
name="English",
locale="en-US",
dir="ltr",
threeLettersCode="en",
twoLettersCode="en",
progress=100,
),
)
progress: list[dict] = self.get_progress()["data"]
for model in models:
if model.locale in LOCALE_DATA:
locale_data = LOCALE_DATA[model.locale]
model.name = locale_data.name
model.dir = locale_data.dir
for p in progress:
if p["data"]["languageId"] == model.id:
model.progress = p["data"]["translationProgress"]
models.sort(key=lambda x: x.locale, reverse=True)
return models
def get_progress(self) -> dict:
response = requests.get(
f"https://api.crowdin.com/api/v2/projects/{self.project_id}/languages/progress?limit=500",
headers=self.headers,
)
data = response.json()["data"]
return {p["data"]["languageId"]: p["translationProgress"] for p in data}
return response.json()
PROJECT_DIR = Path(__file__).parent.parent.parent
@@ -120,8 +195,8 @@ def inject_nuxt_values():
all_langs = []
for match in locales_dir.glob("*.json"):
match_data = LOCALE_CONFIG.get(match.stem)
match_dir = match_data.dir if match_data else LocaleTextDirection.LTR
match_data = LOCALE_DATA.get(match.stem)
match_dir = match_data.dir if match_data else "ltr"
lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}", dir: "{match_dir}" }},'
all_langs.append(lang_string)
@@ -146,82 +221,9 @@ def inject_registration_validation_values():
inject_inline(reg_valid, CodeKeys.nuxt_local_messages, all_langs)
def _get_local_models() -> list[TargetLanguage]:
return [
TargetLanguage(
id=locale,
name=data.name,
locale=locale,
threeLettersCode=locale.split("-")[-1],
twoLettersCode=locale.split("-")[-1],
)
for locale, data in LOCALE_CONFIG.items()
if locale != "en-US" # Crowdin doesn't include this, so we manually inject it later
]
def _get_local_progress() -> dict[str, int]:
with open(CodeDest.use_locales) as f:
content = f.read()
# Extract the array content between [ and ]
match = re.search(r"export const LOCALES = (\[.*?\]);", content, re.DOTALL)
if not match:
raise ValueError("Could not find LOCALES array in file")
# Convert JS to JSON
array_content = match.group(1)
# Replace unquoted keys with quoted keys for valid JSON
# This converts: { name: "value" } to { "name": "value" }
json_str = re.sub(r"([,\{\s])([a-zA-Z_][a-zA-Z0-9_]*)\s*:", r'\1"\2":', array_content)
# Remove trailing commas before } and ]
json_str = re.sub(r",(\s*[}\]])", r"\1", json_str)
locales = json.loads(json_str)
return {locale["value"]: locale["progress"] for locale in locales}
def get_languages() -> list[TargetLanguage]:
if API_KEY:
api = CrowdinApi(None)
models = api.get_languages()
progress = api.get_progress()
else:
log.warning("CROWDIN_API_KEY is not set, using local lanugages instead")
log.warning("DOUBLE CHECK the output!!! Do not overwrite with bad local locale data!")
models = _get_local_models()
progress = _get_local_progress()
models.insert(
0,
TargetLanguage(
id="en-US",
name="English",
locale="en-US",
dir=LocaleTextDirection.LTR,
plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT,
threeLettersCode="en",
twoLettersCode="en",
progress=100,
),
)
for model in models:
if model.locale in LOCALE_CONFIG:
locale_data = LOCALE_CONFIG[model.locale]
model.name = locale_data.name
model.dir = locale_data.dir
model.plural_food_handling = locale_data.plural_food_handling
model.progress = progress.get(model.id, model.progress)
models.sort(key=lambda x: x.locale, reverse=True)
return models
def generate_locales_ts_file():
models = get_languages()
api = CrowdinApi(None)
models = api.get_languages()
tmpl = Template(LOCALE_TEMPLATE)
rendered = tmpl.render(locales=models)
@@ -231,6 +233,10 @@ def generate_locales_ts_file():
def main():
if API_KEY is None or API_KEY == "":
log.error("CROWDIN_API_KEY is not set")
return
generate_locales_ts_file()
inject_nuxt_values()
inject_registration_validation_values()

View File

@@ -1,7 +1,7 @@
###############################################
# Frontend Build
###############################################
FROM node:24@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 \
FROM node:24@sha256:20988bcdc6dc76690023eb2505dd273bdeefddcd0bde4bfd1efe4ebf8707f747 \
AS frontend-builder
WORKDIR /frontend
@@ -111,6 +111,7 @@ RUN . $VENV_PATH/bin/activate \
# Production Image
###############################################
FROM python-base AS production
LABEL org.opencontainers.image.source="https://github.com/mealie-recipes/mealie"
ENV PRODUCTION=true
ENV TESTING=false

4
docs/docs/api/redoc.md Normal file
View File

@@ -0,0 +1,4 @@
---
title: API
template: api.html
---

View File

@@ -6,7 +6,7 @@ While this guide aims to simplify the migration process for developers, it's not
## V1 → V2
The biggest change between V1 and V2 is the introduction of Households. For more information on how households work in relation to groups/users, check out the [Groups and Households](../../documentation/getting-started/features.md#groups-and-households) section in the Features guide.
The biggest change between V1 and V2 is the introduction of Households. For more information on how households work in relation to groups/users, check out the [Groups and Households](./features.md#groups-and-households) section in the Features guide.
### `updateAt` is now `updatedAt`

View File

@@ -16,7 +16,7 @@ Recipes extras are a key feature of the Mealie API. They allow you to create cus
For example you could add `{"message": "Remember to thaw the chicken"}` to a recipe and use the webhooks built into mealie to send that message payload to a destination to be processed.
#### Shopping List and Food Extras
Similarly to recipes, extras are supported on shopping lists, shopping list items, and foods. At this time they are only accessible through the API. Extras for these objects allow for rich integrations between the Mealie shopping list and your favorite list manager, such as Todoist, Trello, or any other list manager with an API.
Similarly to recipes, extras are supported on shopping lists, shopping list items, and foods. At this time they are only accessible through the API. Extras for these objects allow for rich integrations between the Mealie shopping list and your favorite list manager, such as Alexa, ToDoist, Trello, or any other list manager with an API.
To keep shopping lists in sync, for instance, you can store your Trello list id on your Mealie shopping list: <br />
`{"trello_list_id": "5abbe4b7ddc1b351ef961414"}`
@@ -52,7 +52,6 @@ Many applications will keep track of the query and adjust the page parameter app
Notice that the route does not contain the baseurl (e.g. `https://mymealieapplication.com/api`).
There are a few shorthands available to reduce the number of calls for certain common requests:
- if you want to return _all_ results, effectively disabling pagination, set `perPage = -1` (and fetch the first page)
- if you want to fetch the _last_ page, set `page = -1`
@@ -79,8 +78,8 @@ This filter will find all foods that are not named "carrot": <br>
##### Keyword Filters
The API supports many SQL keywords, such as `IS NULL` and `IN`, as well as their negations (e.g. `IS NOT NULL` and `NOT IN`).
Here is an example of a filter that returns all shopping list items without a food: <br>
`foodId IS NULL`
Here is an example of a filter that returns all recipes where the "last made" value is not null: <br>
`lastMade IS NOT NULL`
This filter will find all recipes that don't start with the word "Test": <br>
`name NOT LIKE "Test%"`
@@ -90,28 +89,6 @@ This filter will find all recipes that don't start with the word "Test": <br>
This filter will find all recipes that have particular slugs: <br>
`slug IN ["pasta-fagioli", "delicious-ramen"]`
##### Placeholder Keywords
You can use placeholders to insert dynamic values as opposed to static values. Currently the only supported placeholder keyword is `$NOW`, to insert the current date/time.
`$NOW` can optionally be paired with basic offsets. Here is an example of a filter which gives you recipes not made within the past 30 days: <br>
`lastMade <= "$NOW-30d"`
Supported offset operations include:
- `-` for subtracting a time (i.e. in the past)
- `+` for adding a time (i.e. in the future)
Supported offset intervals include:
- `y` for years
- `m` for months
- `d` for days
- `H` for hours
- `M` for minutes
- `S` for seconds
Note that intervals are _case sensitive_ (e.g. `s` is an invalid interval).
##### Nested Property filters
When querying tables with relationships, you can filter properties on related tables. For instance, if you want to query all recipes owned by a particular user: <br>
`user.username = "SousChef20220320"`
@@ -119,7 +96,7 @@ When querying tables with relationships, you can filter properties on related ta
This timeline event filter will return all timeline events for recipes that were created after a particular date: <br>
`recipe.createdAt >= "2023-02-25"`
This recipe filter will return all recipes that contain a particular set of tags: <br>
This recipe filter will return all recipes that contains a particular set of tags: <br>
`tags.name CONTAINS ALL ["Easy", "Cajun"]`
##### Compound Filters

View File

@@ -9,7 +9,7 @@
Mealie supports 3rd party authentication via [OpenID Connect (OIDC)](https://openid.net/connect/), an identity layer built on top of OAuth2. OIDC is supported by many Identity Providers (IdP), including:
- [Authentik](https://integrations.goauthentik.io/documentation/mealie/)
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
- [Authelia](https://www.authelia.com/integration/openid-connect/mealie/)
- [Keycloak](https://www.keycloak.org/docs/latest/securing_apps/#_oidc)
- [Okta](https://www.okta.com/openid-connect/)
@@ -68,6 +68,7 @@ Example configurations for several Identity Providers have been provided by the
If you don't see your provider and have successfully set it up, please consider [creating your own example](https://github.com/mealie-recipes/mealie/discussions/new?category=oauth-provider-example) so that others can have a smoother setup.
## Migration from Mealie v1.x
**High level changes**

View File

@@ -85,13 +85,13 @@ The meal planner has the concept of plan rules. These offer a flexible way to us
The shopping lists feature is a great way to keep track of what you need to buy for your next meal. You can add items directly to the shopping list or link a recipe and all of it's ingredients to track meals during the week.
Managing shopping lists can be done from the Sidebar > Shopping Lists.
Managing shopping lists can be done from the Sidebar > Shopping Lists.
Here you will be able to:
- See items already on the Shopping List
- See linked recipes with ingredients
- Toggling via the 'Pot' icon will show you the linked recipe, allowing you to click to access it.
- Toggling via the 'Pot' icon will show you the linked recipe, allowing you to click to access it.
- Check off an item
- Add / Change / Remove / Sort Items via the grid icon
- Be sure if you are modifying an ingredient to click the 'Save' icon.
@@ -103,10 +103,13 @@ Here you will be able to:
!!! tip
You can use Labels to categorize your ingredients. You may want to Label by Food Type (Frozen, Fresh, etc), by Store, Tool, Recipe, or more. Play around with this to see what works best for you.
!!! tip
You can toggle 'Food' on items so that if you add multiple of the same food / ingredient, Mealie will automatically combine them together. Do this by editing an item in the Shopping List and clicking the 'Apple' icon. If you then have recipes that contain "1 | cup | cheese" and "2 | cup | cheese" this would be combined to show "3 cups of cheese."
[See FAQ for more information](../getting-started/faq.md)
[Shopping List Demo](https://demo.mealie.io/shopping-lists){ .md-button .md-button--primary }
## Integrations
@@ -195,7 +198,7 @@ Mealie lets you fully customize how you organize your users. You can use Groups
### Groups
Groups are fully isolated instances of Mealie. Think of a group as a completely separate, fully self-contained site. There is no data shared between groups. Each group has its own users, recipes, tags, categories, etc. A user logged-in to one group cannot make any changes to another.
Groups are fully isolated instances of Mealie. Think of a goup as a completely separate, fully self-contained site. There is no data shared between groups. Each group has its own users, recipes, tags, categories, etc. A user logged-in to one group cannot make any changes to another.
Common use cases for groups include:

View File

@@ -122,18 +122,17 @@ For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.md)
Mealie supports various integrations using OpenAI. For more information, check out our [OpenAI documentation](./open-ai.md).
For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values as JSON encoded strings (e.g. `OPENAI_CUSTOM_PARAMS='{"k1": "v1", "k2": "v2"}'`)
| Variables | Default | Description |
|---------------------------------------------------|:-------:|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| OPENAI_BASE_URL<super>[&dagger;][secrets]</super> | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
| OPENAI_API_KEY<super>[&dagger;][secrets]</super> | None | Your OpenAI API Key. Enables OpenAI-related features |
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
| OPENAI_CUSTOM_HEADERS <br/> :octicons-tag-24: v2.0.0 | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
| OPENAI_CUSTOM_PARAMS <br/> :octicons-tag-24: v2.0.0 | None | Custom HTTP query params to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
| OPENAI_ENABLE_IMAGE_SERVICES <br/> :octicons-tag-24: v1.12.0 | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
| OPENAI_REQUEST_TIMEOUT | 300 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
| OPENAI_CUSTOM_PROMPT_DIR <br/> :octicons-tag-24: v3.10.0 | None | Path to custom prompt files. Only existing files in your custom directory will override the defaults; any missing or empty custom files will automatically fall back to the system defaults. See https://github.com/mealie-recipes/mealie/tree/mealie-next/mealie/services/openai/prompts for expected file names. |
| Variables | Default | Description |
| ------------------------------------------------- | :-----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OPENAI_BASE_URL<super>[&dagger;][secrets]</super> | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
| OPENAI_API_KEY<super>[&dagger;][secrets]</super> | None | Your OpenAI API Key. Enables OpenAI-related features |
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
| OPENAI_CUSTOM_HEADERS | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
| OPENAI_CUSTOM_PARAMS | None | Custom HTTP query params to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
| OPENAI_ENABLE_IMAGE_SERVICES | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
| OPENAI_REQUEST_TIMEOUT | 300 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
### Theming
@@ -236,10 +235,6 @@ The examples below provide copy-ready Docker Compose environment configurations
THEME_DARK_ERROR: '#E57373'
```
!!! info
Browser cookies may cause the client to keep outdated settings.
Clearing the cookies can be required for the change to take effect.
### Docker Secrets
> <super>&dagger;</super> Starting in version `2.4.2`, any environment variable in the preceding lists with a dagger

View File

@@ -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:v3.11.0`
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.8.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

View File

@@ -10,7 +10,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
```yaml
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v3.11.0 # (3)
image: ghcr.io/mealie-recipes/mealie:v3.8.0 # (3)
container_name: mealie
restart: always
ports:

View File

@@ -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:v3.11.0 # (3)
image: ghcr.io/mealie-recipes/mealie:v3.8.0 # (3)
container_name: mealie
restart: always
ports:

File diff suppressed because one or more lines are too long

View File

@@ -93,7 +93,7 @@ nav:
- iOS Shortcut: "documentation/community-guide/ios-shortcut.md"
- Reverse Proxy (SWAG): "documentation/community-guide/swag.md"
- API Reference: "https://demo.mealie.io/docs"
- API Reference: "api/redoc.md"
- Contributors Guide:
- Non-Code: "contributors/non-coders.md"

View File

@@ -16,10 +16,6 @@
max-width: 950px !important;
}
.lg-container {
max-width: 1100px !important;
}
.theme--dark.v-application {
background-color: rgb(var(--v-theme-background, 30, 30, 30)) !important;
}

View File

@@ -41,8 +41,8 @@
export default defineNuxtComponent({
setup() {
const i18n = useI18n();
const auth = useMealieAuth();
const groupSlug = computed(() => auth.user.value?.groupSlug);
const $auth = useMealieAuth();
const groupSlug = computed(() => $auth.user.value?.groupSlug);
const { $globals } = useNuxtApp();
const sections = ref([

View File

@@ -73,11 +73,11 @@ import { useLoggedInState } from "~/composables/use-logged-in-state";
import type { ReadCookBook } from "~/lib/api/types/cookbook";
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
const auth = useMealieAuth();
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const slug = route.params.slug as string;
@@ -88,11 +88,11 @@ const router = useRouter();
const book = getOne(slug);
const isOwnHousehold = computed(() => {
if (!(auth.user.value && book.value?.householdId)) {
if (!($auth.user.value && book.value?.householdId)) {
return false;
}
return auth.user.value.householdId === book.value.householdId;
return $auth.user.value.householdId === book.value.householdId;
});
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);

View File

@@ -1,217 +0,0 @@
<template>
<!-- Create Dialog -->
<BaseDialog
v-model="createDialog"
:title="$t('general.create')"
:icon="icon"
color="primary"
:submit-disabled="!createFormValid"
can-confirm
@confirm="emit('create-one', createForm.data)"
>
<div class="mx-2 mt-2">
<slot name="create-dialog-top" />
<AutoForm
v-model="createForm.data"
v-model:is-valid="createFormValid"
:items="createForm.items"
/>
</div>
</BaseDialog>
<!-- Edit Dialog -->
<BaseDialog
v-model="editDialog"
:title="$t('general.edit')"
:icon="icon"
color="primary"
:submit-disabled="!editFormValid"
can-confirm
@confirm="emit('edit-one', editForm.data)"
>
<div class="mx-2 mt-2">
<AutoForm
v-model="editForm.data"
v-model:is-valid="editFormValid"
:items="editForm.items"
/>
</div>
<template #custom-card-action>
<slot name="edit-dialog-custom-action" />
</template>
</BaseDialog>
<!-- Delete Dialog -->
<BaseDialog
v-model="deleteDialog"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="$emit('deleteOne', deleteTarget.id)"
>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
<p v-if="deleteTarget" class="mt-4 ml-4">
{{ deleteTarget.name || deleteTarget.title || deleteTarget.id }}
</p>
</v-card-text>
</BaseDialog>
<!-- Bulk Delete Dialog -->
<BaseDialog
v-model="bulkDeleteDialog"
width="650px"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
can-confirm
@confirm="$emit('bulk-action', 'delete-selected', bulkDeleteTarget)"
>
<v-card-text>
<p class="h4">
{{ $t('general.confirm-delete-generic-items') }}
</p>
<v-card variant="outlined">
<v-virtual-scroll height="400" item-height="25" :items="bulkDeleteTarget">
<template #default="{ item }">
<v-list-item class="pb-2">
<v-list-item-title>{{ item.name || item.title || item.id }}</v-list-item-title>
</v-list-item>
</template>
</v-virtual-scroll>
</v-card>
</v-card-text>
</BaseDialog>
<BaseCardSectionTitle
:icon="icon"
section
:title="title"
/>
<CrudTable
:headers="tableHeaders"
:table-config="tableConfig"
:data="data || []"
:bulk-actions="bulkActions"
:initial-sort="initialSort"
@edit-one="editEventHandler"
@delete-one="deleteEventHandler"
@bulk-action="handleBulkAction"
>
<template
v-for="slotName in itemSlotNames"
#[slotName]="slotProps"
>
<slot
:name="slotName"
v-bind="slotProps"
/>
</template>
<template #button-row>
<BaseButton
create
@click="createDialog = true"
>
{{ $t("general.create") }}
</BaseButton>
<slot name="table-button-row" />
</template>
<template #button-bottom>
<slot name="table-button-bottom" />
</template>
</CrudTable>
</template>
<script setup lang="ts">
import type { TableHeaders, TableConfig, BulkAction } from "~/components/global/CrudTable.vue";
import type { AutoFormItems } from "~/types/auto-forms";
const slots = useSlots();
const emit = defineEmits<{
(e: "deleteOne", id: string): void;
(e: "deleteMany", ids: string[]): void;
(e: "create-one" | "edit-one", data: any): void;
(e: "bulk-action", event: string, items: any[]): void;
}>();
const tableHeaders = defineModel<TableHeaders[]>("tableHeaders", { required: true });
const createForm = defineModel<{ items: AutoFormItems; data: Record<string, any> }>("createForm", { required: true });
const createDialog = defineModel("createDialog", { type: Boolean, default: false });
const editForm = defineModel<{ items: AutoFormItems; data: Record<string, any> }>("editForm", { required: true });
const editDialog = defineModel("editDialog", { type: Boolean, default: false });
defineProps({
icon: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
tableConfig: {
type: Object as PropType<TableConfig>,
default: () => ({
hideColumns: false,
canExport: true,
}),
},
data: {
type: Array as PropType<Array<any>>,
required: true,
},
bulkActions: {
type: Array as PropType<BulkAction[]>,
required: true,
},
initialSort: {
type: String,
default: "name",
},
});
// ============================================================
// Bulk Action Handler
function handleBulkAction(event: string, items: any[]) {
if (event === "delete-selected") {
bulkDeleteEventHandler(items);
return;
}
emit("bulk-action", event, items);
}
// ============================================================
// Create & Edit
const createFormValid = ref(false);
const editFormValid = ref(false);
const itemSlotNames = computed(() => Object.keys(slots).filter(slotName => slotName.startsWith("item.")));
const editEventHandler = (item: any) => {
editForm.value.data = { ...item };
editDialog.value = true;
};
// ============================================================
// Delete Logic
const deleteTarget = ref<any>(null);
const deleteDialog = ref(false);
function deleteEventHandler(item: any) {
deleteTarget.value = item;
deleteDialog.value = true;
}
// ============================================================
// Bulk Delete Logic
const bulkDeleteTarget = ref<Array<any>>([]);
const bulkDeleteDialog = ref(false);
function bulkDeleteEventHandler(items: Array<any>) {
bulkDeleteTarget.value = items;
bulkDeleteDialog.value = true;
console.log("Bulk Delete Event Handler", items);
}
</script>

View File

@@ -15,6 +15,7 @@
:nudge-top="menuTop ? '5' : '0'"
allow-overflow
close-delay="125"
:open-on-hover="mdAndUp"
content-class="d-print-none"
>
<template #activator="{ props: activatorProps }">
@@ -82,6 +83,8 @@ const emit = defineEmits<{
[key: string]: [];
}>();
const { mdAndUp } = useDisplay();
const i18n = useI18n();
const { $globals } = useNuxtApp();
const api = useUserApi();
@@ -91,7 +94,7 @@ const state = reactive({
shoppingListDialog: false,
menuItems: [
{
title: i18n.t("meal-plan.add-day-to-list"),
title: i18n.t("recipe.add-to-list"),
icon: $globals.icons.cartCheck,
color: undefined,
event: "shoppingList",
@@ -123,8 +126,8 @@ async function getShoppingLists() {
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
shoppingList: async () => {
await getShoppingLists();
shoppingList: () => {
getShoppingLists();
state.shoppingListDialog = true;
},
};

View File

@@ -36,7 +36,7 @@
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
import { Organizer } from "~/lib/api/types/non-generated";
import type { QueryFilterJSON } from "~/lib/api/types/non-generated";
import type { QueryFilterJSON } from "~/lib/api/types/response";
interface Props {
queryFilter?: QueryFilterJSON | null;
@@ -76,6 +76,7 @@ const MEAL_DAY_OPTIONS = [
];
function handleQueryFilterInput(value: string | undefined) {
console.warn("handleQueryFilterInput called with value:", value);
queryFilterString.value = value || "";
}
@@ -113,7 +114,7 @@ const fieldDefs: FieldDefinition[] = [
{
name: "last_made",
label: i18n.t("general.last-made"),
type: "relativeDate",
type: "date",
},
{
name: "created_at",

View File

@@ -108,7 +108,7 @@
<v-select
v-if="field.type !== 'boolean'"
:model-value="field.relationalOperatorValue"
:items="field.relationalOperatorChoices"
:items="field.relationalOperatorOptions"
item-title="label"
item-value="value"
variant="underlined"
@@ -129,9 +129,9 @@
:class="config.col.class"
>
<v-select
v-if="field.fieldChoices"
v-if="field.fieldOptions"
:model-value="field.values"
:items="field.fieldChoices"
:items="field.fieldOptions"
item-title="label"
item-value="value"
multiple
@@ -144,13 +144,11 @@
variant="underlined"
@update:model-value="setFieldValue(field, index, $event)"
/>
<v-number-input
<v-text-field
v-else-if="field.type === 'number'"
:model-value="field.value"
type="number"
variant="underlined"
control-variant="stacked"
inset
:precision="null"
@update:model-value="setFieldValue(field, index, $event)"
/>
<v-checkbox
@@ -169,39 +167,23 @@
>
<template #activator="{ props: activatorProps }">
<v-text-field
:model-value="$d(safeNewDate(field.value + 'T00:00:00'))"
:model-value="field.value ? $d(new Date(field.value + 'T00:00:00')) : null"
persistent-hint
:prepend-icon="$globals.icons.calendar"
variant="underlined"
color="primary"
class="date-input"
v-bind="activatorProps"
readonly
/>
</template>
<v-date-picker
:model-value="safeNewDate(field.value + 'T00:00:00')"
:model-value="field.value ? new Date(field.value + 'T00:00:00') : null"
hide-header
:first-day-of-week="firstDayOfWeek"
:local="$i18n.locale"
@update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
/>
</v-menu>
<!--
Relative dates are assumed to be negative intervals with a unit of days.
The input is a *positive*, interpreted internally as a *negative* offset.
-->
<v-number-input
v-else-if="field.type === 'relativeDate'"
:model-value="parseRelativeDateOffset(field.value)"
:suffix="$t('query-filter.dates.days-ago', parseRelativeDateOffset(field.value))"
variant="underlined"
control-variant="stacked"
density="compact"
inset
:min="0"
:precision="0"
class="date-input"
@update:model-value="setFieldValue(field, index, $event)"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Category"
v-model="field.organizers"
@@ -335,13 +317,7 @@ import { useDebounceFn } from "@vueuse/core";
import { useHouseholdSelf } from "~/composables/use-households";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import { Organizer } from "~/lib/api/types/non-generated";
import type {
LogicalOperator,
QueryFilterJSON,
QueryFilterJSONPart,
RelationalKeyword,
RelationalOperator,
} from "~/lib/api/types/non-generated";
import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
import { useUserStore } from "~/composables/store/use-user-store";
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
@@ -363,14 +339,7 @@ const emit = defineEmits<{
}>();
const { household } = useHouseholdSelf();
const {
logOps,
placeholderKeywords,
getRelOps,
buildQueryFilterString,
getFieldFromFieldDef,
isOrganizerType,
} = useQueryFilterBuilder();
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
@@ -425,29 +394,16 @@ function setField(index: number, fieldLabel: string) {
return;
}
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldChoices !== fields.value[index].fieldChoices);
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions);
const updatedField = { ...fields.value[index], ...fieldDef };
// we have to set this explicitly since it might be undefined
updatedField.fieldChoices = fieldDef.fieldChoices;
updatedField.fieldOptions = fieldDef.fieldOptions;
fields.value[index] = {
...getFieldFromFieldDef(updatedField, resetValue),
id: fields.value[index].id, // keep the id
};
// Defaults
switch (fields.value[index].type) {
case "date":
fields.value[index].value = safeNewDate("");
break;
case "relativeDate":
fields.value[index].value = "$NOW-30d";
break;
default:
break;
}
}
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
@@ -467,21 +423,12 @@ function setLogicalOperatorValue(field: FieldWithId, index: number, value: Logic
}
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
const relOps = getRelOps(field.type);
fields.value[index].relationalOperatorValue = relOps.value[value];
}
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
state.datePickers[index] = false;
if (field.type === "relativeDate") {
// Value is set to an int representing the offset from $NOW
// Values are assumed to be negative offsets ('-') with a unit of days ('d')
fields.value[index].value = `$NOW-${Math.abs(value)}d`;
}
else {
fields.value[index].value = value;
}
fields.value[index].value = value;
}
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
@@ -499,7 +446,12 @@ function removeField(index: number) {
state.datePickers.splice(index, 1);
}
const fieldsUpdater = useDebounceFn(() => {
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
/* newFields.forEach((field, index) => {
const updatedField = getFieldFromFieldDef(field);
fields.value[index] = updatedField; // recursive!!!
}); */
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
if (qf) {
console.debug(`Set query filter: ${qf}`);
@@ -565,9 +517,6 @@ async function initializeFields() {
...getFieldFromFieldDef(fieldDef),
id: useUid(),
};
const relOps = getRelOps(field.type);
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
field.logicalOperator = part.logicalOperator
@@ -576,15 +525,12 @@ async function initializeFields() {
field.relationalOperatorValue = part.relationalOperator
? relOps.value[part.relationalOperator]
: field.relationalOperatorValue;
field.relationalOperatorValue = part.relationalOperator
? relOps.value[part.relationalOperator]
: field.relationalOperatorValue;
if (field.leftParenthesis || field.rightParenthesis) {
state.showAdvanced = true;
}
if (field.fieldChoices?.length || isOrganizerType(field.type)) {
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
if (typeof part.value === "string") {
field.values = part.value ? [part.value] : [];
}
@@ -653,7 +599,7 @@ function buildQueryFilterJSON(): QueryFilterJSON {
relationalOperator: field.relationalOperatorValue?.value,
};
if (field.fieldChoices?.length || isOrganizerType(field.type)) {
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
part.value = field.values.map(value => value.toString());
}
else if (field.type === "boolean") {
@@ -671,50 +617,6 @@ function buildQueryFilterJSON(): QueryFilterJSON {
return qfJSON;
}
function safeNewDate(input: string): Date {
const date = new Date(input);
if (isNaN(date.getTime())) {
const today = new Date();
today.setHours(0, 0, 0, 0);
return today;
}
return date;
}
/**
* Parse a relative date string offset (e.g. $NOW-30d --> 30)
*
* Currently only values with a negative offset ('-') and a unit of days ('d') are supported
*/
function parseRelativeDateOffset(value: string): number {
const defaultVal = 30;
if (!value) {
return defaultVal;
}
try {
if (!value.startsWith(placeholderKeywords.value["$NOW"].value)) {
return defaultVal;
}
const remainder = value.slice(placeholderKeywords.value["$NOW"].value.length);
if (!remainder.startsWith("-")) {
throw new Error("Invalid operator (not '-')");
}
if (remainder.slice(-1) !== "d") {
throw new Error("Invalid unit (not 'd')");
}
// Slice off sign and unit
return parseInt(remainder.slice(1, -1));
}
catch (error) {
console.warn(`Unable to parse relative date offset from '${value}': ${error}`);
return defaultVal;
}
}
const config = computed(() => {
const multiple = fields.value.length > 1;
const adv = state.showAdvanced;
@@ -785,13 +687,4 @@ const config = computed(() => {
.bg-light {
background-color: rgba(255, 255, 255, var(--bg-opactity));
}
:deep(.date-input input) {
text-align: end;
padding-right: 6px;
}
:deep(.date-input .v-field__field) {
align-items: center;
}
</style>

View File

@@ -79,7 +79,7 @@
@print="$emit('print')"
/>
</div>
<div v-if="open" class="custom-btn-group gapped ma-1">
<div v-if="open" class="custom-btn-group gapped">
<v-btn
v-for="(btn, index) in editorButtons"
:key="index"
@@ -126,7 +126,7 @@ withDefaults(defineProps<Props>(), {
canEdit: false,
});
const emit = defineEmits(["print", "input", "save", "delete", "close", "json", "edit"]);
const emit = defineEmits(["print", "input", "delete", "close", "edit"]);
const deleteDialog = ref(false);

View File

@@ -1,97 +1,60 @@
<template>
<div v-if="model.length > 0 || edit">
<v-card class="mt-4">
<v-list-item class="pr-2 pl-0">
<v-card-title>
{{ $t("asset.assets") }}
</v-card-title>
<template #append>
<v-btn
v-if="edit"
variant="plain"
:icon="$globals.icons.create"
@click="state.newAssetDialog = true"
/>
</template>
</v-list-item>
<v-card-title class="py-2">
{{ $t("asset.assets") }}
</v-card-title>
<v-divider class="mx-2" />
<v-list
v-if="model.length > 0"
lines="two"
:flat="!edit"
>
<v-list-item
v-for="(item, i) in model"
:key="i"
:href="!edit ? assetURL(item.fileName ?? '') : ''"
target="_blank"
class="pr-2"
>
<template #prepend>
<v-avatar size="48" rounded="lg" class="elevation-1">
<v-img
v-if="isImage(item.fileName)"
:src="assetURL(item.fileName ?? '')"
:alt="item.name"
loading="lazy"
cover
/>
<v-icon v-else size="large">
{{ getIconDefinition(item.icon).icon }}
</v-icon>
</v-avatar>
<div class="ma-auto">
<v-tooltip location="bottom">
<template #activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps">
{{ getIconDefinition(item.icon).icon }}
</v-icon>
</template>
<span>{{ getIconDefinition(item.icon).title }}</span>
</v-tooltip>
</div>
</template>
<v-list-item-title>
<v-list-item-title class="pl-2">
{{ item.name }}
</v-list-item-title>
<template #append>
<v-menu v-if="edit" location="bottom end">
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
icon
variant="plain"
>
<v-icon :icon="$globals.icons.dotsVertical" />
</v-btn>
</template>
<v-list density="compact" min-width="220">
<v-list-item
:href="assetURL(item.fileName ?? '')"
:prepend-icon="$globals.icons.eye"
:title="$t('general.view')"
target="_blank"
/>
<v-list-item
:href="assetURL(item.fileName ?? '')"
:prepend-icon="$globals.icons.download"
:title="$t('general.download')"
download
/>
<v-list-item
v-if="edit"
:prepend-icon="$globals.icons.contentCopy"
:title="$t('general.copy')"
@click="copyText(assetEmbed(item.fileName ?? ''))"
/>
<v-list-item
v-if="edit"
:prepend-icon="$globals.icons.delete"
:title="$t('general.delete')"
@click="model.splice(i, 1)"
/>
</v-list>
</v-menu>
<v-btn
v-if="!edit"
color="primary"
icon
variant="plain"
size="small"
:href="assetURL(item.fileName ?? '')"
download
target="_blank"
top
>
<v-icon> {{ $globals.icons.download }} </v-icon>
</v-btn>
<div v-else>
<v-btn
color="error"
icon
size="small"
top
@click="model.splice(i, 1)"
>
<v-icon>{{ $globals.icons.delete }}</v-icon>
</v-btn>
<AppButtonCopy
color=""
:copy-text="assetEmbed(item.fileName ?? '')"
/>
</div>
</template>
</v-list-item>
</v-list>
@@ -105,9 +68,18 @@
can-submit
@submit="addAsset"
>
<template #activator>
<BaseButton
v-if="edit"
size="small"
create
@click="state.newAssetDialog = true"
/>
</template>
<v-card-text class="pt-4">
<v-text-field
v-model="state.newAsset.name"
density="compact"
:label="$t('general.name')"
/>
<div class="d-flex justify-space-between">
@@ -120,14 +92,10 @@
item-value="name"
class="mr-2"
>
<template #item="{ props: itemProps, item }">
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps">
<template #prepend>
<v-avatar>
<v-icon>
{{ item.raw.icon }}
</v-icon>
</v-avatar>
<v-icon>{{ item.raw.icon }}</v-icon>
</template>
</v-list-item>
</template>
@@ -139,6 +107,7 @@
@uploaded="setFileObject"
/>
</div>
{{ state.fileObject.name }}
</v-card-text>
</BaseDialog>
</div>
@@ -149,7 +118,6 @@
import { useStaticRoutes, useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import type { RecipeAsset } from "~/lib/api/types/recipe";
import { useCopy } from "~/composables/use-copy";
const props = defineProps({
slug: {
@@ -181,7 +149,6 @@ const state = reactive({
const i18n = useI18n();
const { $globals } = useNuxtApp();
const { copyText } = useCopy();
const iconOptions = [
{
@@ -217,31 +184,21 @@ function getIconDefinition(icon: string) {
return iconOptions.find(item => item.name === icon) || iconOptions[0];
}
function isImage(fileName?: string | null) {
if (!fileName) return false;
return /\.(png|jpe?g|gif|webp|bmp|avif)$/i.test(fileName);
}
const { recipeAssetPath } = useStaticRoutes();
function assetURL(assetName: string) {
return recipeAssetPath(props.recipeId, assetName);
}
function assetEmbed(name: string) {
return `<img src="${serverBase}${assetURL(name)}" height="100%" width="100%" />`;
return `<img src="${serverBase}${assetURL(name)}" height="100%" width="100%"> </img>`;
}
function setFileObject(fileObject: File) {
state.fileObject = fileObject;
// If the user didn't provide a name, default to the file base name
if (!state.newAsset.name?.trim()) {
state.newAsset.name = fileObject.name.substring(0, fileObject.name.lastIndexOf("."));
}
}
function validFields() {
// Only require a file; name will fall back to the file name if empty
return Boolean(state.fileObject?.name);
return state.newAsset.name.length > 0 && state.fileObject.name.length > 0;
}
async function addAsset() {
@@ -250,10 +207,8 @@ async function addAsset() {
return;
}
const nameToUse = state.newAsset.name?.trim() || state.fileObject.name;
const { data } = await api.recipes.createAsset(props.slug, {
name: nameToUse,
name: state.newAsset.name,
icon: state.newAsset.icon,
file: state.fileObject,
extension: state.fileObject.name.split(".").pop() || "",

View File

@@ -130,11 +130,11 @@ defineEmits<{
delete: [slug: string];
}>();
const auth = useMealieAuth();
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const showRecipeContent = computed(() => props.recipeId && props.slug);
const recipeRoute = computed<string>(() => {
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";

View File

@@ -160,11 +160,11 @@ defineEmits<{
delete: [slug: string];
}>();
const auth = useMealieAuth();
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const showRecipeContent = computed(() => props.recipeId && props.slug);
const recipeRoute = computed<string>(() => {
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";

View File

@@ -219,7 +219,7 @@ const EVENTS = {
shuffle: "shuffle",
};
const auth = useMealieAuth();
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { isOwnGroup } = useLoggedInState();
const useMobileCards = computed(() => {
@@ -234,7 +234,7 @@ const sortLoading = ref(false);
const randomSeed = ref(Date.now().toString());
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const page = ref(1);
const perPage = 32;

View File

@@ -10,6 +10,7 @@
:nudge-top="menuTop ? '5' : '0'"
allow-overflow
close-delay="125"
:open-on-hover="$vuetify.display.mdAndUp"
content-class="d-print-none"
@update:model-value="onMenuToggle"
>
@@ -23,6 +24,7 @@
:fab="fab"
v-bind="activatorProps"
@click.prevent
@mouseenter="onHover"
>
<v-icon
:size="!fab ? undefined : 'x-large'"
@@ -125,6 +127,12 @@ const contentProps = computed(() => {
return rest;
});
function onHover() {
if (!isMenuContentLoaded.value) {
isMenuContentLoaded.value = true;
}
}
function onMenuToggle(isOpen: boolean) {
if (isOpen && !isMenuContentLoaded.value) {
isMenuContentLoaded.value = true;

View File

@@ -176,7 +176,6 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
[key: string]: any;
deleted: [slug: string];
print: [];
}>();
const api = useUserApi();
@@ -202,13 +201,13 @@ const newMealdateString = computed(() => {
});
const i18n = useI18n();
const auth = useMealieAuth();
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { household } = useHouseholdSelf();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
@@ -296,12 +295,12 @@ const recipeRefWithScale = computed(() =>
);
const isAdminAndNotOwner = computed(() => {
return (
auth.user.value?.admin
&& auth.user.value?.id !== recipeRef.value?.userId
$auth.user.value?.admin
&& $auth.user.value?.id !== recipeRef.value?.userId
);
});
const canDelete = computed(() => {
const user = auth.user.value;
const user = $auth.user.value;
const recipe = recipeRef.value;
return user && recipe && (user.admin || user.id === recipe.userId);
});

View File

@@ -110,8 +110,8 @@ defineEmits<{
const selected = defineModel<Recipe[]>({ default: () => [] });
const i18n = useI18n();
const auth = useMealieAuth();
const groupSlug = auth.user.value?.groupSlug;
const $auth = useMealieAuth();
const groupSlug = $auth.user.value?.groupSlug;
const router = useRouter();
// Initialize sort state with default sorting by dateAdded descending

View File

@@ -6,7 +6,7 @@
:title="$t('recipe.add-to-list')"
:icon="$globals.icons.cartCheck"
>
<v-container v-if="!filteredShoppingLists.length">
<v-container v-if="!shoppingListChoices.length">
<BasePageTitle>
<template #title>
{{ $t('shopping-list.no-shopping-lists-found') }}
@@ -15,7 +15,7 @@
</v-container>
<v-card-text>
<v-card
v-for="list in filteredShoppingLists"
v-for="list in shoppingListChoices"
:key="list.id"
hover
class="my-2 left-border"
@@ -217,43 +217,44 @@ const props = withDefaults(defineProps<Props>(), {
const dialog = defineModel<boolean>({ default: false });
const i18n = useI18n();
const auth = useMealieAuth();
const $auth = useMealieAuth();
const api = useUserApi();
const preferences = useShoppingListPreferences();
const ready = ref(false);
// Capture values at initialization to avoid reactive updates
const currentHouseholdSlug = ref("");
const filteredShoppingLists = ref<ShoppingListSummary[]>([]);
const state = reactive({
shoppingListDialog: false,
shoppingListDialog: true,
shoppingListIngredientDialog: false,
shoppingListShowAllToggled: false,
});
const { shoppingListDialog, shoppingListIngredientDialog, shoppingListShowAllToggled: _shoppingListShowAllToggled } = toRefs(state);
const userHousehold = computed(() => {
return $auth.user.value?.householdSlug || "";
});
const shoppingListChoices = computed(() => {
return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id);
});
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
watch([dialog, () => preferences.value.viewAllLists], () => {
if (dialog.value) {
currentHouseholdSlug.value = auth.user.value?.householdSlug || "";
filteredShoppingLists.value = props.shoppingLists.filter(
list => preferences.value.viewAllLists || list.userId === auth.user.value?.id,
);
if (filteredShoppingLists.value.length === 1 && !state.shoppingListShowAllToggled) {
selectedShoppingList.value = filteredShoppingLists.value[0];
watchEffect(
() => {
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
selectedShoppingList.value = shoppingListChoices.value[0];
openShoppingListIngredientDialog(selectedShoppingList.value);
}
else {
state.shoppingListDialog = true;
ready.value = true;
}
}
else if (!dialog.value) {
},
);
watch(dialog, (val) => {
if (!val) {
initState();
}
});
@@ -273,26 +274,22 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
continue;
}
// Create a local copy to avoid mutating props
let recipeData = { ...recipe };
if (!(recipeData.id && recipeData.name && recipeData.recipeIngredient)) {
const { data } = await api.recipes.getOne(recipeData.slug);
if (!(recipe.id && recipe.name && recipe.recipeIngredient)) {
const { data } = await api.recipes.getOne(recipe.slug);
if (!data?.recipeIngredient?.length) {
continue;
}
recipeData = {
...recipeData,
id: data.id || "",
name: data.name || "",
recipeIngredient: data.recipeIngredient,
};
recipe.id = data.id || "";
recipe.name = data.name || "";
recipe.recipeIngredient = data.recipeIngredient;
}
else if (!recipeData.recipeIngredient.length) {
else if (!recipe.recipeIngredient.length) {
continue;
}
const shoppingListIngredients: ShoppingListIngredient[] = [];
function flattenRecipeIngredients(ing: RecipeIngredient, parentTitle = ""): ShoppingListIngredient[] {
const householdsWithFood = ing.food?.householdsWithIngredientFood || [];
if (ing.referencedRecipe) {
// Recursively flatten all ingredients in the referenced recipe
return (ing.referencedRecipe.recipeIngredient ?? []).flatMap((subIng) => {
@@ -306,9 +303,8 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
}
else {
// Regular ingredient
const householdsWithFood = ing.food?.householdsWithIngredientFood || [];
return [{
checked: !householdsWithFood.includes(currentHouseholdSlug.value),
checked: !householdsWithFood.includes(userHousehold.value),
ingredient: {
...ing,
title: ing.title || parentTitle,
@@ -317,7 +313,7 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
}
}
recipeData.recipeIngredient.forEach((ing) => {
recipe.recipeIngredient.forEach((ing) => {
const flattened = flattenRecipeIngredients(ing, "");
shoppingListIngredients.push(...flattened);
});
@@ -347,7 +343,7 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
// Store the on-hand ingredients for later
const householdsWithFood = (ing.ingredient?.food?.householdsWithIngredientFood || []);
if (householdsWithFood.includes(currentHouseholdSlug.value)) {
if (householdsWithFood.includes(userHousehold.value)) {
onHandIngs.push(ing);
return sections;
}
@@ -361,9 +357,9 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
shoppingListIngredientSections[shoppingListIngredientSections.length - 1].ingredients.push(...onHandIngs);
recipeSectionMap.set(recipe.slug, {
recipeId: recipeData.id,
recipeName: recipeData.name,
recipeScale: recipeData.scale,
recipeId: recipe.id,
recipeName: recipe.name,
recipeScale: recipe.scale,
ingredientSections: shoppingListIngredientSections,
});
}
@@ -372,7 +368,7 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
}
function initState() {
state.shoppingListDialog = false;
state.shoppingListDialog = true;
state.shoppingListIngredientDialog = false;
state.shoppingListShowAllToggled = false;
recipeIngredientSections.value = [];

View File

@@ -12,7 +12,6 @@
dark
color="primary-lighten-1 top-0 position-relative left-0"
:rounded="!$vuetify.display.xs"
style="width: 100%;"
>
<v-text-field
id="arrow-search"
@@ -33,8 +32,9 @@
<v-btn
v-if="$vuetify.display.xs"
icon
size="x-small"
class="rounded-circle"
light
@click="dialog = false"
>
<v-icon>
@@ -87,7 +87,7 @@ const emit = defineEmits<{
selected: [recipe: RecipeSummary];
}>();
const auth = useMealieAuth();
const $auth = useMealieAuth();
const loading = ref(false);
const selectedIndex = ref(-1);
@@ -153,7 +153,7 @@ watch(dialog, (val) => {
});
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
watch(route, close);
function open() {

View File

@@ -119,10 +119,10 @@ whenever(
);
const i18n = useI18n();
const auth = useMealieAuth();
const $auth = useMealieAuth();
const { household } = useHouseholdSelf();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;

View File

@@ -34,11 +34,11 @@ import { useLazyRecipes } from "~/composables/recipes";
export default defineNuxtComponent({
components: { RecipeCardSection, RecipeExplorerPageSearch },
setup() {
const auth = useMealieAuth();
const $auth = useMealieAuth();
const route = useRoute();
const { isOwnGroup } = useLoggedInState();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const { recipes, appendRecipes, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);

View File

@@ -100,7 +100,6 @@
v-model="state.auto"
:label="$t('search.auto-search')"
single-line
color="primary"
/>
<v-btn
block
@@ -141,13 +140,13 @@ const emit = defineEmits<{
ready: [];
}>();
const auth = useMealieAuth();
const $auth = useMealieAuth();
const route = useRoute();
const { $globals } = useNuxtApp();
const i18n = useI18n();
const showRandomLoading = ref(false);
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const {
state,

View File

@@ -81,11 +81,11 @@ import {
usePublicToolStore,
} from "~/composables/store";
const auth = useMealieAuth();
const $auth = useMealieAuth();
const route = useRoute();
const { isOwnGroup } = useLoggedInState();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const {
state,
@@ -101,14 +101,4 @@ const { store: tags } = isOwnGroup.value ? useTagStore() : usePublicTagStore(gro
const { store: tools } = isOwnGroup.value ? useToolStore() : usePublicToolStore(groupSlug.value);
const { store: foods } = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value);
const { store: households } = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value);
watch(
households,
() => {
// if exactly one household exists, then we shouldn't be filtering by household
if (households.value.length == 1) {
selectedHouseholds.value = [];
}
},
);
</script>

View File

@@ -52,14 +52,14 @@ const isFavorite = computed(() => {
async function toggleFavorite() {
const api = useUserApi();
const auth = useMealieAuth();
const $auth = useMealieAuth();
if (!auth.user.value) return;
if (!$auth.user.value) return;
if (!isFavorite.value) {
await api.users.addFavorite(auth.user.value?.id, props.recipeId);
await api.users.addFavorite($auth.user.value?.id, props.recipeId);
}
else {
await api.users.removeFavorite(auth.user.value?.id, props.recipeId);
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
}
await refreshUserRatings();
}

View File

@@ -22,15 +22,12 @@
cols="12"
class="flex-grow-0 flex-shrink-0"
>
<v-number-input
<v-text-field
v-model="model.quantity"
variant="solo"
:precision="null"
:min="0"
hide-details
control-variant="stacked"
inset
density="compact"
type="number"
:placeholder="$t('recipe.quantity')"
@keypress="quantityFilter"
>
@@ -41,7 +38,7 @@
{{ $globals.icons.arrowUpDown }}
</v-icon>
</template>
</v-number-input>
</v-text-field>
</v-col>
<v-col
v-if="!state.isRecipe"
@@ -58,8 +55,8 @@
density="compact"
variant="solo"
return-object
:items="filteredUnits"
:custom-filter="() => true"
:items="units || []"
:custom-filter="normalizeFilter"
item-title="name"
class="mx-1"
:placeholder="$t('recipe.choose-unit')"
@@ -117,8 +114,8 @@
density="compact"
variant="solo"
return-object
:items="filteredFoods"
:custom-filter="() => true"
:items="foods || []"
:custom-filter="normalizeFilter"
item-title="name"
class="mx-1 py-0"
:placeholder="$t('recipe.choose-food')"
@@ -176,6 +173,7 @@
variant="solo"
return-object
:items="search.data.value || []"
:custom-filter="normalizeFilter"
item-title="name"
class="mx-1 py-0"
:placeholder="$t('search.type-to-search')"
@@ -226,11 +224,11 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, toRefs, watch } from "vue";
import { ref, computed, reactive, toRefs } from "vue";
import { useDisplay } from "vuetify";
import { useI18n } from "vue-i18n";
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
import { useSearch } from "~/composables/use-search";
import { normalizeFilter } from "~/composables/use-utils";
import { useNuxtApp } from "#app";
import type { RecipeIngredient } from "~/lib/api/types/recipe";
import { usePublicExploreApi, useUserApi } from "~/composables/api";
@@ -342,8 +340,8 @@ const btns = computed(() => {
// Foods
const foodStore = useFoodStore();
const foodData = useFoodData();
const foodSearch = ref("");
const foodAutocomplete = ref<HTMLInputElement>();
const { search: foodSearch, filtered: filteredFoods } = useSearch(foodStore.store);
async function createAssignFood() {
foodData.data.name = foodSearch.value;
@@ -354,8 +352,8 @@ async function createAssignFood() {
// Recipes
const route = useRoute();
const auth = useMealieAuth();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const $auth = useMealieAuth();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const { isOwnGroup } = useLoggedInState();
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
@@ -374,8 +372,8 @@ watch(loading, (val) => {
// Units
const unitStore = useUnitStore();
const unitsData = useUnitData();
const unitSearch = ref("");
const unitAutocomplete = ref<HTMLInputElement>();
const { search: unitSearch, filtered: filteredUnits } = useSearch(unitStore.store);
async function createAssignUnit() {
unitsData.data.name = unitSearch.value;
@@ -429,6 +427,9 @@ function quantityFilter(e: KeyboardEvent) {
}
const { showTitle } = toRefs(state);
const foods = foodStore.store;
const units = unitStore.store;
</script>
<style>

View File

@@ -12,7 +12,7 @@
<script setup lang="ts">
import { computed } from "vue";
import type { RecipeIngredient } from "~/lib/api/types/recipe";
import { useIngredientTextParser } from "~/composables/recipes";
import { useParsedIngredientText } from "~/composables/recipes";
interface Props {
ingredient?: RecipeIngredient;
@@ -20,7 +20,6 @@ interface Props {
}
const { ingredient, scale = 1 } = defineProps<Props>();
const { useParsedIngredientText } = useIngredientTextParser();
const baseText = computed(() => {
if (!ingredient) return "";

View File

@@ -34,7 +34,7 @@
<script setup lang="ts">
import type { RecipeIngredient } from "~/lib/api/types/household";
import { useIngredientTextParser } from "~/composables/recipes";
import { useParsedIngredientText } from "~/composables/recipes";
interface Props {
ingredient: RecipeIngredient;
@@ -44,9 +44,8 @@ const props = withDefaults(defineProps<Props>(), {
scale: 1,
});
const route = useRoute();
const auth = useMealieAuth();
const groupSlug = computed(() => route.params.groupSlug || auth.user?.value?.groupSlug || "");
const { useParsedIngredientText } = useIngredientTextParser();
const $auth = useMealieAuth();
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
const parsedIng = computed(() => {
return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());

View File

@@ -17,13 +17,15 @@
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]" />
<template v-if="!isCookMode">
<h3
v-if="showTitleEditor[index]"
class="mt-2"
>
{{ ingredient.title }}
</h3>
<v-divider v-if="showTitleEditor[index]" />
</template>
<v-list-item
density="compact"
class="pa-0"
@@ -52,7 +54,7 @@
<script setup lang="ts">
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
import { useIngredientTextParser } from "~/composables/recipes";
import { parseIngredientText } from "~/composables/recipes";
import type { RecipeIngredient } from "~/lib/api/types/recipe";
interface Props {
@@ -66,8 +68,6 @@ const props = withDefaults(defineProps<Props>(), {
isCookMode: false,
});
const { parseIngredientText } = useIngredientTextParser();
function validateTitle(title?: string | null) {
return !(title === undefined || title === "" || title === null);
}

View File

@@ -159,7 +159,7 @@ const madeThisDialog = ref(false);
const userApi = useUserApi();
const { household } = useHouseholdSelf();
const i18n = useI18n();
const auth = useMealieAuth();
const $auth = useMealieAuth();
const domMadeThisForm = ref<VForm>();
const newTimelineEvent = ref<RecipeTimelineEventIn>({
subject: "",
@@ -179,7 +179,7 @@ const newTimelineEventTimestampString = computed(() => {
const lastMade = ref(props.recipe.lastMade);
const lastMadeReady = ref(false);
onMounted(async () => {
if (!auth.user?.value?.householdSlug) {
if (!$auth.user?.value?.householdSlug) {
lastMade.value = props.recipe.lastMade;
}
else {
@@ -255,8 +255,8 @@ async function createTimelineEvent() {
madeThisFormLoading.value = true;
newTimelineEvent.value.recipeId = props.recipe.id;
// Note: auth.user is now a ref
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: auth.user.value?.fullName });
// Note: $auth.user is now a ref
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.value?.fullName });
// the user only selects the date, so we set the time to end of day local time
// we choose the end of day so it always comes after "new recipe" events

View File

@@ -73,10 +73,10 @@ const props = withDefaults(defineProps<Props>(), {
disabled: false,
});
const auth = useMealieAuth();
const $auth = useMealieAuth();
const { frac } = useFraction();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || auth.user?.value?.groupSlug || "");
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
const attrs = computed(() => {
return props.small

View File

@@ -10,17 +10,14 @@
v-for="(item, key, index) in modelValue"
:key="index"
>
<v-number-input
<v-text-field
density="compact"
:model-value="modelValue[key]"
:label="labels[key].label"
:suffix="labels[key].suffix"
density="compact"
type="number"
autocomplete="off"
variant="underlined"
control-variant="stacked"
inset
:precision="null"
:min="0"
@update:model-value="updateValue(key, $event)"
/>
</div>

View File

@@ -162,9 +162,9 @@ const state = reactive({
},
});
const auth = useMealieAuth();
const $auth = useMealieAuth();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user?.value?.groupSlug || "");
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || "");
// =================================================================
// Context Menu

View File

@@ -48,7 +48,8 @@
</template>
<script setup lang="ts">
import type { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import type { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
import type { RecipeTool } from "~/lib/api/types/admin";
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
import type { HouseholdSummary } from "~/lib/api/types/household";
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
@@ -165,15 +166,6 @@ const items = computed<any[]>(() => {
return list;
});
function removeByIndex(index: number) {
if (selected.value === undefined) {
return;
}
const newSelected = selected.value.filter((_, i) => i !== index);
selected.value = [...newSelected];
}
function appendCreated(item: any) {
if (selected.value === undefined) {
return;

View File

@@ -1,18 +1,5 @@
<template>
<div>
<BaseDialog
v-model="discardDialog"
:title="$t('general.discard-changes')"
color="warning"
:icon="$globals.icons.alertCircle"
can-confirm
@confirm="confirmDiscard"
@cancel="cancelDiscard"
>
<v-card-text>
{{ $t("general.discard-changes-description") }}
</v-card-text>
</BaseDialog>
<RecipePageParseDialog
:model-value="isParsing"
:ingredients="recipe.recipeIngredient"
@@ -28,7 +15,6 @@
:landscape="landscape"
@save="saveRecipe"
@delete="deleteRecipe"
@close="closeEditor"
/>
<RecipeJsonEditor
v-if="isEditJSON"
@@ -188,7 +174,6 @@
<script setup lang="ts">
import { invoke, until } from "@vueuse/core";
import type { RouteLocationNormalized } from "vue-router";
import RecipeIngredients from "../RecipeIngredients.vue";
import RecipePageEditorToolbar from "./RecipePageParts/RecipePageEditorToolbar.vue";
import RecipePageFooter from "./RecipePageParts/RecipePageFooter.vue";
@@ -220,11 +205,12 @@ import { useNavigationWarning } from "~/composables/use-navigation-warning";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const display = useDisplay();
const auth = useMealieAuth();
const i18n = useI18n();
const $auth = useMealieAuth();
const route = useRoute();
const { isOwnGroup } = useLoggedInState();
const groupSlug = computed(() => (route.params.groupSlug as string) || auth.user?.value?.groupSlug || "");
const groupSlug = computed(() => (route.params.groupSlug as string) || $auth.user?.value?.groupSlug || "");
const router = useRouter();
const api = useUserApi();
@@ -245,68 +231,26 @@ const notLinkedIngredients = computed(() => {
* and prompts the user to save if they have unsaved changes.
*/
const originalRecipe = ref<Recipe | null>(null);
const discardDialog = ref(false);
const pendingRoute = ref<RouteLocationNormalized | null>(null);
invoke(async () => {
await until(recipe.value).not.toBeNull();
originalRecipe.value = deepCopy(recipe.value);
});
function hasUnsavedChanges(): boolean {
if (originalRecipe.value === null) {
return false;
}
return JSON.stringify(recipe.value) !== JSON.stringify(originalRecipe.value);
}
onUnmounted(async () => {
const isSame = JSON.stringify(recipe.value) === JSON.stringify(originalRecipe.value);
if (isEditMode.value && !isSame && recipe.value?.slug !== undefined) {
const save = window.confirm(i18n.t("general.unsaved-changes"));
function restoreOriginalRecipe() {
if (originalRecipe.value) {
recipe.value = deepCopy(originalRecipe.value) as NoUndefinedField<Recipe>;
if (save) {
await api.recipes.updateOne(recipe.value.slug, recipe.value);
}
}
}
function closeEditor() {
if (hasUnsavedChanges()) {
pendingRoute.value = null;
discardDialog.value = true;
}
else {
setMode(PageMode.VIEW);
}
}
function confirmDiscard() {
restoreOriginalRecipe();
discardDialog.value = false;
if (pendingRoute.value) {
const destination = pendingRoute.value;
pendingRoute.value = null;
router.push(destination);
}
else {
setMode(PageMode.VIEW);
}
}
function cancelDiscard() {
discardDialog.value = false;
pendingRoute.value = null;
}
onBeforeRouteLeave((to) => {
if (isEditMode.value && hasUnsavedChanges()) {
pendingRoute.value = to;
discardDialog.value = true;
return false;
}
});
onUnmounted(() => {
deactivateNavigationWarning();
toggleCookMode();
clearPageState(recipe.value.slug || "");
console.debug("reset RecipePage state during unmount");
});
const hasLinkedIngredients = computed(() => {
return recipe.value.recipeInstructions.some(
@@ -356,8 +300,6 @@ async function saveRecipe() {
if (data?.slug) {
router.push(`/g/${groupSlug.value}/r/` + data.slug);
recipe.value = data as NoUndefinedField<Recipe>;
// Update the snapshot after successful save
originalRecipe.value = deepCopy(recipe.value);
}
}

View File

@@ -16,7 +16,7 @@
:open="isEditMode"
:recipe-id="recipe.id"
class="ml-auto mt-n7 pb-4"
@close="$emit('close')"
@close="setMode(PageMode.VIEW)"
@json="toggleEditMode()"
@edit="setMode(PageMode.EDIT)"
@save="$emit('save')"
@@ -47,7 +47,7 @@ const props = withDefaults(defineProps<Props>(), {
landscape: false,
});
defineEmits(["save", "delete", "print", "close"]);
defineEmits(["save", "delete"]);
const { recipeImage } = useStaticRoutes();
const { imageKey, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);

View File

@@ -11,27 +11,27 @@
<v-container class="ma-0 pa-0">
<v-row>
<v-col cols="3">
<v-number-input
:model-value="recipe.recipeServings"
<v-text-field
:model-value="recipeServings"
type="number"
:min="0"
:precision="null"
hide-spin-buttons
density="compact"
:label="$t('recipe.servings')"
variant="underlined"
control-variant="hidden"
@update:model-value="recipe.recipeServings = $event"
@update:model-value="validateInput($event, 'recipeServings')"
/>
</v-col>
<v-col cols="3">
<v-number-input
:model-value="recipe.recipeYieldQuantity"
<v-text-field
:model-value="recipeYieldQuantity"
type="number"
:min="0"
:precision="null"
hide-spin-buttons
density="compact"
:label="$t('recipe.yield')"
variant="underlined"
control-variant="hidden"
@update:model-value="recipe.recipeYieldQuantity = $event"
@update:model-value="validateInput($event, 'recipeYieldQuantity')"
/>
</v-col>
<v-col cols="6">
@@ -85,4 +85,37 @@ import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe } from "~/lib/api/types/recipe";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const recipeServings = computed<number>({
get() {
return recipe.value.recipeServings;
},
set(val) {
validateInput(val.toString(), "recipeServings");
},
});
const recipeYieldQuantity = computed<number>({
get() {
return recipe.value.recipeYieldQuantity;
},
set(val) {
validateInput(val.toString(), "recipeYieldQuantity");
},
});
function validateInput(value: string | null, property: "recipeServings" | "recipeYieldQuantity") {
if (!value) {
recipe.value[property] = 0;
return;
}
const number = parseFloat(value.replace(/[^0-9.]/g, ""));
if (isNaN(number) || number <= 0) {
recipe.value[property] = 0;
return;
}
recipe.value[property] = number;
}
</script>

View File

@@ -431,7 +431,6 @@ const props = defineProps({
const emit = defineEmits(["click-instruction-field", "update:assets"]);
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
const { extractIngredientReferences } = useExtractIngredientReferences();
const dialog = ref(false);
const disabledSteps = ref<number[]>([]);
@@ -582,7 +581,7 @@ function setUsedIngredients() {
watch(activeRefs, () => setUsedIngredients());
function autoSetReferences() {
extractIngredientReferences(
useExtractIngredientReferences(
props.recipe.recipeIngredient,
activeRefs.value,
activeText.value,

View File

@@ -197,7 +197,7 @@ import type { IngredientFood, IngredientUnit, ParsedIngredient, RecipeIngredient
import type { Parser } from "~/lib/api/user/recipes/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useUserApi } from "~/composables/api";
import { useIngredientTextParser } from "~/composables/recipes";
import { parseIngredientText } from "~/composables/recipes";
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
import { useGlobalI18n } from "~/composables/use-global-i18n";
import { alert } from "~/composables/use-toast";
@@ -208,8 +208,6 @@ const props = defineProps<{
ingredients: NoUndefinedField<RecipeIngredient[]>;
}>();
const { parseIngredientText } = useIngredientTextParser();
const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void;
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;

View File

@@ -192,7 +192,7 @@ import { useStaticRoutes } from "~/composables/api";
import type { Recipe, RecipeIngredient, RecipeStep } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
import { useIngredientTextParser, useNutritionLabels } from "~/composables/recipes";
import { parseIngredientText, useNutritionLabels } from "~/composables/recipes";
import { usePageState } from "~/composables/recipe-page/shared-state";
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
@@ -362,8 +362,6 @@ const hasNotes = computed(() => {
return props.recipe.notes && props.recipe.notes.length > 0;
});
const { parseIngredientText } = useIngredientTextParser();
function parseText(ingredient: RecipeIngredient) {
return parseIngredientText(ingredient, props.scale);
}

View File

@@ -65,13 +65,13 @@
</v-card-title>
<v-card-text class="mt-n5">
<div class="mt-4 d-flex align-center">
<v-number-input
<v-text-field
:model-value="yieldQuantity"
:precision="null"
type="number"
:min="0"
variant="underlined"
control-variant="hidden"
@update:model-value="recalculateScale($event || 0)"
hide-spin-buttons
@update:model-value="recalculateScale(parseFloat($event) || 0)"
/>
<v-tooltip
location="end"

View File

@@ -39,6 +39,7 @@
:nudge-top="props.menuTop ? '5' : '0'"
allow-overflow
close-delay="125"
:open-on-hover="!props.useMobileFormat"
content-class="d-print-none"
>
<template #activator="{ props: btnProps }">
@@ -98,6 +99,7 @@ const props = defineProps<{
color?: string;
event: RecipeTimelineEventOut;
menuIcon?: string | null;
useMobileFormat?: boolean;
}>();
const emit = defineEmits(["delete", "update"]);

View File

@@ -35,6 +35,7 @@
:menu-top="false"
:event="event"
:menu-icon="$globals.icons.dotsVertical"
:use-mobile-format="useMobileFormat"
color="transparent"
:elevation="0"
:card-menu="false"

View File

@@ -28,8 +28,8 @@
<v-card width="400">
<v-card-text>
<v-text-field
v-model="searchInput"
v-memo="[searchInput]"
v-model="state.search"
v-memo="[state.search]"
class="mb-2"
hide-details
density="comfortable"
@@ -37,29 +37,21 @@
:label="$t('search.search')"
clearable
/>
<div />
<div class="d-flex flex-wrap py-4 px-1 align-center">
<v-btn-toggle
<div class="d-flex py-4 px-1">
<v-switch
v-if="requireAll != undefined"
v-model="combinator"
mandatory
v-model="requireAllValue"
density="compact"
variant="outlined"
hide-details
class="my-auto"
color="primary"
class="my-1"
>
<v-btn value="hasAll">
{{ $t('search.has-all') }}
</v-btn>
<v-btn value="hasAny">
{{ $t('search.has-any') }}
</v-btn>
</v-btn-toggle>
:label="requireAllValue ? $t('search.has-all') : $t('search.has-any')"
/>
<v-spacer />
<v-btn
size="small"
color="accent"
class="my-1"
class="mr-2 my-auto"
@click="clearSelection"
>
{{ $t("search.clear-selection") }}
@@ -146,13 +138,17 @@
</template>
<script lang="ts">
import type { ISearchableItem } from "~/composables/use-search";
import { useSearch } from "~/composables/use-search";
import { watchDebounced } from "@vueuse/core";
export interface SelectableItem {
id: string;
name: string;
}
export default defineNuxtComponent({
props: {
items: {
type: Array as () => ISearchableItem[],
type: Array as () => SelectableItem[],
required: true,
},
modelValue: {
@@ -171,22 +167,23 @@ export default defineNuxtComponent({
emits: ["update:requireAll", "update:modelValue"],
setup(props, context) {
const state = reactive({
search: "",
menu: false,
});
// Use the search composable
const { search: searchInput, filtered } = useSearch(computed(() => props.items));
// Use shallowRef for better performance with arrays
const debouncedSearch = shallowRef("");
const combinator = computed({
get: () => (props.requireAll ? "hasAll" : "hasAny"),
const requireAllValue = computed({
get: () => props.requireAll,
set: (value) => {
context.emit("update:requireAll", value === "hasAll");
context.emit("update:requireAll", value);
},
});
// Use shallowRef to prevent deep reactivity on large arrays
const selected = computed({
get: () => props.modelValue as ISearchableItem[],
get: () => props.modelValue as SelectableItem[],
set: (value) => {
context.emit("update:modelValue", value);
},
@@ -199,12 +196,44 @@ export default defineNuxtComponent({
},
});
watchDebounced(
() => state.search,
(newSearch) => {
debouncedSearch.value = newSearch;
},
{ debounce: 500, maxWait: 1500, immediate: false }, // Increased debounce time
);
const filtered = computed(() => {
const items = props.items;
const search = debouncedSearch.value;
if (!search || search.length < 2) { // Only filter after 2 characters
return items;
}
const searchLower = search.toLowerCase();
return items.filter(item => item.name.toLowerCase().includes(searchLower));
});
const selectedCount = computed(() => selected.value.length);
const selectedIds = computed(() => {
return new Set(selected.value.map(item => item.id));
});
const handleRadioClick = (item: ISearchableItem) => {
const handleCheckboxClick = (item: SelectableItem) => {
const currentSelection = selected.value;
const isSelected = selectedIds.value.has(item.id);
if (isSelected) {
selected.value = currentSelection.filter(i => i.id !== item.id);
}
else {
selected.value = [...currentSelection, item];
}
};
const handleRadioClick = (item: SelectableItem) => {
if (selectedRadio.value === item) {
selectedRadio.value = null;
}
@@ -213,18 +242,18 @@ export default defineNuxtComponent({
function clearSelection() {
selected.value = [];
selectedRadio.value = null;
searchInput.value = "";
state.search = "";
}
return {
combinator,
requireAllValue,
state,
searchInput,
selected,
selectedRadio,
selectedCount,
selectedIds,
filtered,
handleCheckboxClick,
handleRadioClick,
clearSelection,
};

View File

@@ -15,10 +15,7 @@
density="compact"
class="mt-0 flex-shrink-0"
color="null"
@click="() => {
listItem.checked = !listItem.checked
$emit('checked', listItem)
}"
@change="$emit('checked', listItem)"
/>
<div
class="ml-2 text-truncate"

View File

@@ -4,16 +4,7 @@
<v-card-text class="pb-3 pt-1">
<div class="d-md-flex align-center mb-2" style="gap: 20px">
<div>
<v-number-input
v-model="listItem.quantity"
hide-details
:label="$t('form.quantity-label-abbreviated')"
:min="0"
:precision="null"
control-variant="stacked"
inset
style="width: 100px;"
/>
<InputQuantity v-model="listItem.quantity" />
</div>
<InputLabelType
v-model="listItem.unit"
@@ -30,7 +21,6 @@
:items="foods"
:label="$t('shopping-list.food')"
:icon="$globals.icons.foods"
:autofocus="autoFocus === 'food'"
create
@create="createAssignFood"
/>
@@ -42,7 +32,7 @@
:label="$t('shopping-list.note')"
rows="1"
auto-grow
:autofocus="autoFocus === 'note'"
autofocus
@keypress="handleNoteKeyPress"
/>
</div>
@@ -57,6 +47,25 @@
width="250"
/>
</div>
<v-menu
v-if="listItem.recipeReferences && listItem.recipeReferences.length > 0"
open-on-hover
offset-y
start
top
>
<template #activator="{ props }">
<v-icon class="mt-auto" :icon="$globals.icons.alert" v-bind="props" color="warning">
{{ $globals.icons.alert }}
</v-icon>
</template>
<v-card max-width="350px" class="left-warning-border">
<v-card-text>
{{ $t("shopping-list.linked-item-warning") }}
</v-card-text>
</v-card>
</v-menu>
</div>
<BaseButton
v-if="listItem.labelId && listItem.food && listItem.labelId !== listItem.food.labelId"
@@ -149,15 +158,6 @@ export default defineNuxtComponent({
},
});
watch(
() => props.modelValue.quantity,
() => {
if (!props.modelValue.quantity) {
listItem.value.quantity = 0;
}
},
);
watch(
() => props.modelValue.food,
(newFood) => {
@@ -166,8 +166,6 @@ export default defineNuxtComponent({
},
);
const autoFocus = !listItem.value.food && listItem.value.note ? "note" : "food";
async function createAssignFood(val: string) {
// keep UI reactive
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
@@ -207,7 +205,6 @@ export default defineNuxtComponent({
return {
listItem,
autoFocus,
createAssignFood,
createAssignUnit,
assignLabelToFood,

View File

@@ -62,15 +62,15 @@ export default defineNuxtComponent({
error: false,
});
const auth = useMealieAuth();
const $auth = useMealieAuth();
const { store: users } = useUserStore();
const user = computed(() => {
return users.value.find(user => user.id === props.userId);
});
const imageURL = computed(() => {
// Note: auth.user is a ref now
const authUser = auth.user.value;
// Note: $auth.user is a ref now
const authUser = $auth.user.value;
const key = authUser?.cacheKey ?? "";
return `/api/media/users/${props.userId}/profile.webp?cacheKey=${key}`;
});

View File

@@ -102,9 +102,9 @@ export default defineNuxtComponent({
emits: ["update:modelValue"],
setup(props, context) {
const i18n = useI18n();
const auth = useMealieAuth();
const $auth = useMealieAuth();
const isAdmin = computed(() => auth.user.value?.admin);
const isAdmin = computed(() => $auth.user.value?.admin);
const token = ref("");
const selectedGroup = ref<string | null>(null);
const selectedHousehold = ref<string | null>(null);

View File

@@ -106,11 +106,11 @@ export default defineNuxtComponent({
const i18n = useI18n();
const { $appInfo, $globals } = useNuxtApp();
const display = useDisplay();
const auth = useMealieAuth();
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const cookbookPreferences = useCookbookPreferences();
const ownCookbookStore = useCookbookStore(i18n);
@@ -152,7 +152,7 @@ export default defineNuxtComponent({
};
}
const currentUserHouseholdId = computed(() => auth.user.value?.householdId);
const currentUserHouseholdId = computed(() => $auth.user.value?.householdId);
const cookbookLinks = computed<SideBarLink[]>(() => {
if (!cookbooks.value?.length) {
return [];
@@ -187,7 +187,7 @@ export default defineNuxtComponent({
});
links.sort((a, b) => a.title.localeCompare(b.title));
if (auth.user.value && cookbookPreferences.value.hideOtherHouseholds) {
if ($auth.user.value && cookbookPreferences.value.hideOtherHouseholds) {
return ownLinks;
}
else {

View File

@@ -97,10 +97,10 @@ export default defineNuxtComponent({
},
},
setup() {
const auth = useMealieAuth();
const $auth = useMealieAuth();
const { loggedIn } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const { xs, smAndUp } = useDisplay();
const routerLink = computed(() => groupSlug.value ? `/g/${groupSlug.value}` : "/");
@@ -128,7 +128,7 @@ export default defineNuxtComponent({
async function logout() {
try {
await auth.signOut("/login?direct=1");
await $auth.signOut("/login?direct=1");
}
catch (e) {
console.error(e);

View File

@@ -168,13 +168,13 @@ export default defineNuxtComponent({
},
emits: ["update:modelValue"],
setup(props, context) {
const auth = useMealieAuth();
const $auth = useMealieAuth();
const { loggedIn, isOwnGroup } = useLoggedInState();
const isAdmin = computed(() => auth.user.value?.admin);
const canManage = computed(() => auth.user.value?.canManage);
const isAdmin = computed(() => $auth.user.value?.admin);
const canManage = computed(() => $auth.user.value?.canManage);
const userFavoritesLink = computed(() => auth.user.value ? `/user/${auth.user.value.id}/favorites` : undefined);
const userProfileLink = computed(() => auth.user.value ? "/user/profile" : undefined);
const userFavoritesLink = computed(() => $auth.user.value ? `/user/${$auth.user.value.id}/favorites` : undefined);
const userProfileLink = computed(() => $auth.user.value ? "/user/profile" : undefined);
const toggleDark = useToggleDarkMode();
@@ -217,7 +217,7 @@ export default defineNuxtComponent({
isAdmin,
canManage,
isOwnGroup,
sessionUser: auth.user,
sessionUser: $auth.user,
toggleDark,
};
},

View File

@@ -9,9 +9,9 @@
*/
export default defineNuxtComponent({
setup(_, ctx) {
const auth = useMealieAuth();
const $auth = useMealieAuth();
const r = auth.user.value?.advanced || false;
const r = $auth.user.value?.advanced || false;
return () => {
return r ? ctx.slots.default?.() : null;

View File

@@ -1,155 +1,211 @@
<template>
<v-form v-model="isValid" validate-on="input">
<v-card
:color="color"
:dark="dark"
flat
:width="width"
class="my-2"
>
<v-row>
<v-col
v-for="(inputField, index) in items"
:key="index"
cols="12"
sm="12"
<v-card
:color="color"
:dark="dark"
flat
:width="width"
class="my-2"
>
<v-row>
<v-col
v-for="(inputField, index) in items"
:key="index"
cols="12"
sm="12"
>
<v-divider
v-if="inputField.section"
class="my-2"
/>
<v-card-title
v-if="inputField.section"
class="pl-0"
>
<v-divider
v-if="inputField.section"
class="my-2"
/>
<v-card-title
v-if="inputField.section"
class="pl-0"
>
{{ inputField.section }}
</v-card-title>
<v-card-text
v-if="inputField.sectionDetails"
class="pl-0 mt-0 pt-0"
>
{{ inputField.sectionDetails }}
</v-card-text>
{{ inputField.section }}
</v-card-title>
<v-card-text
v-if="inputField.sectionDetails"
class="pl-0 mt-0 pt-0"
>
{{ inputField.sectionDetails }}
</v-card-text>
<!-- Check Box -->
<v-checkbox
v-if="inputField.type === fieldTypes.BOOLEAN"
v-model="model[inputField.varName]"
:name="inputField.varName"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
:hint="inputField.hint"
:hide-details="!inputField.hint"
:persistent-hint="!!inputField.hint"
density="comfortable"
validate-on="input"
>
<template #label>
<span class="ml-4">
<!-- Check Box -->
<v-checkbox
v-if="inputField.type === fieldTypes.BOOLEAN"
v-model="model[inputField.varName]"
:name="inputField.varName"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
:hint="inputField.hint"
:hide-details="!inputField.hint"
:persistent-hint="!!inputField.hint"
density="comfortable"
@change="emitBlur"
>
<template #label>
<span class="ml-4">
{{ inputField.label }}
</span>
</template>
</v-checkbox>
<!-- Text Field -->
<v-text-field
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
variant="solo-filled"
flat
:autofocus="index === 0"
density="comfortable"
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
:rules="!(inputField.disableUpdate && updateMode) ? [...rulesByKey(inputField.rules as any), ...defaultRules] : []"
lazy-validation
@blur="emitBlur"
/>
<!-- Text Area -->
<v-textarea
v-else-if="inputField.type === fieldTypes.TEXT_AREA"
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
variant="solo-filled"
flat
rows="3"
auto-grow
density="comfortable"
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
:rules="[...rulesByKey(inputField.rules as any), ...defaultRules]"
lazy-validation
@blur="emitBlur"
/>
<!-- Option Select -->
<v-select
v-else-if="inputField.type === fieldTypes.SELECT"
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
variant="solo-filled"
flat
:label="inputField.label"
:name="inputField.varName"
:items="inputField.options"
item-title="text"
item-value="text"
:return-object="false"
:hint="inputField.hint"
density="comfortable"
persistent-hint
lazy-validation
@blur="emitBlur"
/>
<!-- Color Picker -->
<div
v-else-if="inputField.type === fieldTypes.COLOR"
class="d-flex"
style="width: 100%"
>
<v-menu offset-y>
<template #activator="{ props: templateProps }">
<v-btn
class="my-2 ml-auto"
style="min-width: 200px"
:color="model[inputField.varName]"
dark
v-bind="templateProps"
>
{{ inputField.label }}
</span>
</v-btn>
</template>
</v-checkbox>
<v-color-picker
v-model="model[inputField.varName]"
value="#7417BE"
hide-canvas
hide-inputs
show-swatches
class="mx-auto"
@input="emitBlur"
/>
</v-menu>
</div>
<!-- Text Field -->
<v-text-field
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
<!-- Object Type -->
<div v-else-if="inputField.type === fieldTypes.OBJECT">
<auto-form
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
variant="solo-filled"
flat
density="comfortable"
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
validate-on="input"
:color="color"
:items="(inputField as any).items"
@blur="emitBlur"
/>
</div>
<!-- Text Area -->
<v-textarea
v-else-if="inputField.type === fieldTypes.TEXT_AREA"
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
variant="solo-filled"
flat
rows="3"
auto-grow
density="comfortable"
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
validate-on="input"
/>
<!-- Number Input -->
<v-number-input
v-else-if="inputField.type === fieldTypes.NUMBER"
v-model="model[inputField.varName]"
variant="underlined"
:control-variant="inputField.numberInputConfig?.controlVariant"
density="comfortable"
:label="inputField.label"
:name="inputField.varName"
:min="inputField.numberInputConfig?.min"
:max="inputField.numberInputConfig?.max"
:precision="inputField.numberInputConfig?.precision"
:hint="inputField.hint"
:hide-details="!inputField.hint"
:persistent-hint="!!inputField.hint"
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
validate-on="input"
/>
<!-- Option Select -->
<v-select
v-else-if="inputField.type === fieldTypes.SELECT"
v-model="model[inputField.varName]"
:readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
variant="solo-filled"
flat
:label="inputField.label"
:name="inputField.varName"
:items="inputField.options"
item-title="text"
:item-value="inputField.selectReturnValue || 'text'"
:return-object="false"
:hint="inputField.hint"
density="comfortable"
persistent-hint
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
validate-on="input"
/>
<!-- Color Picker -->
<!-- List Type -->
<div v-else-if="inputField.type === fieldTypes.LIST">
<div
v-else-if="inputField.type === fieldTypes.COLOR"
class="d-flex"
style="width: 100%"
v-for="(item, idx) in model[inputField.varName]"
:key="idx"
>
<InputColor v-model="model[inputField.varName]" />
<p>
{{ inputField.label }} {{ idx + 1 }}
<span>
<BaseButton
class="ml-5"
x-small
delete
@click="removeByIndex(model[inputField.varName], idx)"
/>
</span>
</p>
<v-divider class="mb-5 mx-2" />
<auto-form
v-model="model[inputField.varName][idx]"
:color="color"
:items="(inputField as any).items"
@blur="emitBlur"
/>
</div>
</v-col>
</v-row>
</v-card>
</v-form>
<v-card-actions>
<v-spacer />
<BaseButton
small
@click="model[inputField.varName].push(getTemplate((inputField as any).items))"
>
{{ $t("general.new") }}
</BaseButton>
</v-card-actions>
</div>
</v-col>
</v-row>
</v-card>
</template>
<script lang="ts" setup>
import { validators } from "@/composables/use-validators";
import { fieldTypes } from "@/composables/forms";
import type { AutoFormItems } from "~/types/auto-forms";
const BLUR_EVENT = "blur";
type ValidatorKey = keyof typeof validators;
// Use defineModel for v-model
const model = defineModel<Record<string, any> | any[]>({
const modelValue = defineModel<Record<string, any> | any[]>({
type: [Object, Array],
required: true,
});
const isValid = defineModel("isValid", { type: Boolean, default: false });
// alias to avoid template TS complaining about possible undefined
const model = modelValue as any;
const props = defineProps({
updateMode: {
@@ -164,6 +220,10 @@ const props = defineProps({
type: [Number, String],
default: "max",
},
globalRules: {
default: null,
type: Array as () => string[],
},
color: {
default: null,
type: String,
@@ -182,6 +242,31 @@ const props = defineProps({
},
});
const emit = defineEmits(["blur", "update:modelValue"]);
function rulesByKey(keys?: ValidatorKey[] | null) {
if (keys === undefined || keys === null) {
return [] as any[];
}
const list: any[] = [];
keys.forEach((key) => {
const split = key.split(":");
const validatorKey = split[0] as ValidatorKey;
if (validatorKey in validators) {
if (split.length === 1) {
list.push((validators as any)[validatorKey]);
}
else {
list.push((validators as any)[validatorKey](split[1] as any));
}
}
});
return list;
}
const defaultRules = computed<any[]>(() => rulesByKey(props.globalRules as any));
// Combined state map for readonly and disabled fields
const fieldState = computed<Record<string, { readonly: boolean; disabled: boolean }>>(() => {
const map: Record<string, { readonly: boolean; disabled: boolean }> = {};
@@ -194,6 +279,25 @@ const fieldState = computed<Record<string, { readonly: boolean; disabled: boolea
});
return map;
});
function removeByIndex(list: never[], index: number) {
// Removes the item at the index
list.splice(index, 1);
}
function getTemplate(item: AutoFormItems) {
const obj = {} as { [key: string]: string };
item.forEach((field) => {
obj[field.varName] = "";
});
return obj;
}
function emitBlur() {
emit(BLUR_EVENT, modelValue.value);
}
</script>
<style lang="scss" scoped></style>

View File

@@ -8,6 +8,7 @@
:nudge-top="menuTop ? '5' : '0'"
allow-overflow
close-delay="125"
open-on-hover
content-class="d-print-none"
>
<template #activator="{ props }">

View File

@@ -8,11 +8,11 @@
nudge-bottom="6"
:close-on-content-click="false"
>
<template #activator="{ props: activatorProps }">
<template #activator="{ props }">
<v-btn
color="accent"
variant="elevated"
v-bind="activatorProps"
v-bind="props"
>
<v-icon>
{{ $globals.icons.cog }}
@@ -108,7 +108,7 @@
</div>
</template>
<script setup lang="ts">
<script lang="ts">
import { downloadAsJson } from "~/composables/use-utils";
export interface TableConfig {
@@ -120,7 +120,7 @@ export interface TableHeaders {
text: string;
value: string;
show: boolean;
align?: "start" | "center" | "end";
align?: string;
sortable?: boolean;
sort?: (a: any, b: any) => number;
}
@@ -131,95 +131,106 @@ export interface BulkAction {
event: string;
}
const props = defineProps({
tableConfig: {
type: Object as () => TableConfig,
default: () => ({
hideColumns: false,
canExport: false,
}),
export default defineNuxtComponent({
props: {
tableConfig: {
type: Object as () => TableConfig,
default: () => ({
hideColumns: false,
canExport: false,
}),
},
headers: {
type: Array as () => TableHeaders[],
required: true,
},
data: {
type: Array as () => any[],
required: true,
},
bulkActions: {
type: Array as () => BulkAction[],
default: () => [],
},
initialSort: {
type: String,
default: "id",
},
initialSortDesc: {
type: Boolean,
default: false,
},
},
headers: {
type: Array as () => TableHeaders[],
required: true,
},
data: {
type: Array as () => any[],
required: true,
},
bulkActions: {
type: Array as () => BulkAction[],
default: () => [],
},
initialSort: {
type: String,
default: "id",
},
initialSortDesc: {
type: Boolean,
default: false,
},
});
emits: ["delete-one", "edit-one"],
setup(props, context) {
const i18n = useI18n();
const sortBy = computed(() => [{
key: props.initialSort,
order: props.initialSortDesc ? "desc" : "asc",
}]);
const emit = defineEmits<{
(e: "delete-one" | "edit-one", item: any): void;
(e: "bulk-action", event: string, items: any[]): void;
}>();
// ===========================================================
// Reactive Headers
// Create a local reactive copy of headers that we can modify
const localHeaders = ref([...props.headers]);
const i18n = useI18n();
const sortBy = computed<{ key: string; order: "asc" | "desc" }[]>(() => [{
key: props.initialSort,
order: props.initialSortDesc ? "desc" : "asc",
}]);
// Watch for changes in props.headers and update local copy
watch(() => props.headers, (newHeaders) => {
localHeaders.value = [...newHeaders];
}, { deep: true });
// ===========================================================
// Reactive Headers
// Create a local reactive copy of headers that we can modify
const localHeaders = ref([...props.headers]);
const filteredHeaders = computed<string[]>(() => {
return localHeaders.value.filter(header => header.show).map(header => header.value);
});
// Watch for changes in props.headers and update local copy
watch(() => props.headers, (newHeaders) => {
localHeaders.value = [...newHeaders];
}, { deep: true });
const headersWithoutActions = computed(() =>
localHeaders.value
.filter(header => filteredHeaders.value.includes(header.value))
.map(header => ({
...header,
title: i18n.t(header.text),
})),
);
const filteredHeaders = computed<string[]>(() => {
return localHeaders.value.filter(header => header.show).map(header => header.value);
});
const activeHeaders = computed(() => [
...headersWithoutActions.value,
{ title: "", value: "actions", show: true, align: "end" },
]);
const headersWithoutActions = computed(() =>
localHeaders.value
.filter(header => filteredHeaders.value.includes(header.value))
.map(header => ({
...header,
title: i18n.t(header.text),
})),
);
const selected = ref<any[]>([]);
const activeHeaders = computed(() => [
...headersWithoutActions.value,
{ title: "", value: "actions", show: true, align: "end" },
]);
// ===========================================================
// Bulk Action Event Handler
const selected = ref<any[]>([]);
const bulkActionListener = computed(() => {
const handlers: { [key: string]: () => void } = {};
// ===========================================================
// Bulk Action Event Handler
props.bulkActions.forEach((action) => {
handlers[action.event] = () => {
context.emit(action.event, selected.value);
// clear selection
selected.value = [];
};
});
const bulkActionListener = computed(() => {
const handlers: { [key: string]: () => void } = {};
return handlers;
});
props.bulkActions.forEach((action) => {
handlers[action.event] = () => {
emit("bulk-action", action.event, selected.value);
// clear selection
selected.value = [];
const search = ref("");
return {
sortBy,
selected,
localHeaders,
filteredHeaders,
headersWithoutActions,
activeHeaders,
bulkActionListener,
search,
downloadAsJson,
};
});
return handlers;
},
});
const search = ref("");
</script>
<style>

View File

@@ -6,13 +6,13 @@
v-model:search="searchInput"
item-title="name"
return-object
:items="filteredItems"
:items="items"
:custom-filter="normalizeFilter"
:prepend-icon="icon || $globals.icons.tags"
auto-select-first
clearable
color="primary"
hide-details
:custom-filter="() => true"
@keyup.enter="emitCreate"
>
<template
@@ -53,7 +53,7 @@
import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels";
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
import { useSearch } from "~/composables/use-search";
import { normalizeFilter } from "~/composables/use-utils";
export default defineNuxtComponent({
props: {
@@ -85,10 +85,7 @@ export default defineNuxtComponent({
emits: ["update:modelValue", "update:item-id", "create"],
setup(props, context) {
const autocompleteRef = ref<HTMLInputElement>();
// Use the search composable
const { search: searchInput, filtered: filteredItems } = useSearch(computed(() => props.items));
const searchInput = ref("");
const itemIdVal = computed({
get: () => {
return props.itemId || undefined;
@@ -126,8 +123,8 @@ export default defineNuxtComponent({
itemVal,
itemIdVal,
searchInput,
filteredItems,
emitCreate,
normalizeFilter,
};
},
});

View File

@@ -0,0 +1,61 @@
<template>
<div
class="d-flex align-center"
style="max-width: 60px"
>
<v-text-field
v-model.number="quantity"
hide-details
:label="$t('form.quantity-label-abbreviated')"
:min="min"
:max="max"
type="number"
variant="plain"
density="compact"
style="width: 60px;"
/>
</div>
</template>
<script lang="ts">
export default defineNuxtComponent({
name: "VInputNumber",
props: {
min: {
type: Number,
default: 0,
},
max: {
type: Number,
default: 9999,
},
rules: {
type: Array,
default: () => [],
},
step: {
type: Number,
default: 1,
},
modelValue: {
type: Number,
default: 0,
},
},
emits: ["update:modelValue"],
setup(props, context) {
const quantity = computed({
get: () => {
return Number(props.modelValue);
},
set: (val) => {
context.emit("update:modelValue", val);
},
});
return {
quantity,
};
},
});
</script>

View File

@@ -7,10 +7,6 @@
import DOMPurify from "isomorphic-dompurify";
import { marked } from "marked";
enum DOMPurifyHook {
UponSanitizeAttribute = "uponSanitizeAttribute",
}
export default defineNuxtComponent({
props: {
source: {
@@ -19,26 +15,14 @@ export default defineNuxtComponent({
},
},
setup(props) {
const ALLOWED_STYLE_TAGS = [
"background-color", "color", "font-style", "font-weight", "text-decoration", "text-align",
];
function sanitizeMarkdown(rawHtml: string | null | undefined): string {
if (!rawHtml) {
return "";
}
DOMPurify.addHook(DOMPurifyHook.UponSanitizeAttribute, (node, data) => {
if (data.attrName === "style") {
const styles = data.attrValue.split(";").filter((style) => {
const [property] = style.split(":");
return ALLOWED_STYLE_TAGS.includes(property.trim().toLowerCase());
});
data.attrValue = styles.join(";");
}
});
const sanitized = DOMPurify.sanitize(rawHtml, {
// List based on
// https://support.zendesk.com/hc/en-us/articles/4408824584602-Allowing-unsafe-HTML-in-help-center-articles
ALLOWED_TAGS: [
"strong", "em", "b", "i", "u", "p", "code", "pre", "samp", "kbd", "var", "sub", "sup", "dfn", "cite",
"small", "address", "hr", "br", "id", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
@@ -47,14 +31,10 @@ export default defineNuxtComponent({
],
ALLOWED_ATTR: [
"href", "src", "alt", "height", "width", "class", "allow", "title", "allowfullscreen", "frameborder",
"scrolling", "cite", "datetime", "name", "abbr", "target", "border", "start", "style",
"scrolling", "cite", "datetime", "name", "abbr", "target", "border",
],
});
Object.values(DOMPurifyHook).forEach((hook) => {
DOMPurify.removeHook(hook);
});
return sanitized;
}

View File

@@ -1,9 +1,10 @@
export const fieldTypes = {
TEXT: "text",
TEXT_AREA: "textarea",
NUMBER: "number",
LIST: "list",
SELECT: "select",
OBJECT: "object",
BOOLEAN: "boolean",
PASSWORD: "password",
COLOR: "color",
PASSWORD: "password",
} as const;

View File

@@ -165,14 +165,14 @@ export function clearPageState(slug: string) {
}
/**
* usePageUser provides a wrapper around auth that provides a type-safe way to
* usePageUser provides a wrapper around $auth that provides a type-safe way to
* access the UserOut type from the context. If no user is logged in then an empty
* object with all properties set to their zero value is returned.
*/
export function usePageUser(): { user: UserOut } {
const auth = useMealieAuth();
const $auth = useMealieAuth();
if (!auth.user.value) {
if (!$auth.user.value) {
return {
user: {
id: "",
@@ -188,5 +188,5 @@ export function usePageUser(): { user: UserOut } {
};
}
return { user: auth.user.value };
return { user: $auth.user.value };
}

View File

@@ -1,82 +1,60 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { describe, expect, test } from "vitest";
import { useExtractIngredientReferences } from "./use-extract-ingredient-references";
import { useLocales } from "../use-locales";
vi.mock("../use-locales");
const punctuationMarks = ["*", "?", "/", "!", "**", "&", "."];
describe("test use extract ingredient references", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useLocales).mockReturnValue({
locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
locale: { value: "en-US", pluralFoodHandling: "without-unit" },
} as any);
});
test("when text empty return empty", () => {
const { extractIngredientReferences } = useExtractIngredientReferences();
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "");
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "", true);
expect(result).toStrictEqual(new Set());
});
test("when and ingredient matches exactly and has a reference id, return the referenceId", () => {
const { extractIngredientReferences } = useExtractIngredientReferences();
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion");
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion", true);
expect(result).toEqual(new Set(["123"]));
});
test.each(punctuationMarks)("when ingredient is suffixed by punctuation, return the referenceId", (suffix) => {
const { extractIngredientReferences } = useExtractIngredientReferences();
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion" + suffix);
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion" + suffix, true);
expect(result).toEqual(new Set(["123"]));
});
test.each(punctuationMarks)("when ingredient is prefixed by punctuation, return the referenceId", (prefix) => {
const { extractIngredientReferences } = useExtractIngredientReferences();
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing " + prefix + "Onion");
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing " + prefix + "Onion", true);
expect(result).toEqual(new Set(["123"]));
});
test("when ingredient is first on a multiline, return the referenceId", () => {
const multilineSting = "lksjdlk\nOnion";
const { extractIngredientReferences } = useExtractIngredientReferences();
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], multilineSting);
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], multilineSting, true);
expect(result).toEqual(new Set(["123"]));
});
test("when the ingredient matches partially exactly and has a reference id, return the referenceId", () => {
const { extractIngredientReferences } = useExtractIngredientReferences();
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onions");
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onions", true);
expect(result).toEqual(new Set(["123"]));
});
test("when the ingredient matches with different casing and has a reference id, return the referenceId", () => {
const { extractIngredientReferences } = useExtractIngredientReferences();
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing oNions");
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing oNions", true);
expect(result).toEqual(new Set(["123"]));
});
test("when no ingredients, return empty", () => {
const { extractIngredientReferences } = useExtractIngredientReferences();
const result = extractIngredientReferences([], [], "A sentence containing oNions");
const result = useExtractIngredientReferences([], [], "A sentence containing oNions", true);
expect(result).toEqual(new Set());
});
test("when and ingredient matches but in the existing referenceIds, do not return the referenceId", () => {
const { extractIngredientReferences } = useExtractIngredientReferences();
const result = extractIngredientReferences([{ note: "Onion", referenceId: "123" }], ["123"], "A sentence containing Onion");
const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], ["123"], "A sentence containing Onion", true);
expect(result).toEqual(new Set());
});
test("when an word is 2 letter of shorter, it is ignored", () => {
const { extractIngredientReferences } = useExtractIngredientReferences();
const result = extractIngredientReferences([{ note: "Onion", referenceId: "123" }], [], "A sentence containing On");
const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], [], "A sentence containing On", true);
expect(result).toEqual(new Set());
});

View File

@@ -1,5 +1,5 @@
import type { RecipeIngredient } from "~/lib/api/types/recipe";
import { useIngredientTextParser } from "~/composables/recipes";
import { parseIngredientText } from "~/composables/recipes";
function normalize(word: string): string {
let normalizing = word;
@@ -18,6 +18,11 @@ function removeStartingPunctuation(word: string): string {
return word.replace(punctuationAtBeginning, "");
}
function ingredientMatchesWord(ingredient: RecipeIngredient, word: string) {
const searchText = parseIngredientText(ingredient);
return searchText.toLowerCase().includes(word.toLowerCase());
}
function isBlackListedWord(word: string) {
// Ignore matching blacklisted words when auto-linking - This is kind of a cludgey implementation. We're blacklisting common words but
// other common phrases trigger false positives and I'm not sure how else to approach this. In the future I maybe look at looking directly
@@ -34,33 +39,20 @@ function isBlackListedWord(word: string) {
return blackListedText.includes(word) || word.match(blackListedRegexMatch);
}
export function useExtractIngredientReferences() {
const { parseIngredientText } = useIngredientTextParser();
export function useExtractIngredientReferences(recipeIngredients: RecipeIngredient[], activeRefs: string[], text: string): Set<string> {
const availableIngredients = recipeIngredients
.filter(ingredient => ingredient.referenceId !== undefined)
.filter(ingredient => !activeRefs.includes(ingredient.referenceId as string));
function extractIngredientReferences(recipeIngredients: RecipeIngredient[], activeRefs: string[], text: string): Set<string> {
function ingredientMatchesWord(ingredient: RecipeIngredient, word: string) {
const searchText = parseIngredientText(ingredient);
return searchText.toLowerCase().includes(word.toLowerCase());
}
const allMatchedIngredientIds: string[] = text
.toLowerCase()
.split(/\s/)
.map(normalize)
.filter(word => word.length > 2)
.filter(word => !isBlackListedWord(word))
.flatMap(word => availableIngredients.filter(ingredient => ingredientMatchesWord(ingredient, word)))
.map(ingredient => ingredient.referenceId as string);
// deduplicate
const availableIngredients = recipeIngredients
.filter(ingredient => ingredient.referenceId !== undefined)
.filter(ingredient => !activeRefs.includes(ingredient.referenceId as string));
const allMatchedIngredientIds: string[] = text
.toLowerCase()
.split(/\s/)
.map(normalize)
.filter(word => word.length > 2)
.filter(word => !isBlackListedWord(word))
.flatMap(word => availableIngredients.filter(ingredient => ingredientMatchesWord(ingredient, word)))
.map(ingredient => ingredient.referenceId as string);
// deduplicate
return new Set<string>(allMatchedIngredientIds);
}
return {
extractIngredientReferences,
};
return new Set<string>(allMatchedIngredientIds);
}

View File

@@ -1,7 +1,7 @@
export { useFraction } from "./use-fraction";
export { useRecipe } from "./use-recipe";
export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes";
export { useIngredientTextParser } from "./use-recipe-ingredients";
export { parseIngredientText, useParsedIngredientText } from "./use-recipe-ingredients";
export { useNutritionLabels } from "./use-recipe-nutrition";
export { useTools } from "./use-recipe-tools";
export { useRecipePermissions } from "./use-recipe-permissions";

View File

@@ -1,21 +1,8 @@
import { describe, test, expect, vi, beforeEach } from "vitest";
import { useIngredientTextParser } from "./use-recipe-ingredients";
import { describe, test, expect } from "vitest";
import { parseIngredientText } from "./use-recipe-ingredients";
import type { RecipeIngredient } from "~/lib/api/types/recipe";
import { useLocales } from "../use-locales";
vi.mock("../use-locales");
let parseIngredientText: (ingredient: RecipeIngredient, scale?: number, includeFormating?: boolean) => string;
describe("parseIngredientText", () => {
beforeEach(() => {
vi.mocked(useLocales).mockReturnValue({
locales: [{ value: "en-US", pluralFoodHandling: "always" }],
locale: { value: "en-US", pluralFoodHandling: "always" },
} as any);
({ parseIngredientText } = useIngredientTextParser());
});
describe(parseIngredientText.name, () => {
const createRecipeIngredient = (overrides: Partial<RecipeIngredient>): RecipeIngredient => ({
quantity: 1,
food: {
@@ -141,98 +128,4 @@ describe("parseIngredientText", () => {
expect(parseIngredientText(ingredient, 2)).toEqual("2 tablespoons diced onions");
});
test("plural handling: 'always' strategy uses plural food with unit", () => {
vi.mocked(useLocales).mockReturnValue({
locales: [{ value: "en-US", pluralFoodHandling: "always" }],
locale: { value: "en-US", pluralFoodHandling: "always" },
} as any);
const { parseIngredientText } = useIngredientTextParser();
const ingredient = createRecipeIngredient({
quantity: 2,
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
});
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onions");
});
test("plural handling: 'never' strategy never uses plural food", () => {
vi.mocked(useLocales).mockReturnValue({
locales: [{ value: "en-US", pluralFoodHandling: "never" }],
locale: { value: "en-US", pluralFoodHandling: "never" },
} as any);
const { parseIngredientText } = useIngredientTextParser();
const ingredient = createRecipeIngredient({
quantity: 2,
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
});
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onion");
});
test("plural handling: 'without-unit' strategy uses plural food without unit", () => {
vi.mocked(useLocales).mockReturnValue({
locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
locale: { value: "en-US", pluralFoodHandling: "without-unit" },
} as any);
const { parseIngredientText } = useIngredientTextParser();
const ingredient = createRecipeIngredient({
quantity: 2,
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
unit: undefined,
});
expect(parseIngredientText(ingredient)).toEqual("2 diced onions");
});
test("plural handling: 'without-unit' strategy uses singular food with unit", () => {
vi.mocked(useLocales).mockReturnValue({
locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
locale: { value: "en-US", pluralFoodHandling: "without-unit" },
} as any);
const { parseIngredientText } = useIngredientTextParser();
const ingredient = createRecipeIngredient({
quantity: 2,
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
});
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onion");
});
test("decimal below minimum precision shows < 0.001", () => {
const ingredient = createRecipeIngredient({
quantity: 0.0001,
unit: { id: "1", name: "cup", useAbbreviation: false },
food: { id: "1", name: "salt" },
});
expect(parseIngredientText(ingredient)).toEqual("&lt; 0.001 cup salt");
});
test("fraction below minimum denominator shows < 1/10", () => {
const ingredient = createRecipeIngredient({
quantity: 0.05,
unit: { id: "1", name: "cup", fraction: true, useAbbreviation: false },
food: { id: "1", name: "salt" },
});
expect(parseIngredientText(ingredient)).toEqual("&lt; <sup>1</sup><span></span><sub>10</sub> cup salt");
});
test("fraction below minimum denominator without formatting shows < 1/10", () => {
const ingredient = createRecipeIngredient({
quantity: 0.05,
unit: { id: "1", name: "cup", fraction: true, useAbbreviation: false },
food: { id: "1", name: "salt" },
});
expect(parseIngredientText(ingredient, 1, false)).toEqual("&lt; 1/10 cup salt");
});
});

View File

@@ -1,13 +1,9 @@
import DOMPurify from "isomorphic-dompurify";
import { useFraction } from "./use-fraction";
import { useLocales } from "../use-locales";
import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
const { frac } = useFraction();
const FRAC_MIN_DENOM = 10;
const DECIMAL_PRECISION = 3;
export function sanitizeIngredientHTML(rawHtml: string) {
return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
@@ -60,90 +56,47 @@ type ParsedIngredientText = {
recipeLink?: string;
};
function shouldUsePluralFood(quantity: number, hasUnit: boolean, pluralFoodHandling: string): boolean {
if (quantity && quantity <= 1) {
return false;
}
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText {
const { quantity, food, unit, note, referencedRecipe } = ingredient;
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
const usePluralFood = (!quantity) || quantity * scale > 1;
switch (pluralFoodHandling) {
case "always":
return true;
case "without-unit":
return !(quantity && hasUnit);
case "never":
return false;
let returnQty = "";
default:
// same as without-unit
return !(quantity && hasUnit);
}
}
export function useIngredientTextParser() {
const { locales, locale } = useLocales();
function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText {
const filteredLocales = locales.filter(lc => lc.value === locale.value);
const pluralFoodHandling = filteredLocales.length ? filteredLocales[0].pluralFoodHandling : "without-unit";
const { quantity, food, unit, note, referencedRecipe } = ingredient;
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
const usePluralFood = shouldUsePluralFood((quantity || 0) * scale, !!unit, pluralFoodHandling);
let returnQty = "";
// casting to number is required as sometimes quantity is a string
if (quantity && Number(quantity) !== 0) {
const scaledQuantity = Number((quantity * scale));
if (unit && !unit.fraction) {
const minVal = 10 ** -DECIMAL_PRECISION;
returnQty = scaledQuantity >= minVal
? Number(scaledQuantity.toPrecision(DECIMAL_PRECISION)).toString()
: `< ${minVal}`;
// casting to number is required as sometimes quantity is a string
if (quantity && Number(quantity) !== 0) {
if (unit && !unit.fraction) {
returnQty = Number((quantity * scale).toPrecision(3)).toString();
}
else {
const fraction = frac(quantity * scale, 10, true);
if (fraction[0] !== undefined && fraction[0] > 0) {
returnQty += fraction[0];
}
else {
const minVal = 1 / FRAC_MIN_DENOM;
const isUnderMinVal = !(scaledQuantity >= minVal);
const fraction = !isUnderMinVal ? frac(scaledQuantity, FRAC_MIN_DENOM, true) : [0, 1, FRAC_MIN_DENOM];
if (fraction[0] !== undefined && fraction[0] > 0) {
returnQty += fraction[0];
}
if (fraction[1] > 0) {
returnQty += includeFormating
? `<sup>${fraction[1]}</sup><span>&frasl;</span><sub>${fraction[2]}</sub>`
: ` ${fraction[1]}/${fraction[2]}`;
}
if (isUnderMinVal) {
returnQty = `< ${returnQty}`;
}
if (fraction[1] > 0) {
returnQty += includeFormating
? `<sup>${fraction[1]}</sup><span>&frasl;</span><sub>${fraction[2]}</sub>`
: ` ${fraction[1]}/${fraction[2]}`;
}
}
}
const unitName = useUnitName(unit || undefined, usePluralUnit);
const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood);
return {
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
name: ingName ? sanitizeIngredientHTML(ingName) : undefined,
note: note ? sanitizeIngredientHTML(note) : undefined,
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
};
};
function parseIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true): string {
const { quantity, unit, name, note } = useParsedIngredientText(ingredient, scale, includeFormating);
const text = `${quantity || ""} ${unit || ""} ${name || ""} ${note || ""}`.replace(/ {2,}/g, " ").trim();
return sanitizeIngredientHTML(text);
};
const unitName = useUnitName(unit || undefined, usePluralUnit);
const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood);
return {
useParsedIngredientText,
parseIngredientText,
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
name: ingName ? sanitizeIngredientHTML(ingName) : undefined,
note: note ? sanitizeIngredientHTML(note) : undefined,
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
};
}
export function parseIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true): string {
const { quantity, unit, name, note } = useParsedIngredientText(ingredient, scale, includeFormating);
const text = `${quantity || ""} ${unit || ""} ${name || ""} ${note || ""}`.replace(/ {2,}/g, " ").trim();
return sanitizeIngredientHTML(text);
}

View File

@@ -5,293 +5,251 @@ export const LOCALES = [
value: "zh-TW",
progress: 9,
dir: "ltr",
pluralFoodHandling: "never",
},
{
name: "简体中文 (Chinese simplified)",
value: "zh-CN",
progress: 38,
dir: "ltr",
pluralFoodHandling: "never",
},
{
name: "Tiếng Việt (Vietnamese)",
value: "vi-VN",
progress: 2,
progress: 1,
dir: "ltr",
pluralFoodHandling: "never",
},
{
name: "Українська (Ukrainian)",
value: "uk-UA",
progress: 83,
progress: 99,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Türkçe (Turkish)",
value: "tr-TR",
progress: 40,
progress: 39,
dir: "ltr",
pluralFoodHandling: "never",
},
{
name: "Svenska (Swedish)",
value: "sv-SE",
progress: 61,
progress: 68,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "српски (Serbian)",
value: "sr-SP",
progress: 16,
progress: 9,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Slovenščina (Slovenian)",
value: "sl-SI",
progress: 40,
progress: 41,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Slovenčina (Slovak)",
value: "sk-SK",
progress: 47,
progress: 46,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Pусский (Russian)",
value: "ru-RU",
progress: 44,
progress: 46,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Română (Romanian)",
value: "ro-RO",
progress: 44,
progress: 41,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Português (Portuguese)",
value: "pt-PT",
progress: 39,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Português do Brasil (Brazilian Portuguese)",
value: "pt-BR",
progress: 46,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Polski (Polish)",
value: "pl-PL",
progress: 49,
progress: 52,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Norsk (Norwegian)",
value: "no-NO",
progress: 42,
progress: 41,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Nederlands (Dutch)",
value: "nl-NL",
progress: 60,
progress: 55,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Latviešu (Latvian)",
value: "lv-LV",
progress: 35,
progress: 36,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Lietuvių (Lithuanian)",
value: "lt-LT",
progress: 30,
progress: 27,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "한국어 (Korean)",
value: "ko-KR",
progress: 38,
progress: 9,
dir: "ltr",
pluralFoodHandling: "never",
},
{
name: "日本語 (Japanese)",
value: "ja-JP",
progress: 36,
dir: "ltr",
pluralFoodHandling: "never",
},
{
name: "Italiano (Italian)",
value: "it-IT",
progress: 52,
progress: 47,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Íslenska (Icelandic)",
value: "is-IS",
progress: 43,
progress: 44,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Magyar (Hungarian)",
value: "hu-HU",
progress: 46,
progress: 47,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Hrvatski (Croatian)",
value: "hr-HR",
progress: 30,
progress: 29,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "עברית (Hebrew)",
value: "he-IL",
progress: 64,
progress: 72,
dir: "rtl",
pluralFoodHandling: "always",
},
{
name: "Galego (Galician)",
value: "gl-ES",
progress: 38,
progress: 39,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Français (French)",
value: "fr-FR",
progress: 67,
progress: 69,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Français canadien (Canadian French)",
value: "fr-CA",
progress: 83,
progress: 99,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Belge (Belgian)",
value: "fr-BE",
progress: 39,
progress: 40,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Suomi (Finnish)",
value: "fi-FI",
progress: 40,
progress: 41,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Eesti (Estonian)",
value: "et-EE",
progress: 45,
progress: 47,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Español (Spanish)",
value: "es-ES",
progress: 46,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "American English",
value: "en-US",
progress: 100,
progress: 100.0,
dir: "ltr",
pluralFoodHandling: "without-unit",
},
{
name: "British English",
value: "en-GB",
progress: 42,
progress: 43,
dir: "ltr",
pluralFoodHandling: "without-unit",
},
{
name: "Ελληνικά (Greek)",
value: "el-GR",
progress: 41,
progress: 42,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Deutsch (German)",
value: "de-DE",
progress: 85,
progress: 97,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Dansk (Danish)",
value: "da-DK",
progress: 65,
progress: 52,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Čeština (Czech)",
value: "cs-CZ",
progress: 43,
progress: 42,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Català (Catalan)",
value: "ca-ES",
progress: 40,
progress: 39,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "Български (Bulgarian)",
value: "bg-BG",
progress: 49,
progress: 51,
dir: "ltr",
pluralFoodHandling: "always",
},
{
name: "العربية (Arabic)",
value: "ar-SA",
progress: 25,
progress: 23,
dir: "rtl",
pluralFoodHandling: "always",
},
{
name: "Afrikaans (Afrikaans)",
value: "af-ZA",
progress: 26,
dir: "ltr",
pluralFoodHandling: "always",
},
];

View File

@@ -1,9 +1,8 @@
import type { LocaleObject } from "@nuxtjs/i18n";
import { LOCALES } from "./available-locales";
import { useGlobalI18n } from "../use-global-i18n";
export const useLocales = () => {
const i18n = useGlobalI18n();
const i18n = useI18n();
const { current: vuetifyLocale } = useLocale();
const locale = computed<LocaleObject["code"]>({

View File

@@ -1,14 +1,14 @@
export const useLoggedInState = function () {
const auth = useMealieAuth();
const $auth = useMealieAuth();
const route = useRoute();
const loggedIn = computed(() => auth.loggedIn.value);
const loggedIn = computed(() => $auth.loggedIn.value);
const isOwnGroup = computed(() => {
if (!route.params.groupSlug) {
return loggedIn.value;
}
else {
return loggedIn.value && auth.user.value?.groupSlug === route.params.groupSlug;
return loggedIn.value && $auth.user.value?.groupSlug === route.params.groupSlug;
}
});

View File

@@ -2,7 +2,6 @@ import { useRecipeCreatePreferences } from "~/composables/use-users/preferences"
export interface UseNewRecipeOptionsProps {
enableImportKeywords?: boolean;
enableImportCategories?: boolean;
enableStayInEditMode?: boolean;
enableParseRecipe?: boolean;
}
@@ -10,7 +9,6 @@ export interface UseNewRecipeOptionsProps {
export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
const {
enableImportKeywords = true,
enableImportCategories = true,
enableStayInEditMode = true,
enableParseRecipe = true,
} = props;
@@ -29,17 +27,6 @@ export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
},
});
const importCategories = computed({
get() {
if (!enableImportCategories) return false;
return recipeCreatePreferences.value.importCategories;
},
set(v: boolean) {
if (!enableImportCategories) return;
recipeCreatePreferences.value.importCategories = v;
},
});
const stayInEditMode = computed({
get() {
if (!enableStayInEditMode) return false;
@@ -84,7 +71,6 @@ export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
return {
// Computed properties for the checkboxes
importKeywordsAsTags,
importCategories,
stayInEditMode,
parseRecipe,
@@ -93,7 +79,6 @@ export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
// Props for conditional rendering
enableImportKeywords,
enableImportCategories,
enableStayInEditMode,
enableParseRecipe,
};

View File

@@ -1,5 +1,5 @@
import { Organizer } from "~/lib/api/types/non-generated";
import type { LogicalOperator, PlaceholderKeyword, RecipeOrganizer, RelationalKeyword, RelationalOperator } from "~/lib/api/types/non-generated";
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
import type { LogicalOperator, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
export interface FieldLogicalOperator {
label: string;
@@ -11,11 +11,6 @@ export interface FieldRelationalOperator {
value: RelationalKeyword | RelationalOperator;
}
export interface FieldPlaceholderKeyword {
label: string;
value: PlaceholderKeyword;
}
export interface OrganizerBase {
id: string;
slug: string;
@@ -27,7 +22,6 @@ export type FieldType
| "number"
| "boolean"
| "date"
| "relativeDate"
| RecipeOrganizer;
export type FieldValue
@@ -47,8 +41,8 @@ export interface FieldDefinition {
label: string;
type: FieldType;
// Select/Organizer
fieldChoices?: SelectableItem[];
// only for select/organizer fields
fieldOptions?: SelectableItem[];
}
export interface Field extends FieldDefinition {
@@ -56,10 +50,10 @@ export interface Field extends FieldDefinition {
logicalOperator?: FieldLogicalOperator;
value: FieldValue;
relationalOperatorValue: FieldRelationalOperator;
relationalOperatorChoices: FieldRelationalOperator[];
relationalOperatorOptions: FieldRelationalOperator[];
rightParenthesis?: string;
// Select/Organizer
// only for select/organizer fields
values: FieldValue[];
organizers: OrganizerBase[];
}
@@ -167,36 +161,6 @@ export function useQueryFilterBuilder() {
};
});
const placeholderKeywords = computed<Record<PlaceholderKeyword, FieldPlaceholderKeyword>>(() => {
const NOW = {
label: "Now",
value: "$NOW",
} as FieldPlaceholderKeyword;
return {
$NOW: NOW,
};
});
const relativeDateRelOps = computed<Record<RelationalKeyword | RelationalOperator, FieldRelationalOperator>>(() => {
const ops = { ...relOps.value };
ops[">="] = { ...relOps.value[">="], label: i18n.t("query-filter.relational-operators.is-newer-than") };
ops["<="] = { ...relOps.value["<="], label: i18n.t("query-filter.relational-operators.is-older-than") };
return ops;
});
function getRelOps(fieldType: FieldType): typeof relOps | typeof relativeDateRelOps {
switch (fieldType) {
case "relativeDate":
return relativeDateRelOps;
default:
return relOps;
}
}
function isOrganizerType(type: FieldType): type is Organizer {
return (
type === Organizer.Category
@@ -209,14 +173,10 @@ export function useQueryFilterBuilder() {
};
function getFieldFromFieldDef(field: Field | FieldDefinition, resetValue = false): Field {
const updatedField = {
logicalOperator: logOps.value.AND,
...field,
} as Field;
let operatorChoices: FieldRelationalOperator[];
if (updatedField.fieldChoices?.length || isOrganizerType(updatedField.type)) {
operatorChoices = [
const updatedField = { logicalOperator: logOps.value.AND, ...field } as Field;
let operatorOptions: FieldRelationalOperator[];
if (updatedField.fieldOptions?.length || isOrganizerType(updatedField.type)) {
operatorOptions = [
relOps.value["IN"],
relOps.value["NOT IN"],
relOps.value["CONTAINS ALL"],
@@ -225,7 +185,7 @@ export function useQueryFilterBuilder() {
else {
switch (updatedField.type) {
case "string":
operatorChoices = [
operatorOptions = [
relOps.value["="],
relOps.value["<>"],
relOps.value["LIKE"],
@@ -233,7 +193,7 @@ export function useQueryFilterBuilder() {
];
break;
case "number":
operatorChoices = [
operatorOptions = [
relOps.value["="],
relOps.value["<>"],
relOps.value[">"],
@@ -243,10 +203,10 @@ export function useQueryFilterBuilder() {
];
break;
case "boolean":
operatorChoices = [relOps.value["="]];
operatorOptions = [relOps.value["="]];
break;
case "date":
operatorChoices = [
operatorOptions = [
relOps.value["="],
relOps.value["<>"],
relOps.value[">"],
@@ -255,20 +215,13 @@ export function useQueryFilterBuilder() {
relOps.value["<="],
];
break;
case "relativeDate":
operatorChoices = [
// "<=" is first since "older than" is the most common operator
relativeDateRelOps.value["<="],
relativeDateRelOps.value[">="],
];
break;
default:
operatorChoices = [relOps.value["="], relOps.value["<>"]];
operatorOptions = [relOps.value["="], relOps.value["<>"]];
}
}
updatedField.relationalOperatorChoices = operatorChoices;
if (!operatorChoices.includes(updatedField.relationalOperatorValue)) {
updatedField.relationalOperatorValue = operatorChoices[0];
updatedField.relationalOperatorOptions = operatorOptions;
if (!operatorOptions.includes(updatedField.relationalOperatorValue)) {
updatedField.relationalOperatorValue = operatorOptions[0];
}
if (resetValue) {
@@ -318,7 +271,7 @@ export function useQueryFilterBuilder() {
isValid = false;
}
if (field.fieldChoices?.length || isOrganizerType(field.type)) {
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
if (field.values?.length) {
let val: string;
if (field.type === "string" || field.type === "date" || isOrganizerType(field.type)) {
@@ -363,8 +316,7 @@ export function useQueryFilterBuilder() {
return {
logOps,
placeholderKeywords,
getRelOps,
relOps,
buildQueryFilterString,
getFieldFromFieldDef,
isOrganizerType,

View File

@@ -1,117 +0,0 @@
import { watchDebounced } from "@vueuse/core";
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
export interface IAlias {
name: string;
}
export interface ISearchableItem {
id: string;
name: string;
aliases?: IAlias[] | undefined;
}
interface ISearchItemInternal extends ISearchableItem {
aliasesText?: string | undefined;
}
export interface ISearchOptions {
debounceMs?: number;
maxWaitMs?: number;
minSearchLength?: number;
fuseOptions?: Partial<IFuseOptions<ISearchItemInternal>>;
}
export function useSearch<T extends ISearchableItem>(
items: ComputedRef<T[]> | Ref<T[]> | T[],
options: ISearchOptions = {},
) {
const {
debounceMs = 0,
maxWaitMs = 1500,
minSearchLength = 1,
fuseOptions: customFuseOptions = {},
} = options;
// State
const search = ref("");
const debouncedSearch = shallowRef("");
// Flatten item aliases to include as searchable text
const searchItems = computed(() => {
const itemsArray = Array.isArray(items) ? items : items.value;
return itemsArray.map((item) => {
return {
...item,
aliasesText: item.aliases ? item.aliases.map(a => a.name).join(" ") : "",
} as ISearchItemInternal;
});
});
// Default Fuse options
const defaultFuseOptions: IFuseOptions<ISearchItemInternal> = {
keys: [
{ name: "name", weight: 3 },
{ name: "pluralName", weight: 3 },
{ name: "abbreviation", weight: 2 },
{ name: "pluralAbbreviation", weight: 2 },
{ name: "aliasesText", weight: 1 },
],
ignoreLocation: true,
shouldSort: true,
threshold: 0.3,
minMatchCharLength: 1,
findAllMatches: false,
};
// Merge custom options with defaults
const fuseOptions = computed(() => ({
...defaultFuseOptions,
...customFuseOptions,
}));
// Debounce search input
watchDebounced(
() => search.value,
(newSearch) => {
debouncedSearch.value = newSearch;
},
{ debounce: debounceMs, maxWait: maxWaitMs, immediate: false },
);
// Initialize Fuse instance
const fuse = computed(() => {
return new Fuse(searchItems.value || [], fuseOptions.value);
});
// Compute filtered results
const filtered = computed(() => {
const itemsArray = Array.isArray(items) ? items : items.value;
const searchTerm = debouncedSearch.value.trim();
// If no search query or less than minSearchLength characters, return all items
if (!searchTerm || searchTerm.length < minSearchLength) {
return itemsArray;
}
if (!itemsArray || itemsArray.length === 0) {
return [];
}
const results = fuse.value.search(searchTerm);
return results.map(result => result.item as T);
});
const reset = () => {
search.value = "";
debouncedSearch.value = "";
};
return {
search,
debouncedSearch,
filtered,
reset,
};
}

View File

@@ -1,5 +1,4 @@
import { fieldTypes } from "../forms";
import { validators } from "../use-validators";
import type { AutoFormItems } from "~/types/auto-forms";
export const useCommonSettingsForm = () => {
@@ -12,7 +11,7 @@ export const useCommonSettingsForm = () => {
hint: i18n.t("group.enable-public-access-description"),
varName: "makeGroupRecipesPublic",
type: fieldTypes.BOOLEAN,
rules: [validators.required],
rules: ["required"],
},
{
section: i18n.t("data-pages.data-management"),
@@ -20,7 +19,7 @@ export const useCommonSettingsForm = () => {
hint: i18n.t("user-registration.use-seed-data-description"),
varName: "useSeedData",
type: fieldTypes.BOOLEAN,
rules: [validators.required],
rules: ["required"],
},
]);

View File

@@ -1,7 +1,7 @@
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
import { ActivityKey } from "~/lib/api/types/activity";
import type { RegisteredParser, TimelineEventType } from "~/lib/api/types/recipe";
import type { QueryFilterJSON } from "~/lib/api/types/non-generated";
import type { QueryFilterJSON } from "~/lib/api/types/response";
export interface UserPrintPreferences {
imagePosition: string;
@@ -63,7 +63,6 @@ export interface UserRecipeFinderPreferences {
export interface UserRecipeCreatePreferences {
importKeywordsAsTags: boolean;
importCategories: boolean;
stayInEditMode: boolean;
parseRecipe: boolean;
}
@@ -234,7 +233,6 @@ export function useRecipeCreatePreferences(): Ref<UserRecipeCreatePreferences> {
"recipe-create-preferences",
{
importKeywordsAsTags: false,
importCategories: false,
stayInEditMode: false,
parseRecipe: true,
},

View File

@@ -1,5 +1,4 @@
import { fieldTypes } from "../forms";
import { validators } from "../use-validators";
import type { AutoFormItems } from "~/types/auto-forms";
export const useUserForm = () => {
@@ -11,26 +10,26 @@ export const useUserForm = () => {
label: i18n.t("user.user-name"),
varName: "username",
type: fieldTypes.TEXT,
rules: [validators.required],
rules: ["required"],
},
{
label: i18n.t("user.full-name"),
varName: "fullName",
type: fieldTypes.TEXT,
rules: [validators.required],
rules: ["required"],
},
{
label: i18n.t("user.email"),
varName: "email",
type: fieldTypes.TEXT,
rules: [validators.required],
rules: ["required"],
},
{
label: i18n.t("user.password"),
varName: "password",
disableUpdate: true,
type: fieldTypes.PASSWORD,
rules: [validators.required, validators.minLength(8)],
rules: ["required", "minLength:8"],
},
{
label: i18n.t("user.authentication-method"),
@@ -45,37 +44,37 @@ export const useUserForm = () => {
label: i18n.t("user.administrator"),
varName: "admin",
type: fieldTypes.BOOLEAN,
rules: [validators.required],
rules: ["required"],
},
{
label: i18n.t("user.user-can-invite-other-to-group"),
varName: "canInvite",
type: fieldTypes.BOOLEAN,
rules: [validators.required],
rules: ["required"],
},
{
label: i18n.t("user.user-can-manage-group"),
varName: "canManage",
type: fieldTypes.BOOLEAN,
rules: [validators.required],
rules: ["required"],
},
{
label: i18n.t("user.user-can-organize-group-data"),
varName: "canOrganize",
type: fieldTypes.BOOLEAN,
rules: [validators.required],
rules: ["required"],
},
{
label: i18n.t("user.user-can-manage-household"),
varName: "canManageHousehold",
type: fieldTypes.BOOLEAN,
rules: [validators.required],
rules: ["required"],
},
{
label: i18n.t("user.enable-advanced-features"),
varName: "advanced",
type: fieldTypes.BOOLEAN,
rules: [validators.required],
rules: ["required"],
},
];

View File

@@ -6,10 +6,10 @@ const loading = ref(false);
const ready = ref(false);
export const useUserSelfRatings = function () {
const auth = useMealieAuth();
const $auth = useMealieAuth();
async function refreshUserRatings() {
if (!auth.user.value || loading.value) {
if (!$auth.user.value || loading.value) {
return;
}
@@ -27,7 +27,7 @@ export const useUserSelfRatings = function () {
loading.value = true;
const api = useUserApi();
const userId = auth.user.value?.id || "";
const userId = $auth.user.value?.id || "";
await api.users.setRating(userId, slug, rating, isFavorite);
loading.value = false;

View File

@@ -34,9 +34,6 @@ const normalizeLigatures = replaceAllBuilder(new Map([
["st", "st"],
]));
/**
* @deprecated prefer fuse.js/use-search.ts
*/
export const normalize = (str: string) => {
if (!str) {
return "";
@@ -48,9 +45,6 @@ export const normalize = (str: string) => {
return normalized;
};
/**
* @deprecated prefer fuse.js/use-search.ts
*/
export const normalizeFilter: FilterFunction = (value: string, query: string) => {
const normalizedValue = normalize(value);
const normalizeQuery = normalize(query);

View File

@@ -13,10 +13,10 @@ export const validators = {
};
/**
* useAsyncValidator us a factory function that returns an async function that
* when called will validate the input against the backend database and set the
* error messages when applicable to the ref.
*/
* useAsyncValidator us a factory function that returns an async function that
* when called will validate the input against the backend database and set the
* error messages when applicable to the ref.
*/
export const useAsyncValidator = (
value: Ref<string>,
validatorFunc: (v: string) => Promise<RequestResponse<ValidationResponse>>,

View File

@@ -89,7 +89,7 @@
"clear": "Maak skoon",
"close": "Maak toe",
"confirm": "Bevestig",
"confirm-how-does-everything-look": "Hoe lyk alles?",
"confirm-how-does-everything-look": "How does everything look?",
"confirm-delete-generic": "Is jy seker jy wil dit uitvee?",
"copied_message": "Gekopieër!",
"create": "Skep",
@@ -120,9 +120,9 @@
"json": "JSON",
"keyword": "Sleutelwoord",
"link-copied": "Skakel gekopieer",
"loading": "Laai tans",
"loading": "Loading",
"loading-events": "Besig om gebeurtenisse te laai",
"loading-recipe": "Laai tans resep...",
"loading-recipe": "Loading recipe...",
"loading-ocr-data": "Loading OCR data...",
"loading-recipes": "Besig om resepte te laai",
"message": "Boodskap",
@@ -212,16 +212,14 @@
"upload-file": "Laai dokument op",
"created-on-date": "Geskep op: {0}",
"unsaved-changes": "You have unsaved changes. Do you want to save before leaving? Okay to save, Cancel to discard changes.",
"discard-changes": "Discard Changes",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
"clipboard-copy-failure": "Kon nie kopieer na die knipbord toe nie.",
"confirm-delete-generic-items": "Is jy seker jy wil die volgende items verwyder?",
"organizers": "Organiseerders",
"caution": "Versigtig",
"show-advanced": "Wys uitgebreide",
"add-field": "Voeg veld by",
"caution": "Caution",
"show-advanced": "Show Advanced",
"add-field": "Add Field",
"date-created": "Datum Geskep",
"date-updated": "Datum Opgedateer"
"date-updated": "Date Updated"
},
"group": {
"are-you-sure-you-want-to-delete-the-group": "Is jy seker jy wil <b>{groupName}<b/> uitvee?",
@@ -328,11 +326,11 @@
"mealplan-households-description": "If no household is selected, recipes can be added from any household",
"any-category": "Any Category",
"any-tag": "Any Tag",
"any-household": "Enide Huishouding",
"any-household": "Any Household",
"no-meal-plan-defined-yet": "Nog geen maaltydplan opgestel nie",
"no-meal-planned-for-today": "Geen maaltyd beplan vir vandag nie",
"numberOfDays-hint": "Number of days on page load",
"numberOfDays-label": "Standaard dae",
"numberOfDays-label": "Default Days",
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Slegs resepte met hierdie kategorieë sal in maaltydplanne gebruik word",
"planner": "Beplanner",
"quick-week": "Vinnige week",
@@ -369,9 +367,7 @@
"recipe-rules": "Resepreëls",
"applies-to-all-days": "Van toepassing op alle dae",
"applies-on-days": "Van toepassing op {0}s",
"meal-plan-settings": "Maaltydplan verstellings",
"add-all-to-list": "Add All to List",
"add-day-to-list": "Add Day to List"
"meal-plan-settings": "Maaltydplan verstellings"
},
"migration": {
"migration-data-removed": "Migrasiedata is uitgevee",
@@ -457,7 +453,7 @@
"import-by-url": "Voer 'n resep vanaf 'n webwerf in",
"create-manually": "Skep 'n resep met die hand",
"make-recipe-image": "Maak dit die prentjie vir hierdie resep",
"add-food": "Voeg Voedsel",
"add-food": "Add Food",
"add-recipe": "Voeg Resep By"
},
"page": {
@@ -644,7 +640,6 @@
"scrape-recipe-website-being-blocked": "Website being blocked?",
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
"import-original-keywords-as-tags": "Voer oorspronklike sleutelwoorde as merkers in",
"import-original-categories": "Import original categories",
"stay-in-edit-mode": "Bly in redigeer modus",
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
"import-from-zip": "Voer vanaf zip in",
@@ -1422,9 +1417,7 @@
"is-greater-than": "is greater than",
"is-greater-than-or-equal-to": "is greater than or equal to",
"is-less-than": "is less than",
"is-less-than-or-equal-to": "is less than or equal to",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
"is-less-than-or-equal-to": "is less than or equal to"
},
"relational-keywords": {
"is": "is",
@@ -1434,9 +1427,6 @@
"contains-all-of": "contains all of",
"is-like": "is like",
"is-not-like": "is not like"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {

View File

@@ -212,8 +212,6 @@
"upload-file": "تحميل الملف",
"created-on-date": "تم الإنشاء في {0}",
"unsaved-changes": "لديك تغييرات غير محفوظة. هل تريد الحفظ قبل المغادرة؟ حسنًا للحفظ، قم بإلغاء تجاهل التغييرات.",
"discard-changes": "إلغاء التغييرات",
"discard-changes-description": "لديك تغييرات غير محفوظة. هل أنت متأكد من أنك تريد تجاهلها؟",
"clipboard-copy-failure": "فشل في النسخ إلى الحافظة.",
"confirm-delete-generic-items": "هل أنت متأكد أنك تريد حذف المجموعات التالية؟",
"organizers": "المنظمون",
@@ -344,9 +342,9 @@
"breakfast": "الإفطار",
"lunch": "الغداء",
"dinner": "العشاء",
"snack": "وجبة خفيفة",
"drink": "مشروب",
"dessert": "حلوى",
"snack": "Snack",
"drink": "Drink",
"dessert": "Dessert",
"type-any": "أي",
"day-any": "أي",
"editor": "المحرر",
@@ -369,9 +367,7 @@
"recipe-rules": "قواعد الوصفات",
"applies-to-all-days": "ينطبق على جميع الأيام",
"applies-on-days": "يطبق على أيام {0}",
"meal-plan-settings": "إعدادات خِطَّة الوجبات الغذائية",
"add-all-to-list": "Add All to List",
"add-day-to-list": "Add Day to List"
"meal-plan-settings": "إعدادات خِطَّة الوجبات الغذائية"
},
"migration": {
"migration-data-removed": "حذف بيانات الهجرة",
@@ -442,23 +438,23 @@
"github-issues": "مشاكل GitHub",
"google-ld-json-info": "معرف Google + معلومات json",
"must-be-a-valid-url": "يجب أن يكون عنوان URL صالحًا",
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "لصق بيانات الوصفة الخاصة بك. سيتم التعامل مع كل سطر كعنصر في قائمة",
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Paste in your recipe data. Each line will be treated as an item in a list",
"recipe-markup-specification": "Recipe Markup Specification",
"recipe-url": "رابط الوصفة",
"recipe-html-or-json": "وصفة HTML أو JSON",
"upload-a-recipe": "تحميل وصفة",
"upload-individual-zip-file": "تحميل مِلَفّ zip فردي تم تصديره من مثيل Malie آخر.",
"url-form-hint": "نسخ ولصق رابط من موقعك المفضل للوصفة",
"copy-and-paste-the-source-url-of-your-data-optional": "نسخ ولصق عنوان URL المصدر لبياناتك (اختياري)",
"copy-and-paste-the-source-url-of-your-data-optional": "Copy and paste the source URL of your data (optional)",
"view-scraped-data": "عرض البيانات المحللة",
"trim-whitespace-description": "قص المسافات البيضاء البادئة واللاحقة وكذلك الأسطر الفارغة",
"trim-prefix-description": "قص الحرف الأول من كل سطر",
"split-by-numbered-line-description": "محاولات تقسيم فقرة عن طريق مطابقة أنماط '1)' أو '1.'",
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns",
"import-by-url": "استيراد وصفة عن طريق عنوان URL",
"create-manually": "إنشاء وصفة يدوياً",
"make-recipe-image": "اجعل هذه صورة الوصفة",
"add-food": "إضافة طعام",
"add-recipe": "إضافة وصفة"
"add-food": "Add Food",
"add-recipe": "Add Recipe"
},
"page": {
"404-page-not-found": "404: لم يتم العثور على الصفحة",
@@ -488,7 +484,7 @@
"comment": "أضف تعليق ",
"comments": "التعليقات",
"delete-confirmation": "هل انت متأكد من رغبتك بحذف هذه الوصفة؟",
"admin-delete-confirmation": "أنت على وشك حذف وصفة ليست لك استخدام أذونات المشرف. هل أنت متأكد؟",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"delete-recipe": "حذف الوصفة",
"description": "الوصف",
"disable-amount": "إيقاف إظهار كميات المكونات",
@@ -525,9 +521,9 @@
"recipe-deleted": "تم حذف الوصفة",
"recipe-image": "صورة الوصفة",
"recipe-image-updated": "تم تحديث صورة الوصفة",
"delete-image": "حذف صورة الوصفة",
"delete-image-confirmation": "هل أنت متأكد أنك تريد حذف صورة الوصفة هذه؟",
"recipe-image-deleted": "تم حذف صورة الوصفة",
"delete-image": "Delete Recipe Image",
"delete-image-confirmation": "Are you sure you want to delete this recipe image?",
"recipe-image-deleted": "Recipe image deleted",
"recipe-name": "اسم الوصفة",
"recipe-settings": "إعدادات الوصفة",
"recipe-update-failed": "فشل تحديث الوصفة",
@@ -560,10 +556,10 @@
"add-to-plan": "أضف إلى الخُطة",
"add-to-timeline": "إضافة إلى الخط الزمني",
"recipe-added-to-list": "تم إضافة الوصفة إلى القائمة",
"recipes-added-to-list": "تم إضافة الوصفات إلى القائمة",
"successfully-added-to-list": "تمت الإضافة إلى القائمة بنجاح",
"recipe-added-to-mealplan": "تم إضافة الوصفة إلى خطة الوجبات",
"failed-to-add-recipes-to-list": "فشل في إضافة وصفة إلى القائمة",
"recipes-added-to-list": "Recipes added to list",
"successfully-added-to-list": "Successfully added to list",
"recipe-added-to-mealplan": "Recipe added to mealplan",
"failed-to-add-recipes-to-list": "Failed to add recipe to list",
"failed-to-add-recipe-to-mealplan": "فشل في إضافة الوصفة إلى خطة الوجبة",
"failed-to-add-to-list": "فشل في الإضافة إلى القائمة",
"yield": "العائد",
@@ -573,13 +569,13 @@
"choose-unit": "اختر الوحدة",
"press-enter-to-create": "",
"choose-food": "اختيار الطعام",
"choose-recipe": "اختر وصفة",
"choose-recipe": "Choose Recipe",
"notes": "ملاحظات",
"toggle-section": "",
"see-original-text": "عرض النص الأصلي",
"original-text-with-value": "النص الأصلي: {originalText}",
"ingredient-linker": "رابط المكون",
"unlinked": "غير مرتبط بعد",
"unlinked": "Not linked yet",
"linked-to-other-step": "مرتبط بخطوة أخرى",
"auto": "تلقائي",
"cook-mode": "وضع الطبخ",
@@ -601,23 +597,23 @@
"made-this": "لقد طبخت هذا",
"how-did-it-turn-out": "كيف كانت النتيجة؟",
"user-made-this": "{user} طبخ هذه",
"made-for-recipe": "صُنع لـ {recipe}",
"added-to-timeline": "تمت الإضافة إلى الجدول الزمني",
"failed-to-add-to-timeline": "فشلت الإضافة إلى الجدول الزمني",
"failed-to-update-recipe": "فشل تحديث الوصفة",
"added-to-timeline-but-failed-to-add-image": "تمت الإضافة إلى الجدول الزمني، ولكن فشل في إضافة صورة",
"made-for-recipe": "Made for {recipe}",
"added-to-timeline": "Added to timeline",
"failed-to-add-to-timeline": "Failed to add to timeline",
"failed-to-update-recipe": "Failed to update recipe",
"added-to-timeline-but-failed-to-add-image": "Added to timeline, but failed to add image",
"api-extras-description": "Recipes extras are a key feature of the Mealie API. They allow you to create custom JSON key/value pairs within a recipe, to reference from 3rd party applications. You can use these keys to provide information, for example to trigger automations or custom messages to relay to your desired device.",
"message-key": "مفتاح الرساله",
"parse": "تحليل",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"attach-images-hint": "إرفاق الصور عن طريق سحبها وإسقاطها في المحرر",
"attach-images-hint": "Attach images by dragging & dropping them into the editor",
"drop-image": "وضع الصورة",
"enable-ingredient-amounts-to-use-this-feature": "Enable ingredient amounts to use this feature",
"recipes-with-units-or-foods-defined-cannot-be-parsed": "Recipes with units or foods defined cannot be parsed.",
"parse-ingredients": "تحليل المكونات",
"edit-markdown": "تعديل Markdown",
"recipe-creation": "إنشاء الوصفة",
"select-one-of-the-various-ways-to-create-a-recipe": "اختر واحدة من الطرق المختلفة لإنشاء وصفة",
"select-one-of-the-various-ways-to-create-a-recipe": "Select one of the various ways to create a recipe",
"looking-for-migrations": "هل تبحث عن نقل المعلومات؟",
"import-with-url": "الاستيراد باستخدام URL",
"create-recipe": "إنشاء وصفة",
@@ -627,14 +623,14 @@
"create-recipe-from-an-image": "إنشاء وصفة عن طريق صورة",
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
"create-from-images": "إنشاء عن طريق صور",
"should-translate-description": "ترجمة الوصفة إلى لغتي",
"please-wait-image-procesing": "الرجاء الانتظار، تتم معالجة الصورة. قد يستغرق هذا بعض الوقت.",
"please-wait-images-processing": "الرجاء الانتظار، يتم معالجة الصور. قد يستغرق هذا بعض الوقت.",
"create-from-images": "Create from Images",
"should-translate-description": "Translate the recipe into my language",
"please-wait-image-procesing": "Please wait, the image is processing. This may take some time.",
"please-wait-images-processing": "Please wait, the images are processing. This may take some time.",
"bulk-url-import": "الاستيراد باستخدام أكثر من URL ",
"debug-scraper": "تصحيح أخطاء المحلل\n",
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "قم بإنشاء وصفة عن طريق تقديم الاسم. يجب أن يكون لجميع الوصفات أسماء فريدة.",
"new-recipe-names-must-be-unique": "يجب أن تكون أسماء الوصفات فريدة",
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Create a recipe by providing the name. All recipes must have unique names.",
"new-recipe-names-must-be-unique": "New recipe names must be unique",
"scrape-recipe": "تحليل الوصفة",
"scrape-recipe-description": "أضف الوصفة عن طريق الرابط. قم بتوفير عنوان الURL للموقع الذي تريد أخذ الوصفة منه، وسيحاول Mealie إستخراج الوصفة من ذلك الموقع وإضافتها إلى مجموعتك.",
"scrape-recipe-have-a-lot-of-recipes": "هل لديك الكثير من الوصفات التي تريد أن تحللها في نفس الوقت؟",
@@ -644,7 +640,6 @@
"scrape-recipe-website-being-blocked": "Website being blocked?",
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
"import-original-keywords-as-tags": "استيراد الكلمات المفتاحية الأصلية كوسوم",
"import-original-categories": "Import original categories",
"stay-in-edit-mode": "البقاء في وضع التعديل",
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
"import-from-zip": "استيراد من ملف Zip",
@@ -698,15 +693,15 @@
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
"delete-item": "حذف العنصر"
"delete-item": "Delete Item"
},
"reset-servings-count": "إعادة تعيين عدد الحصص",
"not-linked-ingredients": "مكونات إضافية",
"upload-another-image": "رفع صورة أخرى",
"upload-images": "رفع الصور",
"upload-more-images": "رفع المزيد من الصور",
"set-as-cover-image": "تعيين كصورة غلاف الوصفة",
"cover-image": "صورة الغلاف",
"upload-another-image": "Upload another image",
"upload-images": "Upload images",
"upload-more-images": "Upload more images",
"set-as-cover-image": "Set as recipe cover image",
"cover-image": "Cover image",
"include-linked-recipes": "Include Linked Recipes",
"include-linked-recipe-ingredients": "Include Linked Recipe Ingredients",
"toggle-recipe": "Toggle Recipe"
@@ -747,7 +742,7 @@
"advanced": "الإعدادات المتقدمة",
"auto-search": "البحث التلقائي",
"no-results": "لم يتم العثور على نتائج",
"type-to-search": "اكتب للبحث ..."
"type-to-search": "Type to search..."
},
"settings": {
"add-a-new-theme": "إضافة سمة جديدة",
@@ -781,8 +776,8 @@
"custom-pages": "الصفحات الخاصة",
"edit-page": "تعديل الصفحة",
"events": "الأحداث",
"first-day-of-week": "اليوم الأول من الأسبوع",
"group-settings-updated": "تم تحديث إعدادات المجموعة",
"first-day-of-week": "First day of the week",
"group-settings-updated": "Group Settings Updated",
"homepage": {
"all-categories": "جميع التصنيفات",
"card-per-section": "Card Per Section",
@@ -804,29 +799,29 @@
"remove-existing-entries-matching-imported-entries": "Remove existing entries matching imported entries",
"set-new-time": "تعيين وقت جديد",
"settings-update-failed": "فشل تحديث الإعدادات",
"settings-updated": "تم تحديث الإعدادات",
"settings-updated": "Settings updated",
"site-settings": "إعدادات الموقع",
"theme": {
"accent": "لون التمييز",
"accent": "Accent",
"dark": "الوضع الليلي",
"default-to-system": "مطابقة وضع الجهاز",
"error": "حدث خطأ",
"error-creating-theme-see-log-file": "خطأ في إنشاء السمة. راجع ملف السجل.",
"error-deleting-theme": "خطأ في حذف السمة",
"error-updating-theme": "خطأ في تحديث السمة",
"info": "معلومات",
"error-creating-theme-see-log-file": "Error creating theme. See log file.",
"error-deleting-theme": "Error deleting theme",
"error-updating-theme": "Error updating theme",
"info": "Info",
"light": "الوضع النهاري",
"primary": "رئيسي",
"secondary": "ثانوي",
"success": "تم بنجاح",
"success": "Success",
"switch-to-dark-mode": "التحويل إلى الوضع الليلي",
"switch-to-light-mode": "التحويل إلى الوضع النهاري",
"theme-deleted": "تم حذف السمة",
"theme-name": "اسم السمة",
"theme-name-is-required": "اسم السمة مطلوب.",
"theme-saved": "تم حفظ السمة",
"theme-updated": "تم تحديث السمة",
"warning": "تحذير",
"theme-deleted": "Theme deleted",
"theme-name": "Theme Name",
"theme-name-is-required": "Theme Name is required.",
"theme-saved": "Theme Saved",
"theme-updated": "Theme updated",
"warning": "Warning",
"light-mode": "الوضع النهاري",
"dark-mode": "الوضع الليلي"
},
@@ -863,7 +858,7 @@
"bug-report": "الإبلاغ عن الأخطاء البرمجية",
"bug-report-information": "Use this information to report a bug. Providing details of your instance to developers is the best way to get your issues resolved quickly.",
"tracker": "Tracker",
"configuration": "الإعدادات",
"configuration": "Configuration",
"docker-volume": "Docker Volume",
"docker-volume-help": "Mealie requires that the frontend container and the backend share the same docker volume or storage. This ensures that the frontend container can properly access the images and assets stored on disk.",
"volumes-are-misconfigured": "Volumes are misconfigured.",
@@ -1118,21 +1113,21 @@
"seed-dialog-text": "Seed the database with common units based on your local language.",
"combine-unit-description": "Combining the selected units will merge the Source Unit and Target Unit into a single unit. The {source-unit-will-be-deleted} and all of the references to the Source Unit will be updated to point to the Target Unit.",
"combine-unit": "دمج الوحدة",
"source-unit": "الوحدة المصدر",
"target-unit": "الوحدة الهدف",
"merging-unit-into-unit": "دمج {0} مع {1}",
"source-unit": "Source Unit",
"target-unit": "Target Unit",
"merging-unit-into-unit": "Merging {0} into {1}",
"create-unit": "إنشاء وحدة",
"abbreviation": "الاختصار",
"abbreviation": "Abbreviation",
"plural-abbreviation": "Plural Abbreviation",
"description": "الوصف",
"description": "Description",
"display-as-fraction": "Display as Fraction",
"use-abbreviation": "Use Abbreviation",
"edit-unit": "تعديل الوحدة",
"unit-data": "بيانات الوحدة",
"use-abbv": "Use Abbv.",
"fraction": "Fraction",
"example-unit-singular": "مثال: ملعقة",
"example-unit-plural": "مثال: ملاعق",
"example-unit-singular": "ex: Tablespoon",
"example-unit-plural": "ex: Tablespoons",
"example-unit-abbreviation-singular": "ex: Tbsp",
"example-unit-abbreviation-plural": "ex: Tbsps"
},
@@ -1150,18 +1145,18 @@
"the-following-recipes-selected-length-will-be-exported": "The following recipes ({0}) will be exported.",
"settings-chosen-explanation": "Settings chosen here, excluding the locked option, will be applied to all selected recipes.",
"selected-length-recipe-s-settings-will-be-updated": "{count} recipe(s) settings will be updated.",
"recipe-data": "بيانات الوصفة",
"recipe-data": "Recipe Data",
"recipe-data-description": "استخدم هذا القسم لإدارة البيانات المرتبطة بوصفاتك. يمكنك تنفيذ العديد من الإجراءات بالجملة على وصفاتك بما في ذلك التصدير والحذف وتعيين الوسوم وتعيين التصنيفات.",
"recipe-columns": "أعمدة الوصفة",
"recipe-columns": "Recipe Columns",
"data-exports-description": "This section provides links to available exports that are ready to download. These exports do expire, so be sure to grab them while they're still available.",
"data-exports": "صادرات البيانات",
"data-exports": "Data Exports",
"tag": "وسم",
"categorize": "Categorize",
"update-settings": "Update Settings",
"tag-recipes": "وسم الوصفات",
"categorize-recipes": "تصنيف الوصفات",
"export-recipes": "تصدير الوصفات",
"delete-recipes": "حذف الوصفات",
"categorize-recipes": "Categorize Recipes",
"export-recipes": "Export Recipes",
"delete-recipes": "Delete Recipes",
"source-unit-will-be-deleted": "Source Unit will be deleted"
},
"recipe-actions": {
@@ -1171,15 +1166,15 @@
"action-type": "Action Type"
},
"create-alias": "Create Alias",
"manage-aliases": "إدارة الأسماء المستعارة",
"manage-aliases": "Manage Aliases",
"seed-data": "Seed Data",
"seed": "Seed",
"data-management": "Data Management",
"data-management-description": "Select which data set you want to make changes to.",
"select-data": " اختر البيانات",
"select-language": "اختر اللغة",
"columns": "الأعمدة",
"combine": "دمج",
"select-data": "Select Data",
"select-language": "Select Language",
"columns": "Columns",
"combine": "Combine",
"categories": {
"edit-category": "تعديل التصنيف",
"new-category": "تصنيف جديد",
@@ -1209,15 +1204,15 @@
"account-details": "تفاصيل الحساب"
},
"validation": {
"group-name-is-taken": "اسم المجموعة مأخوذ",
"group-name-is-taken": "Group name is taken",
"username-is-taken": "اسم المستخدم مستعمل",
"email-is-taken": "هذا البريد الإلكتروني مأخوذ",
"this-field-is-required": "هذا الحقل مطلوب"
"email-is-taken": "Email is taken",
"this-field-is-required": "This Field is Required"
},
"export": {
"export": "تصدير",
"file-name": "اسم الملف",
"size": "الحجم",
"export": "Export",
"file-name": "File Name",
"size": "Size",
"link-expires": "Link Expires"
},
"recipe-share": {
@@ -1346,12 +1341,12 @@
"profile": {
"welcome-user": "مرحبًا 👋، {0}!",
"description": "Manage your profile, recipes, and group settings.",
"invite-link": "رابط الدعوة",
"get-invite-link": "الحصول على رابط الدعوة",
"invite-link": "Invite Link",
"get-invite-link": "Get Invite Link",
"get-public-link": "Get Public Link",
"account-summary": "ملخص الحساب",
"account-summary-description": "إليك ملخص لمعلومات مجموعتك.",
"group-statistics": "إحصائيات المجموعة",
"account-summary": "Account Summary",
"account-summary-description": "Here's a summary of your group's information.",
"group-statistics": "Group Statistics",
"group-statistics-description": "Your Group Statistics provide some insight how you're using Mealie.",
"household-statistics": "Household Statistics",
"household-statistics-description": "Your Household Statistics provide some insight how you're using Mealie.",
@@ -1363,32 +1358,32 @@
"user-settings-description": "إدارة تفضيلاتك، وتغيير كلمة المرور الخاصة بك، وتحديث بريدك الإلكتروني.",
"api-tokens-description": "Manage your API Tokens for access from external applications.",
"group-description": "These items are shared within your group. Editing one of them will change it for the whole group!",
"group-settings": "إعدادات المجموعة",
"group-settings": "Group Settings",
"group-settings-description": "Manage your common group settings, like privacy settings.",
"household-description": "These items are shared within your household. Editing one of them will change it for the whole household!",
"household-settings": "إعدادات الأسرة",
"household-settings": "Household Settings",
"household-settings-description": "Manage your household settings, like mealplan and privacy settings.",
"cookbooks-description": "إدارة مجموعة من تصنيفات الوصفات وإنشاء صفحات لها.",
"members": "الأعضاء",
"members": "Members",
"members-description": "See who's in your household and manage their permissions.",
"webhooks-description": "Setup webhooks that trigger on days that you have have mealplan scheduled.",
"notifiers": "Notifiers",
"notifiers-description": "Setup email and push notifications that trigger on specific events.",
"manage-data": "إدارة البيانات",
"manage-data": "Manage Data",
"manage-data-description": "إدارة بيانات Mealie الخاصة بك؛ الأطعمة، الوحدات، التصنيفات، الوسوم وأكثر من ذلك.",
"data-migrations": "Data Migrations",
"data-migrations-description": "Migrate your existing data from other applications like Nextcloud Recipes and Chowdown.",
"email-sent": "تم إرسال البريد الإلكتروني",
"error-sending-email": "خطأ في إرسال البريد الإلكتروني",
"personal-information": "المعلومات الشخصية",
"preferences": "التفضيلات",
"email-sent": "Email Sent",
"error-sending-email": "Error Sending Email",
"personal-information": "Personal Information",
"preferences": "Preferences",
"show-advanced-description": "Show advanced features (API Keys, Webhooks, and Data Management)",
"back-to-profile": "العودة إلى الملف الشخصي",
"back-to-profile": "Back to Profile",
"looking-for-privacy-settings": "Looking for Privacy Settings?",
"manage-your-api-tokens": "Manage Your API Tokens",
"manage-user-profile": "إدارة الملف الشخصي للمستخدم",
"manage-cookbooks": "إدارة كتب الطبخ",
"manage-members": "إدارة الأعضاء",
"manage-members": "Manage Members",
"manage-webhooks": "Manage Webhooks",
"manage-notifiers": "Manage Notifiers",
"manage-data-migrations": "Manage Data Migrations"
@@ -1422,9 +1417,7 @@
"is-greater-than": "أكبر من",
"is-greater-than-or-equal-to": "أكبر من أو يساوي",
"is-less-than": "أقل من",
"is-less-than-or-equal-to": "أقل من أو يساوي",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
"is-less-than-or-equal-to": "أقل من أو يساوي"
},
"relational-keywords": {
"is": "هو",
@@ -1434,17 +1427,14 @@
"contains-all-of": "يحتوي على كل من",
"is-like": "هو مثل",
"is-not-like": "ليس مثل"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {
"required": "هذا الحقل مطلوب",
"invalid-email": "يجب أن يكون البريد الإلكتروني صالحاً",
"invalid-url": "يجب أن يكون عنوان URL صالحًا",
"no-whitespace": "لا يسمح باستخدام المسافات",
"min-length": "يجب أن يكون على الأقل {min} أحرف",
"max-length": "يجب أن يكون على الأكثر {max} أحرف"
"required": "This Field is Required",
"invalid-email": "Email Must Be Valid",
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
}
}

View File

@@ -156,7 +156,7 @@
"sort-alphabetically": "По азбучен ред",
"status": "състояние",
"subject": "Относно",
"submit": "Потвърди",
"submit": "Изпрати",
"success-count": "Успешни: {count}",
"sunday": "Неделя",
"system": "В хронологичен ред",
@@ -212,13 +212,11 @@
"upload-file": "Качване на файл",
"created-on-date": "Добавена на {0}",
"unsaved-changes": "Имате незапазени промени. Желаете ли да ги запазите преди да излезете? Натиснете Ок за запазване и Отказ за отхвърляне на промените.",
"discard-changes": "Отхвърляне на промените",
"discard-changes-description": "Имате незаписани промени, сигурни ли сте, че искате да ги отмените?",
"clipboard-copy-failure": "Линкът към рецептата е копиран в клипборда.",
"confirm-delete-generic-items": "Сигурни ли сте, че желаете да изтриете следните елементи?",
"organizers": "Органайзер",
"caution": "Внимание",
"show-advanced": "Разширени настройки",
"show-advanced": "Покажи разширени",
"add-field": "Добави поле",
"date-created": "Дата на създаване",
"date-updated": "Дата на актуализация"
@@ -369,9 +367,7 @@
"recipe-rules": "Правила на рецептата",
"applies-to-all-days": "Прилага се за всички дни",
"applies-on-days": "Всеки/всяка {0}",
"meal-plan-settings": "Настройки на плана за хранене",
"add-all-to-list": "Добавяне на всички към списъка за пазаруване",
"add-day-to-list": "Добавяне на ден към списъка за пазаруване"
"meal-plan-settings": "Настройки на плана за хранене"
},
"migration": {
"migration-data-removed": "Данните за мигриране са премахнати",
@@ -449,7 +445,7 @@
"upload-a-recipe": "Качи рецепта",
"upload-individual-zip-file": "Качи като индивидуален .zip файлов формат от друга инстанция на Mealie.",
"url-form-hint": "Копирай и постави линк от твоя любим сайт за рецепти",
"copy-and-paste-the-source-url-of-your-data-optional": "Копирайте и поставете URL адреса на източника на вашите данни (по избор)",
"copy-and-paste-the-source-url-of-your-data-optional": "Copy and paste the source URL of your data (optional)",
"view-scraped-data": "Виж събраните данни",
"trim-whitespace-description": "Премахни интервалите в началото и края на текста, също така и празните редове",
"trim-prefix-description": "Премахни първия символ от всеки ред",
@@ -644,7 +640,6 @@
"scrape-recipe-website-being-blocked": "Блокиран ли е уебсайтът?",
"scrape-recipe-try-importing-raw-html-instead": "Опитайте вместо това да импортирате суровия HTML код.",
"import-original-keywords-as-tags": "Добави оригиналните ключови думи като етикети",
"import-original-categories": "Импортиране на оригиналните категории",
"stay-in-edit-mode": "Остани в режим на редакция",
"parse-recipe-ingredients-after-import": "Анализиране на съставките на рецептата след импортиране",
"import-from-zip": "Импортирай от Zip",
@@ -1183,17 +1178,17 @@
"categories": {
"edit-category": "Редактиране на категория",
"new-category": "Нова категория",
"category-data": "Категории"
"category-data": "Категория за данните"
},
"tags": {
"new-tag": "Нов етикет",
"edit-tag": "Редакция на етикет",
"tag-data": "Етикети"
"tag-data": "Данни на етикета"
},
"tools": {
"new-tool": "Нов инструмент",
"edit-tool": "Редактирай инструмента",
"tool-data": "Прибори"
"tool-data": "Данни на инструмента"
}
},
"user-registration": {
@@ -1422,9 +1417,7 @@
"is-greater-than": "е по-голямо от",
"is-greater-than-or-equal-to": "е по-голямо от или равно на",
"is-less-than": "е по-малко от",
"is-less-than-or-equal-to": "e по-малко или равно на",
"is-older-than": "е по-стар от",
"is-newer-than": "е по-нов от"
"is-less-than-or-equal-to": "e по-малко или равно на"
},
"relational-keywords": {
"is": "е",
@@ -1434,17 +1427,14 @@
"contains-all-of": "съдържа всички от",
"is-like": "е като",
"is-not-like": "не е като"
},
"dates": {
"days-ago": "преди дни|преди ден|преди дни"
}
},
"validators": {
"required": "Това поле е задължително",
"invalid-email": "Email адресът трябва да бъде валиден",
"invalid-url": "Линкът трябва да е валиден",
"no-whitespace": "Не са позволени интервали",
"min-length": "Трябва да съдържа поне {min} знака",
"max-length": "Трябва да съдържа най-много {max} знака"
"required": "This Field is Required",
"invalid-email": "Email Must Be Valid",
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
}
}

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