Compare commits

...

124 Commits

Author SHA1 Message Date
Michael Genson
b9e0fede0d remove unused string 2026-02-25 01:10:08 +00:00
Michael Genson
c87a68188f fixed return value 2026-02-24 18:19:53 +00:00
Michael Genson
7347d81ebc convert to auto form 2026-02-24 18:16:13 +00:00
Michael Genson
fd8577b7ba Merge branch 'mealie-next' into feat/standardize-units 2026-02-24 17:48:15 +00:00
Kuchenpirat
282eedfe2b chore: refactor data management pages (#7107) 2026-02-24 17:23:33 +00:00
renovate[bot]
03f849f20f chore(deps): update dependency mkdocs-material to v9.7.3 (#7134)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-24 16:09:43 +00:00
renovate[bot]
5db3b6ab72 fix(deps): update dependency openai to v2.23.0 (#7132)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-24 08:56:43 +00:00
Hayden
353c24ca4b chore(l10n): New Crowdin updates (#7131) 2026-02-24 07:02:24 +00:00
Michael Genson
216ae8571c fix: Include unmade recipes when filtering by last made (#7130) 2026-02-23 18:34:16 -06:00
renovate[bot]
02d32c8905 fix(deps): update dependency openai to v2.22.0 (#7128)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-23 22:15:25 +00:00
Hayden
7e0d083e77 chore(l10n): New Crowdin updates (#7126) 2026-02-23 18:21:38 +00:00
mealie-actions[bot]
b3cea081fe chore(auto): Update pre-commit hooks (#7122)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2026-02-23 14:07:02 +00:00
Michael Genson
21261bcd9f add missing lang strings 2026-02-22 22:51:59 +00:00
renovate[bot]
d79252752b fix(deps): update dependency fastapi to v0.131.0 (#7113)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-22 22:44:02 +00:00
Hayden
b3c214d102 chore(l10n): New Crowdin updates (#7119) 2026-02-22 22:43:45 +00:00
Michael Genson
6c08b1cba4 fix tests 2026-02-22 22:31:23 +00:00
Michael Genson
eebff3f481 fix fluid ounce -> fluid_ounce 2026-02-22 22:25:15 +00:00
Michael Genson
89b1629d68 Merge branch 'mealie-next' into feat/standardize-units 2026-02-22 16:08:20 -06:00
Michael Genson
0380baedb1 add to data management page 2026-02-22 21:35:08 +00:00
Michael Genson
74c73f051d code gen 2026-02-22 20:19:57 +00:00
Michael Genson
0fc4ff9c75 add migration data/test 2026-02-22 20:12:01 +00:00
Michael Genson
2c5ab9e40e add shopping list merge tests 2026-02-22 20:11:41 +00:00
Michael Genson
efab33ccc5 find unit during shopping list item creation 2026-02-22 19:17:07 +00:00
Michael Genson
6b8b929483 refactor datamatcher to provide units/foods by id 2026-02-22 19:16:28 +00:00
Michael Genson
96c056adfd updated seeder test 2026-02-22 18:31:49 +00:00
Michael Genson
212560c822 add unit repo tests 2026-02-22 17:58:59 +00:00
Michael Genson
787fdf5d74 don't overwrite user-provided standardization data 2026-02-22 17:57:30 +00:00
Michael Genson
a48a9fa10c add uc tests 2026-02-22 17:33:43 +00:00
Michael Genson
1a32bcc1fd re-org parser tests 2026-02-22 17:33:35 +00:00
Michael Genson
ee482afbd2 fix oz -> fl oz conversion in merge 2026-02-22 17:32:51 +00:00
Michael Genson
48cdf27ea9 better error info 2026-02-22 17:32:26 +00:00
Michael Genson
fedd1d9eb6 ??? 2026-02-22 05:50:53 +00:00
Hayden
3a01925e48 chore(l10n): New Crowdin updates (#7116) 2026-02-22 05:31:27 +00:00
Michael Genson
3af9b05bd8 add auto-standardization to migration 2026-02-22 02:56:48 +00:00
Michael Genson
fe9dadefea inject known standardized units upon unit creation 2026-02-22 02:19:14 +00:00
Michael Genson
122ef2d867 add key to locale config 2026-02-22 02:13:01 +00:00
Michael Genson
5edd95ed6d refactor seeders to move file management to class level 2026-02-22 02:12:46 +00:00
Michael Genson
6cd7cdff77 merge units in shopping list using conversions 2026-02-21 22:25:53 +00:00
Michael Genson
be92363538 add helper function for merging mealie units 2026-02-21 22:25:33 +00:00
Michael Genson
74a0671c70 add missing return self to validator 2026-02-21 20:38:51 +00:00
Michael Genson
e772bb6834 add unit utils 2026-02-21 20:16:48 +00:00
Michael Genson
6bf80adca1 add pint lib 2026-02-21 20:06:13 +00:00
Michael Genson
492492939e add to schema 2026-02-21 20:06:03 +00:00
Michael Genson
d9b7f0a3a1 add unit standardization fields 2026-02-21 18:01:32 +00:00
Hayden
16e2386f5a chore(l10n): New Crowdin updates (#7112) 2026-02-21 17:27:20 +00:00
renovate[bot]
bbfa105e99 fix(deps): update dependency fastapi to v0.129.1 (#7111)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-21 14:17:34 +00:00
Hayden
c94c9940b2 chore(l10n): New Crowdin updates (#7110)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2026-02-21 05:44:05 +00:00
Michael Genson
29c6176d89 docs: Remove redoc API generation (#7109) 2026-02-20 20:49:43 +00:00
renovate[bot]
0c0d7d11a5 chore(deps): update dependency pylint to v4.0.5 (#7106)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-20 18:14:48 +00:00
renovate[bot]
e75fc6d391 chore(deps): update dependency ruff to v0.15.2 (#7104)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-20 05:06:50 +00:00
Hayden
f308869154 chore(l10n): New Crowdin updates (#7105) 2026-02-20 04:45:05 +00:00
Michael Genson
af30b8bdfa docs: Add missing release tags to OpenAI docs (#7102) 2026-02-19 14:31:58 -06:00
renovate[bot]
de4f22c3f6 fix(deps): update dependency pydantic-settings to v2.13.1 (#7101)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 18:06:46 +00:00
renovate[bot]
4c55b282d6 chore(deps): update dependency rich to v14.3.3 (#7100)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 18:06:43 +00:00
Hayden
8d2b2eb581 chore(l10n): New Crowdin updates (#7098)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2026-02-19 00:24:41 +00:00
Michael Genson
e9daac5fc4 feat: Auto-adjust shopping list item autofocus (#7096) 2026-02-18 16:37:32 -06:00
renovate[bot]
ee1205cfdc chore(deps): update dependency mkdocs-material to v9.7.2 (#7093)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-18 21:07:43 +00:00
renovate[bot]
a165b707af fix(deps): update dependency pillow-heif to v1.2.1 (#7092)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-18 21:07:11 +00:00
Hayden
564385eb83 chore(l10n): New Crowdin updates (#7088) 2026-02-17 23:53:36 +00:00
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
mealie-commit-bot[bot]
168ac79daa chore: bump version to v3.10.1 2026-02-03 01:04:49 +00:00
Hayden
22296277a8 chore(l10n): New Crowdin updates (#6994) 2026-02-03 00:51:49 +00:00
Michael Genson
6e006458be fix: Button overflow on main page filters (#6992) 2026-02-02 18:44:36 -06:00
Michael Genson
76a2fea076 docs: Typo (#6993) 2026-02-02 16:20:41 -06:00
241 changed files with 9806 additions and 8984 deletions

View File

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

View File

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

View File

@@ -25,16 +25,9 @@ dotenv:
- .env - .env
- .dev.env - .dev.env
tasks: tasks:
docs:gen:
desc: runs the API documentation generator
cmds:
- uv run python dev/code-generation/gen_docs_api.py
docs: docs:
desc: runs the documentation server desc: runs the documentation server
dir: docs dir: docs
deps:
- docs:gen
cmds: cmds:
- uv run python -m mkdocs serve - uv run python -m mkdocs serve
@@ -81,7 +74,6 @@ tasks:
desc: run code generators desc: run code generators
cmds: cmds:
- uv run python dev/code-generation/main.py {{ .CLI_ARGS }} - uv run python dev/code-generation/main.py {{ .CLI_ARGS }}
- task: docs:gen
- task: py:format - task: py:format
dev:services: dev:services:
@@ -350,4 +342,4 @@ tasks:
vars: { WAIT_UNTIL_HEALTHY: true } vars: { WAIT_UNTIL_HEALTHY: true }
- defer: { task: e2e:stop-server } - defer: { task: e2e:stop-server }
- task: e2e:test - task: e2e:test
vars: { PREVENT_REPORT_OPEN: true } vars: { PREVENT_REPORT_OPEN: true }

View File

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

View File

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

View File

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

View File

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

View File

@@ -79,8 +79,8 @@ This filter will find all foods that are not named "carrot": <br>
##### Keyword Filters ##### Keyword Filters
The API supports many SQL keywords, such as `IS NULL` and `IN`, as well as their negations (e.g. `IS NOT NULL` and `NOT IN`). The API supports many SQL keywords, such as `IS NULL` and `IN`, as well as their negations (e.g. `IS NOT NULL` and `NOT IN`).
Here is an example of a filter that returns all recipes where the "last made" value is not null: <br> Here is an example of a filter that returns all shopping list items without a food: <br>
`lastMade IS NOT NULL` `foodId IS NULL`
This filter will find all recipes that don't start with the word "Test": <br> This filter will find all recipes that don't start with the word "Test": <br>
`name NOT LIKE "Test%"` `name NOT LIKE "Test%"`
@@ -91,12 +91,12 @@ This filter will find all recipes that have particular slugs: <br>
`slug IN ["pasta-fagioli", "delicious-ramen"]` `slug IN ["pasta-fagioli", "delicious-ramen"]`
##### Placeholder Keywords ##### Placeholder Keywords
You can use placeholders to insert dynamic values as opposed to static values. Currently the only supported placeholder keyword is `$NOW`, to insert the current time. You can use placeholders to insert dynamic values as opposed to static values. Currently the only supported placeholder keyword is `$NOW`, to insert the current date/time.
`$NOW` can optionally be paired with basic offsets. Here is an example of a filter which gives you recipes not made within the past 30 days: <br> `$NOW` can optionally be paired with basic offsets. Here is an example of a filter which gives you recipes not made within the past 30 days: <br>
`lastMade <= "$NOW-30d"` `lastMade <= "$NOW-30d"`
Supported offsets operations include: Supported offset operations include:
- `-` for subtracting a time (i.e. in the past) - `-` for subtracting a time (i.e. in the past)
- `+` for adding a time (i.e. in the future) - `+` for adding a time (i.e. in the future)

View File

@@ -124,16 +124,16 @@ For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values
| Variables | Default | Description | | Variables | Default | Description |
|---------------------------------------------------|:-------:|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |---------------------------------------------------|:-------:|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| OPENAI_BASE_URL<super>[&dagger;][secrets]</super> | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform | | OPENAI_BASE_URL<super>[&dagger;][secrets]</super> | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
| OPENAI_API_KEY<super>[&dagger;][secrets]</super> | None | Your OpenAI API Key. Enables OpenAI-related features | | OPENAI_API_KEY<super>[&dagger;][secrets]</super> | None | Your OpenAI API Key. Enables OpenAI-related features |
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty | | OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
| OPENAI_CUSTOM_HEADERS | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them | | OPENAI_CUSTOM_HEADERS <br/> :octicons-tag-24: v2.0.0 | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
| OPENAI_CUSTOM_PARAMS | None | Custom HTTP query params to add to all OpenAI requests. This should generally be left empty unless your custom service requires them | | OPENAI_CUSTOM_PARAMS <br/> :octicons-tag-24: v2.0.0 | None | Custom HTTP query params to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
| OPENAI_ENABLE_IMAGE_SERVICES | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs | | OPENAI_ENABLE_IMAGE_SERVICES <br/> :octicons-tag-24: v1.12.0 | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs | | OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs | | OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
| OPENAI_REQUEST_TIMEOUT | 300 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware | | OPENAI_REQUEST_TIMEOUT | 300 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
| OPENAI_CUSTOM_PROMPT_DIR | None | Path to custom prompt files. Only existing files in your custom directory will override the defaults; any missing or empty custom files will automatically fall back to the system defaults. See https://github.com/mealie-recipes/mealie/tree/mealie-next/mealie/services/openai/prompts for expected file names. | | OPENAI_CUSTOM_PROMPT_DIR <br/> :octicons-tag-24: v3.10.0 | None | Path to custom prompt files. Only existing files in your custom directory will override the defaults; any missing or empty custom files will automatically fall back to the system defaults. See https://github.com/mealie-recipes/mealie/tree/mealie-next/mealie/services/openai/prompts for expected file names. |
### Theming ### Theming

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,7 @@
:items="foods" :items="foods"
:label="$t('shopping-list.food')" :label="$t('shopping-list.food')"
:icon="$globals.icons.foods" :icon="$globals.icons.foods"
:autofocus="autoFocus === 'food'"
create create
@create="createAssignFood" @create="createAssignFood"
/> />
@@ -41,7 +42,7 @@
:label="$t('shopping-list.note')" :label="$t('shopping-list.note')"
rows="1" rows="1"
auto-grow auto-grow
autofocus :autofocus="autoFocus === 'note'"
@keypress="handleNoteKeyPress" @keypress="handleNoteKeyPress"
/> />
</div> </div>
@@ -165,6 +166,8 @@ export default defineNuxtComponent({
}, },
); );
const autoFocus = !listItem.value.food && listItem.value.note ? "note" : "food";
async function createAssignFood(val: string) { async function createAssignFood(val: string) {
// keep UI reactive // keep UI reactive
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
@@ -204,6 +207,7 @@ export default defineNuxtComponent({
return { return {
listItem, listItem,
autoFocus,
createAssignFood, createAssignFood,
createAssignUnit, createAssignUnit,
assignLabelToFood, assignLabelToFood,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1134,7 +1134,20 @@
"example-unit-singular": "ex: Tablespoon", "example-unit-singular": "ex: Tablespoon",
"example-unit-plural": "ex: Tablespoons", "example-unit-plural": "ex: Tablespoons",
"example-unit-abbreviation-singular": "ex: Tbsp", "example-unit-abbreviation-singular": "ex: Tbsp",
"example-unit-abbreviation-plural": "ex: Tbsps" "example-unit-abbreviation-plural": "ex: Tbsps",
"standard-unit": "Standard Unit",
"standard-quantity": "Standard Quantity",
"unit-conversion": "Unit Conversion",
"standard-unit-labels": {
"fluid-ounce": "fluid ounce",
"cup": "cup",
"ounce": "ounce",
"pound": "pound",
"milliliter": "milliliter",
"liter": "liter",
"gram": "gram",
"kilogram": "kilogram"
}
}, },
"labels": { "labels": {
"seed-dialog-text": "Seed the database with common labels based on your local language.", "seed-dialog-text": "Seed the database with common labels based on your local language.",
@@ -1422,7 +1435,9 @@
"is-greater-than": "is greater than", "is-greater-than": "is greater than",
"is-greater-than-or-equal-to": "is greater than or equal to", "is-greater-than-or-equal-to": "is greater than or equal to",
"is-less-than": "is less than", "is-less-than": "is less than",
"is-less-than-or-equal-to": "is less than or equal to" "is-less-than-or-equal-to": "is less than or equal to",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "is", "is": "is",
@@ -1432,6 +1447,9 @@
"contains-all-of": "contains all of", "contains-all-of": "contains all of",
"is-like": "is like", "is-like": "is like",
"is-not-like": "is not like" "is-not-like": "is not like"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {
@@ -1440,6 +1458,6 @@
"invalid-url": "Must Be A Valid URL", "invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed", "no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters", "min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters" "max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
} }
} }

View File

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

View File

@@ -212,7 +212,7 @@
"upload-file": "Lae fail üles", "upload-file": "Lae fail üles",
"created-on-date": "Loodud: {0}", "created-on-date": "Loodud: {0}",
"unsaved-changes": "Sul on salvestamata muudatusi. Kas sa tahad salvestada enne lehelt lahkumist? Vajuta OK salvestamiseks või Tühista, et muudatused tühistada.", "unsaved-changes": "Sul on salvestamata muudatusi. Kas sa tahad salvestada enne lehelt lahkumist? Vajuta OK salvestamiseks või Tühista, et muudatused tühistada.",
"discard-changes": "Discard Changes", "discard-changes": "Loobu muudatustest",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?", "discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
"clipboard-copy-failure": "Lõikepuhvrisse kopeerimine ebaõnnestus.", "clipboard-copy-failure": "Lõikepuhvrisse kopeerimine ebaõnnestus.",
"confirm-delete-generic-items": "Kas oled kindel, et tahad kustutada järgnevad asjad?", "confirm-delete-generic-items": "Kas oled kindel, et tahad kustutada järgnevad asjad?",
@@ -344,9 +344,9 @@
"breakfast": "Hommikusöök", "breakfast": "Hommikusöök",
"lunch": "Lõuna", "lunch": "Lõuna",
"dinner": "Õhtusöök", "dinner": "Õhtusöök",
"snack": "Snack", "snack": "Snäkk",
"drink": "Drink", "drink": "Jook",
"dessert": "Dessert", "dessert": "Magustoit",
"type-any": "Kõik", "type-any": "Kõik",
"day-any": "Kõik", "day-any": "Kõik",
"editor": "Editor", "editor": "Editor",
@@ -1422,7 +1422,9 @@
"is-greater-than": "on suurem kui", "is-greater-than": "on suurem kui",
"is-greater-than-or-equal-to": "on suurem või võrdne kui", "is-greater-than-or-equal-to": "on suurem või võrdne kui",
"is-less-than": "on vähem kui", "is-less-than": "on vähem kui",
"is-less-than-or-equal-to": "on väiksem või võrdne kui" "is-less-than-or-equal-to": "on väiksem või võrdne kui",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
}, },
"relational-keywords": { "relational-keywords": {
"is": "on", "is": "on",
@@ -1432,6 +1434,9 @@
"contains-all-of": "sisaldab kõiki", "contains-all-of": "sisaldab kõiki",
"is-like": "on nagu", "is-like": "on nagu",
"is-not-like": "ei ole nagu" "is-not-like": "ei ole nagu"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
} }
}, },
"validators": { "validators": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -370,8 +370,8 @@
"applies-to-all-days": "Minden napra vonatkozóan", "applies-to-all-days": "Minden napra vonatkozóan",
"applies-on-days": "Érvényes {0}-ként", "applies-on-days": "Érvényes {0}-ként",
"meal-plan-settings": "Menütervező beállításai", "meal-plan-settings": "Menütervező beállításai",
"add-all-to-list": "Add All to List", "add-all-to-list": "Összes hozzáadása a listához",
"add-day-to-list": "Add Day to List" "add-day-to-list": "Nap hozzáadása a listához"
}, },
"migration": { "migration": {
"migration-data-removed": "Migrációs adatok eltávolítva", "migration-data-removed": "Migrációs adatok eltávolítva",
@@ -1422,7 +1422,9 @@
"is-greater-than": "nagyobb, mint", "is-greater-than": "nagyobb, mint",
"is-greater-than-or-equal-to": "nagyobb vagy egyenlő", "is-greater-than-or-equal-to": "nagyobb vagy egyenlő",
"is-less-than": "kevesebb, mint", "is-less-than": "kevesebb, mint",
"is-less-than-or-equal-to": "kevesebb vagy egyenlő" "is-less-than-or-equal-to": "kevesebb vagy egyenlő",
"is-older-than": "régebbi, mint",
"is-newer-than": "újabb, mint"
}, },
"relational-keywords": { "relational-keywords": {
"is": "van", "is": "van",
@@ -1432,6 +1434,9 @@
"contains-all-of": "tartalmazza az összes", "contains-all-of": "tartalmazza az összes",
"is-like": "hasonló", "is-like": "hasonló",
"is-not-like": "nem hasonló" "is-not-like": "nem hasonló"
},
"dates": {
"days-ago": "napja|napja|napja"
} }
}, },
"validators": { "validators": {

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{ {
"about": { "about": {
"about": "정보", "about": "약/대략",
"about-mealie": "Mealie에 대해", "about-mealie": "Mealie 정보",
"api-docs": "API 문서", "api-docs": "API 문서",
"api-port": "API 포트", "api-port": "API 포트",
"application-mode": "애플리케이션 모드", "application-mode": "애플리케이션 모드",
@@ -14,7 +14,7 @@
"development": "개발", "development": "개발",
"docs": "문서", "docs": "문서",
"download-log": "다운로드 기록", "download-log": "다운로드 기록",
"download-recipe-json": "마지막으로 불러온 JSON", "download-recipe-json": "마지막으로 스크랩한 JSON",
"github": "GitHub", "github": "GitHub",
"log-lines": "로그 줄", "log-lines": "로그 줄",
"not-demo": "데모 아님", "not-demo": "데모 아님",
@@ -43,8 +43,8 @@
"category-deleted": "카테고리 삭제됨", "category-deleted": "카테고리 삭제됨",
"category-deletion-failed": "카테고리 삭제 실패", "category-deletion-failed": "카테고리 삭제 실패",
"category-filter": "카테고리 필터", "category-filter": "카테고리 필터",
"category-update-failed": "Category 업데이트 실패", "category-update-failed": "카테고리 수정 실패",
"category-updated": "카테고리 업데이트", "category-updated": "카테고리 수정됨",
"uncategorized-count": "카테고리 없음 {count}", "uncategorized-count": "카테고리 없음 {count}",
"create-a-category": "카테고리 생성", "create-a-category": "카테고리 생성",
"category-name": "카테고리 이름", "category-name": "카테고리 이름",
@@ -56,7 +56,7 @@
"delete-event": "이벤트 삭제", "delete-event": "이벤트 삭제",
"event-delete-confirmation": "정말로 이 이벤트를 삭제하시겠어요?", "event-delete-confirmation": "정말로 이 이벤트를 삭제하시겠어요?",
"event-deleted": "이벤트 삭제됨", "event-deleted": "이벤트 삭제됨",
"event-updated": "이벤트 업데이트됨", "event-updated": "이벤트 수정됨",
"new-notification-form-description": "Mealie는 Apprise 라이브러리를 사용하여 알림을 생성합니다. 알림에 사용할 서비스에 대한 다양한 옵션을 제공합니다. 서비스의 URL을 만드는 방법에 대한 종합적인 가이드는 해당 Wiki 문서를 참조하세요. 알림 유형에 따라 추가 기능이 포함될 수 있습니다.", "new-notification-form-description": "Mealie는 Apprise 라이브러리를 사용하여 알림을 생성합니다. 알림에 사용할 서비스에 대한 다양한 옵션을 제공합니다. 서비스의 URL을 만드는 방법에 대한 종합적인 가이드는 해당 Wiki 문서를 참조하세요. 알림 유형에 따라 추가 기능이 포함될 수 있습니다.",
"new-version": "새로운 버전 사용 가능", "new-version": "새로운 버전 사용 가능",
"notification": "알림", "notification": "알림",
@@ -67,14 +67,14 @@
"test-message-sent": "테스트 메시지가 전송됐습니다.", "test-message-sent": "테스트 메시지가 전송됐습니다.",
"message-sent": "메세지가 전송됨", "message-sent": "메세지가 전송됨",
"new-notification": "새 알림", "new-notification": "새 알림",
"event-notifiers": "이벤트 알림이", "event-notifiers": "이벤트 알리미",
"apprise-url-skipped-if-blank": "Apprise URL (비워두면 생략합니다)", "apprise-url-skipped-if-blank": "Apprise URL (비워두면 생략합니다)",
"apprise-url-is-left-intentionally-blank": "Apprise URL에는 일반적으로 민감한 정보가 포함되므로, 편집 시 이 필드는 의도적으로 비워둡니다. URL을 업데이트하려면 여기에 새 주소를 입력하시고, 현재 URL을 유지하려면 비워두십시오.", "apprise-url-is-left-intentionally-blank": "Apprise URL에는 일반적으로 민감한 정보가 포함되므로, 편집 시 이 필드는 의도적으로 비워둡니다. URL을 수정하려면 여기에 새 주소를 입력하시고, 현재 URL을 유지하려면 비워두십시오.",
"enable-notifier": "알 활성화", "enable-notifier": "알리미 활성화",
"what-events": "이 알리미는 어떤 이벤트를 구독해야 합니까?", "what-events": "이 알리미는 어떤 이벤트를 구독해야 합니까?",
"user-events": "사용자 이벤트", "user-events": "사용자 이벤트",
"mealplan-events": "Mealplan 이벤트", "mealplan-events": "Mealplan 이벤트",
"when-a-user-in-your-group-creates-a-new-mealplan": "그룹의 사용자가 새로운 식 계획을 만들 때", "when-a-user-in-your-group-creates-a-new-mealplan": "그룹의 사용자가 새로운 식 계획을 만들 때",
"shopping-list-events": "장보기 목록 이벤트", "shopping-list-events": "장보기 목록 이벤트",
"cookbook-events": "요리책 이벤트", "cookbook-events": "요리책 이벤트",
"tag-events": "Tag 이벤트", "tag-events": "Tag 이벤트",
@@ -92,7 +92,7 @@
"confirm-how-does-everything-look": "모든 게 어떻게 보이나요?", "confirm-how-does-everything-look": "모든 게 어떻게 보이나요?",
"confirm-delete-generic": "이 항목을 삭제하시겠습니까?", "confirm-delete-generic": "이 항목을 삭제하시겠습니까?",
"copied_message": "복사됨!", "copied_message": "복사됨!",
"create": "만들기", "create": "생성",
"created": "생성됨", "created": "생성됨",
"custom": "사용자 정의", "custom": "사용자 정의",
"dashboard": "대시보드", "dashboard": "대시보드",
@@ -193,7 +193,7 @@
"delete-with-name": "{name} 삭제", "delete-with-name": "{name} 삭제",
"confirm-delete-generic-with-name": "{name}을(를) 정말 삭제하시겠습니까?", "confirm-delete-generic-with-name": "{name}을(를) 정말 삭제하시겠습니까?",
"confirm-delete-own-admin-account": "본인의 관리자 계정을 삭제하려고 한다는 점에 유의하세요! 이 작업은 취소할 수 없으며 계정이 영구적으로 삭제됩니다.", "confirm-delete-own-admin-account": "본인의 관리자 계정을 삭제하려고 한다는 점에 유의하세요! 이 작업은 취소할 수 없으며 계정이 영구적으로 삭제됩니다.",
"organizer": "정리 도구", "organizer": "정리",
"transfer": "전송", "transfer": "전송",
"copy": "복사", "copy": "복사",
"color": "색상", "color": "색상",
@@ -212,11 +212,11 @@
"upload-file": "파일 업로드", "upload-file": "파일 업로드",
"created-on-date": "생성일: {0}", "created-on-date": "생성일: {0}",
"unsaved-changes": "저장되지 않은 변경 사항이 있습니다. 떠나기 전에 저장하시겠습니까? 저장하려면 확인을 클릭하고, 변경 사항을 삭제하려면 취소를 클릭합니다.", "unsaved-changes": "저장되지 않은 변경 사항이 있습니다. 떠나기 전에 저장하시겠습니까? 저장하려면 확인을 클릭하고, 변경 사항을 삭제하려면 취소를 클릭합니다.",
"discard-changes": "Discard Changes", "discard-changes": "변경사항 취소",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?", "discard-changes-description": "저장되지 않은 변경사항이 있습니다. 삭제하시겠습니까?",
"clipboard-copy-failure": "클립보드에 복사하는 데 실패했습니다.", "clipboard-copy-failure": "클립보드에 복사하는 데 실패했습니다.",
"confirm-delete-generic-items": "이 항목을 삭제하시겠습니까?", "confirm-delete-generic-items": "이 항목을 삭제하시겠습니까?",
"organizers": "분류자", "organizers": "정리함",
"caution": "주의", "caution": "주의",
"show-advanced": "고급 표시", "show-advanced": "고급 표시",
"add-field": "필드 추가", "add-field": "필드 추가",
@@ -228,8 +228,8 @@
"cannot-delete-default-group": "기본 그룹은 삭제할 수 없습니다", "cannot-delete-default-group": "기본 그룹은 삭제할 수 없습니다",
"cannot-delete-group-with-users": "사용자가 있는 그룹은 삭제할 수 없습니다.", "cannot-delete-group-with-users": "사용자가 있는 그룹은 삭제할 수 없습니다.",
"confirm-group-deletion": "그룹을 삭제할까요?", "confirm-group-deletion": "그룹을 삭제할까요?",
"create-group": "그룹 만들기", "create-group": "그룹 생성",
"error-updating-group": "그룹 업데이트 오류", "error-updating-group": "그룹 수정 오류",
"group": "그룹", "group": "그룹",
"group-deleted": "그룹 삭제됨", "group-deleted": "그룹 삭제됨",
"group-deletion-failed": "그룹 삭제 실패", "group-deletion-failed": "그룹 삭제 실패",
@@ -252,7 +252,7 @@
"manage": "관리", "manage": "관리",
"manage-household": "가구 관리", "manage-household": "가구 관리",
"invite": "초대하기", "invite": "초대하기",
"looking-to-update-your-profile": "프로필을 업데이트하시겠습니까?", "looking-to-update-your-profile": "프로필을 업데이트하고 싶으신가요?",
"default-recipe-preferences-description": "이 설정은 그룹에서 새 레시피를 생성할 때 적용되는 기본값입니다. 레시피 설정 메뉴에서 개별 레시피별로 변경할 수 있습니다.", "default-recipe-preferences-description": "이 설정은 그룹에서 새 레시피를 생성할 때 적용되는 기본값입니다. 레시피 설정 메뉴에서 개별 레시피별로 변경할 수 있습니다.",
"default-recipe-preferences": "기본 레시피 설정", "default-recipe-preferences": "기본 레시피 설정",
"group-preferences": "그룹 설정", "group-preferences": "그룹 설정",
@@ -299,47 +299,47 @@
"private-household-description": "귀하의 가구를 비공개로 설정하면 모든 공개 보기 옵션이 비활성화됩니다. 이는 개별 공개 보기 설정을 재정의합니다.", "private-household-description": "귀하의 가구를 비공개로 설정하면 모든 공개 보기 옵션이 비활성화됩니다. 이는 개별 공개 보기 설정을 재정의합니다.",
"lock-recipe-edits-from-other-households": "다른 가구의 레시피 편집 잠금", "lock-recipe-edits-from-other-households": "다른 가구의 레시피 편집 잠금",
"lock-recipe-edits-from-other-households-description": "이 기능을 활성화하면 귀하의 가족 구성원만 귀하의 가족이 만든 요리법을 편집할 수 있습니다.", "lock-recipe-edits-from-other-households-description": "이 기능을 활성화하면 귀하의 가족 구성원만 귀하의 가족이 만든 요리법을 편집할 수 있습니다.",
"household-recipe-preferences": "가정용 레시피 선호도", "household-recipe-preferences": "가 레시피 설정",
"default-recipe-preferences-description": "이는 가에서 새로운 레시피를 만들 때의 기본 설정입니다. 레시피 설정 메뉴에서 개별 레시피에 대해 이를 변경할 수 있습니다.", "default-recipe-preferences-description": "이는 가에서 새로운 레시피를 만들 때의 기본 설정입니다. 레시피 설정 메뉴에서 개별 레시피에 대해 이를 변경할 수 있습니다.",
"allow-users-outside-of-your-household-to-see-your-recipes": "가족 외의 사용자에게도 요리법을 볼 수 있도록 허용", "allow-users-outside-of-your-household-to-see-your-recipes": "가족 외의 사용자에게도 요리법을 볼 수 있도록 허용",
"allow-users-outside-of-your-household-to-see-your-recipes-description": "활성화하면 공개 공유 링크를 사용하여 사용자에게 권한을 부여하지 않고도 특정 레시피를 공유할 수 있습니다. 비활성화하면 가족 구성원 또는 사전 생성된 비공개 링크로만 레시피를 공유할 수 있습니다.", "allow-users-outside-of-your-household-to-see-your-recipes-description": "활성화하면 공개 공유 링크를 사용하여 사용자에게 권한을 부여하지 않고도 특정 레시피를 공유할 수 있습니다. 비활성화하면 가족 구성원 또는 사전 생성된 비공개 링크로만 레시피를 공유할 수 있습니다.",
"household-preferences": "가구 설정" "household-preferences": "가구 설정"
}, },
"meal-plan": { "meal-plan": {
"create-a-new-meal-plan": "새로운 식 계획 생성", "create-a-new-meal-plan": "새로운 식 계획 생성",
"update-this-meal-plan": "이 식 계획 업데이트", "update-this-meal-plan": "이 식 계획 수정",
"dinner-this-week": "이번 주 저녁 식사", "dinner-this-week": "이번 주 저녁 식사",
"dinner-today": "오늘 저녁 식사", "dinner-today": "오늘 저녁 식사",
"dinner-tonight": "오늘 밤 저녁 식사", "dinner-tonight": "오늘 밤 저녁 식사",
"edit-meal-plan": "식 계획 편집", "edit-meal-plan": "식 계획 편집",
"end-date": "종료 날짜", "end-date": "종료 날짜",
"group": "그룹(베타)", "group": "그룹(베타)",
"main": "메인", "main": "메인",
"meal-planner": "식단 플래너", "meal-planner": "식단 플래너",
"meal-plans": "식단 계획", "meal-plans": "식단 계획",
"mealplan-categories": "식 계획 카테고리", "mealplan-categories": "식 계획 카테고리",
"mealplan-created": "식 계획이 생성됨", "mealplan-created": "식 계획이 생성됨",
"mealplan-creation-failed": "식 계획 생성 실패", "mealplan-creation-failed": "식 계획 생성 실패",
"mealplan-deleted": "식 계획 삭제됨", "mealplan-deleted": "식 계획 삭제됨",
"mealplan-deletion-failed": "식계획 삭제 실패", "mealplan-deletion-failed": "식계획 삭제 실패",
"mealplan-settings": "식 계획 설정", "mealplan-settings": "식 계획 설정",
"mealplan-update-failed": "식 계획 업데이트 실패", "mealplan-update-failed": "식 계획 수정 실패",
"mealplan-updated": "식 계획이 업데이트됨", "mealplan-updated": "식 계획 수정됨",
"mealplan-households-description": "가구를 선택하지 않은 경우 모든 가구의 레시피를 추가할 수 있습니다.", "mealplan-households-description": "가구를 선택하지 않은 경우 모든 가구의 레시피를 추가할 수 있습니다.",
"any-category": "모든 카테고리", "any-category": "모든 카테고리",
"any-tag": "모든 태그", "any-tag": "모든 태그",
"any-household": "모든 가구", "any-household": "모든 가구",
"no-meal-plan-defined-yet": "아직 식 계획이 정의되지 않았습니다.", "no-meal-plan-defined-yet": "아직 식 계획이 정의되지 않았습니다.",
"no-meal-planned-for-today": "오늘은 식 계획이 없습니다", "no-meal-planned-for-today": "오늘은 식 계획이 없습니다",
"numberOfDays-hint": "페이지 로드 일수", "numberOfDays-hint": "페이지 로드 일수",
"numberOfDays-label": "기본 일수", "numberOfDays-label": "기본 일수",
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "이 카테고리의 레시피만 식 계획에 사용됩니다.", "only-recipes-with-these-categories-will-be-used-in-meal-plans": "이 카테고리의 레시피만 식 계획에 사용됩니다.",
"planner": "플래너", "planner": "플래너",
"quick-week": "빠른 주", "quick-week": "빠른 주",
"side": "사이드", "side": "사이드",
"sides": "사이드", "sides": "사이드",
"start-date": "시작 일자", "start-date": "시작 일자",
"rule-day": "규칙의 날", "rule-day": "규칙을 적용할 요일",
"meal-type": "식사 유형", "meal-type": "식사 유형",
"breakfast": "조식", "breakfast": "조식",
"lunch": "점심", "lunch": "점심",
@@ -357,25 +357,25 @@
"random-meal": "랜덤 식사", "random-meal": "랜덤 식사",
"random-dinner": "랜덤 저녁식사", "random-dinner": "랜덤 저녁식사",
"random-side": "랜덤 사이드 메뉴", "random-side": "랜덤 사이드 메뉴",
"this-rule-will-apply": "이 규칙은 {dayCriteria} {mealTypeCriteria} 적용됩니다.", "this-rule-will-apply": "이 규칙은 {dayCriteria} {mealTypeCriteria} 적용됩니다.",
"to-all-days": "모든 날에", "to-all-days": "모든 날에",
"on-days": "{0}에", "on-days": "{0}에",
"for-all-meal-types": "모든 식사 종류에", "for-all-meal-types": "모든 식사 종류에",
"for-type-meal-types": "{0} 식사 종류에", "for-type-meal-types": "{0} 식사 종류에",
"meal-plan-rules": "식단 계획 규칙", "meal-plan-rules": "식단 계획 규칙",
"new-rule": "새 규칙", "new-rule": "새 규칙",
"meal-plan-rules-description": "식단 계획에 사용할 레시피를 자동 선택하 규칙을 생성할 수 있습니다. 이 규칙은 서버가 식사 플랜을 생성할 때 선택할 무작위 레시피 을 결정하는 데 사용됩니다. 동일한 요일/유형 제약 조건을 가진 규칙들은 필터가 병합된다는 점에 유의하세요. 실제로 중복 규칙을 생성할 필요는 없지만, 생성하는 것 가능합니다.", "meal-plan-rules-description": "식단 계획에 사용할 레시피를 자동으로 선택하기 위한 규칙을 만들 수 있습니다. 이러한 규칙은 서버에서 식단 계획을 생성할 때 무작위로 선택할 레시피 목록을 결정하는 데 사용됩니다. 규칙에 동일한 요일/식사 유형 조건이 있는 경우 규칙 필터가 병합된다는 점에 유의하십시오. 실제로 중복 규칙을 만들 필요는 없지만, 만드는 것 가능합니다.",
"new-rule-description": "식단 계획에 새 규칙을 생성할 때, 특정 요일 및/또는 특정 식사 유형에만 적용되도록 규칙을 제한할 수 있습니다. 모든 요일 또는 모든 식사 유형에 규칙을 적용하려면 규칙을 \"모든\"으로 설정하면 됩니다. 이렇게 하면 해당 요일 및/또는 식사 유형의 모든 가능한 값에 규칙이 적용됩니다.", "new-rule-description": "식단 계획에 새 규칙을 생성할 때, 특정 요일 및/또는 특정 식사 유형에만 적용되도록 규칙을 제한할 수 있습니다. 모든 요일 또는 모든 식사 유형에 규칙을 적용하려면 규칙을 \"모든\"으로 설정하면 됩니다. 이렇게 하면 해당 요일 및/또는 식사 유형의 모든 가능한 값에 규칙이 적용됩니다.",
"recipe-rules": "레시피 규칙", "recipe-rules": "레시피 규칙",
"applies-to-all-days": "모든 날짜에 적용됨", "applies-to-all-days": "모든 날짜에 적용됨",
"applies-on-days": "{0}에 적용됨", "applies-on-days": "{0}에 적용됨",
"meal-plan-settings": "식단 계획 설정", "meal-plan-settings": "식단 계획 설정",
"add-all-to-list": "Add All to List", "add-all-to-list": "목록에 전체 추가",
"add-day-to-list": "Add Day to List" "add-day-to-list": "목록에 날짜 추가"
}, },
"migration": { "migration": {
"migration-data-removed": "이전된 데이터 제거됨", "migration-data-removed": "이전된 데이터 제거됨",
"new-migration": "새 마이그레이션", "new-migration": "새 데이터 이전",
"no-file-selected": "선택된 파일이 없습니다", "no-file-selected": "선택된 파일이 없습니다",
"no-migration-data-available": "이전 데이터가 없습니다.", "no-migration-data-available": "이전 데이터가 없습니다.",
"previous-migrations": "이전 데이터 이전", "previous-migrations": "이전 데이터 이전",
@@ -392,19 +392,19 @@
}, },
"copymethat": { "copymethat": {
"description-long": "Mealie는 Copy Me That에서 레시피를 가져올 수 있습니다. 레시피를 HTML 형식으로 내보낸 후, 아래의 .zip 파일을 업로드하세요.", "description-long": "Mealie는 Copy Me That에서 레시피를 가져올 수 있습니다. 레시피를 HTML 형식으로 내보낸 후, 아래의 .zip 파일을 업로드하세요.",
"title": "Copy Me That 레시피 매니저" "title": "Copy Me That Recipe Manager"
}, },
"paprika": { "paprika": {
"description-long": "Mealie는 Paprika 애플리케이션에서 레시피를 가져올 수 있습니다. Paprika에서 레시피를 내보낸 후, 내보낸 파일의 확장자를 .zip으로 변경하여 아래에 업로드하세요.", "description-long": "Mealie는 Paprika 애플리케이션에서 레시피를 가져올 수 있습니다. Paprika에서 레시피를 내보낸 후, 내보낸 파일의 확장자를 .zip으로 변경하여 아래에 업로드하세요.",
"title": "Paprika 레시피 매니저" "title": "Paprika Recipe Manager"
}, },
"mealie-pre-v1": { "mealie-pre-v1": {
"description-long": "Mealie는 v1.0 이전 버전의 Mealie 애플리케이션에서 레시피를 가져올 수 있습니다. 기존 인스턴스에서 레시피를 내보낸 후 아래의 zip 파일을 업로드하세요. 내보 파일에서 레시피만 가져올 수 있다는 점에 유의하십시오.", "description-long": "Mealie는 v1.0 이전 버전의 Mealie 애플리케이션에서 레시피를 가져올 수 있습니다. 기존 인스턴스에서 레시피를 내보낸 후 아래에 있는 압축 파일을 업로드하세요. 내보내기 파일에서 레시피만 가져올 수 있다는 점에 유의하세요.",
"title": "Mealie Pre v1.0" "title": "Mealie Pre v1.0"
}, },
"tandoor": { "tandoor": {
"description-long": "Mealie는 Tandoor에서 레시피를 가져올 수 있습니다. 데이터를 \"기본\" 형식으로 내보낸 후 아래의 .zip 파일을 업로드하세요.", "description-long": "Mealie는 Tandoor에서 레시피를 가져올 수 있습니다. 데이터를 \"기본\" 형식으로 내보낸 후 아래의 .zip 파일을 업로드하세요.",
"title": "Tandoor 레시피" "title": "Tandoor Recipes"
}, },
"cookn": { "cookn": {
"description-long": "Mealie는 DVO Cook'n X3의 레시피를 가져올 수 있습니다. \"Cook'n\" 형식으로 요리책이나 메뉴를 내보낸 후, 내보낸 파일의 확장자를 .zip으로 변경하고 아래에 .zip 파일을 업로드하세요.", "description-long": "Mealie는 DVO Cook'n X3의 레시피를 가져올 수 있습니다. \"Cook'n\" 형식으로 요리책이나 메뉴를 내보낸 후, 내보낸 파일의 확장자를 .zip으로 변경하고 아래에 .zip 파일을 업로드하세요.",
@@ -420,17 +420,17 @@
"recipe-1": "레시피 1", "recipe-1": "레시피 1",
"recipe-2": "레시피 2", "recipe-2": "레시피 2",
"paprika-text": "Mealie는 Paprika 애플리케이션에서 레시피를 가져올 수 있습니다. Paprika에서 레시피를 내보낸 후, 내보낸 파일의 확장자를 .zip으로 변경하여 아래에 업로드하세요.", "paprika-text": "Mealie는 Paprika 애플리케이션에서 레시피를 가져올 수 있습니다. Paprika에서 레시피를 내보낸 후, 내보낸 파일의 확장자를 .zip으로 변경하여 아래에 업로드하세요.",
"mealie-text": "Mealie는 v1.0 이전 버전의 Mealie 애플리케이션에서 레시피를 가져올 수 있습니다. 기존 인스턴스에서 레시피를 내보낸 후 아래의 zip 파일을 업로드하세요. 내보 파일에서 레시피만 가져올 수 있다는 점에 유의하십시오.", "mealie-text": "Mealie는 v1.0 이전 버전의 Mealie 애플리케이션에서 레시피를 가져올 수 있습니다. 기존 인스턴스에서 레시피를 내보낸 후 아래에 있는 압축 파일을 업로드하세요. 내보내기 파일에서 레시피만 가져올 수 있다는 점에 유의하세요.",
"plantoeat": { "plantoeat": {
"title": "Plan to Eat", "title": "Plan to Eat",
"description-long": "Mealie는 Plan to Eat에서 레시피를 가져올 수 있습니다." "description-long": "Mealie는 Plan to Eat에서 레시피를 가져올 수 있습니다."
}, },
"myrecipebox": { "myrecipebox": {
"title": "내 레시피 박스", "title": "My Recipe Box",
"description-long": "Mealie는 My Recipe Box에서 레시피를 가져올 수 있습니다. CSV 형식으로 레시피를 내보낸 다음 아래의 .csv 파일을 업로드하세요." "description-long": "Mealie는 My Recipe Box에서 레시피를 가져올 수 있습니다. CSV 형식으로 레시피를 내보낸 다음 아래의 .csv 파일을 업로드하세요."
}, },
"recipekeeper": { "recipekeeper": {
"title": "레시피 보관함", "title": "Recipe Keeper",
"description-long": "Mealie는 Recipe Keeper에서 레시피를 가져올 수 있습니다. 레시피를 zip 형식으로 내보낸 다음 아래의 .zip 파일을 업로드하세요." "description-long": "Mealie는 Recipe Keeper에서 레시피를 가져올 수 있습니다. 레시피를 zip 형식으로 내보낸 다음 아래의 .zip 파일을 업로드하세요."
} }
}, },
@@ -455,7 +455,7 @@
"trim-prefix-description": "각 줄의 첫 문자 제거하기", "trim-prefix-description": "각 줄의 첫 문자 제거하기",
"split-by-numbered-line-description": "'1)' 또는 '1.' 패턴을 일치시켜 문단을 분할하려고 시도합니다.", "split-by-numbered-line-description": "'1)' 또는 '1.' 패턴을 일치시켜 문단을 분할하려고 시도합니다.",
"import-by-url": "URL로 레시피 가져오기", "import-by-url": "URL로 레시피 가져오기",
"create-manually": "수동으로 레시피 만들기", "create-manually": "수동으로 레시피 생성",
"make-recipe-image": "이것을 레시피 이미지로 만드세요.", "make-recipe-image": "이것을 레시피 이미지로 만드세요.",
"add-food": "식품 추가", "add-food": "식품 추가",
"add-recipe": "레시피 추가" "add-recipe": "레시피 추가"
@@ -468,10 +468,10 @@
"page-creation-failed": "페이지 생성 실패", "page-creation-failed": "페이지 생성 실패",
"page-deleted": "페이지 삭제됨", "page-deleted": "페이지 삭제됨",
"page-deletion-failed": "페이지 삭제 실패", "page-deletion-failed": "페이지 삭제 실패",
"page-update-failed": "페이지 업데이트 실패", "page-update-failed": "페이지 수정 실패",
"page-updated": "페이지 업데이트됨", "page-updated": "페이지 수정됨",
"pages-update-failed": "페이지 업데이트 실패", "pages-update-failed": "페이지 수정 실패",
"pages-updated": "페이지 업데이트됨", "pages-updated": "페이지 수정됨",
"404-not-found": "404 찾을 수 없음", "404-not-found": "404 찾을 수 없음",
"an-error-occurred": "오류가 발생했습니다!" "an-error-occurred": "오류가 발생했습니다!"
}, },
@@ -524,7 +524,7 @@
"recipe-creation-failed": "레시피 생성 실패", "recipe-creation-failed": "레시피 생성 실패",
"recipe-deleted": "레시피 삭제됨", "recipe-deleted": "레시피 삭제됨",
"recipe-image": "레시피 사진", "recipe-image": "레시피 사진",
"recipe-image-updated": "레시피 사진 업데이트됨", "recipe-image-updated": "레시피 사진 수정됨",
"delete-image": "레시피 사진 삭제", "delete-image": "레시피 사진 삭제",
"delete-image-confirmation": "이 레시피 이미지를 삭제하시겠습니까?", "delete-image-confirmation": "이 레시피 이미지를 삭제하시겠습니까?",
"recipe-image-deleted": "레시피 이미지 삭제됨", "recipe-image-deleted": "레시피 이미지 삭제됨",
@@ -552,7 +552,7 @@
"no-recipe": "레시피 없음", "no-recipe": "레시피 없음",
"locked-by-owner": "소유자에 의해 잠김", "locked-by-owner": "소유자에 의해 잠김",
"join-the-conversation": "대화에 참여하기", "join-the-conversation": "대화에 참여하기",
"add-recipe-to-mealplan": "식 계획에 레시피 추가", "add-recipe-to-mealplan": "식 계획에 레시피 추가",
"entry-type": "항목 유형", "entry-type": "항목 유형",
"date-format-hint": "MM/DD/YYYY 형식", "date-format-hint": "MM/DD/YYYY 형식",
"date-format-hint-yyyy-mm-dd": "YYYY-MM-DD 형식", "date-format-hint-yyyy-mm-dd": "YYYY-MM-DD 형식",
@@ -624,8 +624,8 @@
"create-recipe-description": "처음부터 새로운 레시피를 만드세요.", "create-recipe-description": "처음부터 새로운 레시피를 만드세요.",
"create-recipes": "레시피 생성", "create-recipes": "레시피 생성",
"import-with-zip": ".zip 파일로 가져오기", "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.", "create-recipe-from-an-image-description": "레시피 텍스트 이미지를 업로드하여 레시피를 생성하세요. Mealie는 AI를 사용하여 이미지에서 텍스트를 추출하고 이를 통해 새로운 레시피를 생성하려고 시도합니다.",
"crop-and-rotate-the-image": "이미지를 잘라내고 회전시켜 텍스트만 보이도록 하고 올바른 방향으로 배치하십시오.", "crop-and-rotate-the-image": "이미지를 잘라내고 회전시켜 텍스트만 보이도록 하고 올바른 방향으로 배치하십시오.",
"create-from-images": "이미지에서 생성", "create-from-images": "이미지에서 생성",
"should-translate-description": "레시피를 내 언어로 번역하기", "should-translate-description": "레시피를 내 언어로 번역하기",
@@ -644,7 +644,7 @@
"scrape-recipe-website-being-blocked": "웹사이트가 차단되고 있나요?", "scrape-recipe-website-being-blocked": "웹사이트가 차단되고 있나요?",
"scrape-recipe-try-importing-raw-html-instead": "대신 원본 HTML 가져오기를 시도해보세요.", "scrape-recipe-try-importing-raw-html-instead": "대신 원본 HTML 가져오기를 시도해보세요.",
"import-original-keywords-as-tags": "원본 키워드를 태그로 가져오기", "import-original-keywords-as-tags": "원본 키워드를 태그로 가져오기",
"import-original-categories": "Import original categories", "import-original-categories": "원래 카테고리 불러오기",
"stay-in-edit-mode": "편집 모드 유지", "stay-in-edit-mode": "편집 모드 유지",
"parse-recipe-ingredients-after-import": "가져오기 후 레시피 재료 추출", "parse-recipe-ingredients-after-import": "가져오기 후 레시피 재료 추출",
"import-from-zip": "Zip 파일에서 가져오기", "import-from-zip": "Zip 파일에서 가져오기",
@@ -712,7 +712,7 @@
"toggle-recipe": "레시피로 전환" "toggle-recipe": "레시피로 전환"
}, },
"recipe-finder": { "recipe-finder": {
"recipe-finder": "레시피 찾기", "recipe-finder": "레시피 검색",
"recipe-finder-description": "가지고 있는 재료로 레시피를 검색하세요. 사용 가능한 도구로도 필터링할 수 있으며, 부족한 재료나 도구의 최대 개수를 설정할 수 있습니다.", "recipe-finder-description": "가지고 있는 재료로 레시피를 검색하세요. 사용 가능한 도구로도 필터링할 수 있으며, 부족한 재료나 도구의 최대 개수를 설정할 수 있습니다.",
"selected-ingredients": "선택된 재료", "selected-ingredients": "선택된 재료",
"no-ingredients-selected": "선택된 재료 없음", "no-ingredients-selected": "선택된 재료 없음",
@@ -743,7 +743,7 @@
"search-mealie": "Mealie 검색 (/를 눌러보세요)", "search-mealie": "Mealie 검색 (/를 눌러보세요)",
"search-placeholder": "검색...", "search-placeholder": "검색...",
"tag-filter": "태그 필터", "tag-filter": "태그 필터",
"search-hint": "'/'를 누르세요", "search-hint": "'/'를 눌러보세요",
"advanced": "고급", "advanced": "고급",
"auto-search": "자동 검색", "auto-search": "자동 검색",
"no-results": "검색 결과가 없습니다.", "no-results": "검색 결과가 없습니다.",
@@ -759,7 +759,7 @@
"restore-success": "복원 성공!", "restore-success": "복원 성공!",
"restore-fail": "복원이 실패했습니다. 자세한 내용은 서버 로그를 확인하십시오.", "restore-fail": "복원이 실패했습니다. 자세한 내용은 서버 로그를 확인하십시오.",
"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": "전체 백업",
@@ -794,7 +794,7 @@
"latest": "가장 최근", "latest": "가장 최근",
"local-api": "로컬 API", "local-api": "로컬 API",
"locale-settings": "국가별 설정", "locale-settings": "국가별 설정",
"migrations": "마이그레이션", "migrations": "데이터 이전",
"new-page": "새 페이지", "new-page": "새 페이지",
"notify": "알림", "notify": "알림",
"organize": "정리", "organize": "정리",
@@ -803,7 +803,7 @@
"profile": "프로필", "profile": "프로필",
"remove-existing-entries-matching-imported-entries": "가져온 항목과 일치하는 기존 항목을 제거합니다", "remove-existing-entries-matching-imported-entries": "가져온 항목과 일치하는 기존 항목을 제거합니다",
"set-new-time": "새 시간 설정", "set-new-time": "새 시간 설정",
"settings-update-failed": "설정 업데이트 실패", "settings-update-failed": "설정 수정 실패",
"settings-updated": "설정 업데이트", "settings-updated": "설정 업데이트",
"site-settings": "사이트 설정", "site-settings": "사이트 설정",
"theme": { "theme": {
@@ -1029,7 +1029,7 @@
"register": "등록", "register": "등록",
"reset-password": "비밀번호 재설정", "reset-password": "비밀번호 재설정",
"sign-in": "로그인", "sign-in": "로그인",
"total-mealplans": "전체 MealPlan 수", "total-mealplans": "전체 식단 계획 수",
"total-users": "전체 사용자 수", "total-users": "전체 사용자 수",
"upload-photo": "사진 업로드", "upload-photo": "사진 업로드",
"use-8-characters-or-more-for-your-password": "비밀번호는 8자 이상으로 설정하십시오", "use-8-characters-or-more-for-your-password": "비밀번호는 8자 이상으로 설정하십시오",
@@ -1073,7 +1073,7 @@
"user-details": "사용자 정보", "user-details": "사용자 정보",
"user-name": "사용자 이름", "user-name": "사용자 이름",
"authentication-method": "인증 방식", "authentication-method": "인증 방식",
"authentication-method-hint": "This specifies how a user will authenticate with Mealie. If you're not sure, choose 'Mealie", "authentication-method-hint": "이 설정은 사용자가 Mealie에 어떻게 인증할지 지정합니다. 잘 모르겠다면 'Mealie'를 선택하세요.",
"permissions": "권한", "permissions": "권한",
"administrator": "관리자", "administrator": "관리자",
"user-can-invite-other-to-group": "사용자는 다른 사용자를 그룹에 초대할 수 있습니다", "user-can-invite-other-to-group": "사용자는 다른 사용자를 그룹에 초대할 수 있습니다",
@@ -1100,8 +1100,8 @@
"foods": { "foods": {
"merge-dialog-text": "선택한 식품을 병합하면 원본 식품과 대상 식품이 하나의 식품으로 합쳐집니다. 원본 식품은 삭제되며, 원본 식품에 대한 모든 참조는 대상 식품을 가리키도록 업데이트됩니다.", "merge-dialog-text": "선택한 식품을 병합하면 원본 식품과 대상 식품이 하나의 식품으로 합쳐집니다. 원본 식품은 삭제되며, 원본 식품에 대한 모든 참조는 대상 식품을 가리키도록 업데이트됩니다.",
"merge-food-example": "{food1}을 {food2}에 병합", "merge-food-example": "{food1}을 {food2}에 병합",
"seed-dialog-text": "Seed the database with foods based on your local language. This will create 200+ common foods that can be used to organize your database. Foods are translated via a community effort.", "seed-dialog-text": "당신이 사용하는 언어에 맞춰 데이터베이스에 음식 정보를 추가하세요. 이렇게 하면 데이터베이스를 구성하는 데 사용할 수 있는 약 2700가지의 일반적인 음식 정보가 생성됩니다. 음식 정보는 커뮤니티 참여를 통해 번역됩니다.",
"seed-dialog-warning": "You have already have some items in your database. This action will not reconcile duplicates, you will have to manage them manually.", "seed-dialog-warning": "데이터베이스에 이미 일부 항목이 있습니다. 이름이 같은 항목이 이미 존재하는 경우 새 항목은 추가되지 않습니다.",
"combine-food": "식품 병합", "combine-food": "식품 병합",
"source-food": "원본 식품", "source-food": "원본 식품",
"target-food": "대상 식품", "target-food": "대상 식품",
@@ -1371,9 +1371,9 @@
"cookbooks-description": "레시피 카테고리 모음을 관리하고 해당 카테고리별 페이지를 생성합니다.", "cookbooks-description": "레시피 카테고리 모음을 관리하고 해당 카테고리별 페이지를 생성합니다.",
"members": "회원", "members": "회원",
"members-description": "가구 구성원을 확인하고 권한을 관리하세요.", "members-description": "가구 구성원을 확인하고 권한을 관리하세요.",
"webhooks-description": "Setup webhooks that trigger on days that you have have mealplan scheduled.", "webhooks-description": "식단 계획이 예정된 날짜에 발동되는 웹훅을 설정하세요.",
"notifiers": "알리미", "notifiers": "알리미",
"notifiers-description": "Setup email and push notifications that trigger on specific events.", "notifiers-description": "특정 이벤트 발생 시 이메일 및 푸시 알림이 전송되도록 설정하세요.",
"manage-data": "데이터 관리하기", "manage-data": "데이터 관리하기",
"manage-data-description": "Mealie 데이터를 관리하세요; 식품, 단위, 카테고리, 태그 등.", "manage-data-description": "Mealie 데이터를 관리하세요; 식품, 단위, 카테고리, 태그 등.",
"data-migrations": "데이터 이전", "data-migrations": "데이터 이전",
@@ -1395,7 +1395,7 @@
}, },
"cookbook": { "cookbook": {
"cookbooks": "요리책", "cookbooks": "요리책",
"description": "요리책은 레시피, 정리 도구 및 기타 필터를 교차 조합하여 레시피를 체계화하는 또 다른 방법입니다. 요리책을 생성하면 사이드바에 항목이 추가되며, 선택한 필터가 적용된 모든 레시피가 해당 요리책에 표시됩니다.", "description": "요리책은 레시피, 정리 및 기타 필터를 교차 조합하여 레시피를 체계화하는 또 다른 방법입니다. 요리책을 생성하면 사이드바에 항목이 추가되며, 선택한 필터가 적용된 모든 레시피가 해당 요리책에 표시됩니다.",
"hide-cookbooks-from-other-households": "다른 가구의 요리책 숨기기", "hide-cookbooks-from-other-households": "다른 가구의 요리책 숨기기",
"hide-cookbooks-from-other-households-description": "이 기능을 활성화하면 사이드바에는 귀하의 가구에 속한 요리책만 표시됩니다.", "hide-cookbooks-from-other-households-description": "이 기능을 활성화하면 사이드바에는 귀하의 가구에 속한 요리책만 표시됩니다.",
"public-cookbook": "공개 요리책", "public-cookbook": "공개 요리책",
@@ -1419,19 +1419,24 @@
"relational-operators": { "relational-operators": {
"equals": "정확히 일치", "equals": "정확히 일치",
"does-not-equal": "일치하지 않음", "does-not-equal": "일치하지 않음",
"is-greater-than": "은(는) 다음보다 크다:", "is-greater-than": "다음보다 ",
"is-greater-than-or-equal-to": "은(는) 다음보다 크거나 같다:", "is-greater-than-or-equal-to": "다음보다 크거나 같",
"is-less-than": "은(는) 다음보다 작다:", "is-less-than": "다음보다 작",
"is-less-than-or-equal-to": "은(는) 다음보다 작거나 같다:" "is-less-than-or-equal-to": "다음보다 작거나 같음",
"is-older-than": "~보다 오래됨",
"is-newer-than": "~보다 최근임"
}, },
"relational-keywords": { "relational-keywords": {
"is": "은(는)", "is": "같음",
"is-not": "은(는) 아니다", "is-not": "아님",
"is-one-of": "은(는) 다음에 포함:", "is-one-of": "다음에 포함",
"is-not-one-of": "은(는) 다음에 포함되지 않음:", "is-not-one-of": "다음에 포함되지 않음",
"contains-all-of": "은(는) 다음 모두를 포함:", "contains-all-of": "다음 모두를 포함",
"is-like": "은(는) 다음과 같음:", "is-like": "다음과 같음",
"is-not-like": "은(는) 다음과 같지 않음:" "is-not-like": "다음과 같지 않음"
},
"dates": {
"days-ago": "일 전|일 전|일 전"
} }
}, },
"validators": { "validators": {

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
"about-mealie": "Over Mealie", "about-mealie": "Over Mealie",
"api-docs": "API-documentatie", "api-docs": "API-documentatie",
"api-port": "API-poort", "api-port": "API-poort",
"application-mode": "Toepassingsmodus", "application-mode": "Applicatiemodus",
"database-type": "Databasetype", "database-type": "Databasetype",
"database-url": "Database URL", "database-url": "Database URL",
"default-group": "Standaardgroep", "default-group": "Standaardgroep",
@@ -370,8 +370,8 @@
"applies-to-all-days": "Van toepassing op alle dagen", "applies-to-all-days": "Van toepassing op alle dagen",
"applies-on-days": "Van toepassing op {0}s", "applies-on-days": "Van toepassing op {0}s",
"meal-plan-settings": "Maaltijdplan-instellingen", "meal-plan-settings": "Maaltijdplan-instellingen",
"add-all-to-list": "Add All to List", "add-all-to-list": "Alles aan lijst toevoegen",
"add-day-to-list": "Add Day to List" "add-day-to-list": "Dag aan lijst toevoegen"
}, },
"migration": { "migration": {
"migration-data-removed": "Migratiegegevens verwijderd", "migration-data-removed": "Migratiegegevens verwijderd",
@@ -1422,7 +1422,9 @@
"is-greater-than": "is groter dan", "is-greater-than": "is groter dan",
"is-greater-than-or-equal-to": "is groter dan of gelijk aan", "is-greater-than-or-equal-to": "is groter dan of gelijk aan",
"is-less-than": "is kleiner dan", "is-less-than": "is kleiner dan",
"is-less-than-or-equal-to": "is kleiner dan of gelijk aan" "is-less-than-or-equal-to": "is kleiner dan of gelijk aan",
"is-older-than": "is ouder dan",
"is-newer-than": "is nieuwer dan"
}, },
"relational-keywords": { "relational-keywords": {
"is": "is", "is": "is",
@@ -1432,6 +1434,9 @@
"contains-all-of": "bevat alles van", "contains-all-of": "bevat alles van",
"is-like": "is zoals", "is-like": "is zoals",
"is-not-like": "is niet zoals" "is-not-like": "is niet zoals"
},
"dates": {
"days-ago": "dagen geleden|dag geleden|dagen geleden"
} }
}, },
"validators": { "validators": {

View File

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

View File

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

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