Compare commits

..

61 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
mealie-commit-bot[bot]
4101797c0e chore: bump version to v3.10.2 2026-02-04 23:32:41 +00:00
Michael Genson
6110200a04 fix: OIDC caching (#7009) 2026-02-04 14:03:40 -06:00
renovate[bot]
49f1e76776 fix(deps): update dependency fastapi to v0.128.1 (#7008)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-04 19:20:25 +00:00
Hayden
24e9417d02 chore(l10n): New Crowdin updates (#7005) 2026-02-04 11:57:13 +00:00
renovate[bot]
69d6985f3b chore(deps): update node.js to 1de022d (#7002)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-04 00:23:07 +00:00
renovate[bot]
84cdeb2398 chore(deps): update dependency coverage to v7.13.3 (#6998)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-04 00:23:00 +00:00
Hayden
6d439de144 chore(l10n): New Crowdin updates (#7004) 2026-02-03 23:33:27 +00:00
Michael Genson
1b586f8c67 chore: Upgrade to ruff 15.0.0 (#7003) 2026-02-03 16:43:42 -06:00
whattheschnell
f82f387146 fix: use BASE_URL config for redirect_url if available (#6995)
Co-authored-by: Michael Genson <genson.michael@gmail.com>
2026-02-03 16:31:20 -06:00
Hayden
d31c07a6c5 chore(l10n): New Crowdin updates (#6997) 2026-02-03 14:15:18 +00:00
renovate[bot]
84372c2f4f chore(deps): update node.js to bdc7252 (#6996)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 14:15:03 +00:00
183 changed files with 4887 additions and 3980 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
1. Take a backup just in case!
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.10.1`
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.11.0`
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
4. Restart the container

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -141,13 +141,13 @@ const emit = defineEmits<{
ready: [];
}>();
const $auth = useMealieAuth();
const auth = useMealieAuth();
const route = useRoute();
const { $globals } = useNuxtApp();
const i18n = useI18n();
const showRandomLoading = ref(false);
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const {
state,

View File

@@ -81,11 +81,11 @@ import {
usePublicToolStore,
} from "~/composables/store";
const $auth = useMealieAuth();
const auth = useMealieAuth();
const route = useRoute();
const { isOwnGroup } = useLoggedInState();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const {
state,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -220,11 +220,11 @@ import { useNavigationWarning } from "~/composables/use-navigation-warning";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const display = useDisplay();
const $auth = useMealieAuth();
const auth = useMealieAuth();
const route = useRoute();
const { isOwnGroup } = useLoggedInState();
const groupSlug = computed(() => (route.params.groupSlug as string) || $auth.user?.value?.groupSlug || "");
const groupSlug = computed(() => (route.params.groupSlug as string) || auth.user?.value?.groupSlug || "");
const router = useRouter();
const api = useUserApi();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,21 @@
import { describe, test, expect } from "vitest";
import { parseIngredientText } from "./use-recipe-ingredients";
import { describe, test, expect, vi, beforeEach } from "vitest";
import { useIngredientTextParser } from "./use-recipe-ingredients";
import type { RecipeIngredient } from "~/lib/api/types/recipe";
import { useLocales } from "../use-locales";
vi.mock("../use-locales");
let parseIngredientText: (ingredient: RecipeIngredient, scale?: number, includeFormating?: boolean) => string;
describe("parseIngredientText", () => {
beforeEach(() => {
vi.mocked(useLocales).mockReturnValue({
locales: [{ value: "en-US", pluralFoodHandling: "always" }],
locale: { value: "en-US", pluralFoodHandling: "always" },
} as any);
({ parseIngredientText } = useIngredientTextParser());
});
describe(parseIngredientText.name, () => {
const createRecipeIngredient = (overrides: Partial<RecipeIngredient>): RecipeIngredient => ({
quantity: 1,
food: {
@@ -128,4 +141,98 @@ describe(parseIngredientText.name, () => {
expect(parseIngredientText(ingredient, 2)).toEqual("2 tablespoons diced onions");
});
test("plural handling: 'always' strategy uses plural food with unit", () => {
vi.mocked(useLocales).mockReturnValue({
locales: [{ value: "en-US", pluralFoodHandling: "always" }],
locale: { value: "en-US", pluralFoodHandling: "always" },
} as any);
const { parseIngredientText } = useIngredientTextParser();
const ingredient = createRecipeIngredient({
quantity: 2,
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
});
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onions");
});
test("plural handling: 'never' strategy never uses plural food", () => {
vi.mocked(useLocales).mockReturnValue({
locales: [{ value: "en-US", pluralFoodHandling: "never" }],
locale: { value: "en-US", pluralFoodHandling: "never" },
} as any);
const { parseIngredientText } = useIngredientTextParser();
const ingredient = createRecipeIngredient({
quantity: 2,
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
});
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onion");
});
test("plural handling: 'without-unit' strategy uses plural food without unit", () => {
vi.mocked(useLocales).mockReturnValue({
locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
locale: { value: "en-US", pluralFoodHandling: "without-unit" },
} as any);
const { parseIngredientText } = useIngredientTextParser();
const ingredient = createRecipeIngredient({
quantity: 2,
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
unit: undefined,
});
expect(parseIngredientText(ingredient)).toEqual("2 diced onions");
});
test("plural handling: 'without-unit' strategy uses singular food with unit", () => {
vi.mocked(useLocales).mockReturnValue({
locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
locale: { value: "en-US", pluralFoodHandling: "without-unit" },
} as any);
const { parseIngredientText } = useIngredientTextParser();
const ingredient = createRecipeIngredient({
quantity: 2,
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
});
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onion");
});
test("decimal below minimum precision shows < 0.001", () => {
const ingredient = createRecipeIngredient({
quantity: 0.0001,
unit: { id: "1", name: "cup", useAbbreviation: false },
food: { id: "1", name: "salt" },
});
expect(parseIngredientText(ingredient)).toEqual("&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 { useFraction } from "./use-fraction";
import { useLocales } from "../use-locales";
import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
const { frac } = useFraction();
const FRAC_MIN_DENOM = 10;
const DECIMAL_PRECISION = 3;
export function sanitizeIngredientHTML(rawHtml: string) {
return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
@@ -56,47 +60,90 @@ type ParsedIngredientText = {
recipeLink?: string;
};
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText {
const { quantity, food, unit, note, referencedRecipe } = ingredient;
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
const usePluralFood = (!quantity) || quantity * scale > 1;
let returnQty = "";
// casting to number is required as sometimes quantity is a string
if (quantity && Number(quantity) !== 0) {
if (unit && !unit.fraction) {
returnQty = Number((quantity * scale).toPrecision(3)).toString();
}
else {
const fraction = frac(quantity * scale, 10, true);
if (fraction[0] !== undefined && fraction[0] > 0) {
returnQty += fraction[0];
}
if (fraction[1] > 0) {
returnQty += includeFormating
? `<sup>${fraction[1]}</sup><span>&frasl;</span><sub>${fraction[2]}</sub>`
: ` ${fraction[1]}/${fraction[2]}`;
}
}
function shouldUsePluralFood(quantity: number, hasUnit: boolean, pluralFoodHandling: string): boolean {
if (quantity && quantity <= 1) {
return false;
}
const unitName = useUnitName(unit || undefined, usePluralUnit);
const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood);
switch (pluralFoodHandling) {
case "always":
return true;
case "without-unit":
return !(quantity && hasUnit);
case "never":
return false;
default:
// same as without-unit
return !(quantity && hasUnit);
}
}
export function useIngredientTextParser() {
const { locales, locale } = useLocales();
function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText {
const filteredLocales = locales.filter(lc => lc.value === locale.value);
const pluralFoodHandling = filteredLocales.length ? filteredLocales[0].pluralFoodHandling : "without-unit";
const { quantity, food, unit, note, referencedRecipe } = ingredient;
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
const usePluralFood = shouldUsePluralFood((quantity || 0) * scale, !!unit, pluralFoodHandling);
let returnQty = "";
// casting to number is required as sometimes quantity is a string
if (quantity && Number(quantity) !== 0) {
const scaledQuantity = Number((quantity * scale));
if (unit && !unit.fraction) {
const minVal = 10 ** -DECIMAL_PRECISION;
returnQty = scaledQuantity >= minVal
? Number(scaledQuantity.toPrecision(DECIMAL_PRECISION)).toString()
: `< ${minVal}`;
}
else {
const minVal = 1 / FRAC_MIN_DENOM;
const isUnderMinVal = !(scaledQuantity >= minVal);
const fraction = !isUnderMinVal ? frac(scaledQuantity, FRAC_MIN_DENOM, true) : [0, 1, FRAC_MIN_DENOM];
if (fraction[0] !== undefined && fraction[0] > 0) {
returnQty += fraction[0];
}
if (fraction[1] > 0) {
returnQty += includeFormating
? `<sup>${fraction[1]}</sup><span>&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 {
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
name: ingName ? sanitizeIngredientHTML(ingName) : undefined,
note: note ? sanitizeIngredientHTML(note) : undefined,
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
useParsedIngredientText,
parseIngredientText,
};
}
export function parseIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true): string {
const { quantity, unit, name, note } = useParsedIngredientText(ingredient, scale, includeFormating);
const text = `${quantity || ""} ${unit || ""} ${name || ""} ${note || ""}`.replace(/ {2,}/g, " ").trim();
return sanitizeIngredientHTML(text);
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

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-less-than-or-equal-to": "is less than or equal to",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "is",
@@ -1432,6 +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": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "أكبر من",
"is-greater-than-or-equal-to": "أكبر من أو يساوي",
"is-less-than": "أقل من",
"is-less-than-or-equal-to": "أقل من أو يساوي"
"is-less-than-or-equal-to": "أقل من أو يساوي",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "هو",
@@ -1432,6 +1434,9 @@
"contains-all-of": "يحتوي على كل من",
"is-like": "هو مثل",
"is-not-like": "ليس مثل"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {

View File

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

View File

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

View File

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

View File

@@ -212,8 +212,8 @@
"upload-file": "Upload fil",
"created-on-date": "Oprettet den: {0}",
"unsaved-changes": "Du har ændringer som ikke er gemt. Vil du gemme før du forlader? Vælg \"Okay\" for at gemme, eller \"Annullér\" for at kassere ændringer.",
"discard-changes": "Discard Changes",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
"discard-changes": "Kassér ændringer",
"discard-changes-description": "Du har ændringer, der ikke er gemt. Er du sikker på, at du vil kassere dem?",
"clipboard-copy-failure": "Kopiering til udklipsholderen mislykkedes.",
"confirm-delete-generic-items": "Er du sikker på at du ønsker at slette de valgte emner?",
"organizers": "Organisatorer",
@@ -370,8 +370,8 @@
"applies-to-all-days": "Gælder for alle dage",
"applies-on-days": "Gælder for {0}e",
"meal-plan-settings": "Indstillinger for madplanlægning",
"add-all-to-list": "Add All to List",
"add-day-to-list": "Add Day to List"
"add-all-to-list": "Tilføj alle til liste",
"add-day-to-list": "Tilføj dag til liste"
},
"migration": {
"migration-data-removed": "Migreringsdata fjernet",
@@ -644,7 +644,7 @@
"scrape-recipe-website-being-blocked": "Bliver hjemmesiden blokeret?",
"scrape-recipe-try-importing-raw-html-instead": "Forsøg at importere den rå HTML i stedet.",
"import-original-keywords-as-tags": "Importér originale nøgleord som mærker",
"import-original-categories": "Import original categories",
"import-original-categories": "Importér originale kategorier",
"stay-in-edit-mode": "Bliv i redigeringstilstand",
"parse-recipe-ingredients-after-import": "Fortolk opskrift ingredienser efter import",
"import-from-zip": "Importer fra zip-fil",
@@ -1422,7 +1422,9 @@
"is-greater-than": "er større end",
"is-greater-than-or-equal-to": "er større end eller lig med (Automatic Translation)",
"is-less-than": "er mindre end (Automatic Translation)",
"is-less-than-or-equal-to": "er mindre end eller lig med (Automatic Translation)"
"is-less-than-or-equal-to": "er mindre end eller lig med (Automatic Translation)",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "er",
@@ -1432,6 +1434,9 @@
"contains-all-of": "indeholder alle af",
"is-like": "er ligesom",
"is-not-like": "er ikke som"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {

View File

@@ -212,8 +212,8 @@
"upload-file": "Datei hochladen",
"created-on-date": "Erstellt am: {0}",
"unsaved-changes": "Du hast ungespeicherte Änderungen. Möchtest du vor dem Verlassen speichern? OK um zu speichern, Cancel um Änderungen zu verwerfen.",
"discard-changes": "Discard Changes",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
"discard-changes": "Änderungen verwerfen",
"discard-changes-description": "Du hast ungespeicherte Änderungen. Bist du sicher, dass du sie verwerfen möchtest?",
"clipboard-copy-failure": "Fehler beim Kopieren in die Zwischenablage.",
"confirm-delete-generic-items": "Bist du dir sicher, dass du die folgenden Einträge löschen möchtest?",
"organizers": "Organisieren",
@@ -370,8 +370,8 @@
"applies-to-all-days": "Gilt an allen Tagen",
"applies-on-days": "Gilt {0}s",
"meal-plan-settings": "Essensplan Einstellungen",
"add-all-to-list": "Add All to List",
"add-day-to-list": "Add Day to List"
"add-all-to-list": "Alle zur Einkaufsliste hinzufügen",
"add-day-to-list": "Tag zur Einkaufsliste hinzufügen"
},
"migration": {
"migration-data-removed": "Migrationsdaten entfernt",
@@ -644,7 +644,7 @@
"scrape-recipe-website-being-blocked": "Die Website wird blockiert?",
"scrape-recipe-try-importing-raw-html-instead": "Versuche stattdessen das reine HTML zu importieren.",
"import-original-keywords-as-tags": "Importiere ursprüngliche Stichwörter als Schlagwörter",
"import-original-categories": "Import original categories",
"import-original-categories": "Importiere ursprüngliche Kategorien",
"stay-in-edit-mode": "Im Bearbeitungsmodus bleiben",
"parse-recipe-ingredients-after-import": "Zutaten nach dem Import parsen",
"import-from-zip": "Von Zip importieren",
@@ -1422,7 +1422,9 @@
"is-greater-than": "ist größer als",
"is-greater-than-or-equal-to": "ist größer gleich",
"is-less-than": "ist weniger als",
"is-less-than-or-equal-to": "ist kleiner gleich"
"is-less-than-or-equal-to": "ist kleiner gleich",
"is-older-than": "Ist älter als",
"is-newer-than": "Ist neuer als"
},
"relational-keywords": {
"is": "ist",
@@ -1432,6 +1434,9 @@
"contains-all-of": "enthält alle",
"is-like": "ist wie",
"is-not-like": "ist nicht wie"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {

View File

@@ -644,7 +644,7 @@
"scrape-recipe-website-being-blocked": "Η ιστοσελίδα μπλοκάρεται;",
"scrape-recipe-try-importing-raw-html-instead": "Δοκιμάστε να εισάγετε τον ακατέργαστο κώδικα HTML.",
"import-original-keywords-as-tags": "Εισαγωγή αρχικών λέξεων-κλειδιών ως ετικέτες",
"import-original-categories": "Import original categories",
"import-original-categories": "Εισαγωγή αρχικών κατηγοριών",
"stay-in-edit-mode": "Παραμονή σε λειτουργία επεξεργασίας",
"parse-recipe-ingredients-after-import": "Ανάλυση συστατικών συνταγής μετά την εισαγωγή",
"import-from-zip": "Εισαγωγή μέσω zip",
@@ -1422,7 +1422,9 @@
"is-greater-than": "είναι μεγαλύτερο από",
"is-greater-than-or-equal-to": "είναι μεγαλύτερο από ή ίσο με",
"is-less-than": "είναι μικρότερο από",
"is-less-than-or-equal-to": "είναι μικρότερο από ή ίσο με"
"is-less-than-or-equal-to": "είναι μικρότερο από ή ίσο με",
"is-older-than": "είναι παλαιότερο από",
"is-newer-than": "είναι νεότερο από"
},
"relational-keywords": {
"is": "είναι",
@@ -1432,6 +1434,9 @@
"contains-all-of": "περιέχει όλα τα",
"is-like": "είναι όμοιο με",
"is-not-like": "δεν είναι όμοιο με"
},
"dates": {
"days-ago": "ημέρες πριν|ημέρα πριν|ημέρες πριν"
}
},
"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-less-than-or-equal-to": "is less than or equal to",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "is",
@@ -1432,6 +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": {

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-less-than-or-equal-to": "is less than or equal to",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "is",
@@ -1432,6 +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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -212,8 +212,8 @@
"upload-file": "Téléverser un fichier",
"created-on-date": "Créé le {0}",
"unsaved-changes": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer avant de partir? OK pour enregistrer, Annuler pour ignorer les modifications.",
"discard-changes": "Discard Changes",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
"discard-changes": "Annuler les modifications",
"discard-changes-description": "Vous avez des changements non sauvegardés. Êtes-vous sûr de vouloir les annuler ?",
"clipboard-copy-failure": "Échec de la copie dans le presse-papiers.",
"confirm-delete-generic-items": "Êtes-vous sûr de vouloir supprimer les éléments suivants ?",
"organizers": "Classification",
@@ -370,8 +370,8 @@
"applies-to-all-days": "S'applique à tous les jours",
"applies-on-days": "S'applique les {0}s",
"meal-plan-settings": "Paramètres des menus",
"add-all-to-list": "Add All to List",
"add-day-to-list": "Add Day to List"
"add-all-to-list": "Ajouter tout à la liste",
"add-day-to-list": "Ajouter un jour à la liste"
},
"migration": {
"migration-data-removed": "Données de migration supprimées",
@@ -644,7 +644,7 @@
"scrape-recipe-website-being-blocked": "Le site web est bloqué ?",
"scrape-recipe-try-importing-raw-html-instead": "Essayez plutôt d'importer le code HTML brut.",
"import-original-keywords-as-tags": "Importer les mots-clés d'origine en tant que tags",
"import-original-categories": "Import original categories",
"import-original-categories": "Importer les catégories originales",
"stay-in-edit-mode": "Rester en mode édition",
"parse-recipe-ingredients-after-import": "Analyser les ingrédients de la recette après l'import",
"import-from-zip": "Importer depuis un zip",
@@ -1422,7 +1422,9 @@
"is-greater-than": "est supérieur à",
"is-greater-than-or-equal-to": "est plus grand que ou égal à",
"is-less-than": "est inférieur à",
"is-less-than-or-equal-to": "est inférieur ou égal à"
"is-less-than-or-equal-to": "est inférieur ou égal à",
"is-older-than": "est plus ancien que",
"is-newer-than": "est plus récent que"
},
"relational-keywords": {
"is": "est",
@@ -1432,6 +1434,9 @@
"contains-all-of": "contient tout",
"is-like": "est comme",
"is-not-like": "n'est pas similaire à"
},
"dates": {
"days-ago": "jours|jour|jours"
}
},
"validators": {

View File

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

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "גדול מ-",
"is-greater-than-or-equal-to": "גדול או שווה ל-",
"is-less-than": "קטן מ-",
"is-less-than-or-equal-to": "קטן או שווה ל-"
"is-less-than-or-equal-to": "קטן או שווה ל-",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "זהה ל-",
@@ -1432,6 +1434,9 @@
"contains-all-of": "מכיל הכל מתוך",
"is-like": "דומה ל-",
"is-not-like": "לא דומה לא-"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"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-less-than-or-equal-to": "is less than or equal to",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "is",
@@ -1432,6 +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": {

View File

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

View File

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

View File

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

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "より大きい",
"is-greater-than-or-equal-to": "以上",
"is-less-than": "より小さい",
"is-less-than-or-equal-to": "以下"
"is-less-than-or-equal-to": "以下",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "は",
@@ -1432,6 +1434,9 @@
"contains-all-of": "すべてを含む",
"is-like": "次のようなものです",
"is-not-like": "というわけではありません"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {

View File

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

View File

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

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "is groter dan",
"is-greater-than-or-equal-to": "is groter dan of gelijk aan",
"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": {
"is": "is",
@@ -1432,6 +1434,9 @@
"contains-all-of": "bevat alles van",
"is-like": "is zoals",
"is-not-like": "is niet zoals"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "er større enn",
"is-greater-than-or-equal-to": "er større enn eller lik",
"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": {
"is": "er",
@@ -1432,6 +1434,9 @@
"contains-all-of": "inneholder alle",
"is-like": "er som",
"is-not-like": "er ikke som"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {

View File

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

View File

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

View File

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

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "este mai mare ca",
"is-greater-than-or-equal-to": "este mai mare sau egală cu",
"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": {
"is": "este",
@@ -1432,6 +1434,9 @@
"contains-all-of": "conţine toate din",
"is-like": "este similar",
"is-not-like": "nu este similar"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "больше чем",
"is-greater-than-or-equal-to": "больше или равно",
"is-less-than": "меньше чем",
"is-less-than-or-equal-to": "меньше или равно"
"is-less-than-or-equal-to": "меньше или равно",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "соответствует",
@@ -1432,6 +1434,9 @@
"contains-all-of": "содержит все",
"is-like": "содержит",
"is-not-like": "не содержит"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {

View File

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

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "je več kot",
"is-greater-than-or-equal-to": "je več kot ali enako 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": {
"is": "je",
@@ -1432,6 +1434,9 @@
"contains-all-of": "vsebuje vse",
"is-like": "je kot",
"is-not-like": "ni kot"
},
"dates": {
"days-ago": "pred dnevi|dan nazaj|pred dnevi"
}
},
"validators": {

View File

@@ -1,14 +1,14 @@
{
"about": {
"about": "О апликацији",
"about-mealie": "О Мили",
"about-mealie": "О Mealie",
"api-docs": "API документација",
"api-port": "API прикључак",
"application-mode": "Режим апликације",
"database-type": "Тип базе података",
"database-url": "URL базе података",
"default-group": "Подразумевана група",
"default-household": "Default Household",
"default-household": "Подразумевано домаћинство",
"demo": "Демо",
"demo-status": "Демо статус",
"development": "Развој",
@@ -16,7 +16,7 @@
"download-log": "Преузми дневник евиденције",
"download-recipe-json": "Последњи прикупљени JSON",
"github": "GitHub",
"log-lines": "Log Lines",
"log-lines": "Лог",
"not-demo": "Није демо",
"portfolio": "Портфолио",
"production": "У продукцији",
@@ -65,11 +65,11 @@
"something-went-wrong": "Нешто је кренуло погрешно!",
"subscribed-events": "Догађаји на које сте претплаћени",
"test-message-sent": "Тест порука је послата",
"message-sent": "Message Sent",
"message-sent": "Порука послата",
"new-notification": "Ново обавештење",
"event-notifiers": "Обавештавач о догађају",
"apprise-url-skipped-if-blank": "Apprise URL (прескочено ако је празно)",
"apprise-url-is-left-intentionally-blank": "Since Apprise URLs typically contain sensitive information, this field is left intentionally blank while editing. If you wish to update the URL, please enter the new one here, otherwise leave it blank to keep the current URL.",
"apprise-url-is-left-intentionally-blank": "Како Apprise URL обично садржи сензитивне информације, ово поље је намерно остављено празно. Ако желите да промените URL, упишите нови овде или оставите поље празно да задржите тренутни URL.",
"enable-notifier": "Омогући обавештење",
"what-events": "На које догађаје би требао да се претплати овај обавештавач?",
"user-events": "Догађаји корисника",
@@ -84,12 +84,12 @@
"label-events": "Label Events"
},
"general": {
"add": "Add",
"add": "Додај",
"cancel": "Откажи",
"clear": "Обриши",
"close": "Затвори",
"confirm": "Потврди",
"confirm-how-does-everything-look": "How does everything look?",
"confirm-how-does-everything-look": "Како све изгледа?",
"confirm-delete-generic": "Да ли сте сигурни да желите обрисати ово?",
"copied_message": "Копирано!",
"create": "Креирај",
@@ -118,12 +118,12 @@
"image-upload-failed": "Неуспешно додавање слике",
"import": "Увоз",
"json": "JSON",
"keyword": "Ključna reč",
"keyword": "Кључна реч",
"link-copied": "Линк је копиран",
"loading": "Loading",
"loading": "Учитавање",
"loading-events": "Учитавање догађаја",
"loading-recipe": "Loading recipe...",
"loading-ocr-data": "Loading OCR data...",
"loading-recipe": "Рецепт се учитава...",
"loading-ocr-data": "Учитавање OCR података...",
"loading-recipes": "Учитавање рецепата",
"message": "Порука",
"monday": "Понедељак",
@@ -134,7 +134,7 @@
"no-recipe-found": "Рецепт није пронађен",
"ok": "У реду",
"options": "Опције:",
"plural-name": "Ime u množini",
"plural-name": "Име у множини",
"print": "Штампа",
"print-preferences": "Подешавање штампе",
"random": "Насумично",
@@ -148,23 +148,23 @@
"save": "Сачувај",
"settings": "Подешавања",
"share": "Подели",
"show-all": "Show All",
"show-all": "Прикажи све",
"shuffle": "Помешано",
"sort": "Сортирај",
"sort-ascending": "Sort Ascending",
"sort-descending": "Sort Descending",
"sort-ascending": "Сложи по реду - растуће",
"sort-descending": "Сложи по реду - опадајуће",
"sort-alphabetically": "Азбучно",
"status": "Статус",
"subject": "Наслов",
"submit": "Пошаљи",
"success-count": "Успешно {count}",
"sunday": "недеља",
"system": "System",
"system": "Систем",
"templates": "Шаблони:",
"test": "Тест",
"themes": "Теме",
"thursday": "четвртак",
"title": "Title",
"title": "Наслов",
"token": "Токен",
"tuesday": "уторак",
"type": "Тип",
@@ -179,12 +179,12 @@
"units": "Јединице",
"back": "Назад",
"next": "Сљедећи",
"start": "Start",
"start": "Старт",
"toggle-view": "Промени приказ",
"date": "Датум",
"id": д",
"id": Д",
"owner": "Власник",
"change-owner": "Change Owner",
"change-owner": "Промени власника",
"date-added": "Датум додавања",
"none": "Ниједно",
"run": "Покрени",
@@ -211,17 +211,17 @@
"refresh": "Освежи",
"upload-file": "Учитај датотеку",
"created-on-date": "Крерирано: {0}",
"unsaved-changes": "You have unsaved changes. Do you want to save before leaving? Okay to save, Cancel to discard changes.",
"discard-changes": "Discard Changes",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
"clipboard-copy-failure": "Failed to copy to the clipboard.",
"confirm-delete-generic-items": "Are you sure you want to delete the following items?",
"organizers": "Organizers",
"caution": "Caution",
"show-advanced": "Show Advanced",
"add-field": "Add Field",
"date-created": "Date Created",
"date-updated": "Date Updated"
"unsaved-changes": "Имате несачуване измене. Да ли желите да их сачувате пре изласка? ОК за потврду, Откажи да откажете измене.",
"discard-changes": "Откажи измене",
"discard-changes-description": "Имате несачуване измене. Да ли желите да их откажете?",
"clipboard-copy-failure": "Копирање није успело.",
"confirm-delete-generic-items": "Да ли желите да обришете следеће ставке?",
"organizers": "Организатор",
"caution": "Пажња",
"show-advanced": "Прикажи напредно",
"add-field": "Додај поље",
"date-created": "Датум креирања",
"date-updated": "Датум измене"
},
"group": {
"are-you-sure-you-want-to-delete-the-group": "Да ли сте сигурни да желите да обришете <b>{groupName}<b/>?",
@@ -236,7 +236,7 @@
"group-id-with-value": "ID групе: {groupID}",
"group-name": "Назив групе",
"group-not-found": "Група није пронађена",
"group-token": "Group Token",
"group-token": "Групни токен",
"group-with-value": "Група: {groupID}",
"groups": "Групе",
"manage-groups": "Управљај групама",
@@ -248,18 +248,18 @@
"keep-my-recipes-private-description": "Поставља вашу групу и све рецепте као подразумевано приватне. Увек можете касније променити ово."
},
"manage-members": "Управљај члановима",
"manage-members-description": "Manage the permissions of the members in your household. {manage} allows the user to access the data-management page, and {invite} allows the user to generate invitation links for other users. Group owners cannot change their own permissions.",
"manage-members-description": "Управљање дозволама у сопственом домаћинству. {manage} дозвољава кориснику приступ менаџменту података, а {invite} омогућава кориснику да генерише позивнице за друге кориснике. Власници групе не могу променити сопствене дозволе.",
"manage": "Управљај",
"manage-household": "Manage Household",
"manage-household": "Менаџмент домаћинства",
"invite": "Позови",
"looking-to-update-your-profile": "Желите ли да ажурирате свој профил?",
"default-recipe-preferences-description": "Ово су подразумевана подешавања када се креира нови рецепт у вашој групи. Ова подешавања могу бити промењена за појединачне рецепте у менију подешавања рецепата.",
"default-recipe-preferences": "Подразумевана подешавања рецепта",
"group-preferences": "Подешавања групе",
"private-group": "Приватна група",
"private-group-description": "Setting your group to private will disable all public view options. This overrides any individual public view settings",
"enable-public-access": "Enable Public Access",
"enable-public-access-description": "Make group recipes public by default, and allow visitors to view recipes without logging-in",
"private-group-description": "Подешавњем групе као приватне ће онемогућити јавни преглед у потпуности. Ово поништава све индивидуална подешавања јавног приступа",
"enable-public-access": "Дозволи јавни приступ",
"enable-public-access-description": "Подеси рецепте у групи уобичајно као јавне и дозволи гостима преглед рецепата без логовања",
"allow-users-outside-of-your-group-to-see-your-recipes": "Дозволите корисницима, који су ван ваше групе, да виде ваше рецепте",
"allow-users-outside-of-your-group-to-see-your-recipes-description": "Када је омогућено, можете користити јавну везу за дељење одређених рецепата без одобравања корисника. Када је онемогућено, рецепте можете делити само са корисницима који су у вашој групи или помоћу претходно генерисане приватне везе",
"show-nutrition-information": "Прикажи информације о исхрани",
@@ -271,37 +271,37 @@
"disable-users-from-commenting-on-recipes": "Онемогући кориснике да коментаришу рецепте",
"disable-users-from-commenting-on-recipes-description": "Сакрива секцију коментара на страници рецепта и онемогућава коментаре",
"disable-organizing-recipe-ingredients-by-units-and-food": "Онемогући организацију састојака рецепта по јединицама и намирницама",
"disable-organizing-recipe-ingredients-by-units-and-food-description": "Hides the Food, Unit, and Amount fields for ingredients and treats ingredients as plain text fields",
"disable-organizing-recipe-ingredients-by-units-and-food-description": "Сакриј тип, јединицу мере и количину за састојке и третирај их као поље са обичним текстом",
"general-preferences": "Општа подешавања",
"group-recipe-preferences": "Подешавања групе рецепта",
"report": "Извештај",
"report-with-id": "Report ID: {id}",
"report-with-id": "ИД извештаја: {id}",
"group-management": "Управљање групом",
"admin-group-management": "Управљање администраторском групом",
"admin-group-management-text": "Промене у овој групи биће одмах видљиве.",
"group-id-value": "Group Id: {0}",
"total-households": "Total Households",
"you-must-select-a-group-before-selecting-a-household": "You must select a group before selecting a household"
"group-id-value": "ИД групе: {0}",
"total-households": "Укупно домаћинстава",
"you-must-select-a-group-before-selecting-a-household": "Морате селектовати групу пре селектовања домаћинства"
},
"household": {
"household": "Household",
"households": "Households",
"user-household": "User Household",
"create-household": "Create Household",
"household-name": "Household Name",
"household-group": "Household Group",
"household-management": "Household Management",
"manage-households": "Manage Households",
"admin-household-management": "Admin Household Management",
"admin-household-management-text": "Changes to this household will be reflected immediately.",
"household-id-value": "Household Id: {0}",
"private-household": "Private Household",
"private-household-description": "Setting your household to private will disable all public view options. This overrides any individual public view settings",
"lock-recipe-edits-from-other-households": "Lock recipe edits from other households",
"lock-recipe-edits-from-other-households-description": "When enabled only users in your household can edit recipes created by your household",
"household-recipe-preferences": "Household Recipe Preferences",
"default-recipe-preferences-description": "These are the default settings when a new recipe is created in your household. These can be changed for individual recipes in the recipe settings menu.",
"allow-users-outside-of-your-household-to-see-your-recipes": "Allow users outside of your household to see your recipes",
"household": "Домаћинство",
"households": "Домаћинства",
"user-household": "Корисниково домаћинство",
"create-household": "Креирај домаћинство",
"household-name": "Назив домаћинства",
"household-group": "Група домаћинства",
"household-management": "Управљање домаћинством",
"manage-households": "Управљање домаћинствима",
"admin-household-management": "Административно управљање домаћинством",
"admin-household-management-text": "Промене за ово домаћинство ће бити видљиве одмах.",
"household-id-value": "ИД домаћинства {0}",
"private-household": "Приватно домаћинство",
"private-household-description": "Подешавање сопственог домаћинства као приватно ће онемогућити сав јавни преглед. Ово поништава сва индивидуална подешавања за преглед",
"lock-recipe-edits-from-other-households": "Забрани измене рецепата од стране других домаћинстава",
"lock-recipe-edits-from-other-households-description": "Када је опција укључена, само корисници из вашег домаћинства могу мењати рецепте креиране унутар њега",
"household-recipe-preferences": "Подешавање рецепата за домаћинство",
"default-recipe-preferences-description": "Ово су уобичајна подешавања када се нови рецепт креира у вашем домаћинству. Подешавања могу бити промењена за појединачне рецепте из менија за подешавања.",
"allow-users-outside-of-your-household-to-see-your-recipes": "Дозволите корисницима ван вашег домаћинства да прегледају ваше рецепте",
"allow-users-outside-of-your-household-to-see-your-recipes-description": "When enabled you can use a public share link to share specific recipes without authorizing the user. When disabled, you can only share recipes with users who are in your household or with a pre-generated private link",
"household-preferences": "Household Preferences"
},
@@ -507,7 +507,7 @@
"insert-below": "Insert Below",
"instructions": "Instructions",
"key-name-required": "Key Name Required",
"landscape-view-coming-soon": "Landscape View (Coming Soon)",
"landscape-view-coming-soon": "Хорижонтални поглед (Ускоро)",
"milligrams": "milligrams",
"new-key-name": "New Key Name",
"no-white-space-allowed": "No White Space Allowed",
@@ -624,7 +624,7 @@
"create-recipe-description": "Create a new recipe from scratch.",
"create-recipes": "Create Recipes",
"import-with-zip": "Увези помоћу .zip архиве",
"create-recipe-from-an-image": "Create Recipe from an Image",
"create-recipe-from-an-image": "Направи рецепт на основи слике",
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
"create-from-images": "Create from Images",
@@ -759,7 +759,7 @@
"restore-success": "Restore successful",
"restore-fail": "Restore failed. Check your server logs for more details",
"backup-tag": "Backup Tag",
"create-heading": "Create a Backup",
"create-heading": "Креирај бекап",
"delete-backup": "Delete Backup",
"error-creating-backup-see-log-file": "Error Creating Backup. See Log File",
"full-backup": "Full Backup",
@@ -904,13 +904,13 @@
"create-shopping-list": "Направи списак за куповину",
"from-recipe": "From Recipe",
"list-name": "List Name",
"new-list": "Novi spisak",
"new-list": "Нови списак",
"quantity": "Quantity: {0}",
"shopping-list": "Shopping List",
"shopping-lists": "Списак за куповину",
"food": "Храна",
"note": "Note",
"label": "Natpis",
"label": "Натпис",
"save-label": "Save Label",
"linked-item-warning": "This item is linked to one or more recipe. Adjusting the units or foods will yield unexpected results when adding or removing the recipe from this list.",
"toggle-food": "Toggle Food",
@@ -1413,33 +1413,38 @@
},
"query-filter": {
"logical-operators": {
"and": "AND",
"or": "OR"
"and": "И",
"or": "ИЛИ"
},
"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"
"equals": "једнако са",
"does-not-equal": "није једнако са",
"is-greater-than": "је веће од",
"is-greater-than-or-equal-to": "је веће или једнако са",
"is-less-than": "је мање од",
"is-less-than-or-equal-to": "је мање или једнако са",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"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"
"is": "је",
"is-not": "није ",
"is-one-of": "је једно од",
"is-not-one-of": "није једно од",
"contains-all-of": "садржи све из",
"is-like": "је као",
"is-not-like": "није као"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {
"required": "This Field is Required",
"invalid-email": "Email Must Be Valid",
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
"required": "Ово поље је обавезно",
"invalid-email": "Имејл мора бити валидан",
"invalid-url": "Мора бити валидан URL",
"no-whitespace": "Размак није дозвољен",
"min-length": "Мора бити најмање {min} карактера",
"max-length": "Мора бити највише {max} карактера"
}
}

View File

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

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "daha büyük",
"is-greater-than-or-equal-to": "daha büyük veya eşit",
"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": {
"is": "is",
@@ -1432,6 +1434,9 @@
"contains-all-of": "hepsini içeriyor mu",
"is-like": "is like",
"is-not-like": "is not like"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "більше ніж",
"is-greater-than-or-equal-to": "більше або дорівнює",
"is-less-than": "менше ніж",
"is-less-than-or-equal-to": "менше або дорівнює"
"is-less-than-or-equal-to": "менше або дорівнює",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "є",
@@ -1432,6 +1434,9 @@
"contains-all-of": "містить усі",
"is-like": "схожий",
"is-not-like": "не схожий"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"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-less-than-or-equal-to": "is less than or equal to",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "is",
@@ -1432,6 +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": {

View File

@@ -1422,7 +1422,9 @@
"is-greater-than": "大于",
"is-greater-than-or-equal-to": "大于等于",
"is-less-than": "小于",
"is-less-than-or-equal-to": "小于等于"
"is-less-than-or-equal-to": "小于等于",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "是",
@@ -1432,6 +1434,9 @@
"contains-all-of": "包含所有",
"is-like": "匹配",
"is-not-like": "不匹配"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"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-less-than-or-equal-to": "is less than or equal to",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "is",
@@ -1432,6 +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": {

View File

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

View File

@@ -41,8 +41,9 @@ export enum Organizer {
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 LogicalOperator = "AND" | "OR";
export type RelationalOperator = "=" | "<>" | ">" | "<" | ">=" | "<=";
export interface QueryFilterJSON {

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