Compare commits

...

113 Commits

Author SHA1 Message Date
renovate[bot]
9cd849b694 fix(deps): update dependency openai to v2.24.0 2026-02-25 01:52:17 +00:00
Michael Genson
3ae455539c fix: Ensure recipe ingredients have a valid reference_id (#7139) 2026-02-24 19:50:49 -06:00
Hayden
8fd7995681 chore(l10n): New Crowdin updates (#7136) 2026-02-24 19:51:38 +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
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
Hayden
3a01925e48 chore(l10n): New Crowdin updates (#7116) 2026-02-22 05:31:27 +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
mealie-commit-bot[bot]
3de4024619 chore: bump version to v3.10.0 2026-02-02 18:33:02 +00:00
renovate[bot]
194771653d chore(deps): pin dependency freezegun to ==1.5.5 (#6991)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-02 18:18:36 +00:00
renovate[bot]
24aa8f3525 fix(deps): update dependency orjson to v3.11.7 (#6989)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-02 17:54:33 +00:00
Michael Genson
fb8e318739 fix: Flaky $NOW tests (#6990) 2026-02-02 11:24:13 -06:00
Michael Genson
6255c71609 docs: Misc. cleanup (#6988) 2026-02-02 10:47:23 -06:00
Hayden
f2d1569488 chore(l10n): New Crowdin updates (#6987) 2026-02-02 11:18:56 +00:00
Michael Genson
987c7209fc feat: Query relative dates (#6984) 2026-02-01 21:36:46 -06:00
Hayden
f6dbd1f1f1 chore(l10n): New Crowdin updates (#6983) 2026-02-01 23:23:28 +00:00
Michael Genson
d30118899d fix: Re-enable some style tags (#6982) 2026-02-01 16:24:57 -06:00
Michael Genson
af241dad57 feat: Add range of dates to shopping list from meal planner (#6981) 2026-02-01 15:58:03 -06:00
renovate[bot]
b86de79c6f chore(deps): update dependency rich to v14.3.2 (#6980)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-01 20:28:33 +00:00
Hayden
86e86f8c81 chore(l10n): New Crowdin updates (#6979) 2026-02-01 10:58:51 +00:00
mealie-actions[bot]
d795f91938 chore(l10n): Crowdin locale sync (#6977)
Co-authored-by: GitHub Action <action@github.com>
2026-02-01 04:42:54 +00:00
Michael Genson
a59511cc81 dev: Switch to Mealie bot for auto-merging (#6978) 2026-01-31 22:42:03 -06:00
Michael Genson
a5d4cae6d0 dev: Switch approver for automated i18n merges (#6976) 2026-01-31 21:46:13 -06:00
Michael Genson
2987cf8ba6 dev: Allow locale sync path in auto merge (#6974) 2026-01-31 21:37:31 -06:00
Michael Genson
46b46978ff dev: Increase locale merge limit to 300 and allow PRs from Mealie bot (#6972) 2026-01-31 21:31:52 -06:00
Michael Genson
12857883a9 dev: Fix token vars (#6970) 2026-01-31 21:24:58 -06:00
Michael Genson
60fff3b5b8 dev: Switch to bot token for locale sync (#6969) 2026-01-31 21:21:07 -06:00
243 changed files with 10036 additions and 9833 deletions

View File

@@ -14,19 +14,16 @@ jobs:
if: contains(github.event.pull_request.labels.*.name, 'l10n')
steps:
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
- name: Validate PR author
env:
AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
if [[ "$AUTHOR" != "hay-kot" && "$AUTHOR" != "github-actions[bot]" ]]; then
echo "::error::PR author must be hay-kot or github-actions[bot] for auto-merge (got: $AUTHOR)"
if [[
"$AUTHOR" != "hay-kot" &&
"$AUTHOR" != "github-actions[bot]" &&
"$AUTHOR" != "mealie-actions[bot]"
]]; then
echo "::error::PR author must be hay-kot, github-actions[bot], or mealie-actions[bot] for auto-merge (got: $AUTHOR)"
exit 1
fi
echo "Author validated: $AUTHOR"
@@ -43,8 +40,8 @@ jobs:
echo "PR changes: +$ADDITIONS -$DELETIONS (total: $TOTAL lines)"
if [ "$TOTAL" -gt 200 ]; then
echo "::error::PR exceeds 200 line change limit ($TOTAL lines)"
if [ "$TOTAL" -gt 400 ]; then
echo "::error::PR exceeds 400 line change limit ($TOTAL lines)"
exit 1
fi
@@ -57,18 +54,27 @@ jobs:
FILES=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json files --jq '.files[].path')
for file in $FILES; do
if [[ ! "$file" =~ ^frontend/lang/ ]] && [[ ! "$file" =~ ^mealie/repos/seed/resources/[^/]+/locales/ ]]; then
echo "::error::Invalid file path: $file"
echo "Only files in frontend/lang/ or mealie/repos/seed/resources/*/locales/ are allowed"
exit 1
# Check if file matches any allowed path
if [[ "$file" == "frontend/composables/use-locales/available-locales.ts" ]] || \
[[ "$file" =~ ^frontend/lang/ ]] || \
[[ "$file" =~ ^mealie/repos/seed/resources/[^/]+/locales/ ]]; then
continue
fi
# File doesn't match allowed paths
echo "::error::Invalid file path: $file"
echo "Only the following paths are allowed:"
echo " - frontend/composables/use-locales/available-locales.ts"
echo " - frontend/lang/"
echo " - mealie/repos/seed/resources/*/locales/"
exit 1
done
echo "All files are in allowed paths"
- name: Approve PR
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
@@ -88,9 +94,16 @@ jobs:
--approve \
--body "Auto-approved: l10n PR from trusted author with valid file paths"
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.COMMIT_BOT_APP_ID }}
private-key: ${{ secrets.COMMIT_BOT_APP_PRIVATE_KEY }}
- name: Enable auto-merge
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

@@ -91,7 +91,7 @@ const state = reactive({
shoppingListDialog: false,
menuItems: [
{
title: i18n.t("recipe.add-to-list"),
title: i18n.t("meal-plan.add-day-to-list"),
icon: $globals.icons.cartCheck,
color: undefined,
event: "shoppingList",
@@ -123,8 +123,8 @@ async function getShoppingLists() {
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
shoppingList: () => {
getShoppingLists();
shoppingList: async () => {
await getShoppingLists();
state.shoppingListDialog = true;
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -217,7 +217,7 @@ const props = withDefaults(defineProps<Props>(), {
const dialog = defineModel<boolean>({ default: false });
const i18n = useI18n();
const $auth = useMealieAuth();
const auth = useMealieAuth();
const api = useUserApi();
const preferences = useShoppingListPreferences();
const ready = ref(false);
@@ -227,7 +227,7 @@ const currentHouseholdSlug = ref("");
const filteredShoppingLists = ref<ShoppingListSummary[]>([]);
const state = reactive({
shoppingListDialog: true,
shoppingListDialog: false,
shoppingListIngredientDialog: false,
shoppingListShowAllToggled: false,
});
@@ -239,9 +239,9 @@ const selectedShoppingList = ref<ShoppingListSummary | null>(null);
watch([dialog, () => preferences.value.viewAllLists], () => {
if (dialog.value) {
currentHouseholdSlug.value = $auth.user.value?.householdSlug || "";
currentHouseholdSlug.value = auth.user.value?.householdSlug || "";
filteredShoppingLists.value = props.shoppingLists.filter(
list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id,
list => preferences.value.viewAllLists || list.userId === auth.user.value?.id,
);
if (filteredShoppingLists.value.length === 1 && !state.shoppingListShowAllToggled) {
@@ -249,6 +249,7 @@ watch([dialog, () => preferences.value.viewAllLists], () => {
openShoppingListIngredientDialog(selectedShoppingList.value);
}
else {
state.shoppingListDialog = true;
ready.value = true;
}
}
@@ -371,7 +372,7 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
}
function initState() {
state.shoppingListDialog = true;
state.shoppingListDialog = false;
state.shoppingListIngredientDialog = false;
state.shoppingListShowAllToggled = false;
recipeIngredientSections.value = [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
import { ActivityKey } from "~/lib/api/types/activity";
import type { RegisteredParser, TimelineEventType } from "~/lib/api/types/recipe";
import type { QueryFilterJSON } from "~/lib/api/types/response";
import type { QueryFilterJSON } from "~/lib/api/types/non-generated";
export interface UserPrintPreferences {
imagePosition: string;

View File

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

View File

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

View File

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

View File

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

View File

@@ -369,7 +369,9 @@
"recipe-rules": "Resepreëls",
"applies-to-all-days": "Van toepassing op alle dae",
"applies-on-days": "Van toepassing op {0}s",
"meal-plan-settings": "Maaltydplan verstellings"
"meal-plan-settings": "Maaltydplan verstellings",
"add-all-to-list": "Add All to List",
"add-day-to-list": "Add Day to List"
},
"migration": {
"migration-data-removed": "Migrasiedata is uitgevee",
@@ -1420,7 +1422,9 @@
"is-greater-than": "is greater than",
"is-greater-than-or-equal-to": "is greater than or equal to",
"is-less-than": "is less than",
"is-less-than-or-equal-to": "is less than or equal to"
"is-less-than-or-equal-to": "is less than or equal to",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "is",
@@ -1430,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": {
@@ -1438,6 +1445,6 @@
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

@@ -369,7 +369,9 @@
"recipe-rules": "قواعد الوصفات",
"applies-to-all-days": "ينطبق على جميع الأيام",
"applies-on-days": "يطبق على أيام {0}",
"meal-plan-settings": "إعدادات خِطَّة الوجبات الغذائية"
"meal-plan-settings": "إعدادات خِطَّة الوجبات الغذائية",
"add-all-to-list": "Add All to List",
"add-day-to-list": "Add Day to List"
},
"migration": {
"migration-data-removed": "حذف بيانات الهجرة",
@@ -1420,7 +1422,9 @@
"is-greater-than": "أكبر من",
"is-greater-than-or-equal-to": "أكبر من أو يساوي",
"is-less-than": "أقل من",
"is-less-than-or-equal-to": "أقل من أو يساوي"
"is-less-than-or-equal-to": "أقل من أو يساوي",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "هو",
@@ -1430,6 +1434,9 @@
"contains-all-of": "يحتوي على كل من",
"is-like": "هو مثل",
"is-not-like": "ليس مثل"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {
@@ -1438,6 +1445,6 @@
"invalid-url": "يجب أن يكون عنوان URL صالحًا",
"no-whitespace": "لا يسمح باستخدام المسافات",
"min-length": "يجب أن يكون على الأقل {min} أحرف",
"max-length": "يجب أن يكون على الأكثر {max} أحرف"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

@@ -369,7 +369,9 @@
"recipe-rules": "Правила на рецептата",
"applies-to-all-days": "Прилага се за всички дни",
"applies-on-days": "Всеки/всяка {0}",
"meal-plan-settings": "Настройки на плана за хранене"
"meal-plan-settings": "Настройки на плана за хранене",
"add-all-to-list": "Добавяне на всички към списъка за пазаруване",
"add-day-to-list": "Добавяне на ден към списъка за пазаруване"
},
"migration": {
"migration-data-removed": "Данните за мигриране са премахнати",
@@ -642,7 +644,7 @@
"scrape-recipe-website-being-blocked": "Блокиран ли е уебсайтът?",
"scrape-recipe-try-importing-raw-html-instead": "Опитайте вместо това да импортирате суровия HTML код.",
"import-original-keywords-as-tags": "Добави оригиналните ключови думи като етикети",
"import-original-categories": "Import original categories",
"import-original-categories": "Импортиране на оригиналните категории",
"stay-in-edit-mode": "Остани в режим на редакция",
"parse-recipe-ingredients-after-import": "Анализиране на съставките на рецептата след импортиране",
"import-from-zip": "Импортирай от Zip",
@@ -1420,7 +1422,9 @@
"is-greater-than": "е по-голямо от",
"is-greater-than-or-equal-to": "е по-голямо от или равно на",
"is-less-than": "е по-малко от",
"is-less-than-or-equal-to": "e по-малко или равно на"
"is-less-than-or-equal-to": "e по-малко или равно на",
"is-older-than": "е по-стар от",
"is-newer-than": "е по-нов от"
},
"relational-keywords": {
"is": "е",
@@ -1430,6 +1434,9 @@
"contains-all-of": "съдържа всички от",
"is-like": "е като",
"is-not-like": "не е като"
},
"dates": {
"days-ago": "преди дни|преди ден|преди дни"
}
},
"validators": {
@@ -1438,6 +1445,6 @@
"invalid-url": "Линкът трябва да е валиден",
"no-whitespace": "Не са позволени интервали",
"min-length": "Трябва да съдържа поне {min} знака",
"max-length": "Трябва да съдържа най-много {max} знака"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

@@ -369,7 +369,9 @@
"recipe-rules": "Normes per la recepta",
"applies-to-all-days": "Aplica a tots els dies",
"applies-on-days": "S'aplicarà en {0}s",
"meal-plan-settings": "Opcions de planificació de menús"
"meal-plan-settings": "Opcions de planificació de menús",
"add-all-to-list": "Add All to List",
"add-day-to-list": "Add Day to List"
},
"migration": {
"migration-data-removed": "S'han suprimit les dades migrades",
@@ -1420,7 +1422,9 @@
"is-greater-than": "és més gran que",
"is-greater-than-or-equal-to": "és més gran o igual a",
"is-less-than": "és menys que",
"is-less-than-or-equal-to": "és menor o igual a"
"is-less-than-or-equal-to": "és menor o igual a",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "és",
@@ -1430,6 +1434,9 @@
"contains-all-of": "conté tots de",
"is-like": "és com",
"is-not-like": "no és com"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {
@@ -1438,6 +1445,6 @@
"invalid-url": "La URL ha de ser vàlida",
"no-whitespace": "No es permeten espais en blanc",
"min-length": "Ha de tenir almenys {min} caràcters",
"max-length": "Ha de tenir com a màxim {max} caràcters"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

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

View File

@@ -212,8 +212,8 @@
"upload-file": "Upload fil",
"created-on-date": "Oprettet den: {0}",
"unsaved-changes": "Du har ændringer som ikke er gemt. Vil du gemme før du forlader? Vælg \"Okay\" for at gemme, eller \"Annullér\" for at kassere ændringer.",
"discard-changes": "Discard Changes",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
"discard-changes": "Kassér ændringer",
"discard-changes-description": "Du har ændringer, der ikke er gemt. Er du sikker på, at du vil kassere dem?",
"clipboard-copy-failure": "Kopiering til udklipsholderen mislykkedes.",
"confirm-delete-generic-items": "Er du sikker på at du ønsker at slette de valgte emner?",
"organizers": "Organisatorer",
@@ -369,7 +369,9 @@
"recipe-rules": "Opskriftsregler",
"applies-to-all-days": "Gælder for alle dage",
"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": "Tilføj alle til liste",
"add-day-to-list": "Tilføj dag til liste"
},
"migration": {
"migration-data-removed": "Migreringsdata fjernet",
@@ -642,7 +644,7 @@
"scrape-recipe-website-being-blocked": "Bliver hjemmesiden blokeret?",
"scrape-recipe-try-importing-raw-html-instead": "Forsøg at importere den rå HTML i stedet.",
"import-original-keywords-as-tags": "Importér originale nøgleord som mærker",
"import-original-categories": "Import original categories",
"import-original-categories": "Importér originale kategorier",
"stay-in-edit-mode": "Bliv i redigeringstilstand",
"parse-recipe-ingredients-after-import": "Fortolk opskrift ingredienser efter import",
"import-from-zip": "Importer fra zip-fil",
@@ -1420,7 +1422,9 @@
"is-greater-than": "er større end",
"is-greater-than-or-equal-to": "er større end eller lig med (Automatic Translation)",
"is-less-than": "er mindre end (Automatic Translation)",
"is-less-than-or-equal-to": "er mindre end eller lig med (Automatic Translation)"
"is-less-than-or-equal-to": "er mindre end eller lig med (Automatic Translation)",
"is-older-than": "er ældre end",
"is-newer-than": "er nyere end"
},
"relational-keywords": {
"is": "er",
@@ -1430,6 +1434,9 @@
"contains-all-of": "indeholder alle af",
"is-like": "er ligesom",
"is-not-like": "er ikke som"
},
"dates": {
"days-ago": "dage siden|dag siden|dage siden"
}
},
"validators": {
@@ -1438,6 +1445,6 @@
"invalid-url": "URL'en skal være gyldig",
"no-whitespace": "Mellemrum er ikke tilladt",
"min-length": "Der skal mindst være {min} tegn",
"max-length": "Der må højst være {max} tegn"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

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

View File

@@ -369,7 +369,9 @@
"recipe-rules": "Κανόνες Συνταγής",
"applies-to-all-days": "Εφαρμόζεται για όλες τις ημέρες",
"applies-on-days": "Εφαρμόζεται κάθε {0}",
"meal-plan-settings": "Ρυθμίσεις προγράμματος γευμάτων"
"meal-plan-settings": "Ρυθμίσεις προγράμματος γευμάτων",
"add-all-to-list": "Προσθήκη όλων στη λίστα",
"add-day-to-list": "Προσθήκη ημέρας στη λίστα"
},
"migration": {
"migration-data-removed": "Τα δεδομένα μετεγκατάστασης καταργήθηκαν",
@@ -642,7 +644,7 @@
"scrape-recipe-website-being-blocked": "Η ιστοσελίδα μπλοκάρεται;",
"scrape-recipe-try-importing-raw-html-instead": "Δοκιμάστε να εισάγετε τον ακατέργαστο κώδικα HTML.",
"import-original-keywords-as-tags": "Εισαγωγή αρχικών λέξεων-κλειδιών ως ετικέτες",
"import-original-categories": "Import original categories",
"import-original-categories": "Εισαγωγή αρχικών κατηγοριών",
"stay-in-edit-mode": "Παραμονή σε λειτουργία επεξεργασίας",
"parse-recipe-ingredients-after-import": "Ανάλυση συστατικών συνταγής μετά την εισαγωγή",
"import-from-zip": "Εισαγωγή μέσω zip",
@@ -1420,7 +1422,9 @@
"is-greater-than": "είναι μεγαλύτερο από",
"is-greater-than-or-equal-to": "είναι μεγαλύτερο από ή ίσο με",
"is-less-than": "είναι μικρότερο από",
"is-less-than-or-equal-to": "είναι μικρότερο από ή ίσο με"
"is-less-than-or-equal-to": "είναι μικρότερο από ή ίσο με",
"is-older-than": "είναι παλαιότερο από",
"is-newer-than": "είναι νεότερο από"
},
"relational-keywords": {
"is": "είναι",
@@ -1430,6 +1434,9 @@
"contains-all-of": "περιέχει όλα τα",
"is-like": "είναι όμοιο με",
"is-not-like": "δεν είναι όμοιο με"
},
"dates": {
"days-ago": "ημέρες πριν|ημέρα πριν|ημέρες πριν"
}
},
"validators": {
@@ -1438,6 +1445,6 @@
"invalid-url": "Πρέπει να είναι μια έγκυρη διεύθυνση URL",
"no-whitespace": "Δεν επιτρέπονται κενοί χαρακτήρες",
"min-length": "Πρέπει να αποτελείται από τουλάχιστον {min} χαρακτήρες",
"max-length": "Πρέπει να αποτελείται το πολύ από {max} χαρακτήρες"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

@@ -369,7 +369,9 @@
"recipe-rules": "Recipe Rules",
"applies-to-all-days": "Applies to all days",
"applies-on-days": "Applies on {0}s",
"meal-plan-settings": "Meal Plan Settings"
"meal-plan-settings": "Meal Plan Settings",
"add-all-to-list": "Add All to List",
"add-day-to-list": "Add Day to List"
},
"migration": {
"migration-data-removed": "Migration data removed",
@@ -1420,7 +1422,9 @@
"is-greater-than": "is greater than",
"is-greater-than-or-equal-to": "is greater than or equal to",
"is-less-than": "is less than",
"is-less-than-or-equal-to": "is less than or equal to"
"is-less-than-or-equal-to": "is less than or equal to",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "is",
@@ -1430,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": {
@@ -1438,6 +1445,6 @@
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

@@ -369,7 +369,9 @@
"recipe-rules": "Recipe Rules",
"applies-to-all-days": "Applies to all days",
"applies-on-days": "Applies on {0}s",
"meal-plan-settings": "Meal Plan Settings"
"meal-plan-settings": "Meal Plan Settings",
"add-all-to-list": "Add All to List",
"add-day-to-list": "Add Day to List"
},
"migration": {
"migration-data-removed": "Migration data removed",
@@ -1420,7 +1422,9 @@
"is-greater-than": "is greater than",
"is-greater-than-or-equal-to": "is greater than or equal to",
"is-less-than": "is less than",
"is-less-than-or-equal-to": "is less than or equal to"
"is-less-than-or-equal-to": "is less than or equal to",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "is",
@@ -1430,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": {
@@ -1438,6 +1445,6 @@
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

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

View File

@@ -212,7 +212,7 @@
"upload-file": "Lae fail üles",
"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.",
"discard-changes": "Discard Changes",
"discard-changes": "Loobu muudatustest",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
"clipboard-copy-failure": "Lõikepuhvrisse kopeerimine ebaõnnestus.",
"confirm-delete-generic-items": "Kas oled kindel, et tahad kustutada järgnevad asjad?",
@@ -344,9 +344,9 @@
"breakfast": "Hommikusöök",
"lunch": "Lõuna",
"dinner": "Õhtusöök",
"snack": "Snack",
"drink": "Drink",
"dessert": "Dessert",
"snack": "Snäkk",
"drink": "Jook",
"dessert": "Magustoit",
"type-any": "Kõik",
"day-any": "Kõik",
"editor": "Editor",
@@ -369,7 +369,9 @@
"recipe-rules": "Retsepti reeglid",
"applies-to-all-days": "Kehtib kõikide päevade kohta",
"applies-on-days": "Kehtib {0}l",
"meal-plan-settings": "Toitumisplaani sätted"
"meal-plan-settings": "Toitumisplaani sätted",
"add-all-to-list": "Add All to List",
"add-day-to-list": "Add Day to List"
},
"migration": {
"migration-data-removed": "Ületoomiste andmed eemaldatud",
@@ -1420,7 +1422,9 @@
"is-greater-than": "on suurem kui",
"is-greater-than-or-equal-to": "on suurem või võrdne kui",
"is-less-than": "on vähem kui",
"is-less-than-or-equal-to": "on väiksem või võrdne kui"
"is-less-than-or-equal-to": "on väiksem või võrdne kui",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "on",
@@ -1430,6 +1434,9 @@
"contains-all-of": "sisaldab kõiki",
"is-like": "on nagu",
"is-not-like": "ei ole nagu"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {
@@ -1438,6 +1445,6 @@
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

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

View File

@@ -212,8 +212,8 @@
"upload-file": "Transférer un fichier",
"created-on-date": "Créé le {0}",
"unsaved-changes": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer avant de partir? OK pour enregistrer, Annuler pour ignorer les modifications.",
"discard-changes": "Discard Changes",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
"discard-changes": "Annuler les modifications",
"discard-changes-description": "Vous avez des changements non sauvegardés. Êtes-vous sûr de vouloir les annuler ?",
"clipboard-copy-failure": "Échec de la copie dans le presse-papiers.",
"confirm-delete-generic-items": "Êtes-vous sûr de vouloir supprimer les éléments suivants ?",
"organizers": "Classification",
@@ -369,7 +369,9 @@
"recipe-rules": "Règles de recette",
"applies-to-all-days": "S'applique à tous les jours",
"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": "Tout ajouter a une liste",
"add-day-to-list": "Ajouter un jour à la liste"
},
"migration": {
"migration-data-removed": "Données de migration supprimées",
@@ -642,7 +644,7 @@
"scrape-recipe-website-being-blocked": "Le site web est bloqué ?",
"scrape-recipe-try-importing-raw-html-instead": "Essayez plutôt d'importer le code HTML brut.",
"import-original-keywords-as-tags": "Importer les mots-clés d'origine en tant que tags",
"import-original-categories": "Import original categories",
"import-original-categories": "Importer les catégories originales",
"stay-in-edit-mode": "Rester en mode édition",
"parse-recipe-ingredients-after-import": "Analyser les ingrédients de la recette après l'import",
"import-from-zip": "Importer depuis un zip",
@@ -1420,7 +1422,9 @@
"is-greater-than": "est supérieur à",
"is-greater-than-or-equal-to": "est plus grand que ou égal à",
"is-less-than": "est inférieur à",
"is-less-than-or-equal-to": "est inférieur ou égal à"
"is-less-than-or-equal-to": "est inférieur ou égal à",
"is-older-than": "est plus ancien que",
"is-newer-than": "est plus récent que"
},
"relational-keywords": {
"is": "est",
@@ -1430,6 +1434,9 @@
"contains-all-of": "contient tout",
"is-like": "est comme",
"is-not-like": "n'est pas similaire à"
},
"dates": {
"days-ago": "jours|jour|jours"
}
},
"validators": {
@@ -1438,6 +1445,6 @@
"invalid-url": "Doit être une URL valide",
"no-whitespace": "Aucun espace n'est autorisé",
"min-length": "Doit contenir au moins {min} caractères",
"max-length": "Doit contenir au maximum {max} caractères"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

@@ -212,8 +212,8 @@
"upload-file": "Téléverser un fichier",
"created-on-date": "Créé le {0}",
"unsaved-changes": "Vous avez des modifications non enregistrées. Voulez-vous les enregistrer? Ok pour enregistrer, annuler pour ignorer les modifications.",
"discard-changes": "Discard Changes",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
"discard-changes": "Annuler les modifications",
"discard-changes-description": "Vous avez des changements non sauvegardés. Êtes-vous sûr de vouloir les annuler ?",
"clipboard-copy-failure": "Échec de la copie vers le presse-papiers.",
"confirm-delete-generic-items": "Êtes-vous sûr de vouloir supprimer les éléments suivants ?",
"organizers": "Classification",
@@ -369,7 +369,9 @@
"recipe-rules": "Règles de recette",
"applies-to-all-days": "S'applique à tous les jours",
"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": "Ajouter tout à la liste",
"add-day-to-list": "Ajouter un jour à la liste"
},
"migration": {
"migration-data-removed": "Données de migration supprimées",
@@ -642,7 +644,7 @@
"scrape-recipe-website-being-blocked": "Le site web est bloqué ?",
"scrape-recipe-try-importing-raw-html-instead": "Essayez plutôt d'importer le code HTML brut.",
"import-original-keywords-as-tags": "Importer les mots-clés d'origine en tant que tags",
"import-original-categories": "Import original categories",
"import-original-categories": "Importer les catégories originales",
"stay-in-edit-mode": "Rester en mode édition",
"parse-recipe-ingredients-after-import": "Analyser les ingrédients de la recette après l'import",
"import-from-zip": "Importer depuis un zip",
@@ -1420,7 +1422,9 @@
"is-greater-than": "est supérieur à",
"is-greater-than-or-equal-to": "est supérieur ou égal à",
"is-less-than": "est inférieure à",
"is-less-than-or-equal-to": "est inférieur ou égal à"
"is-less-than-or-equal-to": "est inférieur ou égal à",
"is-older-than": "est plus ancien que",
"is-newer-than": "est plus récent que"
},
"relational-keywords": {
"is": "est",
@@ -1430,6 +1434,9 @@
"contains-all-of": "contient tous les",
"is-like": "est similaire à",
"is-not-like": "n'est pas similaire à"
},
"dates": {
"days-ago": "jours|jour|jours"
}
},
"validators": {
@@ -1438,6 +1445,6 @@
"invalid-url": "Doit être une URL valide",
"no-whitespace": "Aucun espace n'est autorisé",
"min-length": "",
"max-length": "Doit contenir au maximum {max} caractères"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

@@ -212,8 +212,8 @@
"upload-file": "Téléverser un fichier",
"created-on-date": "Créé le {0}",
"unsaved-changes": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer avant de partir? OK pour enregistrer, Annuler pour ignorer les modifications.",
"discard-changes": "Discard Changes",
"discard-changes-description": "You have unsaved changes. Are you sure you want to discard them?",
"discard-changes": "Annuler les modifications",
"discard-changes-description": "Vous avez des changements non sauvegardés. Êtes-vous sûr de vouloir les annuler ?",
"clipboard-copy-failure": "Échec de la copie dans le presse-papiers.",
"confirm-delete-generic-items": "Êtes-vous sûr de vouloir supprimer les éléments suivants ?",
"organizers": "Classification",
@@ -369,7 +369,9 @@
"recipe-rules": "Règles de recette",
"applies-to-all-days": "S'applique à tous les jours",
"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": "Ajouter tout à la liste",
"add-day-to-list": "Ajouter un jour à la liste"
},
"migration": {
"migration-data-removed": "Données de migration supprimées",
@@ -642,7 +644,7 @@
"scrape-recipe-website-being-blocked": "Le site web est bloqué ?",
"scrape-recipe-try-importing-raw-html-instead": "Essayez plutôt d'importer le code HTML brut.",
"import-original-keywords-as-tags": "Importer les mots-clés d'origine en tant que tags",
"import-original-categories": "Import original categories",
"import-original-categories": "Importer les catégories originales",
"stay-in-edit-mode": "Rester en mode édition",
"parse-recipe-ingredients-after-import": "Analyser les ingrédients de la recette après l'import",
"import-from-zip": "Importer depuis un zip",
@@ -1420,7 +1422,9 @@
"is-greater-than": "est supérieur à",
"is-greater-than-or-equal-to": "est plus grand que ou égal à",
"is-less-than": "est inférieur à",
"is-less-than-or-equal-to": "est inférieur ou égal à"
"is-less-than-or-equal-to": "est inférieur ou égal à",
"is-older-than": "est plus ancien que",
"is-newer-than": "est plus récent que"
},
"relational-keywords": {
"is": "est",
@@ -1430,6 +1434,9 @@
"contains-all-of": "contient tout",
"is-like": "est comme",
"is-not-like": "n'est pas similaire à"
},
"dates": {
"days-ago": "jours|jour|jours"
}
},
"validators": {
@@ -1438,6 +1445,6 @@
"invalid-url": "Doit être une URL valide",
"no-whitespace": "Aucun espace n'est autorisé",
"min-length": "Doit contenir au moins {min} caractères",
"max-length": "Doit contenir au maximum {max} caractères"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

@@ -369,7 +369,9 @@
"recipe-rules": "Regras da Receita",
"applies-to-all-days": "Aplícase a todos os días",
"applies-on-days": "Aplícase en {0}s",
"meal-plan-settings": "Axustes do Menú"
"meal-plan-settings": "Axustes do Menú",
"add-all-to-list": "Add All to List",
"add-day-to-list": "Add Day to List"
},
"migration": {
"migration-data-removed": "Elimináronse os datos de migración",
@@ -1420,7 +1422,9 @@
"is-greater-than": "é maior que",
"is-greater-than-or-equal-to": "é maior ou igual a",
"is-less-than": "é menor que",
"is-less-than-or-equal-to": "é menor ou igual a"
"is-less-than-or-equal-to": "é menor ou igual a",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "é",
@@ -1430,6 +1434,9 @@
"contains-all-of": "contén todos os",
"is-like": "é como",
"is-not-like": "non é como"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {
@@ -1438,6 +1445,6 @@
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

@@ -369,7 +369,9 @@
"recipe-rules": "חוקי מתכון",
"applies-to-all-days": "החל על כל הימים",
"applies-on-days": "חל על {0}",
"meal-plan-settings": "הגדרות תכנון ארוחות"
"meal-plan-settings": "הגדרות תכנון ארוחות",
"add-all-to-list": "Add All to List",
"add-day-to-list": "Add Day to List"
},
"migration": {
"migration-data-removed": "מידע ממוגרץ נמחק",
@@ -1420,7 +1422,9 @@
"is-greater-than": "גדול מ-",
"is-greater-than-or-equal-to": "גדול או שווה ל-",
"is-less-than": "קטן מ-",
"is-less-than-or-equal-to": "קטן או שווה ל-"
"is-less-than-or-equal-to": "קטן או שווה ל-",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "זהה ל-",
@@ -1430,6 +1434,9 @@
"contains-all-of": "מכיל הכל מתוך",
"is-like": "דומה ל-",
"is-not-like": "לא דומה לא-"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {
@@ -1438,6 +1445,6 @@
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

@@ -369,7 +369,9 @@
"recipe-rules": "Pravila Recepata",
"applies-to-all-days": "Primjeni na sve dane",
"applies-on-days": "Primjeni na {0}",
"meal-plan-settings": "Postavke Plana Obroka"
"meal-plan-settings": "Postavke Plana Obroka",
"add-all-to-list": "Add All to List",
"add-day-to-list": "Add Day to List"
},
"migration": {
"migration-data-removed": "Podaci o migraciji su uklonjeni",
@@ -1420,7 +1422,9 @@
"is-greater-than": "is greater than",
"is-greater-than-or-equal-to": "is greater than or equal to",
"is-less-than": "is less than",
"is-less-than-or-equal-to": "is less than or equal to"
"is-less-than-or-equal-to": "is less than or equal to",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "is",
@@ -1430,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": {
@@ -1438,6 +1445,6 @@
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

@@ -369,7 +369,9 @@
"recipe-rules": "Recept szabályok",
"applies-to-all-days": "Minden napra vonatkozóan",
"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": "Összes hozzáadása a listához",
"add-day-to-list": "Nap hozzáadása a listához"
},
"migration": {
"migration-data-removed": "Migrációs adatok eltávolítva",
@@ -1420,7 +1422,9 @@
"is-greater-than": "nagyobb, mint",
"is-greater-than-or-equal-to": "nagyobb vagy egyenlő",
"is-less-than": "kevesebb, mint",
"is-less-than-or-equal-to": "kevesebb vagy egyenlő"
"is-less-than-or-equal-to": "kevesebb vagy egyenlő",
"is-older-than": "régebbi, mint",
"is-newer-than": "újabb, mint"
},
"relational-keywords": {
"is": "van",
@@ -1430,6 +1434,9 @@
"contains-all-of": "tartalmazza az összes",
"is-like": "hasonló",
"is-not-like": "nem hasonló"
},
"dates": {
"days-ago": "napja|napja|napja"
}
},
"validators": {
@@ -1438,6 +1445,6 @@
"invalid-url": "Érvényes URL-nek kell lennie",
"no-whitespace": "Szóközt nem tartalmazhat",
"min-length": "Legalább {min} karakter legyen",
"max-length": "Legfeljebb {max} karakter legyen"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

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

View File

@@ -3,7 +3,7 @@
"about": "Dettagli",
"about-mealie": "Info su Mealie",
"api-docs": "Documentazione API",
"api-port": "Porta API",
"api-port": "Importazione API",
"application-mode": "Modalità",
"database-type": "Tipo Database",
"database-url": "URL Database",
@@ -97,7 +97,7 @@
"custom": "Personalizzato",
"dashboard": "Pannello di controllo",
"delete": "Elimina",
"disabled": "Disabilitato",
"disabled": "Disabilita",
"download": "Download",
"duplicate": "Duplicato",
"edit": "Modifica",
@@ -369,7 +369,9 @@
"recipe-rules": "Regole per le ricette",
"applies-to-all-days": "Si applica a ogni giorno",
"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": "Aggiungi tutto alla lista",
"add-day-to-list": "Aggiungi Giorno"
},
"migration": {
"migration-data-removed": "Dati di migrazione rimossi",
@@ -1420,7 +1422,9 @@
"is-greater-than": "è maggiore di",
"is-greater-than-or-equal-to": "è maggiore o uguale di",
"is-less-than": "è minore di",
"is-less-than-or-equal-to": "è minore o uguale di"
"is-less-than-or-equal-to": "è minore o uguale di",
"is-older-than": "è più vecchio di",
"is-newer-than": "è più recente di"
},
"relational-keywords": {
"is": "è",
@@ -1430,6 +1434,9 @@
"contains-all-of": "contiene tutti i",
"is-like": "è simile",
"is-not-like": "non è come"
},
"dates": {
"days-ago": "giorni fa|giorno fa|giorni fa"
}
},
"validators": {
@@ -1438,6 +1445,6 @@
"invalid-url": "Lo URL Deve Essere Valido",
"no-whitespace": "Gli Spazi Non Sono Ammessi",
"min-length": "Deve Essere Almeno {min} Caratteri",
"max-length": "Deve Essere Al Massimo {max} Caratteri"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

View File

@@ -369,7 +369,9 @@
"recipe-rules": "レシピのルール",
"applies-to-all-days": "すべての日に適用",
"applies-on-days": "{0}曜日に適用",
"meal-plan-settings": "献立設定"
"meal-plan-settings": "献立設定",
"add-all-to-list": "Add All to List",
"add-day-to-list": "Add Day to List"
},
"migration": {
"migration-data-removed": "移行データが削除されました",
@@ -1420,7 +1422,9 @@
"is-greater-than": "より大きい",
"is-greater-than-or-equal-to": "以上",
"is-less-than": "より小さい",
"is-less-than-or-equal-to": "以下"
"is-less-than-or-equal-to": "以下",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
},
"relational-keywords": {
"is": "は",
@@ -1430,6 +1434,9 @@
"contains-all-of": "すべてを含む",
"is-like": "次のようなものです",
"is-not-like": "というわけではありません"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
}
},
"validators": {
@@ -1438,6 +1445,6 @@
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}

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