Compare commits

...

171 Commits

Author SHA1 Message Date
Hayden
77d01adf15 fix: add --no-project flags to prevent mealie installation 2025-12-20 20:54:39 -06:00
Hayden
b0d85bc406 ci(docs): isolate docs dependencies to avoid python-ldap build
Add dedicated docs dependency group with only mkdocs-material to avoid
installing python-ldap during docs builds. python-ldap requires OpenLDAP
dev headers (libldap2-dev) which aren't available on standard CI runners.
2025-12-20 20:31:33 -06:00
Hayden
efb9dae681 docs: add GitHub Actions workflow for docs deployment (#6752) 2025-12-20 20:26:14 -06:00
renovate[bot]
cee93d2a87 fix(deps): update dependency fastapi to v0.126.0 (#6750)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-20 16:06:03 -06:00
Arsène Reymond
0d4a8654c1 fix: PWA maskable android icons & enctype shared_target (#6731)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-19 05:06:27 +00:00
Hayden
95b1be07bb chore(l10n): New Crowdin updates (#6744)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-19 04:53:09 +00:00
mealie-commit-bot[bot]
a6fc98fc82 chore: bump version to v3.8.0 2025-12-19 01:37:16 +00:00
Michael Genson
6f03010f6c fix: Security Patches (#6743) 2025-12-18 22:54:16 +00:00
renovate[bot]
69397c91b8 fix(deps): update dependency openai to v2.13.0 (#6726)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-18 21:45:42 +00:00
Hayden
798792dcdc chore(l10n): New Crowdin updates (#6736)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-18 15:32:22 -06:00
renovate[bot]
cc32dd9fa6 chore(deps): update dependency ruff to v0.14.10 (#6742)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-18 15:32:00 -06:00
renovate[bot]
0c64eb29f9 fix(deps): update dependency fastapi to v0.125.0 (#6740)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-17 17:37:43 -06:00
renovate[bot]
8baa5cc315 chore(deps): update dependency pre-commit to v4.5.1 (#6734)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-17 00:07:49 +00:00
Hayden
6f3a5c6c8f chore(l10n): New Crowdin updates (#6733) 2025-12-16 17:42:13 +00:00
Hayden
778078590b chore(l10n): New Crowdin updates (#6729) 2025-12-15 22:54:37 -06:00
github-actions[bot]
53c82e5491 chore(auto): Update pre-commit hooks (#6724) 2025-12-15 18:16:59 +00:00
Hayden
fef114d97f chore(l10n): New Crowdin updates (#6725) 2025-12-15 08:55:48 -06:00
Hayden
e80cbfad7f chore(l10n): New Crowdin updates (#6722)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-14 23:31:48 -06:00
renovate[bot]
99527ce738 chore(deps): update dependency mypy to v1.19.1 (#6723)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-14 23:31:34 -06:00
Arsène Reymond
08ccced734 fix: localize text validators message (#6719) 2025-12-14 09:56:11 -06:00
github-actions[bot]
43c2c9552b chore(l10n): Crowdin locale sync (#6716)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-13 22:10:43 -06:00
Hayden
db5741c7ee chore(l10n): New Crowdin updates (#6710) 2025-12-13 22:10:23 -06:00
renovate[bot]
a1e394cf36 fix(deps): update dependency tzdata to v2025.3 (#6713) 2025-12-13 15:21:42 -06:00
Michael Genson
bdbef1ab9e fix: More lenient postgres override parsing (#6712) 2025-12-13 14:21:54 -06:00
Michael Genson
e5276f6c20 fix: Put tooltips behind app bar (#6711) 2025-12-13 10:56:18 -06:00
Michael Genson
20a6e71b31 feat: Optionally include URL when importing via HTML/JSON (#6709) 2025-12-12 23:20:26 -06:00
Michael Genson
24c111af7b chore: Miscellaneous cleanup (#6708) 2025-12-12 22:48:49 -06:00
davidschinkel
ab4559319e fix: Improved bulk deletion by reducing refreshs (#6634)
Co-authored-by: David Schinkel <david@zollsoft.de>
Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
2025-12-13 04:08:04 +00:00
Michael Genson
2f8625ac44 fix: Disable submit on enter when editing timeline events (#6707) 2025-12-12 21:56:36 -06:00
Hayden
dd146afa57 chore(l10n): New Crowdin updates (#6706) 2025-12-12 21:20:34 -06:00
renovate[bot]
91d15f671e fix(deps): update dependency authlib to v1.6.6 (#6700)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-13 01:54:41 +00:00
renovate[bot]
7008b13246 fix(deps): update dependency fastapi to v0.124.4 (#6702)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 19:43:35 -06:00
mealie-commit-bot[bot]
1a1798cd88 chore: bump version to v3.7.0 2025-12-13 01:21:07 +00:00
Michael Genson
64f47c1589 fix: Reprocess script UUID handling for postgres (#6705) 2025-12-12 19:18:52 -06:00
Michael Genson
326bb1eb8e feat: Reprocess image user script (#6704) 2025-12-12 18:30:49 -06:00
Hayden
80dc2ecfb7 chore(l10n): New Crowdin updates (#6701) 2025-12-12 13:53:06 +00:00
renovate[bot]
b72082663f chore(deps): update node.js to 20988bc (#6698) 2025-12-12 05:24:05 +00:00
renovate[bot]
f46ae423d3 chore(deps): update dependency ruff to v0.14.9 (#6699) 2025-12-11 23:13:36 -06:00
Hayden
05cdff8ae7 chore(l10n): New Crowdin updates (#6697) 2025-12-12 03:57:19 +01:00
Hayden
0facdf73be chore(l10n): New Crowdin updates (#6694) 2025-12-11 21:38:11 +00:00
renovate[bot]
cbad569134 fix(deps): update dependency openai to v2.11.0 (#6696)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 15:27:38 -06:00
Hayden
1063433aa9 chore(l10n): New Crowdin updates (#6693) 2025-12-10 19:47:30 -06:00
renovate[bot]
0ba22c81e7 fix(deps): update dependency fastapi to v0.124.2 (#6688) 2025-12-10 18:59:49 +00:00
renovate[bot]
0667177a2e fix(deps): update dependency recipe-scrapers to v15.11.0 (#6691) 2025-12-10 18:48:05 +00:00
Hayden
6fcf22869b chore(l10n): New Crowdin updates (#6689) 2025-12-10 12:37:02 -06:00
renovate[bot]
20b45e57e0 fix(deps): update dependency sqlalchemy to v2.0.45 (#6687)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 20:53:09 -06:00
Hayden
7a38a52158 chore(l10n): New Crowdin updates (#6686) 2025-12-09 19:45:24 -06:00
renovate[bot]
e27eca5571 chore(deps): update node.js to 9a2ed90 (#6684)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-09 12:53:03 -06:00
renovate[bot]
a90b2ccafd chore(deps): update dependency coverage to v7.13.0 (#6683)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 12:52:45 -06:00
Michael Genson
e0d8104643 feat: Suggest HTML importer on URL importer failure (#6685) 2025-12-09 11:05:34 -06:00
Hayden
53ee64828b chore(l10n): New Crowdin updates (#6678)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-09 10:27:21 -06:00
Arsène Reymond
6f7fba5ac1 feat: Add user QueryFilter and improve UI on mobile (#6235)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Michael Genson <genson.michael@gmail.com>
2025-12-09 09:49:12 -06:00
github-actions[bot]
89aed15905 chore(auto): Update pre-commit hooks (#6680)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-12-08 14:55:38 +00:00
renovate[bot]
aac48287a4 fix(deps): update dependency apprise to v1.9.6 (#6677)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 08:45:21 -06:00
Hayden
34daaa0476 chore(l10n): New Crowdin updates (#6675) 2025-12-07 09:43:47 -06:00
Henri Cook
af56a3e69d fix: improve password manager autofill compatibility on login page (#6662)
Co-authored-by: Henri Cook <henri.cook@linklaters.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-06 23:35:16 -06:00
github-actions[bot]
0908812b47 chore(l10n): Crowdin locale sync (#6672)
Co-authored-by: GitHub Action <action@github.com>
2025-12-06 22:58:22 -06:00
renovate[bot]
d910fbafe8 chore(deps): update dependency pytest to v9.0.2 (#6670) 2025-12-07 00:15:15 +00:00
renovate[bot]
c7692426d5 fix(deps): update dependency orjson to v3.11.5 (#6667) 2025-12-07 00:04:01 +00:00
renovate[bot]
b7a615add9 fix(deps): update dependency fastapi to v0.124.0 (#6664) 2025-12-06 17:52:57 -06:00
Hayden
3167e23b6b chore(l10n): New Crowdin updates (#6671) 2025-12-06 17:21:05 -06:00
Hayden
8b582f8682 feat: autofill default credentials on first login (#6666) 2025-12-06 17:47:33 +00:00
Hayden
05f648d7fb fix: clear cached store data on logout to prevent user data leakage (#6665) 2025-12-06 11:36:39 -06:00
Nathan Winspear
1f19133870 docs: add theming examples to backend configuration guide (#6443)
Co-authored-by: Michael Genson <genson.michael@gmail.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-05 19:35:33 -06:00
Hayden
98273da16e chore(l10n): New Crowdin updates (#6661)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-05 16:29:47 -06:00
miah
f857ca18da feat: Improve startup workflow UI (#6342)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-05 22:18:32 +00:00
renovate[bot]
22a0e6d608 fix(deps): update dependency fastapi to v0.123.10 (#6660)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-05 15:58:01 -06:00
Tempest
ed806b9fec feat: Improve Image Minification Logic and Efficiency (#5883)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Michael Genson <genson.michael@gmail.com>
2025-12-05 15:45:53 -06:00
Michael Genson
ae8b489f97 dev: Add copilot-instructions.md (#6659) 2025-12-05 13:00:45 -06:00
Noneangel
71732d4766 feat: frontend autocomplete is diacritics/ligatures insensitive (#6169)
Co-authored-by: Pierre <pierre@debian.zabi.ovh>
Co-authored-by: Michael Genson <genson.michael@gmail.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-05 12:44:37 -06:00
Cash Prokop-Weaver
6695314588 feat: Add snack, drink, and dessert (#6149)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Michael Genson <genson.michael@gmail.com>
2025-12-05 11:54:00 -06:00
Nico Hirsch
c115e6d83f feat: Put calendar directly in the date picker dialogs (#6110)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Michael Genson <genson.michael@gmail.com>
2025-12-05 10:05:47 -06:00
renovate[bot]
e3e970213c fix(deps): update dependency fastapi to v0.123.9 (#6657)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 23:29:52 -06:00
Hayden
7fe358e5e7 chore(l10n): New Crowdin updates (#6653) 2025-12-04 21:54:37 +00:00
renovate[bot]
c7f3334479 fix(deps): update dependency openai to v2.9.0 (#6656)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 15:43:47 -06:00
renovate[bot]
d4467f65fb fix(deps): update dependency fastapi to v0.123.8 (#6640)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 21:23:23 +00:00
renovate[bot]
27e61ec6b1 chore(deps): update dependency ruff to v0.14.8 (#6655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 15:12:20 -06:00
Hayden
6c6dc8103d chore(l10n): New Crowdin updates (#6649) 2025-12-03 10:25:28 +01:00
Hayden
35963dad2e fix: change log rotation size from 10kb to 10mb (#6648) 2025-12-02 19:06:51 -06:00
mealie-commit-bot[bot]
acd0c2cb3e chore: bump version to v3.6.1 2025-12-02 23:08:41 +00:00
Michael Genson
28d00f7dd5 fix: Bump version before building release (#6647) 2025-12-02 16:51:46 -06:00
Michael Genson
fdd3d4b37a fix: Remove Auth Refresh (#6646) 2025-12-02 15:55:01 -06:00
Hayden
b09a85dfab chore(l10n): New Crowdin updates (#6643) 2025-12-02 10:39:36 -06:00
github-actions[bot]
b6ceece901 docs(auto): Update image tag, for release v3.6.0 (#6639) 2025-12-01 23:35:45 -06:00
renovate[bot]
54b8760d15 fix(deps): update dependency fastapi to v0.123.1 (#6638)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 05:00:40 +00:00
davidschinkel
187e0300a0 fix: Enabled newlines in timeline comment (#5905) (#6620)
Co-authored-by: David Schinkel <david@zollsoft.de>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-12-01 22:48:24 -06:00
github-actions[bot]
c398316b55 chore(auto): Update pre-commit hooks (#6632)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-12-02 04:46:26 +00:00
Hayden
eb093a755b chore(l10n): New Crowdin updates (#6637) 2025-12-02 04:35:15 +00:00
Hayden
2e982fad82 chore(l10n): New Crowdin updates (#6631) 2025-11-30 20:54:05 -06:00
renovate[bot]
f5570bf9b2 fix(deps): update dependency beautifulsoup4 to v4.14.3 (#6629)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-30 10:46:27 -06:00
renovate[bot]
ddd7ee0696 fix(deps): update dependency fastapi to v0.123.0 (#6627)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-30 16:06:01 +00:00
Hayden
f1b5b999b9 chore(l10n): New Crowdin updates (#6628) 2025-11-30 15:55:01 +00:00
renovate[bot]
47892f84be chore(deps): update dependency pylint to v4.0.4 (#6626)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-30 09:44:26 -06:00
github-actions[bot]
18002351b6 chore(l10n): Crowdin locale sync (#6625)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-30 03:13:02 +00:00
Hayden
9605c448e7 chore(l10n): New Crowdin updates (#6624) 2025-11-30 02:46:27 +00:00
renovate[bot]
9499c2942c fix(deps): update dependency pydantic-settings to v2.12.0 (#6617)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 23:40:05 +00:00
renovate[bot]
f04bd7b777 fix(deps): update dependency openai to v2.8.1 (#6616)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 23:26:16 +00:00
renovate[bot]
710708ea68 fix(deps): update dependency fastapi to v0.122.0 (#6615)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 23:14:55 +00:00
Michael Genson
bb196da83b fix: Bump Pydantic to v2.12.5 (#6622) 2025-11-29 17:04:13 -06:00
renovate[bot]
d500fbf0b4 chore(deps): update dependency pre-commit to v4.5.0 (#6614)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 16:47:05 -06:00
renovate[bot]
ca94ca973c chore(deps): update dependency mypy to v1.19.0 (#6613) 2025-11-29 22:23:35 +00:00
renovate[bot]
454d1eff1c chore(deps): update dependency mkdocs-material to v9.7.0 (#6612) 2025-11-29 16:11:58 -06:00
renovate[bot]
280be88fc5 chore(deps): update dependency coverage to v7.12.0 (#6611) 2025-11-29 15:27:39 -06:00
renovate[bot]
e24c37957b fix(deps): update dependency rapidfuzz to v3.14.3 (#6610)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 20:59:37 +00:00
renovate[bot]
46b069ba71 fix(deps): update dependency alembic to v1.17.2 (#6608)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 14:48:10 -06:00
renovate[bot]
2caed5e192 chore(deps): update dependency pylint to v4.0.3 (#6605) 2025-11-29 13:16:27 -06:00
renovate[bot]
406f44e6a7 chore(deps): update dependency types-python-dateutil to v2.9.0.20251115 (#6607)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 18:07:56 +00:00
renovate[bot]
f6787f18ba chore(deps): update dependency ruff to v0.14.7 (#6606)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 17:56:01 +00:00
Michael Genson
1d64f428db chore: Fail frontend lint if there are warnings (#6619) 2025-11-29 11:33:17 -06:00
renovate[bot]
77906da9f1 fix(deps): update dependency recipe-scrapers to v15.10.0 (#6618)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 17:32:40 +00:00
renovate[bot]
35d470f5ea fix(deps): pin dependencies (#6604)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 17:04:14 +00:00
Hayden
d7cdcfa734 chore(l10n): New Crowdin updates (#6594) 2025-11-29 16:53:03 +00:00
Michael Genson
bfbdf76c2d chore: Update Renovate config to pin versions in pyproject.toml (#6603) 2025-11-29 10:42:01 -06:00
github-actions[bot]
7cc0fafbaa chore(auto): Update pre-commit hooks (#6558)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-11-29 16:23:16 +00:00
Simon Lam
5b65ceda93 fix: Asset type selector dropdown #6413; asset entry layout; asset download content disposition (#6595)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-29 10:13:26 -06:00
Michael Genson
07ecd88685 feat: Remove backend cookie and use frontend for auth (#6601) 2025-11-28 19:29:16 -06:00
gpotter@gmail.com
8f1ce1a1c3 fix: recipe recursion false positive (#6591)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Michael Genson <genson.michael@gmail.com>
2025-11-28 11:28:18 -06:00
cordlord
3146e99b03 fix: PWA follows OS screen rotation/lock settings (#6573) 2025-11-25 21:42:38 +00:00
Simon Lam
fe53cc28ba fix: Tool management bug #6447 - correct mismatch between event fired vs event handler (#6590)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-23 17:14:59 -06:00
github-actions[bot]
d85635997b chore(l10n): Crowdin locale sync (#6589)
Co-authored-by: GitHub Action <action@github.com>
2025-11-23 23:13:26 +00:00
Hayden
1ca29df52e chore(l10n): New Crowdin updates (#6565)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-11-23 02:59:59 +00:00
renovate[bot]
ee5de10ffb chore(deps): update node.js to aa648b3 (#6568)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-22 12:41:51 -06:00
Michael Genson
201ab4b8ac chore: lint (#6582) 2025-11-21 23:37:59 -06:00
miah
45af609161 dev: Allow dev server to be accessed on local network (#6581)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-21 23:19:37 -06:00
Michael Genson
c4a3068492 fix: Set maxAge on frontend auth cookie (#6576) 2025-11-20 15:18:27 -06:00
ithabi
6d4f573526 fix: Favorites page fails to load when sorted by random (#6517)
Co-authored-by: “a24ithay” <“abilan.ithayakumar@imt-atlantique.net”>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-20 15:04:49 +00:00
ithabi
3c14df453e fix : Can't edit extra long category name depending on resolution (#6536)
Co-authored-by: “a24ithay” <“abilan.ithayakumar@imt-atlantique.net”>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-20 14:54:23 +00:00
Hayden
9826f3483e chore(l10n): New Crowdin updates (#6563) 2025-11-18 09:30:53 +01:00
Hayden
caf0f5f441 chore(l10n): New Crowdin updates (#6561) 2025-11-17 14:36:42 -06:00
github-actions[bot]
b599de9c22 chore(l10n): Crowdin locale sync (#6553)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-11-17 12:15:00 +00:00
Hayden
fd7aa44c13 chore(l10n): New Crowdin updates (#6559) 2025-11-17 12:20:54 +01:00
Hayden
82b7bacdb7 chore(l10n): New Crowdin updates (#6557) 2025-11-16 20:32:32 +01:00
Hayden
84f86c2682 chore(l10n): New Crowdin updates (#6554) 2025-11-16 09:39:17 +01:00
Hayden
527edb1a92 chore(l10n): New Crowdin updates (#6552) 2025-11-15 19:22:47 +00:00
Hayden
6e11b92e74 chore(l10n): New Crowdin updates (#6548) 2025-11-15 10:12:28 +01:00
Hayden
3f5b25a30e chore(l10n): New Crowdin updates (#6547) 2025-11-14 18:41:48 +00:00
github-actions[bot]
662d06b5a8 docs(auto): Update image tag, for release v3.5.0 (#6542) 2025-11-14 18:22:32 +00:00
Hayden
9003d0f1d1 chore(l10n): New Crowdin updates (#6513)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
2025-11-14 12:12:35 -06:00
Lory
1cf7e37ada fix: prevent URL encoding in postgres placeholder display (#6438) 2025-11-14 16:17:17 +00:00
Arsène Reymond
930c92365d fix: Improve recipe ingredient selection (#6518)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-14 10:05:54 -06:00
Aurelien
6f1fee5511 fix: Make Ingredients and Instructions independently scrollable in cook mode (#6358)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-14 09:45:36 -06:00
renovate[bot]
f5de126d86 chore(deps): update node.js to 42ce5b9 (#6539)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-13 05:15:20 +00:00
renovate[bot]
725dae41b1 chore(deps): update dependency pytest to v9 (#6525)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-12 23:04:56 -06:00
github-actions[bot]
39e919526a chore(auto): Update pre-commit hooks (#6528)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-12 17:19:19 +00:00
renovate[bot]
1978ad2c96 chore(deps): update node.js to e5bbac0 (#6507)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-12 11:04:49 -06:00
github-actions[bot]
23e8dc1941 chore(l10n): Crowdin locale sync (#6524) 2025-11-08 22:15:51 -06:00
Hayden
96b408a661 chore(l10n): New Crowdin updates (#6508) 2025-11-05 09:39:12 +01:00
Hayden
20a9a94770 chore(l10n): New Crowdin updates (#6506) 2025-11-04 21:30:09 +01:00
renovate[bot]
b280e2d1a0 chore(deps): update node.js to 55b6bbe (#6503)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-04 11:09:32 -06:00
Hayden
735162d042 chore(l10n): New Crowdin updates (#6502) 2025-11-04 09:03:03 -06:00
gpotter@gmail.com
60d9294861 feat: Add recipe as ingredient (#4800)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-03 23:57:57 -06:00
Michael Genson
ff42964537 fix: Brute parser fails if unit or food is empty (#6500) 2025-11-03 23:44:13 -06:00
Christian Hollinger
bb67d993a0 feat: Add DELETE /{slug}/image (#6259)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-03 19:41:54 -06:00
Michael Genson
7bb0f0801a fix: Stabilize shopping list queuing (#6498) 2025-11-03 18:38:33 -06:00
Hayden
3a4875a54f chore(l10n): New Crowdin updates (#6495) 2025-11-03 20:53:45 +00:00
Michael Genson
0371874670 fix: Refactor Recipe Zip File Flow (#6170) 2025-11-03 14:43:22 -06:00
Tom Strange
3d177566ed fix: Include contents of purpose field when parsing ingredients (#6494) 2025-11-03 18:09:00 +00:00
github-actions[bot]
14e87918fb chore(auto): Update pre-commit hooks (#6493)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
2025-11-03 16:15:38 +00:00
Hayden
ac75b0254d chore(l10n): New Crowdin updates (#6492) 2025-11-03 09:12:57 +01:00
Michael Genson
7f2927600b chore: Update some frontend deps (#6490) 2025-11-02 22:42:19 -06:00
aliyyanWijaya
5e8c4a6cee fix: Update the random button flow (#6248)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-03 02:52:17 +00:00
Arsène Reymond
a460c32674 fix: Locale dates format (#6211)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2025-11-02 20:39:33 -06:00
Hayden
973cd5ab02 chore(l10n): New Crowdin updates (#6487) 2025-11-02 20:08:02 +01:00
Hayden
ac355c1071 chore(l10n): New Crowdin updates (#6486) 2025-11-02 01:07:56 -05:00
github-actions[bot]
3a617cd3c3 chore(l10n): Crowdin locale sync (#6485)
Co-authored-by: GitHub Action <action@github.com>
2025-11-01 23:05:26 -05:00
Hayden
3c874c2f85 chore(l10n): New Crowdin updates (#6478) 2025-11-01 20:11:17 +00:00
renovate[bot]
fb3be73163 chore(deps): update dependency types-python-slugify to v8 (#6480)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-01 20:00:36 +00:00
renovate[bot]
14b783852e fix(deps): update dependency tzdata to v2025 (#6481)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-01 19:49:51 +00:00
Michael Genson
75616d66b8 dev: Migrate to uv (#6470) 2025-11-01 14:36:40 -05:00
github-actions[bot]
01713b0416 docs(auto): Update image tag, for release v3.4.0 (#6471)
Co-authored-by: michael-genson <71845777+michael-genson@users.noreply.github.com>
2025-10-31 19:29:05 +00:00
Hayden
123a8b99f8 chore(l10n): New Crowdin updates (#6469) 2025-10-31 19:15:33 +00:00
306 changed files with 21822 additions and 20260 deletions

View File

@@ -8,28 +8,14 @@ FROM mcr.microsoft.com/devcontainers/python:${VARIANT}
ARG NODE_VERSION="none"
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
# install poetry - respects $POETRY_VERSION & $POETRY_HOME
RUN echo "export PROMPT_COMMAND='history -a'" >> /home/vscode/.bashrc \
&& echo "export HISTFILE=~/commandhistory/.bash_history" >> /home/vscode/.bashrc \
&& chown vscode:vscode -R /home/vscode/
RUN npm install -g @go-task/cli
RUN npm install -g json-schema-to-typescript
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=off \
PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=100 \
POETRY_HOME="/opt/poetry" \
POETRY_VIRTUALENVS_IN_PROJECT=true
# prepend poetry and venv to path
ENV PATH="$POETRY_HOME/bin:$PATH"
RUN curl -sSL https://install.python-poetry.org | python3 -
# RUN poetry config virtualenvs.create false
# Install additional OS packages
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
curl \
@@ -39,5 +25,9 @@ RUN apt-get update \
libsasl2-dev libldap2-dev libssl-dev \
gnupg gnupg2 gnupg1
# create directory used for Docker Secrets
# Install uv
RUN pip install uv
ENV UV_LINK_MODE=copy
# Create directory for Docker Secrets
RUN mkdir -p /run/secrets

View File

@@ -23,7 +23,6 @@
"settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python",
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
"python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf",
"mypy.runUsingActiveInterpreter": true
},
@@ -31,10 +30,10 @@
"charliermarsh.ruff",
"dbaeumer.vscode-eslint",
"matangover.mypy",
"ms-python.black-formatter",
"ms-python.pylint",
"ms-python.python",
"ms-python.vscode-pylance",
"streetsidesoftware.code-spell-checker-cspell-bundled-dictionaries",
"Vue.volar"
]
}
@@ -42,13 +41,14 @@
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
3000,
8000, // used by mkdocs
9000,
9091, // used by docker production
24678 // used by nuxt when hot-reloading using polling
],
// Use 'onCreateCommand' to run commands at the end of container creation.
// Use 'postCreateCommand' to run commands after the container is created.
"onCreateCommand": "sudo chown -R vscode:vscode /workspaces/mealie/frontend/node_modules /home/vscode/commandhistory && task setup",
"onCreateCommand": "sudo chown -R vscode:vscode /workspaces/mealie/frontend/node_modules /home/vscode/commandhistory && task setup --force",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode",
"features": {
@@ -56,5 +56,8 @@
"dockerDashComposeVersion": "v2"
}
},
"appPort": 3000
"appPort": [
"3000:3000",
"9000:9000"
]
}

240
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,240 @@
# Mealie Development Guide for AI Agents
## Project Overview
Mealie is a self-hosted recipe manager, meal planner, and shopping list application with a FastAPI backend (Python 3.12) and Nuxt 3 frontend (Vue 3 + TypeScript). It uses SQLAlchemy ORM with support for SQLite and PostgreSQL databases.
**Development vs Production:**
- **Development:** Frontend (port 3000) and backend (port 9000) run as separate processes
- **Production:** Frontend is statically generated and served via FastAPI's SPA module (`mealie/routes/spa/`) in a single container
## Architecture & Key Patterns
### Backend Architecture (mealie/)
**Repository-Service-Controller Pattern:**
- **Controllers** (`mealie/routes/**/controller_*.py`): Inherit from `BaseUserController` or `BaseAdminController`, handle HTTP concerns, delegate to services
- **Services** (`mealie/services/`): Business logic layer, inherit from `BaseService`, coordinate repos and external dependencies
- **Repositories** (`mealie/repos/`): Data access layer using SQLAlchemy, accessed via `AllRepositories` factory
- Get repos via dependency injection: `repos: AllRepositories = Depends(get_repositories)`
- All repos scoped to group/household context automatically
**Route Organization:**
- Routes in `mealie/routes/` organized by domain (auth, recipe, groups, households, admin)
- Use `APIRouter` with FastAPI dependency injection
- Apply `@router.get/post/put/delete` decorators with Pydantic response models
- Route controllers use `HttpRepo` mixin for common CRUD operations (see `mealie/routes/_base/mixins.py`)
**Schemas & Type Generation:**
- Pydantic schemas in `mealie/schema/` with strict separation: `*In`, `*Out`, `*Create`, `*Update` suffixes
- Auto-exported from submodules via `__init__.py` files (generated by `task dev:generate`)
- TypeScript types auto-generated from Pydantic schemas - **never manually edit** `frontend/lib/api/types/`
**Database & Sessions:**
- Session management via `Depends(generate_session)` in FastAPI routes
- Use `session_context()` context manager in services/scripts
- SQLAlchemy models in `mealie/db/models/`, migrations in `mealie/alembic/`
- Create migrations: `task py:migrate -- "description"`
### Frontend Architecture (frontend/)
**Component Organization (strict naming conventions):**
- **Domain Components** (`components/Domain/`): Feature-specific, prefix with domain (e.g., `AdminDashboard`)
- **Global Components** (`components/global/`): Reusable primitives, prefix with `Base` (e.g., `BaseButton`)
- **Layout Components** (`components/Layout/`): Layout-only, prefix with `App` if props or `The` if singleton
- **Page Components** (`components/` with page prefix): Last resort for breaking up complex pages
**API Client Pattern:**
- API clients in `frontend/lib/api/` extend `BaseAPI`, `BaseCRUDAPI`, or `BaseCRUDAPIReadOnly`
- Types imported from auto-generated `frontend/lib/api/types/` (DO NOT EDIT MANUALLY)
- Composables in `frontend/composables/` for shared state and API logic (e.g., `use-mealie-auth.ts`)
- Use `useAuthBackend()` for authentication state, `useMealieAuth()` for user management
**State Management:**
- Nuxt 3 composables for state (no Vuex)
- Auth state via `use-mealie-auth.ts` composable
- Prefer composables over global state stores
## Essential Commands (via Task/Taskfile.yml)
**Development workflow:**
```bash
task setup # Install all dependencies (Python + Node)
task dev:services # Start Postgres & Mailpit containers
task py # Start FastAPI backend (port 9000)
task ui # Start Nuxt frontend (port 3000)
task docs # Start MkDocs documentation server
```
**Code generation (REQUIRED after schema changes):**
```bash
task dev:generate # Generate TypeScript types, schema exports, test helpers
```
**Testing & Quality:**
```bash
task py:test # Run pytest (supports args: task py:test -- -k test_name)
task py:check # Format + lint + type-check + test (full validation)
task py:format # Ruff format
task py:lint # Ruff check
task py:mypy # Type checking
task ui:test # Vitest frontend tests
task ui:check # Frontend lint + test
```
**Database:**
```bash
task py:migrate -- "description" # Generate Alembic migration
task py:postgres # Run backend with PostgreSQL config
```
**Docker:**
```bash
task docker:prod # Build and run production Docker compose
```
## Critical Development Practices
### Python Backend
1. **Always use `uv` for Python commands** (not `python` or `pip`):
```bash
uv run python mealie/app.py
uv run pytest tests/
```
2. **Type hints are mandatory:** Use mypy-compatible annotations, handle Optional types explicitly
3. **Dependency injection pattern:**
```python
from fastapi import Depends
from mealie.repos.all_repositories import get_repositories, AllRepositories
def my_route(
repos: AllRepositories = Depends(get_repositories),
user: PrivateUser = Depends(get_current_user)
):
recipe = repos.recipes.get_one(recipe_id)
```
4. **Settings & Configuration:**
- Get settings: `settings = get_app_settings()` (cached singleton)
- Get directories: `dirs = get_app_dirs()`
- Never instantiate `AppSettings()` directly
5. **Testing:**
- Fixtures in `tests/fixtures/`
- Use `api_client` fixture for integration tests
- Follow existing patterns in `tests/integration_tests/` and `tests/unit_tests/`
### Frontend
1. **Run code generation after backend schema changes:** `task dev:generate`
2. **TypeScript strict mode:** All code must pass type checking
3. **Component naming:** Follow strict conventions (see Architecture section above)
4. **API calls pattern:**
```typescript
const api = useUserApi();
const recipe = await api.recipes.getOne(recipeId);
```
5. **Composables for shared logic:** Prefer composables in `composables/` over inline code duplication
6. **Translations:** Only modify `en-US` locale files when adding new translation strings - other locales are managed via Crowdin and **must never be modified** (PRs modifying non-English locales will be rejected)
### Cross-Cutting Concerns
1. **Code generation is source of truth:** After Pydantic schema changes, run `task dev:generate` to update:
- TypeScript types (`frontend/lib/api/types/`)
- Schema exports (`mealie/schema/*/__init__.py`)
- Test data paths and routes
2. **Multi-tenancy:** All data scoped to **groups** and **households**:
- Groups contain multiple households
- Households contain recipes, meal plans, shopping lists
- Repositories automatically filter by group/household context
3. **Pre-commit hooks:** Install via `task setup:py`, enforces Ruff formatting/linting
4. **Testing before PRs:** Run `task py:check` and `task ui:check` before submitting PRs
## Pull Request Best Practices
### Before Submitting a PR
1. **Draft PRs are optional:** Create a draft PR early if you want feedback while working, or open directly as ready when complete
2. **Verify code generation:** If you modified Pydantic schemas, ensure `task dev:generate` was run
3. **Follow Conventional Commits:** Title your PR according to the conventional commits format (see PR template)
4. **Add release notes:** Include user-facing changes in the PR description
### What to Review
**Architecture & Patterns:**
- Does the code follow the repository-service-controller pattern?
- Are controllers delegating business logic to services?
- Are services coordinating repositories, not accessing the database directly?
- Is dependency injection used properly (`Depends(get_repositories)`, `Depends(get_current_user)`)?
**Data Scoping:**
- Are repositories correctly scoped to group/household context?
- Do route handlers properly validate group/household ownership before operations?
- Are multi-tenant boundaries enforced (users can't access other groups' data)?
**Type Safety:**
- Are type hints present on all functions and methods?
- Are Pydantic schemas using correct suffixes (`*In`, `*Out`, `*Create`, `*Update`)?
- For frontend, does TypeScript code pass strict type checking?
**Generated Files:**
- Verify `frontend/lib/api/types/` files weren't manually edited (they're auto-generated)
- Check that `mealie/schema/*/__init__.py` exports match actual schema files (auto-generated)
- If schemas changed, confirm generated files were updated via `task dev:generate`
**Code Quality:**
- Is the code readable and well-organized?
- Are complex operations documented with clear comments?
- Do component names follow the strict naming conventions (Domain/Global/Layout/Page prefixes)?
- Are composables used for shared frontend logic instead of duplication?
**Translations:**
- Were only `en-US` locale files modified for new translation strings?
- Verify no other locale files (managed by Crowdin) were touched
**Database Changes:**
- Are Alembic migrations included for schema changes?
- Are migrations tested against both SQLite and PostgreSQL?
### Review Etiquette
- Be constructive and specific in feedback
- Suggest code examples when proposing changes
- Focus on architecture and logic - formatting/linting is handled by CI
- Use "Approve" when ready to merge, "Request Changes" for blocking issues, "Comment" for non-blocking suggestions
## Common Gotchas
- **Don't manually edit generated files:** `frontend/lib/api/types/`, schema `__init__.py` files
- **Repository context:** Repos are group/household-scoped - passing wrong IDs causes 404s
- **Session handling:** Don't create sessions manually, use dependency injection or `session_context()`
- **Schema changes require codegen:** After changing Pydantic models, run `task dev:generate`
- **Translation files:** Only modify `en-US` locale files - all other locales are managed by Crowdin
- **Dev containers:** This project uses VS Code dev containers - leverage the pre-configured environment
- **Task commands:** Use `task` commands instead of direct tool invocation for consistency
## Key Files to Reference
- `Taskfile.yml` - All development commands and workflows
- `mealie/routes/_base/base_controllers.py` - Controller base classes and patterns
- `mealie/repos/repository_factory.py` - Repository factory and available repos
- `frontend/lib/api/base/base-clients.ts` - API client base classes
- `tests/conftest.py` - Test fixtures and setup
- `dev/code-generation/main.py` - Code generation entry point
## Additional Resources
- [Documentation](https://docs.mealie.io/)
- [Contributors Guide](https://nightly.mealie.io/contributors/developers-guide/code-contributions/)
- [Discord](https://discord.gg/QuStdQGSGK)

View File

@@ -70,13 +70,8 @@ jobs:
with:
python-version: "3.12"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
plugins: |
poetry-plugin-export
- name: Install uv
run: pip install uv
- name: Retrieve built frontend
uses: actions/download-artifact@v4

50
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Deploy Documentation
on:
push:
branches: [mealie-next]
paths:
- 'docs/**'
- '.github/workflows/docs.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --only-group docs --no-install-project
- name: Build docs
run: uv run --no-project mkdocs build -d site
working-directory: docs
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/site
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -25,24 +25,21 @@ jobs:
with:
python-version: "3.12"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
- name: Install uv
run: pip install uv
- name: Load cached venv
id: cached-poetry-dependencies
id: cached-python-dependencies
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
key: venv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
- name: Check venv cache
id: cache-validate
if: steps.cached-poetry-dependencies.outputs.cache-hit == 'true'
if: steps.cached-python-dependencies.outputs.cache-hit == 'true'
run: |
echo "import fastapi;print('venv good?')" > test.py && poetry run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
echo "import fastapi;print('venv good?')" > test.py && uv run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
rm test.py
continue-on-error: true
@@ -50,13 +47,13 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
poetry install
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
uv sync --group dev
if: steps.cached-python-dependencies.outputs.cache-hit != 'true'
- name: Run locale generation
run: |
cd dev/code-generation
poetry run python main.py locales
uv run python main.py locales
env:
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}

View File

@@ -5,17 +5,73 @@ on:
types: [published]
jobs:
commit-version-bump:
name: Commit version bump to repository
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
commit-sha: ${{ steps.commit.outputs.commit-sha }}
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 🛎
uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
- name: Extract Version From Tag Name
run: echo "VERSION_NUM=$(echo ${{ github.event.release.tag_name }} | sed 's/^v//')" >> $GITHUB_ENV
- name: Configure Git
run: |
git config user.name "mealie-commit-bot[bot]"
git config user.email "mealie-commit-bot[bot]@users.noreply.github.com"
- name: Update all version strings
run: |
sed -i 's/^version = "[^"]*"/version = "${{ env.VERSION_NUM }}"/' pyproject.toml
sed -i '/^name = "mealie"$/,/^version = / s/^version = "[^"]*"/version = "${{ env.VERSION_NUM }}"/' uv.lock
sed -i 's/\("version": "\)[^"]*"/\1${{ env.VERSION_NUM }}"/' frontend/package.json
sed -i 's/:v[0-9]*\.[0-9]*\.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/installation-checklist.md
sed -i 's/:v[0-9]*\.[0-9]*\.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/sqlite.md
sed -i 's/:v[0-9]*\.[0-9]*\.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/postgres.md
- name: Commit and push changes
id: commit
run: |
git add pyproject.toml frontend/package.json uv.lock docs/
git commit -m "chore: bump version to ${{ github.event.release.tag_name }}"
git push origin HEAD:${{ github.event.repository.default_branch }}
echo "commit-sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Move release tag to new commit
run: |
git tag -f ${{ github.event.release.tag_name }}
git push -f origin ${{ github.event.release.tag_name }}
backend-tests:
name: "Backend Server Tests"
uses: ./.github/workflows/test-backend.yml
needs:
- commit-version-bump
frontend-tests:
name: "Frontend Tests"
uses: ./.github/workflows/test-frontend.yml
needs:
- commit-version-bump
build-package:
name: Build Package
uses: ./.github/workflows/build-package.yml
needs:
- commit-version-bump
with:
tag: ${{ github.event.release.tag_name }}
@@ -43,10 +99,48 @@ jobs:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
rollback-on-failure:
name: Rollback version commit if deployment fails
needs:
- commit-version-bump
- publish
if: always() && needs.publish.result == 'failure'
runs-on: ubuntu-latest
permissions:
contents: write
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 🛎
uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "mealie-commit-bot[bot]"
git config user.email "mealie-commit-bot[bot]@users.noreply.github.com"
- name: Delete release tag
run: |
git push --delete origin ${{ github.event.release.tag_name }}
- name: Revert version bump commit
run: |
git revert --no-edit ${{ needs.commit-version-bump.outputs.commit-sha }}
git push origin HEAD:${{ github.event.repository.default_branch }}
notify-discord:
name: Notify Discord
needs:
- publish
if: success()
runs-on: ubuntu-latest
steps:
- name: Discord notification
@@ -55,41 +149,3 @@ jobs:
uses: Ilshidur/action-discord@0.3.2
with:
args: "🚀 Version {{ EVENT_PAYLOAD.release.tag_name }} of Mealie has been released. See the release notes https://github.com/mealie-recipes/mealie/releases/tag/{{ EVENT_PAYLOAD.release.tag_name }}"
update-image-tags:
name: Update image tag in sample docker-compose files
needs:
- publish
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout 🛎
uses: actions/checkout@v4
- name: Extract Version From Tag Name
run: echo "VERSION_NUM=$(echo ${{ github.event.release.tag_name }} | sed 's/^v//')" >> $GITHUB_ENV
- name: Modify version strings
run: |
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/installation-checklist.md
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/sqlite.md
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/postgres.md
sed -i 's/^version = "[^"]*"/version = "${{ env.VERSION_NUM }}"/' pyproject.toml
sed -i 's/\("version": "\)[^"]*"/\1${{ env.VERSION_NUM }}"/' frontend/package.json
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
# This doesn't currently work for us because it creates the PR but the workflows don't run.
# TODO: Provide a personal access token as a parameter here, that solves that problem.
# https://github.com/peter-evans/create-pull-request
with:
commit-message: "Update image tag, for release ${{ github.event.release.tag_name }}"
branch: "docs/newrelease-update-version-${{ github.event.release.tag_name }}"
labels: |
documentation
delete-branch: true
base: mealie-next
title: "docs(auto): Update image tag, for release ${{ github.event.release.tag_name }}"
body: "Auto-generated by `.github/workflows/release.yml`, on publish of release ${{ github.event.release.tag_name }}"

View File

@@ -49,24 +49,21 @@ jobs:
with:
python-version: "3.12"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
- name: Install uv
run: pip install uv
- name: Load cached venv
id: cached-poetry-dependencies
id: cached-python-dependencies
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
key: venv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
- name: Check venv cache
id: cache-validate
if: steps.cached-poetry-dependencies.outputs.cache-hit == 'true'
if: steps.cached-python-dependencies.outputs.cache-hit == 'true'
run: |
echo "import fastapi;print('venv good?')" > test.py && poetry run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
echo "import fastapi;print('venv good?')" > test.py && uv run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
rm test.py
continue-on-error: true
@@ -74,13 +71,12 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
poetry install
poetry add "psycopg2-binary==2.9.9"
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' || steps.cache-validate.outputs.cache-hit-success != 'true'
uv sync --group dev --extra pgsql
if: steps.cached-python-dependencies.outputs.cache-hit != 'true' || steps.cache-validate.outputs.cache-hit-success != 'true'
- name: Formatting (Ruff)
run: |
poetry run ruff format . --check
uv run ruff format . --check
- name: Lint (Ruff)
run: |

View File

@@ -39,7 +39,7 @@ jobs:
working-directory: "frontend"
- name: Run linter 👀
run: yarn lint
run: yarn lint --max-warnings=0
working-directory: "frontend"
- name: Run tests 🧪

1
.gitignore vendored
View File

@@ -20,6 +20,7 @@ dev/data/backups/*
dev/data/debug/*
dev/data/img/*
dev/data/migration/*
dev/data/templates/*
dev/data/users/*
dev/data/groups/*

View File

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

View File

@@ -55,7 +55,7 @@
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"package.json": "package-lock.json, yarn.lock, .eslintrc.js, tsconfig.json, .prettierrc, .editorconfig",
"pyproject.toml": "poetry.lock, alembic.ini, .pylintrc",
"pyproject.toml": "uv.lock, alembic.ini, .pylintrc",
"netlify.toml": "runtime.txt",
"README.md": "LICENSE, SECURITY.md"
},

View File

@@ -28,7 +28,7 @@ tasks:
docs:gen:
desc: runs the API documentation generator
cmds:
- poetry run python dev/code-generation/gen_docs_api.py
- uv run python dev/code-generation/gen_docs_api.py
docs:
desc: runs the documentation server
@@ -36,7 +36,7 @@ tasks:
deps:
- docs:gen
cmds:
- poetry run python -m mkdocs serve
- uv run python -m mkdocs serve
setup:ui:
desc: setup frontend dependencies
@@ -54,10 +54,10 @@ tasks:
desc: setup python dependencies
run: once
cmds:
- poetry install --with main,dev,postgres
- poetry run pre-commit install
- uv sync --extra pgsql --group dev
- uv run pre-commit install
sources:
- poetry.lock
- uv.lock
- pyproject.toml
- .pre-commit-config.yaml
@@ -70,7 +70,7 @@ tasks:
dev:generate:
desc: run code generators
cmds:
- poetry run python dev/code-generation/main.py {{ .CLI_ARGS }}
- uv run python dev/code-generation/main.py {{ .CLI_ARGS }}
- task: docs:gen
- task: py:format
@@ -96,22 +96,22 @@ tasks:
py:mypy:
desc: runs python type checking
cmds:
- poetry run mypy mealie
- uv run mypy mealie
py:test:
desc: runs python tests (support args after '--')
cmds:
- poetry run pytest {{ .CLI_ARGS }}
- uv run pytest {{ .CLI_ARGS }}
py:format:
desc: runs python code formatter
cmds:
- poetry run ruff format .
- uv run ruff format .
py:lint:
desc: runs python linter
cmds:
- poetry run ruff check mealie
- uv run ruff check mealie
py:check:
desc: runs all linters, type checkers, and formatters
@@ -124,10 +124,10 @@ tasks:
py:coverage:
desc: runs python coverage and generates html report
cmds:
- poetry run pytest
- poetry run coverage report -m
- poetry run coveragepy-lcov
- poetry run coverage html
- uv run pytest
- uv run coverage report -m
- uv run coveragepy-lcov
- uv run coverage html
- open htmlcov/index.html
py:package:copy-frontend:
@@ -147,17 +147,17 @@ tasks:
desc: Generate requirements file to pin all packages, effectively a "pip freeze" before installation begins
internal: true
cmds:
- poetry export -n --only=main --extras=pgsql --output=dist/requirements.txt
- uv export --no-editable --no-emit-project --extra pgsql --format requirements-txt --output-file dist/requirements.txt
# Include mealie in the requirements, hashing the package that was just built to ensure it's the one installed
- echo "mealie[pgsql]=={{.MEALIE_VERSION}} \\" >> dist/requirements.txt
- poetry run pip hash dist/mealie-{{.MEALIE_VERSION}}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt
- pip hash dist/mealie-{{.MEALIE_VERSION}}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt
- echo " \\" >> dist/requirements.txt
- poetry run pip hash dist/mealie-{{.MEALIE_VERSION}}.tar.gz | tail -n1 >> dist/requirements.txt
- pip hash dist/mealie-{{.MEALIE_VERSION}}.tar.gz | tail -n1 >> dist/requirements.txt
vars:
MEALIE_VERSION:
sh: poetry version --short
sh: python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])"
sources:
- poetry.lock
- uv.lock
- pyproject.toml
- dist/mealie-*.whl
- dist/mealie-*.tar.gz
@@ -184,13 +184,13 @@ tasks:
deps:
- py:package:deps
cmds:
- poetry build -n --output=dist
- uv build --out-dir dist
- task: py:package:generate-requirements
py:
desc: runs the backend server
cmds:
- poetry run python mealie/app.py
- uv run python mealie/app.py
py:postgres:
desc: runs the backend server configured for containerized postgres
@@ -202,12 +202,12 @@ tasks:
POSTGRES_PORT: 5432
POSTGRES_DB: mealie
cmds:
- poetry run python mealie/app.py
- uv run python mealie/app.py
py:migrate:
desc: generates a new database migration file e.g. task py:migrate -- "add new column"
cmds:
- poetry run alembic --config mealie/alembic/alembic.ini revision --autogenerate -m "{{ .CLI_ARGS }}"
- uv run alembic --config mealie/alembic/alembic.ini revision --autogenerate -m "{{ .CLI_ARGS }}"
- task: py:format
ui:build:
@@ -228,7 +228,7 @@ tasks:
desc: runs the frontend linter
dir: frontend
cmds:
- yarn lint
- yarn lint --max-warnings=0
ui:test:
desc: runs the frontend tests

View File

@@ -113,8 +113,8 @@ def main():
{"children": all_children},
)
subprocess.run(["poetry", "run", "ruff", "check", str(out_path), "--fix"])
subprocess.run(["poetry", "run", "ruff", "format", str(out_path)])
subprocess.run(["uv", "run", "ruff", "check", str(out_path), "--fix"])
subprocess.run(["uv", "run", "ruff", "format", str(out_path)])
if __name__ == "__main__":

View File

@@ -100,8 +100,8 @@ def main() -> None:
render_python_template(template, template_path, {"module": module})
path_args = (str(p) for p in template_paths)
subprocess.run(["poetry", "run", "ruff", "check", *path_args, "--fix"])
subprocess.run(["poetry", "run", "ruff", "format", *path_args])
subprocess.run(["uv", "run", "ruff", "check", *path_args, "--fix"])
subprocess.run(["uv", "run", "ruff", "format", *path_args])
if __name__ == "__main__":

View File

@@ -1,7 +1,7 @@
###############################################
# Frontend Build
###############################################
FROM node:24@sha256:34af25027ee1b8bffd482ba995ec1e577fbd398db87beb4c60b80c2c9c025127 \
FROM node:24@sha256:20988bcdc6dc76690023eb2505dd273bdeefddcd0bde4bfd1efe4ebf8707f747 \
AS frontend-builder
WORKDIR /frontend
@@ -50,40 +50,29 @@ RUN apt-get update \
curl \
&& rm -rf /var/lib/apt/lists/*
ENV POETRY_HOME="/opt/poetry" \
POETRY_NO_INTERACTION=1
# prepend poetry to path
ENV PATH="$POETRY_HOME/bin:$PATH"
# install poetry - respects $POETRY_VERSION & $POETRY_HOME
ENV POETRY_VERSION=2.0.1
RUN curl -sSL https://install.python-poetry.org | python3 -
# install poetry plugins needed to build the package
RUN poetry self add "poetry-plugin-export>=1.9"
RUN pip install uv
WORKDIR /mealie
# copy project files here to ensure they will be cached.
COPY poetry.lock pyproject.toml ./
COPY uv.lock pyproject.toml ./
COPY mealie ./mealie
# Copy frontend to package it into the wheel
COPY --from=frontend-builder /frontend/dist ./mealie/frontend
# Build the source and binary package
RUN poetry build --output=dist
RUN uv build --out-dir dist
# Create the requirements file, which is used to install the built package and
# its pinned dependencies later. mealie is included to ensure the built one is
# what's installed.
RUN export MEALIE_VERSION=$(poetry version --short) \
&& poetry export --only=main --extras=pgsql --output=dist/requirements.txt \
&& echo "mealie[pgsql]==$MEALIE_VERSION \\" >> dist/requirements.txt \
&& poetry run pip hash dist/mealie-$MEALIE_VERSION-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt \
RUN uv export --no-editable --no-emit-project --extra pgsql --format requirements-txt --output-file dist/requirements.txt \
&& MEALIE_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") \
&& echo "mealie[pgsql]==${MEALIE_VERSION} \\" >> dist/requirements.txt \
&& pip hash dist/mealie-${MEALIE_VERSION}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt \
&& echo " \\" >> dist/requirements.txt \
&& poetry run pip hash dist/mealie-$MEALIE_VERSION.tar.gz | tail -n1 >> dist/requirements.txt
&& pip hash dist/mealie-${MEALIE_VERSION}.tar.gz | tail -n1 >> dist/requirements.txt
###############################################
# Package Container

View File

@@ -12,13 +12,13 @@ yarnpkg generate
popd
rm -r mealie/frontend
cp -a frontend/dist mealie/frontend
poetry build
poetry export -n --only=main --extras=pgsql --output=dist/requirements.txt
MEALIE_VERSION=$(poetry version --short)
uv build --out-dir dist
uv export --no-editable --no-emit-project --extra pgsql --format requirements-txt --output-file dist/requirements.txt
MEALIE_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
echo "mealie[pgsql]==${MEALIE_VERSION} \\" >> dist/requirements.txt
poetry run pip hash dist/mealie-${MEALIE_VERSION}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt
pip hash dist/mealie-${MEALIE_VERSION}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt
echo " \\" >> dist/requirements.txt
poetry run pip hash dist/mealie-${MEALIE_VERSION}.tar.gz | tail -n1 >> dist/requirements.txt
pip hash dist/mealie-${MEALIE_VERSION}.tar.gz | tail -n1 >> dist/requirements.txt
```
The Python package can be installed with all of its dependencies pinned to the versions tested by the developers with:

View File

@@ -33,7 +33,7 @@ Make sure the VSCode Dev Containers extension is installed, then select "Dev Con
### Prerequisites
- [Python 3.12](https://www.python.org/downloads/)
- [Poetry](https://python-poetry.org/docs/#installation)
- [uv](https://docs.astral.sh/uv/)
- [Node](https://nodejs.org/en/)
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable)
- [task](https://taskfile.dev/#/installation)

View File

@@ -4,22 +4,22 @@
### General
| Variables | Default | Description |
| ----------------------------- | :-------------------: | -------------------------------------------------------------------------------------------------- |
| PUID | 911 | UserID permissions between host OS and container |
| PGID | 911 | GroupID permissions between host OS and container |
| DEFAULT_GROUP | Home | The default group for users |
| DEFAULT_HOUSEHOLD | Family | The default household for users in each group |
| BASE_URL | http://localhost:8080 | Used for Notifications |
| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid. Must be <= 87600 (10 years, in hours). |
| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
| API_DOCS | True | Turns on/off access to the API documentation locally |
| TZ | UTC | Must be set to get correct date/time on the server |
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
| ALLOW_PASSWORD_LOGIN | true | Whether or not to display the username+password input fields. Keep set to true unless you use OIDC authentication |
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) |
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
| Variables | Default | Description |
| ----------------------------- | :-------------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| PUID | 911 | UserID permissions between host OS and container |
| PGID | 911 | GroupID permissions between host OS and container |
| DEFAULT_GROUP | Home | The default group for users |
| DEFAULT_HOUSEHOLD | Family | The default household for users in each group |
| BASE_URL | http://localhost:8080 | Used for Notifications |
| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid. Must be <= 9600 (400 days, in hours). |
| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
| API_DOCS | True | Turns on/off access to the API documentation locally |
| TZ | UTC | Must be set to get correct date/time on the server |
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
| ALLOW_PASSWORD_LOGIN | true | Whether or not to display the username+password input fields. Keep set to true unless you use OIDC authentication |
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) |
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
<super>\*</super> Starting in v1.4.0 this was changed to default to `false` as part of a security review of the application.
@@ -145,22 +145,95 @@ Setting the following environmental variables will change the theme of the front
If using YAML sequence syntax, don't include any quotes:<br>`THEME_LIGHT_PRIMARY=#E58325` or `THEME_LIGHT_PRIMARY=E58325`
| Variables | Default | Description |
| --------------------- | :-----: | --------------------------- |
| THEME_LIGHT_PRIMARY | #E58325 | Light Theme Config Variable |
| THEME_LIGHT_ACCENT | #007A99 | Light Theme Config Variable |
| THEME_LIGHT_SECONDARY | #973542 | Light Theme Config Variable |
| THEME_LIGHT_SUCCESS | #43A047 | Light Theme Config Variable |
| THEME_LIGHT_INFO | #1976D2 | Light Theme Config Variable |
| THEME_LIGHT_WARNING | #FF6D00 | Light Theme Config Variable |
| THEME_LIGHT_ERROR | #EF5350 | Light Theme Config Variable |
| THEME_DARK_PRIMARY | #E58325 | Dark Theme Config Variable |
| THEME_DARK_ACCENT | #007A99 | Dark Theme Config Variable |
| THEME_DARK_SECONDARY | #973542 | Dark Theme Config Variable |
| THEME_DARK_SUCCESS | #43A047 | Dark Theme Config Variable |
| THEME_DARK_INFO | #1976D2 | Dark Theme Config Variable |
| THEME_DARK_WARNING | #FF6D00 | Dark Theme Config Variable |
| THEME_DARK_ERROR | #EF5350 | Dark Theme Config Variable |
| Variables | Default | Description |
| --------------------- | :-----: | ---------------------------------- |
| THEME_LIGHT_PRIMARY | #E58325 | Main brand color and headers |
| THEME_LIGHT_ACCENT | #007A99 | Buttons and interactive elements |
| THEME_LIGHT_SECONDARY | #973542 | Navigation and sidebar backgrounds |
| THEME_LIGHT_SUCCESS | #43A047 | Success messages and confirmations |
| THEME_LIGHT_INFO | #1976D2 | Information alerts and tooltips |
| THEME_LIGHT_WARNING | #FF6D00 | Warning notifications |
| THEME_LIGHT_ERROR | #EF5350 | Error messages and alerts |
| THEME_DARK_PRIMARY | #E58325 | Main brand color and headers |
| THEME_DARK_ACCENT | #007A99 | Buttons and interactive elements |
| THEME_DARK_SECONDARY | #973542 | Navigation and sidebar backgrounds |
| THEME_DARK_SUCCESS | #43A047 | Success messages and confirmations |
| THEME_DARK_INFO | #1976D2 | Information alerts and tooltips |
| THEME_DARK_WARNING | #FF6D00 | Warning notifications |
| THEME_DARK_ERROR | #EF5350 | Error messages and alerts |
#### Theming Examples
The examples below provide copy-ready Docker Compose environment configurations for three different color palettes. Copy and paste the desired theme into your `docker-compose.yml` file's environment section.
!!! info
These themes are functional and ready to use, but they are provided primarily as examples. The color palettes can be adjusted or refined to better suit your preferences.
=== "Blue Theme"
```yaml
environment:
# Light mode colors
THEME_LIGHT_PRIMARY: '#5E9BD1'
THEME_LIGHT_ACCENT: '#A3C9E8'
THEME_LIGHT_SECONDARY: '#4F89BA'
THEME_LIGHT_SUCCESS: '#4CAF50'
THEME_LIGHT_INFO: '#4A9ED8'
THEME_LIGHT_WARNING: '#EAC46B'
THEME_LIGHT_ERROR: '#E57373'
# Dark mode colors
THEME_DARK_PRIMARY: '#5A8FBF'
THEME_DARK_ACCENT: '#90B8D9'
THEME_DARK_SECONDARY: '#406D96'
THEME_DARK_SUCCESS: '#81C784'
THEME_DARK_INFO: '#78B2C0'
THEME_DARK_WARNING: '#EBC86E'
THEME_DARK_ERROR: '#E57373'
```
=== "Green Theme"
```yaml
environment:
# Light mode colors
THEME_LIGHT_PRIMARY: '#75A86C'
THEME_LIGHT_ACCENT: '#A8D0A6'
THEME_LIGHT_SECONDARY: '#638E5E'
THEME_LIGHT_SUCCESS: '#4CAF50'
THEME_LIGHT_INFO: '#4A9ED8'
THEME_LIGHT_WARNING: '#EAC46B'
THEME_LIGHT_ERROR: '#E57373'
# Dark mode colors
THEME_DARK_PRIMARY: '#739B7A'
THEME_DARK_ACCENT: '#9FBE9D'
THEME_DARK_SECONDARY: '#56775E'
THEME_DARK_SUCCESS: '#81C784'
THEME_DARK_INFO: '#78B2C0'
THEME_DARK_WARNING: '#EBC86E'
THEME_DARK_ERROR: '#E57373'
```
=== "Pink Theme"
```yaml
environment:
# Light mode colors
THEME_LIGHT_PRIMARY: '#D97C96'
THEME_LIGHT_ACCENT: '#E891A7'
THEME_LIGHT_SECONDARY: '#C86C88'
THEME_LIGHT_SUCCESS: '#4CAF50'
THEME_LIGHT_INFO: '#2196F3'
THEME_LIGHT_WARNING: '#FFC107'
THEME_LIGHT_ERROR: '#E57373'
# Dark mode colors
THEME_DARK_PRIMARY: '#C2185B'
THEME_DARK_ACCENT: '#FF80AB'
THEME_DARK_SECONDARY: '#AD1457'
THEME_DARK_SUCCESS: '#81C784'
THEME_DARK_INFO: '#64B5F6'
THEME_DARK_WARNING: '#FFD54F'
THEME_DARK_ERROR: '#E57373'
```
### Docker Secrets

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.3.2`
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.8.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.3.2 # (3)
image: ghcr.io/mealie-recipes/mealie:v3.8.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.3.2 # (3)
image: ghcr.io/mealie-recipes/mealie:v3.8.0 # (3)
container_name: mealie
restart: always
ports:

View File

@@ -9,6 +9,23 @@
- Create a Backup and Download from the UI
- Upgrade
!!! info "Improved Image Processing"
Starting with :octicons-tag-24: v3.7.0, we updated our image processing algorithm to improve image quality and compression. New image processing can be up to 40%-50% smaller on disk while providing higher resolution thumbnails. To take advantage of these improvements on older recipes, you can run our image-processing script:
```shell
docker exec -it mealie bash
python /opt/mealie/lib64/python3.12/site-packages/mealie/scripts/reprocess_images.py
```
### Options
- `--workers N`: Number of worker threads (default: 2, safe for low-powered devices)
- `--force-all`: Reprocess all recipes regardless of current image state
### Example
```shell
python /opt/mealie/lib64/python3.12/site-packages/mealie/scripts/reprocess_images.py --workers 8
```
## Upgrading to Mealie v1 or later
If you are upgrading from pre-v1.0.0 to v1.0.0 or later (v2.0.0, etc.), make sure you read [Migrating to Mealie v1](./migrating-to-mealie-v1.md)!

File diff suppressed because one or more lines are too long

View File

@@ -32,8 +32,8 @@ theme:
markdown_extensions:
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
- def_list
- pymdownx.highlight
- pymdownx.superfences

View File

@@ -0,0 +1,121 @@
<template>
<v-container max-width="880" class="end-page-content">
<div class="d-flex flex-column ga-6">
<div>
<v-card-title class="text-h4 justify-center">
{{ $t('admin.setup.setup-complete') }}
</v-card-title>
<v-card-subtitle class="justify-center">
{{ $t('admin.setup.here-are-a-few-things-to-help-you-get-started') }}
</v-card-subtitle>
</div>
<div
v-for="section, idx in sections"
:key="idx"
class="d-flex flex-column ga-3"
>
<v-card-title class="text-h6 pl-0">
{{ section.title }}
</v-card-title>
<div class="sections d-flex flex-column ga-2">
<v-card
v-for="link, linkIdx in section.links"
:key="linkIdx"
clas="link-card"
:href="link.to"
:title="link.text"
:subtitle="link.description"
:append-icon="$globals.icons.chevronRight"
>
<template #prepend>
<v-avatar :icon="link.icon || undefined" variant="tonal" :color="section.color" />
</template>
</v-card>
</div>
</div>
</div>
</v-container>
</template>
<script lang="ts">
export default defineNuxtComponent({
setup() {
const i18n = useI18n();
const $auth = useMealieAuth();
const groupSlug = computed(() => $auth.user.value?.groupSlug);
const { $globals } = useNuxtApp();
const sections = ref([
{
title: i18n.t("profile.data-migrations"),
color: "info",
links: [
{
icon: $globals.icons.backupRestore,
to: "/admin/backups",
text: i18n.t("settings.backup.backup-restore"),
description: i18n.t("admin.setup.restore-from-v1-backup"),
},
{
icon: $globals.icons.import,
to: "/group/migrations",
text: i18n.t("migration.recipe-migration"),
description: i18n.t("migration.coming-from-another-application-or-an-even-older-version-of-mealie"),
},
],
},
{
title: i18n.t("recipe.create-recipes"),
color: "success",
links: [
{
icon: $globals.icons.createAlt,
to: computed(() => `/g/${groupSlug.value || ""}/r/create/new`),
text: i18n.t("recipe.create-recipe"),
description: i18n.t("recipe.create-recipe-description"),
},
{
icon: $globals.icons.link,
to: computed(() => `/g/${groupSlug.value || ""}/r/create/url`),
text: i18n.t("recipe.import-with-url"),
description: i18n.t("recipe.scrape-recipe-description"),
},
],
},
{
title: i18n.t("user.manage-users"),
color: "primary",
links: [
{
icon: $globals.icons.group,
to: "/admin/manage/users",
text: i18n.t("user.manage-users"),
description: i18n.t("user.manage-users-description"),
},
{
icon: $globals.icons.user,
to: "/user/profile",
text: i18n.t("profile.manage-user-profile"),
description: i18n.t("admin.setup.manage-profile-or-get-invite-link"),
},
],
},
]);
return { sections };
},
});
</script>
<style>
.v-container {
.v-card-title,
.v-card-subtitle {
padding: 0;
white-space: unset;
}
.v-card-item {
gap: 0.5rem;
}
}
</style>

View File

@@ -83,6 +83,11 @@ const fieldDefs: FieldDefinition[] = [
label: i18n.t("household.households"),
type: Organizer.Household,
},
{
name: "user_id",
label: i18n.t("user.users"),
type: Organizer.User,
},
{
name: "created_at",
label: i18n.t("general.date-created"),

View File

@@ -14,7 +14,7 @@
<BaseButton
download
size="small"
:download-url="`/api/recipes/bulk-actions/export/download?path=${item.path}`"
:download-url="`/api/recipes/bulk-actions/export/${item.id}/download`"
/>
</template>
</v-data-table>

View File

@@ -58,6 +58,9 @@ const MEAL_TYPE_OPTIONS = [
{ title: i18n.t("meal-plan.lunch"), value: "lunch" },
{ title: i18n.t("meal-plan.dinner"), value: "dinner" },
{ title: i18n.t("meal-plan.side"), value: "side" },
{ title: i18n.t("meal-plan.snack"), value: "snack" },
{ title: i18n.t("meal-plan.drink"), value: "drink" },
{ title: i18n.t("meal-plan.dessert"), value: "dessert" },
{ title: i18n.t("meal-plan.type-any"), value: "unset" },
];
@@ -103,6 +106,11 @@ const fieldDefs: FieldDefinition[] = [
label: i18n.t("household.households"),
type: Organizer.Household,
},
{
name: "user_id",
label: i18n.t("user.users"),
type: Organizer.User,
},
{
name: "last_made",
label: i18n.t("general.last-made"),

View File

@@ -1,283 +1,297 @@
<template>
<v-card class="ma-0" style="overflow-x: auto;">
<v-card class="ma-0" flat fluid>
<v-card-text class="ma-0 pa-0">
<v-container fluid class="ma-0 pa-0">
<VueDraggable
v-model="fields"
handle=".handle"
:delay="250"
:delay-on-touch-only="true"
v-bind="{
animation: 200,
group: 'recipe-instructions',
ghostClass: 'ghost',
}"
@start="drag = true"
@end="onDragEnd"
<VueDraggable
v-model="fields"
handle=".handle"
:delay="250"
:delay-on-touch-only="true"
v-bind="{
animation: 200,
group: 'recipe-instructions',
ghostClass: 'ghost',
}"
@start="drag = true"
@end="onDragEnd"
>
<v-row
v-for="(field, index) in fields"
:key="field.id"
class="d-flex flex-row flex-wrap mx-auto pb-2"
:class="$vuetify.display.xs ? (Math.floor(index / 1) % 2 === 0 ? 'bg-dark' : 'bg-light') : ''"
style="max-width: 100%;"
>
<v-row
v-for="(field, index) in fields"
:key="field.id"
class="d-flex flex-nowrap"
style="max-width: 100%;"
<!-- drag handle -->
<v-col
:cols="config.items.icon.cols(index)"
:sm="config.items.icon.sm(index)"
:class="$vuetify.display.smAndDown ? 'd-flex pa-0' : 'd-flex justify-end pr-6'"
>
<!-- drag handle -->
<v-col
:cols="config.items.icon.cols"
:class="config.col.class"
:style="config.items.icon.style"
<v-icon class="handle my-auto" :size="28" style="cursor: move;">
{{ $globals.icons.arrowUpDown }}
</v-icon>
</v-col>
<!-- and / or -->
<v-col
v-if="index != 0 || $vuetify.display.smAndUp"
:cols="config.items.logicalOperator.cols(index)"
:sm="config.items.logicalOperator.sm(index)"
:class="config.col.class"
>
<v-select
v-if="index"
:model-value="field.logicalOperator"
:items="[logOps.AND, logOps.OR]"
item-title="label"
item-value="value"
variant="underlined"
@update:model-value="setLogicalOperatorValue(field, index, $event as unknown as LogicalOperator)"
>
<v-icon
class="handle"
:size="24"
style="cursor: move;margin: auto;"
>
{{ $globals.icons.arrowUpDown }}
</v-icon>
</v-col>
<!-- and / or -->
<v-col
:cols="config.items.logicalOperator.cols"
:class="config.col.class"
:style="config.items.logicalOperator.style"
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw.label }}
</span>
</template>
</v-select>
</v-col>
<!-- left parenthesis -->
<v-col
v-if="showAdvanced"
:cols="config.items.leftParens.cols(index)"
:sm="config.items.leftParens.sm(index)"
:class="config.col.class"
>
<v-select
:model-value="field.leftParenthesis"
:items="['', '(', '((', '(((']"
variant="underlined"
@update:model-value="setLeftParenthesisValue(field, index, $event)"
>
<v-select
v-if="index"
:model-value="field.logicalOperator"
:items="[logOps.AND, logOps.OR]"
item-title="label"
item-value="value"
variant="underlined"
@update:model-value="setLogicalOperatorValue(field, index, $event as unknown as LogicalOperator)"
>
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw.label }}
</span>
</template>
</v-select>
</v-col>
<!-- left parenthesis -->
<v-col
v-if="showAdvanced"
:cols="config.items.leftParens.cols"
:class="config.col.class"
:style="config.items.leftParens.style"
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw }}
</span>
</template>
</v-select>
</v-col>
<!-- field name -->
<v-col
:cols="config.items.fieldName.cols(index)"
:sm="config.items.fieldName.sm(index)"
:class="config.col.class"
>
<v-select
chips
:model-value="field.label"
:items="fieldDefs"
variant="underlined"
item-title="label"
@update:model-value="setField(index, $event)"
>
<v-select
:model-value="field.leftParenthesis"
:items="['', '(', '((', '(((']"
variant="underlined"
@update:model-value="setLeftParenthesisValue(field, index, $event)"
>
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw }}
</span>
</template>
</v-select>
</v-col>
<!-- field name -->
<v-col
:cols="config.items.fieldName.cols"
:class="config.col.class"
:style="config.items.fieldName.style"
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw.label }}
</span>
</template>
</v-select>
</v-col>
<!-- relational operator -->
<v-col
:cols="config.items.relationalOperator.cols(index)"
:sm="config.items.relationalOperator.sm(index)"
:class="config.col.class"
>
<v-select
v-if="field.type !== 'boolean'"
:model-value="field.relationalOperatorValue"
:items="field.relationalOperatorOptions"
item-title="label"
item-value="value"
variant="underlined"
@update:model-value="setRelationalOperatorValue(field, index, $event as unknown as RelationalKeyword | RelationalOperator)"
>
<v-select
chips
:model-value="field.label"
:items="fieldDefs"
variant="underlined"
item-title="label"
@update:model-value="setField(index, $event)"
>
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw.label }}
</span>
</template>
</v-select>
</v-col>
<!-- relational operator -->
<v-col
:cols="config.items.relationalOperator.cols"
:class="config.col.class"
:style="config.items.relationalOperator.style"
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw.label }}
</span>
</template>
</v-select>
</v-col>
<!-- field value -->
<v-col
:cols="config.items.fieldValue.cols(index)"
:sm="config.items.fieldValue.sm(index)"
:class="config.col.class"
>
<v-select
v-if="field.fieldOptions"
:model-value="field.values"
:items="field.fieldOptions"
item-title="label"
item-value="value"
multiple
variant="underlined"
@update:model-value="setFieldValues(field, index, $event)"
/>
<v-text-field
v-else-if="field.type === 'string'"
:model-value="field.value"
variant="underlined"
@update:model-value="setFieldValue(field, index, $event)"
/>
<v-text-field
v-else-if="field.type === 'number'"
:model-value="field.value"
type="number"
variant="underlined"
@update:model-value="setFieldValue(field, index, $event)"
/>
<v-checkbox
v-else-if="field.type === 'boolean'"
:model-value="field.value"
@update:model-value="setFieldValue(field, index, $event!)"
/>
<v-menu
v-else-if="field.type === 'date'"
v-model="datePickers[index]"
:close-on-content-click="false"
transition="scale-transition"
offset-y
max-width="290px"
min-width="auto"
>
<v-select
v-if="field.type !== 'boolean'"
:model-value="field.relationalOperatorValue"
:items="field.relationalOperatorOptions"
item-title="label"
item-value="value"
variant="underlined"
@update:model-value="setRelationalOperatorValue(field, index, $event as unknown as RelationalKeyword | RelationalOperator)"
>
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw.label }}
</span>
</template>
</v-select>
</v-col>
<!-- field value -->
<v-col
:cols="config.items.fieldValue.cols"
:class="config.col.class"
:style="config.items.fieldValue.style"
>
<v-select
v-if="field.fieldOptions"
:model-value="field.values"
:items="field.fieldOptions"
item-title="label"
item-value="value"
multiple
variant="underlined"
@update:model-value="setFieldValues(field, index, $event)"
/>
<v-text-field
v-else-if="field.type === 'string'"
:model-value="field.value"
variant="underlined"
@update:model-value="setFieldValue(field, index, $event)"
/>
<v-text-field
v-else-if="field.type === 'number'"
:model-value="field.value"
type="number"
variant="underlined"
@update:model-value="setFieldValue(field, index, $event)"
/>
<v-checkbox
v-else-if="field.type === 'boolean'"
:model-value="field.value"
@update:model-value="setFieldValue(field, index, $event!)"
/>
<v-menu
v-else-if="field.type === 'date'"
v-model="datePickers[index]"
:close-on-content-click="false"
transition="scale-transition"
offset-y
max-width="290px"
min-width="auto"
>
<template #activator="{ props: activatorProps }">
<v-text-field
v-model="field.value"
persistent-hint
:prepend-icon="$globals.icons.calendar"
variant="underlined"
color="primary"
v-bind="activatorProps"
readonly
/>
</template>
<v-date-picker
:model-value="field.value ? new Date(field.value + 'T00:00:00') : null"
hide-header
:first-day-of-week="firstDayOfWeek"
:local="$i18n.locale"
@update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
<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"
variant="underlined"
color="primary"
v-bind="activatorProps"
readonly
/>
</v-menu>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Category"
v-model="field.organizers"
:selector-type="Organizer.Category"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setFieldOrganizers(field, index, $event)"
</template>
<v-date-picker
:model-value="field.value ? new Date(field.value + 'T00:00:00') : null"
hide-header
:first-day-of-week="firstDayOfWeek"
:local="$i18n.locale"
@update:model-value="val => setFieldValue(field, index, val ? val.toISOString().slice(0, 10) : '')"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tag"
v-model="field.organizers"
:selector-type="Organizer.Tag"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tool"
v-model="field.organizers"
:selector-type="Organizer.Tool"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Food"
v-model="field.organizers"
:selector-type="Organizer.Food"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Household"
v-model="field.organizers"
:selector-type="Organizer.Household"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
</v-col>
<!-- right parenthesis -->
<v-col
v-if="showAdvanced"
:cols="config.items.rightParens.cols"
:class="config.col.class"
:style="config.items.rightParens.style"
</v-menu>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Category"
v-model="field.organizers"
:selector-type="Organizer.Category"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tag"
v-model="field.organizers"
:selector-type="Organizer.Tag"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tool"
v-model="field.organizers"
:selector-type="Organizer.Tool"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Food"
v-model="field.organizers"
:selector-type="Organizer.Food"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Household"
v-model="field.organizers"
:selector-type="Organizer.Household"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.User"
v-model="field.organizers"
:selector-type="Organizer.User"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="val => setFieldOrganizers(field, index, (val || []) as OrganizerBase[])"
/>
</v-col>
<!-- right parenthesis -->
<v-col
v-if="showAdvanced"
:cols="config.items.rightParens.cols(index)"
:sm="config.items.rightParens.sm(index)"
:class="config.col.class"
>
<v-select
:model-value="field.rightParenthesis"
:items="['', ')', '))', ')))']"
variant="underlined"
@update:model-value="setRightParenthesisValue(field, index, $event)"
>
<v-select
:model-value="field.rightParenthesis"
:items="['', ')', '))', ')))']"
variant="underlined"
@update:model-value="setRightParenthesisValue(field, index, $event)"
>
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw }}
</span>
</template>
</v-select>
</v-col>
<!-- field actions -->
<v-col
:cols="config.items.fieldActions.cols"
:class="config.col.class"
:style="config.items.fieldActions.style"
>
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.delete,
text: $t('general.delete'),
event: 'delete',
disabled: fields.length === 1,
},
]"
class="my-auto"
@delete="removeField(index)"
/>
</v-col>
</v-row>
</VueDraggable>
</v-container>
<template #chip="{ item }">
<span :class="config.select.textClass" style="width: 100%;">
{{ item.raw }}
</span>
</template>
</v-select>
</v-col>
<!-- field actions -->
<v-col
v-if="!$vuetify.display.smAndDown || index === fields.length - 1"
:cols="config.items.fieldActions.cols(index)"
:sm="config.items.fieldActions.sm(index)"
:class="config.col.class"
>
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.delete,
text: $t('general.delete'),
event: 'delete',
disabled: fields.length === 1,
},
]"
class="my-auto"
@delete="removeField(index)"
/>
</v-col>
</v-row>
</VueDraggable>
</v-card-text>
<v-card-actions>
<v-row fluid class="d-flex justify-end pa-0 mx-2">
<v-row fluid class="d-flex justify-end ma-2">
<v-spacer />
<v-checkbox
v-model="showAdvanced"
@@ -305,6 +319,7 @@ import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerS
import { Organizer } from "~/lib/api/types/non-generated";
import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
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";
const props = defineProps({
@@ -344,6 +359,7 @@ const storeMap = {
[Organizer.Tool]: useToolStore(),
[Organizer.Food]: useFoodStore(),
[Organizer.Household]: useHouseholdStore(),
[Organizer.User]: useUserStore(),
};
function onDragEnd(event: any) {
@@ -602,46 +618,56 @@ function buildQueryFilterJSON(): QueryFilterJSON {
}
const config = computed(() => {
const baseColMaxWidth = 55;
const multiple = fields.value.length > 1;
const adv = state.showAdvanced;
return {
col: {
class: "d-flex justify-center align-end field-col pa-1",
class: "d-flex justify-center align-end py-0",
},
select: {
textClass: "d-flex justify-center text-center",
},
items: {
icon: {
cols: 1,
cols: (_index: number) => 2,
sm: (_index: number) => 1,
style: "width: fit-content;",
},
leftParens: {
cols: state.showAdvanced ? 1 : 0,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
cols: (index: number) => (adv ? (index === 0 ? 2 : 0) : 0),
sm: (_index: number) => (adv ? 1 : 0),
},
logicalOperator: {
cols: 1,
style: `min-width: ${baseColMaxWidth}px;`,
cols: (_index: number) => 0,
sm: (_index: number) => (multiple ? 1 : 0),
},
fieldName: {
cols: state.showAdvanced ? 2 : 3,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
cols: (index: number) => {
if (adv) return index === 0 ? 8 : 12;
return index === 0 ? 10 : 12;
},
sm: (_index: number) => (adv ? 2 : 3),
},
relationalOperator: {
cols: 2,
style: `min-width: ${baseColMaxWidth * 2}px;`,
cols: (_index: number) => 12,
sm: (_index: number) => 2,
},
fieldValue: {
cols: state.showAdvanced ? 3 : 4,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
cols: (index: number) => {
const last = index === fields.value.length - 1;
if (adv) return last ? 8 : 10;
return last ? 10 : 12;
},
sm: (_index: number) => (adv ? 3 : 4),
},
rightParens: {
cols: state.showAdvanced ? 1 : 0,
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
cols: (index: number) => (adv ? (index === fields.value.length - 1 ? 2 : 0) : 0),
sm: (_index: number) => (adv ? 1 : 0),
},
fieldActions: {
cols: 1,
style: `min-width: ${baseColMaxWidth}px;`,
cols: (index: number) => (index === fields.value.length - 1 ? 2 : 0),
sm: (_index: number) => 1,
},
},
};
@@ -651,5 +677,14 @@ const config = computed(() => {
<style scoped>
* {
font-size: 1em;
--bg-opactity: calc(var(--v-hover-opacity) * var(--v-theme-overlay-multiplier));
}
.bg-dark {
background-color: rgba(0, 0, 0, var(--bg-opactity));
}
.bg-light {
background-color: rgba(255, 255, 255, var(--bg-opactity));
}
</style>

View File

@@ -28,11 +28,12 @@
<v-list-item-title class="pl-2">
{{ item.name }}
</v-list-item-title>
<v-list-item-action>
<template #append>
<v-btn
v-if="!edit"
color="primary"
icon
size="small"
:href="assetURL(item.fileName ?? '')"
target="_blank"
top
@@ -43,6 +44,7 @@
<v-btn
color="error"
icon
size="small"
top
@click="model.splice(i, 1)"
>
@@ -53,7 +55,7 @@
:copy-text="assetEmbed(item.fileName ?? '')"
/>
</div>
</v-list-item-action>
</template>
</v-list-item>
</v-list>
</v-card>
@@ -90,13 +92,12 @@
item-value="name"
class="mr-2"
>
<template #item="{ item }">
<v-avatar>
<v-icon class="mr-auto">
{{ item.raw.icon }}
</v-icon>
</v-avatar>
{{ item.title }}
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps">
<template #prepend>
<v-icon>{{ item.raw.icon }}</v-icon>
</template>
</v-list-item>
</template>
</v-select>
<AppButtonUpload

View File

@@ -15,11 +15,11 @@
@click.self="$emit('click')"
>
<RecipeCardImage
small
:icon-size="imageHeight"
:height="imageHeight"
:slug="slug"
:recipe-id="recipeId"
size="small"
:image-version="image"
>
<v-expand-transition v-if="description">

View File

@@ -19,10 +19,10 @@
cover
>
<RecipeCardImage
tiny
:icon-size="100"
:slug="slug"
:recipe-id="recipeId"
size="small"
:image-version="image"
:height="height"
/>
@@ -41,11 +41,11 @@
name="avatar"
>
<RecipeCardImage
tiny
:icon-size="100"
:slug="slug"
:recipe-id="recipeId"
:image-version="image"
size="small"
width="125"
:height="height"
/>

View File

@@ -90,6 +90,14 @@
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
</div>
</v-list-item>
<v-list-item @click="sortRecipes(EVENTS.shuffle)">
<div class="d-flex align-center flex-nowrap">
<v-icon class="mr-2" inline>
{{ $globals.icons.diceMultiple }}
</v-icon>
<v-list-item-title>{{ $t("general.random") }}</v-list-item-title>
</div>
</v-list-item>
</v-list>
</v-menu>
<ContextMenu
@@ -223,6 +231,7 @@ const displayTitleIcon = computed(() => {
});
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 || "");
@@ -256,13 +265,18 @@ const queryFilter = computed(() => {
async function fetchRecipes(pageCount = 1) {
const orderDir = props.query?.orderDirection || preferences.value.orderDirection;
const orderByNullPosition = props.query?.orderByNullPosition || orderDir === "asc" ? "first" : "last";
const orderBy = props.query?.orderBy || preferences.value.orderBy;
const localQuery = { ...props.query };
if (orderBy === "random") {
localQuery._searchSeed = randomSeed.value;
}
return await fetchMore(
page.value,
perPage * pageCount,
props.query?.orderBy || preferences.value.orderBy,
orderBy,
orderDir,
orderByNullPosition,
props.query,
localQuery,
// we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by
queryFilter.value,
);
@@ -288,6 +302,9 @@ watch(
);
async function initRecipes() {
if (preferences.value.orderBy === "random") {
randomSeed.value = Date.now().toString();
}
page.value = 1;
hasMore.value = true;
@@ -380,6 +397,15 @@ async function sortRecipes(sortType: string) {
true,
);
break;
case EVENTS.shuffle:
setter(
"random",
$globals.icons.diceMultiple,
$globals.icons.diceMultiple, // icon in asc and desc is the same for random
);
// We update the seed value to have a different order
randomSeed.value = Date.now().toString();
break;
default:
console.log("Unknown Event", sortType);
return;

View File

@@ -45,31 +45,15 @@
@confirm="addRecipeToPlan()"
>
<v-card-text>
<v-menu
v-model="pickerMenu"
:close-on-content-click="false"
transition="scale-transition"
offset-y
max-width="290px"
min-width="auto"
>
<template #activator="{ props: activatorProps }">
<v-text-field
v-model="newMealdateString"
:label="$t('general.date')"
:prepend-icon="$globals.icons.calendar"
v-bind="activatorProps"
readonly
/>
</template>
<v-date-picker
v-model="newMealdate"
hide-header
:first-day-of-week="firstDayOfWeek"
:local="$i18n.locale"
@update:model-value="pickerMenu = false"
/>
</v-menu>
<v-date-picker
v-model="newMealdate"
class="mx-auto mb-3"
hide-header
show-adjacent-months
color="primary"
:first-day-of-week="firstDayOfWeek"
:local="$i18n.locale"
/>
<v-select
v-model="newMealType"
:return-object="false"
@@ -207,7 +191,6 @@ const loading = ref(false);
const menuItems = ref<ContextMenuItem[]>([]);
const newMealdate = ref(new Date());
const newMealType = ref<PlanEntryType>("dinner");
const pickerMenu = ref(false);
const newMealdateString = computed(() => {
// Format the date to YYYY-MM-DD in the same timezone as newMealdate
@@ -377,11 +360,14 @@ async function deleteRecipe() {
const download = useDownloader();
async function handleDownloadEvent() {
const { data } = await api.recipes.getZipToken(props.slug);
if (data) {
download(api.recipes.getZipRedirectUrl(props.slug, data.token), `${props.slug}.zip`);
const { data: shareToken } = await api.recipes.share.createOne({ recipeId: props.recipeId });
if (!shareToken) {
console.error("No share token received");
alert.error(i18n.t("events.something-went-wrong"));
return;
}
download(api.recipes.share.getZipRedirectUrl(shareToken.id), `${props.slug}.zip`);
}
async function addRecipeToPlan() {

View File

@@ -57,7 +57,7 @@
</div>
</template>
<template #[`item.dateAdded`]="{ item }">
{{ formatDate(item.dateAdded!) }}
{{ item.dateAdded ? $d(new Date(item.dateAdded)) : '' }}
</template>
</v-data-table>
</template>
@@ -153,15 +153,6 @@ const headers = computed(() => {
return hdrs;
});
function formatDate(date: string) {
try {
return i18n.d(Date.parse(date), "medium");
}
catch {
return "";
}
}
// ============
// Group Members
const api = useUserApi();

View File

@@ -139,7 +139,7 @@
color="secondary"
density="compact"
/>
<div :key="`${ingredientData.ingredient.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto">
<div :key="`${ingredientData.ingredient?.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto">
<RecipeIngredientListItem
:ingredient="ingredientData.ingredient"
:scale="recipeSection.recipeScale"
@@ -287,12 +287,35 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
continue;
}
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
const householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
return {
checked: !householdsWithFood.includes(userHousehold.value),
ingredient: ing,
};
const shoppingListIngredients: ShoppingListIngredient[] = [];
function flattenRecipeIngredients(ing: RecipeIngredient, parentTitle = ""): ShoppingListIngredient[] {
const householdsWithFood = ing.food?.householdsWithIngredientFood || [];
if (ing.referencedRecipe) {
// Recursively flatten all ingredients in the referenced recipe
return (ing.referencedRecipe.recipeIngredient ?? []).flatMap((subIng) => {
const calculatedQty = (ing.quantity || 1) * (subIng.quantity || 1);
// Pass the referenced recipe name as the section title
return flattenRecipeIngredients(
{ ...subIng, quantity: calculatedQty },
"",
);
});
}
else {
// Regular ingredient
return [{
checked: !householdsWithFood.includes(userHousehold.value),
ingredient: {
...ing,
title: ing.title || parentTitle,
},
}];
}
}
recipe.recipeIngredient.forEach((ing) => {
const flattened = flattenRecipeIngredients(ing, "");
shoppingListIngredients.push(...flattened);
});
let currentTitle = "";
@@ -301,6 +324,9 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
if (ing.ingredient.title) {
currentTitle = ing.ingredient.title;
}
else if (ing.ingredient.referencedRecipe?.name) {
currentTitle = ing.ingredient.referencedRecipe.name;
}
// If this is the first item in the section, create a new section
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
@@ -316,7 +342,7 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
}
// Store the on-hand ingredients for later
const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []);
const householdsWithFood = (ing.ingredient?.food?.householdsWithIngredientFood || []);
if (householdsWithFood.includes(userHousehold.value)) {
onHandIngs.push(ing);
return sections;

View File

@@ -141,6 +141,13 @@ function save() {
dialog.value = false;
}
function open() {
dialog.value = true;
}
function close() {
dialog.value = false;
}
const i18n = useI18n();
const utilities = [
@@ -160,4 +167,10 @@ const utilities = [
action: splitByNumberedLine,
},
];
// Expose functions to parent components
defineExpose({
open,
close,
});
</script>

View File

@@ -69,7 +69,14 @@
:label="$t('recipe.nutrition')"
/>
</v-row>
<v-row no-gutters />
<v-row no-gutters>
<v-switch
v-model="preferences.expandChildRecipes"
hide-details
color="primary"
:label="$t('recipe.include-linked-recipe-ingredients')"
/>
</v-row>
</v-col>
</v-row>
</v-container>

View File

@@ -16,7 +16,7 @@
>
<template #activator="{ props: activatorProps }">
<v-text-field
v-model="expirationDateString"
:model-value="$d(expirationDate)"
:label="$t('recipe-share.expiration-date')"
:hint="$t('recipe-share.default-30-days')"
persistent-hint
@@ -59,11 +59,8 @@
<div class="pl-3 flex-grow-1">
<v-list-item-title>
{{ $t("recipe-share.expires-at") }}
{{ $t("recipe-share.expires-at") + ' ' + $d(new Date(token.expiresAt!), "short") }}
</v-list-item-title>
<v-list-item-subtitle>
{{ $d(new Date(token.expiresAt!), "long") }}
</v-list-item-subtitle>
</div>
<v-btn
@@ -111,10 +108,6 @@ const datePickerMenu = ref(false);
const expirationDate = ref(new Date(Date.now() - new Date().getTimezoneOffset() * 60000));
const tokens = ref<RecipeShareToken[]>([]);
const expirationDateString = computed(() => {
return expirationDate.value.toISOString().substring(0, 10);
});
whenever(
() => dialog.value,
() => {

View File

@@ -53,10 +53,23 @@
:active="state.orderBy === v.value"
slim
density="comfortable"
:prepend-icon="v.icon"
:title="v.name"
@click="setOrderBy(v.value)"
/>
@click="v.value === 'random' ? setRandomOrderByWrapper() : setOrderBy(v.value)"
>
<template #prepend>
<v-icon>{{ v.icon }}</v-icon>
</template>
<template #title>
<span>{{ v.name }}</span>
<v-icon
v-if="v.value === 'random' && showRandomLoading"
size="small"
class="ml-3"
>
{{ $globals.icons.refreshCircle }}
</v-icon>
</template>
</v-list-item>
</v-list>
</v-card>
</v-menu>
@@ -131,6 +144,7 @@ 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 || "");
@@ -141,6 +155,7 @@ const {
reset,
toggleOrderDirection,
setOrderBy,
setRandomOrderBy,
filterItems,
initialize,
} = useRecipeExplorerSearch(groupSlug);
@@ -205,6 +220,14 @@ const input: Ref<any> = ref(null);
function hideKeyboard() {
input.value?.blur();
}
// function to show refresh icon
async function setRandomOrderByWrapper() {
if (!showRandomLoading.value) {
showRandomLoading.value = true;
}
await setRandomOrderBy();
}
</script>
<style scoped>

View File

@@ -20,18 +20,36 @@
</v-btn>
</template>
<v-card width="400">
<v-card-title class="headline flex mb-0">
<v-card-title class="headline flex-wrap mb-0">
<div>
{{ $t("recipe.recipe-image") }}
</div>
<AppButtonUpload
class="ml-auto"
url="none"
file-name="image"
:text-btn="false"
:post="false"
@uploaded="uploadImage"
/>
<div class="d-flex gap-2">
<AppButtonUpload
url="none"
file-name="image"
:text-btn="false"
:post="false"
@uploaded="uploadImage"
/>
<BaseButton
class="ml-2"
delete
@click="dialogDeleteImage = true"
/>
<BaseDialog
v-model="dialogDeleteImage"
:title="$t('recipe.delete-image')"
:icon="$globals.icons.alertCircle"
color="error"
can-delete
@delete="deleteImage"
>
<v-card-text>
{{ $t("recipe.delete-image-confirmation") }}
</v-card-text>
</BaseDialog>
</div>
</v-card-title>
<v-card-text class="mt-n5">
<div>
@@ -62,38 +80,58 @@
</template>
<script setup lang="ts">
import { alert } from "~/composables/use-toast";
import { useUserApi } from "~/composables/api";
const REFRESH_EVENT = "refresh";
const UPLOAD_EVENT = "upload";
const DELETE_EVENT = "delete";
const props = defineProps<{ slug: string }>();
const emit = defineEmits<{
refresh: [];
upload: [fileObject: File];
delete: [];
}>();
const i18n = useI18n();
const api = useUserApi();
const url = ref("");
const loading = ref(false);
const menu = ref(false);
const dialogDeleteImage = ref(false);
function uploadImage(fileObject: File) {
emit(UPLOAD_EVENT, fileObject);
menu.value = false;
}
const api = useUserApi();
async function deleteImage() {
loading.value = true;
try {
await api.recipes.deleteImage(props.slug);
emit(DELETE_EVENT);
menu.value = false;
}
catch (e) {
alert.error(i18n.t("events.something-went-wrong"));
console.error("Failed to delete image", e);
}
finally {
loading.value = false;
}
}
async function getImageFromURL() {
loading.value = true;
if (await api.recipes.updateImagebyURL(props.slug, url.value)) {
emit(REFRESH_EVENT);
emit(DELETE_EVENT);
}
loading.value = false;
menu.value = false;
}
const i18n = useI18n();
const messages = computed(() =>
props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")],
);

View File

@@ -41,6 +41,7 @@
</v-text-field>
</v-col>
<v-col
v-if="!state.isRecipe"
sm="12"
md="3"
cols="12"
@@ -55,6 +56,7 @@
variant="solo"
return-object
:items="units || []"
:custom-filter="normalizeFilter"
item-title="name"
class="mx-1"
:placeholder="$t('recipe.choose-unit')"
@@ -97,6 +99,7 @@
<!-- Foods Input -->
<v-col
v-if="!state.isRecipe"
m="12"
md="3"
cols="12"
@@ -112,6 +115,7 @@
variant="solo"
return-object
:items="foods || []"
:custom-filter="normalizeFilter"
item-title="name"
class="mx-1 py-0"
:placeholder="$t('recipe.choose-food')"
@@ -151,6 +155,36 @@
</template>
</v-autocomplete>
</v-col>
<!-- Recipe Input -->
<v-col
v-if="state.isRecipe"
m="12"
md="6"
cols="12"
class=""
>
<v-autocomplete
ref="search.query"
v-model="model.referencedRecipe"
v-model:search="search.query.value"
auto-select-first
hide-details
density="compact"
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')"
clearable
:label="!model.referencedRecipe ? $t('recipe.choose-recipe') : ''"
@click="search.trigger()"
@focus="search.trigger()"
>
<template #prepend />
</v-autocomplete>
</v-col>
<v-col
sm="12"
md=""
@@ -173,6 +207,7 @@
class="my-auto d-flex"
:buttons="btns"
@toggle-section="toggleTitle"
@toggle-subrecipe="toggleIsRecipe"
@insert-above="$emit('insert-above')"
@insert-below="$emit('insert-below')"
@delete="$emit('delete')"
@@ -193,8 +228,11 @@ import { ref, computed, reactive, toRefs } 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 { useNuxtApp } from "#app";
import type { RecipeIngredient } from "~/lib/api/types/recipe";
import { usePublicExploreApi, useUserApi } from "~/composables/api";
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
// defineModel replaces modelValue prop
const model = defineModel<RecipeIngredient>({ required: true });
@@ -204,6 +242,10 @@ const props = defineProps({
type: String,
default: "body",
},
isRecipe: {
type: Boolean,
default: false,
},
unitError: {
type: Boolean,
default: false,
@@ -247,6 +289,7 @@ const { $globals } = useNuxtApp();
const state = reactive({
showTitle: false,
isRecipe: props.isRecipe,
});
const contextMenuOptions = computed(() => {
@@ -255,6 +298,10 @@ const contextMenuOptions = computed(() => {
text: i18n.t("recipe.toggle-section"),
event: "toggle-section",
},
{
text: i18n.t("recipe.toggle-recipe"),
event: "toggle-subrecipe",
},
{
text: i18n.t("recipe.insert-above"),
event: "insert-above",
@@ -303,6 +350,25 @@ async function createAssignFood() {
foodAutocomplete.value?.blur();
}
// Recipes
const route = useRoute();
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;
const search = useRecipeSearch(api);
const loading = ref(false);
const selectedIndex = ref(-1);
// Reset or Grab Recipes on Change
watch(loading, (val) => {
if (!val) {
search.query.value = "";
selectedIndex.value = -1;
search.data.value = [];
}
});
// Units
const unitStore = useUnitStore();
const unitsData = useUnitData();
@@ -323,6 +389,17 @@ function toggleTitle() {
state.showTitle = !state.showTitle;
}
function toggleIsRecipe() {
if (state.isRecipe) {
model.value.referencedRecipe = undefined;
}
else {
model.value.unit = undefined;
model.value.food = undefined;
}
state.isRecipe = !state.isRecipe;
}
function handleUnitEnter() {
if (
model.value.unit === undefined

View File

@@ -13,6 +13,10 @@
class="text-bold d-inline"
:source="parsedIng.note"
/>
<template v-else-if="parsedIng.recipeLink">
<SafeMarkdown v-if="parsedIng.recipeLink" class="text-bold d-inline" :source="parsedIng.recipeLink" />
<SafeMarkdown v-if="parsedIng.note" class="note" :source="parsedIng.note" />
</template>
<template v-else>
<SafeMarkdown
v-if="parsedIng.name"
@@ -39,9 +43,12 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
scale: 1,
});
const route = useRoute();
const $auth = useMealieAuth();
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
const parsedIng = computed(() => {
return useParsedIngredientText(props.ingredient, props.scale);
return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());
});
</script>

View File

@@ -8,6 +8,7 @@
:title="$t('recipe.made-this')"
:submit-text="$t('recipe.add-to-timeline')"
can-submit
disable-submit-on-enter
@submit="createTimelineEvent"
>
<v-card-text>
@@ -20,6 +21,29 @@
persistent-hint
rows="4"
/>
<div v-if="childRecipes?.length">
<v-card-text class="pt-6 pb-0">
{{ $t('recipe.include-linked-recipes') }}
</v-card-text>
<v-list>
<v-list-item
v-for="(childRecipe, i) in childRecipes"
:key="childRecipe.recipeId + i"
density="compact"
class="my-0 py-0"
@click="childRecipe.checked = !childRecipe.checked"
>
<v-checkbox
hide-details
density="compact"
:input-value="childRecipe.checked"
:label="childRecipe.name"
class="my-0 py-0"
color="secondary"
/>
</v-list-item>
</v-list>
</div>
<v-container>
<v-row>
<v-col cols="6">
@@ -32,7 +56,7 @@
>
<template #activator="{ props: activatorProps }">
<v-text-field
v-model="newTimelineEventTimestampString"
:model-value="$d(newTimelineEventTimestamp)"
:prepend-icon="$globals.icons.calendar"
v-bind="activatorProps"
readonly
@@ -102,7 +126,7 @@
<span class="text-body-1 opacity-80">
<b>{{ $t("general.last-made") }}</b>
<br>
{{ lastMade ? new Date(lastMade).toLocaleDateString($i18n.locale) : $t("general.never") }}
{{ lastMade ? $d(new Date(lastMade)) : $t("general.never") }}
</span>
<v-icon end size="large" color="primary">
{{ $globals.icons.createAlt }}
@@ -166,6 +190,21 @@ onMounted(async () => {
lastMadeReady.value = true;
});
const childRecipes = computed(() => {
return props.recipe.recipeIngredient?.map((ingredient) => {
if (ingredient.referencedRecipe) {
return {
checked: false, // Default value for checked
recipeId: ingredient.referencedRecipe.id || "", // Non-nullable recipeId
...ingredient.referencedRecipe, // Spread the rest of the referencedRecipe properties
};
}
else {
return undefined;
}
}).filter(recipe => recipe !== undefined); // Filter out undefined values
});
whenever(
() => madeThisDialog.value,
() => {
@@ -250,6 +289,37 @@ async function createTimelineEvent() {
}
}
for (const childRecipe of childRecipes.value || []) {
if (!childRecipe.checked) {
continue;
}
const childTimelineEvent = {
...newTimelineEvent.value,
recipeId: childRecipe.recipeId,
eventMessage: i18n.t("recipe.made-for-recipe", { recipe: childRecipe.name }),
image: undefined,
};
try {
await userApi.recipes.createTimelineEvent(childTimelineEvent);
}
catch (error) {
console.error(`Failed to create timeline event for child recipe ${childRecipe.slug}:`, error);
}
if (
newTimelineEvent.value.timestamp
&& (!childRecipe.lastMade || newTimelineEvent.value.timestamp > childRecipe.lastMade)
) {
try {
await userApi.recipes.updateLastMade(childRecipe.slug || "", newTimelineEvent.value.timestamp);
}
catch (error) {
console.error(`Failed to update last made date for child recipe ${childRecipe.slug}:`, error);
}
}
}
// update the image, if provided
let imageError = false;
if (newTimelineEventImage.value) {
@@ -268,7 +338,6 @@ async function createTimelineEvent() {
console.error("Failed to upload image for timeline event:", error);
}
}
if (imageError) {
alert.error(i18n.t("recipe.added-to-timeline-but-failed-to-add-image"));
}

View File

@@ -105,10 +105,9 @@
<v-icon>
{{ icon }}
</v-icon>
<v-card-title class="py-1">
<v-card-title class="py-1 text-truncate flex-shrink-1 flex-grow-1">
{{ item.name }}
</v-card-title>
<v-spacer />
<ContextMenu
:items="[presets.delete, presets.edit]"
@delete="confirmDelete(item)"

View File

@@ -4,17 +4,19 @@
v-bind="inputAttrs"
v-model:search="searchInput"
:items="items"
:custom-filter="normalizeFilter"
:label="label"
chips
closable-chips
item-title="name"
:item-title="itemTitle"
item-value="name"
multiple
:variant="variant"
:prepend-inner-icon="icon"
:append-icon="showAdd ? $globals.icons.create : undefined"
return-object
auto-select-first
class="pa-0"
class="pa-0 ma-0"
@update:model-value="resetSearchInput"
@click:append="dialog = true"
>
@@ -32,7 +34,6 @@
{{ item.value }}
</v-chip>
</template>
<template
v-if="showAdd"
#append
@@ -52,11 +53,13 @@ import type { RecipeTool } from "~/lib/api/types/admin";
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
import type { HouseholdSummary } from "~/lib/api/types/household";
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
import { useUserStore } from "~/composables/store/use-user-store";
import { normalizeFilter } from "~/composables/use-utils";
import type { UserSummary } from "~/lib/api/types/user";
interface Props {
selectorType: RecipeOrganizer;
inputAttrs?: Record<string, any>;
returnObject?: boolean;
showAdd?: boolean;
showLabel?: boolean;
showIcon?: boolean;
@@ -65,7 +68,6 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
inputAttrs: () => ({}),
returnObject: true,
showAdd: true,
showLabel: true,
showIcon: true,
@@ -78,7 +80,7 @@ const selected = defineModel<(
| RecipeCategory
| RecipeTool
| IngredientFood
| string
| UserSummary
)[] | undefined>({ required: true });
onMounted(() => {
@@ -106,6 +108,8 @@ const label = computed(() => {
return i18n.t("general.foods");
case Organizer.Household:
return i18n.t("household.households");
case Organizer.User:
return i18n.t("user.users");
default:
return i18n.t("general.organizer");
}
@@ -127,11 +131,19 @@ const icon = computed(() => {
return $globals.icons.foods;
case Organizer.Household:
return $globals.icons.household;
case Organizer.User:
return $globals.icons.user;
default:
return $globals.icons.tags;
}
});
const itemTitle = computed(() =>
props.selectorType === Organizer.User
? (i: any) => i?.fullName ?? i?.name ?? ""
: "name",
);
// ===========================================================================
// Store & Items Setup
@@ -141,28 +153,19 @@ const storeMap = {
[Organizer.Tool]: useToolStore(),
[Organizer.Food]: useFoodStore(),
[Organizer.Household]: useHouseholdStore(),
[Organizer.User]: useUserStore(),
};
const store = computed(() => {
const activeStore = computed(() => {
const { store } = storeMap[props.selectorType];
return store.value;
});
const items = computed(() => {
if (!props.returnObject) {
return store.value.map(item => item.name);
}
return store.value;
const items = computed<any[]>(() => {
const list = (activeStore.value as unknown as any[]) ?? [];
return list;
});
function removeByIndex(index: number) {
if (selected.value === undefined) {
return;
}
const newSelected = selected.value.filter((_, i) => i !== index);
selected.value = [...newSelected];
}
function appendCreated(item: any) {
if (selected.value === undefined) {
return;

View File

@@ -95,9 +95,12 @@
<RecipePrintContainer :recipe="recipe" :scale="scale" />
</v-container>
<!-- Cook mode displayes two columns with ingredients and instructions side by side, each being scrolled individually, allowing to view both at the same time -->
<!-- The calc is to account for the navabar height (48px) -->
<v-sheet
v-show="isCookMode && !hasLinkedIngredients"
key="cookmode"
:height="$vuetify.display.smAndUp ? 'calc(100vh - 48px)' : 'auto'"
class-name="overflow-hidden"
>
<!-- the calc is to account for the toolbar a more dynamic solution could be needed -->
<v-row style="height: 100%" no-gutters class="overflow-hidden">
@@ -290,10 +293,13 @@ watch(isParsing, () => {
*/
async function saveRecipe() {
const { data } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
setMode(PageMode.VIEW);
const { data, error } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
if (!error) {
setMode(PageMode.VIEW);
}
if (data?.slug) {
router.push(`/g/${groupSlug.value}/r/` + data.slug);
recipe.value = data as NoUndefinedField<Recipe>;
}
}

View File

@@ -5,6 +5,7 @@
:slug="recipe.slug"
@upload="uploadImage"
@refresh="imageKey++"
@delete="deleteImage"
/>
<RecipeSettingsMenu
v-model="recipe.settings"
@@ -78,4 +79,10 @@ async function uploadImage(fileObject: File) {
}
imageKey.value++;
}
async function deleteImage() {
// The image is already deleted on the backend, just need to update the UI
recipe.value.image = "";
imageKey.value++;
}
</script>

View File

@@ -28,7 +28,7 @@ const props = withDefaults(defineProps<Props>(), {
});
const display = useDisplay();
const { recipeImage } = useStaticRoutes();
const { recipeImage, recipeSmallImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug);
const { user } = usePageUser();
@@ -46,7 +46,9 @@ const imageHeight = computed(() => {
});
const recipeImageUrl = computed(() => {
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
return display.smAndDown.value
? recipeSmallImage(props.recipe.id, props.recipe.image, imageKey.value)
: recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
});
watch(

View File

@@ -30,6 +30,7 @@
v-for="(ingredient, index) in recipe.recipeIngredient"
:key="ingredient.referenceId"
v-model="recipe.recipeIngredient[index]"
:is-recipe="ingredientIsRecipe(ingredient)"
enable-drag-handle
enable-context-menu
class="list-group-item"
@@ -69,15 +70,59 @@
<span>{{ parserToolTip }}</span>
</v-tooltip>
<RecipeDialogBulkAdd
ref="domBulkAddDialog"
class="mx-1 mb-1"
style="display: none"
@bulk-data="addIngredient"
/>
<BaseButton
class="mb-1"
@click="addIngredient"
>
{{ $t("general.add") }}
</BaseButton>
<div class="d-inline-flex">
<!-- Main button: Add Food -->
<v-btn
color="success"
class="split-main ml-2"
@click="addIngredient"
>
<v-icon start>
{{ $globals.icons.createAlt }}
</v-icon>
{{ $t('general.add') || 'Add Food' }}
</v-btn>
<!-- Dropdown button -->
<v-menu>
<template #activator="{ props }">
<v-btn
color="success"
class="split-dropdown"
v-bind="props"
>
<v-icon>{{ $globals.icons.chevronDown }}</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
slim
density="comfortable"
:prepend-icon="$globals.icons.foods"
:title="$t('new-recipe.add-food')"
@click="addIngredient"
/>
<v-list-item
slim
density="comfortable"
:prepend-icon="$globals.icons.silverwareForkKnife"
:title="$t('new-recipe.add-recipe')"
@click="addRecipe"
/>
<v-list-item
slim
density="comfortable"
:prepend-icon="$globals.icons.create"
:title="$t('new-recipe.bulk-add')"
@click="showBulkAdd"
/>
</v-list>
</v-menu>
</div>
</div>
</div>
</template>
@@ -85,16 +130,18 @@
<script setup lang="ts">
import { VueDraggable } from "vue-draggable-plus";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe } from "~/lib/api/types/recipe";
import type { Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
import { usePageState } from "~/composables/recipe-page/shared-state";
import { uuid4 } from "~/composables/use-utils";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const ingredientsWithRecipe = new Map<string, boolean>();
const i18n = useI18n();
const drag = ref(false);
const domBulkAddDialog = ref<InstanceType<typeof RecipeDialogBulkAdd> | null>(null);
const { toggleIsParsing } = usePageState(recipe.value.slug);
const hasFoodOrUnit = computed(() => {
@@ -118,6 +165,22 @@ const parserToolTip = computed(() => {
return i18n.t("recipe.parse-ingredients");
});
function showBulkAdd() {
domBulkAddDialog.value?.open();
}
function ingredientIsRecipe(ingredient: RecipeIngredient): boolean {
if (ingredient.referencedRecipe) {
return true;
}
if (ingredient.referenceId) {
return !!ingredientsWithRecipe.get(ingredient.referenceId);
}
return false;
}
function addIngredient(ingredients: Array<string> | null = null) {
if (ingredients?.length) {
const newIngredients = ingredients.map((x) => {
@@ -150,6 +213,41 @@ function addIngredient(ingredients: Array<string> | null = null) {
}
}
function addRecipe(recipes: Array<string> | null = null) {
const refId = uuid4();
ingredientsWithRecipe.set(refId, true);
if (recipes?.length) {
const newRecipes = recipes.map((x) => {
return {
referenceId: refId,
title: "",
note: x,
unit: undefined,
referencedRecipe: undefined,
quantity: 1,
};
});
if (newRecipes) {
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
recipe.value.recipeIngredient.push(...newRecipes);
}
}
else {
recipe.value.recipeIngredient.push({
referenceId: refId,
title: "",
note: "",
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
unit: undefined,
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
referencedRecipe: undefined,
quantity: 1,
});
}
}
function insertNewIngredient(dest: number) {
recipe.value.recipeIngredient.splice(dest, 0, {
referenceId: uuid4(),
@@ -163,3 +261,17 @@ function insertNewIngredient(dest: number) {
});
}
</script>
<style scoped>
.split-main {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.split-dropdown {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
min-width: 30px;
padding-left: 0;
padding-right: 0;
}
</style>

View File

@@ -196,7 +196,7 @@ import { VueDraggable } from "vue-draggable-plus";
import type { IngredientFood, IngredientUnit, ParsedIngredient, RecipeIngredient } from "~/lib/api/types/recipe";
import type { Parser } from "~/lib/api/user/recipes/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useAppInfo, useUserApi } from "~/composables/api";
import { useUserApi } from "~/composables/api";
import { parseIngredientText } from "~/composables/recipes";
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
import { useGlobalI18n } from "~/composables/use-global-i18n";
@@ -213,9 +213,9 @@ const emit = defineEmits<{
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
}>();
const { $appInfo } = useNuxtApp();
const i18n = useGlobalI18n();
const api = useUserApi();
const appInfo = useAppInfo();
const drag = ref(false);
const unitStore = useUnitStore();
@@ -238,7 +238,7 @@ const availableParsers = computed(() => {
{
text: i18n.t("recipe.parser.openai-parser"),
value: "openai",
hide: !appInfo.value?.enableOpenai,
hide: !$appInfo.enableOpenai,
},
];
});
@@ -268,6 +268,11 @@ const state = reactive({
function shouldReview(ing: ParsedIngredient): boolean {
console.debug(`Checking if ingredient needs review (input="${ing.input})":`, ing);
if (ing.ingredient.referencedRecipe) {
console.debug("No review needed for sub-recipe ingredient");
return false;
}
if ((ing.confidence?.average || 0) < confidenceThreshold) {
console.debug("Needs review due to low confidence:", ing.confidence?.average);
return true;
@@ -364,12 +369,21 @@ async function parseIngredients() {
}
state.loading.parser = true;
try {
const ingsAsString = props.ingredients.map(ing => parseIngredientText(ing, 1, false) ?? "");
const ingsAsString = props.ingredients
.filter(ing => !ing.referencedRecipe)
.map(ing => parseIngredientText(ing, 1, false) ?? "");
const { data, error } = await api.recipes.parseIngredients(parser.value, ingsAsString);
if (error || !data) {
throw new Error("Failed to parse ingredients");
}
parsedIngs.value = data;
const parsed = data ?? [];
const recipeRefs = props.ingredients.filter(ing => ing.referencedRecipe).map(ing => ({
input: ing.note || "",
confidence: {},
ingredient: ing,
}));
parsedIngs.value = [...parsed, ...recipeRefs];
state.currentParsedIndex = -1;
state.allReviewed = false;
createdUnits.clear();

View File

@@ -262,32 +262,55 @@ const ingredientSections = computed<IngredientSection[]>(() => {
if (!props.recipe.recipeIngredient) {
return [];
}
return props.recipe.recipeIngredient.reduce((sections, ingredient) => {
// if title append new section to the end of the array
if (ingredient.title) {
sections.push({
sectionName: ingredient.title,
ingredients: [ingredient],
});
return sections;
const addIngredientsToSections = (ingredients: RecipeIngredient[], sections: IngredientSection[], title: string | null) => {
// If title is set, ensure the section exists before adding ingredients
let section: IngredientSection | undefined;
if (title) {
section = sections.find(sec => sec.sectionName === title);
if (!section) {
section = { sectionName: title, ingredients: [] };
sections.push(section);
}
}
// append new section if first
if (sections.length === 0) {
sections.push({
sectionName: "",
ingredients: [ingredient],
});
ingredients.forEach((ingredient) => {
if (preferences.value.expandChildRecipes && ingredient.referencedRecipe?.recipeIngredient?.length) {
// Recursively add to the section for this referenced recipe
addIngredientsToSections(
ingredient.referencedRecipe.recipeIngredient,
sections,
"",
);
}
else {
const sectionName = title || ingredient.title || "";
if (sectionName) {
let sec = sections.find(sec => sec.sectionName === sectionName);
if (!sec) {
sec = { sectionName, ingredients: [] };
sections.push(sec);
}
ingredient.title = sectionName;
sec.ingredients.push(ingredient);
}
else {
if (sections.length === 0) {
sections.push({
sectionName: "",
ingredients: [ingredient],
});
}
else {
sections[sections.length - 1].ingredients.push(ingredient);
}
}
}
});
};
return sections;
}
// otherwise add ingredient to last section in the array
sections[sections.length - 1].ingredients.push(ingredient);
return sections;
}, [] as IngredientSection[]);
const sections: IngredientSection[] = [];
addIngredientsToSections(props.recipe.recipeIngredient, sections, null);
return sections;
});
// Group instructions by section so we can style them independently

View File

@@ -5,6 +5,7 @@
:title="$t('recipe.edit-timeline-event')"
:icon="$globals.icons.edit"
can-submit
disable-submit-on-enter
:submit-text="$t('general.save')"
@submit="submitEdit"
>

View File

@@ -5,7 +5,7 @@
<v-icon class="mr-1">
{{ $globals.icons.calendar }}
</v-icon>
{{ new Date(event.timestamp).toLocaleDateString($i18n.locale) }}
{{ $d(new Date(event.timestamp)) }}
</v-chip>
</template>
<v-card
@@ -22,7 +22,7 @@
<v-col v-if="useMobileFormat" align-self="center" class="pr-0">
<v-chip label>
<v-icon> {{ $globals.icons.calendar }} </v-icon>
{{ new Date(event.timestamp || "").toLocaleDateString($i18n.locale) }}
{{ $d(new Date(event.timestamp || "")) }}
</v-chip>
</v-col>
<v-col v-else cols="9" style="margin: auto; text-align: center">
@@ -119,7 +119,7 @@ defineEmits<{
const { $globals } = useNuxtApp();
const display = useDisplay();
const { recipeTimelineEventImage } = useStaticRoutes();
const { recipeTimelineEventSmallImage } = useStaticRoutes();
const { eventTypeOptions } = useTimelineEventTypes();
const { user: currentUser } = useMealieAuth();
@@ -173,7 +173,7 @@ const eventImageUrl = computed<string>(() => {
return "";
}
return recipeTimelineEventImage(props.event.recipeId, props.event.id);
return recipeTimelineEventSmallImage(props.event.recipeId, props.event.id);
});
</script>

View File

@@ -130,9 +130,8 @@
<v-col cols="auto">
<div class="text-caption font-weight-light font-italic">
{{ $t("shopping-list.completed-on", {
date: new Date(listItem.updatedAt
|| "").toLocaleDateString($i18n.locale) })
}}
date: listItem.updatedAt ? $d(new Date(listItem.updatedAt)) : '',
}) }}
</div>
</v-col>
</v-row>

View File

@@ -97,7 +97,6 @@
<script lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state";
import type { SideBarLink } from "~/types/application-types";
import { useAppInfo } from "~/composables/api";
import { useCookbookPreferences } from "~/composables/use-users/preferences";
import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store";
import type { ReadCookBook } from "~/lib/api/types/cookbook";
@@ -105,7 +104,7 @@ import type { ReadCookBook } from "~/lib/api/types/cookbook";
export default defineNuxtComponent({
setup() {
const i18n = useI18n();
const { $globals } = useNuxtApp();
const { $appInfo, $globals } = useNuxtApp();
const display = useDisplay();
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
@@ -135,9 +134,7 @@ export default defineNuxtComponent({
return [];
});
const appInfo = useAppInfo();
const showImageImport = computed(() => appInfo.value?.enableOpenaiImageServices);
const showImageImport = computed(() => $appInfo.enableOpenaiImageServices);
const languageDialog = ref<boolean>(false);
const sidebar = ref<boolean>(false);

View File

@@ -149,6 +149,6 @@ export default defineNuxtComponent({
<style scoped>
.v-toolbar {
z-index: 1010 !important;
z-index: 2010 !important;
}
</style>

View File

@@ -59,7 +59,6 @@
<BaseButton
v-if="canDelete"
delete
secondary
@click="deleteEvent"
/>
<BaseButton

View File

@@ -7,6 +7,7 @@
item-title="name"
return-object
:items="items"
:custom-filter="normalizeFilter"
:prepend-icon="icon || $globals.icons.tags"
auto-select-first
clearable
@@ -52,6 +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";
export default defineNuxtComponent({
props: {
@@ -122,6 +124,7 @@ export default defineNuxtComponent({
itemIdVal,
searchInput,
emitCreate,
normalizeFilter,
};
},
});

View File

@@ -9,6 +9,7 @@
<v-autocomplete
v-model="selectedLocale"
:items="locales"
:custom-filter="normalizeFilter"
item-title="name"
item-value="value"
class="my-3"
@@ -44,6 +45,7 @@
<script lang="ts">
import { useLocales } from "~/composables/use-locales";
import { normalizeFilter } from "~/composables/use-utils";
export default defineNuxtComponent({
props: {
@@ -83,6 +85,7 @@ export default defineNuxtComponent({
locale,
selectedLocale,
onLocaleSelect,
normalizeFilter,
};
},
});

View File

@@ -29,7 +29,7 @@ export default defineNuxtComponent({
"ul", "ol", "li", "dl", "dt", "dd", "abbr", "a", "img", "blockquote", "iframe",
"del", "ins", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "colgroup",
],
ADD_ATTR: [
ALLOWED_ATTR: [
"href", "src", "alt", "height", "width", "class", "allow", "title", "allowfullscreen", "frameborder",
"scrolling", "cite", "datetime", "name", "abbr", "target", "border",
],

View File

@@ -1,3 +1,2 @@
export { useAppInfo } from "./use-app-info";
export { useStaticRoutes } from "./static-routes";
export { useAdminApi, usePublicApi, usePublicExploreApi, useUserApi } from "./api-client";

View File

@@ -1,14 +0,0 @@
import type { AppInfo } from "~/lib/api/types/admin";
export function useAppInfo(): Ref<AppInfo | null> {
const i18n = useI18n();
const { $axios } = useNuxtApp();
$axios.defaults.headers.common["Accept-Language"] = i18n.locale.value;
const { data: appInfo } = useAsyncData("app-info", async () => {
const data = await $axios.get<AppInfo>("/api/app/about");
return data.data;
});
return appInfo;
}

View File

@@ -1,9 +1,19 @@
import { alert } from "~/composables/use-toast";
import { useGlobalI18n } from "~/composables/use-global-i18n";
export function useDownloader() {
function download(url: string, filename: string) {
useFetch(url, {
method: "GET",
responseType: "blob",
onResponse({ response }) {
if (!response.ok) {
console.error("Download failed", response);
const i18n = useGlobalI18n();
alert.error(i18n.t("events.something-went-wrong"));
return;
}
const url = window.URL.createObjectURL(new Blob([response._data]));
const link = document.createElement("a");
link.href = url;

View File

@@ -0,0 +1,58 @@
import { describe, expect, test, vi } from "vitest";
import { ref } from "vue";
import { useStoreActions } from "./use-actions-factory";
import type { BaseCRUDAPI } from "~/lib/api/base/base-clients";
describe("useStoreActions", () => {
const mockApi = {
getAll: vi.fn(),
createOne: vi.fn(),
updateOne: vi.fn(),
deleteOne: vi.fn(),
} as unknown as BaseCRUDAPI<unknown, unknown, unknown>;
const mockStore = ref([]);
const mockLoading = ref(false);
test("deleteMany calls deleteOne for each ID and refreshes once", async () => {
const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading);
mockApi.deleteOne = vi.fn().mockResolvedValue({ response: { data: {} } });
mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } });
const ids = ["1", "2", "3"];
await actions.deleteMany(ids);
expect(mockApi.deleteOne).toHaveBeenCalledTimes(3);
expect(mockApi.deleteOne).toHaveBeenCalledWith("1");
expect(mockApi.deleteOne).toHaveBeenCalledWith("2");
expect(mockApi.deleteOne).toHaveBeenCalledWith("3");
expect(mockApi.getAll).toHaveBeenCalledTimes(1);
});
test("deleteMany handles empty array", async () => {
const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading);
mockApi.deleteOne = vi.fn();
mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } });
await actions.deleteMany([]);
expect(mockApi.deleteOne).not.toHaveBeenCalled();
expect(mockApi.getAll).toHaveBeenCalledTimes(1);
});
test("deleteMany sets loading state", async () => {
const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading);
mockApi.deleteOne = vi.fn().mockResolvedValue({});
mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } });
const promise = actions.deleteMany(["1"]);
expect(mockLoading.value).toBe(true);
await promise;
expect(mockLoading.value).toBe(false);
});
});

View File

@@ -12,6 +12,7 @@ interface StoreActions<T extends BoundT> extends ReadOnlyStoreActions<T> {
createOne(createData: T): Promise<T | null>;
updateOne(updateData: T): Promise<T | null>;
deleteOne(id: string | number): Promise<T | null>;
deleteMany(ids: (string | number)[]): Promise<void>;
}
/**
@@ -165,11 +166,23 @@ export function useStoreActions<T extends BoundT>(
return response?.data || null;
}
async function deleteMany(ids: (string | number)[]) {
loading.value = true;
for (const id of ids) {
await api.deleteOne(id);
}
if (allRef?.value) {
await refresh();
}
loading.value = false;
}
return {
getAll,
refresh,
createOne,
updateOne,
deleteOne,
deleteMany,
};
}

View File

@@ -52,7 +52,7 @@ export const useStore = function <T extends BoundT>(
return await storeActions.refresh(1, -1, params);
},
flushStore() {
store = ref([]);
store.value = [];
},
};

View File

@@ -1,6 +1,6 @@
import DOMPurify from "isomorphic-dompurify";
import { useFraction } from "./use-fraction";
import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, RecipeIngredient } from "~/lib/api/types/recipe";
import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
const { frac } = useFraction();
@@ -36,8 +36,28 @@ function useUnitName(unit: CreateIngredientUnit | IngredientUnit | undefined, us
return returnVal;
}
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true) {
const { quantity, food, unit, note, title } = ingredient;
function useRecipeLink(recipe: Recipe | undefined, groupSlug: string | undefined): string | undefined {
if (!(recipe && recipe.slug && recipe.name && groupSlug)) {
return undefined;
}
return `<a href="/g/${groupSlug}/r/${recipe.slug}" target="_blank">${recipe.name}</a>`;
}
type ParsedIngredientText = {
quantity?: string;
unit?: string;
name?: string;
note?: string;
/**
* If the ingredient is a linked recipe, an HTML link to the referenced recipe, otherwise undefined.
*/
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;
@@ -63,14 +83,14 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1,
}
const unitName = useUnitName(unit || undefined, usePluralUnit);
const foodName = useFoodName(food || undefined, usePluralFood);
const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood);
return {
title: title ? sanitizeIngredientHTML(title) : undefined,
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
name: foodName ? sanitizeIngredientHTML(foodName) : undefined,
name: ingName ? sanitizeIngredientHTML(ingName) : undefined,
note: note ? sanitizeIngredientHTML(note) : undefined,
recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
};
}

View File

@@ -97,13 +97,8 @@ export function useShoppingListCrud(
.sort(sortCheckedItems);
}
// Update the item if it's checked, otherwise updateUncheckedListItems will handle it
if (item.checked) {
shoppingListItemActions.updateItem(item);
}
shoppingListItemActions.updateItem(item);
updateListItemOrder();
updateUncheckedListItems();
}
function deleteListItem(item: ShoppingListItemOut) {

View File

@@ -1,7 +1,31 @@
export { useCategoryStore, usePublicCategoryStore, useCategoryData } from "./use-category-store";
export { useFoodStore, usePublicFoodStore, useFoodData } from "./use-food-store";
export { useHouseholdStore, usePublicHouseholdStore } from "./use-household-store";
export { useLabelStore, useLabelData } from "./use-label-store";
export { useTagStore, usePublicTagStore, useTagData } from "./use-tag-store";
export { useToolStore, usePublicToolStore, useToolData } from "./use-tool-store";
export { useUnitStore, useUnitData } from "./use-unit-store";
import { resetCategoryStore } from "./use-category-store";
import { resetFoodStore } from "./use-food-store";
import { resetHouseholdStore } from "./use-household-store";
import { resetLabelStore } from "./use-label-store";
import { resetTagStore } from "./use-tag-store";
import { resetToolStore } from "./use-tool-store";
import { resetUnitStore } from "./use-unit-store";
import { resetCookbookStore } from "./use-cookbook-store";
import { resetUserStore } from "./use-user-store";
export { useCategoryStore, usePublicCategoryStore, useCategoryData, resetCategoryStore } from "./use-category-store";
export { useFoodStore, usePublicFoodStore, useFoodData, resetFoodStore } from "./use-food-store";
export { useHouseholdStore, usePublicHouseholdStore, resetHouseholdStore } from "./use-household-store";
export { useLabelStore, useLabelData, resetLabelStore } from "./use-label-store";
export { useTagStore, usePublicTagStore, useTagData, resetTagStore } from "./use-tag-store";
export { useToolStore, usePublicToolStore, useToolData, resetToolStore } from "./use-tool-store";
export { useUnitStore, useUnitData, resetUnitStore } from "./use-unit-store";
export { useCookbookStore, usePublicCookbookStore, resetCookbookStore } from "./use-cookbook-store";
export { useUserStore, resetUserStore } from "./use-user-store";
export function clearAllStores() {
resetCategoryStore();
resetFoodStore();
resetHouseholdStore();
resetLabelStore();
resetTagStore();
resetToolStore();
resetUnitStore();
resetCookbookStore();
resetUserStore();
}

View File

@@ -7,6 +7,12 @@ const store: Ref<RecipeCategory[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export function resetCategoryStore() {
store.value = [];
loading.value = false;
publicLoading.value = false;
}
export const useCategoryData = function () {
return useData<RecipeCategory>({
id: "",

View File

@@ -7,6 +7,12 @@ const cookbooks: Ref<ReadCookBook[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export function resetCookbookStore() {
cookbooks.value = [];
loading.value = false;
publicLoading.value = false;
}
export const useCookbookStore = function (i18n?: Composer) {
const api = useUserApi(i18n);
const store = useStore<ReadCookBook>("cookbook", cookbooks, loading, api.cookbooks);

View File

@@ -7,6 +7,12 @@ const store: Ref<IngredientFood[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export function resetFoodStore() {
store.value = [];
loading.value = false;
publicLoading.value = false;
}
export const useFoodData = function () {
return useData<IngredientFood>({
id: "",

View File

@@ -7,6 +7,12 @@ const store: Ref<HouseholdSummary[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export function resetHouseholdStore() {
store.value = [];
loading.value = false;
publicLoading.value = false;
}
export const useHouseholdStore = function (i18n?: Composer) {
const api = useUserApi(i18n);
return useReadOnlyStore<HouseholdSummary>("household", store, loading, api.households);

View File

@@ -6,6 +6,11 @@ import { useUserApi } from "~/composables/api";
const store: Ref<MultiPurposeLabelOut[]> = ref([]);
const loading = ref(false);
export function resetLabelStore() {
store.value = [];
loading.value = false;
}
export const useLabelData = function () {
return useData<MultiPurposeLabelOut>({
groupId: "",

View File

@@ -7,6 +7,12 @@ const store: Ref<RecipeTag[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export function resetTagStore() {
store.value = [];
loading.value = false;
publicLoading.value = false;
}
export const useTagData = function () {
return useData<RecipeTag>({
id: "",

View File

@@ -11,6 +11,12 @@ const store: Ref<RecipeTool[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export function resetToolStore() {
store.value = [];
loading.value = false;
publicLoading.value = false;
}
export const useToolData = function () {
return useData<RecipeToolWithOnHand>({
id: "",

View File

@@ -6,6 +6,11 @@ import { useUserApi } from "~/composables/api";
const store: Ref<IngredientUnit[]> = ref([]);
const loading = ref(false);
export function resetUnitStore() {
store.value = [];
loading.value = false;
}
export const useUnitData = function () {
return useData<IngredientUnit>({
id: "",

View File

@@ -7,6 +7,11 @@ import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients";
const store: Ref<UserSummary[]> = ref([]);
const loading = ref(false);
export function resetUserStore() {
store.value = [];
loading.value = false;
}
class GroupUserAPIReadOnly extends BaseCRUDAPIReadOnly<UserSummary> {
baseRoute = "/api/groups/members";
itemRoute = (idOrUsername: string | number) => `/groups/members/${idOrUsername}`;

View File

@@ -1,5 +1,6 @@
import { ref, computed } from "vue";
import type { UserOut } from "~/lib/api/types/user";
import { clearAllStores } from "~/composables/store";
interface AuthData {
value: UserOut | null;
@@ -23,10 +24,15 @@ const authUser = ref<UserOut | null>(null);
const authStatus = ref<"loading" | "authenticated" | "unauthenticated">("loading");
export const useAuthBackend = function (): AuthState {
const { $axios } = useNuxtApp();
const { $appInfo, $axios } = useNuxtApp();
const router = useRouter();
const tokenName = useRuntimeConfig().public.AUTH_TOKEN;
const tokenCookie = useCookie(tokenName);
const runtimeConfig = useRuntimeConfig();
const tokenName = runtimeConfig.public.AUTH_TOKEN;
const tokenCookie = useCookie(tokenName, {
maxAge: $appInfo.tokenTime * 60 * 60,
secure: $appInfo.production && window?.location?.protocol === "https:",
});
function setToken(token: string | null) {
tokenCookie.value = token;
@@ -96,6 +102,13 @@ export const useAuthBackend = function (): AuthState {
setToken(null);
authUser.value = null;
authStatus.value = "unauthenticated";
// Clear all cached store data to prevent data leakage between users
clearAllStores();
// Clear Nuxt's useAsyncData cache
clearNuxtData();
await router.push(callbackUrl || "/login");
}
}
@@ -115,30 +128,6 @@ export const useAuthBackend = function (): AuthState {
}
}
// Auto-refresh user data periodically when authenticated
if (import.meta.client) {
let refreshInterval: NodeJS.Timeout | null = null;
watch(() => authStatus.value, (status) => {
if (status === "authenticated") {
refreshInterval = setInterval(() => {
if (tokenCookie.value) {
getSession().catch(() => {
// Ignore errors in background refresh
});
}
}, 5 * 60 * 1000); // 5 minutes
}
else {
// Clear interval when not authenticated
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
}
}, { immediate: true });
}
return {
data: computed(() => authUser.value),
status: computed(() => authStatus.value),

View File

@@ -15,6 +15,9 @@ export function usePlanTypeOptions() {
{ text: i18n.t("meal-plan.lunch"), value: "lunch" },
{ text: i18n.t("meal-plan.dinner"), value: "dinner" },
{ text: i18n.t("meal-plan.side"), value: "side" },
{ text: i18n.t("meal-plan.snack"), value: "snack" },
{ text: i18n.t("meal-plan.drink"), value: "drink" },
{ text: i18n.t("meal-plan.dessert"), value: "dessert" },
] as PlanOption[];
}

View File

@@ -21,19 +21,19 @@ export const LOCALES = [
{
name: "Українська (Ukrainian)",
value: "uk-UA",
progress: 57,
progress: 99,
dir: "ltr",
},
{
name: "Türkçe (Turkish)",
value: "tr-TR",
progress: 36,
progress: 39,
dir: "ltr",
},
{
name: "Svenska (Swedish)",
value: "sv-SE",
progress: 65,
progress: 68,
dir: "ltr",
},
{
@@ -45,7 +45,7 @@ export const LOCALES = [
{
name: "Slovenščina (Slovenian)",
value: "sl-SI",
progress: 40,
progress: 41,
dir: "ltr",
},
{
@@ -57,19 +57,19 @@ export const LOCALES = [
{
name: "Pусский (Russian)",
value: "ru-RU",
progress: 44,
progress: 46,
dir: "ltr",
},
{
name: "Română (Romanian)",
value: "ro-RO",
progress: 37,
progress: 41,
dir: "ltr",
},
{
name: "Português (Portuguese)",
value: "pt-PT",
progress: 38,
progress: 39,
dir: "ltr",
},
{
@@ -81,19 +81,19 @@ export const LOCALES = [
{
name: "Polski (Polish)",
value: "pl-PL",
progress: 43,
progress: 52,
dir: "ltr",
},
{
name: "Norsk (Norwegian)",
value: "no-NO",
progress: 40,
progress: 41,
dir: "ltr",
},
{
name: "Nederlands (Dutch)",
value: "nl-NL",
progress: 52,
progress: 55,
dir: "ltr",
},
{
@@ -117,31 +117,31 @@ export const LOCALES = [
{
name: "日本語 (Japanese)",
value: "ja-JP",
progress: 37,
progress: 36,
dir: "ltr",
},
{
name: "Italiano (Italian)",
value: "it-IT",
progress: 46,
progress: 47,
dir: "ltr",
},
{
name: "Íslenska (Icelandic)",
value: "is-IS",
progress: 27,
progress: 44,
dir: "ltr",
},
{
name: "Magyar (Hungarian)",
value: "hu-HU",
progress: 46,
progress: 47,
dir: "ltr",
},
{
name: "Hrvatski (Croatian)",
value: "hr-HR",
progress: 28,
progress: 29,
dir: "ltr",
},
{
@@ -159,13 +159,13 @@ export const LOCALES = [
{
name: "Français (French)",
value: "fr-FR",
progress: 67,
progress: 69,
dir: "ltr",
},
{
name: "Français canadien (Canadian French)",
value: "fr-CA",
progress: 38,
progress: 99,
dir: "ltr",
},
{
@@ -177,19 +177,19 @@ export const LOCALES = [
{
name: "Suomi (Finnish)",
value: "fi-FI",
progress: 40,
progress: 41,
dir: "ltr",
},
{
name: "Eesti (Estonian)",
value: "et-EE",
progress: 36,
progress: 47,
dir: "ltr",
},
{
name: "Español (Spanish)",
value: "es-ES",
progress: 45,
progress: 46,
dir: "ltr",
},
{
@@ -207,19 +207,19 @@ export const LOCALES = [
{
name: "Ελληνικά (Greek)",
value: "el-GR",
progress: 41,
progress: 42,
dir: "ltr",
},
{
name: "Deutsch (German)",
value: "de-DE",
progress: 95,
progress: 97,
dir: "ltr",
},
{
name: "Dansk (Danish)",
value: "da-DK",
progress: 45,
progress: 52,
dir: "ltr",
},
{
@@ -231,13 +231,13 @@ export const LOCALES = [
{
name: "Català (Catalan)",
value: "ca-ES",
progress: 38,
progress: 39,
dir: "ltr",
},
{
name: "Български (Bulgarian)",
value: "bg-BG",
progress: 47,
progress: 51,
dir: "ltr",
},
{

View File

@@ -1,5 +1,5 @@
import { ref, watch, computed } from "vue";
import { useAuthBackend } from "~/composables/useAuthBackend";
import { useAuthBackend } from "~/composables/use-auth-backend";
import type { UserOut } from "~/lib/api/types/user";
export const useMealieAuth = function () {

View File

@@ -168,6 +168,7 @@ export function useQueryFilterBuilder() {
|| type === Organizer.Tool
|| type === Organizer.Food
|| type === Organizer.Household
|| type === Organizer.User
);
};

View File

@@ -30,6 +30,7 @@ interface RecipeExplorerSearchState {
requireAllTags: boolean;
requireAllTools: boolean;
requireAllFoods: boolean;
randomSeed: number;
}>;
selectedCategories: Ref<NoUndefinedField<RecipeCategory>[]>;
selectedFoods: Ref<IngredientFood[]>;
@@ -41,6 +42,7 @@ interface RecipeExplorerSearchState {
reset: () => void;
toggleOrderDirection: () => void;
setOrderBy: (value: string) => void;
setRandomOrderBy: () => void;
filterItems: (item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) => void;
initialize: () => Promise<void>;
}
@@ -67,6 +69,7 @@ function createRecipeExplorerSearchState(groupSlug: ComputedRef<string>): Recipe
requireAllTags: false,
requireAllTools: false,
requireAllFoods: false,
randomSeed: 0,
});
// Store references
@@ -131,9 +134,16 @@ function createRecipeExplorerSearchState(groupSlug: ComputedRef<string>): Recipe
return {
...passedQuery.value,
_searchSeed: Date.now().toString(),
_randomSeed: state.value.randomSeed,
};
});
// Update the seed to trigger a new search
function setRandomOrderBy() {
state.value.orderBy = "random";
state.value.randomSeed = Date.now();
}
// Wait utility for async hydration
function waitUntilAndExecute(
condition: () => boolean,
@@ -442,6 +452,7 @@ function createRecipeExplorerSearchState(groupSlug: ComputedRef<string>): Recipe
reset,
toggleOrderDirection,
setOrderBy,
setRandomOrderBy,
filterItems,
initialize,
};

View File

@@ -112,34 +112,46 @@ export function useShoppingListItemActions(shoppingListId: string) {
async function getList() {
const response = await api.shopping.lists.getOne(shoppingListId);
if (!isOnline.value && response.data) {
if (response.data) {
// Merge pending local changes (both online and offline)
const createAndUpdateQueues = mergeListItemsByLatest(queue.update, queue.create);
response.data.listItems = mergeListItemsByLatest(response.data.listItems ?? [], createAndUpdateQueues);
const deleteQueueIds = new Set(queue.delete.map(item => item.id));
const filteredLocalChanges = createAndUpdateQueues.filter(item => !deleteQueueIds.has(item.id));
let mergedItems = mergeListItemsByLatest(response.data.listItems ?? [], filteredLocalChanges);
mergedItems = mergedItems.filter(item => !deleteQueueIds.has(item.id));
response.data.listItems = mergedItems;
}
return response.data;
}
function createItem(item: ShoppingListItemOut) {
removeFromQueue(queue.create, item);
removeFromQueue(queue.update, item);
removeFromQueue(queue.delete, item);
queue.create.push(item);
}
function updateItem(item: ShoppingListItemOut) {
const removedFromCreate = removeFromQueue(queue.create, item);
if (removedFromCreate) {
// this item hasn't been created yet, so we don't need to update it
queue.create.push(item);
return;
}
removeFromQueue(queue.update, item);
queue.update.push(item);
removeFromQueue(queue.delete, item);
if (removedFromCreate) {
// This item hasn't been created yet, so keep it in create queue with updated data
queue.create.push(item);
}
else {
queue.update.push(item);
}
}
function deleteItem(item: ShoppingListItemOut) {
const removedFromCreate = removeFromQueue(queue.create, item);
if (removedFromCreate) {
// this item hasn't been created yet, so we don't need to delete it
// This item hasn't been created yet, so we don't need to delete it
return;
}
@@ -198,10 +210,12 @@ export function useShoppingListItemActions(shoppingListId: string) {
try {
const itemsToProcess = [...queueItems];
const itemIdsToProcess = itemsToProcess.map(item => item.id);
await action(itemsToProcess)
.then(() => {
if (isOnline.value) {
clearQueueItems(itemQueueType, itemsToProcess.map(item => item.id));
clearQueueItems(itemQueueType, itemIdsToProcess);
}
});
}

View File

@@ -8,6 +8,7 @@ export interface UserPrintPreferences {
showDescription: boolean;
showNotes: boolean;
showNutrition: boolean;
expandChildRecipes: boolean;
}
export interface UserSearchQuery {
@@ -91,6 +92,7 @@ export function useUserPrintPreferences(): Ref<UserPrintPreferences> {
imagePosition: "left",
showDescription: true,
showNotes: true,
expandChildRecipes: false,
},
{ mergeDefaults: true },
// we cast to a Ref because by default it will return an optional type ref

View File

@@ -5,9 +5,9 @@ const userRatings = ref<UserRatingSummary[]>([]);
const loading = ref(false);
const ready = ref(false);
const $auth = useMealieAuth();
export const useUserSelfRatings = function () {
const $auth = useMealieAuth();
async function refreshUserRatings() {
if (!$auth.user.value || loading.value) {
return;

View File

@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import { normalize, normalizeFilter } from "./use-utils";
describe("test normalize", () => {
test("base case", () => {
expect(normalize("banana")).not.toEqual(normalize("Potatoes"));
});
test("diacritics", () => {
expect(normalize("Rátàtôuile")).toEqual("ratatouile");
});
test("ligatures", () => {
expect(normalize("IJ")).toEqual("ij");
expect(normalize("æ")).toEqual("ae");
expect(normalize("œ")).toEqual("oe");
expect(normalize("ff")).toEqual("ff");
expect(normalize("fi")).toEqual("fi");
expect(normalize("st")).toEqual("st");
});
});
describe("test normalize filter", () => {
test("base case", () => {
const patternA = "Escargots persillés";
const patternB = "persillés";
expect(normalizeFilter(patternA, patternB)).toBeTruthy();
expect(normalizeFilter(patternB, patternA)).toBeFalsy();
});
test("normalize", () => {
const value = "Cœur de bœuf";
const query = "coeur";
expect(normalizeFilter(value, query)).toBeTruthy();
});
});

View File

@@ -1,4 +1,5 @@
import { useDark, useToggle } from "@vueuse/core";
import type { FilterFunction } from "vuetify";
export const useToggleDarkMode = () => {
const isDark = useDark();
@@ -18,6 +19,38 @@ export const titleCase = function (str: string) {
.join(" ");
};
const replaceAllBuilder = (map: Map<string, string>): ((str: string) => string) => {
const re = new RegExp(Array.from(map.keys()).join("|"), "gi");
return str => str.replace(re, matched => map.get(matched)!);
};
const normalizeLigatures = replaceAllBuilder(new Map([
["œ", "oe"],
["æ", "ae"],
["ij", "ij"],
["ff", "ff"],
["fi", "fi"],
["fl", "fl"],
["st", "st"],
]));
export const normalize = (str: string) => {
if (!str) {
return "";
}
let normalized = str.normalize("NFKD").toLowerCase();
normalized = normalized.replace(/\p{Diacritic}/gu, "");
normalized = normalizeLigatures(normalized);
return normalized;
};
export const normalizeFilter: FilterFunction = (value: string, query: string) => {
const normalizedValue = normalize(value);
const normalizeQuery = normalize(query);
return normalizedValue.includes(normalizeQuery);
};
export function uuid4() {
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
(parseInt(c) ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (parseInt(c) / 4)))).toString(16),

View File

@@ -1,12 +1,13 @@
import type { RequestResponse } from "~/lib/api/types/non-generated";
import type { ValidationResponse } from "~/lib/api/types/response";
import { required, email, whitespace, url, minLength, maxLength } from "~/lib/validators";
import { required, email, whitespace, url, urlOptional, minLength, maxLength } from "~/lib/validators";
export const validators = {
required,
email,
whitespace,
url,
urlOptional,
minLength,
maxLength,
};

View File

@@ -4,10 +4,10 @@
"about-mealie": "Meer oor Mealie",
"api-docs": "API Dokumentasie",
"api-port": "API Poort",
"application-mode": "Applikasie modues",
"application-mode": "Applikasie Modus",
"database-type": "Databasis Tipe",
"database-url": "Databasis URL",
"default-group": "Standaard groep",
"default-group": "Standaard Groep",
"default-household": "Default Household",
"demo": "Demonstrasie",
"demo-status": "Demonstrasie Status",
@@ -65,7 +65,7 @@
"something-went-wrong": "Iets het verkeerd geloop!",
"subscribed-events": "Ingetekende Gebeure",
"test-message-sent": "Toets Boodskap Gestuur",
"message-sent": "Message Sent",
"message-sent": "Boodskap Gestuur",
"new-notification": "Nuwe kennisgewing",
"event-notifiers": "Gebeurteniskennisgewers",
"apprise-url-skipped-if-blank": "Apprise URL (oorgeslaan indien leeg)",
@@ -84,7 +84,7 @@
"label-events": "Label Events"
},
"general": {
"add": "Add",
"add": "Voeg by",
"cancel": "Kanselleer",
"clear": "Maak skoon",
"close": "Maak toe",
@@ -134,7 +134,7 @@
"no-recipe-found": "Geen resep gevind nie",
"ok": "OK",
"options": "Opsies:",
"plural-name": "Plural Name",
"plural-name": "Meervoudsnaam",
"print": "Druk",
"print-preferences": "Drukvoorkeure",
"random": "Willekeurig",
@@ -148,23 +148,23 @@
"save": "Stoor",
"settings": "Verstellings",
"share": "Deel",
"show-all": "Show All",
"show-all": "Wys Alles",
"shuffle": "Skommel",
"sort": "Sorteer",
"sort-ascending": "Sort Ascending",
"sort-descending": "Sort Descending",
"sort-ascending": "Sorteer Oplopend",
"sort-descending": "Sorteer Aflopend",
"sort-alphabetically": "Alfabeties",
"status": "Status",
"subject": "Onderwerp",
"submit": "Dien in",
"success-count": "Sukses: {count}",
"sunday": "Sondag",
"system": "System",
"system": "Sisteem",
"templates": "Sjablone:",
"test": "Toets",
"themes": "Temas",
"thursday": "Donderdag",
"title": "Title",
"title": "Titel",
"token": "Token",
"tuesday": "Dinsdag",
"type": "Tipe",
@@ -179,12 +179,12 @@
"units": "Eenhede",
"back": "Terug",
"next": "Volgende",
"start": "Start",
"start": "Begin",
"toggle-view": "Wissel aansig",
"date": "Datum",
"id": "Id",
"owner": "Eienaar",
"change-owner": "Change Owner",
"change-owner": "Verander Eienaar",
"date-added": "Datum bygevoeg",
"none": "Geen",
"run": "Hardloop",
@@ -212,13 +212,13 @@
"upload-file": "Laai dokument op",
"created-on-date": "Geskep op: {0}",
"unsaved-changes": "You have unsaved changes. Do you want to save before leaving? Okay to save, Cancel to discard changes.",
"clipboard-copy-failure": "Failed to copy to the clipboard.",
"confirm-delete-generic-items": "Are you sure you want to delete the following items?",
"organizers": "Organizers",
"clipboard-copy-failure": "Kon nie kopieer na die knipbord toe nie.",
"confirm-delete-generic-items": "Is jy seker jy wil die volgende items verwyder?",
"organizers": "Organiseerders",
"caution": "Caution",
"show-advanced": "Show Advanced",
"add-field": "Add Field",
"date-created": "Date Created",
"date-created": "Datum Geskep",
"date-updated": "Date Updated"
},
"group": {
@@ -342,6 +342,9 @@
"breakfast": "Ontbyt",
"lunch": "Middagete",
"dinner": "Aandete",
"snack": "Snack",
"drink": "Drank",
"dessert": "Nagereg",
"type-any": "Enige",
"day-any": "Enige",
"editor": "Editor",
@@ -438,17 +441,20 @@
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Plak jou resepdata in. Elke reël sal as 'n item in 'n lys hanteer word",
"recipe-markup-specification": "Resep formaat spesifikasie",
"recipe-url": "Resep URL",
"recipe-html-or-json": "Recipe HTML or JSON",
"recipe-html-or-json": "Resep HTML of JSON",
"upload-a-recipe": "Laai 'n resep op",
"upload-individual-zip-file": "Laai 'n .zip-lêer op wat vanaf 'n ander Mealie-instansie uitgevoer is.",
"url-form-hint": "Kopieer en plak 'n skakel vanaf jou gunstelingresepwebwerf",
"copy-and-paste-the-source-url-of-your-data-optional": "Copy and paste the source URL of your data (optional)",
"view-scraped-data": "Bekyk opgespoorde data",
"trim-whitespace-description": "Knip voorste en agterste witspasie sowel as leë reëls",
"trim-prefix-description": "Knip die eerste karakter van elke reël af",
"split-by-numbered-line-description": "Pogings om 'n paragraaf te verdeel deur die '1)' of '1.' patrone om te gebruik",
"import-by-url": "Voer 'n resep vanaf 'n webwerf in",
"create-manually": "Skep 'n resep met die hand",
"make-recipe-image": "Maak dit die prentjie vir hierdie resep"
"make-recipe-image": "Maak dit die prentjie vir hierdie resep",
"add-food": "Add Food",
"add-recipe": "Voeg Resep By"
},
"page": {
"404-page-not-found": "404 Bladsy nie gevind nie",
@@ -515,6 +521,9 @@
"recipe-deleted": "Resep uitgevee",
"recipe-image": "Resep foto",
"recipe-image-updated": "Resep foto is opgedateer",
"delete-image": "Verwyder Resep Beeld",
"delete-image-confirmation": "Is jy seker jy wil dié beeld van die resep verwyder?",
"recipe-image-deleted": "Recipe image deleted",
"recipe-name": "Resepnaam",
"recipe-settings": "Resep verstellings",
"recipe-update-failed": "Kon nie resep opdateer nie",
@@ -547,7 +556,7 @@
"add-to-plan": "Voeg by plan",
"add-to-timeline": "Voeg by tydlyn",
"recipe-added-to-list": "Resep by lys gevoeg",
"recipes-added-to-list": "Recipes added to list",
"recipes-added-to-list": "Resepte toegevoeg tot lys",
"successfully-added-to-list": "Successfully added to list",
"recipe-added-to-mealplan": "Resep is by die maaltydplan gevoeg",
"failed-to-add-recipes-to-list": "Failed to add recipe to list",
@@ -560,6 +569,7 @@
"choose-unit": "Kies 'n eenheid",
"press-enter-to-create": "Druk Enter om te skep",
"choose-food": "Keuse van kos",
"choose-recipe": "Kies Resep",
"notes": "Notas",
"toggle-section": "Wissel afdeling",
"see-original-text": "Sien oorspronklike teks",
@@ -587,6 +597,7 @@
"made-this": "Ek het dit gemaak",
"how-did-it-turn-out": "Hoe het dit uitgedraai?",
"user-made-this": "{user} het dit gemaak",
"made-for-recipe": "Made for {recipe}",
"added-to-timeline": "Added to timeline",
"failed-to-add-to-timeline": "Failed to add to timeline",
"failed-to-update-recipe": "Failed to update recipe",
@@ -626,6 +637,8 @@
"scrape-recipe-suggest-bulk-importer": "Try out the bulk importer",
"scrape-recipe-have-raw-html-or-json-data": "Have raw HTML or JSON data?",
"scrape-recipe-you-can-import-from-raw-data-directly": "You can import from raw data directly",
"scrape-recipe-website-being-blocked": "Website being blocked?",
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
"import-original-keywords-as-tags": "Voer oorspronklike sleutelwoorde as merkers in",
"stay-in-edit-mode": "Bly in redigeer modus",
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
@@ -647,7 +660,7 @@
"report-deletion-failed": "Kon nie verslag uitvee nie",
"recipe-debugger": "Resep debugger",
"recipe-debugger-description": "Gryp die URL van die resep wat jy wil debug en plak dit hier. Die URL sal deur die resepskraper geskraap word en die resultate sal vertoon word. As jy nie enige data terugstuur sien nie, word die webwerf wat jy probeer skraap nie deur Mealie of sy skraperbiblioteek ondersteun nie.",
"use-openai": "Use OpenAI",
"use-openai": "Gebruik OpenAI",
"recipe-debugger-use-openai-description": "Use OpenAI to parse the results instead of relying on the scraper library. When creating a recipe via URL, this is done automatically if the scraper library fails, but you may test it manually here.",
"debug": "Debug",
"tree-view": "Boomstruktuur",
@@ -658,8 +671,8 @@
"upload-image": "Laai prent",
"screen-awake": "Hou die skerm aan",
"remove-image": "Verwyder prent",
"nextStep": "Next step",
"recipe-actions": "Recipe Actions",
"nextStep": "Volgende stap",
"recipe-actions": "Resep Aksies",
"parser": {
"ingredient-parser": "Ingredient Parser",
"explanation": "To use the ingredient parser, click the 'Parse All' button to start the process. Once the processed ingredients are available, you can review the items and verify that they were parsed correctly. The model's confidence score is displayed on the right of the item title. This score is an average of all the individual scores and may not always be completely accurate.",
@@ -669,7 +682,7 @@
"brute-parser": "Brute Parser",
"openai-parser": "OpenAI Parser",
"parse-all": "Parse All",
"no-unit": "No unit",
"no-unit": "Geen eenheid",
"missing-unit": "Create missing unit: {unit}",
"missing-food": "Create missing food: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
@@ -680,15 +693,18 @@
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
"delete-item": "Delete Item"
"delete-item": "Verwyder Item"
},
"reset-servings-count": "Reset Servings Count",
"not-linked-ingredients": "Additional Ingredients",
"not-linked-ingredients": "Bykomende Bestanddele",
"upload-another-image": "Upload another image",
"upload-images": "Upload images",
"upload-images": "Laai beelde op",
"upload-more-images": "Upload more images",
"set-as-cover-image": "Set as recipe cover image",
"cover-image": "Cover image"
"cover-image": "Cover image",
"include-linked-recipes": "Include Linked Recipes",
"include-linked-recipe-ingredients": "Include Linked Recipe Ingredients",
"toggle-recipe": "Toggle Recipe"
},
"recipe-finder": {
"recipe-finder": "Recipe Finder",
@@ -725,7 +741,8 @@
"search-hint": "Druk '/'",
"advanced": "Gevorderd",
"auto-search": "Outomatiese soektog",
"no-results": "No results found"
"no-results": "No results found",
"type-to-search": "Type to search..."
},
"settings": {
"add-a-new-theme": "Voeg 'n nuwe tema by",
@@ -873,7 +890,7 @@
"oidc-ready": "OIDC Klar",
"oidc-ready-error-text": "Ikke alle OIDC værdier er konfigureret. Dette kan ignoreres hvis du ikke bruger OIDC godkendelse.",
"oidc-ready-success-text": "Krævede OIDC variabler er udfyldt.",
"openai-ready": "OpenAI Ready",
"openai-ready": "OpenAI Gereed",
"openai-ready-error-text": "Not all OpenAI Values are configured. This can be ignored if you are not using OpenAI features.",
"openai-ready-success-text": "Required OpenAI variables are all set."
},
@@ -1411,5 +1428,13 @@
"is-like": "is like",
"is-not-like": "is not like"
}
},
"validators": {
"required": "This Field is Required",
"invalid-email": "Email Must Be Valid",
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
}
}

View File

@@ -342,6 +342,9 @@
"breakfast": "الإفطار",
"lunch": "الغداء",
"dinner": "العشاء",
"snack": "Snack",
"drink": "Drink",
"dessert": "Dessert",
"type-any": "أي",
"day-any": "أي",
"editor": "المحرر",
@@ -442,13 +445,16 @@
"upload-a-recipe": "تحميل وصفة",
"upload-individual-zip-file": "تحميل مِلَفّ zip فردي تم تصديره من مثيل Malie آخر.",
"url-form-hint": "نسخ ولصق رابط من موقعك المفضل للوصفة",
"copy-and-paste-the-source-url-of-your-data-optional": "Copy and paste the source URL of your data (optional)",
"view-scraped-data": "عرض البيانات المحللة",
"trim-whitespace-description": "قص المسافات البيضاء البادئة واللاحقة وكذلك الأسطر الفارغة",
"trim-prefix-description": "قص الحرف الأول من كل سطر",
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns",
"import-by-url": "استيراد وصفة عن طريق عنوان URL",
"create-manually": "إنشاء وصفة يدوياً",
"make-recipe-image": "اجعل هذه صورة الوصفة"
"make-recipe-image": "اجعل هذه صورة الوصفة",
"add-food": "Add Food",
"add-recipe": "Add Recipe"
},
"page": {
"404-page-not-found": "404: لم يتم العثور على الصفحة",
@@ -515,6 +521,9 @@
"recipe-deleted": "تم حذف الوصفة",
"recipe-image": "صورة الوصفة",
"recipe-image-updated": "تم تحديث صورة الوصفة",
"delete-image": "Delete Recipe Image",
"delete-image-confirmation": "Are you sure you want to delete this recipe image?",
"recipe-image-deleted": "Recipe image deleted",
"recipe-name": "اسم الوصفة",
"recipe-settings": "إعدادات الوصفة",
"recipe-update-failed": "فشل تحديث الوصفة",
@@ -560,6 +569,7 @@
"choose-unit": "اختر الوحدة",
"press-enter-to-create": "",
"choose-food": "اختيار الطعام",
"choose-recipe": "Choose Recipe",
"notes": "ملاحظات",
"toggle-section": "",
"see-original-text": "عرض النص الأصلي",
@@ -587,6 +597,7 @@
"made-this": "لقد طبخت هذا",
"how-did-it-turn-out": "كيف كانت النتيجة؟",
"user-made-this": "{user} طبخ هذه",
"made-for-recipe": "Made for {recipe}",
"added-to-timeline": "Added to timeline",
"failed-to-add-to-timeline": "Failed to add to timeline",
"failed-to-update-recipe": "Failed to update recipe",
@@ -626,6 +637,8 @@
"scrape-recipe-suggest-bulk-importer": "جرب الإضافة بالجملة",
"scrape-recipe-have-raw-html-or-json-data": "هل لديك بيانات HTML أو JSON خام؟",
"scrape-recipe-you-can-import-from-raw-data-directly": "يمكنك الإضافة مباشرة باستخدام بيانات خام",
"scrape-recipe-website-being-blocked": "Website being blocked?",
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
"import-original-keywords-as-tags": "استيراد الكلمات المفتاحية الأصلية كوسوم",
"stay-in-edit-mode": "البقاء في وضع التعديل",
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
@@ -688,7 +701,10 @@
"upload-images": "Upload images",
"upload-more-images": "Upload more images",
"set-as-cover-image": "Set as recipe cover image",
"cover-image": "Cover image"
"cover-image": "Cover image",
"include-linked-recipes": "Include Linked Recipes",
"include-linked-recipe-ingredients": "Include Linked Recipe Ingredients",
"toggle-recipe": "Toggle Recipe"
},
"recipe-finder": {
"recipe-finder": "البحث عن الوصفات",
@@ -725,7 +741,8 @@
"search-hint": "اضغط '/'",
"advanced": "الإعدادات المتقدمة",
"auto-search": "البحث التلقائي",
"no-results": "لم يتم العثور على نتائج"
"no-results": "لم يتم العثور على نتائج",
"type-to-search": "Type to search..."
},
"settings": {
"add-a-new-theme": "إضافة سمة جديدة",
@@ -1411,5 +1428,13 @@
"is-like": "هو مثل",
"is-not-like": "ليس مثل"
}
},
"validators": {
"required": "This Field is Required",
"invalid-email": "Email Must Be Valid",
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
}
}

View File

@@ -4,25 +4,25 @@
"about-mealie": "Относно Mealie",
"api-docs": "API Документация",
"api-port": "API Порт",
"application-mode": "Режим на приложение",
"application-mode": "Статус на приложението",
"database-type": "Тип на база данни",
"database-url": "URL адрес база данни",
"default-group": "Група по подразбиране",
"default-household": "Домакинство по подразбиране",
"demo": "Демо",
"demo-status": "Статус на версията",
"demo-status": "Статус на демо-версията",
"development": "Разработване",
"docs": "Документи",
"download-log": "Дневник на изтеглянията",
"download-recipe-json": "Последно обработен json файл",
"github": "GitHub",
"log-lines": "Редове от лога",
"not-demo": "Не е демоверсия",
"not-demo": "Не е демо-версия",
"portfolio": "Портфолио",
"production": "Производствена среда",
"support": "Поддръжка",
"version": "Версия",
"unknown-version": "неизвестно",
"unknown-version": "неизвестен",
"sponsor": "Спонсори"
},
"asset": {
@@ -55,16 +55,16 @@
"database": "База данни",
"delete-event": "Изтриване на събитие",
"event-delete-confirmation": "Наистина ли искате да премахнете това събитие?",
"event-deleted": "Събитието изтрито",
"event-updated": "Събитие Обновено",
"event-deleted": "Събитието бе изтрито",
"event-updated": "Събитието бе обновено",
"new-notification-form-description": "Mealie използва библиотеката Apprise да генерира нотификации. Тя предлага много опции за услуги и нотификации. Прегледайте нейната Wiki страница за подробен гайд как да създадете URL за вашата услуга. Ако е налично, селектирайки типа на нотификацията може да са налични допълнителни функционалности.",
"new-version": "Налична е нова версия!",
"notification": "Известие",
"refresh": "Опресни",
"refresh": "Опресняване",
"scheduled": "Планирано",
"something-went-wrong": "Нещо се обърка!",
"subscribed-events": "Планирани събития",
"test-message-sent": "Тестово съобщение е изпратено",
"test-message-sent": "Тестовото съобщение бе изпратено",
"message-sent": "Съобщението е изпратено",
"new-notification": "Ново известие",
"event-notifiers": "Известия за събитие",
@@ -128,7 +128,7 @@
"message": "Съобщение",
"monday": "Понеделник",
"name": "Име",
"new": "Нов",
"new": "Добавяне",
"never": "няма данни",
"no": "Не",
"no-recipe-found": "Няма намерени рецепти",
@@ -145,7 +145,7 @@
"rename-object": "Преименувай {0}",
"reset": "По подразбиране",
"saturday": "Събота",
"save": "Запази",
"save": "Запазване",
"settings": "Настройки",
"share": "Споделяне",
"show-all": "Покажи всички",
@@ -159,13 +159,13 @@
"submit": "Изпрати",
"success-count": "Успешни: {count}",
"sunday": "Неделя",
"system": "Система",
"system": "В хронологичен ред",
"templates": "Шаблони:",
"test": "Тест",
"themes": "Теми",
"thursday": "четвъртък",
"title": "Заглавие",
"token": "Токън",
"token": "Токен",
"tuesday": "Вторник",
"type": "Тип",
"update": "Актуализация",
@@ -342,6 +342,9 @@
"breakfast": "Закуска",
"lunch": "Обяд",
"dinner": "Вечеря",
"snack": "Закуска",
"drink": "Питие",
"dessert": "Десерт",
"type-any": "Всички",
"day-any": "Всички",
"editor": "Редактор",
@@ -442,13 +445,16 @@
"upload-a-recipe": "Качи рецепта",
"upload-individual-zip-file": "Качи като индивидуален .zip файлов формат от друга инстанция на Mealie.",
"url-form-hint": "Копирай и постави линк от твоя любим сайт за рецепти",
"copy-and-paste-the-source-url-of-your-data-optional": "Copy and paste the source URL of your data (optional)",
"view-scraped-data": "Виж събраните данни",
"trim-whitespace-description": "Премахни интервалите в началото и края на текста, също така и празните редове",
"trim-prefix-description": "Премахни първия символ от всеки ред",
"split-by-numbered-line-description": "Опитва се да раздели параграф по '1)' or '1.' модел",
"import-by-url": "Импортиране на рецепта от линк",
"create-manually": "Създай рецепта ръчно",
"make-recipe-image": "Задай като изображението на рецептата"
"make-recipe-image": "Задай като изображението на рецептата",
"add-food": "Добавяне на продукт",
"add-recipe": "Добавяне на рецепта"
},
"page": {
"404-page-not-found": "404 Страницата не е намерена",
@@ -475,7 +481,7 @@
"categories": "Категории",
"cholesterol-content": "Холестерол",
"comment-action": "Коментирай",
"comment": "Коментар",
"comment": "Добавен коментар",
"comments": "Коментари",
"delete-confirmation": "Сигурни ли сте, че желаете да изтриете тази рецепта?",
"admin-delete-confirmation": "Ще изтриете рецепта, която не е ваша, използвайки администраторски права. Сигурни ли сте?",
@@ -494,8 +500,8 @@
"insert-ingredient": "Въведете съставка",
"insert-section": "Въведете раздел",
"insert-above": "Вмъкни отгоре",
"insert-below": "Вмъкни по-долу",
"instructions": "Инструкции",
"insert-below": "Вмъкни отдолу",
"instructions": "Начин на приготвяне",
"key-name-required": "Ключовото име е задължително",
"landscape-view-coming-soon": "Пейзажен изглед",
"milligrams": "милиграма",
@@ -510,11 +516,14 @@
"prep-time": "Време за подготовка",
"protein-content": "Белтъци",
"public-recipe": "Публична рецепта",
"recipe-created": "Рецептата е създадена",
"recipe-created": "Нова рецепта",
"recipe-creation-failed": "Създаването на рецепта беше неуспешно",
"recipe-deleted": "Рецептата е изтрита",
"recipe-image": "Изображение на рецептата",
"recipe-image-updated": "Изображението на рецептата беше обновено",
"delete-image": "Итриване на изображението на рецептата",
"delete-image-confirmation": "Сигурни ли сте, че желаете да изтриете изображението на рецептата?",
"recipe-image-deleted": "Изображението на рецептата беше изтрито",
"recipe-name": "Наименование",
"recipe-settings": "Настройки на рецептата",
"recipe-update-failed": "Обновяването на рецептата беше неуспешно",
@@ -543,8 +552,8 @@
"entry-type": "Тип на записа",
"date-format-hint": "MM/DD/YYYY формат",
"date-format-hint-yyyy-mm-dd": "YYYY-MM-DD формат",
"add-to-list": "Добавяне към списък",
"add-to-plan": "Добавяне към план",
"add-to-list": "Добавяне към списък за пазаруване",
"add-to-plan": "Добави към меню",
"add-to-timeline": "Добавяне към историята на събитията",
"recipe-added-to-list": "Рецептата е добавена към списъка",
"recipes-added-to-list": "Рецептите са добавени към списъка",
@@ -560,17 +569,18 @@
"choose-unit": "Избери мерна единица",
"press-enter-to-create": "Натисните Enter за да създадете",
"choose-food": "Избери продукт",
"choose-recipe": "Избор на рецепта",
"notes": "Бележки",
"toggle-section": "Превключване на раздела",
"toggle-section": "Създай раздел",
"see-original-text": "Виж оригиналния текст",
"original-text-with-value": "Оригинален текст: {originalText}",
"ingredient-linker": "Инструмент за свързване на съставки",
"unlinked": "Все още не е свързано",
"linked-to-other-step": "Свързано към друга стъпка",
"auto": "Автоматично",
"cook-mode": "Начин на приготвяне",
"link-ingredients": "Свържи съставките",
"merge-above": "Обедини с по-горната",
"cook-mode": "Инструкции",
"link-ingredients": "Свържи съставки",
"merge-above": "Обедини с по-горната стъпка",
"move-to-bottom": "Премести най-долу",
"move-to-top": "Премести най-горе",
"reset-scale": "Оригинален мащаб",
@@ -583,10 +593,11 @@
"timeline-is-empty": "Няма история на събитията. Опитайте да приготвите рецептата!",
"timeline-no-events-found-try-adjusting-filters": "Няма намерени събития. Опитайте да промените филтрите си за търсене.",
"group-global-timeline": "{groupName} История на събитията",
"open-timeline": "История на събитията",
"open-timeline": "Хронология на събитията",
"made-this": "Сготвих рецептата",
"how-did-it-turn-out": "Как се получи?",
"user-made-this": "{user} направи това",
"user-made-this": "{user} сготви",
"made-for-recipe": "Направено за {recipe}",
"added-to-timeline": "Добавено към историята на събитията",
"failed-to-add-to-timeline": "Неуспешно добавяне към историята на събитията",
"failed-to-update-recipe": "Неуспешно актуализиране на рецептата",
@@ -626,6 +637,8 @@
"scrape-recipe-suggest-bulk-importer": "Пробвайте масовото импорторане",
"scrape-recipe-have-raw-html-or-json-data": "Имате ли сурови HTML или JSON данни?",
"scrape-recipe-you-can-import-from-raw-data-directly": "Можете да импортирате директно от сурови данни",
"scrape-recipe-website-being-blocked": "Блокиран ли е уебсайтът?",
"scrape-recipe-try-importing-raw-html-instead": "Опитайте вместо това да импортирате суровия HTML код.",
"import-original-keywords-as-tags": "Добави оригиналните ключови думи като етикети",
"stay-in-edit-mode": "Остани в режим на редакция",
"parse-recipe-ingredients-after-import": "Анализиране на съставките на рецептата след импортиране",
@@ -688,7 +701,10 @@
"upload-images": "Качване на изображения",
"upload-more-images": "Качете още изображения",
"set-as-cover-image": "Задай като изображение на корицата на рецептата",
"cover-image": "Изображение на корицата"
"cover-image": "Изображение на корицата",
"include-linked-recipes": "Влючване на свързаните рецепти",
"include-linked-recipe-ingredients": "Включване на съставките от свързаните рецепти",
"toggle-recipe": "Вмъкни рецепта"
},
"recipe-finder": {
"recipe-finder": "Търсачка на рецепти",
@@ -725,7 +741,8 @@
"search-hint": "Натисни '/'",
"advanced": "Разширени",
"auto-search": "Автоматично търсене",
"no-results": "Не са намерени резултати"
"no-results": "Не са намерени резултати",
"type-to-search": "Въведете текст за търсене..."
},
"settings": {
"add-a-new-theme": "Добавяне на нова тема",
@@ -741,7 +758,7 @@
"delete-backup": "Изтрий резервно копие",
"error-creating-backup-see-log-file": "Грешка при създаването на резервно копие. Виж лог файла",
"full-backup": "Пълно резервно копие",
"import-summary": "Обобщение на импортирането",
"import-summary": "Преглед на импортирането",
"partial-backup": "Частично резервно копие",
"unable-to-delete-backup": "Невъзможно е да се изтрие това резервно копие.",
"experimental-description": "Резервните копия са моменти копия на базата данни и директорията за данни на сайта. Това включва цялата информация и е невъзможно да изключите определени раздели от информация. Може да гледате на това като моменти копия на Mealie за специфично време. Те служат като агностичен начин за експортиране на базата данни и импортиране на данни или архивиране на сайта към външна локация.",
@@ -792,7 +809,7 @@
"error-creating-theme-see-log-file": "Грешка при създаването на темата. Виж лог файла.",
"error-deleting-theme": "Грешка при изтриването на темата",
"error-updating-theme": "Грешка при актуализирането на темата",
"info": "Инфо",
"info": "Информация",
"light": "Светла",
"primary": "Основен",
"secondary": "Вторичен",
@@ -857,7 +874,7 @@
"failed": "Неуспешно",
"general-about": "Основни настройки",
"application-version": "Версия на приложението",
"application-version-error-text": "Вашата текуща версия ({0}) не съответства на най-новата версия. Обмисляте актуализиране до най-новата версия ({1}).",
"application-version-error-text": "Вашата текуща версия ({0}) не съответства на най-новата версия. Обмислете актуализиране до най-новата версия ({1}).",
"mealie-is-up-to-date": "Mealie е обновен до актуалната версия",
"secure-site": "Сигурен сайт",
"secure-site-error-text": "Сервирайте чрез localhost или защитено с https. Клипбордът и допълнителните API на браузъра може да не работят.",
@@ -936,7 +953,7 @@
},
"signup": {
"error-signing-up": "Грешка при регистирането",
"sign-up": "Регистриране",
"sign-up": "Регистрация",
"sign-up-link-created": "Линкът за регистриране е създаден",
"sign-up-link-creation-failed": "Линкът за регистриране не беше създаден",
"sign-up-links": "Линкове за регистриране",
@@ -958,14 +975,14 @@
"tag": "Етикет"
},
"tool": {
"tools": "Инструменти",
"tools": "Прибори",
"on-hand": "Наличности",
"create-a-tool": "Създаване на инструмент",
"tool-name": "Име на инструмента",
"create-new-tool": "Създаване на нов инструмент",
"on-hand-checkbox-label": "Показване като налични (отметнато)",
"required-tools": "Задължителни инструменти",
"tool": "Инструменти"
"tool": "Прибори"
},
"user": {
"admin": "Админ",
@@ -1005,8 +1022,8 @@
"password-strength": "Сигурността на паролата е {strength}",
"please-enter-password": "Моля, въведете новата си парола.",
"register": "Регистриране",
"reset-password": "Нулиране на паролата",
"sign-in": "Вписване",
"reset-password": "Забравена парола",
"sign-in": "Вход в системата",
"total-mealplans": "Хранителни планове общо",
"total-users": "Общо потребители",
"upload-photo": "Качете снимка",
@@ -1045,7 +1062,7 @@
"very-strong": "Много силна"
},
"user-management": "Управление на потребителя",
"reset-locked-users": "Нулиране на заключените потребители",
"reset-locked-users": "Отключване на заключените потребители",
"admin-user-creation": "Създаване на администратор",
"admin-user-management": "Управление на администраторите",
"user-details": "Детайли за потребителя",
@@ -1064,8 +1081,8 @@
"forgot-password": "Забравена Парола",
"forgot-password-text": "Въведете Вашият имейл адрес и ние ще ви изпратим линк, с който да промените Вашата парола.",
"changes-reflected-immediately": "Промените по този потребител ще бъдат отразени моментално.",
"default-activity": "Default Activity",
"default-activity-hint": "Select which page you'd like to navigate to upon logging in from this device"
"default-activity": "Действие по подразбиране",
"default-activity-hint": "Изберете коя страница искате да отворите, след като влезете от това устройство"
},
"language-dialog": {
"translated": "преведено",
@@ -1086,7 +1103,7 @@
"create-food": "Създай продукт",
"food-label": "Заглавие на храната",
"edit-food": "Редактирай храна",
"food-data": "Данни за храните",
"food-data": "Продукти",
"example-food-singular": "пример: Домат",
"example-food-plural": "пример: Домати",
"label-overwrite-warning": "Това ще присвои избрания етикет на всички избрани храни и евентуално ще презапише съществуващите ви етикети.",
@@ -1257,8 +1274,8 @@
"maintenance": {
"storage-details": "Подробности за мястото за съхранение",
"page-title": "Поддръжка на сайта",
"summary-title": "Обобщение",
"button-label-get-summary": "Вземи обобщение",
"summary-title": "Преглед на ресурсите",
"button-label-get-summary": "Опресняване",
"button-label-open-details": "Подробности",
"info-description-data-dir-size": "Размер на директорията с данни",
"info-description-log-file-size": "Размер на лог файла",
@@ -1327,7 +1344,7 @@
"invite-link": "Линк за Покана",
"get-invite-link": "Създай линк за покана",
"get-public-link": "Създай публичен линк",
"account-summary": "Обобщение на акаунта",
"account-summary": "Преглед на потребителския профил",
"account-summary-description": "Обобщение на информацията за Вашата група.",
"group-statistics": "Статистики на групата",
"group-statistics-description": "Вашата статистика на групата дава известна представа как използвате Mealie.",
@@ -1411,5 +1428,13 @@
"is-like": "е като",
"is-not-like": "не е като"
}
},
"validators": {
"required": "This Field is Required",
"invalid-email": "Email Must Be Valid",
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
}
}

View File

@@ -342,6 +342,9 @@
"breakfast": "Esmorzar",
"lunch": "Dinar",
"dinner": "Sopar",
"snack": "Piscolabis",
"drink": "Beguda",
"dessert": "Postres",
"type-any": "Qualsevol",
"day-any": "Qualsevol",
"editor": "Editor",
@@ -442,13 +445,16 @@
"upload-a-recipe": "Puja una recepta",
"upload-individual-zip-file": "Puja només un arxiu zip, exportat d'altre Mealie.",
"url-form-hint": "Copia i enganxa l'enllaç del teu lloc web de receptes preferit",
"copy-and-paste-the-source-url-of-your-data-optional": "Copy and paste the source URL of your data (optional)",
"view-scraped-data": "Visualitza les dades recuperades",
"trim-whitespace-description": "Elimina els espais a principi i final; i elimina les línies buides",
"trim-prefix-description": "Elimina el primer caràcter de cada línia",
"split-by-numbered-line-description": "Intenta separar per paràgrafs, utilitzant com a patró '1)' o '1.'",
"import-by-url": "Importa per URL",
"create-manually": "Crea una recepta manualment",
"make-recipe-image": "Fes-la la imatge de la recepta"
"make-recipe-image": "Fes-la la imatge de la recepta",
"add-food": "Afegeix Aliment",
"add-recipe": "Afegeix Recepta"
},
"page": {
"404-page-not-found": "404 - Pàgina no trobada",
@@ -478,7 +484,7 @@
"comment": "Comentari",
"comments": "Comentaris",
"delete-confirmation": "Estàs segur que vols suprimir-la?",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"admin-delete-confirmation": "Estàs a punt d'eliminar una recepta que no és teva utilitzant permisos d'administrador. N'estàs segur?",
"delete-recipe": "Suprimeix la recepta",
"description": "Descripció",
"disable-amount": "Oculta les quantitats",
@@ -515,6 +521,9 @@
"recipe-deleted": "S'ha suprimit la recepta",
"recipe-image": "Imatge de la recepta",
"recipe-image-updated": "S'ha actualitzat la imatge de la recepta",
"delete-image": "Suprimir la imatge de la recepta",
"delete-image-confirmation": "Estàs segur que vols suprimir la imatge d'aquesta recepta?",
"recipe-image-deleted": "S'ha suprimit la imatge de la recepta",
"recipe-name": "Nom de la recepta",
"recipe-settings": "Opcions de la recepta",
"recipe-update-failed": "S'ha produït un error a l'actualitzar la recepta",
@@ -560,6 +569,7 @@
"choose-unit": "Tria el tipus d'unitat",
"press-enter-to-create": "Premeu enter per a crear-lo",
"choose-food": "Tria un aliment",
"choose-recipe": "Tria la recepta",
"notes": "Notes",
"toggle-section": "Nova secció",
"see-original-text": "Mostra el text original",
@@ -587,14 +597,15 @@
"made-this": "Ho he fet",
"how-did-it-turn-out": "Com ha sortit?",
"user-made-this": "{user} ha fet això",
"made-for-recipe": "Made for {recipe}",
"added-to-timeline": "Added to timeline",
"failed-to-add-to-timeline": "Failed to add to timeline",
"failed-to-update-recipe": "Failed to update recipe",
"added-to-timeline-but-failed-to-add-image": "Added to timeline, but failed to add image",
"added-to-timeline-but-failed-to-add-image": "S'ha afegit a la línia de temps, però no s'ha pogut afegir la imatge",
"api-extras-description": "Els extres de receptes són una funcionalitat clau de l'API de Mealie. Permeten crear parells clau/valor JSON personalitzats dins una recepta, per referenciar-los des d'aplicacions de tercers. Pots emprar aquestes claus per proveir informació, per exemple per a desencadenar automatitzacions o missatges personlitzats per a propagar al teu dispositiu desitjat.",
"message-key": "Clau del missatge",
"parse": "Analitzar",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"ingredients-not-parsed-description": "Sembla que els teus ingredients encara no s'han analitzat. Feu clic al botó \"{parse}\" de sota per transformar els vostres ingredients en aliments estructurats.",
"attach-images-hint": "Afegeix imatges arrossegant i deixant anar la imatge a l'editor",
"drop-image": "Deixa anar la imatge",
"enable-ingredient-amounts-to-use-this-feature": "Habilita les quantitats d'ingredients per a poder fer servir aquesta característica",
@@ -612,10 +623,10 @@
"create-recipe-from-an-image": "Crear una recepta a partir d'una imatge",
"create-recipe-from-an-image-description": "Crear una recepta pujant una imatge d'ella. Mealie intentarà extreure el text de la imatge mitjançant IA i crear-ne la recepta.",
"crop-and-rotate-the-image": "Retalla i rota la imatge, per tal que només el text sigui visible, i estigui orientat correctament.",
"create-from-images": "Create from Images",
"create-from-images": "Crear una recepta a partir d'una imatge",
"should-translate-description": "Tradueix la recepta a la meva llengua",
"please-wait-image-procesing": "Si us plau, esperi, la imatge s'està processant. Això pot tardar un temps.",
"please-wait-images-processing": "Please wait, the images are processing. This may take some time.",
"please-wait-images-processing": "Espereu, les imatges s'estan processant. Això pot trigar una estona.",
"bulk-url-import": "Importació d'URL en massa",
"debug-scraper": "Rastrejador de depuració",
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Crea la recepta proporcionant-ne un nom. Totes les receptes han de tenir un nom únic.",
@@ -626,9 +637,11 @@
"scrape-recipe-suggest-bulk-importer": "Prova l'importador a granel",
"scrape-recipe-have-raw-html-or-json-data": "Teniu dades HTML o JSON pla?",
"scrape-recipe-you-can-import-from-raw-data-directly": "Podeu importar directament des de les dades planes",
"scrape-recipe-website-being-blocked": "Website being blocked?",
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
"import-original-keywords-as-tags": "Importa les paraules clau originals com a tags",
"stay-in-edit-mode": "Segueix en el mode d'edició",
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
"parse-recipe-ingredients-after-import": "Analitza els ingredients de la recepta després d'importar",
"import-from-zip": "Importa des d'un ZIP",
"import-from-zip-description": "Importa una sola recepta que ha estat importada d'una altra instància de Mealie.",
"import-from-html-or-json": "Importar des d'un HTML o JSON",
@@ -672,23 +685,26 @@
"no-unit": "Sense unitat",
"missing-unit": "Crear unitat que manca: {unit}",
"missing-food": "Crear menjar que manca: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"this-unit-could-not-be-parsed-automatically": "Aquesta unitat no s'ha pogut analitzar automàticament",
"this-food-could-not-be-parsed-automatically": "Aquest aliment no s'ha pogut analitzar automàticament",
"no-food": "Sense menjar",
"review-parsed-ingredients": "Review parsed ingredients",
"confidence-score": "Confidence Score",
"ingredient-parser-description": "Your ingredients have been successfully parsed. Please review the ingredients we're not sure about.",
"ingredient-parser-final-review-description": "Once all ingredients have been reviewed, you'll have one more chance to review all ingredients before applying the changes to your recipe.",
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
"delete-item": "Delete Item"
"review-parsed-ingredients": "Revisió d'ingredients analitzats",
"confidence-score": "Puntuació de confiança",
"ingredient-parser-description": "Els teus ingredients s'han analitzat correctament. Si us plau, revisa els ingredients dels quals no estem segurs.",
"ingredient-parser-final-review-description": "Un cop revisats tots els ingredients, tindràs una oportunitat més de revisar tots els ingredients abans d'aplicar els canvis a la teva recepta.",
"add-text-as-alias-for-item": "Afegeix \"{text}\" com a àlies de {item}",
"delete-item": "Eliminar element"
},
"reset-servings-count": "Reiniciar racions servides",
"not-linked-ingredients": "Ingredients addicionals",
"upload-another-image": "Upload another image",
"upload-images": "Upload images",
"upload-more-images": "Upload more images",
"set-as-cover-image": "Set as recipe cover image",
"cover-image": "Cover image"
"upload-another-image": "Puja una altra imatge",
"upload-images": "Puja imatges",
"upload-more-images": "Puja més imatges",
"set-as-cover-image": "Estableix com a imatge de portada de recepta",
"cover-image": "Imatge de portada",
"include-linked-recipes": "Inclou les receptes enllaçades",
"include-linked-recipe-ingredients": "Inclou els ingredients de la recepta enllaçada",
"toggle-recipe": "Alternar recepta"
},
"recipe-finder": {
"recipe-finder": "Cercador de receptes",
@@ -725,7 +741,8 @@
"search-hint": "Prem '/'",
"advanced": "Avançat",
"auto-search": "Cerca automàtica",
"no-results": "No s'han trobat resultats"
"no-results": "No s'han trobat resultats",
"type-to-search": "Escriviu per cercar..."
},
"settings": {
"add-a-new-theme": "Afegiu un nou tema",
@@ -1064,8 +1081,8 @@
"forgot-password": "Contrasenya oblidada",
"forgot-password-text": "Introdueix siusplau la teva adreça de correu electrònic i t'enviarem un enllaç per restablir la teva contrassenya.",
"changes-reflected-immediately": "Els canvis en aquest usuari s'actualitzaran immediatament.",
"default-activity": "Default Activity",
"default-activity-hint": "Select which page you'd like to navigate to upon logging in from this device"
"default-activity": "Activitat per defecte",
"default-activity-hint": "Seleccioneu a quina pàgina voleu navegar en iniciar sessió des d'aquest dispositiu"
},
"language-dialog": {
"translated": "traduït",
@@ -1183,7 +1200,7 @@
"group-details": "Detalls del grup",
"group-details-description": "Abans de crear un compte heu de crear un grup. Al grup només hi serà vostè, però després podeu convidar d'altres. Els membres d'un grup poden compartir menús, llistes de la compra, receptes i molt més!",
"use-seed-data": "Afegiu dades predeterminades",
"use-seed-data-description": "Mealie ships with a collection of Foods, Units, and Labels that can be used to populate your group with helpful data for organizing your recipes. These are translated into the language you currently have selected. You can always add to or modify this data later.",
"use-seed-data-description": "Mealie disposa d'una col·lecció d'aliments, unitats i etiquetes que es poden utilitzar per omplir el vostre grup amb dades útils per organitzar les vostres receptes. Aquests es tradueixen a l'idioma que heu seleccionat actualment. Sempre podeu afegir o modificar aquestes dades més endavant.",
"account-details": "Detalls del compte"
},
"validation": {
@@ -1411,5 +1428,13 @@
"is-like": "és com",
"is-not-like": "no és com"
}
},
"validators": {
"required": "This Field is Required",
"invalid-email": "Email Must Be Valid",
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
}
}

View File

@@ -342,6 +342,9 @@
"breakfast": "Snídaně",
"lunch": "Oběd",
"dinner": "Večeře",
"snack": "Snack",
"drink": "Drink",
"dessert": "Dessert",
"type-any": "Libovolné",
"day-any": "Libovolný",
"editor": "Editor",
@@ -442,13 +445,16 @@
"upload-a-recipe": "Nahrát recept",
"upload-individual-zip-file": "Nahrát individuální .zip soubor exportovaný z jiné instance Mealie.",
"url-form-hint": "Zkopírujte a vložte odkaz z vaší oblíbené stránky s recepty",
"copy-and-paste-the-source-url-of-your-data-optional": "Copy and paste the source URL of your data (optional)",
"view-scraped-data": "Zobrazit scrapovaná data",
"trim-whitespace-description": "Oříznout počáteční a koncové mezery stejně jako prázdné řádky",
"trim-prefix-description": "Oříznout první znak z každé řádky",
"split-by-numbered-line-description": "Pokusí se rozdělit odstavec na místech odpovídajících vzorům '1)' a '1.'",
"import-by-url": "Importovat recept podle URL",
"create-manually": "Vytvořit recept ručně",
"make-recipe-image": "Nastavit jako obrázek receptu"
"make-recipe-image": "Nastavit jako obrázek receptu",
"add-food": "Add Food",
"add-recipe": "Add Recipe"
},
"page": {
"404-page-not-found": "404 Stránka nebyla nalezena",
@@ -515,6 +521,9 @@
"recipe-deleted": "Recept smazán",
"recipe-image": "Obrázek receptu",
"recipe-image-updated": "Obrázek receptu aktualizován",
"delete-image": "Delete Recipe Image",
"delete-image-confirmation": "Are you sure you want to delete this recipe image?",
"recipe-image-deleted": "Recipe image deleted",
"recipe-name": "Název receptu",
"recipe-settings": "Nastavení receptu",
"recipe-update-failed": "Aktualizace receptu se nezdařila",
@@ -560,6 +569,7 @@
"choose-unit": "Vybrat jednotku",
"press-enter-to-create": "Stiskněte enter pro vytvoření",
"choose-food": "Zvolte jídlo",
"choose-recipe": "Choose Recipe",
"notes": "Poznámky",
"toggle-section": "Přidat/odebrat název sekce",
"see-original-text": "Zobrazit původní text",
@@ -587,6 +597,7 @@
"made-this": "Toto jsem uvařil",
"how-did-it-turn-out": "Jak to dopadlo?",
"user-made-this": "{user} udělal toto",
"made-for-recipe": "Made for {recipe}",
"added-to-timeline": "Přidáno na časovou osu",
"failed-to-add-to-timeline": "Přidání na časovou osu selhalo",
"failed-to-update-recipe": "Aktualizace receptu selhala",
@@ -626,6 +637,8 @@
"scrape-recipe-suggest-bulk-importer": "Vyzkoušejte hromadný import",
"scrape-recipe-have-raw-html-or-json-data": "Máte surová data HTML nebo JSON?",
"scrape-recipe-you-can-import-from-raw-data-directly": "Můžete importovat přímo ze surových dat",
"scrape-recipe-website-being-blocked": "Website being blocked?",
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
"import-original-keywords-as-tags": "Importovat původní klíčová slova jako štítky",
"stay-in-edit-mode": "Zůstat v režimu úprav",
"parse-recipe-ingredients-after-import": "Parse recipe ingredients after import",
@@ -688,7 +701,10 @@
"upload-images": "Nahrát obrázky",
"upload-more-images": "Nahrát více obrázků",
"set-as-cover-image": "Nastavit recept jako úvodní obrázek",
"cover-image": "Úvodní obrázek"
"cover-image": "Úvodní obrázek",
"include-linked-recipes": "Include Linked Recipes",
"include-linked-recipe-ingredients": "Include Linked Recipe Ingredients",
"toggle-recipe": "Toggle Recipe"
},
"recipe-finder": {
"recipe-finder": "Vyhledávač receptů",
@@ -725,7 +741,8 @@
"search-hint": "Stiskněte '/'",
"advanced": "Pokročilé",
"auto-search": "Automatické vyhledávání",
"no-results": "Nebyly nalezeny žádné výsledky"
"no-results": "Nebyly nalezeny žádné výsledky",
"type-to-search": "Type to search..."
},
"settings": {
"add-a-new-theme": "Přidat nový motiv",
@@ -1411,5 +1428,13 @@
"is-like": "je jako",
"is-not-like": "není jako"
}
},
"validators": {
"required": "This Field is Required",
"invalid-email": "Email Must Be Valid",
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
}
}

View File

@@ -8,7 +8,7 @@
"database-type": "Database-type",
"database-url": "Database-url",
"default-group": "Standardgruppe",
"default-household": "Standard Husholdning",
"default-household": "Standard Husstand",
"demo": "Demo",
"demo-status": "Demo status",
"development": "Udvikling",
@@ -248,7 +248,7 @@
"manage-members": "Administrer medlemmer",
"manage-members-description": "Administrer tilladelser for medlemmerne i din husstand. {manage} giver brugeren adgang til datastyringssiden, og {invite} giver brugeren mulighed for at generere invitationslinks til andre brugere. Gruppeejere kan ikke ændre deres egne tilladelser.",
"manage": "Administrer",
"manage-household": "Administrer husholdning",
"manage-household": "Administrer Husstand",
"invite": "Invitér",
"looking-to-update-your-profile": "Ønsker du at opdatere din profil?",
"default-recipe-preferences-description": "Dette er standardindstillingerne, når en ny opskrift oprettes i din gruppe. Indstillingerne kan ændres for en opskrift i menuen Opskriftindstillinger.",
@@ -278,30 +278,30 @@
"admin-group-management": "Administratorgruppeadministration",
"admin-group-management-text": "Ændringer i denne gruppe vil træde i kraft øjeblikkeligt.",
"group-id-value": "Gruppe-ID: {0}",
"total-households": "Husholdninger i Alt",
"total-households": "Husstande i Alt",
"you-must-select-a-group-before-selecting-a-household": "Du skal vælge en gruppe, før du vælger en husstand"
},
"household": {
"household": "Husholdning",
"households": "Husholdninger",
"user-household": "Husholdning",
"create-household": "Opret husholdning",
"household-name": "Husholdningens navn",
"household-group": "Husholdnings Gruppe",
"household-management": "Husholdningsadministration",
"manage-households": "Administrer husholdninger",
"admin-household-management": "Husholdningsadministration",
"household": "Husstand",
"households": "Husstande",
"user-household": "Bruger Husstand",
"create-household": "Opret Husstand",
"household-name": "Husstandens Navn",
"household-group": "Husstandens Gruppe",
"household-management": "Husstands Administration",
"manage-households": "Administrer Husstande",
"admin-household-management": "Admin Husstands Administration",
"admin-household-management-text": "Ændringer ved denne husholdning vil træde i kraft øjeblikkeligt.",
"household-id-value": "Id: {0}",
"private-household": "Privat husholdning",
"private-household-description": "Sættes din husholdning til private vil det deaktivere alle indstillinger for offentlig visning. Dette tilsidesætter individuelle indstillinger for offentlig visning",
"lock-recipe-edits-from-other-households": "Lås ændringer fra andre husholdninger",
"lock-recipe-edits-from-other-households-description": "Når aktiveret kan kun husholdningens brugere ændre dens opskrifter",
"household-recipe-preferences": "Husholdningens opskriftspræferencer",
"default-recipe-preferences-description": "Disse er standardindstillingerne, når en ny opskrift er oprettet i din husstand. Disse kan ændres for individuelle opskrifter i menuen Opsætninger.",
"household-id-value": "Husstand Id: {0}",
"private-household": "Privat Husstand",
"private-household-description": "Sættes din Husstand til privat, vil det deaktivere alle indstillinger for offentlig visning. Dette overskriver individuelle indstillinger for offentlig visning",
"lock-recipe-edits-from-other-households": "Lås opskriftredigeringer fra andre husstande",
"lock-recipe-edits-from-other-households-description": "Når denne funktion er aktiveret, kan kun brugere i din husstand redigere opskrifter, der er oprettet af din husstand.",
"household-recipe-preferences": "Husstandens opskriftspræferencer",
"default-recipe-preferences-description": "Dette er standardindstillingerne, når der oprettes en ny opskrift i din husstand. Disse kan ændres for individuelle opskrifter i menuen med opskriftindstillinger.",
"allow-users-outside-of-your-household-to-see-your-recipes": "Tillad brugere udenfor din husholdning at se dine opskrifter",
"allow-users-outside-of-your-household-to-see-your-recipes-description": "Når det er aktiveret kan du bruge et link til offentlig deling af specifikke opskrifter uden at godkende brugeren. Når deaktiveret, kan du kun dele opskrifter med brugere, der er i din husstand eller med et forudgenereret privat link",
"household-preferences": "Præferencer"
"allow-users-outside-of-your-household-to-see-your-recipes-description": "Når denne funktion er aktiveret, kan du bruge et offentligt delingslink til at dele bestemte opskrifter uden at godkende brugeren. Når funktionen er deaktiveret, kan du kun dele opskrifter med brugere, der bor i din husstand, eller med et forudgenereret privat link.",
"household-preferences": "Husstands præferencer"
},
"meal-plan": {
"create-a-new-meal-plan": "Opret madplan",
@@ -326,7 +326,7 @@
"mealplan-households-description": "Hvis ingen husstand er valgt, kan opskrifter tilføjes fra enhver husstand",
"any-category": "Alle kategorier",
"any-tag": "Alle tags",
"any-household": "Alle husholdninger",
"any-household": "Alle husstande",
"no-meal-plan-defined-yet": "Ingen madplaner er oprettet endnu",
"no-meal-planned-for-today": "Ingen ret er planlagt til i dag",
"numberOfDays-hint": "Antal dage ved sideindlæsning",
@@ -342,6 +342,9 @@
"breakfast": "Morgenmad",
"lunch": "Frokost",
"dinner": "Aftensmad",
"snack": "Snack",
"drink": "Drink",
"dessert": "Dessert",
"type-any": "Alle",
"day-any": "Alle",
"editor": "Redigeringsværktøj",
@@ -442,13 +445,16 @@
"upload-a-recipe": "Upload en opskrift",
"upload-individual-zip-file": "Upload en individuel .zip-fil, eksporteret fra en anden Mealie-instans.",
"url-form-hint": "Kopiér og indsæt et link fra din foretrukne opskrifts hjemmeside",
"copy-and-paste-the-source-url-of-your-data-optional": "Copy and paste the source URL of your data (optional)",
"view-scraped-data": "Vis dataudtræk",
"trim-whitespace-description": "Fjern indledende og efterfølgende mellemrum samt blanke linjer",
"trim-prefix-description": "Beskær første tegn fra hver linje",
"split-by-numbered-line-description": "Forsøger at opdele et afsnit ved at matche '1)' eller '1.' mønstre",
"import-by-url": "Importér opskrift fra en webside",
"create-manually": "Opret opskrift manuelt",
"make-recipe-image": "Gør dette til opskriftsbillede"
"make-recipe-image": "Gør dette til opskriftsbillede",
"add-food": "Tilføj Mad",
"add-recipe": "Tilføj opskrift"
},
"page": {
"404-page-not-found": "404 Siden blev ikke fundet",
@@ -515,6 +521,9 @@
"recipe-deleted": "Opskrift slettet",
"recipe-image": "Opskriftsbillede",
"recipe-image-updated": "Opskriftsbillede ændret",
"delete-image": "Slet Opskrift Billede",
"delete-image-confirmation": "Er du sikker på, du vil slette dette opskrift billede?",
"recipe-image-deleted": "Opskrift billede slettet",
"recipe-name": "Opskriftens navn",
"recipe-settings": "Opskriftsindstillinger",
"recipe-update-failed": "Opdatering af opskrift fejlede",
@@ -560,6 +569,7 @@
"choose-unit": "Vælg enhed",
"press-enter-to-create": "Tryk enter for at oprette",
"choose-food": "Vælg fødevarer",
"choose-recipe": "Vælg Opskrift",
"notes": "Kommentarer",
"toggle-section": "Sektion",
"see-original-text": "Vis den oprindelige tekst",
@@ -587,6 +597,7 @@
"made-this": "Jeg har lavet denne",
"how-did-it-turn-out": "Hvordan blev det?",
"user-made-this": "{user} lavede denne",
"made-for-recipe": "Lavet til {recipe}",
"added-to-timeline": "Tilføjet til tidslinjen",
"failed-to-add-to-timeline": "Kunne ikke tilføje til tidslinjen",
"failed-to-update-recipe": "Kunne ikke opdatere opskrift",
@@ -626,6 +637,8 @@
"scrape-recipe-suggest-bulk-importer": "Prøv masseimport",
"scrape-recipe-have-raw-html-or-json-data": "Har rå HTML- eller JSON-data?",
"scrape-recipe-you-can-import-from-raw-data-directly": "Du kan importere direkte fra rå data",
"scrape-recipe-website-being-blocked": "Website being blocked?",
"scrape-recipe-try-importing-raw-html-instead": "Try importing the raw HTML instead.",
"import-original-keywords-as-tags": "Importér originale nøgleord som mærker",
"stay-in-edit-mode": "Bliv i redigeringstilstand",
"parse-recipe-ingredients-after-import": "Fortolk opskrift ingredienser efter import",
@@ -688,7 +701,10 @@
"upload-images": "Upload billeder",
"upload-more-images": "Upload flere billeder",
"set-as-cover-image": "Angiv som opskriftens coverbillede",
"cover-image": "Coverbillede"
"cover-image": "Coverbillede",
"include-linked-recipes": "Inkluder Relaterede Opskrifter",
"include-linked-recipe-ingredients": "Inkluder Relaterede Opskrift Ingredienser",
"toggle-recipe": "Vis/Skjul Opskrift"
},
"recipe-finder": {
"recipe-finder": "Opskriftssøger",
@@ -725,7 +741,8 @@
"search-hint": "Tryk '/'",
"advanced": "Avanceret",
"auto-search": "Automatisk søgning",
"no-results": "Ingen resultater fundet"
"no-results": "Ingen resultater fundet",
"type-to-search": "Skriv for at søge..."
},
"settings": {
"add-a-new-theme": "Tilføj et nyt tema",
@@ -1056,7 +1073,7 @@
"administrator": "Administrator",
"user-can-invite-other-to-group": "Bruger kan invitere andre til gruppen",
"user-can-manage-group": "Bruger kan administrere gruppen",
"user-can-manage-household": "Bruger kan administrere husholdningen",
"user-can-manage-household": "Brugeren kan administrere husstande",
"user-can-organize-group-data": "Bruger kan organisere gruppedata",
"enable-advanced-features": "Aktiver avancerede funktioner",
"it-looks-like-this-is-your-first-time-logging-in": "Det ser ud til, at det er første gang, at du logger ind.",
@@ -1065,7 +1082,7 @@
"forgot-password-text": "Indtast venligst din e-mail-adresse. Vi sender dig en e-mail, så at du kan nulstille din adgangskode.",
"changes-reflected-immediately": "Ændringer til denne bruger vil have effekt med det samme.",
"default-activity": "",
"default-activity-hint": "Select which page you'd like to navigate to upon logging in from this device"
"default-activity-hint": "Vælg den side, du vil navigere til, når du logger ind fra denne enhed"
},
"language-dialog": {
"translated": "oversat",
@@ -1318,8 +1335,8 @@
"debug-openai-services-description": "Brug denne side til at fejlsøge OpenAI-tjenester. Du kan teste din OpenAI-forbindelse og se resultaterne her. Hvis du har billedetjenester aktiveret, kan du også prøve med et billede.",
"run-test": "Kør test",
"test-results": "Testresultater",
"group-delete-note": "Grupper med brugere eller husholdninger kan ikke slettes",
"household-delete-note": "Husholdninger med brugere kan ikke slettes"
"group-delete-note": "Grupper med brugere eller husstande kan ikke slettes",
"household-delete-note": "Husstande med brugere kan ikke slettes"
},
"profile": {
"welcome-user": "👋 Velkommen, {0}!",
@@ -1331,8 +1348,8 @@
"account-summary-description": "Her er en oversigt over din gruppes oplysninger.",
"group-statistics": "Gruppestatistik",
"group-statistics-description": "Din gruppestatistik giver indsigt i, hvordan du bruger Mealie.",
"household-statistics": "Husholdnings Statistikker",
"household-statistics-description": "Dine husstandsstatistikker giver lidt indsigt i, hvordan du bruger Mealie.",
"household-statistics": "Husstands Statistikker",
"household-statistics-description": "Din husstandsstatistik giver et indblik i, hvordan du bruger Mealie.",
"storage-capacity": "Lagerkapacitet",
"storage-capacity-description": "Din lagerkapacitet er en beregning af de billeder og elementer, du har uploadet.",
"personal": "Personlig",
@@ -1343,9 +1360,9 @@
"group-description": "Disse elementer deles i din gruppe. Redigering af et af dem vil ændre det for hele gruppen!",
"group-settings": "Gruppeindstillinger",
"group-settings-description": "Administrer dine fælles gruppeindstillinger, såsom privatlivsindstillinger.",
"household-description": "Disse elementer deles i din husstand. Redigering af en af dem vil ændre det for hele husstanden!",
"household-settings": "Husholdningsindstillinger",
"household-settings-description": "Administrer dine husholdningsindstillinger, såsom madplan og privatlivsindstillinger.",
"household-description": "Disse elementer deles inden for din husstand. Hvis du redigerer et af dem, ændres det for hele husstanden!",
"household-settings": "Husstands indstillinger",
"household-settings-description": "Administrer din husstands indstillinger, såsom madplan og privatlivsindstillinger.",
"cookbooks-description": "Administrer en samling af kategorier og opret sider til dem.",
"members": "Medlemmer",
"members-description": "Se, hvem der er i din husstand og administrer deres tilladelser.",
@@ -1374,7 +1391,7 @@
"cookbook": {
"cookbooks": "Kogebøger",
"description": "Kogebøger er en anden måde at organisere opskrifter ved at skabe tværsnit af opskrifter, arrangører, og andre filtre. Oprettelse af en kogebog vil tilføje et link i sidemenuen, og alle opskrifter med de valgte filtre vil blive vist i kogebogen.",
"hide-cookbooks-from-other-households": "Skjul kogebøger fra andre husholdninger",
"hide-cookbooks-from-other-households": "Skjul kogebøger fra andre husstande",
"hide-cookbooks-from-other-households-description": "Når aktiveret, kun kogebøger fra din husstand vises på sidepanelet",
"public-cookbook": "Offentlig kogebog",
"public-cookbook-description": "Offentlige kogebøger kan deles med personer, der ikke er oprettet som brugere i Mealie og vil blive vist på din gruppe side.",
@@ -1411,5 +1428,13 @@
"is-like": "er ligesom",
"is-not-like": "er ikke som"
}
},
"validators": {
"required": "This Field is Required",
"invalid-email": "Email Must Be Valid",
"invalid-url": "Must Be A Valid URL",
"no-whitespace": "No Whitespace Allowed",
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Characters"
}
}

View File

@@ -51,7 +51,7 @@
"category": "Kategorie"
},
"events": {
"apprise-url": "Apprise-URL",
"apprise-url": "Apprise URL",
"database": "Datenbank",
"delete-event": "Ereignis löschen",
"event-delete-confirmation": "Bist du dir sicher, dass du dieses Ereignis löschen möchtest?",
@@ -191,7 +191,7 @@
"menu": "Menü",
"a-name-is-required": "Ein Name wird benötigt",
"delete-with-name": "{name} löschen",
"confirm-delete-generic-with-name": "Bist du dir sicher, dass du dies löschen möchtest?",
"confirm-delete-generic-with-name": "Bist du dir sicher, dass du {name} löschen möchtest?",
"confirm-delete-own-admin-account": "Bitte beachte, dass du versuchst, dein eigenes Administrator-Konto zu löschen! Diese Aktion kann nicht rückgängig gemacht werden und wird dein Konto dauerhaft löschen?",
"organizer": "Organisator",
"transfer": "Übertragen",
@@ -342,6 +342,9 @@
"breakfast": "Frühstück",
"lunch": "Mittagessen",
"dinner": "Abendessen",
"snack": "Zwischenmahlzeit ",
"drink": "Getränk",
"dessert": "Nachspeise",
"type-any": "Alle",
"day-any": "Alle",
"editor": "Bearbeiten",
@@ -442,13 +445,16 @@
"upload-a-recipe": "Rezept hochladen",
"upload-individual-zip-file": "Lade eine individuelle .zip-Datei hoch, die von einer anderen Mealie-Instanz exportiert wird.",
"url-form-hint": "Kopiere einen Link von deiner Lieblingsrezept-Website und füge ihn ein",
"copy-and-paste-the-source-url-of-your-data-optional": "Kopieren und fügen Sie die Quell-URL Ihrer Daten ein (optional)",
"view-scraped-data": "Gesammelte Daten anzeigen",
"trim-whitespace-description": "Leerzeichen am Anfang und Ende sowie leere Zeilen entfernen",
"trim-prefix-description": "Erste Zeichen aus jeder Zeile entfernen",
"split-by-numbered-line-description": "Absätze nach dem Schema '1)' oder '1.' aufzuteilen versuchen",
"import-by-url": "Ein Rezept von einer Webseite importieren",
"create-manually": "Ein Rezept manuell erstellen",
"make-recipe-image": "Als Rezept-Titelbild verwenden"
"make-recipe-image": "Als Rezept-Titelbild verwenden",
"add-food": "Lebensmittel",
"add-recipe": "Rezepte hinzufügen"
},
"page": {
"404-page-not-found": "404 Seite nicht gefunden",
@@ -515,6 +521,9 @@
"recipe-deleted": "Rezept entfernt",
"recipe-image": "Rezeptbild",
"recipe-image-updated": "Rezeptbild aktualisiert",
"delete-image": "Rezeptbild löschen",
"delete-image-confirmation": "Bist du dir sicher, dass du dieses Rezept löschen möchtest?",
"recipe-image-deleted": "Rezeptbild gelöscht",
"recipe-name": "Rezeptname",
"recipe-settings": "Rezepteinstellungen",
"recipe-update-failed": "Aktualisieren des Rezepts fehlgeschlagen",
@@ -560,6 +569,7 @@
"choose-unit": "Einheit wählen",
"press-enter-to-create": "Zum Erstellen Eingabetaste drücken",
"choose-food": "Lebensmittel wählen",
"choose-recipe": "Rezept wählen",
"notes": "Notizen",
"toggle-section": "Überschrift ein-/ausblenden",
"see-original-text": "Originaltext anzeigen",
@@ -587,6 +597,7 @@
"made-this": "Ich hab's gemacht",
"how-did-it-turn-out": "Wie ist es geworden?",
"user-made-this": "{user} hat's gemacht",
"made-for-recipe": "Erstellt für {recipe}",
"added-to-timeline": "Zur Zeitleiste hinzugefügt",
"failed-to-add-to-timeline": "Fehler beim Hinzufügen zur Zeitleiste",
"failed-to-update-recipe": "Fehler beim Aktualisieren des Rezepts",
@@ -626,6 +637,8 @@
"scrape-recipe-suggest-bulk-importer": "Probiere den Massenimporter aus",
"scrape-recipe-have-raw-html-or-json-data": "Hast du Roh-HTML oder JSON Daten?",
"scrape-recipe-you-can-import-from-raw-data-directly": "Du kannst direkt von Rohdaten importieren",
"scrape-recipe-website-being-blocked": "Die Website wird blockiert?",
"scrape-recipe-try-importing-raw-html-instead": "Versuchen Sie stattdessen das reine HTML zu importieren.",
"import-original-keywords-as-tags": "Importiere ursprüngliche Stichwörter als Schlagwörter",
"stay-in-edit-mode": "Im Bearbeitungsmodus bleiben",
"parse-recipe-ingredients-after-import": "Zutaten nach dem Import parsen",
@@ -688,7 +701,10 @@
"upload-images": "Bilder hochladen",
"upload-more-images": "Weitere Bilder hochladen",
"set-as-cover-image": "Als Rezept-Titelbild setzen",
"cover-image": "Titelbild"
"cover-image": "Titelbild",
"include-linked-recipes": "Verknüpfte Rezepte einbeziehen",
"include-linked-recipe-ingredients": "Zutaten verknüpfter Rezepte einbeziehen",
"toggle-recipe": "Rezept ein/aus"
},
"recipe-finder": {
"recipe-finder": "Rezept-Suche",
@@ -725,7 +741,8 @@
"search-hint": "'/' drücken",
"advanced": "Erweitert",
"auto-search": "Automatische Suche",
"no-results": "Keine Ergebnisse gefunden"
"no-results": "Keine Ergebnisse gefunden",
"type-to-search": "Suchbegriff eingeben..."
},
"settings": {
"add-a-new-theme": "Neues Thema hinzufügen",
@@ -1411,5 +1428,13 @@
"is-like": "ist wie",
"is-not-like": "ist nicht wie"
}
},
"validators": {
"required": "Dieses Feld ist erforderlich",
"invalid-email": "E-Mail muss gültig sein",
"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"
}
}

View File

@@ -334,7 +334,7 @@
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Μόνο συνταγές με αυτές τις κατηγορίες θα χρησιμοποιηθούν στα προγράμματα γευμάτων",
"planner": "Προγραμματισμός",
"quick-week": "Γρήγορη προβολή",
"side": "Πλευρά",
"side": "Συνοδευτικό",
"sides": "Πλευρές",
"start-date": "Ημερομηνία έναρξης",
"rule-day": "Ημέρα/ες Κανόνα",
@@ -342,6 +342,9 @@
"breakfast": "Πρωινό",
"lunch": "Μεσημεριανό",
"dinner": "Βραδινό",
"snack": "Σνακ",
"drink": "Ποτό",
"dessert": "Επιδόρπιο",
"type-any": "Οτιδήποτε",
"day-any": "Οποιαδήποτε",
"editor": "Επεξεργαστής κειμένου",
@@ -359,7 +362,7 @@
"for-type-meal-types": "για γεύματα τύπου {0}",
"meal-plan-rules": "Κανόνες Προγράμματος Γευμάτων",
"new-rule": "Νέος κανόνας",
"meal-plan-rules-description": "Μπορείτε να δημιουργήσετε κανόνες για την αυτόματη επιλογή συνταγών για τα προγράμματα γευμάτων. Αυτοί οι κανόνες χρησιμοποιούνται από το διακομιστή για τον προσδιορισμό της τυχαίας δεξαμενής συνταγών από τις οποίες μπορείτε να επιλέξετε κατά τη δημιουργία προγραμμάτων γευμάτων. Σημειώστε ότι αν οι κανόνες έχουν τους ίδιους περιορισμούς ημέρας/τύπου τότε τα φίλτρα κανόνων θα συγχωνευθούν. Στην πράξη, είναι περιττή η δημιουργία διπλότυπων κανόνων, είναι όμως εφικτή.",
"meal-plan-rules-description": "Μπορείτε να δημιουργήσετε κανόνες για την αυτόματη επιλογή συνταγών για τα προγράμματα γευμάτων. Αυτοί οι κανόνες χρησιμοποιούνται από το διακομιστή για τον προσδιορισμό της δεξαμενής τυχαίας επιλογής συνταγής, κατά τη δημιουργία προγραμμάτων γευμάτων. Σημειώστε ότι αν οι κανόνες έχουν τους ίδιους περιορισμούς ημέρας/τύπου τότε τα φίλτρα κανόνων θα συγχωνευθούν. Στην πράξη, είναι περιττή η δημιουργία διπλότυπων κανόνων, είναι όμως εφικτή.",
"new-rule-description": "Κατά τη δημιουργία ενός νέου κανόνα για ένα σχέδιο γεύματος, μπορείτε να περιορίσετε τον κανόνα ώστε να ισχύει για μια συγκεκριμένη ημέρα της εβδομάδας ή/και ένα συγκεκριμένο τύπο γεύματος. Για να εφαρμόσετε έναν κανόνα σε όλες τις ημέρες ή σε όλους τους τύπους γεύματος μπορείτε να ορίσετε τον κανόνα σε \"Ολα\" που θα τον εφαρμόσει σε όλες τις πιθανές τιμές για την ημέρα ή/και τον τύπο γεύματος.",
"recipe-rules": "Κανόνες Συνταγής",
"applies-to-all-days": "Εφαρμόζεται για όλες τις ημέρες",
@@ -442,13 +445,16 @@
"upload-a-recipe": "Ανεβάστε μια συνταγή",
"upload-individual-zip-file": "Ανεβάστε ένα μεμονωμένο αρχείο .zip που εξάγεται από μια άλλη περίπτωση Mealie.",
"url-form-hint": "Αντιγράψτε και επικολλήστε έναν σύνδεσμο από την αγαπημένη σας ιστοσελίδα συνταγών",
"copy-and-paste-the-source-url-of-your-data-optional": "Αντιγράψτε και επικολλήστε το πηγαίο URL των δεδομένων σας (προαιρετικό)",
"view-scraped-data": "Προβολή Παραγόμενων Δεδομένων",
"trim-whitespace-description": "Περικοπή κενών στην αρχή και το τέλος καθώς και των κενών γραμμών",
"trim-prefix-description": "Περικοπή πρώτου χαρακτήρα από κάθε γραμμή",
"split-by-numbered-line-description": "Προσπάθεια για χωρισμό μιας παραγράφου ταιριάζοντας μοτίβα '1)' ή '1.'",
"import-by-url": "Εισαγωγή μιας συνταγής από URL",
"create-manually": "Δημιουργήστε μια συνταγή χειροκίνητα",
"make-recipe-image": "Ορισμός ως εικόνας συνταγής"
"make-recipe-image": "Ορισμός ως εικόνας συνταγής",
"add-food": "Προσθήκη τρόφιμου",
"add-recipe": "Προσθήκη συνταγής"
},
"page": {
"404-page-not-found": "404. η σελίδα δεν βρέθηκε",
@@ -515,6 +521,9 @@
"recipe-deleted": "Η συνταγή διαγράφηκε",
"recipe-image": "Εικόνα Συνταγής",
"recipe-image-updated": "Η εικόνα συνταγής ενημερώθηκε",
"delete-image": "Διαγραφή Εικόνας Συνταγής",
"delete-image-confirmation": "Θέλετε σίγουρα να διαγράψετε αυτή την εικόνα συνταγής;",
"recipe-image-deleted": "Η εικόνα συνταγής διαγράφηκε",
"recipe-name": "Όνομα συνταγής",
"recipe-settings": "Ρυθμίσεις Συνταγής",
"recipe-update-failed": "Η ενημέρωση συνταγής απέτυχε",
@@ -560,6 +569,7 @@
"choose-unit": "Επιλέξτε μονάδα",
"press-enter-to-create": "Πατήστε Enter για δημιουργία",
"choose-food": "Επιλέξτε τρόφιμο",
"choose-recipe": "Επιλέξτε συνταγή",
"notes": "Σημειώσεις",
"toggle-section": "Ενεργοποίηση/απενεργοποίηση τμήματος",
"see-original-text": "Προβολή Αρχικού Κειμένου",
@@ -587,6 +597,7 @@
"made-this": "Το έφτιαξα",
"how-did-it-turn-out": "Ποιό ήταν το αποτέλεσμα;",
"user-made-this": "Ο/η {user} έφτιαξε αυτό",
"made-for-recipe": "Φτιαγμένο για {recipe}",
"added-to-timeline": "Προστέθηκε στο χρονολόγιο",
"failed-to-add-to-timeline": "Αποτυχία προσθήκης στο χρονολόγιο",
"failed-to-update-recipe": "Αποτυχία ενημέρωσης συνταγής",
@@ -595,7 +606,7 @@
"message-key": "Κλειδί Μηνύματος",
"parse": "Ανάλυση",
"ingredients-not-parsed-description": "Φαίνεται ότι τα συστατικά σας δεν έχουν αναλυθεί ακόμα. Κάντε κλικ στο κουμπί \"{parse}\" παρακάτω για να αναλύσετε τα συστατικά σας σε δομημένα τρόφιμα.",
"attach-images-hint": "Επισυνάψτε εικόνες σύροντας τις & αφήνοντάς τις στον επεξεργαστή",
"attach-images-hint": "Επισυνάψτε εικόνες σύροντας & αφήνοντάς τες στον επεξεργαστή",
"drop-image": "Απόθεση εικόνας",
"enable-ingredient-amounts-to-use-this-feature": "Ενεργοποιήστε τις ποσότητες συστατικών για να χρησιμοποιήσετε αυτήν τη δυνατότητα",
"recipes-with-units-or-foods-defined-cannot-be-parsed": "Δεν είναι δυνατή η ανάλυση συνταγών με καθορισμένες μονάδες ή τρόφιμα.",
@@ -626,6 +637,8 @@
"scrape-recipe-suggest-bulk-importer": "Δοκιμάστε τον μαζικό εισαγωγέα συνταγών μας",
"scrape-recipe-have-raw-html-or-json-data": "Εχουν ακατέργαστα δεδομένα HTML ή JSON;",
"scrape-recipe-you-can-import-from-raw-data-directly": "Μπορείτε να κάνετε εισαγωγή απευθείας από ακατέργαστα δεδομένα",
"scrape-recipe-website-being-blocked": "Η ιστοσελίδα μπλοκάρεται;",
"scrape-recipe-try-importing-raw-html-instead": "Δοκιμάστε να εισάγετε τον ακατέργαστο κώδικα HTML.",
"import-original-keywords-as-tags": "Εισαγωγή αρχικών λέξεων-κλειδιών ως ετικέτες",
"stay-in-edit-mode": "Παραμονή σε λειτουργία επεξεργασίας",
"parse-recipe-ingredients-after-import": "Ανάλυση συστατικών συνταγής μετά την εισαγωγή",
@@ -688,7 +701,10 @@
"upload-images": "Ανέβασμα εικόνων",
"upload-more-images": "Ανέβασμα περισσότερων εικόνων",
"set-as-cover-image": "Ορισμός ως εικόνα εξώφυλλου συνταγής",
"cover-image": "Εικόνα εξώφυλλου"
"cover-image": "Εικόνα εξώφυλλου",
"include-linked-recipes": "Συμπερίληψη συνδεδεμένων συνταγών",
"include-linked-recipe-ingredients": "Συμπερίληψη συστατικών συνδεδεμένης συνταγής",
"toggle-recipe": "Εναλλαγή συνταγής"
},
"recipe-finder": {
"recipe-finder": "Εύρεση συνταγών",
@@ -725,7 +741,8 @@
"search-hint": "Πατήστε '/'",
"advanced": "Για προχωρημένους",
"auto-search": "Αυτόματη Αναζήτηση",
"no-results": "Δε βρέθηκαν αποτελέσματα"
"no-results": "Δε βρέθηκαν αποτελέσματα",
"type-to-search": "Πληκτρολογήστε για αναζήτηση…"
},
"settings": {
"add-a-new-theme": "Προσθήκη νέου θέματος",
@@ -862,9 +879,9 @@
"secure-site": "Ασφαλής Ιστοσελίδα",
"secure-site-error-text": "Παροχή μέσω localhost ή ασφάλεια με https. Το πρόχειρο και τα πρόσθετα API προγράμματος περιήγησης μπορεί να μην λειτουργούν.",
"secure-site-success-text": "Ο ιστότοπος έχει πρόσβαση από localhost ή https",
"server-side-base-url": "Βασική Διεύθυνση URL Πλευράς Διακομιστή",
"server-side-base-url": "Βασική διεύθυνση URL πλευράς διακομιστή",
"server-side-base-url-error-text": "Το `BASE_URL` εξακολουθεί να είναι η προεπιλεγμένη τιμή στο διακομιστή API. Αυτό θα προκαλέσει προβλήματα με τις συνδέσεις ειδοποιήσεων που δημιουργούνται στο διακομιστή για email, κλπ.",
"server-side-base-url-success-text": "Το URL Πλευράς Διακομιστή δεν ταιριάζει με την προεπιλογή",
"server-side-base-url-success-text": "Η διεύθυνση URL πλευράς διακομιστή δεν ταιριάζει με την προεπιλεγμένη",
"ldap-ready": "Ετοιμο για LDAP",
"ldap-ready-error-text": "Δεν έχουν ρυθμιστεί όλες οι τιμές LDAP. Αυτό μπορεί να αγνοηθεί αν δεν χρησιμοποιείτε έλεγχο ταυτότητας LDAP.",
"ldap-ready-success-text": "Ολες οι απαιτούμενες μεταβλητές LDAP έχουν οριστεί.",
@@ -1411,5 +1428,13 @@
"is-like": "είναι όμοιο με",
"is-not-like": "δεν είναι όμοιο με"
}
},
"validators": {
"required": "Αυτό το πεδίο είναι υποχρεωτικό",
"invalid-email": "Το e-mail πρέπει να είναι έγκυρο",
"invalid-url": "Πρέπει να είναι μια έγκυρη διεύθυνση URL",
"no-whitespace": "Δεν επιτρέπονται κενοί χαρακτήρες",
"min-length": "Πρέπει να αποτελείται από τουλάχιστον {min} χαρακτήρες",
"max-length": "Πρέπει να αποτελείται το πολύ από {max} χαρακτήρες"
}
}

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