mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-03-01 21:19:54 -05:00
Compare commits
97 Commits
v3.10.2
...
renovate/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b653cd32f | ||
|
|
7cbe17fe09 | ||
|
|
929349d414 | ||
|
|
595a3c66cd | ||
|
|
d4e7dc6e9d | ||
|
|
b719b39c09 | ||
|
|
b208719cb9 | ||
|
|
3ee64c930c | ||
|
|
189e98fb1f | ||
|
|
7da01f7873 | ||
|
|
8144799733 | ||
|
|
669df6bbb4 | ||
|
|
c6171f2cb2 | ||
|
|
e5d930ecb8 | ||
|
|
668246d369 | ||
|
|
b182b50faa | ||
|
|
a3e3fa6f56 | ||
|
|
bd7acabcd1 | ||
|
|
8059cc731f | ||
|
|
3ae455539c | ||
|
|
8fd7995681 | ||
|
|
282eedfe2b | ||
|
|
03f849f20f | ||
|
|
5db3b6ab72 | ||
|
|
353c24ca4b | ||
|
|
216ae8571c | ||
|
|
02d32c8905 | ||
|
|
7e0d083e77 | ||
|
|
b3cea081fe | ||
|
|
d79252752b | ||
|
|
b3c214d102 | ||
|
|
3a01925e48 | ||
|
|
16e2386f5a | ||
|
|
bbfa105e99 | ||
|
|
c94c9940b2 | ||
|
|
29c6176d89 | ||
|
|
0c0d7d11a5 | ||
|
|
e75fc6d391 | ||
|
|
f308869154 | ||
|
|
af30b8bdfa | ||
|
|
de4f22c3f6 | ||
|
|
4c55b282d6 | ||
|
|
8d2b2eb581 | ||
|
|
e9daac5fc4 | ||
|
|
ee1205cfdc | ||
|
|
a165b707af | ||
|
|
564385eb83 | ||
|
|
c23aa61f17 | ||
|
|
cd39d0c4cb | ||
|
|
20e2d4e1a1 | ||
|
|
c09cc5a323 | ||
|
|
6d7b6bccab | ||
|
|
91fea086e5 | ||
|
|
e2fbe118a7 | ||
|
|
904e6b7d82 | ||
|
|
5aafb56c4f | ||
|
|
b4740d291d | ||
|
|
fc6dc34ace | ||
|
|
73d86f6f6b | ||
|
|
8e225ee796 | ||
|
|
ced233d361 | ||
|
|
b173172e6c | ||
|
|
a66db96eb5 | ||
|
|
dfd5abfb5d | ||
|
|
e2ae5cb5b6 | ||
|
|
634aa5cd25 | ||
|
|
23c7bd7e3d | ||
|
|
9c1ee972c9 | ||
|
|
1b9023c8c0 | ||
|
|
3a37cd6959 | ||
|
|
8da0d010a5 | ||
|
|
37f7f770a8 | ||
|
|
1cebbefd88 | ||
|
|
d55149b904 | ||
|
|
fad7acadfc | ||
|
|
a539c6cd2e | ||
|
|
7b5502d019 | ||
|
|
26d9d8fe24 | ||
|
|
b64f14aaae | ||
|
|
9b686ecd2b | ||
|
|
a956a638f4 | ||
|
|
c9d9e6822e | ||
|
|
4a563b76ad | ||
|
|
73f97c2cca | ||
|
|
75e3c99d72 | ||
|
|
217ddd8814 | ||
|
|
f2cc8dc922 | ||
|
|
b8329def91 | ||
|
|
2ae7dc3b82 | ||
|
|
510a63a71f | ||
|
|
14433819c3 | ||
|
|
96a9dbccb6 | ||
|
|
cfe20214e5 | ||
|
|
eef54879fe | ||
|
|
c789ecf0ba | ||
|
|
008f55e725 | ||
|
|
bcbe32f503 |
8
.github/pull_request_template.md
vendored
8
.github/pull_request_template.md
vendored
@@ -61,3 +61,11 @@ _(fill-in or delete this section)_
|
||||
<!--
|
||||
Describe how you tested this change.
|
||||
-->
|
||||
|
||||
## AI / LLM Assistance
|
||||
|
||||
_(REQUIRED)_
|
||||
|
||||
<!--
|
||||
Describe to which degree an LLM was used in creating this pull request.
|
||||
-->
|
||||
|
||||
12
.github/workflows/publish.yml
vendored
12
.github/workflows/publish.yml
vendored
@@ -37,6 +37,17 @@ 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:
|
||||
@@ -57,5 +68,6 @@ jobs:
|
||||
hkotel/mealie:${{ inputs.tag }}
|
||||
ghcr.io/${{ github.repository }}:${{ inputs.tag }}
|
||||
${{ inputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
COMMIT=${{ github.sha }}
|
||||
|
||||
@@ -12,7 +12,7 @@ repos:
|
||||
exclude: ^tests/data/
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.0
|
||||
rev: v0.15.2
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
|
||||
10
Taskfile.yml
10
Taskfile.yml
@@ -25,16 +25,9 @@ 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
|
||||
|
||||
@@ -81,7 +74,6 @@ tasks:
|
||||
desc: run code generators
|
||||
cmds:
|
||||
- uv run python dev/code-generation/main.py {{ .CLI_ARGS }}
|
||||
- task: docs:gen
|
||||
- task: py:format
|
||||
|
||||
dev:services:
|
||||
@@ -350,4 +342,4 @@ tasks:
|
||||
vars: { WAIT_UNTIL_HEALTHY: true }
|
||||
- defer: { task: e2e:stop-server }
|
||||
- task: e2e:test
|
||||
vars: { PREVENT_REPORT_OPEN: true }
|
||||
vars: { PREVENT_REPORT_OPEN: true }
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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)
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import dotenv
|
||||
@@ -10,6 +11,7 @@ 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
|
||||
@@ -17,57 +19,6 @@ 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 %}
|
||||
{
|
||||
@@ -75,6 +26,7 @@ export const LOCALES = [{% for locale in locales %}
|
||||
value: "{{ locale.locale }}",
|
||||
progress: {{ locale.progress }},
|
||||
dir: "{{ locale.dir }}",
|
||||
pluralFoodHandling: "{{ locale.plural_food_handling }}",
|
||||
},{% endfor %}
|
||||
];
|
||||
|
||||
@@ -87,10 +39,11 @@ class TargetLanguage(MealieModel):
|
||||
id: str
|
||||
name: str
|
||||
locale: str
|
||||
dir: str = "ltr"
|
||||
dir: LocaleTextDirection = LocaleTextDirection.LTR
|
||||
plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS
|
||||
threeLettersCode: str
|
||||
twoLettersCode: str
|
||||
progress: float = 0.0
|
||||
progress: int = 0
|
||||
|
||||
|
||||
class CrowdinApi:
|
||||
@@ -117,43 +70,15 @@ class CrowdinApi:
|
||||
def get_languages(self) -> list[TargetLanguage]:
|
||||
response = self.get_project()
|
||||
tls = response.json()["data"]["targetLanguages"]
|
||||
return [TargetLanguage(**t) for t in tls]
|
||||
|
||||
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:
|
||||
def get_progress(self) -> dict[str, int]:
|
||||
response = requests.get(
|
||||
f"https://api.crowdin.com/api/v2/projects/{self.project_id}/languages/progress?limit=500",
|
||||
headers=self.headers,
|
||||
)
|
||||
return response.json()
|
||||
data = response.json()["data"]
|
||||
return {p["data"]["languageId"]: p["translationProgress"] for p in data}
|
||||
|
||||
|
||||
PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
@@ -195,8 +120,8 @@ def inject_nuxt_values():
|
||||
|
||||
all_langs = []
|
||||
for match in locales_dir.glob("*.json"):
|
||||
match_data = LOCALE_DATA.get(match.stem)
|
||||
match_dir = match_data.dir if match_data else "ltr"
|
||||
match_data = LOCALE_CONFIG.get(match.stem)
|
||||
match_dir = match_data.dir if match_data else LocaleTextDirection.LTR
|
||||
|
||||
lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}", dir: "{match_dir}" }},'
|
||||
all_langs.append(lang_string)
|
||||
@@ -221,9 +146,82 @@ 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():
|
||||
api = CrowdinApi(None)
|
||||
models = api.get_languages()
|
||||
models = get_languages()
|
||||
tmpl = Template(LOCALE_TEMPLATE)
|
||||
rendered = tmpl.render(locales=models)
|
||||
|
||||
@@ -233,10 +231,6 @@ 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()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
###############################################
|
||||
# Frontend Build
|
||||
###############################################
|
||||
FROM node:24@sha256:1de022d8459f896fff2e7b865823699dc7a8d5567507e8b87b14a7442e07f206 \
|
||||
FROM node:24@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b \
|
||||
AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
@@ -111,7 +111,6 @@ 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
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
title: API
|
||||
template: api.html
|
||||
---
|
||||
@@ -79,8 +79,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 recipes where the "last made" value is not null: <br>
|
||||
`lastMade IS NOT NULL`
|
||||
Here is an example of a filter that returns all shopping list items without a food: <br>
|
||||
`foodId IS NULL`
|
||||
|
||||
This filter will find all recipes that don't start with the word "Test": <br>
|
||||
`name NOT LIKE "Test%"`
|
||||
|
||||
@@ -124,16 +124,16 @@ For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values
|
||||
|
||||
| Variables | Default | Description |
|
||||
|---------------------------------------------------|:-------:|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| OPENAI_BASE_URL<super>[†][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>[†][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 |
|
||||
| OPENAI_CUSTOM_PROMPT_DIR | 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. |
|
||||
| OPENAI_BASE_URL<super>[†][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>[†][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. |
|
||||
|
||||
### Theming
|
||||
|
||||
|
||||
@@ -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.10.2`
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.11.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
|
||||
|
||||
|
||||
@@ -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.10.2 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.11.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -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.10.2 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.11.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -93,7 +93,7 @@ nav:
|
||||
- iOS Shortcut: "documentation/community-guide/ios-shortcut.md"
|
||||
- Reverse Proxy (SWAG): "documentation/community-guide/swag.md"
|
||||
|
||||
- API Reference: "api/redoc.md"
|
||||
- API Reference: "https://demo.mealie.io/docs"
|
||||
|
||||
- Contributors Guide:
|
||||
- Non-Code: "contributors/non-coders.md"
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
230
frontend/components/Domain/Group/GroupDataPage.vue
Normal file
230
frontend/components/Domain/Group/GroupDataPage.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<!-- Create Dialog -->
|
||||
<BaseDialog
|
||||
v-model="createDialog"
|
||||
:title="createTitle || $t('general.create')"
|
||||
:icon="icon"
|
||||
color="primary"
|
||||
max-width="600px"
|
||||
width="100%"
|
||||
: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"
|
||||
class="py-2"
|
||||
/>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<BaseDialog
|
||||
v-model="editDialog"
|
||||
:title="editTitle || $t('general.edit')"
|
||||
:icon="icon"
|
||||
color="primary"
|
||||
max-width="600px"
|
||||
width="100%"
|
||||
:submit-disabled="!editFormValid"
|
||||
can-confirm
|
||||
@confirm="emit('edit-one', editForm.data)"
|
||||
>
|
||||
<div class="mx-2 mt-2">
|
||||
<slot name="edit-dialog-top" />
|
||||
<AutoForm
|
||||
v-model="editForm.data"
|
||||
v-model:is-valid="editFormValid"
|
||||
:items="editForm.items"
|
||||
class="py-2"
|
||||
/>
|
||||
</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,
|
||||
},
|
||||
createTitle: {
|
||||
type: String,
|
||||
},
|
||||
editTitle: {
|
||||
type: String,
|
||||
},
|
||||
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>
|
||||
@@ -76,7 +76,6 @@ const MEAL_DAY_OPTIONS = [
|
||||
];
|
||||
|
||||
function handleQueryFilterInput(value: string | undefined) {
|
||||
console.warn("handleQueryFilterInput called with value:", value);
|
||||
queryFilterString.value = value || "";
|
||||
}
|
||||
|
||||
@@ -114,7 +113,7 @@ const fieldDefs: FieldDefinition[] = [
|
||||
{
|
||||
name: "last_made",
|
||||
label: i18n.t("general.last-made"),
|
||||
type: "date",
|
||||
type: "relativeDate",
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
<v-select
|
||||
v-if="field.type !== 'boolean'"
|
||||
:model-value="field.relationalOperatorValue"
|
||||
:items="field.relationalOperatorOptions"
|
||||
:items="field.relationalOperatorChoices"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
variant="underlined"
|
||||
@@ -129,9 +129,9 @@
|
||||
:class="config.col.class"
|
||||
>
|
||||
<v-select
|
||||
v-if="field.fieldOptions"
|
||||
v-if="field.fieldChoices"
|
||||
:model-value="field.values"
|
||||
:items="field.fieldOptions"
|
||||
:items="field.fieldChoices"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
multiple
|
||||
@@ -169,23 +169,39 @@
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
:model-value="field.value ? $d(new Date(field.value + 'T00:00:00')) : null"
|
||||
persistent-hint
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
:model-value="$d(safeNewDate(field.value + 'T00:00:00'))"
|
||||
variant="underlined"
|
||||
color="primary"
|
||||
class="date-input"
|
||||
v-bind="activatorProps"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
<v-date-picker
|
||||
:model-value="field.value ? new Date(field.value + 'T00:00:00') : null"
|
||||
:model-value="safeNewDate(field.value + 'T00:00:00')"
|
||||
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"
|
||||
@@ -319,7 +335,13 @@ 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/non-generated";
|
||||
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";
|
||||
@@ -341,7 +363,14 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { household } = useHouseholdSelf();
|
||||
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
|
||||
const {
|
||||
logOps,
|
||||
placeholderKeywords,
|
||||
getRelOps,
|
||||
buildQueryFilterString,
|
||||
getFieldFromFieldDef,
|
||||
isOrganizerType,
|
||||
} = useQueryFilterBuilder();
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
@@ -396,16 +425,29 @@ function setField(index: number, fieldLabel: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions);
|
||||
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldChoices !== fields.value[index].fieldChoices);
|
||||
const updatedField = { ...fields.value[index], ...fieldDef };
|
||||
|
||||
// we have to set this explicitly since it might be undefined
|
||||
updatedField.fieldOptions = fieldDef.fieldOptions;
|
||||
updatedField.fieldChoices = fieldDef.fieldChoices;
|
||||
|
||||
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) {
|
||||
@@ -425,12 +467,21 @@ 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;
|
||||
fields.value[index].value = value;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
|
||||
@@ -448,12 +499,7 @@ function removeField(index: number) {
|
||||
state.datePickers.splice(index, 1);
|
||||
}
|
||||
|
||||
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
|
||||
/* newFields.forEach((field, index) => {
|
||||
const updatedField = getFieldFromFieldDef(field);
|
||||
fields.value[index] = updatedField; // recursive!!!
|
||||
}); */
|
||||
|
||||
const fieldsUpdater = useDebounceFn(() => {
|
||||
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
||||
if (qf) {
|
||||
console.debug(`Set query filter: ${qf}`);
|
||||
@@ -519,6 +565,9 @@ 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
|
||||
@@ -527,12 +576,15 @@ 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.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
if (field.fieldChoices?.length || isOrganizerType(field.type)) {
|
||||
if (typeof part.value === "string") {
|
||||
field.values = part.value ? [part.value] : [];
|
||||
}
|
||||
@@ -601,7 +653,7 @@ function buildQueryFilterJSON(): QueryFilterJSON {
|
||||
relationalOperator: field.relationalOperatorValue?.value,
|
||||
};
|
||||
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
if (field.fieldChoices?.length || isOrganizerType(field.type)) {
|
||||
part.value = field.values.map(value => value.toString());
|
||||
}
|
||||
else if (field.type === "boolean") {
|
||||
@@ -619,6 +671,50 @@ 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;
|
||||
@@ -689,4 +785,13 @@ 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>
|
||||
|
||||
@@ -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}` : "";
|
||||
|
||||
@@ -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}` : "";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -202,13 +202,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 +296,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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -217,7 +217,7 @@ 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);
|
||||
@@ -239,9 +239,9 @@ const selectedShoppingList = ref<ShoppingListSummary | null>(null);
|
||||
|
||||
watch([dialog, () => preferences.value.viewAllLists], () => {
|
||||
if (dialog.value) {
|
||||
currentHouseholdSlug.value = $auth.user.value?.householdSlug || "";
|
||||
currentHouseholdSlug.value = auth.user.value?.householdSlug || "";
|
||||
filteredShoppingLists.value = props.shoppingLists.filter(
|
||||
list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id,
|
||||
list => preferences.value.viewAllLists || list.userId === auth.user.value?.id,
|
||||
);
|
||||
|
||||
if (filteredShoppingLists.value.length === 1 && !state.shoppingListShowAllToggled) {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
dark
|
||||
color="primary-lighten-1 top-0 position-relative left-0"
|
||||
:rounded="!$vuetify.display.xs"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<v-text-field
|
||||
id="arrow-search"
|
||||
@@ -32,9 +33,8 @@
|
||||
|
||||
<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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -141,13 +141,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -58,8 +58,8 @@
|
||||
density="compact"
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="units || []"
|
||||
:custom-filter="normalizeFilter"
|
||||
:items="filteredUnits"
|
||||
:custom-filter="() => true"
|
||||
item-title="name"
|
||||
class="mx-1"
|
||||
:placeholder="$t('recipe.choose-unit')"
|
||||
@@ -117,8 +117,8 @@
|
||||
density="compact"
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="foods || []"
|
||||
:custom-filter="normalizeFilter"
|
||||
:items="filteredFoods"
|
||||
:custom-filter="() => true"
|
||||
item-title="name"
|
||||
class="mx-1 py-0"
|
||||
:placeholder="$t('recipe.choose-food')"
|
||||
@@ -176,7 +176,6 @@
|
||||
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')"
|
||||
@@ -227,11 +226,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, toRefs } from "vue";
|
||||
import { ref, computed, reactive, toRefs, watch } from "vue";
|
||||
import { useDisplay } from "vuetify";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||
import { normalizeFilter } from "~/composables/use-utils";
|
||||
import { useSearch } from "~/composables/use-search";
|
||||
import { useNuxtApp } from "#app";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { usePublicExploreApi, useUserApi } from "~/composables/api";
|
||||
@@ -343,8 +342,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;
|
||||
@@ -355,8 +354,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;
|
||||
@@ -375,8 +374,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;
|
||||
@@ -430,9 +429,6 @@ function quantityFilter(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
const { showTitle } = toRefs(state);
|
||||
|
||||
const foods = foodStore.store;
|
||||
const units = unitStore.store;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { useParsedIngredientText } from "~/composables/recipes";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
|
||||
interface Props {
|
||||
ingredient?: RecipeIngredient;
|
||||
@@ -20,6 +20,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const { ingredient, scale = 1 } = defineProps<Props>();
|
||||
const { useParsedIngredientText } = useIngredientTextParser();
|
||||
|
||||
const baseText = computed(() => {
|
||||
if (!ingredient) return "";
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RecipeIngredient } from "~/lib/api/types/household";
|
||||
import { useParsedIngredientText } from "~/composables/recipes";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
|
||||
interface Props {
|
||||
ingredient: RecipeIngredient;
|
||||
@@ -44,8 +44,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
scale: 1,
|
||||
});
|
||||
const route = useRoute();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
||||
const auth = useMealieAuth();
|
||||
const groupSlug = computed(() => route.params.groupSlug || auth.user?.value?.groupSlug || "");
|
||||
const { useParsedIngredientText } = useIngredientTextParser();
|
||||
|
||||
const parsedIng = computed(() => {
|
||||
return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
|
||||
interface Props {
|
||||
@@ -66,6 +66,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isCookMode: false,
|
||||
});
|
||||
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
function validateTitle(title?: string | null) {
|
||||
return !(title === undefined || title === "" || title === null);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -220,11 +220,11 @@ import { useNavigationWarning } from "~/composables/use-navigation-warning";
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
|
||||
const display = useDisplay();
|
||||
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 router = useRouter();
|
||||
const api = useUserApi();
|
||||
|
||||
@@ -431,6 +431,7 @@ 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[]>([]);
|
||||
@@ -581,7 +582,7 @@ function setUsedIngredients() {
|
||||
watch(activeRefs, () => setUsedIngredients());
|
||||
|
||||
function autoSetReferences() {
|
||||
useExtractIngredientReferences(
|
||||
extractIngredientReferences(
|
||||
props.recipe.recipeIngredient,
|
||||
activeRefs.value,
|
||||
activeText.value,
|
||||
|
||||
@@ -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 { parseIngredientText } from "~/composables/recipes";
|
||||
import { useIngredientTextParser } 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,6 +208,8 @@ const props = defineProps<{
|
||||
ingredients: NoUndefinedField<RecipeIngredient[]>;
|
||||
}>();
|
||||
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: boolean): void;
|
||||
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
|
||||
|
||||
@@ -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 { parseIngredientText, useNutritionLabels } from "~/composables/recipes";
|
||||
import { useIngredientTextParser, useNutritionLabels } from "~/composables/recipes";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
@@ -362,6 +362,8 @@ const hasNotes = computed(() => {
|
||||
return props.recipe.notes && props.recipe.notes.length > 0;
|
||||
});
|
||||
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
function parseText(ingredient: RecipeIngredient) {
|
||||
return parseIngredientText(ingredient, props.scale);
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
<v-card width="400">
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="state.search"
|
||||
v-memo="[state.search]"
|
||||
v-model="searchInput"
|
||||
v-memo="[searchInput]"
|
||||
class="mb-2"
|
||||
hide-details
|
||||
density="comfortable"
|
||||
@@ -146,17 +146,13 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
|
||||
export interface SelectableItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
import type { ISearchableItem } from "~/composables/use-search";
|
||||
import { useSearch } from "~/composables/use-search";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array as () => SelectableItem[],
|
||||
type: Array as () => ISearchableItem[],
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
@@ -175,12 +171,11 @@ export default defineNuxtComponent({
|
||||
emits: ["update:requireAll", "update:modelValue"],
|
||||
setup(props, context) {
|
||||
const state = reactive({
|
||||
search: "",
|
||||
menu: false,
|
||||
});
|
||||
|
||||
// Use shallowRef for better performance with arrays
|
||||
const debouncedSearch = shallowRef("");
|
||||
// Use the search composable
|
||||
const { search: searchInput, filtered } = useSearch(computed(() => props.items));
|
||||
|
||||
const combinator = computed({
|
||||
get: () => (props.requireAll ? "hasAll" : "hasAny"),
|
||||
@@ -191,7 +186,7 @@ export default defineNuxtComponent({
|
||||
|
||||
// Use shallowRef to prevent deep reactivity on large arrays
|
||||
const selected = computed({
|
||||
get: () => props.modelValue as SelectableItem[],
|
||||
get: () => props.modelValue as ISearchableItem[],
|
||||
set: (value) => {
|
||||
context.emit("update:modelValue", value);
|
||||
},
|
||||
@@ -204,44 +199,12 @@ 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 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) => {
|
||||
const handleRadioClick = (item: ISearchableItem) => {
|
||||
if (selectedRadio.value === item) {
|
||||
selectedRadio.value = null;
|
||||
}
|
||||
@@ -250,18 +213,18 @@ export default defineNuxtComponent({
|
||||
function clearSelection() {
|
||||
selected.value = [];
|
||||
selectedRadio.value = null;
|
||||
state.search = "";
|
||||
searchInput.value = "";
|
||||
}
|
||||
|
||||
return {
|
||||
combinator,
|
||||
state,
|
||||
searchInput,
|
||||
selected,
|
||||
selectedRadio,
|
||||
selectedCount,
|
||||
selectedIds,
|
||||
filtered,
|
||||
handleCheckboxClick,
|
||||
handleRadioClick,
|
||||
clearSelection,
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
:items="foods"
|
||||
:label="$t('shopping-list.food')"
|
||||
:icon="$globals.icons.foods"
|
||||
:autofocus="autoFocus === 'food'"
|
||||
create
|
||||
@create="createAssignFood"
|
||||
/>
|
||||
@@ -41,7 +42,7 @@
|
||||
:label="$t('shopping-list.note')"
|
||||
rows="1"
|
||||
auto-grow
|
||||
autofocus
|
||||
:autofocus="autoFocus === 'note'"
|
||||
@keypress="handleNoteKeyPress"
|
||||
/>
|
||||
</div>
|
||||
@@ -165,6 +166,8 @@ 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
|
||||
@@ -204,6 +207,7 @@ export default defineNuxtComponent({
|
||||
|
||||
return {
|
||||
listItem,
|
||||
autoFocus,
|
||||
createAssignFood,
|
||||
createAssignUnit,
|
||||
assignLabelToFood,
|
||||
|
||||
@@ -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}`;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,211 +1,139 @@
|
||||
<template>
|
||||
<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"
|
||||
>
|
||||
{{ 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"
|
||||
@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 }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-color-picker
|
||||
v-model="model[inputField.varName]"
|
||||
value="#7417BE"
|
||||
hide-canvas
|
||||
hide-inputs
|
||||
show-swatches
|
||||
class="mx-auto"
|
||||
@input="emitBlur"
|
||||
/>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<!-- Object Type -->
|
||||
<div v-else-if="inputField.type === fieldTypes.OBJECT">
|
||||
<auto-form
|
||||
v-model="model[inputField.varName]"
|
||||
:color="color"
|
||||
:items="(inputField as any).items"
|
||||
@blur="emitBlur"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- List Type -->
|
||||
<div v-else-if="inputField.type === fieldTypes.LIST">
|
||||
<div
|
||||
v-for="(item, idx) in model[inputField.varName]"
|
||||
:key="idx"
|
||||
<v-form v-model="isValid" validate-on="input">
|
||||
<v-card
|
||||
:color="color"
|
||||
:dark="dark"
|
||||
flat
|
||||
:width="width"
|
||||
class="my-2"
|
||||
>
|
||||
<v-row no-gutters>
|
||||
<template v-for="(inputField, index) in items" :key="index">
|
||||
<v-col
|
||||
v-if="inputField.section"
|
||||
:cols="12"
|
||||
class="px-2"
|
||||
>
|
||||
<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"
|
||||
<v-divider
|
||||
class="my-2"
|
||||
/>
|
||||
</div>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<BaseButton
|
||||
small
|
||||
@click="model[inputField.varName].push(getTemplate((inputField as any).items))"
|
||||
<v-card-title
|
||||
class="pl-0"
|
||||
>
|
||||
{{ $t("general.new") }}
|
||||
</BaseButton>
|
||||
</v-card-actions>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
{{ inputField.section }}
|
||||
</v-card-title>
|
||||
<v-card-text
|
||||
v-if="inputField.sectionDetails"
|
||||
class="pl-0 mt-0 pt-0"
|
||||
>
|
||||
{{ inputField.sectionDetails }}
|
||||
</v-card-text>
|
||||
</v-col>
|
||||
<v-col
|
||||
:cols="inputField.cols || 12"
|
||||
class="px-2"
|
||||
>
|
||||
<!-- 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">
|
||||
{{ 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
|
||||
density="comfortable"
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:hint="inputField.hint || ''"
|
||||
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
|
||||
validate-on="input"
|
||||
/>
|
||||
|
||||
<!-- 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"
|
||||
/>
|
||||
|
||||
<!-- 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 -->
|
||||
<div
|
||||
v-else-if="inputField.type === fieldTypes.COLOR"
|
||||
class="d-flex"
|
||||
style="width: 100%"
|
||||
>
|
||||
<InputColor v-model="model[inputField.varName]" />
|
||||
</div>
|
||||
</v-col>
|
||||
</template>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-form>
|
||||
</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 modelValue = defineModel<Record<string, any> | any[]>({
|
||||
const model = defineModel<Record<string, any> | any[]>({
|
||||
type: [Object, Array],
|
||||
required: true,
|
||||
});
|
||||
|
||||
// alias to avoid template TS complaining about possible undefined
|
||||
const model = modelValue as any;
|
||||
const isValid = defineModel("isValid", { type: Boolean, default: false });
|
||||
|
||||
const props = defineProps({
|
||||
updateMode: {
|
||||
@@ -220,10 +148,6 @@ const props = defineProps({
|
||||
type: [Number, String],
|
||||
default: "max",
|
||||
},
|
||||
globalRules: {
|
||||
default: null,
|
||||
type: Array as () => string[],
|
||||
},
|
||||
color: {
|
||||
default: null,
|
||||
type: String,
|
||||
@@ -242,31 +166,6 @@ 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 }> = {};
|
||||
@@ -279,25 +178,6 @@ 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>
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
nudge-bottom="6"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
color="accent"
|
||||
variant="elevated"
|
||||
v-bind="props"
|
||||
v-bind="activatorProps"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.cog }}
|
||||
@@ -108,7 +108,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup 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?: string;
|
||||
align?: "start" | "center" | "end";
|
||||
sortable?: boolean;
|
||||
sort?: (a: any, b: any) => number;
|
||||
}
|
||||
@@ -131,106 +131,95 @@ export interface BulkAction {
|
||||
event: string;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
const props = defineProps({
|
||||
tableConfig: {
|
||||
type: Object as () => TableConfig,
|
||||
default: () => ({
|
||||
hideColumns: false,
|
||||
canExport: false,
|
||||
}),
|
||||
},
|
||||
emits: ["delete-one", "edit-one"],
|
||||
setup(props, context) {
|
||||
const i18n = useI18n();
|
||||
const sortBy = computed(() => [{
|
||||
key: props.initialSort,
|
||||
order: props.initialSortDesc ? "desc" : "asc",
|
||||
}]);
|
||||
|
||||
// ===========================================================
|
||||
// Reactive Headers
|
||||
// Create a local reactive copy of headers that we can modify
|
||||
const localHeaders = ref([...props.headers]);
|
||||
|
||||
// Watch for changes in props.headers and update local copy
|
||||
watch(() => props.headers, (newHeaders) => {
|
||||
localHeaders.value = [...newHeaders];
|
||||
}, { deep: true });
|
||||
|
||||
const filteredHeaders = computed<string[]>(() => {
|
||||
return localHeaders.value.filter(header => header.show).map(header => header.value);
|
||||
});
|
||||
|
||||
const headersWithoutActions = computed(() =>
|
||||
localHeaders.value
|
||||
.filter(header => filteredHeaders.value.includes(header.value))
|
||||
.map(header => ({
|
||||
...header,
|
||||
title: i18n.t(header.text),
|
||||
})),
|
||||
);
|
||||
|
||||
const activeHeaders = computed(() => [
|
||||
...headersWithoutActions.value,
|
||||
{ title: "", value: "actions", show: true, align: "end" },
|
||||
]);
|
||||
|
||||
const selected = ref<any[]>([]);
|
||||
|
||||
// ===========================================================
|
||||
// Bulk Action Event Handler
|
||||
|
||||
const bulkActionListener = computed(() => {
|
||||
const handlers: { [key: string]: () => void } = {};
|
||||
|
||||
props.bulkActions.forEach((action) => {
|
||||
handlers[action.event] = () => {
|
||||
context.emit(action.event, selected.value);
|
||||
// clear selection
|
||||
selected.value = [];
|
||||
};
|
||||
});
|
||||
|
||||
return handlers;
|
||||
});
|
||||
|
||||
const search = ref("");
|
||||
|
||||
return {
|
||||
sortBy,
|
||||
selected,
|
||||
localHeaders,
|
||||
filteredHeaders,
|
||||
headersWithoutActions,
|
||||
activeHeaders,
|
||||
bulkActionListener,
|
||||
search,
|
||||
downloadAsJson,
|
||||
};
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete-one" | "edit-one", item: any): void;
|
||||
(e: "bulk-action", event: string, items: any[]): void;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const sortBy = computed<{ key: string; order: "asc" | "desc" }[]>(() => [{
|
||||
key: props.initialSort,
|
||||
order: props.initialSortDesc ? "desc" : "asc",
|
||||
}]);
|
||||
|
||||
// ===========================================================
|
||||
// Reactive Headers
|
||||
// Create a local reactive copy of headers that we can modify
|
||||
const localHeaders = ref([...props.headers]);
|
||||
|
||||
// Watch for changes in props.headers and update local copy
|
||||
watch(() => props.headers, (newHeaders) => {
|
||||
localHeaders.value = [...newHeaders];
|
||||
}, { deep: true });
|
||||
|
||||
const filteredHeaders = computed<string[]>(() => {
|
||||
return localHeaders.value.filter(header => header.show).map(header => header.value);
|
||||
});
|
||||
|
||||
const headersWithoutActions = computed(() =>
|
||||
localHeaders.value
|
||||
.filter(header => filteredHeaders.value.includes(header.value))
|
||||
.map(header => ({
|
||||
...header,
|
||||
title: i18n.t(header.text),
|
||||
})),
|
||||
);
|
||||
|
||||
const activeHeaders = computed(() => [
|
||||
...headersWithoutActions.value,
|
||||
{ title: "", value: "actions", show: true, align: "end" },
|
||||
]);
|
||||
|
||||
const selected = ref<any[]>([]);
|
||||
|
||||
// ===========================================================
|
||||
// Bulk Action Event Handler
|
||||
|
||||
const bulkActionListener = computed(() => {
|
||||
const handlers: { [key: string]: () => void } = {};
|
||||
|
||||
props.bulkActions.forEach((action) => {
|
||||
handlers[action.event] = () => {
|
||||
emit("bulk-action", action.event, selected.value);
|
||||
// clear selection
|
||||
selected.value = [];
|
||||
};
|
||||
});
|
||||
|
||||
return handlers;
|
||||
});
|
||||
|
||||
const search = ref("");
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
v-model:search="searchInput"
|
||||
item-title="name"
|
||||
return-object
|
||||
:items="items"
|
||||
:custom-filter="normalizeFilter"
|
||||
:items="filteredItems"
|
||||
: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 { normalizeFilter } from "~/composables/use-utils";
|
||||
import { useSearch } from "~/composables/use-search";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
@@ -85,7 +85,10 @@ export default defineNuxtComponent({
|
||||
emits: ["update:modelValue", "update:item-id", "create"],
|
||||
setup(props, context) {
|
||||
const autocompleteRef = ref<HTMLInputElement>();
|
||||
const searchInput = ref("");
|
||||
|
||||
// Use the search composable
|
||||
const { search: searchInput, filtered: filteredItems } = useSearch(computed(() => props.items));
|
||||
|
||||
const itemIdVal = computed({
|
||||
get: () => {
|
||||
return props.itemId || undefined;
|
||||
@@ -123,8 +126,8 @@ export default defineNuxtComponent({
|
||||
itemVal,
|
||||
itemIdVal,
|
||||
searchInput,
|
||||
filteredItems,
|
||||
emitCreate,
|
||||
normalizeFilter,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
export const fieldTypes = {
|
||||
TEXT: "text",
|
||||
TEXT_AREA: "textarea",
|
||||
LIST: "list",
|
||||
SELECT: "select",
|
||||
OBJECT: "object",
|
||||
BOOLEAN: "boolean",
|
||||
COLOR: "color",
|
||||
PASSWORD: "password",
|
||||
COLOR: "color",
|
||||
} as const;
|
||||
|
||||
@@ -26,8 +26,10 @@ export function useReadOnlyActions<T extends BoundT>(
|
||||
api: BaseCRUDAPIReadOnly<T>,
|
||||
allRef: Ref<T[] | null> | null,
|
||||
loading: Ref<boolean>,
|
||||
defaultQueryParams: Record<string, QueryValue> = {},
|
||||
): ReadOnlyStoreActions<T> {
|
||||
function getAll(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
|
||||
params = { ...defaultQueryParams, ...params };
|
||||
params.orderBy ??= "name";
|
||||
params.orderDirection ??= "asc";
|
||||
|
||||
@@ -56,6 +58,7 @@ export function useReadOnlyActions<T extends BoundT>(
|
||||
}
|
||||
|
||||
async function refresh(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
|
||||
params = { ...defaultQueryParams, ...params };
|
||||
params.orderBy ??= "name";
|
||||
params.orderDirection ??= "asc";
|
||||
|
||||
@@ -86,8 +89,10 @@ export function useStoreActions<T extends BoundT>(
|
||||
api: BaseCRUDAPI<unknown, T, unknown>,
|
||||
allRef: Ref<T[] | null> | null,
|
||||
loading: Ref<boolean>,
|
||||
defaultQueryParams: Record<string, QueryValue> = {},
|
||||
): StoreActions<T> {
|
||||
function getAll(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
|
||||
params = { ...defaultQueryParams, ...params };
|
||||
params.orderBy ??= "name";
|
||||
params.orderDirection ??= "asc";
|
||||
|
||||
@@ -116,6 +121,7 @@ export function useStoreActions<T extends BoundT>(
|
||||
}
|
||||
|
||||
async function refresh(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
|
||||
params = { ...defaultQueryParams, ...params };
|
||||
params.orderBy ??= "name";
|
||||
params.orderDirection ??= "asc";
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -1,60 +1,82 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi, beforeEach } 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 result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "", true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "");
|
||||
expect(result).toStrictEqual(new Set());
|
||||
});
|
||||
|
||||
test("when and ingredient matches exactly and has a reference id, return the referenceId", () => {
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion", true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion");
|
||||
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test.each(punctuationMarks)("when ingredient is suffixed by punctuation, return the referenceId", (suffix) => {
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion" + suffix, true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion" + suffix);
|
||||
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test.each(punctuationMarks)("when ingredient is prefixed by punctuation, return the referenceId", (prefix) => {
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing " + prefix + "Onion", true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing " + prefix + "Onion");
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test("when ingredient is first on a multiline, return the referenceId", () => {
|
||||
const multilineSting = "lksjdlk\nOnion";
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], multilineSting, true);
|
||||
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], multilineSting);
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test("when the ingredient matches partially exactly and has a reference id, return the referenceId", () => {
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onions", true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onions");
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test("when the ingredient matches with different casing and has a reference id, return the referenceId", () => {
|
||||
const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing oNions", true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing oNions");
|
||||
expect(result).toEqual(new Set(["123"]));
|
||||
});
|
||||
|
||||
test("when no ingredients, return empty", () => {
|
||||
const result = useExtractIngredientReferences([], [], "A sentence containing oNions", true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([], [], "A sentence containing oNions");
|
||||
expect(result).toEqual(new Set());
|
||||
});
|
||||
|
||||
test("when and ingredient matches but in the existing referenceIds, do not return the referenceId", () => {
|
||||
const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], ["123"], "A sentence containing Onion", true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onion", referenceId: "123" }], ["123"], "A sentence containing Onion");
|
||||
|
||||
expect(result).toEqual(new Set());
|
||||
});
|
||||
|
||||
test("when an word is 2 letter of shorter, it is ignored", () => {
|
||||
const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], [], "A sentence containing On", true);
|
||||
const { extractIngredientReferences } = useExtractIngredientReferences();
|
||||
const result = extractIngredientReferences([{ note: "Onion", referenceId: "123" }], [], "A sentence containing On");
|
||||
|
||||
expect(result).toEqual(new Set());
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import { useIngredientTextParser } from "~/composables/recipes";
|
||||
|
||||
function normalize(word: string): string {
|
||||
let normalizing = word;
|
||||
@@ -18,11 +18,6 @@ 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
|
||||
@@ -39,20 +34,33 @@ function isBlackListedWord(word: string) {
|
||||
return blackListedText.includes(word) || word.match(blackListedRegexMatch);
|
||||
}
|
||||
|
||||
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));
|
||||
export function useExtractIngredientReferences() {
|
||||
const { parseIngredientText } = useIngredientTextParser();
|
||||
|
||||
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
|
||||
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());
|
||||
}
|
||||
|
||||
return new Set<string>(allMatchedIngredientIds);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { useFraction } from "./use-fraction";
|
||||
export { useRecipe } from "./use-recipe";
|
||||
export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes";
|
||||
export { parseIngredientText, useParsedIngredientText } from "./use-recipe-ingredients";
|
||||
export { useIngredientTextParser } from "./use-recipe-ingredients";
|
||||
export { useNutritionLabels } from "./use-recipe-nutrition";
|
||||
export { useTools } from "./use-recipe-tools";
|
||||
export { useRecipePermissions } from "./use-recipe-permissions";
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { parseIngredientText } from "./use-recipe-ingredients";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
import { useIngredientTextParser } 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: {
|
||||
@@ -128,4 +141,98 @@ describe(parseIngredientText.name, () => {
|
||||
|
||||
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("< 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("< <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("< 1/10 cup salt");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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 },
|
||||
@@ -56,47 +60,90 @@ type ParsedIngredientText = {
|
||||
recipeLink?: string;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
let returnQty = "";
|
||||
|
||||
// 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];
|
||||
}
|
||||
|
||||
if (fraction[1] > 0) {
|
||||
returnQty += includeFormating
|
||||
? `<sup>${fraction[1]}</sup><span>⁄</span><sub>${fraction[2]}</sub>`
|
||||
: ` ${fraction[1]}/${fraction[2]}`;
|
||||
}
|
||||
}
|
||||
function shouldUsePluralFood(quantity: number, hasUnit: boolean, pluralFoodHandling: string): boolean {
|
||||
if (quantity && quantity <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const unitName = useUnitName(unit || undefined, usePluralUnit);
|
||||
const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood);
|
||||
switch (pluralFoodHandling) {
|
||||
case "always":
|
||||
return true;
|
||||
case "without-unit":
|
||||
return !(quantity && hasUnit);
|
||||
case "never":
|
||||
return false;
|
||||
|
||||
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}`;
|
||||
}
|
||||
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>⁄</span><sub>${fraction[2]}</sub>`
|
||||
: ` ${fraction[1]}/${fraction[2]}`;
|
||||
}
|
||||
|
||||
if (isUnderMinVal) {
|
||||
returnQty = `< ${returnQty}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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),
|
||||
useParsedIngredientText,
|
||||
parseIngredientText,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,13 @@ export const useGroupRecipeActions = function (
|
||||
};
|
||||
|
||||
const actions = {
|
||||
...useStoreActions<GroupRecipeActionOut>("group-recipe-actions", api.groupRecipeActions, groupRecipeActions, loading),
|
||||
...useStoreActions<GroupRecipeActionOut>(
|
||||
"group-recipe-actions",
|
||||
api.groupRecipeActions,
|
||||
groupRecipeActions,
|
||||
loading,
|
||||
{ orderBy: orderBy },
|
||||
),
|
||||
flushStore() {
|
||||
groupRecipeActions.value = [];
|
||||
},
|
||||
|
||||
@@ -5,251 +5,293 @@ 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,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "never",
|
||||
},
|
||||
{
|
||||
name: "Українська (Ukrainian)",
|
||||
value: "uk-UA",
|
||||
progress: 83,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Türkçe (Turkish)",
|
||||
value: "tr-TR",
|
||||
progress: 40,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "never",
|
||||
},
|
||||
{
|
||||
name: "Svenska (Swedish)",
|
||||
value: "sv-SE",
|
||||
progress: 61,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "српски (Serbian)",
|
||||
value: "sr-SP",
|
||||
progress: 9,
|
||||
progress: 16,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Slovenščina (Slovenian)",
|
||||
value: "sl-SI",
|
||||
progress: 40,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Slovenčina (Slovak)",
|
||||
value: "sk-SK",
|
||||
progress: 47,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Pусский (Russian)",
|
||||
value: "ru-RU",
|
||||
progress: 44,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Română (Romanian)",
|
||||
value: "ro-RO",
|
||||
progress: 44,
|
||||
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,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Norsk (Norwegian)",
|
||||
value: "no-NO",
|
||||
progress: 42,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Nederlands (Dutch)",
|
||||
value: "nl-NL",
|
||||
progress: 54,
|
||||
progress: 60,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Latviešu (Latvian)",
|
||||
value: "lv-LV",
|
||||
progress: 35,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Lietuvių (Lithuanian)",
|
||||
value: "lt-LT",
|
||||
progress: 30,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "한국어 (Korean)",
|
||||
value: "ko-KR",
|
||||
progress: 38,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "never",
|
||||
},
|
||||
{
|
||||
name: "日本語 (Japanese)",
|
||||
value: "ja-JP",
|
||||
progress: 36,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "never",
|
||||
},
|
||||
{
|
||||
name: "Italiano (Italian)",
|
||||
value: "it-IT",
|
||||
progress: 49,
|
||||
progress: 52,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Íslenska (Icelandic)",
|
||||
value: "is-IS",
|
||||
progress: 43,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Magyar (Hungarian)",
|
||||
value: "hu-HU",
|
||||
progress: 46,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Hrvatski (Croatian)",
|
||||
value: "hr-HR",
|
||||
progress: 30,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "עברית (Hebrew)",
|
||||
value: "he-IL",
|
||||
progress: 64,
|
||||
dir: "rtl",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Galego (Galician)",
|
||||
value: "gl-ES",
|
||||
progress: 38,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Français (French)",
|
||||
value: "fr-FR",
|
||||
progress: 67,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Français canadien (Canadian French)",
|
||||
value: "fr-CA",
|
||||
progress: 83,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Belge (Belgian)",
|
||||
value: "fr-BE",
|
||||
progress: 39,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Suomi (Finnish)",
|
||||
value: "fi-FI",
|
||||
progress: 40,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Eesti (Estonian)",
|
||||
value: "et-EE",
|
||||
progress: 44,
|
||||
progress: 45,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Español (Spanish)",
|
||||
value: "es-ES",
|
||||
progress: 45,
|
||||
progress: 46,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "American English",
|
||||
value: "en-US",
|
||||
progress: 100.0,
|
||||
progress: 100,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "without-unit",
|
||||
},
|
||||
{
|
||||
name: "British English",
|
||||
value: "en-GB",
|
||||
progress: 42,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "without-unit",
|
||||
},
|
||||
{
|
||||
name: "Ελληνικά (Greek)",
|
||||
value: "el-GR",
|
||||
progress: 41,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Deutsch (German)",
|
||||
value: "de-DE",
|
||||
progress: 83,
|
||||
progress: 85,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Dansk (Danish)",
|
||||
value: "da-DK",
|
||||
progress: 63,
|
||||
progress: 65,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Čeština (Czech)",
|
||||
value: "cs-CZ",
|
||||
progress: 43,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Català (Catalan)",
|
||||
value: "ca-ES",
|
||||
progress: 40,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Български (Bulgarian)",
|
||||
value: "bg-BG",
|
||||
progress: 49,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "العربية (Arabic)",
|
||||
value: "ar-SA",
|
||||
progress: 25,
|
||||
dir: "rtl",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
{
|
||||
name: "Afrikaans (Afrikaans)",
|
||||
value: "af-ZA",
|
||||
progress: 26,
|
||||
dir: "ltr",
|
||||
pluralFoodHandling: "always",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { LocaleObject } from "@nuxtjs/i18n";
|
||||
import { LOCALES } from "./available-locales";
|
||||
import { useGlobalI18n } from "../use-global-i18n";
|
||||
|
||||
export const useLocales = () => {
|
||||
const i18n = useI18n();
|
||||
const i18n = useGlobalI18n();
|
||||
const { current: vuetifyLocale } = useLocale();
|
||||
|
||||
const locale = computed<LocaleObject["code"]>({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import type { LogicalOperator, RecipeOrganizer, RelationalKeyword, RelationalOperator } from "~/lib/api/types/non-generated";
|
||||
import type { LogicalOperator, PlaceholderKeyword, RecipeOrganizer, RelationalKeyword, RelationalOperator } from "~/lib/api/types/non-generated";
|
||||
|
||||
export interface FieldLogicalOperator {
|
||||
label: string;
|
||||
@@ -11,6 +11,11 @@ export interface FieldRelationalOperator {
|
||||
value: RelationalKeyword | RelationalOperator;
|
||||
}
|
||||
|
||||
export interface FieldPlaceholderKeyword {
|
||||
label: string;
|
||||
value: PlaceholderKeyword;
|
||||
}
|
||||
|
||||
export interface OrganizerBase {
|
||||
id: string;
|
||||
slug: string;
|
||||
@@ -22,6 +27,7 @@ export type FieldType
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| "relativeDate"
|
||||
| RecipeOrganizer;
|
||||
|
||||
export type FieldValue
|
||||
@@ -41,8 +47,8 @@ export interface FieldDefinition {
|
||||
label: string;
|
||||
type: FieldType;
|
||||
|
||||
// only for select/organizer fields
|
||||
fieldOptions?: SelectableItem[];
|
||||
// Select/Organizer
|
||||
fieldChoices?: SelectableItem[];
|
||||
}
|
||||
|
||||
export interface Field extends FieldDefinition {
|
||||
@@ -50,10 +56,10 @@ export interface Field extends FieldDefinition {
|
||||
logicalOperator?: FieldLogicalOperator;
|
||||
value: FieldValue;
|
||||
relationalOperatorValue: FieldRelationalOperator;
|
||||
relationalOperatorOptions: FieldRelationalOperator[];
|
||||
relationalOperatorChoices: FieldRelationalOperator[];
|
||||
rightParenthesis?: string;
|
||||
|
||||
// only for select/organizer fields
|
||||
// Select/Organizer
|
||||
values: FieldValue[];
|
||||
organizers: OrganizerBase[];
|
||||
}
|
||||
@@ -161,6 +167,36 @@ 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
|
||||
@@ -173,10 +209,14 @@ export function useQueryFilterBuilder() {
|
||||
};
|
||||
|
||||
function getFieldFromFieldDef(field: Field | FieldDefinition, resetValue = false): Field {
|
||||
const updatedField = { logicalOperator: logOps.value.AND, ...field } as Field;
|
||||
let operatorOptions: FieldRelationalOperator[];
|
||||
if (updatedField.fieldOptions?.length || isOrganizerType(updatedField.type)) {
|
||||
operatorOptions = [
|
||||
const updatedField = {
|
||||
logicalOperator: logOps.value.AND,
|
||||
...field,
|
||||
} as Field;
|
||||
|
||||
let operatorChoices: FieldRelationalOperator[];
|
||||
if (updatedField.fieldChoices?.length || isOrganizerType(updatedField.type)) {
|
||||
operatorChoices = [
|
||||
relOps.value["IN"],
|
||||
relOps.value["NOT IN"],
|
||||
relOps.value["CONTAINS ALL"],
|
||||
@@ -185,7 +225,7 @@ export function useQueryFilterBuilder() {
|
||||
else {
|
||||
switch (updatedField.type) {
|
||||
case "string":
|
||||
operatorOptions = [
|
||||
operatorChoices = [
|
||||
relOps.value["="],
|
||||
relOps.value["<>"],
|
||||
relOps.value["LIKE"],
|
||||
@@ -193,7 +233,7 @@ export function useQueryFilterBuilder() {
|
||||
];
|
||||
break;
|
||||
case "number":
|
||||
operatorOptions = [
|
||||
operatorChoices = [
|
||||
relOps.value["="],
|
||||
relOps.value["<>"],
|
||||
relOps.value[">"],
|
||||
@@ -203,10 +243,10 @@ export function useQueryFilterBuilder() {
|
||||
];
|
||||
break;
|
||||
case "boolean":
|
||||
operatorOptions = [relOps.value["="]];
|
||||
operatorChoices = [relOps.value["="]];
|
||||
break;
|
||||
case "date":
|
||||
operatorOptions = [
|
||||
operatorChoices = [
|
||||
relOps.value["="],
|
||||
relOps.value["<>"],
|
||||
relOps.value[">"],
|
||||
@@ -215,13 +255,20 @@ 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:
|
||||
operatorOptions = [relOps.value["="], relOps.value["<>"]];
|
||||
operatorChoices = [relOps.value["="], relOps.value["<>"]];
|
||||
}
|
||||
}
|
||||
updatedField.relationalOperatorOptions = operatorOptions;
|
||||
if (!operatorOptions.includes(updatedField.relationalOperatorValue)) {
|
||||
updatedField.relationalOperatorValue = operatorOptions[0];
|
||||
updatedField.relationalOperatorChoices = operatorChoices;
|
||||
if (!operatorChoices.includes(updatedField.relationalOperatorValue)) {
|
||||
updatedField.relationalOperatorValue = operatorChoices[0];
|
||||
}
|
||||
|
||||
if (resetValue) {
|
||||
@@ -271,7 +318,7 @@ export function useQueryFilterBuilder() {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
if (field.fieldChoices?.length || isOrganizerType(field.type)) {
|
||||
if (field.values?.length) {
|
||||
let val: string;
|
||||
if (field.type === "string" || field.type === "date" || isOrganizerType(field.type)) {
|
||||
@@ -316,7 +363,8 @@ export function useQueryFilterBuilder() {
|
||||
|
||||
return {
|
||||
logOps,
|
||||
relOps,
|
||||
placeholderKeywords,
|
||||
getRelOps,
|
||||
buildQueryFilterString,
|
||||
getFieldFromFieldDef,
|
||||
isOrganizerType,
|
||||
|
||||
117
frontend/composables/use-search.ts
Normal file
117
frontend/composables/use-search.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fieldTypes } from "../forms";
|
||||
import { validators } from "../use-validators";
|
||||
import type { AutoFormItems } from "~/types/auto-forms";
|
||||
|
||||
export const useCommonSettingsForm = () => {
|
||||
@@ -11,7 +12,7 @@ export const useCommonSettingsForm = () => {
|
||||
hint: i18n.t("group.enable-public-access-description"),
|
||||
varName: "makeGroupRecipesPublic",
|
||||
type: fieldTypes.BOOLEAN,
|
||||
rules: ["required"],
|
||||
rules: [validators.required],
|
||||
},
|
||||
{
|
||||
section: i18n.t("data-pages.data-management"),
|
||||
@@ -19,7 +20,7 @@ export const useCommonSettingsForm = () => {
|
||||
hint: i18n.t("user-registration.use-seed-data-description"),
|
||||
varName: "useSeedData",
|
||||
type: fieldTypes.BOOLEAN,
|
||||
rules: ["required"],
|
||||
rules: [validators.required],
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fieldTypes } from "../forms";
|
||||
import { validators } from "../use-validators";
|
||||
import type { AutoFormItems } from "~/types/auto-forms";
|
||||
|
||||
export const useUserForm = () => {
|
||||
@@ -10,28 +11,30 @@ export const useUserForm = () => {
|
||||
label: i18n.t("user.user-name"),
|
||||
varName: "username",
|
||||
type: fieldTypes.TEXT,
|
||||
rules: ["required"],
|
||||
rules: [validators.required],
|
||||
},
|
||||
{
|
||||
label: i18n.t("user.full-name"),
|
||||
varName: "fullName",
|
||||
type: fieldTypes.TEXT,
|
||||
rules: ["required"],
|
||||
rules: [validators.required],
|
||||
},
|
||||
{
|
||||
label: i18n.t("user.email"),
|
||||
varName: "email",
|
||||
type: fieldTypes.TEXT,
|
||||
rules: ["required"],
|
||||
rules: [validators.required],
|
||||
},
|
||||
{
|
||||
cols: 6,
|
||||
label: i18n.t("user.password"),
|
||||
varName: "password",
|
||||
disableUpdate: true,
|
||||
type: fieldTypes.PASSWORD,
|
||||
rules: ["required", "minLength:8"],
|
||||
rules: [validators.required, validators.minLength(8)],
|
||||
},
|
||||
{
|
||||
cols: 6,
|
||||
label: i18n.t("user.authentication-method"),
|
||||
varName: "authMethod",
|
||||
type: fieldTypes.SELECT,
|
||||
@@ -41,40 +44,46 @@ export const useUserForm = () => {
|
||||
},
|
||||
{
|
||||
section: i18n.t("user.permissions"),
|
||||
cols: 6,
|
||||
label: i18n.t("user.administrator"),
|
||||
varName: "admin",
|
||||
type: fieldTypes.BOOLEAN,
|
||||
rules: ["required"],
|
||||
rules: [validators.required],
|
||||
},
|
||||
{
|
||||
cols: 6,
|
||||
label: i18n.t("user.user-can-invite-other-to-group"),
|
||||
varName: "canInvite",
|
||||
type: fieldTypes.BOOLEAN,
|
||||
rules: ["required"],
|
||||
rules: [validators.required],
|
||||
},
|
||||
{
|
||||
cols: 6,
|
||||
label: i18n.t("user.user-can-manage-group"),
|
||||
varName: "canManage",
|
||||
type: fieldTypes.BOOLEAN,
|
||||
rules: ["required"],
|
||||
rules: [validators.required],
|
||||
},
|
||||
{
|
||||
cols: 6,
|
||||
label: i18n.t("user.user-can-organize-group-data"),
|
||||
varName: "canOrganize",
|
||||
type: fieldTypes.BOOLEAN,
|
||||
rules: ["required"],
|
||||
rules: [validators.required],
|
||||
},
|
||||
{
|
||||
cols: 6,
|
||||
label: i18n.t("user.user-can-manage-household"),
|
||||
varName: "canManageHousehold",
|
||||
type: fieldTypes.BOOLEAN,
|
||||
rules: ["required"],
|
||||
rules: [validators.required],
|
||||
},
|
||||
{
|
||||
cols: 6,
|
||||
label: i18n.t("user.enable-advanced-features"),
|
||||
varName: "advanced",
|
||||
type: fieldTypes.BOOLEAN,
|
||||
rules: ["required"],
|
||||
rules: [validators.required],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -34,6 +34,9 @@ const normalizeLigatures = replaceAllBuilder(new Map([
|
||||
["st", "st"],
|
||||
]));
|
||||
|
||||
/**
|
||||
* @deprecated prefer fuse.js/use-search.ts
|
||||
*/
|
||||
export const normalize = (str: string) => {
|
||||
if (!str) {
|
||||
return "";
|
||||
@@ -45,6 +48,9 @@ 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);
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Recipe Actions Data",
|
||||
"new-recipe-action": "New Recipe Action",
|
||||
"edit-recipe-action": "Edit Recipe Action",
|
||||
"action-type": "Action Type"
|
||||
"action-type": "Action Type",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Create Alias",
|
||||
"manage-aliases": "Manage Aliases",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"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-less-than-or-equal-to": "is less than or equal to",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "is",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"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": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"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"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,7 +420,7 @@
|
||||
"recipe-1": "وصفة 1",
|
||||
"recipe-2": "وصفة 2",
|
||||
"paprika-text": "Mealie can import recipes from the Paprika application. Export your recipes from paprika, rename the export extension to .zip and upload it below.",
|
||||
"mealie-text": "Mealie can import recipes from the Mealie application from a pre v1.0 release. Export your recipes from your old instance, and upload the zip file below. Note that only recipes can be imported from the export.",
|
||||
"mealie-text": "يُمكن لبرنامج Mealie استيراد الوصفات من تطبيق Mealie من إصدار سابق للإصدار 1.0. صدّر وصفاتك من نسختك القديمة، ثم ارفع ملف ZIP أدناه. يُرجى العلم أنه لا يُمكن استيراد سوى الوصفات من الملف المُصدّر.",
|
||||
"plantoeat": {
|
||||
"title": "خِطَّة تناول الطعام",
|
||||
"description-long": "يمكن لمالي أن تستورد الوصفات من Plan to Eat."
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Recipe Actions Data",
|
||||
"new-recipe-action": "New Recipe Action",
|
||||
"edit-recipe-action": "Edit Recipe Action",
|
||||
"action-type": "Action Type"
|
||||
"action-type": "Action Type",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Create Alias",
|
||||
"manage-aliases": "إدارة الأسماء المستعارة",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "أكبر من",
|
||||
"is-greater-than-or-equal-to": "أكبر من أو يساوي",
|
||||
"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"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "هو",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "يحتوي على كل من",
|
||||
"is-like": "هو مثل",
|
||||
"is-not-like": "ليس مثل"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"invalid-url": "يجب أن يكون عنوان URL صالحًا",
|
||||
"no-whitespace": "لا يسمح باستخدام المسافات",
|
||||
"min-length": "يجب أن يكون على الأقل {min} أحرف",
|
||||
"max-length": "يجب أن يكون على الأكثر {max} أحرف"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,8 +370,8 @@
|
||||
"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"
|
||||
"add-all-to-list": "Добавяне на всички към списъка за пазаруване",
|
||||
"add-day-to-list": "Добавяне на ден към списъка за пазаруване"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Данните за мигриране са премахнати",
|
||||
@@ -644,7 +644,7 @@
|
||||
"scrape-recipe-website-being-blocked": "Блокиран ли е уебсайтът?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Опитайте вместо това да импортирате суровия HTML код.",
|
||||
"import-original-keywords-as-tags": "Добави оригиналните ключови думи като етикети",
|
||||
"import-original-categories": "Import original categories",
|
||||
"import-original-categories": "Импортиране на оригиналните категории",
|
||||
"stay-in-edit-mode": "Остани в режим на редакция",
|
||||
"parse-recipe-ingredients-after-import": "Анализиране на съставките на рецептата след импортиране",
|
||||
"import-from-zip": "Импортирай от Zip",
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Данни за действия с рецепти",
|
||||
"new-recipe-action": "Ново действие с рецепта",
|
||||
"edit-recipe-action": "Редактиране на действието с рецепта",
|
||||
"action-type": "Вид действие"
|
||||
"action-type": "Вид действие",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Създаване на псевдоним",
|
||||
"manage-aliases": "Управление на псевдоними",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "е по-голямо от",
|
||||
"is-greater-than-or-equal-to": "е по-голямо от или равно на",
|
||||
"is-less-than": "е по-малко от",
|
||||
"is-less-than-or-equal-to": "e по-малко или равно на"
|
||||
"is-less-than-or-equal-to": "e по-малко или равно на",
|
||||
"is-older-than": "е по-стар от",
|
||||
"is-newer-than": "е по-нов от"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "е",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "съдържа всички от",
|
||||
"is-like": "е като",
|
||||
"is-not-like": "не е като"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "преди дни|преди ден|преди дни"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"invalid-url": "Линкът трябва да е валиден",
|
||||
"no-whitespace": "Не са позволени интервали",
|
||||
"min-length": "Трябва да съдържа поне {min} знака",
|
||||
"max-length": "Трябва да съдържа най-много {max} знака"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Dades d'accions de receptes",
|
||||
"new-recipe-action": "Nova acció de receptes",
|
||||
"edit-recipe-action": "Edita acció de receptes",
|
||||
"action-type": "Tipus d'acció"
|
||||
"action-type": "Tipus d'acció",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Crea un àlies",
|
||||
"manage-aliases": "Gestiona els àlies",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "és més gran que",
|
||||
"is-greater-than-or-equal-to": "és més gran o igual a",
|
||||
"is-less-than": "és menys que",
|
||||
"is-less-than-or-equal-to": "és menor o igual a"
|
||||
"is-less-than-or-equal-to": "és menor o igual a",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "és",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "conté tots de",
|
||||
"is-like": "és com",
|
||||
"is-not-like": "no és com"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"invalid-url": "La URL ha de ser vàlida",
|
||||
"no-whitespace": "No es permeten espais en blanc",
|
||||
"min-length": "Ha de tenir almenys {min} caràcters",
|
||||
"max-length": "Ha de tenir com a màxim {max} caràcters"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,8 @@
|
||||
"upload-file": "Nahrát soubor",
|
||||
"created-on-date": "Vytvořeno dne: {0}",
|
||||
"unsaved-changes": "Máte neuložené změny. Chcete je uložit před odchodem? Klikněte Okay pro uložení, Cancel pro smazání změn.",
|
||||
"discard-changes": "Discard Changes",
|
||||
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"discard-changes": "Zahodit změny",
|
||||
"discard-changes-description": "Máte neuložené změny. Určitě je chcete zahodit?",
|
||||
"clipboard-copy-failure": "Zkopírování do schránky se nezdařilo.",
|
||||
"confirm-delete-generic-items": "Opravdu chcete smazat následující položky?",
|
||||
"organizers": "Organizace",
|
||||
@@ -370,8 +370,8 @@
|
||||
"applies-to-all-days": "Použije se na všechny dny",
|
||||
"applies-on-days": "Platí pro {0}",
|
||||
"meal-plan-settings": "Nastavení jídelníčku",
|
||||
"add-all-to-list": "Add All to List",
|
||||
"add-day-to-list": "Add Day to List"
|
||||
"add-all-to-list": "Přidat vše do seznamu",
|
||||
"add-day-to-list": "Přidat den do seznamu"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Data z migrace byla smazána",
|
||||
@@ -644,7 +644,7 @@
|
||||
"scrape-recipe-website-being-blocked": "Webové stránky jsou blokovány?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Zkuste namísto toho importovat raw HTML.",
|
||||
"import-original-keywords-as-tags": "Importovat původní klíčová slova jako štítky",
|
||||
"import-original-categories": "Import original categories",
|
||||
"import-original-categories": "Importovat původní kategorie",
|
||||
"stay-in-edit-mode": "Zůstat v režimu úprav",
|
||||
"parse-recipe-ingredients-after-import": "Po importu analyzovat ingredience receptu",
|
||||
"import-from-zip": "Importovat ze zipu",
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Data akcí receptu",
|
||||
"new-recipe-action": "Nová akce receptu",
|
||||
"edit-recipe-action": "Upravit akci receptu",
|
||||
"action-type": "Typ akce"
|
||||
"action-type": "Typ akce",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Vytvořit alias",
|
||||
"manage-aliases": "Spravovat aliasy",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "je větší než",
|
||||
"is-greater-than-or-equal-to": "je větší než nebo rovno",
|
||||
"is-less-than": "je menší než",
|
||||
"is-less-than-or-equal-to": "je menší než nebo rovno"
|
||||
"is-less-than-or-equal-to": "je menší než nebo rovno",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "je",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "obsahuje všechny z",
|
||||
"is-like": "je jako",
|
||||
"is-not-like": "není jako"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"invalid-url": "Musí být platná URL adresa",
|
||||
"no-whitespace": "Mezery nejsou povoleny",
|
||||
"min-length": "Musí být alespoň {min} znaků",
|
||||
"max-length": "Musí být nejvíce {max} znaků"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1086,7 +1086,7 @@
|
||||
"forgot-password": "Glemt adgangskode",
|
||||
"forgot-password-text": "Indtast venligst din e-mail-adresse. Vi sender dig en e-mail, så at du kan nulstille din adgangskode.",
|
||||
"changes-reflected-immediately": "Ændringer til denne bruger vil have effekt med det samme.",
|
||||
"default-activity": "",
|
||||
"default-activity": "Standardaktivitet",
|
||||
"default-activity-hint": "Vælg den side, du vil navigere til, når du logger ind fra denne enhed"
|
||||
},
|
||||
"language-dialog": {
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Opskriftshandlinger",
|
||||
"new-recipe-action": "Ny opskriftshandling",
|
||||
"edit-recipe-action": "Rediger opskriftshandling",
|
||||
"action-type": "Handlingstype"
|
||||
"action-type": "Handlingstype",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Opslag"
|
||||
}
|
||||
},
|
||||
"create-alias": "Opret alias",
|
||||
"manage-aliases": "Administrer aliaser",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "er større end",
|
||||
"is-greater-than-or-equal-to": "er større end eller lig med (Automatic Translation)",
|
||||
"is-less-than": "er mindre end (Automatic Translation)",
|
||||
"is-less-than-or-equal-to": "er mindre end eller lig med (Automatic Translation)"
|
||||
"is-less-than-or-equal-to": "er mindre end eller lig med (Automatic Translation)",
|
||||
"is-older-than": "er ældre end",
|
||||
"is-newer-than": "er nyere end"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "er",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "indeholder alle af",
|
||||
"is-like": "er ligesom",
|
||||
"is-not-like": "er ikke som"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "dage siden|dag siden|dage siden"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"invalid-url": "URL'en skal være gyldig",
|
||||
"no-whitespace": "Mellemrum er ikke tilladt",
|
||||
"min-length": "Der skal mindst være {min} tegn",
|
||||
"max-length": "Der må højst være {max} tegn"
|
||||
"max-length": "Må højst være {max} tegn|Må højst være {max} tegn"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Rezept-Aktionen Daten",
|
||||
"new-recipe-action": "Neue Rezept-Aktion",
|
||||
"edit-recipe-action": "Rezept-Aktion bearbeiten",
|
||||
"action-type": "Aktionstyp"
|
||||
"action-type": "Aktionstyp",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Alias erstellen",
|
||||
"manage-aliases": "Aliasse verwalten",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "ist größer als",
|
||||
"is-greater-than-or-equal-to": "ist größer gleich",
|
||||
"is-less-than": "ist weniger als",
|
||||
"is-less-than-or-equal-to": "ist kleiner gleich"
|
||||
"is-less-than-or-equal-to": "ist kleiner gleich",
|
||||
"is-older-than": "Ist älter als",
|
||||
"is-newer-than": "Ist neuer als"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "ist",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "enthält alle",
|
||||
"is-like": "ist wie",
|
||||
"is-not-like": "ist nicht wie"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "vor Tagen|vor Tag|vor Tagen"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"invalid-url": "Muss eine gültige URL sein",
|
||||
"no-whitespace": "Kein Leerzeichen erlaubt",
|
||||
"min-length": "Muss mindestens {min} Zeichen haben",
|
||||
"max-length": "Darf mindestens {max} Zeichen haben"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Δεδομένα Ενεργειών Συνταγής",
|
||||
"new-recipe-action": "Νέα Ενέργεια Συνταγής",
|
||||
"edit-recipe-action": "Επεξεργασία Ενέργειας Συνταγής",
|
||||
"action-type": "Τύπος Ενέργειας"
|
||||
"action-type": "Τύπος Ενέργειας",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Δημιουργία ψευδώνυμου",
|
||||
"manage-aliases": "Διαχείριση ψευδωνύμων",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "είναι μεγαλύτερο από",
|
||||
"is-greater-than-or-equal-to": "είναι μεγαλύτερο από ή ίσο με",
|
||||
"is-less-than": "είναι μικρότερο από",
|
||||
"is-less-than-or-equal-to": "είναι μικρότερο από ή ίσο με"
|
||||
"is-less-than-or-equal-to": "είναι μικρότερο από ή ίσο με",
|
||||
"is-older-than": "είναι παλαιότερο από",
|
||||
"is-newer-than": "είναι νεότερο από"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "είναι",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "περιέχει όλα τα",
|
||||
"is-like": "είναι όμοιο με",
|
||||
"is-not-like": "δεν είναι όμοιο με"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "ημέρες πριν|ημέρα πριν|ημέρες πριν"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"invalid-url": "Πρέπει να είναι μια έγκυρη διεύθυνση URL",
|
||||
"no-whitespace": "Δεν επιτρέπονται κενοί χαρακτήρες",
|
||||
"min-length": "Πρέπει να αποτελείται από τουλάχιστον {min} χαρακτήρες",
|
||||
"max-length": "Πρέπει να αποτελείται το πολύ από {max} χαρακτήρες"
|
||||
"max-length": "Πρέπει να αποτελείται το πολύ από {max} χαρακτήρα|Πρέπει να αποτελείται το πολύ από {max} χαρακτήρες"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Recipe Actions Data",
|
||||
"new-recipe-action": "New Recipe Action",
|
||||
"edit-recipe-action": "Edit Recipe Action",
|
||||
"action-type": "Action Type"
|
||||
"action-type": "Action Type",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Create Alias",
|
||||
"manage-aliases": "Manage Aliases",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"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-less-than-or-equal-to": "is less than or equal to",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "is",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"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": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"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"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Recipe Actions Data",
|
||||
"new-recipe-action": "New Recipe Action",
|
||||
"edit-recipe-action": "Edit Recipe Action",
|
||||
"action-type": "Action Type"
|
||||
"action-type": "Action Type",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Create Alias",
|
||||
"manage-aliases": "Manage Aliases",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"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-less-than-or-equal-to": "is less than or equal to",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "is",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"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": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"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"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,8 +64,8 @@
|
||||
"scheduled": "Programado",
|
||||
"something-went-wrong": "¡Algo ha salido mal!",
|
||||
"subscribed-events": "Eventos suscritos",
|
||||
"test-message-sent": "Mensaje Enviado",
|
||||
"message-sent": "Mensaje Enviado",
|
||||
"test-message-sent": "Mensaje enviado",
|
||||
"message-sent": "Mensaje de prueba enviado",
|
||||
"new-notification": "Nueva notificación",
|
||||
"event-notifiers": "Notificaciones de eventos",
|
||||
"apprise-url-skipped-if-blank": "URL de Apprise (omitida si está en blanco)",
|
||||
@@ -212,8 +212,8 @@
|
||||
"upload-file": "Subir Archivo",
|
||||
"created-on-date": "Creado el {0}",
|
||||
"unsaved-changes": "Tienes cambios sin guardar. ¿Quieres guardar antes de salir? Aceptar para guardar, Cancelar para descartar cambios.",
|
||||
"discard-changes": "Discard Changes",
|
||||
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"discard-changes": "Descartar Cambios",
|
||||
"discard-changes-description": "Tiene cambios sin guardar. ¿Está seguro que desea descartarlos?",
|
||||
"clipboard-copy-failure": "No se pudo copiar al portapapeles.",
|
||||
"confirm-delete-generic-items": "¿Estás seguro que quieres eliminar los siguientes elementos?",
|
||||
"organizers": "Organizadores",
|
||||
@@ -370,8 +370,8 @@
|
||||
"applies-to-all-days": "Aplica para todos los días",
|
||||
"applies-on-days": "Se aplica en {0}s",
|
||||
"meal-plan-settings": "Configuración del Plan de Comidas",
|
||||
"add-all-to-list": "Add All to List",
|
||||
"add-day-to-list": "Add Day to List"
|
||||
"add-all-to-list": "Añadir todos a la lista",
|
||||
"add-day-to-list": "Añadir Día a la Lista"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Datos de migración eliminados",
|
||||
@@ -644,7 +644,7 @@
|
||||
"scrape-recipe-website-being-blocked": "¿Sitio web bloqueado?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Intenta importar el HTML en bruto.",
|
||||
"import-original-keywords-as-tags": "Importar palabras clave originales como etiquetas",
|
||||
"import-original-categories": "Import original categories",
|
||||
"import-original-categories": "Importar categorías originales",
|
||||
"stay-in-edit-mode": "Permanecer en modo edición",
|
||||
"parse-recipe-ingredients-after-import": "Analizar los ingredientes de la receta después de importarla",
|
||||
"import-from-zip": "Importar desde zip",
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Datos de Acciones de Receta",
|
||||
"new-recipe-action": "Nueva Acción de Receta",
|
||||
"edit-recipe-action": "Editar Acción de Receta",
|
||||
"action-type": "Tipo de Acción"
|
||||
"action-type": "Tipo de Acción",
|
||||
"action-types": {
|
||||
"link": "Enlace",
|
||||
"post": "Publicar"
|
||||
}
|
||||
},
|
||||
"create-alias": "Crear un Alias",
|
||||
"manage-aliases": "Administrar Alias",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "es mayor que",
|
||||
"is-greater-than-or-equal-to": "es mayor que o igual a",
|
||||
"is-less-than": "es menor que",
|
||||
"is-less-than-or-equal-to": "es menor que o igual a"
|
||||
"is-less-than-or-equal-to": "es menor que o igual a",
|
||||
"is-older-than": "es anterior a",
|
||||
"is-newer-than": "es más reciente que"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "es",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "contiene todo de",
|
||||
"is-like": "es como",
|
||||
"is-not-like": "no es como"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1439,7 +1448,7 @@
|
||||
"invalid-email": "El email debe ser válido",
|
||||
"invalid-url": "Debe ser una URL válida",
|
||||
"no-whitespace": "No se permiten espacios en blanco",
|
||||
"min-length": "Debe ser como mínimo {min} caracteres",
|
||||
"max-length": "Debe ser como máximo {max} caracteres"
|
||||
"min-length": "Debe tener como mínimo {min} caracteres",
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Retsepti kasutusteave",
|
||||
"new-recipe-action": "Uus retsepti tegevus",
|
||||
"edit-recipe-action": "Muuda retsepti tegevust",
|
||||
"action-type": "Tegevuse tüüp"
|
||||
"action-type": "Tegevuse tüüp",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Loo alias",
|
||||
"manage-aliases": "Halda aliaseid",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "on suurem kui",
|
||||
"is-greater-than-or-equal-to": "on suurem või võrdne kui",
|
||||
"is-less-than": "on vähem kui",
|
||||
"is-less-than-or-equal-to": "on väiksem või võrdne kui"
|
||||
"is-less-than-or-equal-to": "on väiksem või võrdne kui",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "on",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "sisaldab kõiki",
|
||||
"is-like": "on nagu",
|
||||
"is-not-like": "ei ole nagu"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"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"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,18 @@
|
||||
"api-port": "API-portti",
|
||||
"application-mode": "Sovellustila",
|
||||
"database-type": "Tietokannan tyyppi",
|
||||
"database-url": "Tietokannan URL",
|
||||
"database-url": "Tietokannan URL-osoite",
|
||||
"default-group": "Oletusryhmä",
|
||||
"default-household": "Oletuskotitalous",
|
||||
"demo": "Demo",
|
||||
"demo-status": "Demon tila",
|
||||
"demo": "Esittelytila",
|
||||
"demo-status": "Esittelytila",
|
||||
"development": "Kehitys",
|
||||
"docs": "Dokumentit",
|
||||
"download-log": "Latausloki",
|
||||
"download-recipe-json": "Viimeisin haettu JSON",
|
||||
"github": "GitHub",
|
||||
"log-lines": "Lokirivit",
|
||||
"not-demo": "Ei demotilassa",
|
||||
"not-demo": "Ei käytössä",
|
||||
"portfolio": "Portfolio",
|
||||
"production": "Tuotanto",
|
||||
"support": "Tuki",
|
||||
@@ -138,7 +138,7 @@
|
||||
"print": "Tulosta",
|
||||
"print-preferences": "Tulostusasetukset",
|
||||
"random": "Satunnainen",
|
||||
"rating": "Arvio",
|
||||
"rating": "Arvosana",
|
||||
"recent": "Viimeisimmät",
|
||||
"recipe": "Resepti",
|
||||
"recipes": "Reseptit",
|
||||
@@ -153,7 +153,7 @@
|
||||
"sort": "Järjestä",
|
||||
"sort-ascending": "Järjestä nousevasti",
|
||||
"sort-descending": "Järjestä laskevasti",
|
||||
"sort-alphabetically": "Aakkosjärjestyksessä",
|
||||
"sort-alphabetically": "Aakkosjärjestys",
|
||||
"status": "Tila",
|
||||
"subject": "Aihe",
|
||||
"submit": "Lähetä",
|
||||
@@ -205,15 +205,15 @@
|
||||
"copied-to-clipboard": "Kopioitu leikepöydälle",
|
||||
"your-browser-does-not-support-clipboard": "Selaimesi ei tue leikepöytää",
|
||||
"copied-items-to-clipboard": "Mitään ei kopioitu leikepöydälle|Kohde kopioitu leikepöydälle|{count} kohdetta kopioitu leikepöydälle",
|
||||
"actions": "Toimet",
|
||||
"actions": "Toiminnot",
|
||||
"selected-count": "Valittu {count}",
|
||||
"export-all": "Vie kaikki",
|
||||
"refresh": "Päivitä",
|
||||
"upload-file": "Tuo tiedosto",
|
||||
"created-on-date": "Luotu {0}",
|
||||
"unsaved-changes": "Et ole tallentanut tekemiäsi muutoksia. Tallennetaanko ne? Paina \"ok\" tallentaaksesi ja \"peruuta\", jos et halua tallentaa.",
|
||||
"discard-changes": "Discard Changes",
|
||||
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"unsaved-changes": "Tallenna muutokset? ”Ok” tallentaa, ”Peruuta” hylkää muutokset.",
|
||||
"discard-changes": "Hylkää muutokset",
|
||||
"discard-changes-description": "Muutoksia ei ole tallennettu. Hylätäänkö muutokset?",
|
||||
"clipboard-copy-failure": "Kopioiminen leikepöydälle epäonnistui.",
|
||||
"confirm-delete-generic-items": "Haluatko varmasti poistaa seuraavat kohteet?",
|
||||
"organizers": "Järjestäjät",
|
||||
@@ -712,8 +712,8 @@
|
||||
"toggle-recipe": "Vaihda osio"
|
||||
},
|
||||
"recipe-finder": {
|
||||
"recipe-finder": "Reseptin etsijä",
|
||||
"recipe-finder-description": "Etsi sopivia reseptejä saatavilla olevien ainesosien perusteella. Voit myös suodattaa tulokset saatavilla olevien ruoanvalmistusvälineiden perusteella, ja asettaa enimmäismäärän puuttuvia ainesosia tai välineitä.",
|
||||
"recipe-finder": "Reseptihaku",
|
||||
"recipe-finder-description": "Etsi sopivia reseptejä saatavilla olevien ainesosien perusteella. Voit myös suodattaa tulokset saatavilla olevien keittiövälineiden perusteella, ja asettaa enimmäismäärän puuttuvia ainesosia tai välineitä.",
|
||||
"selected-ingredients": "Valitut ainesosat",
|
||||
"no-ingredients-selected": "Ei valittuja ainesosia",
|
||||
"missing": "Puuttuu",
|
||||
@@ -723,7 +723,7 @@
|
||||
"include-tools-on-hand": "Sisällytä saatavilla olevat välineet",
|
||||
"max-missing-ingredients": "Puuttuvien ainesten enimmäismäärä",
|
||||
"max-missing-tools": "Puuttuvien välineiden enimmäismäärä",
|
||||
"selected-tools": "Valitut välineet",
|
||||
"selected-tools": "Valitut keittiövälineet",
|
||||
"other-filters": "Muut suodattimet",
|
||||
"ready-to-make": "Valmis tekemään",
|
||||
"almost-ready-to-make": "Melkein valmis tekemään"
|
||||
@@ -980,14 +980,14 @@
|
||||
"tag": "Tunniste"
|
||||
},
|
||||
"tool": {
|
||||
"tools": "Työkalut",
|
||||
"on-hand": "Minulla on tämä työkalu",
|
||||
"create-a-tool": "Luo työkalu",
|
||||
"tool-name": "Työkalun Nimi",
|
||||
"create-new-tool": "Luo Uusi Työkalu",
|
||||
"on-hand-checkbox-label": "Näytä työkalut, jotka omistan jo (valittu)",
|
||||
"required-tools": "Tarvittavat Työkalut",
|
||||
"tool": "Työkalu"
|
||||
"tools": "Keittiövälineet",
|
||||
"on-hand": "Omistan välineen",
|
||||
"create-a-tool": "Lisää keittiöväline",
|
||||
"tool-name": "Keittiöväline",
|
||||
"create-new-tool": "Lisää keittiöväline",
|
||||
"on-hand-checkbox-label": "Näytä keittiövälineeni (valittu)",
|
||||
"required-tools": "Tarvittavat keittiövälineet",
|
||||
"tool": "Keittiöväline"
|
||||
},
|
||||
"user": {
|
||||
"admin": "Ylläpitäjä",
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Reseptin toimintojen käyttötiedot",
|
||||
"new-recipe-action": "Uusi reseptin toiminto",
|
||||
"edit-recipe-action": "Muuta reseptin toimintoa",
|
||||
"action-type": "Toiminnon tyyppi"
|
||||
"action-type": "Toiminnon tyyppi",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Luo alias",
|
||||
"manage-aliases": "Hallitse aliaksia",
|
||||
@@ -1191,9 +1195,9 @@
|
||||
"tag-data": "Tunnisteen tiedot"
|
||||
},
|
||||
"tools": {
|
||||
"new-tool": "Uusi työkalu",
|
||||
"edit-tool": "Muokkaa työkalua",
|
||||
"tool-data": "Työkalun tiedot"
|
||||
"new-tool": "Lisää keittiöväline",
|
||||
"edit-tool": "Muokkaa keittiövälinettä",
|
||||
"tool-data": "Keittiövälineen tiedot"
|
||||
}
|
||||
},
|
||||
"user-registration": {
|
||||
@@ -1239,7 +1243,7 @@
|
||||
"preview-markdown-button-label": "Esikatsele Markdownia"
|
||||
},
|
||||
"demo": {
|
||||
"info_message_with_version": "Tämä on demo: {version}",
|
||||
"info_message_with_version": "Mealie on esittelytilassa. Mealien versio: {version}",
|
||||
"demo_username": "Käyttäjätunnus: {username}",
|
||||
"demo_password": "Salasana: {password}"
|
||||
},
|
||||
@@ -1404,7 +1408,7 @@
|
||||
"filter-options-description": "Kun vaaditaan kaikki on valittu, keittokirja sisältää vain reseptejä, joissa on kaikki valitut tuotteet. Tämä koskee jokaista valitsimien osajoukkoa, ei valittujen kohteiden poikkileikkausta.",
|
||||
"require-all-categories": "Vaadi Kaikki Kategoriat",
|
||||
"require-all-tags": "Vaadi Kaikki Tunnisteet",
|
||||
"require-all-tools": "Vaadi Kaikki Työkalut",
|
||||
"require-all-tools": "Kaikki keittiövälineet tulee löytyä",
|
||||
"cookbook-name": "Keittokirjan Nimi",
|
||||
"cookbook-with-name": "Keittokirja {0}",
|
||||
"household-cookbook-name": "{0} Keittokirja {1}",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "on suurempi kuin",
|
||||
"is-greater-than-or-equal-to": "on suurempi tai yhtäsuuri kuin",
|
||||
"is-less-than": "on vähemmän kuin",
|
||||
"is-less-than-or-equal-to": "on vähemmän tai yhtäsuuri kuin"
|
||||
"is-less-than-or-equal-to": "on vähemmän tai yhtäsuuri kuin",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "on",
|
||||
@@ -1432,14 +1438,17 @@
|
||||
"contains-all-of": "sisältää kaikki nämä",
|
||||
"is-like": "on kuin",
|
||||
"is-not-like": "ei ole kuin"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
"required": "Tämä kenttä on pakollinen",
|
||||
"invalid-email": "Sähköpostiosoite ei ole kelvollinen",
|
||||
"invalid-url": "URL ei ole kelvollinen",
|
||||
"no-whitespace": "No Whitespace Allowed",
|
||||
"no-whitespace": "Tekstissä ei saa olla välilyöntejä",
|
||||
"min-length": "Vähimmäispituus on {min} merkkiä",
|
||||
"max-length": "Enimmäispituus on {max} merkkiä"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,8 @@
|
||||
"upload-file": "Transférer un fichier",
|
||||
"created-on-date": "Créé le {0}",
|
||||
"unsaved-changes": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer avant de partir ? OK pour enregistrer, Annuler pour ignorer les modifications.",
|
||||
"discard-changes": "Discard Changes",
|
||||
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"discard-changes": "Annuler les modifications",
|
||||
"discard-changes-description": "Vous avez des changements non sauvegardés. Êtes-vous sûr de vouloir les annuler ?",
|
||||
"clipboard-copy-failure": "Échec de la copie dans le presse-papiers.",
|
||||
"confirm-delete-generic-items": "Êtes-vous sûr de vouloir supprimer les éléments suivants ?",
|
||||
"organizers": "Classification",
|
||||
@@ -370,8 +370,8 @@
|
||||
"applies-to-all-days": "S'applique à tous les jours",
|
||||
"applies-on-days": "S'applique les {0}s",
|
||||
"meal-plan-settings": "Paramètres des menus",
|
||||
"add-all-to-list": "Add All to List",
|
||||
"add-day-to-list": "Add Day to List"
|
||||
"add-all-to-list": "Tout ajouter a une liste",
|
||||
"add-day-to-list": "Ajouter un jour à la liste"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Données de migration supprimées",
|
||||
@@ -644,7 +644,7 @@
|
||||
"scrape-recipe-website-being-blocked": "Le site web est bloqué ?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Essayez plutôt d'importer le code HTML brut.",
|
||||
"import-original-keywords-as-tags": "Importer les mots-clés d'origine en tant que tags",
|
||||
"import-original-categories": "Import original categories",
|
||||
"import-original-categories": "Importer les catégories originales",
|
||||
"stay-in-edit-mode": "Rester en mode édition",
|
||||
"parse-recipe-ingredients-after-import": "Analyser les ingrédients de la recette après l'import",
|
||||
"import-from-zip": "Importer depuis un zip",
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Données des actions de recette",
|
||||
"new-recipe-action": "Nouvelle action de recette",
|
||||
"edit-recipe-action": "Modifier l'action de recette",
|
||||
"action-type": "Type d'action"
|
||||
"action-type": "Type d'action",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Créer un alias",
|
||||
"manage-aliases": "Gérer les alias",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "est supérieur à",
|
||||
"is-greater-than-or-equal-to": "est plus grand que ou égal à",
|
||||
"is-less-than": "est inférieur à",
|
||||
"is-less-than-or-equal-to": "est inférieur ou égal à"
|
||||
"is-less-than-or-equal-to": "est inférieur ou égal à",
|
||||
"is-older-than": "est plus ancien que",
|
||||
"is-newer-than": "est plus récent que"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "est",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "contient tout",
|
||||
"is-like": "est comme",
|
||||
"is-not-like": "n'est pas similaire à"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "jours|jour|jours"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"invalid-url": "Doit être une URL valide",
|
||||
"no-whitespace": "Aucun espace n'est autorisé",
|
||||
"min-length": "Doit contenir au moins {min} caractères",
|
||||
"max-length": "Doit contenir au maximum {max} caractères"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,8 @@
|
||||
"upload-file": "Téléverser un fichier",
|
||||
"created-on-date": "Créé le {0}",
|
||||
"unsaved-changes": "Vous avez des modifications non enregistrées. Voulez-vous les enregistrer ? Ok pour enregistrer, annuler pour ignorer les modifications.",
|
||||
"discard-changes": "Discard Changes",
|
||||
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"discard-changes": "Annuler les modifications",
|
||||
"discard-changes-description": "Vous avez des changements non sauvegardés. Êtes-vous sûr de vouloir les annuler ?",
|
||||
"clipboard-copy-failure": "Échec de la copie vers le presse-papiers.",
|
||||
"confirm-delete-generic-items": "Êtes-vous sûr de vouloir supprimer les éléments suivants ?",
|
||||
"organizers": "Classification",
|
||||
@@ -370,8 +370,8 @@
|
||||
"applies-to-all-days": "S'applique à tous les jours",
|
||||
"applies-on-days": "S'applique les {0}s",
|
||||
"meal-plan-settings": "Paramètres des menus",
|
||||
"add-all-to-list": "Add All to List",
|
||||
"add-day-to-list": "Add Day to List"
|
||||
"add-all-to-list": "Ajouter tout à la liste",
|
||||
"add-day-to-list": "Ajouter un jour à la liste"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Données de migration supprimées",
|
||||
@@ -644,7 +644,7 @@
|
||||
"scrape-recipe-website-being-blocked": "Le site web est bloqué ?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Essayez plutôt d'importer le code HTML brut.",
|
||||
"import-original-keywords-as-tags": "Importer les mots-clés d'origine en tant que tags",
|
||||
"import-original-categories": "Import original categories",
|
||||
"import-original-categories": "Importer les catégories originales",
|
||||
"stay-in-edit-mode": "Rester en mode édition",
|
||||
"parse-recipe-ingredients-after-import": "Analyser les ingrédients de la recette après l'import",
|
||||
"import-from-zip": "Importer depuis un zip",
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Données des actions de recette",
|
||||
"new-recipe-action": "Nouvelle action de recette",
|
||||
"edit-recipe-action": "Modifier l'action de recette",
|
||||
"action-type": "Type d'action"
|
||||
"action-type": "Type d'action",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Créer un alias",
|
||||
"manage-aliases": "Gérer les alias",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "est supérieur à",
|
||||
"is-greater-than-or-equal-to": "est supérieur ou égal à",
|
||||
"is-less-than": "est inférieure à",
|
||||
"is-less-than-or-equal-to": "est inférieur ou égal à"
|
||||
"is-less-than-or-equal-to": "est inférieur ou égal à",
|
||||
"is-older-than": "est plus ancien que",
|
||||
"is-newer-than": "est plus récent que"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "est",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "contient tous les",
|
||||
"is-like": "est similaire à",
|
||||
"is-not-like": "n'est pas similaire à"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "jours|jour|jours"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"invalid-url": "Doit être une URL valide",
|
||||
"no-whitespace": "Aucun espace n'est autorisé",
|
||||
"min-length": "",
|
||||
"max-length": "Doit contenir au maximum {max} caractères"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,8 @@
|
||||
"upload-file": "Téléverser un fichier",
|
||||
"created-on-date": "Créé le {0}",
|
||||
"unsaved-changes": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer avant de partir ? OK pour enregistrer, Annuler pour ignorer les modifications.",
|
||||
"discard-changes": "Discard Changes",
|
||||
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"discard-changes": "Annuler les modifications",
|
||||
"discard-changes-description": "Vous avez des changements non sauvegardés. Êtes-vous sûr de vouloir les annuler ?",
|
||||
"clipboard-copy-failure": "Échec de la copie dans le presse-papiers.",
|
||||
"confirm-delete-generic-items": "Êtes-vous sûr de vouloir supprimer les éléments suivants ?",
|
||||
"organizers": "Classification",
|
||||
@@ -370,8 +370,8 @@
|
||||
"applies-to-all-days": "S'applique à tous les jours",
|
||||
"applies-on-days": "S'applique les {0}s",
|
||||
"meal-plan-settings": "Paramètres des menus",
|
||||
"add-all-to-list": "Add All to List",
|
||||
"add-day-to-list": "Add Day to List"
|
||||
"add-all-to-list": "Ajouter tout à la liste",
|
||||
"add-day-to-list": "Ajouter un jour à la liste"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Données de migration supprimées",
|
||||
@@ -644,7 +644,7 @@
|
||||
"scrape-recipe-website-being-blocked": "Le site web est bloqué ?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Essayez plutôt d'importer le code HTML brut.",
|
||||
"import-original-keywords-as-tags": "Importer les mots-clés d'origine en tant que tags",
|
||||
"import-original-categories": "Import original categories",
|
||||
"import-original-categories": "Importer les catégories originales",
|
||||
"stay-in-edit-mode": "Rester en mode édition",
|
||||
"parse-recipe-ingredients-after-import": "Analyser les ingrédients de la recette après l'import",
|
||||
"import-from-zip": "Importer depuis un zip",
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Données des actions de recette",
|
||||
"new-recipe-action": "Nouvelle action de recette",
|
||||
"edit-recipe-action": "Modifier l'action de recette",
|
||||
"action-type": "Type d'action"
|
||||
"action-type": "Type d'action",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Créer un alias",
|
||||
"manage-aliases": "Gérer les alias",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "est supérieur à",
|
||||
"is-greater-than-or-equal-to": "est plus grand que ou égal à",
|
||||
"is-less-than": "est inférieur à",
|
||||
"is-less-than-or-equal-to": "est inférieur ou égal à"
|
||||
"is-less-than-or-equal-to": "est inférieur ou égal à",
|
||||
"is-older-than": "est plus ancien que",
|
||||
"is-newer-than": "est plus récent que"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "est",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "contient tout",
|
||||
"is-like": "est comme",
|
||||
"is-not-like": "n'est pas similaire à"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "jours|jour|jours"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"invalid-url": "Doit être une URL valide",
|
||||
"no-whitespace": "Aucun espace n'est autorisé",
|
||||
"min-length": "Doit contenir au moins {min} caractères",
|
||||
"max-length": "Doit contenir au maximum {max} caractères"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Datos das Acións da Receita",
|
||||
"new-recipe-action": "Nova Ación da Receita",
|
||||
"edit-recipe-action": "Editar Ación da Receita",
|
||||
"action-type": "Tipo de Ación"
|
||||
"action-type": "Tipo de Ación",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Crear Pseudónimo",
|
||||
"manage-aliases": "Xestionar Pseudónimos",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "é maior que",
|
||||
"is-greater-than-or-equal-to": "é maior ou igual a",
|
||||
"is-less-than": "é menor que",
|
||||
"is-less-than-or-equal-to": "é menor ou igual a"
|
||||
"is-less-than-or-equal-to": "é menor ou igual a",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "é",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "contén todos os",
|
||||
"is-like": "é como",
|
||||
"is-not-like": "non é como"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"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"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "מידע על הפעולות במתכון",
|
||||
"new-recipe-action": "פעולת-מתכון חדשה",
|
||||
"edit-recipe-action": "עריכת פעולת-מתכון",
|
||||
"action-type": "סוג פעולה"
|
||||
"action-type": "סוג פעולה",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "יצירת שם נרדף",
|
||||
"manage-aliases": "נהל שמות נרדפים",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "גדול מ-",
|
||||
"is-greater-than-or-equal-to": "גדול או שווה ל-",
|
||||
"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"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "זהה ל-",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "מכיל הכל מתוך",
|
||||
"is-like": "דומה ל-",
|
||||
"is-not-like": "לא דומה לא-"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"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"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Recipe Actions Data",
|
||||
"new-recipe-action": "New Recipe Action",
|
||||
"edit-recipe-action": "Edit Recipe Action",
|
||||
"action-type": "Action Type"
|
||||
"action-type": "Action Type",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Create Alias",
|
||||
"manage-aliases": "Manage Aliases",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"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-less-than-or-equal-to": "is less than or equal to",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "is",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"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": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"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"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Receptekkel kapcsolatos tevékenységek adatai",
|
||||
"new-recipe-action": "Új recept tevékenység",
|
||||
"edit-recipe-action": "Recept tevékenység szerkesztése",
|
||||
"action-type": "Művelet típusa"
|
||||
"action-type": "Művelet típusa",
|
||||
"action-types": {
|
||||
"link": "Hivatkozás",
|
||||
"post": "Közzététel"
|
||||
}
|
||||
},
|
||||
"create-alias": "Alias készítése",
|
||||
"manage-aliases": "Alias kezelése",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "nagyobb, mint",
|
||||
"is-greater-than-or-equal-to": "nagyobb vagy egyenlő",
|
||||
"is-less-than": "kevesebb, mint",
|
||||
"is-less-than-or-equal-to": "kevesebb vagy egyenlő"
|
||||
"is-less-than-or-equal-to": "kevesebb vagy egyenlő",
|
||||
"is-older-than": "régebbi, mint",
|
||||
"is-newer-than": "újabb, mint"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "van",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "tartalmazza az összes",
|
||||
"is-like": "hasonló",
|
||||
"is-not-like": "nem hasonló"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "napja|napja|napja"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"invalid-url": "Érvényes URL-nek kell lennie",
|
||||
"no-whitespace": "Szóközt nem tartalmazhat",
|
||||
"min-length": "Legalább {min} karakter legyen",
|
||||
"max-length": "Legfeljebb {max} karakter legyen"
|
||||
"max-length": "Legfeljebb {max} karakter lehet|Legfeljebb {max} karakter lehet"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,8 @@
|
||||
"upload-file": "Hlaða upp skrá",
|
||||
"created-on-date": "Búið til: {0}",
|
||||
"unsaved-changes": "Þú hefur ekki vistað breytingar. Viltu vista áður en þú ferð? Ýttu á \"Í lagi\" til að vista, \"Hætta við\" til að henda breytingum.",
|
||||
"discard-changes": "Discard Changes",
|
||||
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"discard-changes": "Henda breytingum",
|
||||
"discard-changes-description": "Þú ert með óvistaðar breytingar, ertu viss um að þú viljir henda þeim?",
|
||||
"clipboard-copy-failure": "Mistókst að afrita klippispjaldið.",
|
||||
"confirm-delete-generic-items": "Ertu viss um að þú viljir eyða eftirfylgjandi atriðum?",
|
||||
"organizers": "Skipuleggjarar",
|
||||
@@ -370,8 +370,8 @@
|
||||
"applies-to-all-days": "Á við alla daga",
|
||||
"applies-on-days": "Gildir þegar er {0},",
|
||||
"meal-plan-settings": "Stillingar matarplans",
|
||||
"add-all-to-list": "Add All to List",
|
||||
"add-day-to-list": "Add Day to List"
|
||||
"add-all-to-list": "Bæta öllum á innkaupalista",
|
||||
"add-day-to-list": "Bæta deginum á innkaupalista"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Gagnaflutningur fjarlægður",
|
||||
@@ -644,7 +644,7 @@
|
||||
"scrape-recipe-website-being-blocked": "Er vefsíðan lokuð?",
|
||||
"scrape-recipe-try-importing-raw-html-instead": "Reyndu að flytja inn HTML kóðann í staðinn.",
|
||||
"import-original-keywords-as-tags": "Nota upprunanleg merki",
|
||||
"import-original-categories": "Import original categories",
|
||||
"import-original-categories": "Flytja inn upprunalega flokka",
|
||||
"stay-in-edit-mode": "Vera í breytingarham",
|
||||
"parse-recipe-ingredients-after-import": "Greina innhald uppskriftar eftir að búið er að hlaða inn uppskrift",
|
||||
"import-from-zip": "Hlaða inn frá .zip",
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Gögn fyrir uppskriftaaðgerðir",
|
||||
"new-recipe-action": "Nú uppskriftaaðgerð",
|
||||
"edit-recipe-action": "Breyta uppskriftaaðgerð",
|
||||
"action-type": "Aðgerð"
|
||||
"action-type": "Aðgerð",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Stofna samheiti",
|
||||
"manage-aliases": "Vinna með samheiti",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "eftir þann",
|
||||
"is-greater-than-or-equal-to": "þann eða eftir þann",
|
||||
"is-less-than": "fyrir þann",
|
||||
"is-less-than-or-equal-to": "fyrir þann eða þann"
|
||||
"is-less-than-or-equal-to": "fyrir þann eða þann",
|
||||
"is-older-than": "er eldra en",
|
||||
"is-newer-than": "er nýrra en"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "er",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "inniheldur alla af",
|
||||
"is-like": "is like",
|
||||
"is-not-like": "is not like"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "daga gamalt|dags gamalt|daga gamalt"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"invalid-url": "Verður að vera gild vefslóð",
|
||||
"no-whitespace": "Engin bil leyfð",
|
||||
"min-length": "Verður að vera að lágmarki {min} stafir",
|
||||
"max-length": "Má vera að hámarki {max} stafir"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"about": "Dettagli",
|
||||
"about-mealie": "Info su Mealie",
|
||||
"api-docs": "Documentazione API",
|
||||
"api-port": "Porta API",
|
||||
"api-port": "Importazione API",
|
||||
"application-mode": "Modalità",
|
||||
"database-type": "Tipo Database",
|
||||
"database-url": "URL Database",
|
||||
@@ -97,7 +97,7 @@
|
||||
"custom": "Personalizzato",
|
||||
"dashboard": "Pannello di controllo",
|
||||
"delete": "Elimina",
|
||||
"disabled": "Disabilitato",
|
||||
"disabled": "Disabilita",
|
||||
"download": "Download",
|
||||
"duplicate": "Duplicato",
|
||||
"edit": "Modifica",
|
||||
@@ -370,8 +370,8 @@
|
||||
"applies-to-all-days": "Si applica a ogni giorno",
|
||||
"applies-on-days": "Si applica ai {0}",
|
||||
"meal-plan-settings": "Impostazioni del piano alimentare",
|
||||
"add-all-to-list": "Add All to List",
|
||||
"add-day-to-list": "Add Day to List"
|
||||
"add-all-to-list": "Aggiungi tutto alla lista",
|
||||
"add-day-to-list": "Aggiungi Giorno"
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "Dati di migrazione rimossi",
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Dati Azioni Ricetta",
|
||||
"new-recipe-action": "Nuova Azione Ricetta",
|
||||
"edit-recipe-action": "Modifica Azione Ricetta",
|
||||
"action-type": "Tipo Di Azione"
|
||||
"action-type": "Tipo Di Azione",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Crea Alias",
|
||||
"manage-aliases": "Gestisci Alias",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "è maggiore di",
|
||||
"is-greater-than-or-equal-to": "è maggiore o uguale di",
|
||||
"is-less-than": "è minore di",
|
||||
"is-less-than-or-equal-to": "è minore o uguale di"
|
||||
"is-less-than-or-equal-to": "è minore o uguale di",
|
||||
"is-older-than": "è più vecchio di",
|
||||
"is-newer-than": "è più recente di"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "è",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "contiene tutti i",
|
||||
"is-like": "è simile",
|
||||
"is-not-like": "non è come"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "giorni fa|giorno fa|giorni fa"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"invalid-url": "Lo URL Deve Essere Valido",
|
||||
"no-whitespace": "Gli Spazi Non Sono Ammessi",
|
||||
"min-length": "Deve Essere Almeno {min} Caratteri",
|
||||
"max-length": "Deve Essere Al Massimo {max} Caratteri"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "レシピ操作データ",
|
||||
"new-recipe-action": "新しいレシピ操作",
|
||||
"edit-recipe-action": "レシピ操作の編集",
|
||||
"action-type": "操作タイプ"
|
||||
"action-type": "操作タイプ",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "エイリアスを作成",
|
||||
"manage-aliases": "エイリアスの管理",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "より大きい",
|
||||
"is-greater-than-or-equal-to": "以上",
|
||||
"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"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "は",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "すべてを含む",
|
||||
"is-like": "次のようなものです",
|
||||
"is-not-like": "というわけではありません"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"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"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"about": {
|
||||
"about": "정보",
|
||||
"about-mealie": "Mealie에 대해",
|
||||
"about": "약/대략",
|
||||
"about-mealie": "Mealie 정보",
|
||||
"api-docs": "API 문서",
|
||||
"api-port": "API 포트",
|
||||
"application-mode": "애플리케이션 모드",
|
||||
@@ -14,7 +14,7 @@
|
||||
"development": "개발",
|
||||
"docs": "문서",
|
||||
"download-log": "다운로드 기록",
|
||||
"download-recipe-json": "마지막으로 불러온 JSON",
|
||||
"download-recipe-json": "마지막으로 스크랩한 JSON",
|
||||
"github": "GitHub",
|
||||
"log-lines": "로그 줄",
|
||||
"not-demo": "데모 아님",
|
||||
@@ -67,10 +67,10 @@
|
||||
"test-message-sent": "테스트 메시지가 전송됐습니다.",
|
||||
"message-sent": "메세지가 전송됨",
|
||||
"new-notification": "새 알림",
|
||||
"event-notifiers": "이벤트 알림이",
|
||||
"event-notifiers": "이벤트 알리미",
|
||||
"apprise-url-skipped-if-blank": "Apprise URL (비워두면 생략합니다)",
|
||||
"apprise-url-is-left-intentionally-blank": "Apprise URL에는 일반적으로 민감한 정보가 포함되므로, 편집 시 이 필드는 의도적으로 비워둡니다. URL을 수정하려면 여기에 새 주소를 입력하시고, 현재 URL을 유지하려면 비워두십시오.",
|
||||
"enable-notifier": "알림 활성화",
|
||||
"enable-notifier": "알리미 활성화",
|
||||
"what-events": "이 알리미는 어떤 이벤트를 구독해야 합니까?",
|
||||
"user-events": "사용자 이벤트",
|
||||
"mealplan-events": "Mealplan 이벤트",
|
||||
@@ -193,7 +193,7 @@
|
||||
"delete-with-name": "{name} 삭제",
|
||||
"confirm-delete-generic-with-name": "{name}을(를) 정말 삭제하시겠습니까?",
|
||||
"confirm-delete-own-admin-account": "본인의 관리자 계정을 삭제하려고 한다는 점에 유의하세요! 이 작업은 취소할 수 없으며 계정이 영구적으로 삭제됩니다.",
|
||||
"organizer": "정리 도구",
|
||||
"organizer": "정리함",
|
||||
"transfer": "전송",
|
||||
"copy": "복사",
|
||||
"color": "색상",
|
||||
@@ -216,7 +216,7 @@
|
||||
"discard-changes-description": "저장되지 않은 변경사항이 있습니다. 삭제하시겠습니까?",
|
||||
"clipboard-copy-failure": "클립보드에 복사하는 데 실패했습니다.",
|
||||
"confirm-delete-generic-items": "이 항목을 삭제하시겠습니까?",
|
||||
"organizers": "정리 도구",
|
||||
"organizers": "정리함",
|
||||
"caution": "주의",
|
||||
"show-advanced": "고급 표시",
|
||||
"add-field": "필드 추가",
|
||||
@@ -299,8 +299,8 @@
|
||||
"private-household-description": "귀하의 가구를 비공개로 설정하면 모든 공개 보기 옵션이 비활성화됩니다. 이는 개별 공개 보기 설정을 재정의합니다.",
|
||||
"lock-recipe-edits-from-other-households": "다른 가구의 레시피 편집 잠금",
|
||||
"lock-recipe-edits-from-other-households-description": "이 기능을 활성화하면 귀하의 가족 구성원만 귀하의 가족이 만든 요리법을 편집할 수 있습니다.",
|
||||
"household-recipe-preferences": "가정용 레시피 선호도",
|
||||
"default-recipe-preferences-description": "이는 가정에서 새로운 레시피를 만들 때의 기본 설정입니다. 레시피 설정 메뉴에서 개별 레시피에 대해 이를 변경할 수 있습니다.",
|
||||
"household-recipe-preferences": "가구 레시피 설정",
|
||||
"default-recipe-preferences-description": "이는 가구에서 새로운 레시피를 만들 때의 기본 설정입니다. 레시피 설정 메뉴에서 개별 레시피에 대해 이를 변경할 수 있습니다.",
|
||||
"allow-users-outside-of-your-household-to-see-your-recipes": "가족 외의 사용자에게도 요리법을 볼 수 있도록 허용",
|
||||
"allow-users-outside-of-your-household-to-see-your-recipes-description": "활성화하면 공개 공유 링크를 사용하여 사용자에게 권한을 부여하지 않고도 특정 레시피를 공유할 수 있습니다. 비활성화하면 가족 구성원 또는 사전 생성된 비공개 링크로만 레시피를 공유할 수 있습니다.",
|
||||
"household-preferences": "가구 설정"
|
||||
@@ -339,7 +339,7 @@
|
||||
"side": "사이드",
|
||||
"sides": "사이드",
|
||||
"start-date": "시작 일자",
|
||||
"rule-day": "규칙의 날",
|
||||
"rule-day": "규칙을 적용할 요일",
|
||||
"meal-type": "식사 유형",
|
||||
"breakfast": "조식",
|
||||
"lunch": "점심",
|
||||
@@ -357,14 +357,14 @@
|
||||
"random-meal": "랜덤 식사",
|
||||
"random-dinner": "랜덤 저녁식사",
|
||||
"random-side": "랜덤 사이드 메뉴",
|
||||
"this-rule-will-apply": "이 규칙은 {dayCriteria} {mealTypeCriteria}에 적용됩니다.",
|
||||
"this-rule-will-apply": "이 규칙은 {dayCriteria} {mealTypeCriteria} 적용됩니다.",
|
||||
"to-all-days": "모든 날에",
|
||||
"on-days": "{0}에",
|
||||
"for-all-meal-types": "모든 식사 종류에",
|
||||
"for-type-meal-types": "{0} 식사 종류에",
|
||||
"meal-plan-rules": "식단 계획 규칙",
|
||||
"new-rule": "새 규칙",
|
||||
"meal-plan-rules-description": "식단 계획에 사용할 레시피를 자동 선택하는 규칙을 생성할 수 있습니다. 이 규칙들은 서버가 식단 계획을 생성할 때 선택할 무작위 레시피 풀을 결정하는 데 사용됩니다. 동일한 요일/유형 제약 조건을 가진 규칙들은 필터가 병합된다는 점에 유의하세요. 실제로 중복 규칙을 생성할 필요는 없지만, 생성하는 것은 가능합니다.",
|
||||
"meal-plan-rules-description": "식단 계획에 사용할 레시피를 자동으로 선택하기 위한 규칙을 만들 수 있습니다. 이러한 규칙은 서버에서 식단 계획을 생성할 때 무작위로 선택할 레시피 목록을 결정하는 데 사용됩니다. 규칙에 동일한 요일/식사 유형 조건이 있는 경우 규칙 필터가 병합된다는 점에 유의하십시오. 실제로 중복 규칙을 만들 필요는 없지만, 만드는 것도 가능합니다.",
|
||||
"new-rule-description": "식단 계획에 새 규칙을 생성할 때, 특정 요일 및/또는 특정 식사 유형에만 적용되도록 규칙을 제한할 수 있습니다. 모든 요일 또는 모든 식사 유형에 규칙을 적용하려면 규칙을 \"모든\"으로 설정하면 됩니다. 이렇게 하면 해당 요일 및/또는 식사 유형의 모든 가능한 값에 규칙이 적용됩니다.",
|
||||
"recipe-rules": "레시피 규칙",
|
||||
"applies-to-all-days": "모든 날짜에 적용됨",
|
||||
@@ -375,7 +375,7 @@
|
||||
},
|
||||
"migration": {
|
||||
"migration-data-removed": "이전된 데이터 제거됨",
|
||||
"new-migration": "새 마이그레이션",
|
||||
"new-migration": "새 데이터 이전",
|
||||
"no-file-selected": "선택된 파일이 없습니다",
|
||||
"no-migration-data-available": "이전 데이터가 없습니다.",
|
||||
"previous-migrations": "이전 데이터 이전",
|
||||
@@ -392,19 +392,19 @@
|
||||
},
|
||||
"copymethat": {
|
||||
"description-long": "Mealie는 Copy Me That에서 레시피를 가져올 수 있습니다. 레시피를 HTML 형식으로 내보낸 후, 아래의 .zip 파일을 업로드하세요.",
|
||||
"title": "Copy Me That 레시피 매니저"
|
||||
"title": "Copy Me That Recipe Manager"
|
||||
},
|
||||
"paprika": {
|
||||
"description-long": "Mealie는 Paprika 애플리케이션에서 레시피를 가져올 수 있습니다. Paprika에서 레시피를 내보낸 후, 내보낸 파일의 확장자를 .zip으로 변경하여 아래에 업로드하세요.",
|
||||
"title": "Paprika 레시피 매니저"
|
||||
"title": "Paprika Recipe Manager"
|
||||
},
|
||||
"mealie-pre-v1": {
|
||||
"description-long": "Mealie는 v1.0 이전 버전의 Mealie 애플리케이션에서 레시피를 가져올 수 있습니다. 기존 인스턴스에서 레시피를 내보낸 후 아래의 zip 파일을 업로드하세요. 내보낸 파일에서 레시피만 가져올 수 있다는 점에 유의하십시오.",
|
||||
"description-long": "Mealie는 v1.0 이전 버전의 Mealie 애플리케이션에서 레시피를 가져올 수 있습니다. 기존 인스턴스에서 레시피를 내보낸 후 아래에 있는 압축 파일을 업로드하세요. 내보내기 파일에서는 레시피만 가져올 수 있다는 점에 유의하세요.",
|
||||
"title": "Mealie Pre v1.0"
|
||||
},
|
||||
"tandoor": {
|
||||
"description-long": "Mealie는 Tandoor에서 레시피를 가져올 수 있습니다. 데이터를 \"기본\" 형식으로 내보낸 후 아래의 .zip 파일을 업로드하세요.",
|
||||
"title": "Tandoor 레시피"
|
||||
"title": "Tandoor Recipes"
|
||||
},
|
||||
"cookn": {
|
||||
"description-long": "Mealie는 DVO Cook'n X3의 레시피를 가져올 수 있습니다. \"Cook'n\" 형식으로 요리책이나 메뉴를 내보낸 후, 내보낸 파일의 확장자를 .zip으로 변경하고 아래에 .zip 파일을 업로드하세요.",
|
||||
@@ -420,17 +420,17 @@
|
||||
"recipe-1": "레시피 1",
|
||||
"recipe-2": "레시피 2",
|
||||
"paprika-text": "Mealie는 Paprika 애플리케이션에서 레시피를 가져올 수 있습니다. Paprika에서 레시피를 내보낸 후, 내보낸 파일의 확장자를 .zip으로 변경하여 아래에 업로드하세요.",
|
||||
"mealie-text": "Mealie는 v1.0 이전 버전의 Mealie 애플리케이션에서 레시피를 가져올 수 있습니다. 기존 인스턴스에서 레시피를 내보낸 후 아래의 zip 파일을 업로드하세요. 내보낸 파일에서 레시피만 가져올 수 있다는 점에 유의하십시오.",
|
||||
"mealie-text": "Mealie는 v1.0 이전 버전의 Mealie 애플리케이션에서 레시피를 가져올 수 있습니다. 기존 인스턴스에서 레시피를 내보낸 후 아래에 있는 압축 파일을 업로드하세요. 내보내기 파일에서는 레시피만 가져올 수 있다는 점에 유의하세요.",
|
||||
"plantoeat": {
|
||||
"title": "Plan to Eat",
|
||||
"description-long": "Mealie는 Plan to Eat에서 레시피를 가져올 수 있습니다."
|
||||
},
|
||||
"myrecipebox": {
|
||||
"title": "내 레시피 박스",
|
||||
"title": "My Recipe Box",
|
||||
"description-long": "Mealie는 My Recipe Box에서 레시피를 가져올 수 있습니다. CSV 형식으로 레시피를 내보낸 다음 아래의 .csv 파일을 업로드하세요."
|
||||
},
|
||||
"recipekeeper": {
|
||||
"title": "레시피 보관함",
|
||||
"title": "Recipe Keeper",
|
||||
"description-long": "Mealie는 Recipe Keeper에서 레시피를 가져올 수 있습니다. 레시피를 zip 형식으로 내보낸 다음 아래의 .zip 파일을 업로드하세요."
|
||||
}
|
||||
},
|
||||
@@ -712,7 +712,7 @@
|
||||
"toggle-recipe": "레시피로 전환"
|
||||
},
|
||||
"recipe-finder": {
|
||||
"recipe-finder": "레시피 찾기",
|
||||
"recipe-finder": "레시피 검색",
|
||||
"recipe-finder-description": "가지고 있는 재료로 레시피를 검색하세요. 사용 가능한 도구로도 필터링할 수 있으며, 부족한 재료나 도구의 최대 개수를 설정할 수 있습니다.",
|
||||
"selected-ingredients": "선택된 재료",
|
||||
"no-ingredients-selected": "선택된 재료 없음",
|
||||
@@ -794,7 +794,7 @@
|
||||
"latest": "가장 최근",
|
||||
"local-api": "로컬 API",
|
||||
"locale-settings": "국가별 설정",
|
||||
"migrations": "마이그레이션",
|
||||
"migrations": "데이터 이전",
|
||||
"new-page": "새 페이지",
|
||||
"notify": "알림",
|
||||
"organize": "정리",
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "레시피 액션 데이터",
|
||||
"new-recipe-action": "새 레시피 액션",
|
||||
"edit-recipe-action": "레시피 액션 편집",
|
||||
"action-type": "액션 종류"
|
||||
"action-type": "액션 종류",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "별칭 생성",
|
||||
"manage-aliases": "별칭 관리",
|
||||
@@ -1395,7 +1399,7 @@
|
||||
},
|
||||
"cookbook": {
|
||||
"cookbooks": "요리책",
|
||||
"description": "요리책은 레시피, 정리 도구 및 기타 필터를 교차 조합하여 레시피를 체계화하는 또 다른 방법입니다. 요리책을 생성하면 사이드바에 항목이 추가되며, 선택한 필터가 적용된 모든 레시피가 해당 요리책에 표시됩니다.",
|
||||
"description": "요리책은 레시피, 정리함 및 기타 필터를 교차 조합하여 레시피를 체계화하는 또 다른 방법입니다. 요리책을 생성하면 사이드바에 항목이 추가되며, 선택한 필터가 적용된 모든 레시피가 해당 요리책에 표시됩니다.",
|
||||
"hide-cookbooks-from-other-households": "다른 가구의 요리책 숨기기",
|
||||
"hide-cookbooks-from-other-households-description": "이 기능을 활성화하면 사이드바에는 귀하의 가구에 속한 요리책만 표시됩니다.",
|
||||
"public-cookbook": "공개 요리책",
|
||||
@@ -1419,19 +1423,24 @@
|
||||
"relational-operators": {
|
||||
"equals": "정확히 일치",
|
||||
"does-not-equal": "일치하지 않음",
|
||||
"is-greater-than": "은(는) 다음보다 크다:",
|
||||
"is-greater-than-or-equal-to": "은(는) 다음보다 크거나 같다:",
|
||||
"is-less-than": "은(는) 다음보다 작다:",
|
||||
"is-less-than-or-equal-to": "은(는) 다음보다 작거나 같다:"
|
||||
"is-greater-than": "다음보다 큼",
|
||||
"is-greater-than-or-equal-to": "다음보다 크거나 같음",
|
||||
"is-less-than": "다음보다 작음",
|
||||
"is-less-than-or-equal-to": "다음보다 작거나 같음",
|
||||
"is-older-than": "~보다 오래됨",
|
||||
"is-newer-than": "~보다 최근임"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "은(는)",
|
||||
"is-not": "은(는) 아니다",
|
||||
"is-one-of": "은(는) 다음에 포함:",
|
||||
"is-not-one-of": "은(는) 다음에 포함되지 않음:",
|
||||
"contains-all-of": "은(는) 다음 모두를 포함:",
|
||||
"is-like": "은(는) 다음과 같음:",
|
||||
"is-not-like": "은(는) 다음과 같지 않음:"
|
||||
"is": "같음",
|
||||
"is-not": "아님",
|
||||
"is-one-of": "다음에 포함",
|
||||
"is-not-one-of": "다음에 포함되지 않음",
|
||||
"contains-all-of": "다음 모두를 포함",
|
||||
"is-like": "다음과 같음",
|
||||
"is-not-like": "다음과 같지 않음"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "일 전|일 전|일 전"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"invalid-url": "유효한 URL이어야 합니다.",
|
||||
"no-whitespace": "공백 허용 안 됨",
|
||||
"min-length": "최소 {min}자 이상이어야 합니다",
|
||||
"max-length": "최대 {min}자 이하여야 합니다"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Recipe Actions Data",
|
||||
"new-recipe-action": "New Recipe Action",
|
||||
"edit-recipe-action": "Edit Recipe Action",
|
||||
"action-type": "Action Type"
|
||||
"action-type": "Action Type",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Create Alias",
|
||||
"manage-aliases": "Manage Aliases",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"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-less-than-or-equal-to": "is less than or equal to",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "is",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"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": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"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"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,7 +1168,11 @@
|
||||
"recipe-actions-data": "Recepšu darbību dati",
|
||||
"new-recipe-action": "Jaunas receptes darbība",
|
||||
"edit-recipe-action": "Rediģēt receptes darbību",
|
||||
"action-type": "Darbības veids"
|
||||
"action-type": "Darbības veids",
|
||||
"action-types": {
|
||||
"link": "Link",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"create-alias": "Izveidot aizstājvārdu",
|
||||
"manage-aliases": "Pārvaldīt aizstājvārdus",
|
||||
@@ -1422,7 +1426,9 @@
|
||||
"is-greater-than": "ir lielāks par",
|
||||
"is-greater-than-or-equal-to": "ir lielāks vai vienāds ar",
|
||||
"is-less-than": "ir mazāks par",
|
||||
"is-less-than-or-equal-to": "ir mazāks vai vienāds ar"
|
||||
"is-less-than-or-equal-to": "ir mazāks vai vienāds ar",
|
||||
"is-older-than": "is older than",
|
||||
"is-newer-than": "is newer than"
|
||||
},
|
||||
"relational-keywords": {
|
||||
"is": "IR",
|
||||
@@ -1432,6 +1438,9 @@
|
||||
"contains-all-of": "satur visus",
|
||||
"is-like": "ir kā",
|
||||
"is-not-like": "nav tāds, kā"
|
||||
},
|
||||
"dates": {
|
||||
"days-ago": "days ago|day ago|days ago"
|
||||
}
|
||||
},
|
||||
"validators": {
|
||||
@@ -1440,6 +1449,6 @@
|
||||
"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"
|
||||
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user