Compare commits

...

50 Commits

Author SHA1 Message Date
mealie-commit-bot[bot]
c23aa61f17 chore: bump version to v3.11.0 2026-02-17 04:13:46 +00:00
renovate[bot]
cd39d0c4cb fix(deps): update dependency uvicorn to v0.41.0 (#7083)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-17 01:25:35 +00:00
Michael Genson
20e2d4e1a1 fix: Exclude docs/redoc from (#7082) 2026-02-16 19:24:33 -06:00
mealie-actions[bot]
c09cc5a323 chore(auto): Update pre-commit hooks (#7080)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2026-02-16 15:36:17 +00:00
Michael Genson
6d7b6bccab fix: Show minimum value for quantity (#7077) 2026-02-15 13:08:07 -06:00
Hayden
91fea086e5 chore(l10n): New Crowdin updates (#7075) 2026-02-15 17:52:15 +00:00
renovate[bot]
e2fbe118a7 fix(deps): update dependency pydantic-settings to v2.13.0 (#7073)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-15 17:30:44 +00:00
Zachary Schaffter
904e6b7d82 fix: #6263 remove reserved prefix (#7033) 2026-02-14 21:05:42 +00:00
renovate[bot]
5aafb56c4f fix(deps): update dependency authlib to v1.6.8 (#7067)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-14 19:01:34 +00:00
renovate[bot]
b4740d291d fix(deps): update dependency openai to v2.21.0 (#7065)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-14 19:01:27 +00:00
Hayden
fc6dc34ace chore(l10n): New Crowdin updates (#7070) 2026-02-14 17:29:38 +00:00
Michael Genson
73d86f6f6b feat: Further improve recipe filter search and shopping list and recipe ingredient editor (#7063) 2026-02-14 00:34:17 -06:00
Hayden
8e225ee796 chore(l10n): New Crowdin updates (#7066) 2026-02-14 05:06:25 +00:00
Hayden
ced233d361 chore(l10n): New Crowdin updates (#7062) 2026-02-13 17:08:08 +00:00
Michael Genson
b173172e6c feat: Improve recipe filter search ordering (#7061) 2026-02-13 10:15:38 -06:00
Michael Genson
a66db96eb5 fix: Search bar width (#7060) 2026-02-13 09:51:55 -06:00
Hayden
dfd5abfb5d chore(l10n): New Crowdin updates (#7059) 2026-02-13 03:52:07 +00:00
renovate[bot]
e2ae5cb5b6 chore(deps): update dependency ruff to v0.15.1 (#7058)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-13 01:08:02 +00:00
Hayden
634aa5cd25 chore(l10n): New Crowdin updates (#7055) 2026-02-13 01:07:47 +00:00
Michael Genson
23c7bd7e3d feat: Customize Ingredient Plural Handling (#7057) 2026-02-12 19:07:23 -06:00
renovate[bot]
9c1ee972c9 fix(deps): update dependency fastapi to v0.129.0 (#7056)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-12 18:46:30 +00:00
renovate[bot]
1b9023c8c0 chore(deps): update node.js to 00e9195 (#7054)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-12 04:06:13 +00:00
Hayden
3a37cd6959 chore(l10n): New Crowdin updates (#7053) 2026-02-12 03:23:18 +00:00
Michael Genson
8da0d010a5 feat: Add Docker metadata to published images (#7052) 2026-02-11 17:07:40 -06:00
renovate[bot]
37f7f770a8 fix(deps): update dependency fastapi to v0.128.8 (#7049)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-11 16:47:04 +00:00
Hayden
1cebbefd88 chore(l10n): New Crowdin updates (#7048) 2026-02-11 15:19:22 +00:00
Hayden
d55149b904 chore(l10n): New Crowdin updates (#7028) 2026-02-11 13:59:57 +00:00
renovate[bot]
fad7acadfc fix(deps): update dependency openai to v2.20.0 (#7042)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-11 13:59:30 +00:00
renovate[bot]
a539c6cd2e fix(deps): update dependency fastapi to v0.128.7 (#7043)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-11 13:59:13 +00:00
renovate[bot]
7b5502d019 fix(deps): update dependency alembic to v1.18.4 (#7044)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-11 13:56:51 +00:00
renovate[bot]
26d9d8fe24 fix(deps): update dependency pillow to v12.1.1 (#7047)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-11 13:56:08 +00:00
Michael Genson
b64f14aaae feat: Dynamic Placeholders UI (#7034) 2026-02-11 05:43:17 +00:00
renovate[bot]
9b686ecd2b chore(deps): update dependency axios to v1.13.5 [security] (#7041)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 21:50:28 +00:00
renovate[bot]
a956a638f4 chore(deps): update dependency coverage to v7.13.4 (#7039)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 21:25:33 +00:00
renovate[bot]
c9d9e6822e fix(deps): update dependency fastapi to v0.128.6 (#7040)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 21:25:19 +00:00
renovate[bot]
4a563b76ad chore(deps): update dependency setuptools to v82 (#7032)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-08 17:40:17 +00:00
renovate[bot]
73f97c2cca fix(deps): update dependency fastapi to v0.128.5 (#7030)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-08 17:18:42 +00:00
mealie-actions[bot]
75e3c99d72 chore(l10n): Crowdin locale sync (#7029)
Co-authored-by: GitHub Action <action@github.com>
2026-02-08 03:06:17 +00:00
renovate[bot]
217ddd8814 fix(deps): update dependency fastapi to v0.128.4 (#7023)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-07 19:54:18 +00:00
Joey
f2cc8dc922 fix: handle numeric recipeCategory from LLM/site to prevent import failure (#7026) 2026-02-07 19:47:44 +00:00
Hayden
b8329def91 chore(l10n): New Crowdin updates (#7024) 2026-02-07 13:28:44 +00:00
Hayden
2ae7dc3b82 chore(l10n): New Crowdin updates (#7022) 2026-02-07 01:57:29 +00:00
renovate[bot]
510a63a71f chore(deps): update dependency setuptools to v81 (#7021)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-06 22:22:25 +00:00
renovate[bot]
14433819c3 fix(deps): update dependency fastapi to v0.128.3 (#7020)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-06 21:13:54 +00:00
renovate[bot]
96a9dbccb6 fix(deps): update dependency authlib to v1.6.7 (#7019)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-06 20:51:27 +00:00
Hayden
cfe20214e5 chore(l10n): New Crowdin updates (#7016) 2026-02-06 12:26:59 +00:00
Hayden
eef54879fe chore(l10n): New Crowdin updates (#7014)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2026-02-06 00:47:28 +00:00
renovate[bot]
c789ecf0ba fix(deps): update dependency openai to v2.17.0 (#7012)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-05 22:09:12 +00:00
renovate[bot]
008f55e725 fix(deps): update dependency fastapi to v0.128.2 (#7013)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-05 22:08:58 +00:00
Hayden
bcbe32f503 chore(l10n): New Crowdin updates (#7010) 2026-02-05 00:03:53 +00:00
169 changed files with 4245 additions and 3349 deletions

View File

@@ -37,6 +37,17 @@ jobs:
- uses: depot/setup-action@v1 - 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 - name: Retrieve Python package
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
@@ -57,5 +68,6 @@ jobs:
hkotel/mealie:${{ inputs.tag }} hkotel/mealie:${{ inputs.tag }}
ghcr.io/${{ github.repository }}:${{ inputs.tag }} ghcr.io/${{ github.repository }}:${{ inputs.tag }}
${{ inputs.tags }} ${{ inputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: | build-args: |
COMMIT=${{ github.sha }} COMMIT=${{ github.sha }}

View File

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

View File

@@ -1,6 +1,7 @@
import json
import os import os
import pathlib import pathlib
from dataclasses import dataclass import re
from pathlib import Path from pathlib import Path
import dotenv import dotenv
@@ -10,6 +11,7 @@ from pydantic import ConfigDict
from requests import Response from requests import Response
from utils import CodeDest, CodeKeys, inject_inline, log from utils import CodeDest, CodeKeys, inject_inline, log
from mealie.lang.locale_config import LOCALE_CONFIG, LocalePluralFoodHandling, LocaleTextDirection
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
BASE = pathlib.Path(__file__).parent.parent.parent 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", "") 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 LOCALE_TEMPLATE = """// This Code is auto generated by gen_ts_locales.py
export const LOCALES = [{% for locale in locales %} export const LOCALES = [{% for locale in locales %}
{ {
@@ -75,6 +26,7 @@ export const LOCALES = [{% for locale in locales %}
value: "{{ locale.locale }}", value: "{{ locale.locale }}",
progress: {{ locale.progress }}, progress: {{ locale.progress }},
dir: "{{ locale.dir }}", dir: "{{ locale.dir }}",
pluralFoodHandling: "{{ locale.plural_food_handling }}",
},{% endfor %} },{% endfor %}
]; ];
@@ -87,10 +39,11 @@ class TargetLanguage(MealieModel):
id: str id: str
name: str name: str
locale: str locale: str
dir: str = "ltr" dir: LocaleTextDirection = LocaleTextDirection.LTR
plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS
threeLettersCode: str threeLettersCode: str
twoLettersCode: str twoLettersCode: str
progress: float = 0.0 progress: int = 0
class CrowdinApi: class CrowdinApi:
@@ -117,43 +70,15 @@ class CrowdinApi:
def get_languages(self) -> list[TargetLanguage]: def get_languages(self) -> list[TargetLanguage]:
response = self.get_project() response = self.get_project()
tls = response.json()["data"]["targetLanguages"] tls = response.json()["data"]["targetLanguages"]
return [TargetLanguage(**t) for t in tls]
models = [TargetLanguage(**t) for t in tls] def get_progress(self) -> dict[str, int]:
models.insert(
0,
TargetLanguage(
id="en-US",
name="English",
locale="en-US",
dir="ltr",
threeLettersCode="en",
twoLettersCode="en",
progress=100,
),
)
progress: list[dict] = self.get_progress()["data"]
for model in models:
if model.locale in LOCALE_DATA:
locale_data = LOCALE_DATA[model.locale]
model.name = locale_data.name
model.dir = locale_data.dir
for p in progress:
if p["data"]["languageId"] == model.id:
model.progress = p["data"]["translationProgress"]
models.sort(key=lambda x: x.locale, reverse=True)
return models
def get_progress(self) -> dict:
response = requests.get( response = requests.get(
f"https://api.crowdin.com/api/v2/projects/{self.project_id}/languages/progress?limit=500", f"https://api.crowdin.com/api/v2/projects/{self.project_id}/languages/progress?limit=500",
headers=self.headers, 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 PROJECT_DIR = Path(__file__).parent.parent.parent
@@ -195,8 +120,8 @@ def inject_nuxt_values():
all_langs = [] all_langs = []
for match in locales_dir.glob("*.json"): for match in locales_dir.glob("*.json"):
match_data = LOCALE_DATA.get(match.stem) match_data = LOCALE_CONFIG.get(match.stem)
match_dir = match_data.dir if match_data else "ltr" 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}" }},' lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}", dir: "{match_dir}" }},'
all_langs.append(lang_string) all_langs.append(lang_string)
@@ -221,9 +146,82 @@ def inject_registration_validation_values():
inject_inline(reg_valid, CodeKeys.nuxt_local_messages, all_langs) 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(): def generate_locales_ts_file():
api = CrowdinApi(None) models = get_languages()
models = api.get_languages()
tmpl = Template(LOCALE_TEMPLATE) tmpl = Template(LOCALE_TEMPLATE)
rendered = tmpl.render(locales=models) rendered = tmpl.render(locales=models)
@@ -233,10 +231,6 @@ def generate_locales_ts_file():
def main(): def main():
if API_KEY is None or API_KEY == "":
log.error("CROWDIN_API_KEY is not set")
return
generate_locales_ts_file() generate_locales_ts_file()
inject_nuxt_values() inject_nuxt_values()
inject_registration_validation_values() inject_registration_validation_values()

View File

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

View File

@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do: 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! 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. 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 4. Restart the container

View File

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

View File

@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
```yaml ```yaml
services: services:
mealie: mealie:
image: ghcr.io/mealie-recipes/mealie:v3.10.2 # (3) image: ghcr.io/mealie-recipes/mealie:v3.11.0 # (3)
container_name: mealie container_name: mealie
restart: always restart: always
ports: ports:

View File

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

View File

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

View File

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

View File

@@ -76,7 +76,6 @@ const MEAL_DAY_OPTIONS = [
]; ];
function handleQueryFilterInput(value: string | undefined) { function handleQueryFilterInput(value: string | undefined) {
console.warn("handleQueryFilterInput called with value:", value);
queryFilterString.value = value || ""; queryFilterString.value = value || "";
} }
@@ -114,7 +113,7 @@ const fieldDefs: FieldDefinition[] = [
{ {
name: "last_made", name: "last_made",
label: i18n.t("general.last-made"), label: i18n.t("general.last-made"),
type: "date", type: "relativeDate",
}, },
{ {
name: "created_at", name: "created_at",

View File

@@ -108,7 +108,7 @@
<v-select <v-select
v-if="field.type !== 'boolean'" v-if="field.type !== 'boolean'"
:model-value="field.relationalOperatorValue" :model-value="field.relationalOperatorValue"
:items="field.relationalOperatorOptions" :items="field.relationalOperatorChoices"
item-title="label" item-title="label"
item-value="value" item-value="value"
variant="underlined" variant="underlined"
@@ -129,9 +129,9 @@
:class="config.col.class" :class="config.col.class"
> >
<v-select <v-select
v-if="field.fieldOptions" v-if="field.fieldChoices"
:model-value="field.values" :model-value="field.values"
:items="field.fieldOptions" :items="field.fieldChoices"
item-title="label" item-title="label"
item-value="value" item-value="value"
multiple multiple
@@ -169,23 +169,39 @@
> >
<template #activator="{ props: activatorProps }"> <template #activator="{ props: activatorProps }">
<v-text-field <v-text-field
:model-value="field.value ? $d(new Date(field.value + 'T00:00:00')) : null" :model-value="$d(safeNewDate(field.value + 'T00:00:00'))"
persistent-hint
:prepend-icon="$globals.icons.calendar"
variant="underlined" variant="underlined"
color="primary" color="primary"
class="date-input"
v-bind="activatorProps" v-bind="activatorProps"
readonly readonly
/> />
</template> </template>
<v-date-picker <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 hide-header
:first-day-of-week="firstDayOfWeek" :first-day-of-week="firstDayOfWeek"
:local="$i18n.locale" :local="$i18n.locale"
@update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')" @update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
/> />
</v-menu> </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 <RecipeOrganizerSelector
v-else-if="field.type === Organizer.Category" v-else-if="field.type === Organizer.Category"
v-model="field.organizers" v-model="field.organizers"
@@ -319,7 +335,13 @@ import { useDebounceFn } from "@vueuse/core";
import { useHouseholdSelf } from "~/composables/use-households"; import { useHouseholdSelf } from "~/composables/use-households";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue"; import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import { Organizer } from "~/lib/api/types/non-generated"; 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 { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
import { useUserStore } from "~/composables/store/use-user-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"; 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 { household } = useHouseholdSelf();
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder(); const {
logOps,
placeholderKeywords,
getRelOps,
buildQueryFilterString,
getFieldFromFieldDef,
isOrganizerType,
} = useQueryFilterBuilder();
const firstDayOfWeek = computed(() => { const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0; return household.value?.preferences?.firstDayOfWeek || 0;
@@ -396,16 +425,29 @@ function setField(index: number, fieldLabel: string) {
return; 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 }; const updatedField = { ...fields.value[index], ...fieldDef };
// we have to set this explicitly since it might be undefined // we have to set this explicitly since it might be undefined
updatedField.fieldOptions = fieldDef.fieldOptions; updatedField.fieldChoices = fieldDef.fieldChoices;
fields.value[index] = { fields.value[index] = {
...getFieldFromFieldDef(updatedField, resetValue), ...getFieldFromFieldDef(updatedField, resetValue),
id: fields.value[index].id, // keep the id 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) { 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) { function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
const relOps = getRelOps(field.type);
fields.value[index].relationalOperatorValue = relOps.value[value]; fields.value[index].relationalOperatorValue = relOps.value[value];
} }
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) { function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
state.datePickers[index] = false; 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[]) { function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
@@ -448,12 +499,7 @@ function removeField(index: number) {
state.datePickers.splice(index, 1); state.datePickers.splice(index, 1);
} }
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => { const fieldsUpdater = useDebounceFn(() => {
/* newFields.forEach((field, index) => {
const updatedField = getFieldFromFieldDef(field);
fields.value[index] = updatedField; // recursive!!!
}); */
const qf = buildQueryFilterString(fields.value, state.showAdvanced); const qf = buildQueryFilterString(fields.value, state.showAdvanced);
if (qf) { if (qf) {
console.debug(`Set query filter: ${qf}`); console.debug(`Set query filter: ${qf}`);
@@ -519,6 +565,9 @@ async function initializeFields() {
...getFieldFromFieldDef(fieldDef), ...getFieldFromFieldDef(fieldDef),
id: useUid(), id: useUid(),
}; };
const relOps = getRelOps(field.type);
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis; field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis; field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
field.logicalOperator = part.logicalOperator field.logicalOperator = part.logicalOperator
@@ -527,12 +576,15 @@ async function initializeFields() {
field.relationalOperatorValue = part.relationalOperator field.relationalOperatorValue = part.relationalOperator
? relOps.value[part.relationalOperator] ? relOps.value[part.relationalOperator]
: field.relationalOperatorValue; : field.relationalOperatorValue;
field.relationalOperatorValue = part.relationalOperator
? relOps.value[part.relationalOperator]
: field.relationalOperatorValue;
if (field.leftParenthesis || field.rightParenthesis) { if (field.leftParenthesis || field.rightParenthesis) {
state.showAdvanced = true; state.showAdvanced = true;
} }
if (field.fieldOptions?.length || isOrganizerType(field.type)) { if (field.fieldChoices?.length || isOrganizerType(field.type)) {
if (typeof part.value === "string") { if (typeof part.value === "string") {
field.values = part.value ? [part.value] : []; field.values = part.value ? [part.value] : [];
} }
@@ -601,7 +653,7 @@ function buildQueryFilterJSON(): QueryFilterJSON {
relationalOperator: field.relationalOperatorValue?.value, 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()); part.value = field.values.map(value => value.toString());
} }
else if (field.type === "boolean") { else if (field.type === "boolean") {
@@ -619,6 +671,50 @@ function buildQueryFilterJSON(): QueryFilterJSON {
return qfJSON; 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 config = computed(() => {
const multiple = fields.value.length > 1; const multiple = fields.value.length > 1;
const adv = state.showAdvanced; const adv = state.showAdvanced;
@@ -689,4 +785,13 @@ const config = computed(() => {
.bg-light { .bg-light {
background-color: rgba(255, 255, 255, var(--bg-opactity)); 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> </style>

View File

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

View File

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

View File

@@ -219,7 +219,7 @@ const EVENTS = {
shuffle: "shuffle", shuffle: "shuffle",
}; };
const $auth = useMealieAuth(); const auth = useMealieAuth();
const { $globals } = useNuxtApp(); const { $globals } = useNuxtApp();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const useMobileCards = computed(() => { const useMobileCards = computed(() => {
@@ -234,7 +234,7 @@ const sortLoading = ref(false);
const randomSeed = ref(Date.now().toString()); const randomSeed = ref(Date.now().toString());
const route = useRoute(); 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 page = ref(1);
const perPage = 32; const perPage = 32;

View File

@@ -202,13 +202,13 @@ const newMealdateString = computed(() => {
}); });
const i18n = useI18n(); const i18n = useI18n();
const $auth = useMealieAuth(); const auth = useMealieAuth();
const { $globals } = useNuxtApp(); const { $globals } = useNuxtApp();
const { household } = useHouseholdSelf(); const { household } = useHouseholdSelf();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const route = useRoute(); 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(() => { const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0; return household.value?.preferences?.firstDayOfWeek || 0;
@@ -296,12 +296,12 @@ const recipeRefWithScale = computed(() =>
); );
const isAdminAndNotOwner = computed(() => { const isAdminAndNotOwner = computed(() => {
return ( return (
$auth.user.value?.admin auth.user.value?.admin
&& $auth.user.value?.id !== recipeRef.value?.userId && auth.user.value?.id !== recipeRef.value?.userId
); );
}); });
const canDelete = computed(() => { const canDelete = computed(() => {
const user = $auth.user.value; const user = auth.user.value;
const recipe = recipeRef.value; const recipe = recipeRef.value;
return user && recipe && (user.admin || user.id === recipe.userId); return user && recipe && (user.admin || user.id === recipe.userId);
}); });

View File

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

View File

@@ -217,7 +217,7 @@ const props = withDefaults(defineProps<Props>(), {
const dialog = defineModel<boolean>({ default: false }); const dialog = defineModel<boolean>({ default: false });
const i18n = useI18n(); const i18n = useI18n();
const $auth = useMealieAuth(); const auth = useMealieAuth();
const api = useUserApi(); const api = useUserApi();
const preferences = useShoppingListPreferences(); const preferences = useShoppingListPreferences();
const ready = ref(false); const ready = ref(false);
@@ -239,9 +239,9 @@ const selectedShoppingList = ref<ShoppingListSummary | null>(null);
watch([dialog, () => preferences.value.viewAllLists], () => { watch([dialog, () => preferences.value.viewAllLists], () => {
if (dialog.value) { if (dialog.value) {
currentHouseholdSlug.value = $auth.user.value?.householdSlug || ""; currentHouseholdSlug.value = auth.user.value?.householdSlug || "";
filteredShoppingLists.value = props.shoppingLists.filter( 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) { if (filteredShoppingLists.value.length === 1 && !state.shoppingListShowAllToggled) {

View File

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

View File

@@ -119,10 +119,10 @@ whenever(
); );
const i18n = useI18n(); const i18n = useI18n();
const $auth = useMealieAuth(); const auth = useMealieAuth();
const { household } = useHouseholdSelf(); const { household } = useHouseholdSelf();
const route = useRoute(); 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(() => { const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0; return household.value?.preferences?.firstDayOfWeek || 0;

View File

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

View File

@@ -141,13 +141,13 @@ const emit = defineEmits<{
ready: []; ready: [];
}>(); }>();
const $auth = useMealieAuth(); const auth = useMealieAuth();
const route = useRoute(); const route = useRoute();
const { $globals } = useNuxtApp(); const { $globals } = useNuxtApp();
const i18n = useI18n(); const i18n = useI18n();
const showRandomLoading = ref(false); 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 { const {
state, state,

View File

@@ -81,11 +81,11 @@ import {
usePublicToolStore, usePublicToolStore,
} from "~/composables/store"; } from "~/composables/store";
const $auth = useMealieAuth(); const auth = useMealieAuth();
const route = useRoute(); const route = useRoute();
const { isOwnGroup } = useLoggedInState(); 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 { const {
state, state,

View File

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

View File

@@ -58,8 +58,8 @@
density="compact" density="compact"
variant="solo" variant="solo"
return-object return-object
:items="units || []" :items="filteredUnits"
:custom-filter="normalizeFilter" :custom-filter="() => true"
item-title="name" item-title="name"
class="mx-1" class="mx-1"
:placeholder="$t('recipe.choose-unit')" :placeholder="$t('recipe.choose-unit')"
@@ -117,8 +117,8 @@
density="compact" density="compact"
variant="solo" variant="solo"
return-object return-object
:items="foods || []" :items="filteredFoods"
:custom-filter="normalizeFilter" :custom-filter="() => true"
item-title="name" item-title="name"
class="mx-1 py-0" class="mx-1 py-0"
:placeholder="$t('recipe.choose-food')" :placeholder="$t('recipe.choose-food')"
@@ -176,7 +176,6 @@
variant="solo" variant="solo"
return-object return-object
:items="search.data.value || []" :items="search.data.value || []"
:custom-filter="normalizeFilter"
item-title="name" item-title="name"
class="mx-1 py-0" class="mx-1 py-0"
:placeholder="$t('search.type-to-search')" :placeholder="$t('search.type-to-search')"
@@ -227,11 +226,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, reactive, toRefs } from "vue"; import { ref, computed, reactive, toRefs, watch } from "vue";
import { useDisplay } from "vuetify"; import { useDisplay } from "vuetify";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store"; 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 { useNuxtApp } from "#app";
import type { RecipeIngredient } from "~/lib/api/types/recipe"; import type { RecipeIngredient } from "~/lib/api/types/recipe";
import { usePublicExploreApi, useUserApi } from "~/composables/api"; import { usePublicExploreApi, useUserApi } from "~/composables/api";
@@ -343,8 +342,8 @@ const btns = computed(() => {
// Foods // Foods
const foodStore = useFoodStore(); const foodStore = useFoodStore();
const foodData = useFoodData(); const foodData = useFoodData();
const foodSearch = ref("");
const foodAutocomplete = ref<HTMLInputElement>(); const foodAutocomplete = ref<HTMLInputElement>();
const { search: foodSearch, filtered: filteredFoods } = useSearch(foodStore.store);
async function createAssignFood() { async function createAssignFood() {
foodData.data.name = foodSearch.value; foodData.data.name = foodSearch.value;
@@ -355,8 +354,8 @@ async function createAssignFood() {
// Recipes // Recipes
const route = useRoute(); const route = useRoute();
const $auth = useMealieAuth(); const auth = useMealieAuth();
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 { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore; const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
@@ -375,8 +374,8 @@ watch(loading, (val) => {
// Units // Units
const unitStore = useUnitStore(); const unitStore = useUnitStore();
const unitsData = useUnitData(); const unitsData = useUnitData();
const unitSearch = ref("");
const unitAutocomplete = ref<HTMLInputElement>(); const unitAutocomplete = ref<HTMLInputElement>();
const { search: unitSearch, filtered: filteredUnits } = useSearch(unitStore.store);
async function createAssignUnit() { async function createAssignUnit() {
unitsData.data.name = unitSearch.value; unitsData.data.name = unitSearch.value;
@@ -430,9 +429,6 @@ function quantityFilter(e: KeyboardEvent) {
} }
const { showTitle } = toRefs(state); const { showTitle } = toRefs(state);
const foods = foodStore.store;
const units = unitStore.store;
</script> </script>
<style> <style>

View File

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

View File

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

View File

@@ -52,7 +52,7 @@
<script setup lang="ts"> <script setup lang="ts">
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue"; import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
import { parseIngredientText } from "~/composables/recipes"; import { useIngredientTextParser } from "~/composables/recipes";
import type { RecipeIngredient } from "~/lib/api/types/recipe"; import type { RecipeIngredient } from "~/lib/api/types/recipe";
interface Props { interface Props {
@@ -66,6 +66,8 @@ const props = withDefaults(defineProps<Props>(), {
isCookMode: false, isCookMode: false,
}); });
const { parseIngredientText } = useIngredientTextParser();
function validateTitle(title?: string | null) { function validateTitle(title?: string | null) {
return !(title === undefined || title === "" || title === null); return !(title === undefined || title === "" || title === null);
} }

View File

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

View File

@@ -73,10 +73,10 @@ const props = withDefaults(defineProps<Props>(), {
disabled: false, disabled: false,
}); });
const $auth = useMealieAuth(); const auth = useMealieAuth();
const { frac } = useFraction(); const { frac } = useFraction();
const route = useRoute(); 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(() => { const attrs = computed(() => {
return props.small return props.small

View File

@@ -162,9 +162,9 @@ const state = reactive({
}, },
}); });
const $auth = useMealieAuth(); const auth = useMealieAuth();
const route = useRoute(); 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 // Context Menu

View File

@@ -220,11 +220,11 @@ import { useNavigationWarning } from "~/composables/use-navigation-warning";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true }); const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const display = useDisplay(); const display = useDisplay();
const $auth = useMealieAuth(); const auth = useMealieAuth();
const route = useRoute(); const route = useRoute();
const { isOwnGroup } = useLoggedInState(); 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 router = useRouter();
const api = useUserApi(); const api = useUserApi();

View File

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

View File

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

View File

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

View File

@@ -28,8 +28,8 @@
<v-card width="400"> <v-card width="400">
<v-card-text> <v-card-text>
<v-text-field <v-text-field
v-model="state.search" v-model="searchInput"
v-memo="[state.search]" v-memo="[searchInput]"
class="mb-2" class="mb-2"
hide-details hide-details
density="comfortable" density="comfortable"
@@ -146,17 +146,13 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { watchDebounced } from "@vueuse/core"; import type { ISearchableItem } from "~/composables/use-search";
import { useSearch } from "~/composables/use-search";
export interface SelectableItem {
id: string;
name: string;
}
export default defineNuxtComponent({ export default defineNuxtComponent({
props: { props: {
items: { items: {
type: Array as () => SelectableItem[], type: Array as () => ISearchableItem[],
required: true, required: true,
}, },
modelValue: { modelValue: {
@@ -175,12 +171,11 @@ export default defineNuxtComponent({
emits: ["update:requireAll", "update:modelValue"], emits: ["update:requireAll", "update:modelValue"],
setup(props, context) { setup(props, context) {
const state = reactive({ const state = reactive({
search: "",
menu: false, menu: false,
}); });
// Use shallowRef for better performance with arrays // Use the search composable
const debouncedSearch = shallowRef(""); const { search: searchInput, filtered } = useSearch(computed(() => props.items));
const combinator = computed({ const combinator = computed({
get: () => (props.requireAll ? "hasAll" : "hasAny"), get: () => (props.requireAll ? "hasAll" : "hasAny"),
@@ -191,7 +186,7 @@ export default defineNuxtComponent({
// Use shallowRef to prevent deep reactivity on large arrays // Use shallowRef to prevent deep reactivity on large arrays
const selected = computed({ const selected = computed({
get: () => props.modelValue as SelectableItem[], get: () => props.modelValue as ISearchableItem[],
set: (value) => { set: (value) => {
context.emit("update:modelValue", 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 selectedCount = computed(() => selected.value.length);
const selectedIds = computed(() => { const selectedIds = computed(() => {
return new Set(selected.value.map(item => item.id)); return new Set(selected.value.map(item => item.id));
}); });
const handleCheckboxClick = (item: SelectableItem) => { const handleRadioClick = (item: ISearchableItem) => {
const currentSelection = selected.value;
const isSelected = selectedIds.value.has(item.id);
if (isSelected) {
selected.value = currentSelection.filter(i => i.id !== item.id);
}
else {
selected.value = [...currentSelection, item];
}
};
const handleRadioClick = (item: SelectableItem) => {
if (selectedRadio.value === item) { if (selectedRadio.value === item) {
selectedRadio.value = null; selectedRadio.value = null;
} }
@@ -250,18 +213,18 @@ export default defineNuxtComponent({
function clearSelection() { function clearSelection() {
selected.value = []; selected.value = [];
selectedRadio.value = null; selectedRadio.value = null;
state.search = ""; searchInput.value = "";
} }
return { return {
combinator, combinator,
state, state,
searchInput,
selected, selected,
selectedRadio, selectedRadio,
selectedCount, selectedCount,
selectedIds, selectedIds,
filtered, filtered,
handleCheckboxClick,
handleRadioClick, handleRadioClick,
clearSelection, clearSelection,
}; };

View File

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

View File

@@ -102,9 +102,9 @@ export default defineNuxtComponent({
emits: ["update:modelValue"], emits: ["update:modelValue"],
setup(props, context) { setup(props, context) {
const i18n = useI18n(); 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 token = ref("");
const selectedGroup = ref<string | null>(null); const selectedGroup = ref<string | null>(null);
const selectedHousehold = ref<string | null>(null); const selectedHousehold = ref<string | null>(null);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 { useExtractIngredientReferences } from "./use-extract-ingredient-references";
import { useLocales } from "../use-locales";
vi.mock("../use-locales");
const punctuationMarks = ["*", "?", "/", "!", "**", "&", "."]; const punctuationMarks = ["*", "?", "/", "!", "**", "&", "."];
describe("test use extract ingredient references", () => { 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", () => { 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()); expect(result).toStrictEqual(new Set());
}); });
test("when and ingredient matches exactly and has a reference id, return the referenceId", () => { 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"])); expect(result).toEqual(new Set(["123"]));
}); });
test.each(punctuationMarks)("when ingredient is suffixed by punctuation, return the referenceId", (suffix) => { 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"])); expect(result).toEqual(new Set(["123"]));
}); });
test.each(punctuationMarks)("when ingredient is prefixed by punctuation, return the referenceId", (prefix) => { 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"])); expect(result).toEqual(new Set(["123"]));
}); });
test("when ingredient is first on a multiline, return the referenceId", () => { test("when ingredient is first on a multiline, return the referenceId", () => {
const multilineSting = "lksjdlk\nOnion"; 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"])); expect(result).toEqual(new Set(["123"]));
}); });
test("when the ingredient matches partially exactly and has a reference id, return the referenceId", () => { 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"])); expect(result).toEqual(new Set(["123"]));
}); });
test("when the ingredient matches with different casing and has a reference id, return the referenceId", () => { 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"])); expect(result).toEqual(new Set(["123"]));
}); });
test("when no ingredients, return empty", () => { 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()); expect(result).toEqual(new Set());
}); });
test("when and ingredient matches but in the existing referenceIds, do not return the referenceId", () => { 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()); expect(result).toEqual(new Set());
}); });
test("when an word is 2 letter of shorter, it is ignored", () => { 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()); expect(result).toEqual(new Set());
}); });

View File

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

View File

@@ -1,7 +1,7 @@
export { useFraction } from "./use-fraction"; export { useFraction } from "./use-fraction";
export { useRecipe } from "./use-recipe"; export { useRecipe } from "./use-recipe";
export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes"; 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 { useNutritionLabels } from "./use-recipe-nutrition";
export { useTools } from "./use-recipe-tools"; export { useTools } from "./use-recipe-tools";
export { useRecipePermissions } from "./use-recipe-permissions"; export { useRecipePermissions } from "./use-recipe-permissions";

View File

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

View File

@@ -1,9 +1,13 @@
import DOMPurify from "isomorphic-dompurify"; import DOMPurify from "isomorphic-dompurify";
import { useFraction } from "./use-fraction"; import { useFraction } from "./use-fraction";
import { useLocales } from "../use-locales";
import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, Recipe, RecipeIngredient } from "~/lib/api/types/recipe"; import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
const { frac } = useFraction(); const { frac } = useFraction();
const FRAC_MIN_DENOM = 10;
const DECIMAL_PRECISION = 3;
export function sanitizeIngredientHTML(rawHtml: string) { export function sanitizeIngredientHTML(rawHtml: string) {
return DOMPurify.sanitize(rawHtml, { return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true }, USE_PROFILES: { html: true },
@@ -56,47 +60,90 @@ type ParsedIngredientText = {
recipeLink?: string; recipeLink?: string;
}; };
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText { function shouldUsePluralFood(quantity: number, hasUnit: boolean, pluralFoodHandling: string): boolean {
const { quantity, food, unit, note, referencedRecipe } = ingredient; if (quantity && quantity <= 1) {
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0); return false;
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>&frasl;</span><sub>${fraction[2]}</sub>`
: ` ${fraction[1]}/${fraction[2]}`;
}
}
} }
const unitName = useUnitName(unit || undefined, usePluralUnit); switch (pluralFoodHandling) {
const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood); 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>&frasl;</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 { return {
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined, useParsedIngredientText,
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined, parseIngredientText,
name: ingName ? sanitizeIngredientHTML(ingName) : undefined,
note: note ? sanitizeIngredientHTML(note) : undefined,
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
}; };
} }
export function parseIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true): string {
const { quantity, unit, name, note } = useParsedIngredientText(ingredient, scale, includeFormating);
const text = `${quantity || ""} ${unit || ""} ${name || ""} ${note || ""}`.replace(/ {2,}/g, " ").trim();
return sanitizeIngredientHTML(text);
}

View File

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

View File

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

View File

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

View File

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

View 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,
};
}

View File

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

View File

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

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "is greater than", "is-greater-than": "is greater than",
"is-greater-than-or-equal-to": "is greater than or equal to", "is-greater-than-or-equal-to": "is greater than or equal to",
"is-less-than": "is less than", "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": { "relational-keywords": {
"is": "is", "is": "is",
@@ -1432,6 +1434,9 @@
"contains-all-of": "contains all of", "contains-all-of": "contains all of",
"is-like": "is like", "is-like": "is like",
"is-not-like": "is not like" "is-not-like": "is not like"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,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-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "هو", "is": "هو",
@@ -1432,6 +1434,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": { "validators": {

View File

@@ -370,8 +370,8 @@
"applies-to-all-days": "Прилага се за всички дни", "applies-to-all-days": "Прилага се за всички дни",
"applies-on-days": "Всеки/всяка {0}", "applies-on-days": "Всеки/всяка {0}",
"meal-plan-settings": "Настройки на плана за хранене", "meal-plan-settings": "Настройки на плана за хранене",
"add-all-to-list": "Add All to List", "add-all-to-list": "Добавяне на всички към списъка за пазаруване",
"add-day-to-list": "Add Day to List" "add-day-to-list": "Добавяне на ден към списъка за пазаруване"
}, },
"migration": { "migration": {
"migration-data-removed": "Данните за мигриране са премахнати", "migration-data-removed": "Данните за мигриране са премахнати",
@@ -644,7 +644,7 @@
"scrape-recipe-website-being-blocked": "Блокиран ли е уебсайтът?", "scrape-recipe-website-being-blocked": "Блокиран ли е уебсайтът?",
"scrape-recipe-try-importing-raw-html-instead": "Опитайте вместо това да импортирате суровия HTML код.", "scrape-recipe-try-importing-raw-html-instead": "Опитайте вместо това да импортирате суровия HTML код.",
"import-original-keywords-as-tags": "Добави оригиналните ключови думи като етикети", "import-original-keywords-as-tags": "Добави оригиналните ключови думи като етикети",
"import-original-categories": "Import original categories", "import-original-categories": "Импортиране на оригиналните категории",
"stay-in-edit-mode": "Остани в режим на редакция", "stay-in-edit-mode": "Остани в режим на редакция",
"parse-recipe-ingredients-after-import": "Анализиране на съставките на рецептата след импортиране", "parse-recipe-ingredients-after-import": "Анализиране на съставките на рецептата след импортиране",
"import-from-zip": "Импортирай от Zip", "import-from-zip": "Импортирай от Zip",
@@ -1422,7 +1422,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": "e по-малко или равно на" "is-less-than-or-equal-to": "e по-малко или равно на",
"is-older-than": "е по-стар от",
"is-newer-than": "е по-нов от"
}, },
"relational-keywords": { "relational-keywords": {
"is": "е", "is": "е",
@@ -1432,6 +1434,9 @@
"contains-all-of": "съдържа всички от", "contains-all-of": "съдържа всички от",
"is-like": "е като", "is-like": "е като",
"is-not-like": "не е като" "is-not-like": "не е като"
},
"dates": {
"days-ago": "преди дни|преди ден|преди дни"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "és més gran que", "is-greater-than": "és més gran que",
"is-greater-than-or-equal-to": "és més gran o igual a", "is-greater-than-or-equal-to": "és més gran o igual a",
"is-less-than": "és menys que", "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": { "relational-keywords": {
"is": "és", "is": "és",
@@ -1432,6 +1434,9 @@
"contains-all-of": "conté tots de", "contains-all-of": "conté tots de",
"is-like": "és com", "is-like": "és com",
"is-not-like": "no és com" "is-not-like": "no és com"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -212,8 +212,8 @@
"upload-file": "Nahrát soubor", "upload-file": "Nahrát soubor",
"created-on-date": "Vytvořeno dne: {0}", "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.", "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": "Zahodit změny",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?", "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.", "clipboard-copy-failure": "Zkopírování do schránky se nezdařilo.",
"confirm-delete-generic-items": "Opravdu chcete smazat následující položky?", "confirm-delete-generic-items": "Opravdu chcete smazat následující položky?",
"organizers": "Organizace", "organizers": "Organizace",
@@ -370,8 +370,8 @@
"applies-to-all-days": "Použije se na všechny dny", "applies-to-all-days": "Použije se na všechny dny",
"applies-on-days": "Platí pro {0}", "applies-on-days": "Platí pro {0}",
"meal-plan-settings": "Nastavení jídelníčku", "meal-plan-settings": "Nastavení jídelníčku",
"add-all-to-list": "Add All to List", "add-all-to-list": "Přidat vše do seznamu",
"add-day-to-list": "Add Day to List" "add-day-to-list": "Přidat den do seznamu"
}, },
"migration": { "migration": {
"migration-data-removed": "Data z migrace byla smazána", "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-website-being-blocked": "Webové stránky jsou blokovány?",
"scrape-recipe-try-importing-raw-html-instead": "Zkuste namísto toho importovat raw HTML.", "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-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", "stay-in-edit-mode": "Zůstat v režimu úprav",
"parse-recipe-ingredients-after-import": "Po importu analyzovat ingredience receptu", "parse-recipe-ingredients-after-import": "Po importu analyzovat ingredience receptu",
"import-from-zip": "Importovat ze zipu", "import-from-zip": "Importovat ze zipu",
@@ -1422,7 +1422,9 @@
"is-greater-than": "je větší než", "is-greater-than": "je větší než",
"is-greater-than-or-equal-to": "je větší než nebo rovno", "is-greater-than-or-equal-to": "je větší než nebo rovno",
"is-less-than": "je menší než", "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": { "relational-keywords": {
"is": "je", "is": "je",
@@ -1432,6 +1434,9 @@
"contains-all-of": "obsahuje všechny z", "contains-all-of": "obsahuje všechny z",
"is-like": "je jako", "is-like": "je jako",
"is-not-like": "není jako" "is-not-like": "není jako"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "er større end", "is-greater-than": "er større end",
"is-greater-than-or-equal-to": "er større end eller lig med (Automatic Translation)", "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": "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": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "er", "is": "er",
@@ -1432,6 +1434,9 @@
"contains-all-of": "indeholder alle af", "contains-all-of": "indeholder alle af",
"is-like": "er ligesom", "is-like": "er ligesom",
"is-not-like": "er ikke som" "is-not-like": "er ikke som"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "ist größer als", "is-greater-than": "ist größer als",
"is-greater-than-or-equal-to": "ist größer gleich", "is-greater-than-or-equal-to": "ist größer gleich",
"is-less-than": "ist weniger als", "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": { "relational-keywords": {
"is": "ist", "is": "ist",
@@ -1432,6 +1434,9 @@
"contains-all-of": "enthält alle", "contains-all-of": "enthält alle",
"is-like": "ist wie", "is-like": "ist wie",
"is-not-like": "ist nicht wie" "is-not-like": "ist nicht wie"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,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-older-than": "είναι παλαιότερο από",
"is-newer-than": "είναι νεότερο από"
}, },
"relational-keywords": { "relational-keywords": {
"is": "είναι", "is": "είναι",
@@ -1432,6 +1434,9 @@
"contains-all-of": "περιέχει όλα τα", "contains-all-of": "περιέχει όλα τα",
"is-like": "είναι όμοιο με", "is-like": "είναι όμοιο με",
"is-not-like": "δεν είναι όμοιο με" "is-not-like": "δεν είναι όμοιο με"
},
"dates": {
"days-ago": "ημέρες πριν|ημέρα πριν|ημέρες πριν"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "is greater than", "is-greater-than": "is greater than",
"is-greater-than-or-equal-to": "is greater than or equal to", "is-greater-than-or-equal-to": "is greater than or equal to",
"is-less-than": "is less than", "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": { "relational-keywords": {
"is": "is", "is": "is",
@@ -1432,6 +1434,9 @@
"contains-all-of": "contains all of", "contains-all-of": "contains all of",
"is-like": "is like", "is-like": "is like",
"is-not-like": "is not like" "is-not-like": "is not like"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "is greater than", "is-greater-than": "is greater than",
"is-greater-than-or-equal-to": "is greater than or equal to", "is-greater-than-or-equal-to": "is greater than or equal to",
"is-less-than": "is less than", "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": { "relational-keywords": {
"is": "is", "is": "is",
@@ -1432,6 +1434,9 @@
"contains-all-of": "contains all of", "contains-all-of": "contains all of",
"is-like": "is like", "is-like": "is like",
"is-not-like": "is not like" "is-not-like": "is not like"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -212,8 +212,8 @@
"upload-file": "Subir Archivo", "upload-file": "Subir Archivo",
"created-on-date": "Creado el {0}", "created-on-date": "Creado el {0}",
"unsaved-changes": "Tienes cambios sin guardar. ¿Quieres guardar antes de salir? Aceptar para guardar, Cancelar para descartar cambios.", "unsaved-changes": "Tienes cambios sin guardar. ¿Quieres guardar antes de salir? Aceptar para guardar, Cancelar para descartar cambios.",
"discard-changes": "Discard Changes", "discard-changes": "Descartar Cambios",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?", "discard-changes-description": "Tiene cambios sin guardar. ¿Está seguro que desea descartarlos?",
"clipboard-copy-failure": "No se pudo copiar al portapapeles.", "clipboard-copy-failure": "No se pudo copiar al portapapeles.",
"confirm-delete-generic-items": "¿Estás seguro que quieres eliminar los siguientes elementos?", "confirm-delete-generic-items": "¿Estás seguro que quieres eliminar los siguientes elementos?",
"organizers": "Organizadores", "organizers": "Organizadores",
@@ -370,8 +370,8 @@
"applies-to-all-days": "Aplica para todos los días", "applies-to-all-days": "Aplica para todos los días",
"applies-on-days": "Se aplica en {0}s", "applies-on-days": "Se aplica en {0}s",
"meal-plan-settings": "Configuración del Plan de Comidas", "meal-plan-settings": "Configuración del Plan de Comidas",
"add-all-to-list": "Add All to List", "add-all-to-list": "Añadir todos a la lista",
"add-day-to-list": "Add Day to List" "add-day-to-list": "Añadir Día a la Lista"
}, },
"migration": { "migration": {
"migration-data-removed": "Datos de migración eliminados", "migration-data-removed": "Datos de migración eliminados",
@@ -644,7 +644,7 @@
"scrape-recipe-website-being-blocked": "¿Sitio web bloqueado?", "scrape-recipe-website-being-blocked": "¿Sitio web bloqueado?",
"scrape-recipe-try-importing-raw-html-instead": "Intenta importar el HTML en bruto.", "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-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", "stay-in-edit-mode": "Permanecer en modo edición",
"parse-recipe-ingredients-after-import": "Analizar los ingredientes de la receta después de importarla", "parse-recipe-ingredients-after-import": "Analizar los ingredientes de la receta después de importarla",
"import-from-zip": "Importar desde zip", "import-from-zip": "Importar desde zip",
@@ -1422,7 +1422,9 @@
"is-greater-than": "es mayor que", "is-greater-than": "es mayor que",
"is-greater-than-or-equal-to": "es mayor que o igual a", "is-greater-than-or-equal-to": "es mayor que o igual a",
"is-less-than": "es menor que", "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": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "es", "is": "es",
@@ -1432,6 +1434,9 @@
"contains-all-of": "contiene todo de", "contains-all-of": "contiene todo de",
"is-like": "es como", "is-like": "es como",
"is-not-like": "no es como" "is-not-like": "no es como"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "on suurem kui", "is-greater-than": "on suurem kui",
"is-greater-than-or-equal-to": "on suurem või võrdne kui", "is-greater-than-or-equal-to": "on suurem või võrdne kui",
"is-less-than": "on vähem 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": { "relational-keywords": {
"is": "on", "is": "on",
@@ -1432,6 +1434,9 @@
"contains-all-of": "sisaldab kõiki", "contains-all-of": "sisaldab kõiki",
"is-like": "on nagu", "is-like": "on nagu",
"is-not-like": "ei ole nagu" "is-not-like": "ei ole nagu"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -6,18 +6,18 @@
"api-port": "API-portti", "api-port": "API-portti",
"application-mode": "Sovellustila", "application-mode": "Sovellustila",
"database-type": "Tietokannan tyyppi", "database-type": "Tietokannan tyyppi",
"database-url": "Tietokannan URL", "database-url": "Tietokannan URL-osoite",
"default-group": "Oletusryhmä", "default-group": "Oletusryhmä",
"default-household": "Oletuskotitalous", "default-household": "Oletuskotitalous",
"demo": "Demo", "demo": "Esittelytila",
"demo-status": "Demon tila", "demo-status": "Esittelytila",
"development": "Kehitys", "development": "Kehitys",
"docs": "Dokumentit", "docs": "Dokumentit",
"download-log": "Latausloki", "download-log": "Latausloki",
"download-recipe-json": "Viimeisin haettu JSON", "download-recipe-json": "Viimeisin haettu JSON",
"github": "GitHub", "github": "GitHub",
"log-lines": "Lokirivit", "log-lines": "Lokirivit",
"not-demo": "Ei demotilassa", "not-demo": "Ei käytössä",
"portfolio": "Portfolio", "portfolio": "Portfolio",
"production": "Tuotanto", "production": "Tuotanto",
"support": "Tuki", "support": "Tuki",
@@ -138,7 +138,7 @@
"print": "Tulosta", "print": "Tulosta",
"print-preferences": "Tulostusasetukset", "print-preferences": "Tulostusasetukset",
"random": "Satunnainen", "random": "Satunnainen",
"rating": "Arvio", "rating": "Arvosana",
"recent": "Viimeisimmät", "recent": "Viimeisimmät",
"recipe": "Resepti", "recipe": "Resepti",
"recipes": "Reseptit", "recipes": "Reseptit",
@@ -153,7 +153,7 @@
"sort": "Järjestä", "sort": "Järjestä",
"sort-ascending": "Järjestä nousevasti", "sort-ascending": "Järjestä nousevasti",
"sort-descending": "Järjestä laskevasti", "sort-descending": "Järjestä laskevasti",
"sort-alphabetically": "Aakkosjärjestyksessä", "sort-alphabetically": "Aakkosjärjestys",
"status": "Tila", "status": "Tila",
"subject": "Aihe", "subject": "Aihe",
"submit": "Lähetä", "submit": "Lähetä",
@@ -205,15 +205,15 @@
"copied-to-clipboard": "Kopioitu leikepöydälle", "copied-to-clipboard": "Kopioitu leikepöydälle",
"your-browser-does-not-support-clipboard": "Selaimesi ei tue leikepöytää", "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", "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}", "selected-count": "Valittu {count}",
"export-all": "Vie kaikki", "export-all": "Vie kaikki",
"refresh": "Päivitä", "refresh": "Päivitä",
"upload-file": "Tuo tiedosto", "upload-file": "Tuo tiedosto",
"created-on-date": "Luotu {0}", "created-on-date": "Luotu {0}",
"unsaved-changes": "Et ole tallentanut tekemiäsi muutoksia. Tallennetaanko ne? Paina \"ok\" tallentaaksesi ja \"peruuta\", jos et halua tallentaa.", "unsaved-changes": "Tallenna muutokset? ”Ok” tallentaa, ”Peruuta” hylkää muutokset.",
"discard-changes": "Discard Changes", "discard-changes": "Hylkää muutokset",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?", "discard-changes-description": "Muutoksia ei ole tallennettu. Hylätäänkö muutokset?",
"clipboard-copy-failure": "Kopioiminen leikepöydälle epäonnistui.", "clipboard-copy-failure": "Kopioiminen leikepöydälle epäonnistui.",
"confirm-delete-generic-items": "Haluatko varmasti poistaa seuraavat kohteet?", "confirm-delete-generic-items": "Haluatko varmasti poistaa seuraavat kohteet?",
"organizers": "Järjestäjät", "organizers": "Järjestäjät",
@@ -712,8 +712,8 @@
"toggle-recipe": "Vaihda osio" "toggle-recipe": "Vaihda osio"
}, },
"recipe-finder": { "recipe-finder": {
"recipe-finder": "Reseptin etsijä", "recipe-finder": "Reseptihaku",
"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-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", "selected-ingredients": "Valitut ainesosat",
"no-ingredients-selected": "Ei valittuja ainesosia", "no-ingredients-selected": "Ei valittuja ainesosia",
"missing": "Puuttuu", "missing": "Puuttuu",
@@ -723,7 +723,7 @@
"include-tools-on-hand": "Sisällytä saatavilla olevat välineet", "include-tools-on-hand": "Sisällytä saatavilla olevat välineet",
"max-missing-ingredients": "Puuttuvien ainesten enimmäismäärä", "max-missing-ingredients": "Puuttuvien ainesten enimmäismäärä",
"max-missing-tools": "Puuttuvien välineiden 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", "other-filters": "Muut suodattimet",
"ready-to-make": "Valmis tekemään", "ready-to-make": "Valmis tekemään",
"almost-ready-to-make": "Melkein valmis tekemään" "almost-ready-to-make": "Melkein valmis tekemään"
@@ -980,14 +980,14 @@
"tag": "Tunniste" "tag": "Tunniste"
}, },
"tool": { "tool": {
"tools": "Työkalut", "tools": "Keittiövälineet",
"on-hand": "Minulla on tämä työkalu", "on-hand": "Omistan välineen",
"create-a-tool": "Luo työkalu", "create-a-tool": "Lisää keittiöväline",
"tool-name": "Työkalun Nimi", "tool-name": "Keittiöväline",
"create-new-tool": "Luo Uusi Työkalu", "create-new-tool": "Lisää keittiöväline",
"on-hand-checkbox-label": "Näytä työkalut, jotka omistan jo (valittu)", "on-hand-checkbox-label": "Näytä keittiövälineeni (valittu)",
"required-tools": "Tarvittavat Työkalut", "required-tools": "Tarvittavat keittiövälineet",
"tool": "Työkalu" "tool": "Keittiöväline"
}, },
"user": { "user": {
"admin": "Ylläpitäjä", "admin": "Ylläpitäjä",
@@ -1191,9 +1191,9 @@
"tag-data": "Tunnisteen tiedot" "tag-data": "Tunnisteen tiedot"
}, },
"tools": { "tools": {
"new-tool": "Uusi työkalu", "new-tool": "Lisää keittiöväline",
"edit-tool": "Muokkaa työkalua", "edit-tool": "Muokkaa keittiövälinettä",
"tool-data": "Työkalun tiedot" "tool-data": "Keittiövälineen tiedot"
} }
}, },
"user-registration": { "user-registration": {
@@ -1239,7 +1239,7 @@
"preview-markdown-button-label": "Esikatsele Markdownia" "preview-markdown-button-label": "Esikatsele Markdownia"
}, },
"demo": { "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_username": "Käyttäjätunnus: {username}",
"demo_password": "Salasana: {password}" "demo_password": "Salasana: {password}"
}, },
@@ -1404,7 +1404,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.", "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-categories": "Vaadi Kaikki Kategoriat",
"require-all-tags": "Vaadi Kaikki Tunnisteet", "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-name": "Keittokirjan Nimi",
"cookbook-with-name": "Keittokirja {0}", "cookbook-with-name": "Keittokirja {0}",
"household-cookbook-name": "{0} Keittokirja {1}", "household-cookbook-name": "{0} Keittokirja {1}",
@@ -1422,7 +1422,9 @@
"is-greater-than": "on suurempi kuin", "is-greater-than": "on suurempi kuin",
"is-greater-than-or-equal-to": "on suurempi tai yhtäsuuri kuin", "is-greater-than-or-equal-to": "on suurempi tai yhtäsuuri kuin",
"is-less-than": "on vähemmän 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": { "relational-keywords": {
"is": "on", "is": "on",
@@ -1432,13 +1434,16 @@
"contains-all-of": "sisältää kaikki nämä", "contains-all-of": "sisältää kaikki nämä",
"is-like": "on kuin", "is-like": "on kuin",
"is-not-like": "ei ole kuin" "is-not-like": "ei ole kuin"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {
"required": "Tämä kenttä on pakollinen", "required": "Tämä kenttä on pakollinen",
"invalid-email": "Sähköpostiosoite ei ole kelvollinen", "invalid-email": "Sähköpostiosoite ei ole kelvollinen",
"invalid-url": "URL 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ä", "min-length": "Vähimmäispituus on {min} merkkiä",
"max-length": "Enimmäispituus on {max} merkkiä" "max-length": "Enimmäispituus on {max} merkkiä"
} }

View File

@@ -212,8 +212,8 @@
"upload-file": "Transférer un fichier", "upload-file": "Transférer un fichier",
"created-on-date": "Créé le {0}", "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.", "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": "Annuler les modifications",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?", "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.", "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 ?", "confirm-delete-generic-items": "Êtes-vous sûr de vouloir supprimer les éléments suivants ?",
"organizers": "Classification", "organizers": "Classification",
@@ -370,8 +370,8 @@
"applies-to-all-days": "S'applique à tous les jours", "applies-to-all-days": "S'applique à tous les jours",
"applies-on-days": "S'applique les {0}s", "applies-on-days": "S'applique les {0}s",
"meal-plan-settings": "Paramètres des menus", "meal-plan-settings": "Paramètres des menus",
"add-all-to-list": "Add All to List", "add-all-to-list": "Ajouter tout à la liste",
"add-day-to-list": "Add Day to List" "add-day-to-list": "Ajouter un jour à la liste"
}, },
"migration": { "migration": {
"migration-data-removed": "Données de migration supprimées", "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-website-being-blocked": "Le site web est bloqué ?",
"scrape-recipe-try-importing-raw-html-instead": "Essayez plutôt d'importer le code HTML brut.", "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-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", "stay-in-edit-mode": "Rester en mode édition",
"parse-recipe-ingredients-after-import": "Analyser les ingrédients de la recette après l'import", "parse-recipe-ingredients-after-import": "Analyser les ingrédients de la recette après l'import",
"import-from-zip": "Importer depuis un zip", "import-from-zip": "Importer depuis un zip",
@@ -1422,7 +1422,9 @@
"is-greater-than": "est supérieur à", "is-greater-than": "est supérieur à",
"is-greater-than-or-equal-to": "est plus grand que ou égal à", "is-greater-than-or-equal-to": "est plus grand que ou égal à",
"is-less-than": "est inférieur à", "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": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "est", "is": "est",
@@ -1432,6 +1434,9 @@
"contains-all-of": "contient tout", "contains-all-of": "contient tout",
"is-like": "est comme", "is-like": "est comme",
"is-not-like": "n'est pas similaire à" "is-not-like": "n'est pas similaire à"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -212,8 +212,8 @@
"upload-file": "Téléverser un fichier", "upload-file": "Téléverser un fichier",
"created-on-date": "Créé le {0}", "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.", "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": "Annuler les modifications",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?", "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.", "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 ?", "confirm-delete-generic-items": "Êtes-vous sûr de vouloir supprimer les éléments suivants ?",
"organizers": "Classification", "organizers": "Classification",
@@ -370,8 +370,8 @@
"applies-to-all-days": "S'applique à tous les jours", "applies-to-all-days": "S'applique à tous les jours",
"applies-on-days": "S'applique les {0}s", "applies-on-days": "S'applique les {0}s",
"meal-plan-settings": "Paramètres des menus", "meal-plan-settings": "Paramètres des menus",
"add-all-to-list": "Add All to List", "add-all-to-list": "Ajouter tout à la liste",
"add-day-to-list": "Add Day to List" "add-day-to-list": "Ajouter un jour à la liste"
}, },
"migration": { "migration": {
"migration-data-removed": "Données de migration supprimées", "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-website-being-blocked": "Le site web est bloqué ?",
"scrape-recipe-try-importing-raw-html-instead": "Essayez plutôt d'importer le code HTML brut.", "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-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", "stay-in-edit-mode": "Rester en mode édition",
"parse-recipe-ingredients-after-import": "Analyser les ingrédients de la recette après l'import", "parse-recipe-ingredients-after-import": "Analyser les ingrédients de la recette après l'import",
"import-from-zip": "Importer depuis un zip", "import-from-zip": "Importer depuis un zip",
@@ -1422,7 +1422,9 @@
"is-greater-than": "est supérieur à", "is-greater-than": "est supérieur à",
"is-greater-than-or-equal-to": "est supérieur ou égal à", "is-greater-than-or-equal-to": "est supérieur ou égal à",
"is-less-than": "est inférieure à", "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": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "est", "is": "est",
@@ -1432,6 +1434,9 @@
"contains-all-of": "contient tous les", "contains-all-of": "contient tous les",
"is-like": "est similaire à", "is-like": "est similaire à",
"is-not-like": "n'est pas similaire à" "is-not-like": "n'est pas similaire à"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -212,8 +212,8 @@
"upload-file": "Téléverser un fichier", "upload-file": "Téléverser un fichier",
"created-on-date": "Créé le {0}", "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.", "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": "Annuler les modifications",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?", "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.", "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 ?", "confirm-delete-generic-items": "Êtes-vous sûr de vouloir supprimer les éléments suivants ?",
"organizers": "Classification", "organizers": "Classification",
@@ -370,8 +370,8 @@
"applies-to-all-days": "S'applique à tous les jours", "applies-to-all-days": "S'applique à tous les jours",
"applies-on-days": "S'applique les {0}s", "applies-on-days": "S'applique les {0}s",
"meal-plan-settings": "Paramètres des menus", "meal-plan-settings": "Paramètres des menus",
"add-all-to-list": "Add All to List", "add-all-to-list": "Ajouter tout à la liste",
"add-day-to-list": "Add Day to List" "add-day-to-list": "Ajouter un jour à la liste"
}, },
"migration": { "migration": {
"migration-data-removed": "Données de migration supprimées", "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-website-being-blocked": "Le site web est bloqué ?",
"scrape-recipe-try-importing-raw-html-instead": "Essayez plutôt d'importer le code HTML brut.", "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-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", "stay-in-edit-mode": "Rester en mode édition",
"parse-recipe-ingredients-after-import": "Analyser les ingrédients de la recette après l'import", "parse-recipe-ingredients-after-import": "Analyser les ingrédients de la recette après l'import",
"import-from-zip": "Importer depuis un zip", "import-from-zip": "Importer depuis un zip",
@@ -1422,7 +1422,9 @@
"is-greater-than": "est supérieur à", "is-greater-than": "est supérieur à",
"is-greater-than-or-equal-to": "est plus grand que ou égal à", "is-greater-than-or-equal-to": "est plus grand que ou égal à",
"is-less-than": "est inférieur à", "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": { "relational-keywords": {
"is": "est", "is": "est",
@@ -1432,6 +1434,9 @@
"contains-all-of": "contient tout", "contains-all-of": "contient tout",
"is-like": "est comme", "is-like": "est comme",
"is-not-like": "n'est pas similaire à" "is-not-like": "n'est pas similaire à"
},
"dates": {
"days-ago": "jours|jour|jours"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "é maior que", "is-greater-than": "é maior que",
"is-greater-than-or-equal-to": "é maior ou igual a", "is-greater-than-or-equal-to": "é maior ou igual a",
"is-less-than": "é menor que", "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": { "relational-keywords": {
"is": "é", "is": "é",
@@ -1432,6 +1434,9 @@
"contains-all-of": "contén todos os", "contains-all-of": "contén todos os",
"is-like": "é como", "is-like": "é como",
"is-not-like": "non é como" "is-not-like": "non é como"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,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-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "זהה ל-", "is": "זהה ל-",
@@ -1432,6 +1434,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": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "is greater than", "is-greater-than": "is greater than",
"is-greater-than-or-equal-to": "is greater than or equal to", "is-greater-than-or-equal-to": "is greater than or equal to",
"is-less-than": "is less than", "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": { "relational-keywords": {
"is": "is", "is": "is",
@@ -1432,6 +1434,9 @@
"contains-all-of": "contains all of", "contains-all-of": "contains all of",
"is-like": "is like", "is-like": "is like",
"is-not-like": "is not like" "is-not-like": "is not like"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "nagyobb, mint", "is-greater-than": "nagyobb, mint",
"is-greater-than-or-equal-to": "nagyobb vagy egyenlő", "is-greater-than-or-equal-to": "nagyobb vagy egyenlő",
"is-less-than": "kevesebb, mint", "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": { "relational-keywords": {
"is": "van", "is": "van",
@@ -1432,6 +1434,9 @@
"contains-all-of": "tartalmazza az összes", "contains-all-of": "tartalmazza az összes",
"is-like": "hasonló", "is-like": "hasonló",
"is-not-like": "nem hasonló" "is-not-like": "nem hasonló"
},
"dates": {
"days-ago": "napja|napja|napja"
} }
}, },
"validators": { "validators": {

View File

@@ -212,8 +212,8 @@
"upload-file": "Hlaða upp skrá", "upload-file": "Hlaða upp skrá",
"created-on-date": "Búið til: {0}", "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.", "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": "Henda breytingum",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?", "discard-changes-description": "Þú ert með óvistaðar breytingar, ertu viss um að þú viljir henda þeim?",
"clipboard-copy-failure": "Mistókst að afrita klippispjaldið.", "clipboard-copy-failure": "Mistókst að afrita klippispjaldið.",
"confirm-delete-generic-items": "Ertu viss um að þú viljir eyða eftirfylgjandi atriðum?", "confirm-delete-generic-items": "Ertu viss um að þú viljir eyða eftirfylgjandi atriðum?",
"organizers": "Skipuleggjarar", "organizers": "Skipuleggjarar",
@@ -370,8 +370,8 @@
"applies-to-all-days": "Á við alla daga", "applies-to-all-days": "Á við alla daga",
"applies-on-days": "Gildir þegar er {0},", "applies-on-days": "Gildir þegar er {0},",
"meal-plan-settings": "Stillingar matarplans", "meal-plan-settings": "Stillingar matarplans",
"add-all-to-list": "Add All to List", "add-all-to-list": "Bæta öllum á innkaupalista",
"add-day-to-list": "Add Day to List" "add-day-to-list": "Bæta deginum á innkaupalista"
}, },
"migration": { "migration": {
"migration-data-removed": "Gagnaflutningur fjarlægður", "migration-data-removed": "Gagnaflutningur fjarlægður",
@@ -644,7 +644,7 @@
"scrape-recipe-website-being-blocked": "Er vefsíðan lokuð?", "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.", "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-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", "stay-in-edit-mode": "Vera í breytingarham",
"parse-recipe-ingredients-after-import": "Greina innhald uppskriftar eftir að búið er að hlaða inn uppskrift", "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", "import-from-zip": "Hlaða inn frá .zip",
@@ -1422,7 +1422,9 @@
"is-greater-than": "eftir þann", "is-greater-than": "eftir þann",
"is-greater-than-or-equal-to": "þann eða eftir þann", "is-greater-than-or-equal-to": "þann eða eftir þann",
"is-less-than": "fyrir þ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": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "er", "is": "er",
@@ -1432,6 +1434,9 @@
"contains-all-of": "inniheldur alla af", "contains-all-of": "inniheldur alla af",
"is-like": "is like", "is-like": "is like",
"is-not-like": "is not like" "is-not-like": "is not like"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -3,7 +3,7 @@
"about": "Dettagli", "about": "Dettagli",
"about-mealie": "Info su Mealie", "about-mealie": "Info su Mealie",
"api-docs": "Documentazione API", "api-docs": "Documentazione API",
"api-port": "Porta API", "api-port": "Importazione API",
"application-mode": "Modalità", "application-mode": "Modalità",
"database-type": "Tipo Database", "database-type": "Tipo Database",
"database-url": "URL Database", "database-url": "URL Database",
@@ -97,7 +97,7 @@
"custom": "Personalizzato", "custom": "Personalizzato",
"dashboard": "Pannello di controllo", "dashboard": "Pannello di controllo",
"delete": "Elimina", "delete": "Elimina",
"disabled": "Disabilitato", "disabled": "Disabilita",
"download": "Download", "download": "Download",
"duplicate": "Duplicato", "duplicate": "Duplicato",
"edit": "Modifica", "edit": "Modifica",
@@ -370,8 +370,8 @@
"applies-to-all-days": "Si applica a ogni giorno", "applies-to-all-days": "Si applica a ogni giorno",
"applies-on-days": "Si applica ai {0}", "applies-on-days": "Si applica ai {0}",
"meal-plan-settings": "Impostazioni del piano alimentare", "meal-plan-settings": "Impostazioni del piano alimentare",
"add-all-to-list": "Add All to List", "add-all-to-list": "Aggiungi tutto alla lista",
"add-day-to-list": "Add Day to List" "add-day-to-list": "Aggiungi Giorno"
}, },
"migration": { "migration": {
"migration-data-removed": "Dati di migrazione rimossi", "migration-data-removed": "Dati di migrazione rimossi",
@@ -1422,7 +1422,9 @@
"is-greater-than": "è maggiore di", "is-greater-than": "è maggiore di",
"is-greater-than-or-equal-to": "è maggiore o uguale di", "is-greater-than-or-equal-to": "è maggiore o uguale di",
"is-less-than": "è minore 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": { "relational-keywords": {
"is": "è", "is": "è",
@@ -1432,6 +1434,9 @@
"contains-all-of": "contiene tutti i", "contains-all-of": "contiene tutti i",
"is-like": "è simile", "is-like": "è simile",
"is-not-like": "non è come" "is-not-like": "non è come"
},
"dates": {
"days-ago": "giorni fa|giorno fa|giorni fa"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,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-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "は", "is": "は",
@@ -1432,6 +1434,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": { "validators": {

View File

@@ -1,6 +1,6 @@
{ {
"about": { "about": {
"about": "정보", "about": "약/대략",
"about-mealie": "Mealie에 대해", "about-mealie": "Mealie에 대해",
"api-docs": "API 문서", "api-docs": "API 문서",
"api-port": "API 포트", "api-port": "API 포트",
@@ -14,7 +14,7 @@
"development": "개발", "development": "개발",
"docs": "문서", "docs": "문서",
"download-log": "다운로드 기록", "download-log": "다운로드 기록",
"download-recipe-json": "마지막으로 불러온 JSON", "download-recipe-json": "마지막으로 스크랩한 JSON",
"github": "GitHub", "github": "GitHub",
"log-lines": "로그 줄", "log-lines": "로그 줄",
"not-demo": "데모 아님", "not-demo": "데모 아님",
@@ -67,10 +67,10 @@
"test-message-sent": "테스트 메시지가 전송됐습니다.", "test-message-sent": "테스트 메시지가 전송됐습니다.",
"message-sent": "메세지가 전송됨", "message-sent": "메세지가 전송됨",
"new-notification": "새 알림", "new-notification": "새 알림",
"event-notifiers": "이벤트 알림이", "event-notifiers": "이벤트 알리미",
"apprise-url-skipped-if-blank": "Apprise URL (비워두면 생략합니다)", "apprise-url-skipped-if-blank": "Apprise URL (비워두면 생략합니다)",
"apprise-url-is-left-intentionally-blank": "Apprise URL에는 일반적으로 민감한 정보가 포함되므로, 편집 시 이 필드는 의도적으로 비워둡니다. URL을 수정하려면 여기에 새 주소를 입력하시고, 현재 URL을 유지하려면 비워두십시오.", "apprise-url-is-left-intentionally-blank": "Apprise URL에는 일반적으로 민감한 정보가 포함되므로, 편집 시 이 필드는 의도적으로 비워둡니다. URL을 수정하려면 여기에 새 주소를 입력하시고, 현재 URL을 유지하려면 비워두십시오.",
"enable-notifier": "알 활성화", "enable-notifier": "알리미 활성화",
"what-events": "이 알리미는 어떤 이벤트를 구독해야 합니까?", "what-events": "이 알리미는 어떤 이벤트를 구독해야 합니까?",
"user-events": "사용자 이벤트", "user-events": "사용자 이벤트",
"mealplan-events": "Mealplan 이벤트", "mealplan-events": "Mealplan 이벤트",
@@ -193,7 +193,7 @@
"delete-with-name": "{name} 삭제", "delete-with-name": "{name} 삭제",
"confirm-delete-generic-with-name": "{name}을(를) 정말 삭제하시겠습니까?", "confirm-delete-generic-with-name": "{name}을(를) 정말 삭제하시겠습니까?",
"confirm-delete-own-admin-account": "본인의 관리자 계정을 삭제하려고 한다는 점에 유의하세요! 이 작업은 취소할 수 없으며 계정이 영구적으로 삭제됩니다.", "confirm-delete-own-admin-account": "본인의 관리자 계정을 삭제하려고 한다는 점에 유의하세요! 이 작업은 취소할 수 없으며 계정이 영구적으로 삭제됩니다.",
"organizer": "정리 도구", "organizer": "정리",
"transfer": "전송", "transfer": "전송",
"copy": "복사", "copy": "복사",
"color": "색상", "color": "색상",
@@ -216,7 +216,7 @@
"discard-changes-description": "저장되지 않은 변경사항이 있습니다. 삭제하시겠습니까?", "discard-changes-description": "저장되지 않은 변경사항이 있습니다. 삭제하시겠습니까?",
"clipboard-copy-failure": "클립보드에 복사하는 데 실패했습니다.", "clipboard-copy-failure": "클립보드에 복사하는 데 실패했습니다.",
"confirm-delete-generic-items": "이 항목을 삭제하시겠습니까?", "confirm-delete-generic-items": "이 항목을 삭제하시겠습니까?",
"organizers": "정리 도구", "organizers": "정리",
"caution": "주의", "caution": "주의",
"show-advanced": "고급 표시", "show-advanced": "고급 표시",
"add-field": "필드 추가", "add-field": "필드 추가",
@@ -339,7 +339,7 @@
"side": "사이드", "side": "사이드",
"sides": "사이드", "sides": "사이드",
"start-date": "시작 일자", "start-date": "시작 일자",
"rule-day": "규칙의 날", "rule-day": "규칙을 적용할 요일",
"meal-type": "식사 유형", "meal-type": "식사 유형",
"breakfast": "조식", "breakfast": "조식",
"lunch": "점심", "lunch": "점심",
@@ -357,14 +357,14 @@
"random-meal": "랜덤 식사", "random-meal": "랜덤 식사",
"random-dinner": "랜덤 저녁식사", "random-dinner": "랜덤 저녁식사",
"random-side": "랜덤 사이드 메뉴", "random-side": "랜덤 사이드 메뉴",
"this-rule-will-apply": "이 규칙은 {dayCriteria} {mealTypeCriteria} 적용됩니다.", "this-rule-will-apply": "이 규칙은 {dayCriteria} {mealTypeCriteria} 적용됩니다.",
"to-all-days": "모든 날에", "to-all-days": "모든 날에",
"on-days": "{0}에", "on-days": "{0}에",
"for-all-meal-types": "모든 식사 종류에", "for-all-meal-types": "모든 식사 종류에",
"for-type-meal-types": "{0} 식사 종류에", "for-type-meal-types": "{0} 식사 종류에",
"meal-plan-rules": "식단 계획 규칙", "meal-plan-rules": "식단 계획 규칙",
"new-rule": "새 규칙", "new-rule": "새 규칙",
"meal-plan-rules-description": "식단 계획에 사용할 레시피를 자동 선택하 규칙을 생성할 수 있습니다. 이 규칙은 서버 식단 계획을 생성할 때 선택할 무작위 레시피 을 결정하는 데 사용됩니다. 동일한 요일/유형 제약 조건을 가진 규칙들은 필터가 병합된다는 점에 유의하세요. 실제로 중복 규칙을 생성할 필요는 없지만, 생성하는 것 가능합니다.", "meal-plan-rules-description": "식단 계획에 사용할 레시피를 자동으로 선택하기 위한 규칙을 만들 수 있습니다. 이러한 규칙은 서버에서 식단 계획을 생성할 때 무작위로 선택할 레시피 목록을 결정하는 데 사용됩니다. 규칙에 동일한 요일/식사 유형 조건이 있는 경우 규칙 필터가 병합된다는 점에 유의하십시오. 실제로 중복 규칙을 만들 필요는 없지만, 만드는 것 가능합니다.",
"new-rule-description": "식단 계획에 새 규칙을 생성할 때, 특정 요일 및/또는 특정 식사 유형에만 적용되도록 규칙을 제한할 수 있습니다. 모든 요일 또는 모든 식사 유형에 규칙을 적용하려면 규칙을 \"모든\"으로 설정하면 됩니다. 이렇게 하면 해당 요일 및/또는 식사 유형의 모든 가능한 값에 규칙이 적용됩니다.", "new-rule-description": "식단 계획에 새 규칙을 생성할 때, 특정 요일 및/또는 특정 식사 유형에만 적용되도록 규칙을 제한할 수 있습니다. 모든 요일 또는 모든 식사 유형에 규칙을 적용하려면 규칙을 \"모든\"으로 설정하면 됩니다. 이렇게 하면 해당 요일 및/또는 식사 유형의 모든 가능한 값에 규칙이 적용됩니다.",
"recipe-rules": "레시피 규칙", "recipe-rules": "레시피 규칙",
"applies-to-all-days": "모든 날짜에 적용됨", "applies-to-all-days": "모든 날짜에 적용됨",
@@ -375,7 +375,7 @@
}, },
"migration": { "migration": {
"migration-data-removed": "이전된 데이터 제거됨", "migration-data-removed": "이전된 데이터 제거됨",
"new-migration": "새 마이그레이션", "new-migration": "새 데이터 이전",
"no-file-selected": "선택된 파일이 없습니다", "no-file-selected": "선택된 파일이 없습니다",
"no-migration-data-available": "이전 데이터가 없습니다.", "no-migration-data-available": "이전 데이터가 없습니다.",
"previous-migrations": "이전 데이터 이전", "previous-migrations": "이전 데이터 이전",
@@ -392,19 +392,19 @@
}, },
"copymethat": { "copymethat": {
"description-long": "Mealie는 Copy Me That에서 레시피를 가져올 수 있습니다. 레시피를 HTML 형식으로 내보낸 후, 아래의 .zip 파일을 업로드하세요.", "description-long": "Mealie는 Copy Me That에서 레시피를 가져올 수 있습니다. 레시피를 HTML 형식으로 내보낸 후, 아래의 .zip 파일을 업로드하세요.",
"title": "Copy Me That 레시피 매니저" "title": "Copy Me That Recipe Manager"
}, },
"paprika": { "paprika": {
"description-long": "Mealie는 Paprika 애플리케이션에서 레시피를 가져올 수 있습니다. Paprika에서 레시피를 내보낸 후, 내보낸 파일의 확장자를 .zip으로 변경하여 아래에 업로드하세요.", "description-long": "Mealie는 Paprika 애플리케이션에서 레시피를 가져올 수 있습니다. Paprika에서 레시피를 내보낸 후, 내보낸 파일의 확장자를 .zip으로 변경하여 아래에 업로드하세요.",
"title": "Paprika 레시피 매니저" "title": "Paprika Recipe Manager"
}, },
"mealie-pre-v1": { "mealie-pre-v1": {
"description-long": "Mealie는 v1.0 이전 버전의 Mealie 애플리케이션에서 레시피를 가져올 수 있습니다. 기존 인스턴스에서 레시피를 내보낸 후 아래의 zip 파일을 업로드하세요. 내보 파일에서 레시피만 가져올 수 있다는 점에 유의하십시오.", "description-long": "Mealie는 v1.0 이전 버전의 Mealie 애플리케이션에서 레시피를 가져올 수 있습니다. 기존 인스턴스에서 레시피를 내보낸 후 아래에 있는 압축 파일을 업로드하세요. 내보내기 파일에서 레시피만 가져올 수 있다는 점에 유의하세요.",
"title": "Mealie Pre v1.0" "title": "Mealie Pre v1.0"
}, },
"tandoor": { "tandoor": {
"description-long": "Mealie는 Tandoor에서 레시피를 가져올 수 있습니다. 데이터를 \"기본\" 형식으로 내보낸 후 아래의 .zip 파일을 업로드하세요.", "description-long": "Mealie는 Tandoor에서 레시피를 가져올 수 있습니다. 데이터를 \"기본\" 형식으로 내보낸 후 아래의 .zip 파일을 업로드하세요.",
"title": "Tandoor 레시피" "title": "Tandoor Recipes"
}, },
"cookn": { "cookn": {
"description-long": "Mealie는 DVO Cook'n X3의 레시피를 가져올 수 있습니다. \"Cook'n\" 형식으로 요리책이나 메뉴를 내보낸 후, 내보낸 파일의 확장자를 .zip으로 변경하고 아래에 .zip 파일을 업로드하세요.", "description-long": "Mealie는 DVO Cook'n X3의 레시피를 가져올 수 있습니다. \"Cook'n\" 형식으로 요리책이나 메뉴를 내보낸 후, 내보낸 파일의 확장자를 .zip으로 변경하고 아래에 .zip 파일을 업로드하세요.",
@@ -420,17 +420,17 @@
"recipe-1": "레시피 1", "recipe-1": "레시피 1",
"recipe-2": "레시피 2", "recipe-2": "레시피 2",
"paprika-text": "Mealie는 Paprika 애플리케이션에서 레시피를 가져올 수 있습니다. Paprika에서 레시피를 내보낸 후, 내보낸 파일의 확장자를 .zip으로 변경하여 아래에 업로드하세요.", "paprika-text": "Mealie는 Paprika 애플리케이션에서 레시피를 가져올 수 있습니다. Paprika에서 레시피를 내보낸 후, 내보낸 파일의 확장자를 .zip으로 변경하여 아래에 업로드하세요.",
"mealie-text": "Mealie는 v1.0 이전 버전의 Mealie 애플리케이션에서 레시피를 가져올 수 있습니다. 기존 인스턴스에서 레시피를 내보낸 후 아래의 zip 파일을 업로드하세요. 내보 파일에서 레시피만 가져올 수 있다는 점에 유의하십시오.", "mealie-text": "Mealie는 v1.0 이전 버전의 Mealie 애플리케이션에서 레시피를 가져올 수 있습니다. 기존 인스턴스에서 레시피를 내보낸 후 아래에 있는 압축 파일을 업로드하세요. 내보내기 파일에서 레시피만 가져올 수 있다는 점에 유의하세요.",
"plantoeat": { "plantoeat": {
"title": "Plan to Eat", "title": "Plan to Eat",
"description-long": "Mealie는 Plan to Eat에서 레시피를 가져올 수 있습니다." "description-long": "Mealie는 Plan to Eat에서 레시피를 가져올 수 있습니다."
}, },
"myrecipebox": { "myrecipebox": {
"title": "내 레시피 박스", "title": "My Recipe Box",
"description-long": "Mealie는 My Recipe Box에서 레시피를 가져올 수 있습니다. CSV 형식으로 레시피를 내보낸 다음 아래의 .csv 파일을 업로드하세요." "description-long": "Mealie는 My Recipe Box에서 레시피를 가져올 수 있습니다. CSV 형식으로 레시피를 내보낸 다음 아래의 .csv 파일을 업로드하세요."
}, },
"recipekeeper": { "recipekeeper": {
"title": "레시피 보관함", "title": "Recipe Keeper",
"description-long": "Mealie는 Recipe Keeper에서 레시피를 가져올 수 있습니다. 레시피를 zip 형식으로 내보낸 다음 아래의 .zip 파일을 업로드하세요." "description-long": "Mealie는 Recipe Keeper에서 레시피를 가져올 수 있습니다. 레시피를 zip 형식으로 내보낸 다음 아래의 .zip 파일을 업로드하세요."
} }
}, },
@@ -712,7 +712,7 @@
"toggle-recipe": "레시피로 전환" "toggle-recipe": "레시피로 전환"
}, },
"recipe-finder": { "recipe-finder": {
"recipe-finder": "레시피 찾기", "recipe-finder": "레시피 검색",
"recipe-finder-description": "가지고 있는 재료로 레시피를 검색하세요. 사용 가능한 도구로도 필터링할 수 있으며, 부족한 재료나 도구의 최대 개수를 설정할 수 있습니다.", "recipe-finder-description": "가지고 있는 재료로 레시피를 검색하세요. 사용 가능한 도구로도 필터링할 수 있으며, 부족한 재료나 도구의 최대 개수를 설정할 수 있습니다.",
"selected-ingredients": "선택된 재료", "selected-ingredients": "선택된 재료",
"no-ingredients-selected": "선택된 재료 없음", "no-ingredients-selected": "선택된 재료 없음",
@@ -794,7 +794,7 @@
"latest": "가장 최근", "latest": "가장 최근",
"local-api": "로컬 API", "local-api": "로컬 API",
"locale-settings": "국가별 설정", "locale-settings": "국가별 설정",
"migrations": "마이그레이션", "migrations": "데이터 이전",
"new-page": "새 페이지", "new-page": "새 페이지",
"notify": "알림", "notify": "알림",
"organize": "정리", "organize": "정리",
@@ -1395,7 +1395,7 @@
}, },
"cookbook": { "cookbook": {
"cookbooks": "요리책", "cookbooks": "요리책",
"description": "요리책은 레시피, 정리 도구 및 기타 필터를 교차 조합하여 레시피를 체계화하는 또 다른 방법입니다. 요리책을 생성하면 사이드바에 항목이 추가되며, 선택한 필터가 적용된 모든 레시피가 해당 요리책에 표시됩니다.", "description": "요리책은 레시피, 정리 및 기타 필터를 교차 조합하여 레시피를 체계화하는 또 다른 방법입니다. 요리책을 생성하면 사이드바에 항목이 추가되며, 선택한 필터가 적용된 모든 레시피가 해당 요리책에 표시됩니다.",
"hide-cookbooks-from-other-households": "다른 가구의 요리책 숨기기", "hide-cookbooks-from-other-households": "다른 가구의 요리책 숨기기",
"hide-cookbooks-from-other-households-description": "이 기능을 활성화하면 사이드바에는 귀하의 가구에 속한 요리책만 표시됩니다.", "hide-cookbooks-from-other-households-description": "이 기능을 활성화하면 사이드바에는 귀하의 가구에 속한 요리책만 표시됩니다.",
"public-cookbook": "공개 요리책", "public-cookbook": "공개 요리책",
@@ -1419,19 +1419,24 @@
"relational-operators": { "relational-operators": {
"equals": "정확히 일치", "equals": "정확히 일치",
"does-not-equal": "일치하지 않음", "does-not-equal": "일치하지 않음",
"is-greater-than": "은(는) 다음보다 크다:", "is-greater-than": "다음보다 ",
"is-greater-than-or-equal-to": "은(는) 다음보다 크거나 같다:", "is-greater-than-or-equal-to": "다음보다 크거나 같",
"is-less-than": "은(는) 다음보다 작다:", "is-less-than": "다음보다 작",
"is-less-than-or-equal-to": "은(는) 다음보다 작거나 같다:" "is-less-than-or-equal-to": "다음보다 작거나 같음",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "은(는)", "is": "같음",
"is-not": "은(는) 아니다", "is-not": "아님",
"is-one-of": "은(는) 다음에 포함:", "is-one-of": "다음에 포함",
"is-not-one-of": "은(는) 다음에 포함되지 않음:", "is-not-one-of": "다음에 포함되지 않음",
"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": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "is greater than", "is-greater-than": "is greater than",
"is-greater-than-or-equal-to": "is greater than or equal to", "is-greater-than-or-equal-to": "is greater than or equal to",
"is-less-than": "is less than", "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": { "relational-keywords": {
"is": "is", "is": "is",
@@ -1432,6 +1434,9 @@
"contains-all-of": "contains all of", "contains-all-of": "contains all of",
"is-like": "is like", "is-like": "is like",
"is-not-like": "is not like" "is-not-like": "is not like"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "ir lielāks par", "is-greater-than": "ir lielāks par",
"is-greater-than-or-equal-to": "ir lielāks vai vienāds ar", "is-greater-than-or-equal-to": "ir lielāks vai vienāds ar",
"is-less-than": "ir mazāks par", "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": { "relational-keywords": {
"is": "IR", "is": "IR",
@@ -1432,6 +1434,9 @@
"contains-all-of": "satur visus", "contains-all-of": "satur visus",
"is-like": "ir kā", "is-like": "ir kā",
"is-not-like": "nav tāds, kā" "is-not-like": "nav tāds, kā"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "is groter dan", "is-greater-than": "is groter dan",
"is-greater-than-or-equal-to": "is groter dan of gelijk aan", "is-greater-than-or-equal-to": "is groter dan of gelijk aan",
"is-less-than": "is kleiner dan", "is-less-than": "is kleiner dan",
"is-less-than-or-equal-to": "is kleiner dan of gelijk aan" "is-less-than-or-equal-to": "is kleiner dan of gelijk aan",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "is", "is": "is",
@@ -1432,6 +1434,9 @@
"contains-all-of": "bevat alles van", "contains-all-of": "bevat alles van",
"is-like": "is zoals", "is-like": "is zoals",
"is-not-like": "is niet zoals" "is-not-like": "is niet zoals"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "er større enn", "is-greater-than": "er større enn",
"is-greater-than-or-equal-to": "er større enn eller lik", "is-greater-than-or-equal-to": "er større enn eller lik",
"is-less-than": "er mindre enn", "is-less-than": "er mindre enn",
"is-less-than-or-equal-to": "er mindre enn eller lik" "is-less-than-or-equal-to": "er mindre enn eller lik",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "er", "is": "er",
@@ -1432,6 +1434,9 @@
"contains-all-of": "inneholder alle", "contains-all-of": "inneholder alle",
"is-like": "er som", "is-like": "er som",
"is-not-like": "er ikke som" "is-not-like": "er ikke som"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -370,8 +370,8 @@
"applies-to-all-days": "Dotyczy wszystkich dni", "applies-to-all-days": "Dotyczy wszystkich dni",
"applies-on-days": "Dotyczy {0}", "applies-on-days": "Dotyczy {0}",
"meal-plan-settings": "Ustawienia planera posiłków", "meal-plan-settings": "Ustawienia planera posiłków",
"add-all-to-list": "Add All to List", "add-all-to-list": "Dodaj wszystko do listy",
"add-day-to-list": "Add Day to List" "add-day-to-list": "Dodaj Dzień do Listy"
}, },
"migration": { "migration": {
"migration-data-removed": "Dane migracji usunięte", "migration-data-removed": "Dane migracji usunięte",
@@ -1422,7 +1422,9 @@
"is-greater-than": "jest większe niż", "is-greater-than": "jest większe niż",
"is-greater-than-or-equal-to": "jest większe lub równe", "is-greater-than-or-equal-to": "jest większe lub równe",
"is-less-than": "jest mniejsze niż", "is-less-than": "jest mniejsze niż",
"is-less-than-or-equal-to": "jest mniejsze lub równe" "is-less-than-or-equal-to": "jest mniejsze lub równe",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "jest", "is": "jest",
@@ -1432,6 +1434,9 @@
"contains-all-of": "zawiera wszystkie z", "contains-all-of": "zawiera wszystkie z",
"is-like": "jest jak", "is-like": "jest jak",
"is-not-like": "nie jest jak" "is-not-like": "nie jest jak"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "é maior que", "is-greater-than": "é maior que",
"is-greater-than-or-equal-to": "é maior ou igual a", "is-greater-than-or-equal-to": "é maior ou igual a",
"is-less-than": "é menor que", "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": { "relational-keywords": {
"is": "é", "is": "é",
@@ -1432,6 +1434,9 @@
"contains-all-of": "contém todos de", "contains-all-of": "contém todos de",
"is-like": "é como", "is-like": "é como",
"is-not-like": "não é como" "is-not-like": "não é como"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1,7 +1,7 @@
{ {
"about": { "about": {
"about": "Sobre", "about": "Sobre",
"about-mealie": "Sobre o Mealie", "about-mealie": "Sobre Mealie",
"api-docs": "Documentação de API", "api-docs": "Documentação de API",
"api-port": "Porta da API", "api-port": "Porta da API",
"application-mode": "Modo de aplicação", "application-mode": "Modo de aplicação",
@@ -16,7 +16,7 @@
"download-log": "Transferir Log", "download-log": "Transferir Log",
"download-recipe-json": "Último JSON recuperado", "download-recipe-json": "Último JSON recuperado",
"github": "GitHub", "github": "GitHub",
"log-lines": "Linhas de registo", "log-lines": "Linhas de Logs",
"not-demo": "Não Demonstração", "not-demo": "Não Demonstração",
"portfolio": "Portefólio", "portfolio": "Portefólio",
"production": "Produção", "production": "Produção",
@@ -212,8 +212,8 @@
"upload-file": "Carregar ficheiro", "upload-file": "Carregar ficheiro",
"created-on-date": "Criado em: {0}", "created-on-date": "Criado em: {0}",
"unsaved-changes": "Tem alterações por gravar. Quer gravar antes de sair? OK para gravar, Cancelar para descartar alterações.", "unsaved-changes": "Tem alterações por gravar. Quer gravar antes de sair? OK para gravar, Cancelar para descartar alterações.",
"discard-changes": "Discard Changes", "discard-changes": "Descartar alterações",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?", "discard-changes-description": "Tem alterações por gravar. De certeza que as quer descartar?",
"clipboard-copy-failure": "Erro ao copiar para a área de transferência.", "clipboard-copy-failure": "Erro ao copiar para a área de transferência.",
"confirm-delete-generic-items": "Tem a certeza de que deseja eliminar os seguintes itens?", "confirm-delete-generic-items": "Tem a certeza de que deseja eliminar os seguintes itens?",
"organizers": "Organizadores", "organizers": "Organizadores",
@@ -370,8 +370,8 @@
"applies-to-all-days": "Aplica-se a todos os dias", "applies-to-all-days": "Aplica-se a todos os dias",
"applies-on-days": "Aplica-se em {0}s", "applies-on-days": "Aplica-se em {0}s",
"meal-plan-settings": "Definições do Plano de Refeições", "meal-plan-settings": "Definições do Plano de Refeições",
"add-all-to-list": "Add All to List", "add-all-to-list": "Adicionar todos",
"add-day-to-list": "Add Day to List" "add-day-to-list": "Adicionar Dia"
}, },
"migration": { "migration": {
"migration-data-removed": "Dados de migração removidos", "migration-data-removed": "Dados de migração removidos",
@@ -644,7 +644,7 @@
"scrape-recipe-website-being-blocked": "O site está bloqueado?", "scrape-recipe-website-being-blocked": "O site está bloqueado?",
"scrape-recipe-try-importing-raw-html-instead": "Tente importar o HTML bruto em vez disso.", "scrape-recipe-try-importing-raw-html-instead": "Tente importar o HTML bruto em vez disso.",
"import-original-keywords-as-tags": "Importar palavras-chave originais como etiquetas", "import-original-keywords-as-tags": "Importar palavras-chave originais como etiquetas",
"import-original-categories": "Import original categories", "import-original-categories": "Importar categorias originais",
"stay-in-edit-mode": "Permanecer no modo de edição", "stay-in-edit-mode": "Permanecer no modo de edição",
"parse-recipe-ingredients-after-import": "Analisar ingredientes da receita após a importação", "parse-recipe-ingredients-after-import": "Analisar ingredientes da receita após a importação",
"import-from-zip": "Importar de Zip", "import-from-zip": "Importar de Zip",
@@ -1422,7 +1422,9 @@
"is-greater-than": "é maior que", "is-greater-than": "é maior que",
"is-greater-than-or-equal-to": "é maior ou igual a", "is-greater-than-or-equal-to": "é maior ou igual a",
"is-less-than": "é menor que", "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": "é mais antigo que",
"is-newer-than": "é mais recente que"
}, },
"relational-keywords": { "relational-keywords": {
"is": "é", "is": "é",
@@ -1432,6 +1434,9 @@
"contains-all-of": "contém todos os", "contains-all-of": "contém todos os",
"is-like": "é como", "is-like": "é como",
"is-not-like": "não é como" "is-not-like": "não é como"
},
"dates": {
"days-ago": "dias atrás|dia atrás|dias atrás"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "este mai mare ca", "is-greater-than": "este mai mare ca",
"is-greater-than-or-equal-to": "este mai mare sau egală cu", "is-greater-than-or-equal-to": "este mai mare sau egală cu",
"is-less-than": "este mai mic decât", "is-less-than": "este mai mic decât",
"is-less-than-or-equal-to": "este mai mic sau egal cu" "is-less-than-or-equal-to": "este mai mic sau egal cu",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "este", "is": "este",
@@ -1432,6 +1434,9 @@
"contains-all-of": "conţine toate din", "contains-all-of": "conţine toate din",
"is-like": "este similar", "is-like": "este similar",
"is-not-like": "nu este similar" "is-not-like": "nu este similar"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,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-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "соответствует", "is": "соответствует",
@@ -1432,6 +1434,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": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "je väčšie ako", "is-greater-than": "je väčšie ako",
"is-greater-than-or-equal-to": "je väčšie alebo rovné", "is-greater-than-or-equal-to": "je väčšie alebo rovné",
"is-less-than": "je menšie ako", "is-less-than": "je menšie ako",
"is-less-than-or-equal-to": "je menšie alebo rovné" "is-less-than-or-equal-to": "je menšie alebo rovné",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "je", "is": "je",
@@ -1432,6 +1434,9 @@
"contains-all-of": "obsahuje všetky", "contains-all-of": "obsahuje všetky",
"is-like": "je ako", "is-like": "je ako",
"is-not-like": "nie je ako" "is-not-like": "nie je ako"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "je več kot", "is-greater-than": "je več kot",
"is-greater-than-or-equal-to": "je več kot ali enako kot", "is-greater-than-or-equal-to": "je več kot ali enako kot",
"is-less-than": "je manj kot", "is-less-than": "je manj kot",
"is-less-than-or-equal-to": "je manj kot ali enako kot" "is-less-than-or-equal-to": "je manj kot ali enako kot",
"is-older-than": "je starejše od",
"is-newer-than": "je novejše od"
}, },
"relational-keywords": { "relational-keywords": {
"is": "je", "is": "je",
@@ -1432,6 +1434,9 @@
"contains-all-of": "vsebuje vse", "contains-all-of": "vsebuje vse",
"is-like": "je kot", "is-like": "je kot",
"is-not-like": "ni kot" "is-not-like": "ni kot"
},
"dates": {
"days-ago": "pred dnevi|dan nazaj|pred dnevi"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,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-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "је", "is": "је",
@@ -1432,6 +1434,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": { "validators": {

View File

@@ -370,8 +370,8 @@
"applies-to-all-days": "Gäller för alla dagar", "applies-to-all-days": "Gäller för alla dagar",
"applies-on-days": "Gäller på {0}s", "applies-on-days": "Gäller på {0}s",
"meal-plan-settings": "Inställningar för måltidsplanering", "meal-plan-settings": "Inställningar för måltidsplanering",
"add-all-to-list": "Add All to List", "add-all-to-list": "Lägg till alla i inköpslista",
"add-day-to-list": "Add Day to List" "add-day-to-list": "Lägg till dag i inköpslista"
}, },
"migration": { "migration": {
"migration-data-removed": "Importerad data borttagen", "migration-data-removed": "Importerad data borttagen",
@@ -1422,7 +1422,9 @@
"is-greater-than": "är större än", "is-greater-than": "är större än",
"is-greater-than-or-equal-to": "är större än eller lika med", "is-greater-than-or-equal-to": "är större än eller lika med",
"is-less-than": "är mindre än", "is-less-than": "är mindre än",
"is-less-than-or-equal-to": "är mindre eller lika med" "is-less-than-or-equal-to": "är mindre eller lika med",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "är", "is": "är",
@@ -1432,6 +1434,9 @@
"contains-all-of": "innehåller alla", "contains-all-of": "innehåller alla",
"is-like": "är som", "is-like": "är som",
"is-not-like": "är inte som" "is-not-like": "är inte som"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "daha büyük", "is-greater-than": "daha büyük",
"is-greater-than-or-equal-to": "daha büyük veya eşit", "is-greater-than-or-equal-to": "daha büyük veya eşit",
"is-less-than": "daha mı az", "is-less-than": "daha mı az",
"is-less-than-or-equal-to": "daha az veya eşit mi" "is-less-than-or-equal-to": "daha az veya eşit mi",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "is", "is": "is",
@@ -1432,6 +1434,9 @@
"contains-all-of": "hepsini içeriyor mu", "contains-all-of": "hepsini içeriyor mu",
"is-like": "is like", "is-like": "is like",
"is-not-like": "is not like" "is-not-like": "is not like"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,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-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "є", "is": "є",
@@ -1432,6 +1434,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": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "is greater than", "is-greater-than": "is greater than",
"is-greater-than-or-equal-to": "is greater than or equal to", "is-greater-than-or-equal-to": "is greater than or equal to",
"is-less-than": "is less than", "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": { "relational-keywords": {
"is": "is", "is": "is",
@@ -1432,6 +1434,9 @@
"contains-all-of": "contains all of", "contains-all-of": "contains all of",
"is-like": "is like", "is-like": "is like",
"is-not-like": "is not like" "is-not-like": "is not like"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -1422,7 +1422,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-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "是", "is": "是",
@@ -1432,6 +1434,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": { "validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "is greater than", "is-greater-than": "is greater than",
"is-greater-than-or-equal-to": "is greater than or equal to", "is-greater-than-or-equal-to": "is greater than or equal to",
"is-less-than": "is less than", "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": { "relational-keywords": {
"is": "is", "is": "is",
@@ -1432,6 +1434,9 @@
"contains-all-of": "contains all of", "contains-all-of": "contains all of",
"is-like": "is like", "is-like": "is like",
"is-not-like": "is not like" "is-not-like": "is not like"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

@@ -64,7 +64,7 @@ export default defineNuxtComponent({
}); });
const i18n = useGlobalI18n(); const i18n = useGlobalI18n();
const $auth = useMealieAuth(); const auth = useMealieAuth();
const { $globals } = useNuxtApp(); const { $globals } = useNuxtApp();
const ready = ref(false); const ready = ref(false);
@@ -72,7 +72,7 @@ export default defineNuxtComponent({
const router = useRouter(); const router = useRouter();
async function insertGroupSlugIntoRoute() { async function insertGroupSlugIntoRoute() {
const groupSlug = ref($auth.user.value?.groupSlug); const groupSlug = ref(auth.user.value?.groupSlug);
if (!groupSlug.value) { if (!groupSlug.value) {
return; return;
} }

View File

@@ -41,8 +41,9 @@ export enum Organizer {
User = "users", User = "users",
} }
export type LogicalOperator = "AND" | "OR"; export type PlaceholderKeyword = "$NOW";
export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE"; export type RelationalKeyword = "IS" | "IS NOT" | "IN" | "NOT IN" | "CONTAINS ALL" | "LIKE" | "NOT LIKE";
export type LogicalOperator = "AND" | "OR";
export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<="; export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<=";
export interface QueryFilterJSON { export interface QueryFilterJSON {

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