mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-24 16:53:12 -05:00
Compare commits
780 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
583bd742fb | ||
|
|
af86ed2028 | ||
|
|
80dc1dcaad | ||
|
|
90e184c0fc | ||
|
|
393103662f | ||
|
|
6e2957fb1e | ||
|
|
e7a668e64e | ||
|
|
88577b696b | ||
|
|
289038ba17 | ||
|
|
4aec294c26 | ||
|
|
99a13bd0c4 | ||
|
|
3bd4353685 | ||
|
|
109ec651cc | ||
|
|
166582acf4 | ||
|
|
4bc88e653f | ||
|
|
94e91d3602 | ||
|
|
d6ce607a4e | ||
|
|
9e8822fabe | ||
|
|
0734ec9ce8 | ||
|
|
f4f2b863e0 | ||
|
|
a7fcb6c84d | ||
|
|
abcee51d0c | ||
|
|
8b61f95c8c | ||
|
|
e01bb60aab | ||
|
|
bf5340b902 | ||
|
|
985041e61f | ||
|
|
70edf36073 | ||
|
|
15a0d25caa | ||
|
|
445754c5d8 | ||
|
|
831cd9c543 | ||
|
|
abaf6062c6 | ||
|
|
125c3914b3 | ||
|
|
93cb6bf341 | ||
|
|
46fb2b2c5a | ||
|
|
ac2a77b3b0 | ||
|
|
66a6426d15 | ||
|
|
508ae30133 | ||
|
|
e3c642debf | ||
|
|
af9e0f27a3 | ||
|
|
e07467df57 | ||
|
|
55b91bf847 | ||
|
|
58fc46af9f | ||
|
|
8e4fe55df1 | ||
|
|
f150c3f41e | ||
|
|
36c7ae5b4b | ||
|
|
d1c5a6ed8c | ||
|
|
ca26639525 | ||
|
|
89982f3e5f | ||
|
|
8485b17490 | ||
|
|
5c57b3dd1a | ||
|
|
4c8bbdcde2 | ||
|
|
2607066570 | ||
|
|
8b7c8be51d | ||
|
|
c70a5cb72c | ||
|
|
c610ec1344 | ||
|
|
61becdbec7 | ||
|
|
78d2a3b8aa | ||
|
|
a4c3b0da71 | ||
|
|
3d3279738b | ||
|
|
495d643ed9 | ||
|
|
9094d24e50 | ||
|
|
aa4d0f9958 | ||
|
|
cb821994ae | ||
|
|
b07a3a31f7 | ||
|
|
68ff5f4b1c | ||
|
|
cde9d166a4 | ||
|
|
041145423f | ||
|
|
c227519fb7 | ||
|
|
94223d2903 | ||
|
|
e015c65d92 | ||
|
|
53916badf3 | ||
|
|
c82549ccb4 | ||
|
|
554b3fa749 | ||
|
|
3f263281e7 | ||
|
|
dc47145af6 | ||
|
|
0ccee3584c | ||
|
|
dc8aadc327 | ||
|
|
efbb571bc2 | ||
|
|
1df75328d7 | ||
|
|
85e402ccc3 | ||
|
|
53a1f04562 | ||
|
|
418a8ec72b | ||
|
|
770630bf73 | ||
|
|
89ee7475a6 | ||
|
|
bca5dd8282 | ||
|
|
dabd93c919 | ||
|
|
6991dff3e6 | ||
|
|
b0eece789d | ||
|
|
9fad4a9dce | ||
|
|
22d8c4d5dc | ||
|
|
7be24d3479 | ||
|
|
fbceb61b9a | ||
|
|
1be5bfaef1 | ||
|
|
fac1df31d3 | ||
|
|
6957e2fa74 | ||
|
|
4f02fae284 | ||
|
|
f2615c97e9 | ||
|
|
6b4c9a400d | ||
|
|
cca11b5a12 | ||
|
|
f697a7ee34 | ||
|
|
0d73338e12 | ||
|
|
2f4c6bd500 | ||
|
|
3807778e2f | ||
|
|
ee87a14401 | ||
|
|
ec458a0a08 | ||
|
|
2ff37c86d6 | ||
|
|
b7da3c0f73 | ||
|
|
d799136f0d | ||
|
|
d1d5754c6d | ||
|
|
52662fdce2 | ||
|
|
8df6033c19 | ||
|
|
c23660007e | ||
|
|
786aa2279c | ||
|
|
ab8c3be367 | ||
|
|
8bf8dfd3ed | ||
|
|
b3aa7aeb1a | ||
|
|
0f2b1d8d3a | ||
|
|
4de6391684 | ||
|
|
c3e68b7d8a | ||
|
|
7557d2e818 | ||
|
|
c22a2fc4a8 | ||
|
|
ad94a4f42f | ||
|
|
e6bf3b3acd | ||
|
|
711dd93851 | ||
|
|
2b6d7811ca | ||
|
|
3373abf787 | ||
|
|
741d37f59e | ||
|
|
b38c19ce71 | ||
|
|
1a385e941c | ||
|
|
c6f5b62ad0 | ||
|
|
84dad84326 | ||
|
|
f369c8fd6e | ||
|
|
467cf46c6d | ||
|
|
360b8e21d9 | ||
|
|
0b851e79ec | ||
|
|
faf716cb7e | ||
|
|
46f1ad7941 | ||
|
|
6e1112c73e | ||
|
|
6bd5a82b92 | ||
|
|
cba076b6a4 | ||
|
|
2ff1135b00 | ||
|
|
467b9c6d65 | ||
|
|
176e471276 | ||
|
|
193888fb30 | ||
|
|
13edefbf41 | ||
|
|
fd33468fda | ||
|
|
2a541f081a | ||
|
|
8ab09cf03b | ||
|
|
9e6ae2e514 | ||
|
|
94678fe6e0 | ||
|
|
ed533c8fad | ||
|
|
93f7d15917 | ||
|
|
53aa4dab51 | ||
|
|
92659c64eb | ||
|
|
6f871c6bdb | ||
|
|
f4f511aad6 | ||
|
|
03d384f3a5 | ||
|
|
0c2917a112 | ||
|
|
606a8f03a3 | ||
|
|
2d31c0abf2 | ||
|
|
15c752d428 | ||
|
|
b254cf3833 | ||
|
|
0bd023d8a8 | ||
|
|
aad50f2267 | ||
|
|
fcbc57b392 | ||
|
|
a62299e6ef | ||
|
|
82563fa948 | ||
|
|
7583c56b35 | ||
|
|
b9cc2dc257 | ||
|
|
0dcf81e764 | ||
|
|
3d3763d4b9 | ||
|
|
517727a4b6 | ||
|
|
1c26dff1e9 | ||
|
|
ed1834d945 | ||
|
|
bf8bc88ffb | ||
|
|
6c48eba5f7 | ||
|
|
dc7df0d4aa | ||
|
|
45d5194f19 | ||
|
|
8ad1a15bf1 | ||
|
|
57aeb401b8 | ||
|
|
e15a2f35e2 | ||
|
|
b28e135ceb | ||
|
|
148aca5e85 | ||
|
|
1ac7f90c28 | ||
|
|
413a8a82fc | ||
|
|
72c414bf94 | ||
|
|
b67263e63f | ||
|
|
1673eedff7 | ||
|
|
d3ee5f34f8 | ||
|
|
683f1ac69e | ||
|
|
d6d0f7de71 | ||
|
|
dd0eaac45f | ||
|
|
f8e672c7ac | ||
|
|
2aa9d84d6c | ||
|
|
2c13c4760e | ||
|
|
62bf733548 | ||
|
|
2c72ea17a2 | ||
|
|
06406c86f5 | ||
|
|
b7f7712011 | ||
|
|
4b13686261 | ||
|
|
9fade36014 | ||
|
|
eb1d569e95 | ||
|
|
1099e30a1d | ||
|
|
fa9a2d64f7 | ||
|
|
de142c47df | ||
|
|
c990420a87 | ||
|
|
d772e3bb4f | ||
|
|
bb8080475c | ||
|
|
c1e05f57db | ||
|
|
9ad68542e0 | ||
|
|
83997dbb47 | ||
|
|
b5f3c5bef7 | ||
|
|
ddd97cce10 | ||
|
|
0ecd57a50b | ||
|
|
e4efcee0df | ||
|
|
a6920f057e | ||
|
|
dbb212ceda | ||
|
|
5d5805459a | ||
|
|
3e68920e69 | ||
|
|
91c978a309 | ||
|
|
e7c101c96b | ||
|
|
004f3552c0 | ||
|
|
cd56149371 | ||
|
|
8edea0a7e0 | ||
|
|
df15e97026 | ||
|
|
760462e12f | ||
|
|
60793bb560 | ||
|
|
7c84d3dea5 | ||
|
|
eee1c5733d | ||
|
|
cf0a7ae9c9 | ||
|
|
be80d3e74c | ||
|
|
db1fabf5c8 | ||
|
|
e7e73772e0 | ||
|
|
e5cab0e4d0 | ||
|
|
6a14d5b7db | ||
|
|
57106c4cce | ||
|
|
61c6a991f3 | ||
|
|
6824b3c269 | ||
|
|
6b13166880 | ||
|
|
b2747d77e1 | ||
|
|
fc4d1b88d0 | ||
|
|
8798bd6e55 | ||
|
|
cef61ae29f | ||
|
|
e304d48e84 | ||
|
|
4f1a7c55b9 | ||
|
|
bae7acbc3b | ||
|
|
c0cf6a9aca | ||
|
|
f4570faf1a | ||
|
|
9548a7eb70 | ||
|
|
d5e3a1dacb | ||
|
|
1ce760ec7e | ||
|
|
9e23ed1a07 | ||
|
|
b3885cc3f8 | ||
|
|
5da990abd4 | ||
|
|
f709d11952 | ||
|
|
2a3463b746 | ||
|
|
cb7302d2d9 | ||
|
|
a30084a199 | ||
|
|
c0654a5d95 | ||
|
|
737a370874 | ||
|
|
2a2b6f312b | ||
|
|
96d220acbd | ||
|
|
b9a9b8695d | ||
|
|
e80c8a50e6 | ||
|
|
41795799e6 | ||
|
|
9980e49eef | ||
|
|
945810c47a | ||
|
|
a283828461 | ||
|
|
7c365b7c03 | ||
|
|
16da55f58b | ||
|
|
35f6b0e80e | ||
|
|
ab37c2e8c0 | ||
|
|
d1f82df936 | ||
|
|
6c7cb7e795 | ||
|
|
ecf80b8e9c | ||
|
|
e280734e33 | ||
|
|
24d8854723 | ||
|
|
2f9b711973 | ||
|
|
2b09495e87 | ||
|
|
ae5a1a9af2 | ||
|
|
a312c4dbf3 | ||
|
|
79fb1fb299 | ||
|
|
b7c1cdfd46 | ||
|
|
09873d4814 | ||
|
|
7f596c653b | ||
|
|
bb06b0414b | ||
|
|
6b720bafd6 | ||
|
|
6449591143 | ||
|
|
8fb43246b2 | ||
|
|
95d3fd4958 | ||
|
|
dfbc890f2c | ||
|
|
21886ab4b8 | ||
|
|
4b0df3ace8 | ||
|
|
7505b5cf65 | ||
|
|
45e71da402 | ||
|
|
3ded63dfdf | ||
|
|
735c3e3146 | ||
|
|
83861cfcb8 | ||
|
|
c22ff8ccad | ||
|
|
9a17a484f3 | ||
|
|
bc6734399f | ||
|
|
8e6f2a3d61 | ||
|
|
20fa3a25f2 | ||
|
|
1a9f5470ca | ||
|
|
2254d114be | ||
|
|
ef22d29ef1 | ||
|
|
ef165cd276 | ||
|
|
e6477920ce | ||
|
|
c49584d027 | ||
|
|
e56eabf1e2 | ||
|
|
a35f2ae56a | ||
|
|
2a4a195dcb | ||
|
|
6318e8d1c6 | ||
|
|
b7a4899302 | ||
|
|
7fe6ef4da5 | ||
|
|
0391763d18 | ||
|
|
3250384862 | ||
|
|
1bf496751c | ||
|
|
580700458c | ||
|
|
6c6276cb79 | ||
|
|
787f9293fb | ||
|
|
aa0547ae69 | ||
|
|
1c6b0f1122 | ||
|
|
014721b6f7 | ||
|
|
692d8a5681 | ||
|
|
120d4cfc5a | ||
|
|
e3b3f70621 | ||
|
|
c5e55a2207 | ||
|
|
f6d508af92 | ||
|
|
516a732b12 | ||
|
|
dfd4943304 | ||
|
|
511e08e7e9 | ||
|
|
ed50dfc145 | ||
|
|
ee542255a5 | ||
|
|
32fa9d4439 | ||
|
|
f2be3383ac | ||
|
|
fd6874e8dd | ||
|
|
09e514fa84 | ||
|
|
f4678f99ed | ||
|
|
4e9670acf6 | ||
|
|
fb097ca095 | ||
|
|
8f40d13f20 | ||
|
|
2aaef9ae54 | ||
|
|
a6d31638e2 | ||
|
|
f73aefce4e | ||
|
|
ff5131018b | ||
|
|
1c6c5042ae | ||
|
|
d770009e0d | ||
|
|
4a0b211f27 | ||
|
|
7dcf2ca33d | ||
|
|
95c15504d0 | ||
|
|
4e38625bde | ||
|
|
4a411f0483 | ||
|
|
95b6d901bf | ||
|
|
c9d74e25ac | ||
|
|
54aef24caf | ||
|
|
6050b1e25a | ||
|
|
b54cef2702 | ||
|
|
abb2c8110c | ||
|
|
f1e8d633fc | ||
|
|
c4f60942b5 | ||
|
|
abbaf12e9e | ||
|
|
04e6601d5b | ||
|
|
1ebc7d27d4 | ||
|
|
59d53a02b3 | ||
|
|
370da5aee3 | ||
|
|
af2992eee9 | ||
|
|
e17b8b813c | ||
|
|
9b4e0dd0d9 | ||
|
|
d7bc1c75da | ||
|
|
b963b99a4c | ||
|
|
9270e22f19 | ||
|
|
7df34890d4 | ||
|
|
36b0661e1d | ||
|
|
6da50b576f | ||
|
|
517491e507 | ||
|
|
82aca1f77f | ||
|
|
8c4f657aa7 | ||
|
|
4352762e93 | ||
|
|
ad0ea09be9 | ||
|
|
52c6fe34b2 | ||
|
|
b6ccb9fbdb | ||
|
|
f83ab6ecc1 | ||
|
|
1af0f426ae | ||
|
|
d960947258 | ||
|
|
f2735ba22b | ||
|
|
aa4c02ef30 | ||
|
|
f1fbf0d120 | ||
|
|
63a362a48a | ||
|
|
e0d7341139 | ||
|
|
5f5b06683a | ||
|
|
42523bbfc9 | ||
|
|
0a344731c8 | ||
|
|
e83fa89ec4 | ||
|
|
13cd7a1c0f | ||
|
|
0e23a41bdb | ||
|
|
e17b320dc8 | ||
|
|
403038a5b2 | ||
|
|
430e1d7d4e | ||
|
|
c44cd7ffab | ||
|
|
73dfb523ec | ||
|
|
21d57735c9 | ||
|
|
05e13e6078 | ||
|
|
5f6844eceb | ||
|
|
bea1a592d7 | ||
|
|
b54cdf6425 | ||
|
|
02da2114f9 | ||
|
|
a67533a778 | ||
|
|
59ad834c12 | ||
|
|
315d5b370e | ||
|
|
130813ffe4 | ||
|
|
65ddb7c9e2 | ||
|
|
dbe29e15ae | ||
|
|
980b3c634b | ||
|
|
457d8c93ce | ||
|
|
23aad6358c | ||
|
|
7c896361f2 | ||
|
|
5b7f5738e3 | ||
|
|
5bfcb80c98 | ||
|
|
b1278b45e2 | ||
|
|
e7ae76ea48 | ||
|
|
e84e5e2910 | ||
|
|
5e6f5bc175 | ||
|
|
d577978f2f | ||
|
|
e30728e8e9 | ||
|
|
29368d9cc4 | ||
|
|
4776da7aea | ||
|
|
87518b1fbd | ||
|
|
d7deb5a3f8 | ||
|
|
445ec18bb4 | ||
|
|
3ecc289e1a | ||
|
|
805e1c2d7d | ||
|
|
58286013c9 | ||
|
|
1afdc400a8 | ||
|
|
f6d526741b | ||
|
|
c9b21f862e | ||
|
|
4ae7f6eca4 | ||
|
|
c9fdf862a3 | ||
|
|
cf97c2247c | ||
|
|
9c95c56f0a | ||
|
|
9f3aca5a3f | ||
|
|
dd87779476 | ||
|
|
2e7078e90b | ||
|
|
6b52b61604 | ||
|
|
41e2643755 | ||
|
|
170b4d338d | ||
|
|
ecd506c714 | ||
|
|
e6aadc4902 | ||
|
|
ae74e0d71c | ||
|
|
d6db8c23ce | ||
|
|
dcf7afa441 | ||
|
|
185c93100b | ||
|
|
cf68420976 | ||
|
|
dea3b756f1 | ||
|
|
825b19c634 | ||
|
|
8d59c35bc9 | ||
|
|
06ec8dd4f3 | ||
|
|
fc4ec3261f | ||
|
|
663716ca0f | ||
|
|
6c4ce585d6 | ||
|
|
0acf30db61 | ||
|
|
db9035f92c | ||
|
|
a475afd570 | ||
|
|
57ca357969 | ||
|
|
cac099eeb3 | ||
|
|
c0929634a9 | ||
|
|
52de8afe2d | ||
|
|
2809b87ab0 | ||
|
|
7aab87813c | ||
|
|
4a67fffccd | ||
|
|
12b7625d42 | ||
|
|
275e1dc85c | ||
|
|
3a8e814315 | ||
|
|
07ebd1e613 | ||
|
|
a626330139 | ||
|
|
5ac7645350 | ||
|
|
efc6064605 | ||
|
|
71a6f32665 | ||
|
|
d60c4c179b | ||
|
|
c5c8c59168 | ||
|
|
fca75c5c07 | ||
|
|
012142feec | ||
|
|
d532395d89 | ||
|
|
b25f9f2cdf | ||
|
|
5471e742f0 | ||
|
|
c5849b2a74 | ||
|
|
429b2adf98 | ||
|
|
df366cd82a | ||
|
|
0a4bb583ff | ||
|
|
dfb650c4b1 | ||
|
|
aa4527e5f7 | ||
|
|
ae8ea16dab | ||
|
|
d321c69244 | ||
|
|
478a4e5d73 | ||
|
|
44cd2fef1c | ||
|
|
0abe8b1921 | ||
|
|
4a13714177 | ||
|
|
ac3514f4c6 | ||
|
|
f9b71f4b4c | ||
|
|
7ca50b63f9 | ||
|
|
62adc920a9 | ||
|
|
eeda71e186 | ||
|
|
8e5ea1df5e | ||
|
|
0bf3aed287 | ||
|
|
74d6f58363 | ||
|
|
3d4405cd42 | ||
|
|
98c8694979 | ||
|
|
32812d6a6c | ||
|
|
1f8d7c0b21 | ||
|
|
8b88f6892c | ||
|
|
618c567392 | ||
|
|
fb44451c6f | ||
|
|
9e1edbacb6 | ||
|
|
f45d02299a | ||
|
|
7afd7b2334 | ||
|
|
0a28d36df9 | ||
|
|
2c1185e1d4 | ||
|
|
69bbf9fdcc | ||
|
|
38125fa362 | ||
|
|
5db7a735d7 | ||
|
|
4d2363ea22 | ||
|
|
994940f270 | ||
|
|
3adb324b25 | ||
|
|
fbfc5b31d6 | ||
|
|
2a016ecce9 | ||
|
|
782d4ec180 | ||
|
|
8c52448da2 | ||
|
|
7e194887f5 | ||
|
|
806a1b9392 | ||
|
|
d575a3b222 | ||
|
|
f61fdb8623 | ||
|
|
ea7005e822 | ||
|
|
a7775ea7ef | ||
|
|
ba4eddccd9 | ||
|
|
6fcda5e446 | ||
|
|
34d742963a | ||
|
|
59cd68d54a | ||
|
|
1e4dbe4e95 | ||
|
|
5d89d53a4a | ||
|
|
349ccbad6f | ||
|
|
5d68620382 | ||
|
|
cb06c8a877 | ||
|
|
7d57fdcd96 | ||
|
|
fa60d81e26 | ||
|
|
6c4294dc49 | ||
|
|
0a3542e97c | ||
|
|
83887e3c37 | ||
|
|
5fe29cdd93 | ||
|
|
f618c45767 | ||
|
|
e1a87b32d9 | ||
|
|
9e739c8b35 | ||
|
|
d6fab197e7 | ||
|
|
89a5326d3f | ||
|
|
f0b542c990 | ||
|
|
c1a3516b37 | ||
|
|
db467105b9 | ||
|
|
2b5372f693 | ||
|
|
1a2ff9540f | ||
|
|
28fdc8a9ac | ||
|
|
b0ce1483fe | ||
|
|
ad251b2449 | ||
|
|
0ebc2a746b | ||
|
|
9c6e3ebe5b | ||
|
|
1b404ee6d8 | ||
|
|
72052be92f | ||
|
|
f2e7deb5cb | ||
|
|
b2e0c51ead | ||
|
|
3d73e7498f | ||
|
|
690f595491 | ||
|
|
058d968833 | ||
|
|
da2adaa694 | ||
|
|
fb9be66f97 | ||
|
|
0a446928d7 | ||
|
|
dc01ff36dc | ||
|
|
2d90ae903b | ||
|
|
d324c6ac57 | ||
|
|
aebf229b86 | ||
|
|
3c76a82997 | ||
|
|
30ec65f43c | ||
|
|
2471c7b08e | ||
|
|
4be23ccffb | ||
|
|
99db24cdec | ||
|
|
719a33352a | ||
|
|
fe3bd95c85 | ||
|
|
8db08c21e5 | ||
|
|
a384e6716d | ||
|
|
e35b2e9fbf | ||
|
|
b48c2ab736 | ||
|
|
a12ee536d9 | ||
|
|
97d5439a4f | ||
|
|
c94a1d7c17 | ||
|
|
0ce05c781c | ||
|
|
0e0dfbf014 | ||
|
|
ae03e61bb9 | ||
|
|
f6167b1d81 | ||
|
|
df75cb4034 | ||
|
|
67313f8f03 | ||
|
|
39eab01885 | ||
|
|
9fb63a00fd | ||
|
|
f945cb8d2d | ||
|
|
7a107584c7 | ||
|
|
248459671e | ||
|
|
67e48c2fd1 | ||
|
|
368d25fa01 | ||
|
|
12b1d29413 | ||
|
|
dccc676b24 | ||
|
|
d9c1cf8bec | ||
|
|
0836c303d9 | ||
|
|
a43fd6b7fc | ||
|
|
e7ee189fbb | ||
|
|
b6708613b9 | ||
|
|
3317e061a8 | ||
|
|
0dc8584485 | ||
|
|
673ad6d42b | ||
|
|
1450d6fc4c | ||
|
|
b082242439 | ||
|
|
de69a3ca86 | ||
|
|
96e37b3ee1 | ||
|
|
cb2d8a9a50 | ||
|
|
19c5b7c7ab | ||
|
|
38a4215b35 | ||
|
|
77a05c754e | ||
|
|
2226d7cbf9 | ||
|
|
42a33cd993 | ||
|
|
d73817adad | ||
|
|
ce58da8e18 | ||
|
|
fe89981e78 | ||
|
|
520bc7154a | ||
|
|
a38dfc094e | ||
|
|
0a81579da1 | ||
|
|
f4e77f6837 | ||
|
|
c1a2c7d485 | ||
|
|
8127f48924 | ||
|
|
a0e7f85c32 | ||
|
|
b45ffd2046 | ||
|
|
1e04e9424f | ||
|
|
ef4f6245d5 | ||
|
|
7bd1c8ef14 | ||
|
|
d1bbfece9d | ||
|
|
94a85f9977 | ||
|
|
3d0adda405 | ||
|
|
47086da6b6 | ||
|
|
9ce71c911f | ||
|
|
2244c3a8b5 | ||
|
|
f0d0d0d463 | ||
|
|
dbec3e58f9 | ||
|
|
a5e56dc97f | ||
|
|
6d64418727 | ||
|
|
2b71174765 | ||
|
|
7f6de730a3 | ||
|
|
22cc19a085 | ||
|
|
b16fa49f16 | ||
|
|
90e373582b | ||
|
|
1b3cbb38ae | ||
|
|
ac5a63e32d | ||
|
|
d5d86488a0 | ||
|
|
3f9c46a763 | ||
|
|
04176f6927 | ||
|
|
2aa8c5810a | ||
|
|
a071a7d16b | ||
|
|
a14c1b48c6 | ||
|
|
43174dcebe | ||
|
|
3ee53977ec | ||
|
|
de1486c57f | ||
|
|
a12aba6b9d | ||
|
|
fb70bc76b3 | ||
|
|
e6351273e2 | ||
|
|
82dcfb5635 | ||
|
|
dddeed6359 | ||
|
|
5f4a36bbd8 | ||
|
|
f74610a0f7 | ||
|
|
166f2486a2 | ||
|
|
88a5209237 | ||
|
|
d954869dd7 | ||
|
|
9cf181b415 | ||
|
|
5a7dc14a48 | ||
|
|
d916c0a472 | ||
|
|
a3693d83a3 | ||
|
|
bb9620b67e | ||
|
|
3174216931 | ||
|
|
94342081f9 | ||
|
|
5d049d5696 | ||
|
|
36088f0db9 | ||
|
|
4c60febb9c | ||
|
|
0283185913 | ||
|
|
704d0a8392 | ||
|
|
f42114e966 | ||
|
|
d4de15ba1e | ||
|
|
e686fa671c | ||
|
|
12547feb4c | ||
|
|
7dbc031725 | ||
|
|
597e6c8e0f | ||
|
|
d5ba69d828 | ||
|
|
564f43085b | ||
|
|
a0b6cc3e62 | ||
|
|
c960c00cbe | ||
|
|
47b60e9ad5 | ||
|
|
026ca0364e | ||
|
|
70ce34d6c9 | ||
|
|
dff351a8eb | ||
|
|
13e7dfe920 | ||
|
|
abf4b7706f | ||
|
|
813a124250 | ||
|
|
8a3173094e | ||
|
|
0d16a2a943 | ||
|
|
2918a824e4 | ||
|
|
00e5e4384d | ||
|
|
634b0590ed | ||
|
|
52c58e1dc0 | ||
|
|
67b7fb007b | ||
|
|
7299c9ec9a | ||
|
|
292672601c | ||
|
|
fa3bbdcde1 | ||
|
|
7e519c6b5a | ||
|
|
57c11b23c4 | ||
|
|
ba60428b03 | ||
|
|
e48619bae6 | ||
|
|
3a0e4ff119 | ||
|
|
6d38960a5a | ||
|
|
9cc59e81d6 | ||
|
|
a04b6983e7 | ||
|
|
6beea06a41 | ||
|
|
e75b5f2f15 | ||
|
|
c9acc48bd6 | ||
|
|
f4df68a9e2 | ||
|
|
4cee8ea879 | ||
|
|
e7f5a4adff | ||
|
|
0301713214 | ||
|
|
5ef23e0330 | ||
|
|
9bf2e3fabd | ||
|
|
dcf50b9a00 | ||
|
|
073efd7a2f | ||
|
|
88529457bf | ||
|
|
890b5d93a7 | ||
|
|
95b7990f26 | ||
|
|
7947aa99ae | ||
|
|
c3f7ad8954 | ||
|
|
7dafa6c7fe | ||
|
|
8d2d571683 | ||
|
|
19e776a772 | ||
|
|
8df7848c96 | ||
|
|
6097440781 | ||
|
|
208608b32e | ||
|
|
02997cd36e | ||
|
|
e1cd2717d3 | ||
|
|
694511cb60 | ||
|
|
f0c89525f6 | ||
|
|
a05ede5e05 | ||
|
|
7e51cf0352 | ||
|
|
ce110c23e4 | ||
|
|
8247f21101 | ||
|
|
60f9a3be5c | ||
|
|
dddcb644bf | ||
|
|
84e981fd03 | ||
|
|
937464115e | ||
|
|
24aee11607 | ||
|
|
023c57dd61 | ||
|
|
14d8ff8754 | ||
|
|
e6f531c111 | ||
|
|
c0a4f624d1 | ||
|
|
a32dc4baa0 | ||
|
|
7c4690a7a9 | ||
|
|
b44487596d | ||
|
|
2cb4c21db3 | ||
|
|
2dcc765e86 | ||
|
|
a5ef18669b | ||
|
|
14497b9b5e | ||
|
|
f77649abc8 | ||
|
|
33870dc845 | ||
|
|
265313919c | ||
|
|
dd5d1b9cba | ||
|
|
e90f05d2dc | ||
|
|
677dc8f36a | ||
|
|
449eeb0d53 | ||
|
|
41204ca7f9 | ||
|
|
437f5c454f | ||
|
|
3a30b3216e | ||
|
|
408df286fd | ||
|
|
2cfc63b302 | ||
|
|
a8583c8e69 |
@@ -30,7 +30,6 @@
|
|||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"matangover.mypy",
|
"matangover.mypy",
|
||||||
"ms-python.black-formatter",
|
"ms-python.black-formatter",
|
||||||
"ms-python.isort",
|
|
||||||
"ms-python.pylint",
|
"ms-python.pylint",
|
||||||
"ms-python.python",
|
"ms-python.python",
|
||||||
"ms-python.vscode-pylance",
|
"ms-python.vscode-pylance",
|
||||||
@@ -42,6 +41,7 @@
|
|||||||
"forwardPorts": [
|
"forwardPorts": [
|
||||||
3000,
|
3000,
|
||||||
9000,
|
9000,
|
||||||
|
9091, // used by docker production
|
||||||
24678 // used by nuxt when hot-reloading using polling
|
24678 // used by nuxt when hot-reloading using polling
|
||||||
],
|
],
|
||||||
// Use 'onCreateCommand' to run commands at the end of container creation.
|
// Use 'onCreateCommand' to run commands at the end of container creation.
|
||||||
@@ -49,7 +49,9 @@
|
|||||||
"onCreateCommand": "sudo chown -R vscode:vscode /workspaces/mealie/frontend/node_modules && task setup",
|
"onCreateCommand": "sudo chown -R vscode:vscode /workspaces/mealie/frontend/node_modules && task setup",
|
||||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||||
"remoteUser": "vscode",
|
"remoteUser": "vscode",
|
||||||
// "features": {
|
"features": {
|
||||||
// "git": "latest"
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||||
// }
|
"dockerDashComposeVersion": "v2"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: I used the GitHub search to find a similar requests and didn't find it.
|
- label: I used the GitHub search to find a similar requests and didn't find it.
|
||||||
required: true
|
required: true
|
||||||
- label: Checked the [tasks tagged](https://github.com/hay-kot/mealie/issues?q=is%3Aissue+is%3Aopen+label%3Atask+) issues and verified my feature is not covered
|
- label: Checked the [tasks tagged](https://github.com/mealie-recipes/mealie/issues?q=is%3Aissue+is%3Aopen+label%3Atask+) issues and verified my feature is not covered
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: problem
|
id: problem
|
||||||
|
|||||||
9
.github/DISCUSSION_TEMPLATE/oauth-provider-example.yaml
vendored
Normal file
9
.github/DISCUSSION_TEMPLATE/oauth-provider-example.yaml
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
title: OAuth setup with <PROVIDER>
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Configuration Example
|
||||||
|
description: Add your example configuration. You can provide code blocks, screenshots, and links.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
12
.github/ISSUE_TEMPLATE/task.yaml
vendored
12
.github/ISSUE_TEMPLATE/task.yaml
vendored
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: v1.0.0b Task
|
name: Task
|
||||||
description: "CONTRIBUTORS ONLY: Submit a Task that needs to be completed"
|
description: "CONTRIBUTORS ONLY: Submit a Task that needs to be completed"
|
||||||
title: "[v1.0.0b] [Task] - TASK DESCRIPTION"
|
title: "[Task] - TASK DESCRIPTION"
|
||||||
labels:
|
labels:
|
||||||
- task
|
- task
|
||||||
- v1
|
- v1
|
||||||
@@ -11,17 +11,17 @@ body:
|
|||||||
value: |
|
value: |
|
||||||
Thanks for your interest in Mealie! 🚀
|
Thanks for your interest in Mealie! 🚀
|
||||||
|
|
||||||
This is a place for Mealie contributors to find tasks that need to get done around the repository. Tasks are different than issues as they are generally related to providing a new feature or improve an existing feature. They are _generally_ not related to an issue.
|
This is a place for Mealie contributors to find tasks that need to get done around the repository. Tasks are different than issues as they are generally related to providing a new feature or improving an existing feature. They are _generally_ not related to an issue.
|
||||||
|
|
||||||
**DO NOT** create a task unless
|
**DO NOT** create a task unless
|
||||||
- You are a contributors who has prior approval via discord/discussions
|
- You are a contributor who has prior approval via discord/discussions
|
||||||
- You have otherwise been given approval to post the tasks
|
- You have otherwise been given approval to post the tasks
|
||||||
|
|
||||||
Otherwise, your post will be closed/deleted.
|
Otherwise, your post will be closed/deleted.
|
||||||
|
|
||||||
**Interested in Taking This?**
|
**Interested in Taking This?**
|
||||||
|
|
||||||
If you're interested in completing this tasks and it hasn't already been taken, comment below and to let others know you're working on it. As you work through the task, I ask that you submit a draft pull request as soon as possible, and tag this issue so we can all collaborate as best as possible.
|
If you're interested in completing this task and it hasn't already been taken, comment below and to let others know you're working on it. As you work through the task, I ask that you submit a draft pull request as soon as possible, and tag this issue so we can all collaborate as best as possible.
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: problem
|
id: problem
|
||||||
attributes:
|
attributes:
|
||||||
@@ -33,6 +33,6 @@ body:
|
|||||||
id: solution
|
id: solution
|
||||||
attributes:
|
attributes:
|
||||||
label: Proposed/Possible Solution(s)?
|
label: Proposed/Possible Solution(s)?
|
||||||
placeholder: Provide as much context around the idea as possible with potential files and roadblocks that may come up
|
placeholder: Provide as much context around the idea as possible with potential files and roadblocks that may come up.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
12
.github/pull_request_template.md
vendored
12
.github/pull_request_template.md
vendored
@@ -1,6 +1,12 @@
|
|||||||
<!--
|
<!--
|
||||||
This template provides some ideas of things to include in your PR description.
|
This template provides some ideas of things to include in your PR description.
|
||||||
To start, try providing a short summary of your changes in the Title above.
|
|
||||||
|
To start, try providing a short summary of your changes in the Title above. We follow Conventional Commits syntax, please ensure your title is prefixed with one of:
|
||||||
|
- `feat: `
|
||||||
|
- `fix: `
|
||||||
|
- `docs: `
|
||||||
|
- `chore: `
|
||||||
|
|
||||||
If a section of the PR template does not apply to this PR, then delete that section.
|
If a section of the PR template does not apply to this PR, then delete that section.
|
||||||
|
|
||||||
PLEASE READ:
|
PLEASE READ:
|
||||||
@@ -36,6 +42,8 @@ _(REQUIRED)_
|
|||||||
Briefly explain any decisions you made with respect to the changes.
|
Briefly explain any decisions you made with respect to the changes.
|
||||||
Include anything here that you didn't include in *Release Notes*
|
Include anything here that you didn't include in *Release Notes*
|
||||||
above, such as changes to CI or changes to internal methods.
|
above, such as changes to CI or changes to internal methods.
|
||||||
|
|
||||||
|
If there is a UI component to the change, please include before/after images.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Which issue(s) this PR fixes:
|
## Which issue(s) this PR fixes:
|
||||||
@@ -44,7 +52,7 @@ _(REQUIRED)_
|
|||||||
|
|
||||||
<!--
|
<!--
|
||||||
If this PR fixes one of more issues, list them here.
|
If this PR fixes one of more issues, list them here.
|
||||||
One line each, like so:
|
One per line, like so:
|
||||||
Fixes #123
|
Fixes #123
|
||||||
Fixes #39
|
Fixes #39
|
||||||
-->
|
-->
|
||||||
|
|||||||
46
.github/workflows/e2e.yml
vendored
Normal file
46
.github/workflows/e2e.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
name: E2E Tests
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- mealie-next
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
timeout-minutes: 60
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./tests/e2e
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: 'yarn'
|
||||||
|
cache-dependency-path: ./tests/e2e/yarn.lock
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Build Image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
file: ./docker/Dockerfile
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: mealie:e2e
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
- name: Deploy E2E Test Environment
|
||||||
|
run: docker compose up -d
|
||||||
|
working-directory: ./tests/e2e/docker
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install -g yarn && yarn
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
run: yarn playwright install --with-deps
|
||||||
|
- name: Check test environment
|
||||||
|
run: docker ps
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: yarn playwright test
|
||||||
|
- name: Destroy Test Environment
|
||||||
|
if: always()
|
||||||
|
run: docker compose down --volumes
|
||||||
|
working-directory: ./tests/e2e/docker
|
||||||
13
.github/workflows/nightly.yml
vendored
13
.github/workflows/nightly.yml
vendored
@@ -4,6 +4,12 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- mealie-next
|
- mealie-next
|
||||||
|
paths-ignore:
|
||||||
|
- '*.md'
|
||||||
|
- '.devcontainer/**'
|
||||||
|
# I'm not excluding .github as changes in there might be to workflows etc
|
||||||
|
- '.vscode/**'
|
||||||
|
- 'docs/**'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: nightly-${{ github.ref }}
|
group: nightly-${{ github.ref }}
|
||||||
@@ -22,7 +28,13 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
# The id-token write permission is needed to connect to Depot.dev
|
||||||
|
# as part of the partial-builder.yml action. It needs to be declared
|
||||||
|
# in the parent action, as noted here:
|
||||||
|
# https://github.com/orgs/community/discussions/76409#discussioncomment-8131390
|
||||||
|
id-token: write
|
||||||
name: Build Tagged Release
|
name: Build Tagged Release
|
||||||
|
if: github.repository == 'mealie-recipes/mealie'
|
||||||
uses: ./.github/workflows/partial-builder.yml
|
uses: ./.github/workflows/partial-builder.yml
|
||||||
needs:
|
needs:
|
||||||
- frontend-tests
|
- frontend-tests
|
||||||
@@ -35,6 +47,7 @@ jobs:
|
|||||||
|
|
||||||
notify-discord:
|
notify-discord:
|
||||||
name: Notify Discord
|
name: Notify Discord
|
||||||
|
if: github.repository == 'mealie-recipes/mealie'
|
||||||
needs:
|
needs:
|
||||||
- build-release
|
- build-release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
8
.github/workflows/partial-backend.yml
vendored
8
.github/workflows/partial-backend.yml
vendored
@@ -66,7 +66,7 @@ jobs:
|
|||||||
id: cache-validate
|
id: cache-validate
|
||||||
if: steps.cached-poetry-dependencies.outputs.cache-hit == 'true'
|
if: steps.cached-poetry-dependencies.outputs.cache-hit == 'true'
|
||||||
run: |
|
run: |
|
||||||
echo "import black;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 && poetry run python test.py && echo "cache-hit-success=true" >> $GITHUB_OUTPUT
|
||||||
rm test.py
|
rm test.py
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
@@ -75,12 +75,12 @@ jobs:
|
|||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
|
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
|
||||||
poetry install
|
poetry install
|
||||||
poetry add "psycopg2-binary==2.8.6"
|
poetry add "psycopg2-binary==2.9.9"
|
||||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' || steps.cache-validate.outputs.cache-hit-success != 'true'
|
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' || steps.cache-validate.outputs.cache-hit-success != 'true'
|
||||||
|
|
||||||
- name: Formatting (Black)
|
- name: Formatting (Ruff)
|
||||||
run: |
|
run: |
|
||||||
poetry run black . --check
|
poetry run ruff format . --check
|
||||||
|
|
||||||
- name: Lint (Ruff)
|
- name: Lint (Ruff)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
16
.github/workflows/partial-builder.yml
vendored
16
.github/workflows/partial-builder.yml
vendored
@@ -35,19 +35,16 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Override __init__.py
|
- name: Override __init__.py
|
||||||
run: |
|
run: |
|
||||||
echo "__version__ = \"${{ inputs.tag }}\"" > ./mealie/__init__.py
|
echo "__version__ = \"${{ inputs.tag }}\"" > ./mealie/__init__.py
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- uses: depot/setup-action@v1
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
|
- name: Build and push Docker image, via Depot.dev
|
||||||
|
uses: depot/build-push-action@v1
|
||||||
with:
|
with:
|
||||||
|
project: srzjb6mhzm
|
||||||
file: ./docker/Dockerfile
|
file: ./docker/Dockerfile
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
@@ -58,6 +55,3 @@ jobs:
|
|||||||
${{ inputs.tags }}
|
${{ inputs.tags }}
|
||||||
build-args: |
|
build-args: |
|
||||||
COMMIT=${{ github.sha }}
|
COMMIT=${{ github.sha }}
|
||||||
# https://docs.docker.com/build/ci/github-actions/cache/#github-cache
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|||||||
41
.github/workflows/pull-request-lint.yml
vendored
Normal file
41
.github/workflows/pull-request-lint.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Pull Request Linter
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
pull_request:
|
||||||
|
types: [edited] # This captures the PR title changing
|
||||||
|
branches:
|
||||||
|
- mealie-next
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-title:
|
||||||
|
name: Validate PR title
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# https://github.com/amannn/action-semantic-pull-request
|
||||||
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
# Configure which types are allowed (newline-delimited).
|
||||||
|
# Default: https://github.com/commitizen/conventional-commit-types
|
||||||
|
types: |
|
||||||
|
fix
|
||||||
|
feat
|
||||||
|
docs
|
||||||
|
chore
|
||||||
|
# Configure which scopes are allowed (newline-delimited).
|
||||||
|
# These are regex patterns auto-wrapped in `^ $`.
|
||||||
|
scopes: |
|
||||||
|
deps
|
||||||
|
auto
|
||||||
|
l10n
|
||||||
|
# Configure that a scope must always be provided.
|
||||||
|
requireScope: false
|
||||||
|
# If the PR contains one of these newline-delimited labels, the
|
||||||
|
# validation is skipped. If you want to rerun the validation when
|
||||||
|
# labels change, you might want to use the `labeled` and `unlabeled`
|
||||||
|
# event triggers in your workflow.
|
||||||
|
ignoreLabels: |
|
||||||
|
bot
|
||||||
|
ignore-semantic-pull-request
|
||||||
4
.github/workflows/pull-requests.yml
vendored
4
.github/workflows/pull-requests.yml
vendored
@@ -6,6 +6,10 @@ on:
|
|||||||
- mealie-next
|
- mealie-next
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
pull-request-lint:
|
||||||
|
name: "Lint PR"
|
||||||
|
uses: ./.github/workflows/pull-request-lint.yml
|
||||||
|
|
||||||
backend-tests:
|
backend-tests:
|
||||||
name: "Backend Server Tests"
|
name: "Backend Server Tests"
|
||||||
uses: ./.github/workflows/partial-backend.yml
|
uses: ./.github/workflows/partial-backend.yml
|
||||||
|
|||||||
37
.github/workflows/release.yml
vendored
37
.github/workflows/release.yml
vendored
@@ -17,6 +17,11 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
# The id-token write permission is needed to connect to Depot.dev
|
||||||
|
# as part of the partial-builder.yml action. It needs to be declared
|
||||||
|
# in the parent action, as noted here:
|
||||||
|
# https://github.com/orgs/community/discussions/76409#discussioncomment-8131390
|
||||||
|
id-token: write
|
||||||
name: Build Tagged Release
|
name: Build Tagged Release
|
||||||
uses: ./.github/workflows/partial-builder.yml
|
uses: ./.github/workflows/partial-builder.yml
|
||||||
needs:
|
needs:
|
||||||
@@ -42,4 +47,34 @@ jobs:
|
|||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@0.3.2
|
uses: Ilshidur/action-discord@0.3.2
|
||||||
with:
|
with:
|
||||||
args: "🚀 Version {{ EVENT_PAYLOAD.release.tag_name }} of Mealie has been released. See the release notes https://github.com/hay-kot/mealie/releases/tag/{{ EVENT_PAYLOAD.release.tag_name }}"
|
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:
|
||||||
|
- build-release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout 🛎
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Modify version strings
|
||||||
|
run: |
|
||||||
|
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:${{ github.event.release.tag_name }}/' docs/docs/documentation/getting-started/installation/sqlite.md
|
||||||
|
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:${{ github.event.release.tag_name }}/' docs/docs/documentation/getting-started/installation/postgres.md
|
||||||
|
|
||||||
|
- 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 }}"
|
||||||
|
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 }}"
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ repos:
|
|||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
exclude: ^tests/data/
|
exclude: ^tests/data/
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: 23.1.0
|
# Ruff version.
|
||||||
|
rev: v0.4.3
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: ruff-format
|
||||||
|
|||||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -60,8 +60,5 @@
|
|||||||
},
|
},
|
||||||
"[vue]": {
|
"[vue]": {
|
||||||
"editor.formatOnSave": false
|
"editor.formatOnSave": false
|
||||||
},
|
}
|
||||||
"[python]": {
|
|
||||||
"editor.defaultFormatter": "ms-python.black-formatter",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
10
.vscode/tasks.json
vendored
10
.vscode/tasks.json
vendored
@@ -24,16 +24,6 @@
|
|||||||
},
|
},
|
||||||
"problemMatcher": []
|
"problemMatcher": []
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"label": "Init Database",
|
|
||||||
"command": "poetry run python mealie/db/init_db.py",
|
|
||||||
"type": "shell",
|
|
||||||
"presentation": {
|
|
||||||
"reveal": "always",
|
|
||||||
"group": "groupA"
|
|
||||||
},
|
|
||||||
"problemMatcher": []
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"label": "Dev: Start Frontend",
|
"label": "Dev: Start Frontend",
|
||||||
"command": "task ui",
|
"command": "task ui",
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -18,9 +18,9 @@
|
|||||||
<h3 align="center">Mealie</h3>
|
<h3 align="center">Mealie</h3>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
A Place for All Your Recipes
|
A Place For All Your Recipes
|
||||||
<br />
|
<br />
|
||||||
<a href="https://nightly.mealie.io"><strong>Explore the docs »</strong></a>
|
<a href="https://docs.mealie.io/"><strong>Explore the docs »</strong></a>
|
||||||
<a href="https://github.com/mealie-recipes/mealie">
|
<a href="https://github.com/mealie-recipes/mealie">
|
||||||
</a>
|
</a>
|
||||||
<br />
|
<br />
|
||||||
@@ -38,12 +38,20 @@
|
|||||||
|
|
||||||
# About The Project
|
# About The Project
|
||||||
|
|
||||||
Mealie is a self hosted recipe manager and meal planner with a RestAPI backend and a reactive frontend application built in Vue for a pleasant user experience for the whole family. Easily add recipes into your database by providing the URL and Mealie will automatically import the relevant data, or add a family recipe with the UI editor. Mealie also provides an API for interactions from 3rd party applications.
|
Mealie is a self hosted recipe manager, meal planner and shopping list with a RestAPI backend and a reactive frontend built in Vue for a pleasant user experience for the whole family. Easily add recipes into your database by providing the URL and Mealie will automatically import the relevant data, or add a family recipe with the UI editor. Mealie also provides an API for interactions from 3rd party applications.
|
||||||
|
|
||||||
- [Remember to join the Discord](https://discord.gg/QuStdQGSGK)!
|
- [Remember to join the Discord](https://discord.gg/QuStdQGSGK)!
|
||||||
- [Documentation](https://nightly.mealie.io)
|
- [Documentation](https://docs.mealie.io/)
|
||||||
|
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
- Recipe imports: Create recipes, by **importing from a URL** or entering data manually
|
||||||
|
- Meal Planner: Use the **Meal Planner** to plan your what you'll cook for the next week
|
||||||
|
- Shopping List: Put the necessary ingredients on your **Shopping List**, organised into sections of your local supermarket
|
||||||
|
- Cookbooks: Group recipes into **Cookbooks** based on your own criteria
|
||||||
|
- Docker: Easy **Docker** deployment
|
||||||
|
- Localisation: **Translations** for 35+ languages
|
||||||
|
|
||||||
<!-- CONTRIBUTING -->
|
<!-- CONTRIBUTING -->
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -58,7 +66,7 @@ If you are not a coder, you can still contribute financially. Financial contribu
|
|||||||
|
|
||||||
### Translations
|
### Translations
|
||||||
|
|
||||||
Translations can be a great way for **non-coders** to contribute to project. We use [Crowdin](https://crowdin.com/project/mealie) to allow several contributors to work on translating Mealie. You can simply help by voting for your preferred translations, or even by completely translating Mealie into a new language.
|
Translations can be a great way for **non-coders** to contribute to the project. We use [Crowdin](https://crowdin.com/project/mealie) to allow several contributors to work on translating Mealie. You can simply help by voting for your preferred translations, or even by completely translating Mealie into a new language.
|
||||||
|
|
||||||
For more information, check out the translation page on the [contributor's guide](https://nightly.mealie.io/contributors/translating/).
|
For more information, check out the translation page on the [contributor's guide](https://nightly.mealie.io/contributors/translating/).
|
||||||
|
|
||||||
@@ -71,12 +79,9 @@ Distributed under the AGPL License. See `LICENSE` for more information.
|
|||||||
|
|
||||||
Huge thanks to all the sponsors of this project on [Github Sponsors](https://github.com/sponsors/hay-kot) and Buy Me a Coffee. Without you, this project would surely not be possible.
|
Huge thanks to all the sponsors of this project on [Github Sponsors](https://github.com/sponsors/hay-kot) and Buy Me a Coffee. Without you, this project would surely not be possible.
|
||||||
|
|
||||||
Thanks to Linode for providing Hosting for the Demo, Beta, and Documentation sites! Another big thanks to JetBrains for providing their IDEs for development.
|
Thanks to Depot for providing build instances for our Docker image builds.
|
||||||
|
|
||||||
<div align='center'>
|
[](https://depot.dev?utm_source=Mealie)
|
||||||
<img height="100" src="docs/docs/assets/img/sponsors-linode.svg" />
|
|
||||||
<img height="100" src="docs/docs/assets/img/sponsors-jetbrains.png" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -91,7 +96,7 @@ Thanks to Linode for providing Hosting for the Demo, Beta, and Documentation sit
|
|||||||
[stars-url]: https://github.com/mealie-recipes/mealie/stargazers
|
[stars-url]: https://github.com/mealie-recipes/mealie/stargazers
|
||||||
[issues-shield]: https://img.shields.io/github/issues/mealie-recipes/mealie.svg?style=flat-square
|
[issues-shield]: https://img.shields.io/github/issues/mealie-recipes/mealie.svg?style=flat-square
|
||||||
[issues-url]: https://github.com/mealie-recipes/mealie/issues
|
[issues-url]: https://github.com/mealie-recipes/mealie/issues
|
||||||
[latest-release-shield]: https://img.shields.io/github/v/release/mealie-recipes/mealie.svg?style=flat-square
|
[latest-release-shield]: https://img.shields.io/github/v/release/mealie-recipes/mealie?style=flat-square&label=latest%20release
|
||||||
[latest-release-url]: https://img.shields.io/github/v/release/mealie-recipes/mealie
|
[latest-release-url]: https://img.shields.io/github/v/release/mealie-recipes/mealie
|
||||||
[license-shield]: https://img.shields.io/github/license/mealie-recipes/mealie.svg?style=flat-square
|
[license-shield]: https://img.shields.io/github/license/mealie-recipes/mealie.svg?style=flat-square
|
||||||
[license-url]: https://github.com/mealie-recipes/mealie/blob/mealie-next/LICENSE
|
[license-url]: https://github.com/mealie-recipes/mealie/blob/mealie-next/LICENSE
|
||||||
|
|||||||
15
Taskfile.yml
15
Taskfile.yml
@@ -14,7 +14,9 @@ env:
|
|||||||
SMTP_HOST: localhost
|
SMTP_HOST: localhost
|
||||||
SMTP_PORT: 1025
|
SMTP_PORT: 1025
|
||||||
SMTP_FROM_NAME: MealieDev
|
SMTP_FROM_NAME: MealieDev
|
||||||
|
SMTP_FROM_EMAIL: mealie@example.com
|
||||||
SMTP_AUTH_STRATEGY: NONE
|
SMTP_AUTH_STRATEGY: NONE
|
||||||
|
BASE_URL: http://localhost:3000
|
||||||
LANG: en-US
|
LANG: en-US
|
||||||
|
|
||||||
# loads .env file if it exists
|
# loads .env file if it exists
|
||||||
@@ -72,6 +74,7 @@ tasks:
|
|||||||
desc: run code generators
|
desc: run code generators
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run python dev/code-generation/main.py
|
- poetry run python dev/code-generation/main.py
|
||||||
|
- task: py:format
|
||||||
|
|
||||||
dev:services:
|
dev:services:
|
||||||
desc: starts postgres and mailpit containers
|
desc: starts postgres and mailpit containers
|
||||||
@@ -103,12 +106,12 @@ tasks:
|
|||||||
py:format:
|
py:format:
|
||||||
desc: runs python code formatter
|
desc: runs python code formatter
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run black mealie
|
- poetry run ruff format .
|
||||||
|
|
||||||
py:lint:
|
py:lint:
|
||||||
desc: runs python linter
|
desc: runs python linter
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run ruff mealie
|
- poetry run ruff check mealie
|
||||||
|
|
||||||
py:check:
|
py:check:
|
||||||
desc: runs all linters, type checkers, and formatters
|
desc: runs all linters, type checkers, and formatters
|
||||||
@@ -130,7 +133,6 @@ tasks:
|
|||||||
py:
|
py:
|
||||||
desc: runs the backend server
|
desc: runs the backend server
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run python mealie/db/init_db.py
|
|
||||||
- poetry run python mealie/app.py
|
- poetry run python mealie/app.py
|
||||||
|
|
||||||
py:postgres:
|
py:postgres:
|
||||||
@@ -143,9 +145,14 @@ tasks:
|
|||||||
POSTGRES_PORT: 5432
|
POSTGRES_PORT: 5432
|
||||||
POSTGRES_DB: mealie
|
POSTGRES_DB: mealie
|
||||||
cmds:
|
cmds:
|
||||||
- poetry run python mealie/db/init_db.py
|
|
||||||
- poetry run python mealie/app.py
|
- poetry run python mealie/app.py
|
||||||
|
|
||||||
|
py:migrate:
|
||||||
|
desc: generates a new migration file e.g. task py:migrate:generate "add new column"
|
||||||
|
cmds:
|
||||||
|
- poetry run alembic revision --autogenerate -m "{{ .CLI_ARGS }}"
|
||||||
|
- task: py:format
|
||||||
|
|
||||||
ui:build:
|
ui:build:
|
||||||
desc: builds the frontend in frontend/dist
|
desc: builds the frontend in frontend/dist
|
||||||
dir: frontend
|
dir: frontend
|
||||||
|
|||||||
12
alembic.ini
12
alembic.ini
@@ -58,15 +58,3 @@ sqlalchemy.url =
|
|||||||
# post_write_hooks defines scripts or Python functions that are run
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
# on newly generated revision scripts. See the documentation for further
|
# on newly generated revision scripts. See the documentation for further
|
||||||
# detail and examples
|
# detail and examples
|
||||||
|
|
||||||
hooks = isort, black
|
|
||||||
|
|
||||||
# format using "isort" - use the console_scripts runner, against the "isort" entrypoint
|
|
||||||
isort.type = console_scripts
|
|
||||||
isort.entrypoint = isort
|
|
||||||
isort.options = REVISION_SCRIPT_FILENAME
|
|
||||||
|
|
||||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
|
||||||
black.type = console_scripts
|
|
||||||
black.entrypoint = black
|
|
||||||
black.options = REVISION_SCRIPT_FILENAME
|
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ target_metadata = SqlAlchemyBase.metadata
|
|||||||
|
|
||||||
# Set DB url from config
|
# Set DB url from config
|
||||||
settings = get_app_settings()
|
settings = get_app_settings()
|
||||||
config.set_main_option("sqlalchemy.url", settings.DB_URL)
|
|
||||||
|
if not settings.DB_URL:
|
||||||
|
raise Exception("DB URL not set in config")
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.DB_URL.replace("%", "%%"))
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline():
|
def run_migrations_offline():
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""Initial tables
|
"""Initial tables
|
||||||
|
|
||||||
Revision ID: 6b0f5f32d602
|
Revision ID: 6b0f5f32d602
|
||||||
Revises:
|
Revises:
|
||||||
Create Date: 2022-02-21 19:56:24.351115
|
Create Date: 2022-02-21 19:56:24.351115
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import engine_from_config
|
from sqlalchemy import engine_from_config
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: 6b0f5f32d602
|
|||||||
Create Date: 2022-03-23 17:43:34.727829
|
Create Date: 2022-03-23 17:43:34.727829
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: 263dd6707191
|
|||||||
Create Date: 2022-03-27 19:30:28.545846
|
Create Date: 2022-03-27 19:30:28.545846
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: f1a2dbee5fe9
|
|||||||
Create Date: 2022-03-31 19:19:55.428965
|
Create Date: 2022-03-31 19:19:55.428965
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
import mealie.db.migration_types
|
import mealie.db.migration_types
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: 59eb59135381
|
|||||||
Create Date: 2022-04-03 10:48:51.379968
|
Create Date: 2022-04-03 10:48:51.379968
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
import mealie.db.migration_types # noqa: F401
|
import mealie.db.migration_types # noqa: F401
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: 09dfc897ad62
|
|||||||
Create Date: 2022-06-01 11:12:06.748383
|
Create Date: 2022-06-01 11:12:06.748383
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Revises: ab0bae02578f
|
|||||||
Create Date: 2022-06-15 21:05:34.851857
|
Create Date: 2022-06-15 21:05:34.851857
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: f30cf048c228
|
|||||||
Create Date: 2022-08-12 19:05:59.776361
|
Create Date: 2022-08-12 19:05:59.776361
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: 188374910655
|
|||||||
Create Date: 2022-08-05 17:07:07.389271
|
Create Date: 2022-08-05 17:07:07.389271
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: 089bfa50d0ed
|
|||||||
Create Date: 2022-08-29 13:57:40.452245
|
Create Date: 2022-08-29 13:57:40.452245
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
import mealie.db.migration_types
|
import mealie.db.migration_types
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: 44e8d670719d
|
|||||||
Create Date: 2022-09-27 14:53:14.111054
|
Create Date: 2022-09-27 14:53:14.111054
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
import mealie.db.migration_types
|
import mealie.db.migration_types
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: 2ea7a807915c
|
|||||||
Create Date: 2022-11-03 13:10:24.811134
|
Create Date: 2022-11-03 13:10:24.811134
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: 1923519381ad
|
|||||||
Create Date: 2022-11-22 03:42:45.494567
|
Create Date: 2022-11-22 03:42:45.494567
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: 167eb69066ad
|
|||||||
Create Date: 2023-01-21 16:54:44.368768
|
Create Date: 2023-01-21 16:54:44.368768
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
import mealie.db.migration_types
|
import mealie.db.migration_types
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: ff5f73b01a7a
|
|||||||
Create Date: 2023-02-10 21:18:32.405130
|
Create Date: 2023-02-10 21:18:32.405130
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
import mealie.db.migration_types
|
import mealie.db.migration_types
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: 16160bf731a0
|
|||||||
Create Date: 2023-02-14 20:45:41.102571
|
Create Date: 2023-02-14 20:45:41.102571
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import orm, select
|
from sqlalchemy import orm, select
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ Revises: 5ab195a474eb
|
|||||||
Create Date: 2023-21-02 22:03:19.837244
|
Create Date: 2023-21-02 22:03:19.837244
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy import orm
|
||||||
|
|
||||||
import mealie.db.migration_types
|
import mealie.db.migration_types
|
||||||
from alembic import op
|
from alembic import op
|
||||||
@@ -22,8 +23,10 @@ branch_labels = None
|
|||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def populate_shopping_lists_multi_purpose_labels(shopping_lists_multi_purpose_labels_table: sa.Table, session: Session):
|
def populate_shopping_lists_multi_purpose_labels(
|
||||||
shopping_lists = session.query(ShoppingList).all()
|
shopping_lists_multi_purpose_labels_table: sa.Table, session: orm.Session
|
||||||
|
):
|
||||||
|
shopping_lists = session.query(ShoppingList).options(orm.load_only(ShoppingList.id, ShoppingList.group_id)).all()
|
||||||
|
|
||||||
shopping_lists_labels_data: list[dict] = []
|
shopping_lists_labels_data: list[dict] = []
|
||||||
for shopping_list in shopping_lists:
|
for shopping_list in shopping_lists:
|
||||||
@@ -59,7 +62,7 @@ def upgrade():
|
|||||||
)
|
)
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
session = Session(bind=op.get_bind())
|
session = orm.Session(bind=op.get_bind())
|
||||||
populate_shopping_lists_multi_purpose_labels(shopping_lists_multi_purpose_labels_table, session)
|
populate_shopping_lists_multi_purpose_labels(shopping_lists_multi_purpose_labels_table, session)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: b04a08da2108
|
|||||||
Create Date: 2023-02-22 21:45:52.900964
|
Create Date: 2023-02-22 21:45:52.900964
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
import mealie.db.migration_types
|
import mealie.db.migration_types
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: 38514b39a824
|
|||||||
Create Date: 2023-04-13 06:47:04.617131
|
Create Date: 2023-04-13 06:47:04.617131
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
import mealie.db.migration_types
|
import mealie.db.migration_types
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: b3dbb554ba53
|
|||||||
Create Date: 2023-08-06 21:00:34.582905
|
Create Date: 2023-08-06 21:00:34.582905
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from slugify import slugify
|
from slugify import slugify
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: 04ac51cbe9a4
|
|||||||
Create Date: 2023-08-14 19:30:49.103185
|
Create Date: 2023-08-14 19:30:49.103185
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: 1825b5225403
|
|||||||
Create Date: 2023-08-15 16:25:07.058929
|
Create Date: 2023-08-15 16:25:07.058929
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: bcfdad6b7355
|
|||||||
Create Date: 2023-09-01 14:55:42.166766
|
Create Date: 2023-09-01 14:55:42.166766
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import orm, select
|
from sqlalchemy import orm, select
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: 0341b154f79a
|
|||||||
Create Date: 2023-10-04 14:29:26.688065
|
Create Date: 2023-10-04 14:29:26.688065
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -58,7 +59,12 @@ def _resolve_duplicate_food(
|
|||||||
keep_food_id: UUID4,
|
keep_food_id: UUID4,
|
||||||
dupe_food_id: UUID4,
|
dupe_food_id: UUID4,
|
||||||
):
|
):
|
||||||
for shopping_list_item in session.query(ShoppingListItem).filter_by(food_id=dupe_food_id).all():
|
for shopping_list_item in (
|
||||||
|
session.query(ShoppingListItem)
|
||||||
|
.options(load_only(ShoppingListItem.id, ShoppingListItem.food_id))
|
||||||
|
.filter_by(food_id=dupe_food_id)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
shopping_list_item.food_id = keep_food_id
|
shopping_list_item.food_id = keep_food_id
|
||||||
|
|
||||||
for recipe_ingredient in (
|
for recipe_ingredient in (
|
||||||
@@ -81,10 +87,20 @@ def _resolve_duplicate_unit(
|
|||||||
keep_unit_id: UUID4,
|
keep_unit_id: UUID4,
|
||||||
dupe_unit_id: UUID4,
|
dupe_unit_id: UUID4,
|
||||||
):
|
):
|
||||||
for shopping_list_item in session.query(ShoppingListItem).filter_by(unit_id=dupe_unit_id).all():
|
for shopping_list_item in (
|
||||||
|
session.query(ShoppingListItem)
|
||||||
|
.options(load_only(ShoppingListItem.id, ShoppingListItem.unit_id))
|
||||||
|
.filter_by(unit_id=dupe_unit_id)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
shopping_list_item.unit_id = keep_unit_id
|
shopping_list_item.unit_id = keep_unit_id
|
||||||
|
|
||||||
for recipe_ingredient in session.query(RecipeIngredientModel).filter_by(unit_id=dupe_unit_id).all():
|
for recipe_ingredient in (
|
||||||
|
session.query(RecipeIngredientModel)
|
||||||
|
.options(load_only(RecipeIngredientModel.id, RecipeIngredientModel.unit_id))
|
||||||
|
.filter_by(unit_id=dupe_unit_id)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
recipe_ingredient.unit_id = keep_unit_id
|
recipe_ingredient.unit_id = keep_unit_id
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -99,10 +115,20 @@ def _resolve_duplicate_label(
|
|||||||
keep_label_id: UUID4,
|
keep_label_id: UUID4,
|
||||||
dupe_label_id: UUID4,
|
dupe_label_id: UUID4,
|
||||||
):
|
):
|
||||||
for shopping_list_item in session.query(ShoppingListItem).filter_by(label_id=dupe_label_id).all():
|
for shopping_list_item in (
|
||||||
|
session.query(ShoppingListItem)
|
||||||
|
.options(load_only(ShoppingListItem.id, ShoppingListItem.label_id))
|
||||||
|
.filter_by(label_id=dupe_label_id)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
shopping_list_item.label_id = keep_label_id
|
shopping_list_item.label_id = keep_label_id
|
||||||
|
|
||||||
for ingredient_food in session.query(IngredientFoodModel).filter_by(label_id=dupe_label_id).all():
|
for ingredient_food in (
|
||||||
|
session.query(IngredientFoodModel)
|
||||||
|
.options(load_only(IngredientFoodModel.id, IngredientFoodModel.label_id))
|
||||||
|
.filter_by(label_id=dupe_label_id)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
ingredient_food.label_id = keep_label_id
|
ingredient_food.label_id = keep_label_id
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Revises: dded3119c1fe
|
|||||||
Create Date: 2023-10-19 19:22:55.369319
|
Create Date: 2023-10-19 19:22:55.369319
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
import mealie.db.migration_types
|
import mealie.db.migration_types
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""added user to shopping list
|
||||||
|
|
||||||
|
Revision ID: 2298bb460ffd
|
||||||
|
Revises: ba1e4a6cfe99
|
||||||
|
Create Date: 2024-02-23 16:15:07.115641
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import orm
|
||||||
|
|
||||||
|
import mealie.db.migration_types
|
||||||
|
from alembic import op
|
||||||
|
from mealie.core.root_logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger()
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "2298bb460ffd"
|
||||||
|
down_revision = "ba1e4a6cfe99"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def is_postgres():
|
||||||
|
return op.get_context().dialect.name == "postgresql"
|
||||||
|
|
||||||
|
|
||||||
|
def find_user_id_for_group(group_id: UUID):
|
||||||
|
bind = op.get_bind()
|
||||||
|
session = orm.Session(bind=bind)
|
||||||
|
|
||||||
|
if is_postgres():
|
||||||
|
stmt = "SELECT id FROM users WHERE group_id=:group_id AND admin = TRUE LIMIT 1"
|
||||||
|
else:
|
||||||
|
stmt = "SELECT id FROM users WHERE group_id=:group_id AND admin = 1 LIMIT 1"
|
||||||
|
|
||||||
|
with session:
|
||||||
|
try:
|
||||||
|
# try to find an admin user
|
||||||
|
return session.execute(sa.text(stmt).bindparams(group_id=group_id)).scalar_one()
|
||||||
|
except orm.exc.NoResultFound:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
# fallback to any user
|
||||||
|
return session.execute(
|
||||||
|
sa.text("SELECT id FROM users WHERE group_id=:group_id LIMIT 1").bindparams(group_id=group_id)
|
||||||
|
).scalar_one()
|
||||||
|
except orm.exc.NoResultFound:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# no user could be found
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def populate_shopping_list_users():
|
||||||
|
bind = op.get_bind()
|
||||||
|
session = orm.Session(bind=bind)
|
||||||
|
|
||||||
|
with session:
|
||||||
|
list_ids_and_group_ids = session.execute(sa.text("SELECT id, group_id FROM shopping_lists")).all()
|
||||||
|
for list_id, group_id in list_ids_and_group_ids:
|
||||||
|
user_id = find_user_id_for_group(group_id)
|
||||||
|
if user_id:
|
||||||
|
session.execute(
|
||||||
|
sa.text(f"UPDATE shopping_lists SET user_id=:user_id WHERE id=:id").bindparams(
|
||||||
|
user_id=user_id, id=list_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"No user found for shopping list {list_id} with group {group_id}; deleting shopping list"
|
||||||
|
)
|
||||||
|
session.execute(sa.text(f"DELETE FROM shopping_lists WHERE id=:id").bindparams(id=list_id))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table("shopping_lists") as batch_op:
|
||||||
|
# allow nulls during migration
|
||||||
|
batch_op.add_column(sa.Column("user_id", mealie.db.migration_types.GUID(), nullable=True))
|
||||||
|
batch_op.create_index(op.f("ix_shopping_lists_user_id"), ["user_id"], unique=False)
|
||||||
|
batch_op.create_foreign_key("fk_user_shopping_lists", "users", ["user_id"], ["id"])
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
populate_shopping_list_users()
|
||||||
|
|
||||||
|
# forbid nulls after migration
|
||||||
|
with op.batch_alter_table("shopping_lists") as batch_op:
|
||||||
|
batch_op.alter_column("user_id", nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_constraint(None, "shopping_lists", type_="foreignkey")
|
||||||
|
op.drop_index(op.f("ix_shopping_lists_user_id"), table_name="shopping_lists")
|
||||||
|
op.drop_column("shopping_lists", "user_id")
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""add OIDC auth method
|
||||||
|
|
||||||
|
Revision ID: 09aba125b57a
|
||||||
|
Revises: 2298bb460ffd
|
||||||
|
Create Date: 2024-03-10 05:08:32.397027
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
import mealie.db.migration_types
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "09aba125b57a"
|
||||||
|
down_revision = "2298bb460ffd"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def is_postgres():
|
||||||
|
return op.get_context().dialect.name == "postgresql"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
if is_postgres():
|
||||||
|
op.execute("ALTER TYPE authmethod ADD VALUE 'OIDC'")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
pass
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""migrate favorites and ratings to user_ratings
|
||||||
|
|
||||||
|
Revision ID: d7c6efd2de42
|
||||||
|
Revises: 09aba125b57a
|
||||||
|
Create Date: 2024-03-18 02:28:15.896959
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from textwrap import dedent
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import orm
|
||||||
|
|
||||||
|
import mealie.db.migration_types
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "d7c6efd2de42"
|
||||||
|
down_revision = "09aba125b57a"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def is_postgres():
|
||||||
|
return op.get_context().dialect.name == "postgresql"
|
||||||
|
|
||||||
|
|
||||||
|
def new_user_rating(user_id: Any, recipe_id: Any, rating: float | None = None, is_favorite: bool = False):
|
||||||
|
if is_postgres():
|
||||||
|
id = str(uuid4())
|
||||||
|
else:
|
||||||
|
id = "%.32x" % uuid4().int
|
||||||
|
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
return {
|
||||||
|
"id": id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"rating": rating,
|
||||||
|
"is_favorite": is_favorite,
|
||||||
|
"created_at": now,
|
||||||
|
"update_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_user_favorites_to_user_ratings():
|
||||||
|
bind = op.get_bind()
|
||||||
|
session = orm.Session(bind=bind)
|
||||||
|
|
||||||
|
with session:
|
||||||
|
user_ids_and_recipe_ids = session.execute(sa.text("SELECT user_id, recipe_id FROM users_to_favorites")).all()
|
||||||
|
rows = [
|
||||||
|
new_user_rating(user_id, recipe_id, is_favorite=True)
|
||||||
|
for user_id, recipe_id in user_ids_and_recipe_ids
|
||||||
|
if user_id and recipe_id
|
||||||
|
]
|
||||||
|
|
||||||
|
if is_postgres():
|
||||||
|
query = dedent(
|
||||||
|
"""
|
||||||
|
INSERT INTO users_to_recipes (id, user_id, recipe_id, rating, is_favorite, created_at, update_at)
|
||||||
|
VALUES (:id, :user_id, :recipe_id, :rating, :is_favorite, :created_at, :update_at)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query = dedent(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO users_to_recipes
|
||||||
|
(id, user_id, recipe_id, rating, is_favorite, created_at, update_at)
|
||||||
|
VALUES (:id, :user_id, :recipe_id, :rating, :is_favorite, :created_at, :update_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
session.execute(sa.text(query), row)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_group_to_user_ratings(group_id: Any):
|
||||||
|
bind = op.get_bind()
|
||||||
|
session = orm.Session(bind=bind)
|
||||||
|
|
||||||
|
with session:
|
||||||
|
user_ids = (
|
||||||
|
session.execute(sa.text("SELECT id FROM users WHERE group_id=:group_id").bindparams(group_id=group_id))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
recipe_ids_ratings = session.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT id, rating FROM recipes WHERE group_id=:group_id AND rating > 0 AND rating IS NOT NULL"
|
||||||
|
).bindparams(group_id=group_id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Convert recipe ratings to user ratings. Since we don't know who
|
||||||
|
# rated the recipe initially, we copy the rating to all users.
|
||||||
|
rows: list[dict] = []
|
||||||
|
for recipe_id, rating in recipe_ids_ratings:
|
||||||
|
for user_id in user_ids:
|
||||||
|
rows.append(new_user_rating(user_id, recipe_id, rating, is_favorite=False))
|
||||||
|
|
||||||
|
if is_postgres():
|
||||||
|
insert_query = dedent(
|
||||||
|
"""
|
||||||
|
INSERT INTO users_to_recipes (id, user_id, recipe_id, rating, is_favorite, created_at, update_at)
|
||||||
|
VALUES (:id, :user_id, :recipe_id, :rating, :is_favorite, :created_at, :update_at)
|
||||||
|
ON CONFLICT (user_id, recipe_id) DO NOTHING;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
insert_query = dedent(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO users_to_recipes
|
||||||
|
(id, user_id, recipe_id, rating, is_favorite, created_at, update_at)
|
||||||
|
VALUES (:id, :user_id, :recipe_id, :rating, :is_favorite, :created_at, :update_at);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
update_query = dedent(
|
||||||
|
"""
|
||||||
|
UPDATE users_to_recipes
|
||||||
|
SET rating = :rating, update_at = :update_at
|
||||||
|
WHERE user_id = :user_id AND recipe_id = :recipe_id;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new user ratings with is_favorite set to False
|
||||||
|
for row in rows:
|
||||||
|
session.execute(sa.text(insert_query), row)
|
||||||
|
|
||||||
|
# Update existing user ratings with the correct rating
|
||||||
|
for row in rows:
|
||||||
|
session.execute(sa.text(update_query), row)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_to_user_ratings():
|
||||||
|
migrate_user_favorites_to_user_ratings()
|
||||||
|
|
||||||
|
bind = op.get_bind()
|
||||||
|
session = orm.Session(bind=bind)
|
||||||
|
|
||||||
|
with session:
|
||||||
|
group_ids = session.execute(sa.text("SELECT id FROM groups")).scalars().all()
|
||||||
|
|
||||||
|
for group_id in group_ids:
|
||||||
|
migrate_group_to_user_ratings(group_id)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"users_to_recipes",
|
||||||
|
sa.Column("user_id", mealie.db.migration_types.GUID(), nullable=False),
|
||||||
|
sa.Column("recipe_id", mealie.db.migration_types.GUID(), nullable=False),
|
||||||
|
sa.Column("rating", sa.Float(), nullable=True),
|
||||||
|
sa.Column("is_favorite", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("update_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["recipe_id"],
|
||||||
|
["recipes.id"],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"],
|
||||||
|
["users.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("user_id", "recipe_id", "id"),
|
||||||
|
sa.UniqueConstraint("user_id", "recipe_id", name="user_id_recipe_id_rating_key"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_users_to_recipes_created_at"), "users_to_recipes", ["created_at"], unique=False)
|
||||||
|
op.create_index(op.f("ix_users_to_recipes_is_favorite"), "users_to_recipes", ["is_favorite"], unique=False)
|
||||||
|
op.create_index(op.f("ix_users_to_recipes_rating"), "users_to_recipes", ["rating"], unique=False)
|
||||||
|
op.create_index(op.f("ix_users_to_recipes_recipe_id"), "users_to_recipes", ["recipe_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_users_to_recipes_user_id"), "users_to_recipes", ["user_id"], unique=False)
|
||||||
|
|
||||||
|
migrate_to_user_ratings()
|
||||||
|
|
||||||
|
if is_postgres():
|
||||||
|
op.drop_index("ix_users_to_favorites_recipe_id", table_name="users_to_favorites")
|
||||||
|
op.drop_index("ix_users_to_favorites_user_id", table_name="users_to_favorites")
|
||||||
|
op.alter_column("recipes", "rating", existing_type=sa.INTEGER(), type_=sa.Float(), existing_nullable=True)
|
||||||
|
else:
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_users_to_favorites_recipe_id")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_users_to_favorites_user_id")
|
||||||
|
with op.batch_alter_table("recipes") as batch_op:
|
||||||
|
batch_op.alter_column("rating", existing_type=sa.INTEGER(), type_=sa.Float(), existing_nullable=True)
|
||||||
|
|
||||||
|
op.drop_table("users_to_favorites")
|
||||||
|
op.create_index(op.f("ix_recipes_rating"), "recipes", ["rating"], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.alter_column(
|
||||||
|
"recipes_ingredients", "quantity", existing_type=sa.Float(), type_=sa.INTEGER(), existing_nullable=True
|
||||||
|
)
|
||||||
|
op.drop_index(op.f("ix_recipes_rating"), table_name="recipes")
|
||||||
|
op.alter_column("recipes", "rating", existing_type=sa.Float(), type_=sa.INTEGER(), existing_nullable=True)
|
||||||
|
op.create_unique_constraint("ingredient_units_name_group_id_key", "ingredient_units", ["name", "group_id"])
|
||||||
|
op.create_unique_constraint("ingredient_foods_name_group_id_key", "ingredient_foods", ["name", "group_id"])
|
||||||
|
op.create_table(
|
||||||
|
"users_to_favorites",
|
||||||
|
sa.Column("user_id", sa.CHAR(length=32), nullable=True),
|
||||||
|
sa.Column("recipe_id", sa.CHAR(length=32), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["recipe_id"],
|
||||||
|
["recipes.id"],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"],
|
||||||
|
["users.id"],
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("user_id", "recipe_id", name="user_id_recipe_id_key"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_to_favorites_user_id", "users_to_favorites", ["user_id"], unique=False)
|
||||||
|
op.create_index("ix_users_to_favorites_recipe_id", "users_to_favorites", ["recipe_id"], unique=False)
|
||||||
|
op.drop_index(op.f("ix_users_to_recipes_user_id"), table_name="users_to_recipes")
|
||||||
|
op.drop_index(op.f("ix_users_to_recipes_recipe_id"), table_name="users_to_recipes")
|
||||||
|
op.drop_index(op.f("ix_users_to_recipes_rating"), table_name="users_to_recipes")
|
||||||
|
op.drop_index(op.f("ix_users_to_recipes_is_favorite"), table_name="users_to_recipes")
|
||||||
|
op.drop_index(op.f("ix_users_to_recipes_created_at"), table_name="users_to_recipes")
|
||||||
|
op.drop_table("users_to_recipes")
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""add group recipe actions
|
||||||
|
|
||||||
|
Revision ID: 7788478a0338
|
||||||
|
Revises: d7c6efd2de42
|
||||||
|
Create Date: 2024-04-07 01:05:20.816270
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
import mealie.db.migration_types
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "7788478a0338"
|
||||||
|
down_revision = "d7c6efd2de42"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"recipe_actions",
|
||||||
|
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
|
||||||
|
sa.Column("group_id", mealie.db.migration_types.GUID(), nullable=False),
|
||||||
|
sa.Column("action_type", sa.String(), nullable=False),
|
||||||
|
sa.Column("title", sa.String(), nullable=False),
|
||||||
|
sa.Column("url", sa.String(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("update_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["group_id"],
|
||||||
|
["groups.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_recipe_actions_action_type"), "recipe_actions", ["action_type"], unique=False)
|
||||||
|
op.create_index(op.f("ix_recipe_actions_created_at"), "recipe_actions", ["created_at"], unique=False)
|
||||||
|
op.create_index(op.f("ix_recipe_actions_group_id"), "recipe_actions", ["group_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_recipe_actions_title"), "recipe_actions", ["title"], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f("ix_recipe_actions_title"), table_name="recipe_actions")
|
||||||
|
op.drop_index(op.f("ix_recipe_actions_group_id"), table_name="recipe_actions")
|
||||||
|
op.drop_index(op.f("ix_recipe_actions_created_at"), table_name="recipe_actions")
|
||||||
|
op.drop_index(op.f("ix_recipe_actions_action_type"), table_name="recipe_actions")
|
||||||
|
op.drop_table("recipe_actions")
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
preserve_hierarchy: false
|
preserve_hierarchy: false
|
||||||
|
pull_request_title: "chore(l10n): New Crowdin updates"
|
||||||
|
pull_request_labels: [
|
||||||
|
"l10n"
|
||||||
|
]
|
||||||
files:
|
files:
|
||||||
- source: /frontend/lang/messages/en-US.json
|
- source: /frontend/lang/messages/en-US.json
|
||||||
translation: /frontend/lang/messages/%locale%.json
|
translation: /frontend/lang/messages/%locale%.json
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, ConfigDict
|
||||||
from utils import PROJECT_DIR, CodeTemplates, HTTPRequest, RouteObject
|
from utils import PROJECT_DIR, CodeTemplates, HTTPRequest, RouteObject, RequestType
|
||||||
|
|
||||||
CWD = Path(__file__).parent
|
CWD = Path(__file__).parent
|
||||||
|
|
||||||
@@ -12,23 +12,25 @@ OUTFILE = PROJECT_DIR / "tests" / "utils" / "api_routes" / "__init__.py"
|
|||||||
|
|
||||||
|
|
||||||
class PathObject(BaseModel):
|
class PathObject(BaseModel):
|
||||||
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
route_object: RouteObject
|
route_object: RouteObject
|
||||||
http_verbs: list[HTTPRequest]
|
http_verbs: list[HTTPRequest]
|
||||||
|
|
||||||
class Config:
|
|
||||||
arbitrary_types_allowed = True
|
|
||||||
|
|
||||||
|
|
||||||
def get_path_objects(app: FastAPI):
|
def get_path_objects(app: FastAPI):
|
||||||
paths = []
|
paths = []
|
||||||
|
|
||||||
for key, value in app.openapi().items():
|
for key, value in app.openapi().items():
|
||||||
if key == "paths":
|
if key == "paths":
|
||||||
for key, value in value.items():
|
for key, value2 in value.items():
|
||||||
|
verbs = []
|
||||||
|
for k, v in value2.items():
|
||||||
|
verbs.append(HTTPRequest(request_type=k, **v))
|
||||||
|
|
||||||
paths.append(
|
paths.append(
|
||||||
PathObject(
|
PathObject(
|
||||||
route_object=RouteObject(key),
|
route_object=RouteObject(key),
|
||||||
http_verbs=[HTTPRequest(request_type=k, **v) for k, v in value.items()],
|
http_verbs=verbs,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
import dotenv
|
import dotenv
|
||||||
import requests
|
import requests
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from pydantic import Extra
|
from pydantic import ConfigDict
|
||||||
from requests import Response
|
from requests import Response
|
||||||
from utils import CodeDest, CodeKeys, inject_inline, log
|
from utils import CodeDest, CodeKeys, inject_inline, log
|
||||||
|
|
||||||
@@ -35,18 +35,24 @@ LOCALE_DATA: dict[str, LocaleData] = {
|
|||||||
"es-ES": LocaleData(name="Español (Spanish)"),
|
"es-ES": LocaleData(name="Español (Spanish)"),
|
||||||
"fi-FI": LocaleData(name="Suomi (Finnish)"),
|
"fi-FI": LocaleData(name="Suomi (Finnish)"),
|
||||||
"fr-FR": LocaleData(name="Français (French)"),
|
"fr-FR": LocaleData(name="Français (French)"),
|
||||||
|
"gl-ES": LocaleData(name="Galego (Galician)"),
|
||||||
"he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"),
|
"he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"),
|
||||||
|
"hr-HR": LocaleData(name="Hrvatski (Croatian)"),
|
||||||
"hu-HU": LocaleData(name="Magyar (Hungarian)"),
|
"hu-HU": LocaleData(name="Magyar (Hungarian)"),
|
||||||
|
"is-IS": LocaleData(name="Íslenska (Icelandic)"),
|
||||||
"it-IT": LocaleData(name="Italiano (Italian)"),
|
"it-IT": LocaleData(name="Italiano (Italian)"),
|
||||||
"ja-JP": LocaleData(name="日本語 (Japanese)"),
|
"ja-JP": LocaleData(name="日本語 (Japanese)"),
|
||||||
"ko-KR": LocaleData(name="한국어 (Korean)"),
|
"ko-KR": LocaleData(name="한국어 (Korean)"),
|
||||||
"no-NO": LocaleData(name="Norsk (Norwegian)"),
|
"lt-LT": LocaleData(name="Lietuvių (Lithuanian)"),
|
||||||
|
"lv-LV": LocaleData(name="Latviešu (Latvian)"),
|
||||||
"nl-NL": LocaleData(name="Nederlands (Dutch)"),
|
"nl-NL": LocaleData(name="Nederlands (Dutch)"),
|
||||||
|
"no-NO": LocaleData(name="Norsk (Norwegian)"),
|
||||||
"pl-PL": LocaleData(name="Polski (Polish)"),
|
"pl-PL": LocaleData(name="Polski (Polish)"),
|
||||||
"pt-BR": LocaleData(name="Português do Brasil (Brazilian Portuguese)"),
|
"pt-BR": LocaleData(name="Português do Brasil (Brazilian Portuguese)"),
|
||||||
"pt-PT": LocaleData(name="Português (Portuguese)"),
|
"pt-PT": LocaleData(name="Português (Portuguese)"),
|
||||||
"ro-RO": LocaleData(name="Română (Romanian)"),
|
"ro-RO": LocaleData(name="Română (Romanian)"),
|
||||||
"ru-RU": LocaleData(name="Pусский (Russian)"),
|
"ru-RU": LocaleData(name="Pусский (Russian)"),
|
||||||
|
"sl-SI": LocaleData(name="Slovenščina (Slovenian)"),
|
||||||
"sr-SP": LocaleData(name="српски (Serbian)"),
|
"sr-SP": LocaleData(name="српски (Serbian)"),
|
||||||
"sv-SE": LocaleData(name="Svenska (Swedish)"),
|
"sv-SE": LocaleData(name="Svenska (Swedish)"),
|
||||||
"tr-TR": LocaleData(name="Türkçe (Turkish)"),
|
"tr-TR": LocaleData(name="Türkçe (Turkish)"),
|
||||||
@@ -56,7 +62,7 @@ LOCALE_DATA: dict[str, LocaleData] = {
|
|||||||
"zh-TW": LocaleData(name="繁體中文 (Chinese traditional)"),
|
"zh-TW": LocaleData(name="繁體中文 (Chinese traditional)"),
|
||||||
}
|
}
|
||||||
|
|
||||||
LOCALE_TEMPLATE = """// This Code is auto generated by gen_global_components.py
|
LOCALE_TEMPLATE = """// This Code is auto generated by gen_ts_locales.py
|
||||||
export const LOCALES = [{% for locale in locales %}
|
export const LOCALES = [{% for locale in locales %}
|
||||||
{
|
{
|
||||||
name: "{{ locale.name }}",
|
name: "{{ locale.name }}",
|
||||||
@@ -70,6 +76,8 @@ export const LOCALES = [{% for locale in locales %}
|
|||||||
|
|
||||||
|
|
||||||
class TargetLanguage(MealieModel):
|
class TargetLanguage(MealieModel):
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
locale: str
|
locale: str
|
||||||
@@ -78,10 +86,6 @@ class TargetLanguage(MealieModel):
|
|||||||
twoLettersCode: str
|
twoLettersCode: str
|
||||||
progress: float = 0.0
|
progress: float = 0.0
|
||||||
|
|
||||||
class Config:
|
|
||||||
extra = Extra.allow
|
|
||||||
allow_population_by_field_name = True
|
|
||||||
|
|
||||||
|
|
||||||
class CrowdinApi:
|
class CrowdinApi:
|
||||||
project_name = "Mealie"
|
project_name = "Mealie"
|
||||||
@@ -152,6 +156,7 @@ PROJECT_DIR = Path(__file__).parent.parent.parent
|
|||||||
datetime_dir = PROJECT_DIR / "frontend" / "lang" / "dateTimeFormats"
|
datetime_dir = PROJECT_DIR / "frontend" / "lang" / "dateTimeFormats"
|
||||||
locales_dir = PROJECT_DIR / "frontend" / "lang" / "messages"
|
locales_dir = PROJECT_DIR / "frontend" / "lang" / "messages"
|
||||||
nuxt_config = PROJECT_DIR / "frontend" / "nuxt.config.js"
|
nuxt_config = PROJECT_DIR / "frontend" / "nuxt.config.js"
|
||||||
|
reg_valid = PROJECT_DIR / "mealie" / "schema" / "_mealie" / "validators.py"
|
||||||
|
|
||||||
"""
|
"""
|
||||||
This snippet walks the message and dat locales directories and generates the import information
|
This snippet walks the message and dat locales directories and generates the import information
|
||||||
@@ -175,6 +180,19 @@ def inject_nuxt_values():
|
|||||||
inject_inline(nuxt_config, CodeKeys.nuxt_local_dates, all_date_locales)
|
inject_inline(nuxt_config, CodeKeys.nuxt_local_dates, all_date_locales)
|
||||||
|
|
||||||
|
|
||||||
|
def inject_registration_validation_values():
|
||||||
|
all_langs = []
|
||||||
|
for match in locales_dir.glob("*.json"):
|
||||||
|
lang_string = f'"{match.stem}",'
|
||||||
|
all_langs.append(lang_string)
|
||||||
|
|
||||||
|
# sort
|
||||||
|
all_langs.sort()
|
||||||
|
|
||||||
|
log.debug(f"injecting locales into user registration validation -> {reg_valid}")
|
||||||
|
inject_inline(reg_valid, CodeKeys.nuxt_local_messages, all_langs)
|
||||||
|
|
||||||
|
|
||||||
def generate_locales_ts_file():
|
def generate_locales_ts_file():
|
||||||
api = CrowdinApi("")
|
api = CrowdinApi("")
|
||||||
models = api.get_languages()
|
models = api.get_languages()
|
||||||
@@ -193,6 +211,7 @@ def main():
|
|||||||
|
|
||||||
generate_locales_ts_file()
|
generate_locales_ts_file()
|
||||||
inject_nuxt_values()
|
inject_nuxt_values()
|
||||||
|
inject_registration_validation_values()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from utils import log
|
|||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
template = """// This Code is auto generated by gen_global_components.py
|
template = """// This Code is auto generated by gen_ts_types.py
|
||||||
{% for name in global %}import {{ name }} from "@/components/global/{{ name }}.vue";
|
{% for name in global %}import {{ name }} from "@/components/global/{{ name }}.vue";
|
||||||
{% endfor %}{% for name in layout %}import {{ name }} from "@/components/layout/{{ name }}.vue";
|
{% endfor %}{% for name in layout %}import {{ name }} from "@/components/layout/{{ name }}.vue";
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import re
|
import re
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from humps import camelize
|
from humps import camelize
|
||||||
from pydantic import BaseModel, Extra, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from slugify import slugify
|
from slugify import slugify
|
||||||
|
|
||||||
|
|
||||||
@@ -34,33 +33,30 @@ class ParameterIn(str, Enum):
|
|||||||
|
|
||||||
|
|
||||||
class RouterParameter(BaseModel):
|
class RouterParameter(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
required: bool = False
|
required: bool = False
|
||||||
name: str
|
name: str
|
||||||
location: ParameterIn = Field(..., alias="in")
|
location: ParameterIn = Field(..., alias="in")
|
||||||
|
|
||||||
class Config:
|
|
||||||
extra = Extra.allow
|
|
||||||
|
|
||||||
|
|
||||||
class RequestBody(BaseModel):
|
class RequestBody(BaseModel):
|
||||||
required: bool = False
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
class Config:
|
required: bool = False
|
||||||
extra = Extra.allow
|
|
||||||
|
|
||||||
|
|
||||||
class HTTPRequest(BaseModel):
|
class HTTPRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||||
|
|
||||||
request_type: RequestType
|
request_type: RequestType
|
||||||
description: str = ""
|
description: str = ""
|
||||||
summary: str
|
summary: str
|
||||||
requestBody: Optional[RequestBody]
|
request_body: RequestBody | None = None
|
||||||
|
|
||||||
parameters: list[RouterParameter] = []
|
parameters: list[RouterParameter] = []
|
||||||
tags: list[str] | None = []
|
tags: list[str] | None = []
|
||||||
|
|
||||||
class Config:
|
|
||||||
extra = Extra.allow
|
|
||||||
|
|
||||||
def list_as_js_object_string(self, parameters, braces=True):
|
def list_as_js_object_string(self, parameters, braces=True):
|
||||||
if len(parameters) == 0:
|
if len(parameters) == 0:
|
||||||
return ""
|
return ""
|
||||||
@@ -71,11 +67,11 @@ class HTTPRequest(BaseModel):
|
|||||||
return ", ".join(parameters)
|
return ", ".join(parameters)
|
||||||
|
|
||||||
def payload(self):
|
def payload(self):
|
||||||
return "payload" if self.requestBody else ""
|
return "payload" if self.request_body else ""
|
||||||
|
|
||||||
def function_args(self):
|
def function_args(self):
|
||||||
all_params = [p.name for p in self.parameters]
|
all_params = [p.name for p in self.parameters]
|
||||||
if self.requestBody:
|
if self.request_body:
|
||||||
all_params.append("payload")
|
all_params.append("payload")
|
||||||
return self.list_as_js_object_string(all_params)
|
return self.list_as_js_object_string(all_params)
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import re
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import black
|
|
||||||
import isort
|
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from rich.logging import RichHandler
|
from rich.logging import RichHandler
|
||||||
|
|
||||||
@@ -23,10 +21,7 @@ def render_python_template(template_file: Path | str, dest: Path, data: dict):
|
|||||||
|
|
||||||
text = tplt.render(data=data)
|
text = tplt.render(data=data)
|
||||||
|
|
||||||
text = black.format_str(text, mode=black.FileMode())
|
|
||||||
|
|
||||||
dest.write_text(text)
|
dest.write_text(text)
|
||||||
isort.file(dest)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -50,7 +45,7 @@ class CodeSlicer:
|
|||||||
self._next_line += 1
|
self._next_line += 1
|
||||||
|
|
||||||
|
|
||||||
def get_indentation_of_string(line: str, comment_char: str = "//") -> str:
|
def get_indentation_of_string(line: str, comment_char: str = "//|#") -> str:
|
||||||
return re.sub(rf"{comment_char}.*", "", line).removesuffix("\n")
|
return re.sub(rf"{comment_char}.*", "", line).removesuffix("\n")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ services:
|
|||||||
- 9091:9000
|
- 9091:9000
|
||||||
environment:
|
environment:
|
||||||
ALLOW_SIGNUP: "false"
|
ALLOW_SIGNUP: "false"
|
||||||
|
LOG_LEVEL: "DEBUG"
|
||||||
|
|
||||||
DB_ENGINE: sqlite # Optional: 'sqlite', 'postgres'
|
DB_ENGINE: sqlite # Optional: 'sqlite', 'postgres'
|
||||||
# =====================================
|
# =====================================
|
||||||
|
|||||||
@@ -33,9 +33,6 @@ init() {
|
|||||||
|
|
||||||
# Activate our virtual environment here
|
# Activate our virtual environment here
|
||||||
. /opt/pysetup/.venv/bin/activate
|
. /opt/pysetup/.venv/bin/activate
|
||||||
|
|
||||||
# Initialize Database Prerun
|
|
||||||
poetry run python /app/mealie/db/init_db.py
|
|
||||||
}
|
}
|
||||||
|
|
||||||
change_user
|
change_user
|
||||||
@@ -43,10 +40,11 @@ init
|
|||||||
GUNICORN_PORT=${API_PORT:-9000}
|
GUNICORN_PORT=${API_PORT:-9000}
|
||||||
|
|
||||||
# Start API
|
# Start API
|
||||||
hostip=`/sbin/ip route|awk '/default/ { print $3 }'`
|
HOST_IP=`/sbin/ip route|awk '/default/ { print $3 }'`
|
||||||
|
|
||||||
if [ "$WEB_GUNICORN" = 'true' ]; then
|
if [ "$WEB_GUNICORN" = 'true' ]; then
|
||||||
echo "Starting Gunicorn"
|
echo "Starting Gunicorn"
|
||||||
exec gunicorn mealie.app:app -b 0.0.0.0:$GUNICORN_PORT --forwarded-allow-ips=$hostip -k uvicorn.workers.UvicornWorker -c /app/gunicorn_conf.py --preload
|
exec gunicorn mealie.app:app -b 0.0.0.0:$GUNICORN_PORT --forwarded-allow-ips=$HOST_IP -k uvicorn.workers.UvicornWorker -c /app/gunicorn_conf.py --preload
|
||||||
else
|
else
|
||||||
exec uvicorn mealie.app:app --host 0.0.0.0 --forwarded-allow-ips=$hostip --port $GUNICORN_PORT
|
exec python /app/mealie/main.py
|
||||||
fi
|
fi
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 230 90" width="2500" height="978"><path d="M93.8 27.8l5.8-1.4v28c0 3.1.9 4.9 2.7 5.5-.9 1.7-2.4 2.6-4.6 2.6-2.6 0-4-1.8-4-5.5V27.8zM108.4 62V41.8h-3.2V37h9.1v25h-5.9zm3-34.6c.9 0 1.7.3 2.4 1s1 1.5 1 2.4c0 .9-.3 1.7-1 2.4s-1.5 1-2.4 1c-.9 0-1.7-.3-2.4-1s-1-1.5-1-2.4c0-.9.3-1.7 1-2.4s1.5-1 2.4-1zM137.1 62V47.6c0-2.1-.4-3.7-1.2-4.6-.8-1-2.1-1.5-4-1.5-.9 0-1.8.2-2.7.7-1 .5-1.7 1.1-2.3 1.8v18h-5.8V37.1h4.2l1.1 2.3c1.6-1.9 3.9-2.8 7-2.8 3 0 5.3.9 7 2.7 1.7 1.8 2.6 4.3 2.6 7.4V62h-5.9zM147.5 49.5c0-3.8 1.1-6.9 3.3-9.3 2.2-2.4 5.1-3.6 8.7-3.6 3.8 0 6.7 1.1 8.8 3.4 2.1 2.3 3.1 5.4 3.1 9.4s-1.1 7.1-3.2 9.5c-2.1 2.3-5 3.5-8.8 3.5-3.8 0-6.7-1.2-8.8-3.5-2-2.4-3.1-5.5-3.1-9.4zm6.1 0c0 5.5 2 8.2 5.9 8.2 1.8 0 3.2-.7 4.3-2.1 1.1-1.4 1.6-3.5 1.6-6.1 0-5.4-2-8.1-5.9-8.1-1.8 0-3.3.7-4.3 2.1-1.1 1.4-1.6 3.4-1.6 6zM192.1 62v-1.5c-.5.5-1.3 1-2.4 1.4-1.1.4-2.3.6-3.6.6-3.5 0-6.2-1.1-8.2-3.3-2-2.2-3-5.3-3-9.2 0-3.9 1.1-7.1 3.4-9.6s5.1-3.7 8.6-3.7c1.9 0 3.6.4 5.2 1.2v-10l5.8-1.4V62h-5.8zm0-19c-1.2-1-2.5-1.5-3.9-1.5-2.3 0-4.1.7-5.4 2.1-1.3 1.4-1.9 3.5-1.9 6.1 0 5.2 2.5 7.8 7.5 7.8.6 0 1.2-.2 2.1-.5.8-.3 1.3-.7 1.6-1V43zM226 51.3h-17.8c.1 2 .8 3.5 2 4.6 1.3 1.1 2.9 1.7 5.1 1.7 2.6 0 4.7-.7 6-2.1l2.3 4.4c-2 1.7-5.1 2.5-9.2 2.5-3.8 0-6.8-1.1-9-3.3-2.2-2.2-3.3-5.3-3.3-9.3 0-3.9 1.2-7.1 3.6-9.5 2.4-2.4 5.3-3.6 8.7-3.6 3.6 0 6.5 1.1 8.7 3.2 2.2 2.2 3.3 4.9 3.3 8.2.1.7-.1 1.7-.4 3.2zm-17.6-4.4h12.2c-.4-3.6-2.4-5.5-6-5.5-3.3.1-5.4 1.9-6.2 5.5z"/><g><path fill="#004712" d="M65.9 47.4l-1 11.5-3.3-2.3.4-5.8v-.1-.1l-.1-.1-.1-.1-7.1-4.7.1-5.1 11.1 6.8zM48.5 59.9L43.4 56v.9c0 .2-.1.4-.2.5L39.4 60l4.2 3.4.1.1v.2l.2 4 4.7 3.9-.1-11.7zm-32.1 5l2.4 11.5 9.9 10.5L27 75.3 16.4 64.9zm9.3 1.7l-2.4-16.1-12-10 3.2 15.6 11.2 10.5zm-3.8-26l-3.3-22.8L4.8 9.2l4.5 21.5 12.6 9.9z"/><path fill="#00B259" d="M75.7 41.2l-1.5 10.9-8.2 6.6 1-11.2 8.7-6.3zM49.6 59.9l.1 11.8 10.5-8.4.7-11.5-11.3 8.1zm-6.8 4.8L28 75.3l1.8 12.2 13.4-10.7-.4-12.1zm-.4-8l-.7-16-17.3 9.9 2.4 16.6 15.6-10.5zm-1.1-25.3l-.9-21.6-20.8 8L23 41l18.3-9.6z"/><path d="M76.9 40c0-.1 0-.1 0 0v-.2s0-.1-.1-.1c0 0-.1 0-.1-.1l-12-6.7c-.2-.1-.4-.1-.5 0L54 39.1h-.1v.6l-.1 5.4-4.1-2.7c-.2-.1-.4-.1-.6 0L43 45.8l-.3-6v-.1-.1-.1-.1-.1h-.1l-6.2-4.1 5.8-3c.2-.1.3-.3.3-.5L41.4 9v-.1s0-.1-.1-.1c0 0 0-.1-.1-.1L25.5 1.1c-.1-.1-.2-.1-.3-.1L3.9 7.6s-.1 0-.1.1c0 0-.1 0-.1.1v.6l4.7 22.9c0 .1.1.2.2.3l6.4 5-4.7 2.2s-.1 0-.1.1c0 0 0 .1-.1.1v.2l3.6 17.2c0 .1.1.2.2.3l4.5 4.2-3 1.8-.1.1s0 .1-.1.1V63.2L18 76.5c0 .1.1.2.1.3l10.9 12h.1s.1 0 .1.1h.5l14.4-11.5c.1-.1.2-.3.2-.4l-.3-7.9 4.8 4s.1 0 .1.1h.5L61 64c.1-.1.2-.2.2-.4l.4-5.8 3.5 2.4h.4s.1 0 .1-.1l9.4-7.5c.1-.1.2-.2.2-.3L76.9 40c0 .1 0 .1 0 0zM66 58.7l1-11.2 8.8-6.3-1.5 10.9-8.3 6.6zm-4.4-2.1l.4-5.8v-.1-.1l-.1-.1-.1-.1-7.1-4.7.1-5.1 11.1 6.9-1 11.5-3.3-2.4zm-1.5 6.7l-10.5 8.4-.1-11.8 11.3-8.1-.7 11.5zM43.3 76.8L29.8 87.5 28 75.3l14.7-10.5.6 12zm-24.6-.4l-2.4-11.5L27 75.3l1.7 11.6-10-10.5zm-.2-58.6l3.3 22.8-12.5-9.9L4.8 9.2l13.7 8.6zm21.9-8l.9 21.6L23 41l-3.4-23.2 20.8-8zm2 46.9L26.8 67.1l-2.4-16.6 17.3-9.9.7 16.1zm-19.1-6.1l2.4 16.1-11.2-10.6-3.2-15.6 12 10.1zm20.5 13.1v-.1-.1l-.1-.1-4.2-3.4 3.8-2.6c.2-.1.2-.3.2-.5V56l5.1 3.9.1 11.8-4.7-3.9-.2-4.1z"/></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
[Please Join the Discord](https://discord.gg/QuStdQGSGK). We are building a community of developers working on the project.
|
[Please Join the Discord](https://discord.gg/QuStdQGSGK). We are building a community of developers working on the project.
|
||||||
|
|
||||||
## We Develop with Github
|
## We Develop with GitHub
|
||||||
We use github to host code, to track issues and feature requests, as well as accept pull requests.
|
We use GitHub to host code, to track issues and feature requests, as well as accept pull requests.
|
||||||
|
|
||||||
## We Use [Github Flow](https://guides.github.com/introduction/flow/index.html), So All Code Changes Happen Through Pull Requests
|
## We Use [GitHub Flow](https://docs.github.com/en/get-started/using-github/github-flow), So All Code Changes Happen Through Pull Requests
|
||||||
Pull requests are the best way to propose changes to the codebase (we use [Github Flow](https://guides.github.com/introduction/flow/index.html)). We actively welcome your pull requests:
|
Pull requests are the best way to propose changes to the codebase (we use [GitHub Flow](https://docs.github.com/en/get-started/using-github/github-flow)). We actively welcome your pull requests:
|
||||||
|
|
||||||
1. Fork the repo and create your branch from `mealie-next`.
|
1. Fork the repo and create your branch from `mealie-next`.
|
||||||
2. Checkout the Discord, the PRs page, or the Projects page to get an idea of what's already being worked on.
|
2. Checkout the Discord, the PRs page, or the Projects page to get an idea of what's already being worked on.
|
||||||
@@ -14,13 +14,13 @@ Pull requests are the best way to propose changes to the codebase (we use [Githu
|
|||||||
4. Once you've got an idea of what changes you want to make, create a draft PR as soon as you can to let us know what you're working on and how we can help!
|
4. Once you've got an idea of what changes you want to make, create a draft PR as soon as you can to let us know what you're working on and how we can help!
|
||||||
5. If you've changed APIs, update the documentation.
|
5. If you've changed APIs, update the documentation.
|
||||||
6. Run tests, including `task py:check`.
|
6. Run tests, including `task py:check`.
|
||||||
6. Issue that pull request! First make a draft PR, make sure that the automated github tests all pass, then mark as ready for review.
|
7. Issue that pull request! First make a draft PR, make sure that the automated GitHub tests all pass, then mark as ready for review. We follow Conventional Commits syntax; please title your PR as described in the PR template.
|
||||||
7. Be sure to add release notes to the pull request.
|
8. Be sure to add release notes to the pull request.
|
||||||
|
|
||||||
## Any contributions you make will be under the AGPL Software License
|
## Any contributions you make will be under the AGPL Software License
|
||||||
In short, when you submit code changes, your submissions are understood to be under the same [AGPL License](https://choosealicense.com/licenses/agpl-3.0/) that covers the project. Feel free to contact the maintainers if that's a concern.
|
In short, when you submit code changes, your submissions are understood to be under the same [AGPL License](https://choosealicense.com/licenses/agpl-3.0/) that covers the project. Feel free to contact the maintainers if that's a concern.
|
||||||
|
|
||||||
## Report bugs using Github's [issues](https://github.com/mealie-recipes/mealie/issues)
|
## Report bugs using GitHub's [issues](https://github.com/mealie-recipes/mealie/issues)
|
||||||
We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/mealie-recipes/mealie/issues/new); it's that easy!
|
We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/mealie-recipes/mealie/issues/new); it's that easy!
|
||||||
|
|
||||||
## Write bug reports with detail, background, and sample code
|
## Write bug reports with detail, background, and sample code
|
||||||
@@ -28,8 +28,8 @@ We use GitHub issues to track public bugs. Report a bug by [opening a new issue]
|
|||||||
|
|
||||||
- A quick summary and/or background
|
- A quick summary and/or background
|
||||||
- Steps to reproduce
|
- Steps to reproduce
|
||||||
- Be specific!
|
* Be specific!
|
||||||
- Give sample code if you can. [This stackoverflow question](http://stackoverflow.com/q/12488905/180626) includes sample code that *anyone* with a base R setup can run to reproduce what I was seeing
|
* Give sample code if you can. [This stackoverflow question](http://stackoverflow.com/q/12488905/180626) includes sample code that *anyone* with a base R setup can run to reproduce what I was seeing
|
||||||
- What you expected would happen
|
- What you expected would happen
|
||||||
- What actually happens
|
- What actually happens
|
||||||
- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work)
|
- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work)
|
||||||
@@ -41,4 +41,4 @@ People *love* thorough bug reports. I'm not even kidding.
|
|||||||
By contributing, you agree that your contributions will be licensed under its AGPL License.
|
By contributing, you agree that your contributions will be licensed under its AGPL License.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md)
|
This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebookarchive/draft-js/blob/main/CONTRIBUTING.md)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This is the start of the maintainers guide for Mealie developers. Those who have
|
|||||||
|
|
||||||
If you are working on issues, it can be helpful to understand the workflow for our repository. When an issue comes in it is tagged with the `bug` and `triage` flags. This is to indicate that they need to be reviewed by a maintainer to determine validity.
|
If you are working on issues, it can be helpful to understand the workflow for our repository. When an issue comes in it is tagged with the `bug` and `triage` flags. This is to indicate that they need to be reviewed by a maintainer to determine validity.
|
||||||
|
|
||||||
After you've reviered an issue it will generally move into one of two states:
|
After you've reviewed an issue it will generally move into one of two states:
|
||||||
|
|
||||||
`bug:confirmed`
|
`bug:confirmed`
|
||||||
: Your were able to verify the issue and we determined we need to fix it
|
: Your were able to verify the issue and we determined we need to fix it
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# OpenID Connect (OIDC) Authentication
|
||||||
|
|
||||||
|
:octicons-tag-24: v1.4.0
|
||||||
|
|
||||||
|
Mealie supports 3rd party authentication via [OpenID Connect (OIDC)](https://openid.net/connect/), an identity layer built on top of OAuth2. OIDC is supported by many Identity Providers (IdP), including:
|
||||||
|
|
||||||
|
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
|
||||||
|
- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/)
|
||||||
|
- [Keycloak](https://www.keycloak.org/docs/latest/securing_apps/#_oidc)
|
||||||
|
- [Okta](https://www.okta.com/openid-connect/)
|
||||||
|
|
||||||
|
## Account Linking
|
||||||
|
|
||||||
|
Signing in with OAuth will automatically find your account in Mealie and link to it. If a user does not exist in Mealie, then one will be created (if enabled), but will be unable to log in with any other authentication method. An admin can configure another authentication method for such a user.
|
||||||
|
|
||||||
|
## Provider Setup
|
||||||
|
|
||||||
|
Before you can start using OIDC Authentication, you must first configure a new client application in your identity provider. Your identity provider must support the OAuth **Authorization Code flow with PKCE**. The steps will vary by provider, but generally, the steps are as follows.
|
||||||
|
|
||||||
|
1. Create a new client application
|
||||||
|
- The Provider type should be OIDC or OAuth2
|
||||||
|
- The Grant type should be `Authorization Code`
|
||||||
|
- The Application type should be `Web` or `SPA`
|
||||||
|
- The Client type should be `public`
|
||||||
|
|
||||||
|
2. Configure redirect URI
|
||||||
|
|
||||||
|
The redirect URI(s) that are needed:
|
||||||
|
|
||||||
|
1. `http(s)://DOMAIN:PORT/login`
|
||||||
|
2. `https(s)://DOMAIN:PORT/login?direct=1`
|
||||||
|
1. This URI is only required if your IdP supports [RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) such as Keycloak. You may also be able to combine this into the previous URI by using a wildcard: `http(s)://DOMAIN:PORT/login*`
|
||||||
|
|
||||||
|
The redirect URI(s) should include any URL that Mealie is accessible from. Some examples include
|
||||||
|
|
||||||
|
http://localhost:9091/login
|
||||||
|
https://mealie.example.com/login
|
||||||
|
|
||||||
|
3. Configure origins
|
||||||
|
|
||||||
|
If your identity provider enforces CORS on any endpoints, you will need to specify your Mealie URL as an Allowed Origin.
|
||||||
|
|
||||||
|
4. Configure allowed scopes
|
||||||
|
|
||||||
|
The scopes required are `openid profile email`
|
||||||
|
|
||||||
|
If you plan to use the [groups](#groups) to configure access within Mealie, you will need to also add the scope defined by the `OIDC_GROUPS_CLAIM` environment variable. The default claim is `groups`
|
||||||
|
|
||||||
|
## Mealie Setup
|
||||||
|
|
||||||
|
Take the client id and your discovery URL and update your environment variables to include the required OIDC variables described in [Installation - Backend Configuration](../installation/backend-config.md#openid-connect-oidc).
|
||||||
|
|
||||||
|
### Groups
|
||||||
|
|
||||||
|
There are two (optional) [environment variables](../installation/backend-config.md#openid-connect-oidc) that can control which of the users in your IdP can log in to Mealie and what permissions they will have. Keep in mind that these groups **do not necessarily correspond to groups in Mealie**. The groups claim is configurable via the `OIDC_GROUPS_CLAIM` environment variable. The groups should be **defined in your IdP** and be returned in the configured claim value.
|
||||||
|
|
||||||
|
`OIDC_USER_GROUP`: Users must be a part of this group (within your IdP) to be able to log in.
|
||||||
|
|
||||||
|
`OIDC_ADMIN_GROUP`: Users that are in this group (within your IdP) will be made an **admin** in Mealie.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Example configurations for several Identity Providers have been provided by the Community in the [GitHub Discussions](https://github.com/mealie-recipes/mealie/discussions/categories/oauth-provider-example).
|
||||||
|
|
||||||
|
If you don't see your provider and have successfully set it up, please consider [creating your own example](https://github.com/mealie-recipes/mealie/discussions/new?category=oauth-provider-example) so that others can have a smoother setup.
|
||||||
@@ -26,7 +26,7 @@ Do the following for each recipe you want to intelligently handle ingredients.
|
|||||||
6. Click the Edit button/icon again
|
6. Click the Edit button/icon again
|
||||||
7. Scroll to the ingredients and you should see new fields for Amount, Unit, Food, and Note. The Note in particular will contain the original text of the Recipe.
|
7. Scroll to the ingredients and you should see new fields for Amount, Unit, Food, and Note. The Note in particular will contain the original text of the Recipe.
|
||||||
8. Click `Parse` and you will be taken to the ingredient parsing page.
|
8. Click `Parse` and you will be taken to the ingredient parsing page.
|
||||||
9. Choose your parser. The `Natural Language Parser` works very well, but you can also use the `Brute Parser`.
|
9. Choose your parser. The `Natural Language Parser` works very well, but you can also use the `Brute Parser`, or the `OpenAI Parser` if you've [enabled OpenAI support](./installation/backend-config.md#openai).
|
||||||
10. Click `Parse All`, and your ingredients should be separated out into Units and Foods based on your seeding in Step 1 above.
|
10. Click `Parse All`, and your ingredients should be separated out into Units and Foods based on your seeding in Step 1 above.
|
||||||
11. For ingredients where the Unit or Food was not found, you can click a button to accept an automatically suggested Food to add to the database. Or, manually enter the Unit/Food and hit `Enter` (or click `Create`) to add it to the database
|
11. For ingredients where the Unit or Food was not found, you can click a button to accept an automatically suggested Food to add to the database. Or, manually enter the Unit/Food and hit `Enter` (or click `Create`) to add it to the database
|
||||||
12. When done, click `Save All` and you will be taken back to the recipe. Now the Unit and Food fields of the recipe should be filled out.
|
12. When done, click `Save All` and you will be taken back to the recipe. Now the Unit and Food fields of the recipe should be filled out.
|
||||||
@@ -94,6 +94,10 @@ docker exec -it mealie-next bash
|
|||||||
python /app/mealie/scripts/change_password.py
|
python /app/mealie/scripts/change_password.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## I can't log in with external auth. How can I change my authentication method?
|
||||||
|
|
||||||
|
Follow the [steps above](#how-can-i-change-my-password) for changing your password. You will be prompted if you would like to switch your authentication method back to local auth so you can log in again.
|
||||||
|
|
||||||
## How do private groups and recipes work?
|
## How do private groups and recipes work?
|
||||||
|
|
||||||
Managing private groups and recipes can be confusing. The following diagram and notes should help explain how they work to determine if a recipe can be shared publicly.
|
Managing private groups and recipes can be confusing. The following diagram and notes should help explain how they work to determine if a recipe can be shared publicly.
|
||||||
|
|||||||
@@ -14,10 +14,14 @@ Mealie offers two main ways to create recipes. You can use the integrated recipe
|
|||||||
|
|
||||||
Mealie supports importing recipes from a few other sources besides websites. Currently the following sources are supported:
|
Mealie supports importing recipes from a few other sources besides websites. Currently the following sources are supported:
|
||||||
|
|
||||||
- Mealie Pre v1
|
- Tandoor
|
||||||
- Nextcloud Cookbooks
|
- Nextcloud Cookbooks
|
||||||
- Paprika
|
- Paprika
|
||||||
- Chowdown
|
- Chowdown
|
||||||
|
- Plan to Eat
|
||||||
|
- Recipe Keeper
|
||||||
|
- Copy Me That
|
||||||
|
- My Recipe Box
|
||||||
|
|
||||||
You can access these options on your installation at the `/group/migrations` page on your installation. If you'd like to see another source added, feel free to request so on Github.
|
You can access these options on your installation at the `/group/migrations` page on your installation. If you'd like to see another source added, feel free to request so on Github.
|
||||||
|
|
||||||
@@ -81,12 +85,63 @@ The meal planner has the concept of plan rules. These offer a flexible way to us
|
|||||||
|
|
||||||
The shopping lists feature is a great way to keep track of what you need to buy for your next meal. You can add items directly to the shopping list or link a recipe and all of it's ingredients to track meals during the week.
|
The shopping lists feature is a great way to keep track of what you need to buy for your next meal. You can add items directly to the shopping list or link a recipe and all of it's ingredients to track meals during the week.
|
||||||
|
|
||||||
!!! warning
|
|
||||||
At this time there isn't a tight integration between meal-plans and shopping lists; however, it's something we have planned for the future.
|
|
||||||
|
|
||||||
|
|
||||||
[Shopping List Demo](https://demo.mealie.io/shopping-lists){ .md-button .md-button--primary }
|
[Shopping List Demo](https://demo.mealie.io/shopping-lists){ .md-button .md-button--primary }
|
||||||
|
|
||||||
|
## Integrations
|
||||||
|
|
||||||
|
Mealie is designed to integrate with many different external services. There are several ways you can integrate with Mealie to achieve custom IoT automations, data synchronization, and anything else you can think of. [You can work directly with Mealie through the API](./api-usage.md), or leverage other services to make seamless integrations.
|
||||||
|
|
||||||
|
### Notifiers
|
||||||
|
|
||||||
|
Notifiers are event-driven notifications sent when specific actions are performed within Mealie. Some actions include:
|
||||||
|
- creating a recipe
|
||||||
|
- adding items to a shopping list
|
||||||
|
- creating a new mealplan
|
||||||
|
|
||||||
|
Notifiers use the [Apprise library](https://github.com/caronc/apprise/wiki), which integrates with a large number of notification services. In addition, certain custom notifiers send basic event data to the consumer (e.g. the `id` of the resource). These include:
|
||||||
|
|
||||||
|
- `form` and `forms`
|
||||||
|
- `json` and `jsons`
|
||||||
|
- `xml` and `xmls`
|
||||||
|
|
||||||
|
[Notifiers Demo](https://demo.mealie.io/group/notifiers){ .md-button .md-button--primary }
|
||||||
|
|
||||||
|
### Webhooks
|
||||||
|
|
||||||
|
Unlike notifiers, which are event-driven notifications, Webhooks allow you to send scheduled notifications to your desired endpoint. Webhooks are sent on the day of a scheduled mealplan, at the specified time, and contain the mealplan data in the request.
|
||||||
|
|
||||||
|
[Webhooks Demo](https://demo.mealie.io/group/webhooks){ .md-button .md-button--primary }
|
||||||
|
|
||||||
|
### Recipe Actions
|
||||||
|
|
||||||
|
Recipe Actions are custom actions you can add to all recipes in Mealie. This is a great way to add custom integrations that are fired manually. There are two types of recipe actions:
|
||||||
|
|
||||||
|
1. link - these actions will take you directly to an external page
|
||||||
|
2. post - these actions will send a `POST` request to the specified URL, with the recipe JSON in the request body. These can be used, for instance, to manually trigger a webhook in Home Assistant
|
||||||
|
|
||||||
|
Recipe Action URLs can include merge fields to inject the current recipe's data. For instance, you can use the following URL to include a Google search with the recipe's slug:
|
||||||
|
```
|
||||||
|
https://www.google.com/search?q=${slug}
|
||||||
|
```
|
||||||
|
|
||||||
|
When the action is clicked on, the `${slug}` field is replaced with the recipe's slug value. So, for example, it might take you to this URL on one of your recipes:
|
||||||
|
```
|
||||||
|
https://www.google.com/search?q=pasta-fagioli
|
||||||
|
```
|
||||||
|
|
||||||
|
A common use case for "link" recipe actions is to integrate with the Bring! shopping list. Simply add a Recipe Action with the following URL:
|
||||||
|
```
|
||||||
|
https://api.getbring.com/rest/bringrecipes/deeplink?url=${url}&source=web
|
||||||
|
```
|
||||||
|
|
||||||
|
Below is a list of all valid merge fields:
|
||||||
|
|
||||||
|
- ${id}
|
||||||
|
- ${slug}
|
||||||
|
- ${url}
|
||||||
|
|
||||||
|
To add, modify, or delete Recipe Actions, visit the Data Management page (more on that below).
|
||||||
|
|
||||||
## Data Management
|
## Data Management
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,22 @@
|
|||||||
|
|
||||||
### General
|
### General
|
||||||
|
|
||||||
| Variables | Default | Description |
|
| Variables | Default | Description |
|
||||||
| ------------- | :-------------------: | ----------------------------------------------------------------------------------- |
|
| ----------------------------- | :-------------------: | ----------------------------------------------------------------------------------- |
|
||||||
| PUID | 911 | UserID permissions between host OS and container |
|
| PUID | 911 | UserID permissions between host OS and container |
|
||||||
| PGID | 911 | GroupID permissions between host OS and container |
|
| PGID | 911 | GroupID permissions between host OS and container |
|
||||||
| DEFAULT_GROUP | Home | The default group for users |
|
| DEFAULT_GROUP | Home | The default group for users |
|
||||||
| BASE_URL | http://localhost:8080 | Used for Notifications |
|
| BASE_URL | http://localhost:8080 | Used for Notifications |
|
||||||
| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid |
|
| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid |
|
||||||
| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
|
| 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. |
|
| 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 |
|
| TZ | UTC | Must be set to get correct date/time on the server |
|
||||||
| ALLOW_SIGNUP | true | Allow user sign-up without token |
|
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
|
||||||
|
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
|
||||||
|
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug, trace) |
|
||||||
|
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run the daily tasks. |
|
||||||
|
|
||||||
|
<super>\*</super> Starting in v1.4.0 this was changed to default to `false` as apart of a security review of the application.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
@@ -25,14 +30,15 @@
|
|||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
||||||
| Variables | Default | Description |
|
| Variables | Default | Description |
|
||||||
| ----------------- | :------: | -------------------------------- |
|
| --------------------- | :------: | ----------------------------------------------------------------------- |
|
||||||
| DB_ENGINE | sqlite | Optional: 'sqlite', 'postgres' |
|
| DB_ENGINE | sqlite | Optional: 'sqlite', 'postgres' |
|
||||||
| POSTGRES_USER | mealie | Postgres database user |
|
| POSTGRES_USER | mealie | Postgres database user |
|
||||||
| POSTGRES_PASSWORD | mealie | Postgres database password |
|
| POSTGRES_PASSWORD | mealie | Postgres database password |
|
||||||
| POSTGRES_SERVER | postgres | Postgres database server address |
|
| POSTGRES_SERVER | postgres | Postgres database server address |
|
||||||
| POSTGRES_PORT | 5432 | Postgres database port |
|
| POSTGRES_PORT | 5432 | Postgres database port |
|
||||||
| POSTGRES_DB | mealie | Postgres database name |
|
| POSTGRES_DB | mealie | Postgres database name |
|
||||||
|
| POSTGRES_URL_OVERRIDE | None | Optional Postgres URL override to use instead of POSTGRES\_\* variables |
|
||||||
|
|
||||||
### Email
|
### Email
|
||||||
|
|
||||||
@@ -54,8 +60,8 @@ Changing the webworker settings may cause unforeseen memory leak issues with Mea
|
|||||||
| ---------------- | :-----: | --------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------- | :-----: | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| WEB_GUNICORN | false | Enables Gunicorn to manage Uvicorn web for multiple works |
|
| WEB_GUNICORN | false | Enables Gunicorn to manage Uvicorn web for multiple works |
|
||||||
| WORKERS_PER_CORE | 1 | Set the number of workers to the number of CPU cores multiplied by this value (Value \* CPUs). More info [here][workers_per_core] |
|
| WORKERS_PER_CORE | 1 | Set the number of workers to the number of CPU cores multiplied by this value (Value \* CPUs). More info [here][workers_per_core] |
|
||||||
| MAX_WORKERS | 1 | Set the maximum number of workers to use. Default is not set meaning unlimited. More info [here][max_workers] |
|
| MAX_WORKERS | None | Set the maximum number of workers to use. Default is not set meaning unlimited. More info [here][max_workers] |
|
||||||
| WEB_CONCURRENCY | 1 | Override the automatic definition of number of workers. More info [here][web_concurrency] |
|
| WEB_CONCURRENCY | 2 | Override the automatic definition of number of workers. More info [here][web_concurrency] |
|
||||||
|
|
||||||
### LDAP
|
### LDAP
|
||||||
|
|
||||||
@@ -75,6 +81,42 @@ Changing the webworker settings may cause unforeseen memory leak issues with Mea
|
|||||||
| LDAP_NAME_ATTRIBUTE | name | The LDAP attribute that maps to the user's name |
|
| LDAP_NAME_ATTRIBUTE | name | The LDAP attribute that maps to the user's name |
|
||||||
| LDAP_MAIL_ATTRIBUTE | mail | The LDAP attribute that maps to the user's email |
|
| LDAP_MAIL_ATTRIBUTE | mail | The LDAP attribute that maps to the user's email |
|
||||||
|
|
||||||
|
### OpenID Connect (OIDC)
|
||||||
|
|
||||||
|
:octicons-tag-24: v1.4.0
|
||||||
|
|
||||||
|
For usage, see [Usage - OpenID Connect](../authentication/oidc.md)
|
||||||
|
|
||||||
|
| Variables | Default | Description |
|
||||||
|
| ---------------------- | :-----: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| OIDC_AUTH_ENABLED | False | Enables authentication via OpenID Connect |
|
||||||
|
| OIDC_SIGNUP_ENABLED | True | Enables new users to be created when signing in for the first time with OIDC |
|
||||||
|
| OIDC_CONFIGURATION_URL | None | The URL to the OIDC configuration of your provider. This is usually something like https://auth.example.com/.well-known/openid-configuration |
|
||||||
|
| OIDC_CLIENT_ID | None | The client id of your configured client in your provider |
|
||||||
|
| OIDC_USER_GROUP | None | If specified, only users belonging to this group will be able to successfully authenticate, regardless of the `OIDC_ADMIN_GROUP`. For more information see [this page](../authentication/oidc.md#groups) |
|
||||||
|
| OIDC_ADMIN_GROUP | None | If specified, users belonging to this group will be made an admin. For more information see [this page](../authentication/oidc.md#groups) |
|
||||||
|
| OIDC_AUTO_REDIRECT | False | If `True`, then the login page will be bypassed an you will be sent directly to your Identity Provider. You can still get to the login page by adding `?direct=1` to the login URL |
|
||||||
|
| OIDC_PROVIDER_NAME | OAuth | The provider name is shown in SSO login button. "Login with <OIDC_PROVIDER_NAME\>" |
|
||||||
|
| OIDC_REMEMBER_ME | False | Because redirects bypass the login screen, you cant extend your session by clicking the "Remember Me" checkbox. By setting this value to true, a session will be extended as if "Remember Me" was checked |
|
||||||
|
| OIDC_SIGNING_ALGORITHM | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
|
||||||
|
| OIDC_USER_CLAIM | email | This is the claim which Mealie will use to look up an existing user by (e.g. "email", "preferred_username") |
|
||||||
|
| OIDC_GROUPS_CLAIM | groups | Optional if not using `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP`. This is the claim Mealie will request from your IdP and will use to compare to `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP` to allow the user to log in to Mealie or is set as an admin. **Your IdP must be configured to grant this claim**|
|
||||||
|
| OIDC_TLS_CACERTFILE | None | File path to Certificate Authority used to verify server certificate (e.g. `/path/to/ca.crt`) |
|
||||||
|
|
||||||
|
### OpenAI
|
||||||
|
|
||||||
|
:octicons-tag-24: v1.7.0
|
||||||
|
|
||||||
|
Mealie supports various integrations using OpenAI. To enable OpenAI, [you must provide your OpenAI API key](https://platform.openai.com/api-keys). You can tweak how OpenAI is used using these backend settings. Please note that while OpenAI usage is optimized to reduce API costs, you're unlikely to be able to use OpenAI features with the free tier limits.
|
||||||
|
|
||||||
|
| Variables | Default | Description |
|
||||||
|
| ------------------------- | :------: | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| OPENAI_BASE_URL | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
|
||||||
|
| OPENAI_API_KEY | None | Your OpenAI API Key. Enables OpenAI-related features |
|
||||||
|
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
|
||||||
|
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
|
||||||
|
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
|
||||||
|
|
||||||
### Themeing
|
### Themeing
|
||||||
|
|
||||||
Setting the following environmental variables will change the theme of the frontend. Note that the themes are the same for all users. This is a break-change when migration from v0.x.x -> 1.x.x.
|
Setting the following environmental variables will change the theme of the frontend. Note that the themes are the same for all users. This is a break-change when migration from v0.x.x -> 1.x.x.
|
||||||
@@ -95,3 +137,29 @@ Setting the following environmental variables will change the theme of the front
|
|||||||
| THEME_DARK_INFO | #1976D2 | Dark Theme Config Variable |
|
| THEME_DARK_INFO | #1976D2 | Dark Theme Config Variable |
|
||||||
| THEME_DARK_WARNING | #FF6D00 | Dark Theme Config Variable |
|
| THEME_DARK_WARNING | #FF6D00 | Dark Theme Config Variable |
|
||||||
| THEME_DARK_ERROR | #EF5350 | Dark Theme Config Variable |
|
| THEME_DARK_ERROR | #EF5350 | Dark Theme Config Variable |
|
||||||
|
|
||||||
|
### Docker Secrets
|
||||||
|
|
||||||
|
Setting a credential can be done using secrets when running in a Docker container.
|
||||||
|
This can be used to avoid leaking passwords through compose files, environment variables, or command-line history.
|
||||||
|
For example, to configure the Postgres database password in Docker compose, create a file on the host that contains only the password, and expose that file to the Mealie service as a secret with the correct name.
|
||||||
|
Note that environment variables take priority over secrets, so any previously defined environment variables should be removed when migrating to secrets.
|
||||||
|
|
||||||
|
```
|
||||||
|
services:
|
||||||
|
mealie:
|
||||||
|
...
|
||||||
|
environment:
|
||||||
|
...
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
secrets:
|
||||||
|
- POSTGRES_PASSWORD
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
POSTGRES_PASSWORD:
|
||||||
|
file: postgrespassword.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
[workers_per_core]: https://github.com/tiangolo/uvicorn-gunicorn-docker/blob/2daa3e3873c837d5781feb4ff6a40a89f791f81b/README.md#workers_per_core
|
||||||
|
[max_workers]: https://github.com/tiangolo/uvicorn-gunicorn-docker/blob/2daa3e3873c837d5781feb4ff6a40a89f791f81b/README.md#max_workers
|
||||||
|
[web_concurrency]: https://github.com/tiangolo/uvicorn-gunicorn-docker/blob/2daa3e3873c837d5781feb4ff6a40a89f791f81b/README.md#web_concurrency
|
||||||
|
|||||||
16
docs/docs/documentation/getting-started/installation/logs.md
Normal file
16
docs/docs/documentation/getting-started/installation/logs.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Logs
|
||||||
|
|
||||||
|
:octicons-tag-24: v1.5.0
|
||||||
|
|
||||||
|
## Highlighs
|
||||||
|
|
||||||
|
- Logs are written to `/app/data/mealie.log` by default in the container.
|
||||||
|
- Logs are also written to stdout and stderr.
|
||||||
|
- You can adjust the log level using the `LOG_LEVEL` environment variable.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Starting in v1.5.0 logging is now highly configurable. Using the `LOG_CONFIG_OVERRIDE` you can provide the application with a custom configuration to log however you'd like. This configuration file is based off the [Python Logging Config](https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig). It can be difficult to understand the configuration at first, so here are some resources to help get started.
|
||||||
|
|
||||||
|
- This [YouTube Video](https://www.youtube.com/watch?v=9L77QExPmI0) for a great walkthrough on the logging file format.
|
||||||
|
- Our [Logging Config](https://github.com/mealie-recipes/mealie/blob/mealie-next/mealie/core/logger/logconf.prod.json)
|
||||||
@@ -5,40 +5,39 @@ PostgreSQL might be considered if you need to support many concurrent users. In
|
|||||||
**For Environment Variable Configuration, see** [Backend Configuration](./backend-config.md)
|
**For Environment Variable Configuration, see** [Backend Configuration](./backend-config.md)
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
|
||||||
version: "3.7"
|
|
||||||
services:
|
services:
|
||||||
mealie:
|
mealie:
|
||||||
image: ghcr.io/mealie-recipes/mealie:v1.0.0 # (3)
|
image: ghcr.io/mealie-recipes/mealie:v1.7.0 # (3)
|
||||||
container_name: mealie
|
container_name: mealie
|
||||||
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "9925:9000" # (1)
|
- "9925:9000" # (1)
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 1000M # (2)
|
memory: 1000M # (2)
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
volumes:
|
volumes:
|
||||||
- mealie-data:/app/data/
|
- mealie-data:/app/data/
|
||||||
environment:
|
environment:
|
||||||
# Set Backend ENV Variables Here
|
# Set Backend ENV Variables Here
|
||||||
- ALLOW_SIGNUP=true
|
ALLOW_SIGNUP: true
|
||||||
- PUID=1000
|
PUID: 1000
|
||||||
- PGID=1000
|
PGID: 1000
|
||||||
- TZ=America/Anchorage
|
TZ: America/Anchorage
|
||||||
- MAX_WORKERS=1
|
MAX_WORKERS: 1
|
||||||
- WEB_CONCURRENCY=1
|
WEB_CONCURRENCY: 1
|
||||||
- BASE_URL=https://mealie.yourdomain.com
|
BASE_URL: https://mealie.yourdomain.com
|
||||||
|
# Database Settings
|
||||||
|
DB_ENGINE: postgres
|
||||||
|
POSTGRES_USER: mealie
|
||||||
|
POSTGRES_PASSWORD: mealie
|
||||||
|
POSTGRES_SERVER: postgres
|
||||||
|
POSTGRES_PORT: 5432
|
||||||
|
POSTGRES_DB: mealie
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
# Database Settings
|
|
||||||
- DB_ENGINE=postgres
|
|
||||||
- POSTGRES_USER=mealie
|
|
||||||
- POSTGRES_PASSWORD=mealie
|
|
||||||
- POSTGRES_SERVER=postgres
|
|
||||||
- POSTGRES_PORT=5432
|
|
||||||
- POSTGRES_DB=mealie
|
|
||||||
restart: always
|
|
||||||
postgres:
|
postgres:
|
||||||
container_name: postgres
|
container_name: postgres
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
@@ -48,16 +47,19 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: mealie
|
POSTGRES_PASSWORD: mealie
|
||||||
POSTGRES_USER: mealie
|
POSTGRES_USER: mealie
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "pg_isready"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 20s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mealie-data:
|
mealie-data:
|
||||||
driver: local
|
|
||||||
mealie-pgdata:
|
mealie-pgdata:
|
||||||
driver: local
|
|
||||||
```
|
```
|
||||||
|
|
||||||
<!-- Updating This? Be Sure to also update the SQLite Annotations -->
|
<!-- Updating This? Be Sure to also update the SQLite Annotations -->
|
||||||
|
|
||||||
1. To access the mealie interface you only need to expose port 9000 on the mealie container. Here we expose port 9925 on the host, but feel free to change this to any port you like.
|
1. To access the mealie interface you only need to expose port 9000 on the mealie container. Here we expose port 9925 on the host, but feel free to change this to any port you like.
|
||||||
2. Setting an explicit memory limit is recommended. Python can pre-allocate larger amounts of memory than is necessary if you have a machine with a lot of RAM. This can cause the container to idle at a high memory usage. Setting a memory limit will improve idle performance.
|
2. Setting an explicit memory limit is recommended. Python can pre-allocate larger amounts of memory than is necessary if you have a machine with a lot of RAM. This can cause the container to idle at a high memory usage. Setting a memory limit will improve idle performance.
|
||||||
3. Whilst a 'latest' tag is available, the Mealie team advises specifying a specific version tag and consciously updating to newer versions when you have time to read the release notes and ensure you follow any manual actions required (which should be rare).
|
3. You should double check this value isn't out of date when setting up for the first time; check the README and use the value from the "latest release" badge at the top - the format should be `vX.Y.Z`. Whilst a 'latest' tag is available, the Mealie team advises specifying a specific version tag and consciously updating to newer versions when you have time to read the release notes and ensure you follow any manual actions required (which should be rare).
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Security
|
||||||
|
---
|
||||||
|
|
||||||
|
# Security Considerations
|
||||||
|
|
||||||
|
This page is a collection of security considerations for Mealie. It mostly deals with reported issues and how it's possible to mitigate them. Note that this page is for you to use as a guide for how secure you want to make your deployment. It's important to note that most of these will not apply to you, if you:
|
||||||
|
|
||||||
|
1. Run behind a VPN
|
||||||
|
2. Use a strong password
|
||||||
|
3. Disable Sign-Ups
|
||||||
|
4. Don't host for malicious users
|
||||||
|
|
||||||
|
Use your best judgement when deciding what to do.
|
||||||
|
|
||||||
|
## Denial of Service
|
||||||
|
|
||||||
|
By default, the API is **not** rate limited. This leaves Mealie open to a potential **Denial of Service Attack**. While it's possible to perform a **Denial of Service Attack** on any endpoint, there are a few key endpoints that are more vulnerable than others.
|
||||||
|
|
||||||
|
- `/api/recipes/create-url`
|
||||||
|
- `/api/recipes/{id}/image`
|
||||||
|
|
||||||
|
These endpoints are used to scrape data based off a user provided URL. It is possible for a malicious user to issue multiple requests to download an arbitrarily large external file (e.g a Debian ISO) and sufficiently saturate a CPU assigned to the container. While we do implement some protections against this by chunking the response, and using a timeout strategy, it's still possible to overload the CPU if an attacker issues multiple requests concurrently.
|
||||||
|
|
||||||
|
### Mitigation
|
||||||
|
|
||||||
|
If you'd like to mitigate this risk, we suggest that you rate limit the API in general, and apply strict rate limits to these endpoints. You can do this by utilizing a reverse proxy. See the following links to get started:
|
||||||
|
|
||||||
|
- [Traefik](https://doc.traefik.io/traefik/middlewares/http/ratelimit/)
|
||||||
|
- [Nginx](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html)
|
||||||
|
- [Caddy](https://caddyserver.com/docs/modules/http.handlers.rate_limit)
|
||||||
|
|
||||||
|
## Server Side Request Forgery
|
||||||
|
|
||||||
|
- `/api/recipes/create-url`
|
||||||
|
- `/api/recipes/{id}/image`
|
||||||
|
|
||||||
|
Given the nature of these APIs it's possible to perform a **Server Side Request Forgery** attack. This is where a malicious user can issue a request to an internal network resource, and potentially exfiltrate data. We _do_ perform some checks to mitigate access to resources within your network but at the end of the day, users of Mealie are allowed to trigger HTTP requests on **your server**.
|
||||||
|
|
||||||
|
### Mitigation
|
||||||
|
|
||||||
|
If you'd like to mitigate this risk, we suggest that you isolate the container that Mealie is running in to ensure that it's access to internal resources is limited only to what is required. _Note that Mealie does require access to the internet for recipe imports._ You might consider isolating Mealie from your home network entirely and only allowing access to the external internet.
|
||||||
@@ -9,12 +9,11 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
|||||||
**For Environment Variable Configuration, see** [Backend Configuration](./backend-config.md)
|
**For Environment Variable Configuration, see** [Backend Configuration](./backend-config.md)
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
|
||||||
version: "3.7"
|
|
||||||
services:
|
services:
|
||||||
mealie:
|
mealie:
|
||||||
image: ghcr.io/mealie-recipes/mealie:v1.0.0 # (3)
|
image: ghcr.io/mealie-recipes/mealie:v1.7.0 # (3)
|
||||||
container_name: mealie
|
container_name: mealie
|
||||||
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "9925:9000" # (1)
|
- "9925:9000" # (1)
|
||||||
deploy:
|
deploy:
|
||||||
@@ -24,23 +23,21 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- mealie-data:/app/data/
|
- mealie-data:/app/data/
|
||||||
environment:
|
environment:
|
||||||
# Set Backend ENV Variables Here
|
# Set Backend ENV Variables Here
|
||||||
- ALLOW_SIGNUP=true
|
ALLOW_SIGNUP: true
|
||||||
- PUID=1000
|
PUID: 1000
|
||||||
- PGID=1000
|
PGID: 1000
|
||||||
- TZ=America/Anchorage
|
TZ: America/Anchorage
|
||||||
- MAX_WORKERS=1
|
MAX_WORKERS: 1
|
||||||
- WEB_CONCURRENCY=1
|
WEB_CONCURRENCY: 1
|
||||||
- BASE_URL=https://mealie.yourdomain.com
|
BASE_URL: https://mealie.yourdomain.com
|
||||||
restart: always
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mealie-data:
|
mealie-data:
|
||||||
driver: local
|
|
||||||
```
|
```
|
||||||
|
|
||||||
<!-- Updating This? Be Sure to also update the Postgres Annotations -->
|
<!-- Updating This? Be Sure to also update the Postgres Annotations -->
|
||||||
|
|
||||||
1. To access the mealie interface you only need to expose port 9000 on the container. Here we expose port 9925 on the host, but feel free to change this to any port you like.
|
1. To access the mealie interface you only need to expose port 9000 on the container. Here we expose port 9925 on the host, but feel free to change this to any port you like.
|
||||||
2. Setting an explicit memory limit is recommended. Python can pre-allocate larger amounts of memory than is necessary if you have a machine with a lot of RAM. This can cause the container to idle at a high memory usage. Setting a memory limit will improve idle performance.
|
2. Setting an explicit memory limit is recommended. Python can pre-allocate larger amounts of memory than is necessary if you have a machine with a lot of RAM. This can cause the container to idle at a high memory usage. Setting a memory limit will improve idle performance.
|
||||||
3. Whilst a 'latest' tag is available, the Mealie team advises specifying a specific version tag and consciously updating to newer versions when you have time to read the release notes and ensure you follow any manual actions required (which should be rare).
|
3. You should double check this value isn't out of date when setting up for the first time; check the README and use the value from the "latest release" badge at the top - the format should be `vX.Y.Z`. Whilst a 'latest' tag is available, the Mealie team advises specifying a specific version tag and consciously updating to newer versions when you have time to read the release notes and ensure you follow any manual actions required (which should be rare).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Permissions and Public Access
|
# Permissions and Public Access
|
||||||
|
|
||||||
Mealie provides various levels of user access and permissions. This includes:
|
Mealie provides various levels of user access and permissions. This includes:
|
||||||
- Authentication and registration ([check out the LDAP guide](./ldap.md) for how to configure access using LDAP)
|
- Authentication and registration ([LDAP](../authentication/ldap.md) and [OpenID Connect](../authentication/oidc.md) are both supported)
|
||||||
- Customizable user permissions
|
- Customizable user permissions
|
||||||
- Fine-tuned public access for non-users
|
- Fine-tuned public access for non-users
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -41,7 +41,8 @@ markdown_extensions:
|
|||||||
custom_checkbox: true
|
custom_checkbox: true
|
||||||
- admonition
|
- admonition
|
||||||
- attr_list
|
- attr_list
|
||||||
- pymdownx.tabbed
|
- pymdownx.tabbed:
|
||||||
|
alternate_style: true
|
||||||
- pymdownx.superfences:
|
- pymdownx.superfences:
|
||||||
custom_fences:
|
custom_fences:
|
||||||
- name: mermaid
|
- name: mermaid
|
||||||
@@ -51,8 +52,8 @@ extra_css:
|
|||||||
- assets/stylesheets/custom.css
|
- assets/stylesheets/custom.css
|
||||||
extra_javascript:
|
extra_javascript:
|
||||||
- assets/js/extra.js
|
- assets/js/extra.js
|
||||||
repo_url: https://github.com/hay-kot/mealie/
|
repo_url: https://github.com/mealie-recipes/mealie/
|
||||||
repo_name: hay-kot/mealie
|
repo_name: mealie-recipes/mealie
|
||||||
edit_uri: edit/mealie-next/docs/docs/
|
edit_uri: edit/mealie-next/docs/docs/
|
||||||
|
|
||||||
nav:
|
nav:
|
||||||
@@ -71,9 +72,15 @@ nav:
|
|||||||
- SQLite (Recommended): "documentation/getting-started/installation/sqlite.md"
|
- SQLite (Recommended): "documentation/getting-started/installation/sqlite.md"
|
||||||
- PostgreSQL: "documentation/getting-started/installation/postgres.md"
|
- PostgreSQL: "documentation/getting-started/installation/postgres.md"
|
||||||
- Backend Configuration: "documentation/getting-started/installation/backend-config.md"
|
- Backend Configuration: "documentation/getting-started/installation/backend-config.md"
|
||||||
|
- Security: "documentation/getting-started/installation/security.md"
|
||||||
|
- Logs: "documentation/getting-started/installation/logs.md"
|
||||||
- Usage:
|
- Usage:
|
||||||
- Backup and Restoring: "documentation/getting-started/usage/backups-and-restoring.md"
|
- Backup and Restoring: "documentation/getting-started/usage/backups-and-restoring.md"
|
||||||
- LDAP Authentication: "documentation/getting-started/usage/ldap.md"
|
- Permissions and Public Access: "documentation/getting-started/usage/permissions-and-public-access.md"
|
||||||
|
|
||||||
|
- Authentication:
|
||||||
|
- LDAP: "documentation/getting-started/authentication/ldap.md"
|
||||||
|
- OpenID Connect: "documentation/getting-started/authentication/oidc.md"
|
||||||
|
|
||||||
- Community Guides:
|
- Community Guides:
|
||||||
- iOS Shortcuts: "documentation/community-guide/ios.md"
|
- iOS Shortcuts: "documentation/community-guide/ios.md"
|
||||||
|
|||||||
55
frontend/components/Domain/Cookbook/CookbookEditor.vue
Normal file
55
frontend/components/Domain/Cookbook/CookbookEditor.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-card-text v-if="cookbook">
|
||||||
|
<v-text-field v-model="cookbook.name" :label="$t('cookbook.cookbook-name')"></v-text-field>
|
||||||
|
<v-textarea v-model="cookbook.description" auto-grow :rows="2" :label="$t('recipe.description')"></v-textarea>
|
||||||
|
<RecipeOrganizerSelector v-model="cookbook.categories" selector-type="categories" />
|
||||||
|
<RecipeOrganizerSelector v-model="cookbook.tags" selector-type="tags" />
|
||||||
|
<RecipeOrganizerSelector v-model="cookbook.tools" selector-type="tools" />
|
||||||
|
<v-switch v-model="cookbook.public" hide-details single-line>
|
||||||
|
<template #label>
|
||||||
|
{{ $t('cookbook.public-cookbook') }}
|
||||||
|
<HelpIcon small right class="ml-2">
|
||||||
|
{{ $t('cookbook.public-cookbook-description') }}
|
||||||
|
</HelpIcon>
|
||||||
|
</template>
|
||||||
|
</v-switch>
|
||||||
|
<div class="mt-4">
|
||||||
|
<h3 class="text-subtitle-1 d-flex align-center mb-0 pb-0">
|
||||||
|
{{ $t('cookbook.filter-options') }}
|
||||||
|
<HelpIcon right small class="ml-2">
|
||||||
|
{{ $t('cookbook.filter-options-description') }}
|
||||||
|
</HelpIcon>
|
||||||
|
</h3>
|
||||||
|
<v-switch v-model="cookbook.requireAllCategories" class="mt-0" hide-details single-line>
|
||||||
|
<template #label> {{ $t('cookbook.require-all-categories') }} </template>
|
||||||
|
</v-switch>
|
||||||
|
<v-switch v-model="cookbook.requireAllTags" hide-details single-line>
|
||||||
|
<template #label> {{ $t('cookbook.require-all-tags') }} </template>
|
||||||
|
</v-switch>
|
||||||
|
<v-switch v-model="cookbook.requireAllTools" hide-details single-line>
|
||||||
|
<template #label> {{ $t('cookbook.require-all-tools') }} </template>
|
||||||
|
</v-switch>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "@nuxtjs/composition-api";
|
||||||
|
import { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||||
|
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||||
|
export default defineComponent({
|
||||||
|
components: { RecipeOrganizerSelector },
|
||||||
|
props: {
|
||||||
|
cookbook: {
|
||||||
|
type: Object as () => ReadCookBook,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
type: Object as () => any,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,8 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Edit Dialog -->
|
||||||
|
<BaseDialog
|
||||||
|
v-if="editTarget"
|
||||||
|
v-model="dialogStates.edit"
|
||||||
|
:width="650"
|
||||||
|
:icon="$globals.icons.pages"
|
||||||
|
:title="$t('general.edit')"
|
||||||
|
:submit-icon="$globals.icons.save"
|
||||||
|
:submit-text="$tc('general.save')"
|
||||||
|
@submit="editCookbook"
|
||||||
|
>
|
||||||
|
<v-card-text>
|
||||||
|
<CookbookEditor :cookbook="editTarget" :actions="actions" />
|
||||||
|
</v-card-text>
|
||||||
|
</BaseDialog>
|
||||||
|
|
||||||
|
<!-- Page -->
|
||||||
<v-container v-if="book" fluid>
|
<v-container v-if="book" fluid>
|
||||||
<v-app-bar color="transparent" flat class="mt-n1 rounded">
|
<v-app-bar color="transparent" flat class="mt-n1">
|
||||||
<v-icon large left> {{ $globals.icons.pages }} </v-icon>
|
<v-icon large left> {{ $globals.icons.pages }} </v-icon>
|
||||||
<v-toolbar-title class="headline"> {{ book.name }} </v-toolbar-title>
|
<v-toolbar-title class="headline"> {{ book.name }} </v-toolbar-title>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<BaseButton
|
||||||
|
v-if="isOwnGroup"
|
||||||
|
class="mx-1"
|
||||||
|
:edit="true"
|
||||||
|
@click="handleEditCookbook"
|
||||||
|
/>
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
<v-card flat>
|
<v-card flat>
|
||||||
<v-card-text class="py-0">
|
<v-card-text class="py-0">
|
||||||
@@ -22,17 +47,20 @@
|
|||||||
/>
|
/>
|
||||||
</v-container>
|
</v-container>
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useRoute, ref, useContext, useMeta } from "@nuxtjs/composition-api";
|
import { computed, defineComponent, useRoute, ref, useContext, useMeta, reactive, useRouter } from "@nuxtjs/composition-api";
|
||||||
import { useLazyRecipes } from "~/composables/recipes";
|
import { useLazyRecipes } from "~/composables/recipes";
|
||||||
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
|
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
|
||||||
import { useCookbook } from "~/composables/use-group-cookbooks";
|
import { useCookbook, useCookbooks } from "~/composables/use-group-cookbooks";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
|
import { RecipeCookBook } from "~/lib/api/types/cookbook";
|
||||||
|
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: { RecipeCardSection },
|
components: { RecipeCardSection, CookbookEditor },
|
||||||
setup() {
|
setup() {
|
||||||
const { $auth } = useContext();
|
const { $auth } = useContext();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
@@ -43,10 +71,36 @@
|
|||||||
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||||
const slug = route.value.params.slug;
|
const slug = route.value.params.slug;
|
||||||
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
|
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
|
||||||
|
const { actions } = useCookbooks();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const tab = ref(null);
|
const tab = ref(null);
|
||||||
const book = getOne(slug);
|
const book = getOne(slug);
|
||||||
|
|
||||||
|
const dialogStates = reactive({
|
||||||
|
edit: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const editTarget = ref<RecipeCookBook | null>(null);
|
||||||
|
function handleEditCookbook() {
|
||||||
|
dialogStates.edit = true;
|
||||||
|
editTarget.value = book.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editCookbook() {
|
||||||
|
if (!editTarget.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await actions.updateOne(editTarget.value);
|
||||||
|
|
||||||
|
// if name changed, redirect to new slug
|
||||||
|
if (response?.slug && book.value?.slug !== response?.slug) {
|
||||||
|
router.push(`/g/${route.value.params.groupSlug}/cookbooks/${response?.slug}`);
|
||||||
|
}
|
||||||
|
dialogStates.edit = false;
|
||||||
|
editTarget.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
useMeta(() => {
|
useMeta(() => {
|
||||||
return {
|
return {
|
||||||
title: book?.value?.name || "Cookbook",
|
title: book?.value?.name || "Cookbook",
|
||||||
@@ -62,6 +116,12 @@
|
|||||||
recipes,
|
recipes,
|
||||||
removeRecipe,
|
removeRecipe,
|
||||||
replaceRecipes,
|
replaceRecipes,
|
||||||
|
isOwnGroup,
|
||||||
|
dialogStates,
|
||||||
|
editTarget,
|
||||||
|
handleEditCookbook,
|
||||||
|
editCookbook,
|
||||||
|
actions,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
head: {}, // Must include for useMeta
|
head: {}, // Must include for useMeta
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default defineComponent({
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function getShoppingLists() {
|
async function getShoppingLists() {
|
||||||
const { data } = await api.shopping.lists.getAll();
|
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
|
||||||
if (data) {
|
if (data) {
|
||||||
shoppingLists.value = data.items ?? [];
|
shoppingLists.value = data.items ?? [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
||||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||||
import { RecipeTag, RecipeCategory } from "~/lib/api/types/group";
|
import { RecipeTag, RecipeCategory } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<div v-if="!open" class="custom-btn-group ma-1">
|
<div v-if="!open" class="custom-btn-group ma-1">
|
||||||
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :slug="recipe.slug" show-always />
|
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :recipe-id="recipe.id" show-always />
|
||||||
<RecipeTimelineBadge v-if="loggedIn" button-style :slug="recipe.slug" :recipe-name="recipe.name" />
|
<RecipeTimelineBadge v-if="loggedIn" button-style :slug="recipe.slug" :recipe-name="recipe.name" />
|
||||||
<div v-if="loggedIn">
|
<div v-if="loggedIn">
|
||||||
<v-tooltip v-if="!locked" bottom color="info">
|
<v-tooltip v-if="!locked" bottom color="info">
|
||||||
@@ -70,6 +70,7 @@
|
|||||||
print: true,
|
print: true,
|
||||||
printPreferences: true,
|
printPreferences: true,
|
||||||
share: loggedIn,
|
share: loggedIn,
|
||||||
|
recipeActions: true,
|
||||||
}"
|
}"
|
||||||
@print="$emit('print')"
|
@print="$emit('print')"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
<v-hover v-slot="{ hover }" :open-delay="50">
|
<v-hover v-slot="{ hover }" :open-delay="50">
|
||||||
<v-card
|
<v-card
|
||||||
:class="{ 'on-hover': hover }"
|
:class="{ 'on-hover': hover }"
|
||||||
|
:style="{ cursor }"
|
||||||
:elevation="hover ? 12 : 2"
|
:elevation="hover ? 12 : 2"
|
||||||
:to="route ? recipeRoute : ''"
|
:to="recipeRoute"
|
||||||
:min-height="imageHeight + 75"
|
:min-height="imageHeight + 75"
|
||||||
@click="$emit('click')"
|
@click="$emit('click')"
|
||||||
>
|
>
|
||||||
@@ -33,10 +34,10 @@
|
|||||||
</v-card-title>
|
</v-card-title>
|
||||||
|
|
||||||
<slot name="actions">
|
<slot name="actions">
|
||||||
<v-card-actions class="px-1">
|
<v-card-actions v-if="showRecipeContent" class="px-1">
|
||||||
<RecipeFavoriteBadge v-if="isOwnGroup" class="absolute" :slug="slug" show-always />
|
<RecipeFavoriteBadge v-if="isOwnGroup" class="absolute" :recipe-id="recipeId" show-always />
|
||||||
|
|
||||||
<RecipeRating class="pb-1" :value="rating" :name="name" :slug="slug" :small="true" />
|
<RecipeRating class="pb-1" :value="rating" :recipe-id="recipeId" :slug="slug" :small="true" />
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" />
|
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" />
|
||||||
|
|
||||||
@@ -96,15 +97,15 @@ export default defineComponent({
|
|||||||
required: false,
|
required: false,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
|
ratingColor: {
|
||||||
|
type: String,
|
||||||
|
default: "secondary",
|
||||||
|
},
|
||||||
image: {
|
image: {
|
||||||
type: String,
|
type: String,
|
||||||
required: false,
|
required: false,
|
||||||
default: "abc123",
|
default: "abc123",
|
||||||
},
|
},
|
||||||
route: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
tags: {
|
tags: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
@@ -123,14 +124,18 @@ export default defineComponent({
|
|||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "")
|
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||||
|
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
||||||
const recipeRoute = computed<string>(() => {
|
const recipeRoute = computed<string>(() => {
|
||||||
return `/g/${groupSlug.value}/r/${props.slug}`;
|
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||||
});
|
});
|
||||||
|
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isOwnGroup,
|
isOwnGroup,
|
||||||
recipeRoute,
|
recipeRoute,
|
||||||
|
showRecipeContent,
|
||||||
|
cursor,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<v-card
|
<v-card
|
||||||
:ripple="false"
|
:ripple="false"
|
||||||
:class="isFlat ? 'mx-auto flat' : 'mx-auto'"
|
:class="isFlat ? 'mx-auto flat' : 'mx-auto'"
|
||||||
|
:style="{ cursor }"
|
||||||
hover
|
hover
|
||||||
:to="$listeners.selected ? undefined : recipeRoute"
|
:to="$listeners.selected ? undefined : recipeRoute"
|
||||||
@click="$emit('selected')"
|
@click="$emit('selected')"
|
||||||
@@ -37,22 +38,20 @@
|
|||||||
</v-list-item-subtitle>
|
</v-list-item-subtitle>
|
||||||
<div class="d-flex flex-wrap justify-end align-center">
|
<div class="d-flex flex-wrap justify-end align-center">
|
||||||
<slot name="actions">
|
<slot name="actions">
|
||||||
<RecipeFavoriteBadge v-if="isOwnGroup" :slug="slug" show-always />
|
<RecipeFavoriteBadge v-if="isOwnGroup && showRecipeContent" :recipe-id="recipeId" show-always />
|
||||||
<v-rating
|
<RecipeRating
|
||||||
color="secondary"
|
|
||||||
:class="isOwnGroup ? 'ml-auto' : 'ml-auto pb-2'"
|
:class="isOwnGroup ? 'ml-auto' : 'ml-auto pb-2'"
|
||||||
background-color="secondary lighten-3"
|
|
||||||
dense
|
|
||||||
length="5"
|
|
||||||
size="15"
|
|
||||||
:value="rating"
|
:value="rating"
|
||||||
></v-rating>
|
:recipe-id="recipeId"
|
||||||
|
:slug="slug"
|
||||||
|
:small="true"
|
||||||
|
/>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
|
|
||||||
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
||||||
<!-- We also add padding to the v-rating above to compensate -->
|
<!-- We also add padding to the v-rating above to compensate -->
|
||||||
<RecipeContextMenu
|
<RecipeContextMenu
|
||||||
v-if="isOwnGroup"
|
v-if="isOwnGroup && showRecipeContent"
|
||||||
:slug="slug"
|
:slug="slug"
|
||||||
:menu-icon="$globals.icons.dotsHorizontal"
|
:menu-icon="$globals.icons.dotsHorizontal"
|
||||||
:name="name"
|
:name="name"
|
||||||
@@ -83,12 +82,14 @@ import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composi
|
|||||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||||
import RecipeCardImage from "./RecipeCardImage.vue";
|
import RecipeCardImage from "./RecipeCardImage.vue";
|
||||||
|
import RecipeRating from "./RecipeRating.vue";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipeFavoriteBadge,
|
RecipeFavoriteBadge,
|
||||||
RecipeContextMenu,
|
RecipeContextMenu,
|
||||||
|
RecipeRating,
|
||||||
RecipeCardImage,
|
RecipeCardImage,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
@@ -113,10 +114,6 @@ export default defineComponent({
|
|||||||
required: false,
|
required: false,
|
||||||
default: "abc123",
|
default: "abc123",
|
||||||
},
|
},
|
||||||
route: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
recipeId: {
|
recipeId: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -135,14 +132,19 @@ export default defineComponent({
|
|||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "")
|
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||||
|
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
||||||
const recipeRoute = computed<string>(() => {
|
const recipeRoute = computed<string>(() => {
|
||||||
return `/g/${groupSlug.value}/r/${props.slug}`;
|
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||||
});
|
});
|
||||||
|
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isOwnGroup,
|
isOwnGroup,
|
||||||
recipeRoute,
|
recipeRoute,
|
||||||
|
showRecipeContent,
|
||||||
|
cursor,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
color="accent"
|
color="accent"
|
||||||
:small="small"
|
:small="small"
|
||||||
dark
|
dark
|
||||||
:to="isOwnGroup ? `${baseRecipeRoute}?${urlPrefix}=${category.id}` : undefined"
|
:to="`${baseRecipeRoute}?${urlPrefix}=${category.id}`"
|
||||||
>
|
>
|
||||||
{{ truncateText(category.name) }}
|
{{ truncateText(category.name) }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
@@ -18,8 +18,7 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
|
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||||
import { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/user";
|
|
||||||
|
|
||||||
export type UrlPrefixParam = "tags" | "categories" | "tools";
|
export type UrlPrefixParam = "tags" | "categories" | "tools";
|
||||||
|
|
||||||
@@ -56,7 +55,6 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const { $auth } = useContext();
|
const { $auth } = useContext();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "")
|
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "")
|
||||||
@@ -74,7 +72,6 @@ export default defineComponent({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
baseRecipeRoute,
|
baseRecipeRoute,
|
||||||
isOwnGroup,
|
|
||||||
truncateText,
|
truncateText,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -59,7 +59,13 @@
|
|||||||
v-on="on"
|
v-on="on"
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
</template>
|
</template>
|
||||||
<v-date-picker v-model="newMealdate" no-title @input="pickerMenu = false"></v-date-picker>
|
<v-date-picker
|
||||||
|
v-model="newMealdate"
|
||||||
|
no-title
|
||||||
|
:first-day-of-week="firstDayOfWeek"
|
||||||
|
:local="$i18n.locale"
|
||||||
|
@input="pickerMenu = false"
|
||||||
|
/>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
<v-select
|
<v-select
|
||||||
v-model="newMealType"
|
v-model="newMealType"
|
||||||
@@ -99,6 +105,26 @@
|
|||||||
</v-list-item-icon>
|
</v-list-item-icon>
|
||||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length">
|
||||||
|
<v-divider />
|
||||||
|
<v-list-group @click.stop>
|
||||||
|
<template #activator>
|
||||||
|
<v-list-item-title>{{ $tc("recipe.recipe-actions") }}</v-list-item-title>
|
||||||
|
</template>
|
||||||
|
<v-list dense class="ma-0 pa-0">
|
||||||
|
<v-list-item
|
||||||
|
v-for="(action, index) in recipeActions"
|
||||||
|
:key="index"
|
||||||
|
class="pl-6"
|
||||||
|
@click="executeRecipeAction(action)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>
|
||||||
|
{{ action.title }}
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-list-group>
|
||||||
|
</div>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,10 +137,12 @@ import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
|
|||||||
import RecipeDialogShare from "./RecipeDialogShare.vue";
|
import RecipeDialogShare from "./RecipeDialogShare.vue";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
|
import { useGroupRecipeActions } from "~/composables/use-group-recipe-actions";
|
||||||
|
import { useGroupSelf } from "~/composables/use-groups";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
|
import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import { Recipe } from "~/lib/api/types/recipe";
|
||||||
import { ShoppingListSummary } from "~/lib/api/types/group";
|
import { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/group";
|
||||||
import { PlanEntryType } from "~/lib/api/types/meal-plan";
|
import { PlanEntryType } from "~/lib/api/types/meal-plan";
|
||||||
import { useAxiosDownloader } from "~/composables/api/use-axios-download";
|
import { useAxiosDownloader } from "~/composables/api/use-axios-download";
|
||||||
|
|
||||||
@@ -127,6 +155,7 @@ export interface ContextMenuIncludes {
|
|||||||
print: boolean;
|
print: boolean;
|
||||||
printPreferences: boolean;
|
printPreferences: boolean;
|
||||||
share: boolean;
|
share: boolean;
|
||||||
|
recipeActions: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContextMenuItem {
|
export interface ContextMenuItem {
|
||||||
@@ -156,6 +185,7 @@ export default defineComponent({
|
|||||||
print: true,
|
print: true,
|
||||||
printPreferences: true,
|
printPreferences: true,
|
||||||
share: true,
|
share: true,
|
||||||
|
recipeActions: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
// Append items are added at the end of the useItems list
|
// Append items are added at the end of the useItems list
|
||||||
@@ -224,11 +254,16 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { i18n, $auth, $globals } = useContext();
|
const { i18n, $auth, $globals } = useContext();
|
||||||
|
const { group } = useGroupSelf();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||||
|
|
||||||
|
const firstDayOfWeek = computed(() => {
|
||||||
|
return group.value?.preferences?.firstDayOfWeek || 0;
|
||||||
|
});
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// Context Menu Setup
|
// Context Menu Setup
|
||||||
|
|
||||||
@@ -321,7 +356,7 @@ export default defineComponent({
|
|||||||
const recipeRefWithScale = computed(() => recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined);
|
const recipeRefWithScale = computed(() => recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined);
|
||||||
|
|
||||||
async function getShoppingLists() {
|
async function getShoppingLists() {
|
||||||
const { data } = await api.shopping.lists.getAll();
|
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
|
||||||
if (data) {
|
if (data) {
|
||||||
shoppingLists.value = data.items ?? [];
|
shoppingLists.value = data.items ?? [];
|
||||||
}
|
}
|
||||||
@@ -335,6 +370,19 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const groupRecipeActionsStore = useGroupRecipeActions();
|
||||||
|
|
||||||
|
async function executeRecipeAction(action: GroupRecipeActionOut) {
|
||||||
|
const response = await groupRecipeActionsStore.execute(action, props.recipe);
|
||||||
|
|
||||||
|
if (action.actionType === "post") {
|
||||||
|
if (!response || (response.status >= 200 && response.status < 300)) {
|
||||||
|
alert.success(i18n.tc("events.message-sent"));
|
||||||
|
} else {
|
||||||
|
alert.error(i18n.tc("events.something-went-wrong"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteRecipe() {
|
async function deleteRecipe() {
|
||||||
await api.recipes.deleteOne(props.slug);
|
await api.recipes.deleteOne(props.slug);
|
||||||
@@ -425,6 +473,8 @@ export default defineComponent({
|
|||||||
...toRefs(state),
|
...toRefs(state),
|
||||||
recipeRef,
|
recipeRef,
|
||||||
recipeRefWithScale,
|
recipeRefWithScale,
|
||||||
|
executeRecipeAction,
|
||||||
|
recipeActions: groupRecipeActionsStore.recipeActions,
|
||||||
shoppingLists,
|
shoppingLists,
|
||||||
duplicateRecipe,
|
duplicateRecipe,
|
||||||
contextMenuEventHandler,
|
contextMenuEventHandler,
|
||||||
@@ -432,6 +482,7 @@ export default defineComponent({
|
|||||||
addRecipeToPlan,
|
addRecipeToPlan,
|
||||||
icon,
|
icon,
|
||||||
planTypeOptions,
|
planTypeOptions,
|
||||||
|
firstDayOfWeek,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<BaseDialog v-if="shoppingListDialog" v-model="dialog" :title="$t('recipe.add-to-list')" :icon="$globals.icons.cartCheck">
|
<BaseDialog v-if="shoppingListDialog" v-model="dialog" :title="$t('recipe.add-to-list')" :icon="$globals.icons.cartCheck">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-card
|
<v-card
|
||||||
v-for="list in shoppingLists"
|
v-for="list in shoppingListChoices"
|
||||||
:key="list.id"
|
:key="list.id"
|
||||||
hover
|
hover
|
||||||
class="my-2 left-border"
|
class="my-2 left-border"
|
||||||
@@ -14,6 +14,18 @@
|
|||||||
</v-card-title>
|
</v-card-title>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
<template #card-actions>
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
color="grey"
|
||||||
|
@click="dialog = false"
|
||||||
|
>
|
||||||
|
{{ $t("general.cancel") }}
|
||||||
|
</v-btn>
|
||||||
|
<div class="d-flex justify-end" style="width: 100%;">
|
||||||
|
<v-checkbox v-model="preferences.viewAllLists" hide-details :label="$tc('general.show-all')" class="my-auto mr-4" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
v-if="shoppingListIngredientDialog"
|
v-if="shoppingListIngredientDialog"
|
||||||
@@ -120,6 +132,7 @@ import { toRefs } from "@vueuse/core";
|
|||||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
|
import { useShoppingListPreferences } from "~/composables/use-users/preferences";
|
||||||
import { ShoppingListSummary } from "~/lib/api/types/group";
|
import { ShoppingListSummary } from "~/lib/api/types/group";
|
||||||
import { Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
|
import { Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
@@ -164,8 +177,9 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const { i18n } = useContext();
|
const { $auth, i18n } = useContext();
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
|
const preferences = useShoppingListPreferences();
|
||||||
|
|
||||||
// v-model support
|
// v-model support
|
||||||
const dialog = computed({
|
const dialog = computed({
|
||||||
@@ -183,6 +197,10 @@ export default defineComponent({
|
|||||||
shoppingListIngredientDialog: false,
|
shoppingListIngredientDialog: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shoppingListChoices = computed(() => {
|
||||||
|
return props.shoppingLists.filter((list) => preferences.value.viewAllLists || list.userId === $auth.user?.id);
|
||||||
|
});
|
||||||
|
|
||||||
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
|
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
|
||||||
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
|
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
|
||||||
|
|
||||||
@@ -334,6 +352,8 @@ export default defineComponent({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
dialog,
|
dialog,
|
||||||
|
preferences,
|
||||||
|
shoppingListChoices,
|
||||||
...toRefs(state),
|
...toRefs(state),
|
||||||
addRecipesToList,
|
addRecipesToList,
|
||||||
bulkCheckIngredients,
|
bulkCheckIngredients,
|
||||||
|
|||||||
@@ -45,7 +45,6 @@
|
|||||||
:rating="recipe.rating"
|
:rating="recipe.rating"
|
||||||
:image="recipe.image"
|
:image="recipe.image"
|
||||||
:recipe-id="recipe.id"
|
:recipe-id="recipe.id"
|
||||||
:route="true"
|
|
||||||
v-on="$listeners.selected ? { selected: () => handleSelect(recipe) } : {}"
|
v-on="$listeners.selected ? { selected: () => handleSelect(recipe) } : {}"
|
||||||
/>
|
/>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|||||||
@@ -22,7 +22,13 @@
|
|||||||
v-on="on"
|
v-on="on"
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
</template>
|
</template>
|
||||||
<v-date-picker v-model="expirationDate" no-title @input="datePickerMenu = false"></v-date-picker>
|
<v-date-picker
|
||||||
|
v-model="expirationDate"
|
||||||
|
no-title
|
||||||
|
:first-day-of-week="firstDayOfWeek"
|
||||||
|
:local="$i18n.locale"
|
||||||
|
@input="datePickerMenu = false"
|
||||||
|
/>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions class="justify-end">
|
<v-card-actions class="justify-end">
|
||||||
@@ -60,6 +66,7 @@ import { defineComponent, computed, toRefs, reactive, useContext, useRoute } fro
|
|||||||
import { useClipboard, useShare, whenever } from "@vueuse/core";
|
import { useClipboard, useShare, whenever } from "@vueuse/core";
|
||||||
import { RecipeShareToken } from "~/lib/api/types/recipe";
|
import { RecipeShareToken } from "~/lib/api/types/recipe";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
|
import { useGroupSelf } from "~/composables/use-groups";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
@@ -106,9 +113,14 @@ export default defineComponent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { $auth, i18n } = useContext();
|
const { $auth, i18n } = useContext();
|
||||||
|
const { group } = useGroupSelf();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||||
|
|
||||||
|
const firstDayOfWeek = computed(() => {
|
||||||
|
return group.value?.preferences?.firstDayOfWeek || 0;
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Token Actions
|
// Token Actions
|
||||||
|
|
||||||
@@ -185,6 +197,7 @@ export default defineComponent({
|
|||||||
dialog,
|
dialog,
|
||||||
createNewToken,
|
createNewToken,
|
||||||
deleteToken,
|
deleteToken,
|
||||||
|
firstDayOfWeek,
|
||||||
shareRecipe,
|
shareRecipe,
|
||||||
copyTokenLink,
|
copyTokenLink,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,12 +67,16 @@
|
|||||||
<v-list>
|
<v-list>
|
||||||
<v-list-item @click="toggleOrderDirection()">
|
<v-list-item @click="toggleOrderDirection()">
|
||||||
<v-icon left>
|
<v-icon left>
|
||||||
{{ $globals.icons.sort }}
|
{{
|
||||||
|
state.orderDirection === "asc" ?
|
||||||
|
$globals.icons.sortDescending : $globals.icons.sortAscending
|
||||||
|
}}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-list-item-title>
|
<v-list-item-title>
|
||||||
{{ state.orderDirection === "asc" ? "Sort Descending" : "Sort Ascending" }}
|
{{ state.orderDirection === "asc" ? $tc("general.sort-descending") : $tc("general.sort-ascending") }}
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
<v-divider />
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="v in sortable"
|
v-for="v in sortable"
|
||||||
:key="v.name"
|
:key="v.name"
|
||||||
@@ -120,11 +124,12 @@
|
|||||||
<v-divider></v-divider>
|
<v-divider></v-divider>
|
||||||
<v-container class="mt-6 px-md-6">
|
<v-container class="mt-6 px-md-6">
|
||||||
<RecipeCardSection
|
<RecipeCardSection
|
||||||
|
v-if="state.ready"
|
||||||
class="mt-n5"
|
class="mt-n5"
|
||||||
:icon="$globals.icons.search"
|
:icon="$globals.icons.search"
|
||||||
:title="$tc('search.results')"
|
:title="$tc('search.results')"
|
||||||
:recipes="recipes"
|
:recipes="recipes"
|
||||||
:query="passedQuery"
|
:query="passedQueryWithSeed"
|
||||||
@replaceRecipes="replaceRecipes"
|
@replaceRecipes="replaceRecipes"
|
||||||
@appendRecipes="appendRecipes"
|
@appendRecipes="appendRecipes"
|
||||||
/>
|
/>
|
||||||
@@ -133,11 +138,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ref, defineComponent, useRouter, onMounted, useContext, computed, Ref, useRoute } from "@nuxtjs/composition-api";
|
import { ref, defineComponent, useRouter, onMounted, useContext, computed, Ref, useRoute, watch } from "@nuxtjs/composition-api";
|
||||||
import { watchDebounced } from "@vueuse/shared";
|
import { watchDebounced } from "@vueuse/shared";
|
||||||
import SearchFilter from "~/components/Domain/SearchFilter.vue";
|
import SearchFilter from "~/components/Domain/SearchFilter.vue";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import { useCategoryStore, useFoodStore, useTagStore, useToolStore } from "~/composables/store";
|
import { useCategoryStore, useFoodStore, useTagStore, useToolStore } from "~/composables/store";
|
||||||
|
import { useUserSearchQuerySession } from "~/composables/use-users/preferences";
|
||||||
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
|
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
|
||||||
import { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
import { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
@@ -157,6 +163,7 @@ export default defineComponent({
|
|||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
const state = ref({
|
const state = ref({
|
||||||
auto: true,
|
auto: true,
|
||||||
|
ready: false,
|
||||||
search: "",
|
search: "",
|
||||||
orderBy: "created_at",
|
orderBy: "created_at",
|
||||||
orderDirection: "desc" as "asc" | "desc",
|
orderDirection: "desc" as "asc" | "desc",
|
||||||
@@ -170,6 +177,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||||
|
const searchQuerySession = useUserSearchQuerySession();
|
||||||
|
|
||||||
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||||
const categories = isOwnGroup.value ? useCategoryStore() : usePublicCategoryStore(groupSlug.value);
|
const categories = isOwnGroup.value ? useCategoryStore() : usePublicCategoryStore(groupSlug.value);
|
||||||
@@ -184,65 +192,11 @@ export default defineComponent({
|
|||||||
const tools = isOwnGroup.value ? useToolStore() : usePublicToolStore(groupSlug.value);
|
const tools = isOwnGroup.value ? useToolStore() : usePublicToolStore(groupSlug.value);
|
||||||
const selectedTools = ref<NoUndefinedField<RecipeTool>[]>([]);
|
const selectedTools = ref<NoUndefinedField<RecipeTool>[]>([]);
|
||||||
|
|
||||||
const passedQuery = ref<RecipeSearchQuery | null>(null);
|
function calcPassedQuery(): RecipeSearchQuery {
|
||||||
|
return {
|
||||||
function reset() {
|
// the search clear button sets search to null, which still renders the query param for a moment,
|
||||||
state.value.search = "";
|
// whereas an empty string is not rendered
|
||||||
state.value.orderBy = "created_at";
|
search: state.value.search ? state.value.search : "",
|
||||||
state.value.orderDirection = "desc";
|
|
||||||
state.value.requireAllCategories = false;
|
|
||||||
state.value.requireAllTags = false;
|
|
||||||
state.value.requireAllTools = false;
|
|
||||||
state.value.requireAllFoods = false;
|
|
||||||
selectedCategories.value = [];
|
|
||||||
selectedFoods.value = [];
|
|
||||||
selectedTags.value = [];
|
|
||||||
selectedTools.value = [];
|
|
||||||
|
|
||||||
router.push({
|
|
||||||
query: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
search();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleOrderDirection() {
|
|
||||||
state.value.orderDirection = state.value.orderDirection === "asc" ? "desc" : "asc";
|
|
||||||
}
|
|
||||||
|
|
||||||
function toIDArray(array: { id: string }[]) {
|
|
||||||
return array.map((item) => item.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideKeyboard() {
|
|
||||||
input.value.blur()
|
|
||||||
}
|
|
||||||
|
|
||||||
const input: Ref<any> = ref(null);
|
|
||||||
|
|
||||||
async function search() {
|
|
||||||
await router.push({
|
|
||||||
query: {
|
|
||||||
categories: toIDArray(selectedCategories.value),
|
|
||||||
foods: toIDArray(selectedFoods.value),
|
|
||||||
tags: toIDArray(selectedTags.value),
|
|
||||||
tools: toIDArray(selectedTools.value),
|
|
||||||
// Only add the query param if it's or not default
|
|
||||||
...{
|
|
||||||
auto: state.value.auto ? undefined : "false",
|
|
||||||
search: state.value.search === "" ? undefined : state.value.search,
|
|
||||||
orderBy: state.value.orderBy === "createdAt" ? undefined : state.value.orderBy,
|
|
||||||
orderDirection: state.value.orderDirection === "desc" ? undefined : state.value.orderDirection,
|
|
||||||
requireAllCategories: state.value.requireAllCategories ? "true" : undefined,
|
|
||||||
requireAllTags: state.value.requireAllTags ? "true" : undefined,
|
|
||||||
requireAllTools: state.value.requireAllTools ? "true" : undefined,
|
|
||||||
requireAllFoods: state.value.requireAllFoods ? "true" : undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
passedQuery.value = {
|
|
||||||
search: state.value.search,
|
|
||||||
categories: toIDArray(selectedCategories.value),
|
categories: toIDArray(selectedCategories.value),
|
||||||
foods: toIDArray(selectedFoods.value),
|
foods: toIDArray(selectedFoods.value),
|
||||||
tags: toIDArray(selectedTags.value),
|
tags: toIDArray(selectedTags.value),
|
||||||
@@ -253,8 +207,87 @@ export default defineComponent({
|
|||||||
requireAllFoods: state.value.requireAllFoods,
|
requireAllFoods: state.value.requireAllFoods,
|
||||||
orderBy: state.value.orderBy,
|
orderBy: state.value.orderBy,
|
||||||
orderDirection: state.value.orderDirection,
|
orderDirection: state.value.orderDirection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const passedQuery = ref<RecipeSearchQuery>(calcPassedQuery());
|
||||||
|
|
||||||
|
// we calculate this separately because otherwise we can't check for query changes
|
||||||
|
const passedQueryWithSeed = computed(() => {
|
||||||
|
return {
|
||||||
|
...passedQuery.value,
|
||||||
_searchSeed: Date.now().toString()
|
_searchSeed: Date.now().toString()
|
||||||
};
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
const queryDefaults = {
|
||||||
|
search: "",
|
||||||
|
orderBy: "created_at",
|
||||||
|
orderDirection: "desc" as "asc" | "desc",
|
||||||
|
requireAllCategories: false,
|
||||||
|
requireAllTags: false,
|
||||||
|
requireAllTools: false,
|
||||||
|
requireAllFoods: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
state.value.search = queryDefaults.search;
|
||||||
|
state.value.orderBy = queryDefaults.orderBy;
|
||||||
|
state.value.orderDirection = queryDefaults.orderDirection;
|
||||||
|
state.value.requireAllCategories = queryDefaults.requireAllCategories;
|
||||||
|
state.value.requireAllTags = queryDefaults.requireAllTags;
|
||||||
|
state.value.requireAllTools = queryDefaults.requireAllTools;
|
||||||
|
state.value.requireAllFoods = queryDefaults.requireAllFoods;
|
||||||
|
selectedCategories.value = [];
|
||||||
|
selectedFoods.value = [];
|
||||||
|
selectedTags.value = [];
|
||||||
|
selectedTools.value = [];
|
||||||
|
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOrderDirection() {
|
||||||
|
state.value.orderDirection = state.value.orderDirection === "asc" ? "desc" : "asc";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIDArray(array: { id: string }[]) {
|
||||||
|
// we sort the array to make sure the query is always the same
|
||||||
|
return array.map((item) => item.id).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideKeyboard() {
|
||||||
|
input.value.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
const input: Ref<any> = ref(null);
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
const oldQueryValueString = JSON.stringify(passedQuery.value);
|
||||||
|
const newQueryValue = calcPassedQuery();
|
||||||
|
const newQueryValueString = JSON.stringify(newQueryValue);
|
||||||
|
if (oldQueryValueString === newQueryValueString) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
passedQuery.value = newQueryValue;
|
||||||
|
const query = {
|
||||||
|
categories: passedQuery.value.categories,
|
||||||
|
foods: passedQuery.value.foods,
|
||||||
|
tags: passedQuery.value.tags,
|
||||||
|
tools: passedQuery.value.tools,
|
||||||
|
// Only add the query param if it's not the default value
|
||||||
|
...{
|
||||||
|
auto: state.value.auto ? undefined : "false",
|
||||||
|
search: passedQuery.value.search === queryDefaults.search ? undefined : passedQuery.value.search,
|
||||||
|
orderBy: passedQuery.value.orderBy === queryDefaults.orderBy ? undefined : passedQuery.value.orderBy,
|
||||||
|
orderDirection: passedQuery.value.orderDirection === queryDefaults.orderDirection ? undefined : passedQuery.value.orderDirection,
|
||||||
|
requireAllCategories: passedQuery.value.requireAllCategories ? "true" : undefined,
|
||||||
|
requireAllTags: passedQuery.value.requireAllTags ? "true" : undefined,
|
||||||
|
requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined,
|
||||||
|
requireAllFoods: passedQuery.value.requireAllFoods ? "true" : undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await router.push({ query });
|
||||||
|
searchQuerySession.value.recipe = JSON.stringify(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
function waitUntilAndExecute(
|
function waitUntilAndExecute(
|
||||||
@@ -325,32 +358,69 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
onMounted(() => {
|
watch(
|
||||||
// Hydrate Search
|
() => route.value.query,
|
||||||
// wait for stores to be hydrated
|
() => {
|
||||||
|
if (state.value.ready) {
|
||||||
|
hydrateSearch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// read query params
|
async function hydrateSearch() {
|
||||||
const query = router.currentRoute.query;
|
const query = router.currentRoute.query;
|
||||||
|
if (query.auto?.length) {
|
||||||
if (query.auto) {
|
|
||||||
state.value.auto = query.auto === "true";
|
state.value.auto = query.auto === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.search) {
|
if (query.search?.length) {
|
||||||
state.value.search = query.search as string;
|
state.value.search = query.search as string;
|
||||||
|
} else {
|
||||||
|
state.value.search = queryDefaults.search;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.orderBy) {
|
if (query.orderBy?.length) {
|
||||||
state.value.orderBy = query.orderBy as string;
|
state.value.orderBy = query.orderBy as string;
|
||||||
|
} else {
|
||||||
|
state.value.orderBy = queryDefaults.orderBy;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.orderDirection) {
|
if (query.orderDirection?.length) {
|
||||||
state.value.orderDirection = query.orderDirection as "asc" | "desc";
|
state.value.orderDirection = query.orderDirection as "asc" | "desc";
|
||||||
|
} else {
|
||||||
|
state.value.orderDirection = queryDefaults.orderDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.requireAllCategories?.length) {
|
||||||
|
state.value.requireAllCategories = query.requireAllCategories === "true";
|
||||||
|
} else {
|
||||||
|
state.value.requireAllCategories = queryDefaults.requireAllCategories;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.requireAllTags?.length) {
|
||||||
|
state.value.requireAllTags = query.requireAllTags === "true";
|
||||||
|
} else {
|
||||||
|
state.value.requireAllTags = queryDefaults.requireAllTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.requireAllTools?.length) {
|
||||||
|
state.value.requireAllTools = query.requireAllTools === "true";
|
||||||
|
} else {
|
||||||
|
state.value.requireAllTools = queryDefaults.requireAllTools;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.requireAllFoods?.length) {
|
||||||
|
state.value.requireAllFoods = query.requireAllFoods === "true";
|
||||||
|
} else {
|
||||||
|
state.value.requireAllFoods = queryDefaults.requireAllFoods;
|
||||||
}
|
}
|
||||||
|
|
||||||
const promises: Promise<void>[] = [];
|
const promises: Promise<void>[] = [];
|
||||||
|
|
||||||
if (query.categories) {
|
if (query.categories?.length) {
|
||||||
promises.push(
|
promises.push(
|
||||||
waitUntilAndExecute(
|
waitUntilAndExecute(
|
||||||
() => categories.items.value.length > 0,
|
() => categories.items.value.length > 0,
|
||||||
@@ -363,9 +433,39 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
selectedCategories.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.foods) {
|
if (query.tags?.length) {
|
||||||
|
promises.push(
|
||||||
|
waitUntilAndExecute(
|
||||||
|
() => tags.items.value.length > 0,
|
||||||
|
() => {
|
||||||
|
const result = tags.items.value.filter((item) => (query.tags as string[]).includes(item.id as string));
|
||||||
|
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selectedTags.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.tools?.length) {
|
||||||
|
promises.push(
|
||||||
|
waitUntilAndExecute(
|
||||||
|
() => tools.items.value.length > 0,
|
||||||
|
() => {
|
||||||
|
const result = tools.items.value.filter((item) => (query.tools as string[]).includes(item.id));
|
||||||
|
selectedTools.value = result as NoUndefinedField<RecipeTool>[];
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selectedTools.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.foods?.length) {
|
||||||
promises.push(
|
promises.push(
|
||||||
waitUntilAndExecute(
|
waitUntilAndExecute(
|
||||||
() => {
|
() => {
|
||||||
@@ -380,35 +480,28 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
selectedFoods.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.tags) {
|
await Promise.allSettled(promises);
|
||||||
promises.push(
|
};
|
||||||
waitUntilAndExecute(
|
|
||||||
() => tags.items.value.length > 0,
|
onMounted(async () => {
|
||||||
() => {
|
// restore the user's last search query
|
||||||
const result = tags.items.value.filter((item) => (query.tags as string[]).includes(item.id as string));
|
if (searchQuerySession.value.recipe && !(Object.keys(route.value.query).length > 0)) {
|
||||||
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
|
try {
|
||||||
}
|
const query = JSON.parse(searchQuerySession.value.recipe);
|
||||||
)
|
await router.replace({ query });
|
||||||
);
|
} catch (error) {
|
||||||
|
searchQuerySession.value.recipe = "";
|
||||||
|
router.replace({ query: {} });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.tools) {
|
await hydrateSearch();
|
||||||
promises.push(
|
await search();
|
||||||
waitUntilAndExecute(
|
state.value.ready = true;
|
||||||
() => tools.items.value.length > 0,
|
|
||||||
() => {
|
|
||||||
const result = tools.items.value.filter((item) => (query.tools as string[]).includes(item.id));
|
|
||||||
selectedTools.value = result as NoUndefinedField<RecipeTool>[];
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Promise.allSettled(promises).then(() => {
|
|
||||||
search();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watchDebounced(
|
watchDebounced(
|
||||||
@@ -426,7 +519,7 @@ export default defineComponent({
|
|||||||
selectedTools,
|
selectedTools,
|
||||||
],
|
],
|
||||||
async () => {
|
async () => {
|
||||||
if (state.value.auto) {
|
if (state.value.ready && state.value.auto) {
|
||||||
await search();
|
await search();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -459,7 +552,7 @@ export default defineComponent({
|
|||||||
recipes,
|
recipes,
|
||||||
removeRecipe,
|
removeRecipe,
|
||||||
replaceRecipes,
|
replaceRecipes,
|
||||||
passedQuery,
|
passedQueryWithSeed,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
head: {},
|
head: {},
|
||||||
|
|||||||
@@ -22,11 +22,12 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||||
|
import { useUserSelfRatings } from "~/composables/use-users";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { UserOut } from "~/lib/api/types/user";
|
import { UserOut } from "~/lib/api/types/user";
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
slug: {
|
recipeId: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
@@ -42,19 +43,23 @@ export default defineComponent({
|
|||||||
setup(props) {
|
setup(props) {
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const { $auth } = useContext();
|
const { $auth } = useContext();
|
||||||
|
const { userRatings, refreshUserRatings } = useUserSelfRatings();
|
||||||
|
|
||||||
// TODO Setup the correct type for $auth.user
|
// TODO Setup the correct type for $auth.user
|
||||||
// See https://github.com/nuxt-community/auth-module/issues/1097
|
// See https://github.com/nuxt-community/auth-module/issues/1097
|
||||||
const user = computed(() => $auth.user as unknown as UserOut);
|
const user = computed(() => $auth.user as unknown as UserOut);
|
||||||
const isFavorite = computed(() => user.value?.favoriteRecipes?.includes(props.slug));
|
const isFavorite = computed(() => {
|
||||||
|
const rating = userRatings.value.find((r) => r.recipeId === props.recipeId);
|
||||||
|
return rating?.isFavorite || false;
|
||||||
|
});
|
||||||
|
|
||||||
async function toggleFavorite() {
|
async function toggleFavorite() {
|
||||||
if (!isFavorite.value) {
|
if (!isFavorite.value) {
|
||||||
await api.users.addFavorite(user.value?.id, props.slug);
|
await api.users.addFavorite(user.value?.id, props.recipeId);
|
||||||
} else {
|
} else {
|
||||||
await api.users.removeFavorite(user.value?.id, props.slug);
|
await api.users.removeFavorite(user.value?.id, props.recipeId);
|
||||||
}
|
}
|
||||||
$auth.fetchUser();
|
await refreshUserRatings();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { isFavorite, toggleFavorite };
|
return { isFavorite, toggleFavorite };
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
</v-col>
|
</v-col>
|
||||||
<v-col v-if="!disableAmount" sm="12" md="3" cols="12">
|
<v-col v-if="!disableAmount" sm="12" md="3" cols="12">
|
||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
|
ref="unitAutocomplete"
|
||||||
v-model="value.unit"
|
v-model="value.unit"
|
||||||
:search-input.sync="unitSearch"
|
:search-input.sync="unitSearch"
|
||||||
auto-select-first
|
auto-select-first
|
||||||
@@ -57,6 +58,7 @@
|
|||||||
<!-- Foods Input -->
|
<!-- Foods Input -->
|
||||||
<v-col v-if="!disableAmount" m="12" md="3" cols="12" class="">
|
<v-col v-if="!disableAmount" m="12" md="3" cols="12" class="">
|
||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
|
ref="foodAutocomplete"
|
||||||
v-model="value.food"
|
v-model="value.food"
|
||||||
:search-input.sync="foodSearch"
|
:search-input.sync="foodSearch"
|
||||||
auto-select-first
|
auto-select-first
|
||||||
@@ -200,11 +202,13 @@ export default defineComponent({
|
|||||||
const foodStore = useFoodStore();
|
const foodStore = useFoodStore();
|
||||||
const foodData = useFoodData();
|
const foodData = useFoodData();
|
||||||
const foodSearch = ref("");
|
const foodSearch = ref("");
|
||||||
|
const foodAutocomplete = ref<HTMLInputElement>();
|
||||||
|
|
||||||
async function createAssignFood() {
|
async function createAssignFood() {
|
||||||
foodData.data.name = foodSearch.value;
|
foodData.data.name = foodSearch.value;
|
||||||
props.value.food = await foodStore.actions.createOne(foodData.data) || undefined;
|
props.value.food = await foodStore.actions.createOne(foodData.data) || undefined;
|
||||||
foodData.reset();
|
foodData.reset();
|
||||||
|
foodAutocomplete.value?.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================================================
|
// ==================================================
|
||||||
@@ -212,11 +216,13 @@ export default defineComponent({
|
|||||||
const unitStore = useUnitStore();
|
const unitStore = useUnitStore();
|
||||||
const unitsData = useUnitData();
|
const unitsData = useUnitData();
|
||||||
const unitSearch = ref("");
|
const unitSearch = ref("");
|
||||||
|
const unitAutocomplete = ref<HTMLInputElement>();
|
||||||
|
|
||||||
async function createAssignUnit() {
|
async function createAssignUnit() {
|
||||||
unitsData.data.name = unitSearch.value;
|
unitsData.data.name = unitSearch.value;
|
||||||
props.value.unit = await unitStore.actions.createOne(unitsData.data) || undefined;
|
props.value.unit = await unitStore.actions.createOne(unitsData.data) || undefined;
|
||||||
unitsData.reset();
|
unitsData.reset();
|
||||||
|
unitAutocomplete.value?.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
@@ -269,7 +275,9 @@ export default defineComponent({
|
|||||||
contextMenuOptions,
|
contextMenuOptions,
|
||||||
handleUnitEnter,
|
handleUnitEnter,
|
||||||
handleFoodEnter,
|
handleFoodEnter,
|
||||||
|
foodAutocomplete,
|
||||||
createAssignFood,
|
createAssignFood,
|
||||||
|
unitAutocomplete,
|
||||||
createAssignUnit,
|
createAssignUnit,
|
||||||
foods: foodStore.foods,
|
foods: foodStore.foods,
|
||||||
foodSearch,
|
foodSearch,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
<v-date-picker
|
<v-date-picker
|
||||||
v-model="newTimelineEventTimestamp"
|
v-model="newTimelineEventTimestamp"
|
||||||
no-title
|
no-title
|
||||||
|
:first-day-of-week="firstDayOfWeek"
|
||||||
:local="$i18n.locale"
|
:local="$i18n.locale"
|
||||||
@input="datePickerMenu = false"
|
@input="datePickerMenu = false"
|
||||||
/>
|
/>
|
||||||
@@ -109,10 +110,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
|
import { computed, defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
|
||||||
import { whenever } from "@vueuse/core";
|
import { whenever } from "@vueuse/core";
|
||||||
import { VForm } from "~/types/vuetify";
|
import { VForm } from "~/types/vuetify";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
|
import { useGroupSelf } from "~/composables/use-groups";
|
||||||
import { Recipe, RecipeTimelineEventIn } from "~/lib/api/types/recipe";
|
import { Recipe, RecipeTimelineEventIn } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
@@ -129,11 +131,11 @@ export default defineComponent({
|
|||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const madeThisDialog = ref(false);
|
const madeThisDialog = ref(false);
|
||||||
const userApi = useUserApi();
|
const userApi = useUserApi();
|
||||||
|
const { group } = useGroupSelf();
|
||||||
const { $auth, i18n } = useContext();
|
const { $auth, i18n } = useContext();
|
||||||
const domMadeThisForm = ref<VForm>();
|
const domMadeThisForm = ref<VForm>();
|
||||||
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
||||||
// @ts-expect-error - TS doesn't like the $auth global user attribute
|
subject: "",
|
||||||
subject: i18n.t("recipe.user-made-this", { user: $auth.user.fullName } as string),
|
|
||||||
eventType: "comment",
|
eventType: "comment",
|
||||||
eventMessage: "",
|
eventMessage: "",
|
||||||
timestamp: undefined,
|
timestamp: undefined,
|
||||||
@@ -154,6 +156,10 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const firstDayOfWeek = computed(() => {
|
||||||
|
return group.value?.preferences?.firstDayOfWeek || 0;
|
||||||
|
});
|
||||||
|
|
||||||
function clearImage() {
|
function clearImage() {
|
||||||
newTimelineEventImage.value = undefined;
|
newTimelineEventImage.value = undefined;
|
||||||
newTimelineEventImageName.value = "";
|
newTimelineEventImageName.value = "";
|
||||||
@@ -178,6 +184,8 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
newTimelineEvent.value.recipeId = props.recipe.id
|
newTimelineEvent.value.recipeId = props.recipe.id
|
||||||
|
// @ts-expect-error - TS doesn't like the $auth global user attribute
|
||||||
|
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.fullName })
|
||||||
|
|
||||||
// the user only selects the date, so we set the time to end of day local time
|
// the user only selects the date, so we set the time to end of day local time
|
||||||
// we choose the end of day so it always comes after "new recipe" events
|
// we choose the end of day so it always comes after "new recipe" events
|
||||||
@@ -225,6 +233,7 @@ export default defineComponent({
|
|||||||
...toRefs(state),
|
...toRefs(state),
|
||||||
domMadeThisForm,
|
domMadeThisForm,
|
||||||
madeThisDialog,
|
madeThisDialog,
|
||||||
|
firstDayOfWeek,
|
||||||
newTimelineEvent,
|
newTimelineEvent,
|
||||||
newTimelineEventImage,
|
newTimelineEventImage,
|
||||||
newTimelineEventImagePreviewUrl,
|
newTimelineEventImagePreviewUrl,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="edit" class="d-flex justify-end">
|
<div v-if="edit" class="d-flex justify-end">
|
||||||
<BaseButton class="ml-auto my-2" @click="addNote"> {{ $t("general.new") }}</BaseButton>
|
<BaseButton class="ml-auto my-2" @click="addNote"> {{ $t("general.add") }}</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -19,11 +19,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-list v-if="showViewer" dense class="mt-0 pt-0">
|
<v-list v-if="showViewer" dense class="mt-0 pt-0">
|
||||||
<v-list-item v-for="(item, key, index) in labels" :key="index" style="min-height: 25px" dense>
|
<v-list-item v-for="(item, key, index) in renderedList" :key="index" style="min-height: 25px" dense>
|
||||||
<v-list-item-content>
|
<v-list-item-content>
|
||||||
<v-list-item-title class="pl-4 caption flex row">
|
<v-list-item-title class="pl-4 caption flex row">
|
||||||
<div>{{ item.label }}</div>
|
<div>{{ item.label }}</div>
|
||||||
<div class="ml-auto mr-1">{{ value[key] }}</div>
|
<div class="ml-auto mr-1">{{ item.value }}</div>
|
||||||
<div>{{ item.suffix }}</div>
|
<div>{{ item.suffix }}</div>
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
</v-list-item-content>
|
</v-list-item-content>
|
||||||
@@ -37,6 +37,14 @@
|
|||||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||||
import { Nutrition } from "~/lib/api/types/recipe";
|
import { Nutrition } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
|
type NutritionLabelType = {
|
||||||
|
[key: string]: {
|
||||||
|
label: string;
|
||||||
|
suffix: string;
|
||||||
|
value?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
value: {
|
value: {
|
||||||
@@ -50,34 +58,34 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const { i18n } = useContext();
|
const { i18n } = useContext();
|
||||||
const labels = {
|
const labels = <NutritionLabelType>{
|
||||||
calories: {
|
calories: {
|
||||||
label: i18n.t("recipe.calories"),
|
label: i18n.tc("recipe.calories"),
|
||||||
suffix: i18n.t("recipe.calories-suffix"),
|
suffix: i18n.tc("recipe.calories-suffix"),
|
||||||
},
|
},
|
||||||
fatContent: {
|
fatContent: {
|
||||||
label: i18n.t("recipe.fat-content"),
|
label: i18n.tc("recipe.fat-content"),
|
||||||
suffix: i18n.t("recipe.grams"),
|
suffix: i18n.tc("recipe.grams"),
|
||||||
},
|
},
|
||||||
fiberContent: {
|
fiberContent: {
|
||||||
label: i18n.t("recipe.fiber-content"),
|
label: i18n.tc("recipe.fiber-content"),
|
||||||
suffix: i18n.t("recipe.grams"),
|
suffix: i18n.tc("recipe.grams"),
|
||||||
},
|
},
|
||||||
proteinContent: {
|
proteinContent: {
|
||||||
label: i18n.t("recipe.protein-content"),
|
label: i18n.tc("recipe.protein-content"),
|
||||||
suffix: i18n.t("recipe.grams"),
|
suffix: i18n.tc("recipe.grams"),
|
||||||
},
|
},
|
||||||
sodiumContent: {
|
sodiumContent: {
|
||||||
label: i18n.t("recipe.sodium-content"),
|
label: i18n.tc("recipe.sodium-content"),
|
||||||
suffix: i18n.t("recipe.milligrams"),
|
suffix: i18n.tc("recipe.milligrams"),
|
||||||
},
|
},
|
||||||
sugarContent: {
|
sugarContent: {
|
||||||
label: i18n.t("recipe.sugar-content"),
|
label: i18n.tc("recipe.sugar-content"),
|
||||||
suffix: i18n.t("recipe.grams"),
|
suffix: i18n.tc("recipe.grams"),
|
||||||
},
|
},
|
||||||
carbohydrateContent: {
|
carbohydrateContent: {
|
||||||
label: i18n.t("recipe.carbohydrate-content"),
|
label: i18n.tc("recipe.carbohydrate-content"),
|
||||||
suffix: i18n.t("recipe.grams"),
|
suffix: i18n.tc("recipe.grams"),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const valueNotNull = computed(() => {
|
const valueNotNull = computed(() => {
|
||||||
@@ -96,11 +104,25 @@ export default defineComponent({
|
|||||||
context.emit("input", { ...props.value, [key]: event });
|
context.emit("input", { ...props.value, [key]: event });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a new list that only contains nutritional information that has a value
|
||||||
|
const renderedList = computed(() => {
|
||||||
|
return Object.entries(labels).reduce((item: NutritionLabelType, [key, label]) => {
|
||||||
|
if (props.value[key]?.trim()) {
|
||||||
|
item[key] = {
|
||||||
|
...label,
|
||||||
|
value: props.value[key],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}, {});
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
labels,
|
labels,
|
||||||
valueNotNull,
|
valueNotNull,
|
||||||
showViewer,
|
showViewer,
|
||||||
updateValue,
|
updateValue,
|
||||||
|
renderedList,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<BaseDialog v-if="updateTarget" v-model="dialogs.update" :title="$t('general.update')" @confirm="updateOne()">
|
<BaseDialog v-if="updateTarget" v-model="dialogs.update" :title="$t('general.update')" @confirm="updateOne()">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-text-field v-model="updateTarget.name" label="$t('general.name')"> </v-text-field>
|
<v-text-field v-model="updateTarget.name" :label="$t('general.name')"> </v-text-field>
|
||||||
<v-checkbox v-if="itemType === Organizer.Tool" v-model="updateTarget.onHand" :label="$t('tool.on-hand')"></v-checkbox>
|
<v-checkbox v-if="itemType === Organizer.Tool" v-model="updateTarget.onHand" :label="$t('tool.on-hand')"></v-checkbox>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
$globals.icons.tags"
|
$globals.icons.tags"
|
||||||
return-object
|
return-object
|
||||||
v-bind="inputAttrs"
|
v-bind="inputAttrs"
|
||||||
|
auto-select-first
|
||||||
|
:search-input.sync="searchInput"
|
||||||
|
@change="resetSearchInput"
|
||||||
>
|
>
|
||||||
<template #selection="data">
|
<template #selection="data">
|
||||||
<v-chip
|
<v-chip
|
||||||
@@ -43,7 +46,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, ref, useContext, computed, onMounted } from "@nuxtjs/composition-api";
|
import { defineComponent, ref, useContext, computed, onMounted } from "@nuxtjs/composition-api";
|
||||||
import RecipeOrganizerDialog from "./RecipeOrganizerDialog.vue";
|
import RecipeOrganizerDialog from "./RecipeOrganizerDialog.vue";
|
||||||
import { RecipeCategory, RecipeTag } from "~/lib/api/types/user";
|
import { RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
|
||||||
import { RecipeTool } from "~/lib/api/types/admin";
|
import { RecipeTool } from "~/lib/api/types/admin";
|
||||||
import { useTagStore } from "~/composables/store/use-tag-store";
|
import { useTagStore } from "~/composables/store/use-tag-store";
|
||||||
import { useCategoryStore, useToolStore } from "~/composables/store";
|
import { useCategoryStore, useToolStore } from "~/composables/store";
|
||||||
@@ -138,7 +141,6 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function appendCreated(item: RecipeTag | RecipeCategory | RecipeTool) {
|
function appendCreated(item: RecipeTag | RecipeCategory | RecipeTool) {
|
||||||
console.log(item);
|
|
||||||
if (selected.value === undefined) {
|
if (selected.value === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -148,6 +150,12 @@ export default defineComponent({
|
|||||||
|
|
||||||
const dialog = ref(false);
|
const dialog = ref(false);
|
||||||
|
|
||||||
|
const searchInput = ref("");
|
||||||
|
|
||||||
|
function resetSearchInput() {
|
||||||
|
searchInput.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Organizer,
|
Organizer,
|
||||||
appendCreated,
|
appendCreated,
|
||||||
@@ -156,6 +164,8 @@ export default defineComponent({
|
|||||||
label,
|
label,
|
||||||
selected,
|
selected,
|
||||||
removeByIndex,
|
removeByIndex,
|
||||||
|
searchInput,
|
||||||
|
resetSearchInput,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
/>
|
/>
|
||||||
<div v-if="isEditForm" class="d-flex">
|
<div v-if="isEditForm" class="d-flex">
|
||||||
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
|
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
|
||||||
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.new") }}</BaseButton>
|
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.add") }}</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!$vuetify.breakpoint.mdAndUp">
|
<div v-if="!$vuetify.breakpoint.mdAndUp">
|
||||||
<RecipePageOrganizers :recipe="recipe" />
|
<RecipePageOrganizers :recipe="recipe" />
|
||||||
@@ -112,6 +112,7 @@ import { useUserApi } from "~/composables/api";
|
|||||||
import { uuid4, deepCopy } from "~/composables/use-utils";
|
import { uuid4, deepCopy } from "~/composables/use-utils";
|
||||||
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
||||||
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
|
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
|
||||||
|
import { useNavigationWarning } from "~/composables/use-navigation-warning";
|
||||||
|
|
||||||
const EDITOR_OPTIONS = {
|
const EDITOR_OPTIONS = {
|
||||||
mode: "code",
|
mode: "code",
|
||||||
@@ -151,6 +152,7 @@ export default defineComponent({
|
|||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const { pageMode, editMode, setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode } =
|
const { pageMode, editMode, setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode } =
|
||||||
usePageState(props.recipe.slug);
|
usePageState(props.recipe.slug);
|
||||||
|
const { deactivateNavigationWarning } = useNavigationWarning();
|
||||||
|
|
||||||
/** =============================================================
|
/** =============================================================
|
||||||
* Recipe Snapshot on Mount
|
* Recipe Snapshot on Mount
|
||||||
@@ -175,6 +177,7 @@ export default defineComponent({
|
|||||||
await api.recipes.updateOne(props.recipe.slug, props.recipe);
|
await api.recipes.updateOne(props.recipe.slug, props.recipe);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
deactivateNavigationWarning();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** =============================================================
|
/** =============================================================
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-card-title class="headline pa-0 flex-column align-center">
|
<v-card-title class="headline pa-0 flex-column align-center">
|
||||||
{{ recipe.name }}
|
{{ recipe.name }}
|
||||||
<RecipeRating :key="recipe.slug" v-model="recipe.rating" :name="recipe.name" :slug="recipe.slug" />
|
<RecipeRating :key="recipe.slug" :value="recipe.rating" :recipe-id="recipe.id" :slug="recipe.slug" />
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-divider class="my-2"></v-divider>
|
<v-divider class="my-2"></v-divider>
|
||||||
<SafeMarkdown :source="recipe.description" />
|
<SafeMarkdown :source="recipe.description" />
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
<span>{{ parserToolTip }}</span>
|
<span>{{ parserToolTip }}</span>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<RecipeDialogBulkAdd class="mx-1 mb-1" @bulk-data="addIngredient" />
|
<RecipeDialogBulkAdd class="mx-1 mb-1" @bulk-data="addIngredient" />
|
||||||
<BaseButton class="mb-1" @click="addIngredient" > {{ $t("general.new") }} </BaseButton>
|
<BaseButton class="mb-1" @click="addIngredient" > {{ $t("general.add") }} </BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -148,10 +148,6 @@
|
|||||||
text: $tc('recipe.link-ingredients'),
|
text: $tc('recipe.link-ingredients'),
|
||||||
event: 'link-ingredients',
|
event: 'link-ingredients',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
text: $tc('recipe.merge-above'),
|
|
||||||
event: 'merge-above',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: $tc('recipe.upload-image'),
|
text: $tc('recipe.upload-image'),
|
||||||
event: 'upload-image'
|
event: 'upload-image'
|
||||||
@@ -160,11 +156,26 @@
|
|||||||
icon: previewStates[index] ? $globals.icons.edit : $globals.icons.eye,
|
icon: previewStates[index] ? $globals.icons.edit : $globals.icons.eye,
|
||||||
text: previewStates[index] ? $tc('recipe.edit-markdown') : $tc('markdown-editor.preview-markdown-button-label'),
|
text: previewStates[index] ? $tc('recipe.edit-markdown') : $tc('markdown-editor.preview-markdown-button-label'),
|
||||||
event: 'preview-step',
|
event: 'preview-step',
|
||||||
|
divider: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: $tc('recipe.merge-above'),
|
||||||
|
event: 'merge-above',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: $tc('recipe.move-to-top'),
|
||||||
|
event: 'move-to-top',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: $tc('recipe.move-to-bottom'),
|
||||||
|
event: 'move-to-bottom',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
@merge-above="mergeAbove(index - 1, index)"
|
@merge-above="mergeAbove(index - 1, index)"
|
||||||
|
@move-to-top="moveTo('top', index)"
|
||||||
|
@move-to-bottom="moveTo('bottom', index)"
|
||||||
@toggle-section="toggleShowTitle(step.id)"
|
@toggle-section="toggleShowTitle(step.id)"
|
||||||
@link-ingredients="openDialog(index, step.text, step.ingredientReferences)"
|
@link-ingredients="openDialog(index, step.text, step.ingredientReferences)"
|
||||||
@preview-step="togglePreviewState(index)"
|
@preview-step="togglePreviewState(index)"
|
||||||
@@ -531,6 +542,14 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moveTo(dest: string, source: number) {
|
||||||
|
if (dest === "top") {
|
||||||
|
props.value.unshift(props.value.splice(source, 1)[0]);
|
||||||
|
} else {
|
||||||
|
props.value.push(props.value.splice(source, 1)[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const previewStates = ref<boolean[]>([]);
|
const previewStates = ref<boolean[]>([]);
|
||||||
|
|
||||||
function togglePreviewState(index: number) {
|
function togglePreviewState(index: number) {
|
||||||
@@ -646,6 +665,7 @@ export default defineComponent({
|
|||||||
getIngredientByRefId,
|
getIngredientByRefId,
|
||||||
showTitleEditor,
|
showTitleEditor,
|
||||||
mergeAbove,
|
mergeAbove,
|
||||||
|
moveTo,
|
||||||
openDialog,
|
openDialog,
|
||||||
setIngredientIds,
|
setIngredientIds,
|
||||||
availableNextStep,
|
availableNextStep,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
v-if="landscape && $vuetify.breakpoint.smAndUp"
|
v-if="landscape && $vuetify.breakpoint.smAndUp"
|
||||||
:key="recipe.slug"
|
:key="recipe.slug"
|
||||||
v-model="recipe.rating"
|
v-model="recipe.rating"
|
||||||
:name="recipe.name"
|
:recipe-id="recipe.id"
|
||||||
:slug="recipe.slug"
|
:slug="recipe.slug"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
v-if="$vuetify.breakpoint.smAndDown"
|
v-if="$vuetify.breakpoint.smAndDown"
|
||||||
:key="recipe.slug"
|
:key="recipe.slug"
|
||||||
v-model="recipe.rating"
|
v-model="recipe.rating"
|
||||||
:name="recipe.name"
|
:recipe-id="recipe.id"
|
||||||
:slug="recipe.slug"
|
:slug="recipe.slug"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,48 @@
|
|||||||
<template>
|
<template>
|
||||||
<div @click.prevent>
|
<div @click.prevent>
|
||||||
<v-rating
|
<!-- User Rating -->
|
||||||
v-model="rating"
|
<v-hover v-slot="{ hover }">
|
||||||
:readonly="!isOwnGroup"
|
<v-rating
|
||||||
color="secondary"
|
v-if="isOwnGroup && (userRating || hover || !ratingsLoaded)"
|
||||||
background-color="secondary lighten-3"
|
:value="userRating"
|
||||||
length="5"
|
color="secondary"
|
||||||
:dense="small ? true : undefined"
|
background-color="secondary lighten-3"
|
||||||
:size="small ? 15 : undefined"
|
length="5"
|
||||||
hover
|
:dense="small ? true : undefined"
|
||||||
:value="value"
|
:size="small ? 15 : undefined"
|
||||||
clearable
|
hover
|
||||||
@input="updateRating"
|
clearable
|
||||||
@click="updateRating"
|
@input="updateRating"
|
||||||
></v-rating>
|
@click="updateRating"
|
||||||
|
/>
|
||||||
|
<!-- Group Rating -->
|
||||||
|
<v-rating
|
||||||
|
v-else
|
||||||
|
:value="groupRating"
|
||||||
|
:half-increments="true"
|
||||||
|
:readonly="true"
|
||||||
|
color="grey darken-1"
|
||||||
|
background-color="secondary lighten-3"
|
||||||
|
length="5"
|
||||||
|
:dense="small ? true : undefined"
|
||||||
|
:size="small ? 15 : undefined"
|
||||||
|
hover
|
||||||
|
/>
|
||||||
|
</v-hover>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, ref } from "@nuxtjs/composition-api";
|
import { computed, defineComponent, ref, watch } from "@nuxtjs/composition-api";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserSelfRatings } from "~/composables/use-users";
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
emitOnly: {
|
emitOnly: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
// TODO Remove name prop?
|
recipeId: {
|
||||||
name: {
|
|
||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
@@ -47,23 +61,45 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
const { userRatings, setRating, ready: ratingsLoaded } = useUserSelfRatings();
|
||||||
|
|
||||||
const rating = ref(props.value);
|
const userRating = computed(() => {
|
||||||
|
return userRatings.value.find((r) => r.recipeId === props.recipeId)?.rating;
|
||||||
|
});
|
||||||
|
|
||||||
|
// if a user unsets their rating, we don't want to fall back to the group rating since it's out of sync
|
||||||
|
const hideGroupRating = ref(!!userRating.value);
|
||||||
|
watch(
|
||||||
|
() => userRating.value,
|
||||||
|
() => {
|
||||||
|
if (userRating.value) {
|
||||||
|
hideGroupRating.value = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const groupRating = computed(() => {
|
||||||
|
return hideGroupRating.value ? 0 : props.value;
|
||||||
|
});
|
||||||
|
|
||||||
const api = useUserApi();
|
|
||||||
function updateRating(val: number | null) {
|
function updateRating(val: number | null) {
|
||||||
if (val === 0) {
|
if (!isOwnGroup.value) {
|
||||||
val = null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.emitOnly) {
|
if (!props.emitOnly) {
|
||||||
api.recipes.patchOne(props.slug, {
|
setRating(props.slug, val || 0, null);
|
||||||
rating: val,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
context.emit("input", val);
|
context.emit("input", val);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { isOwnGroup, rating, updateRating };
|
return {
|
||||||
|
isOwnGroup,
|
||||||
|
ratingsLoaded,
|
||||||
|
groupRating,
|
||||||
|
userRating,
|
||||||
|
updateRating,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,9 +3,53 @@
|
|||||||
<v-row class="my-0 mx-7">
|
<v-row class="my-0 mx-7">
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-col class="text-right">
|
<v-col class="text-right">
|
||||||
<v-btn fab small color="info" @click="reverseSort">
|
<!-- Filters -->
|
||||||
<v-icon> {{ preferences.orderDirection === "asc" ? $globals.icons.sortCalendarAscending : $globals.icons.sortCalendarDescending }} </v-icon>
|
<v-menu offset-y bottom left nudge-bottom="3" :close-on-content-click="false">
|
||||||
</v-btn>
|
<template #activator="{ on, attrs }">
|
||||||
|
<v-badge :content="filterBadgeCount" :value="filterBadgeCount" bordered overlap>
|
||||||
|
<v-btn fab small color="info" v-bind="attrs" v-on="on">
|
||||||
|
<v-icon> {{ $globals.icons.filter }} </v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-badge>
|
||||||
|
</template>
|
||||||
|
<v-card>
|
||||||
|
<v-list>
|
||||||
|
<v-list-item @click="reverseSort">
|
||||||
|
<v-icon left>
|
||||||
|
{{
|
||||||
|
preferences.orderDirection === "asc" ?
|
||||||
|
$globals.icons.sortCalendarDescending : $globals.icons.sortCalendarAscending
|
||||||
|
}}
|
||||||
|
</v-icon>
|
||||||
|
<v-list-item-title>
|
||||||
|
{{ preferences.orderDirection === "asc" ? $tc("general.sort-descending") : $tc("general.sort-ascending") }}
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
<v-divider />
|
||||||
|
<v-list-item class="pa-0">
|
||||||
|
<v-list class="py-0" style="width: 100%;">
|
||||||
|
<v-list-item
|
||||||
|
v-for="option, idx in eventTypeFilterState"
|
||||||
|
:key="idx"
|
||||||
|
>
|
||||||
|
<v-checkbox
|
||||||
|
:input-value="option.checked"
|
||||||
|
readonly
|
||||||
|
@click="toggleEventTypeOption(option.value)"
|
||||||
|
>
|
||||||
|
<template #label>
|
||||||
|
<v-icon left>
|
||||||
|
{{ option.icon }}
|
||||||
|
</v-icon>
|
||||||
|
{{ option.label }}
|
||||||
|
</template>
|
||||||
|
</v-checkbox>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-divider class="mx-2"/>
|
<v-divider class="mx-2"/>
|
||||||
@@ -29,9 +73,9 @@
|
|||||||
/>
|
/>
|
||||||
</v-timeline>
|
</v-timeline>
|
||||||
</div>
|
</div>
|
||||||
<v-card v-else-if="!loading">
|
<v-card v-else-if="!loading" class="mt-2">
|
||||||
<v-card-title class="justify-center pa-9">
|
<v-card-title class="justify-center pa-9">
|
||||||
{{ $t("recipe.timeline-is-empty") }}
|
{{ $t("recipe.timeline-no-events-found-try-adjusting-filters") }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
</v-card>
|
</v-card>
|
||||||
<div v-if="loading" class="mb-3 text-center">
|
<div v-if="loading" class="mb-3 text-center">
|
||||||
@@ -41,14 +85,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, onMounted, ref, useAsync, useContext } from "@nuxtjs/composition-api";
|
import { computed, defineComponent, onMounted, ref, useAsync, useContext } from "@nuxtjs/composition-api";
|
||||||
import { useThrottleFn, whenever } from "@vueuse/core";
|
import { useThrottleFn, whenever } from "@vueuse/core";
|
||||||
import RecipeTimelineItem from "./RecipeTimelineItem.vue"
|
import RecipeTimelineItem from "./RecipeTimelineItem.vue"
|
||||||
import { useTimelinePreferences } from "~/composables/use-users/preferences";
|
import { useTimelinePreferences } from "~/composables/use-users/preferences";
|
||||||
|
import { useTimelineEventTypes } from "~/composables/recipes/use-recipe-timeline-events";
|
||||||
import { useAsyncKey } from "~/composables/use-utils";
|
import { useAsyncKey } from "~/composables/use-utils";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate } from "~/lib/api/types/recipe"
|
import { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate, TimelineEventType } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: { RecipeTimelineItem },
|
components: { RecipeTimelineItem },
|
||||||
@@ -76,6 +121,7 @@ export default defineComponent({
|
|||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const { i18n } = useContext();
|
const { i18n } = useContext();
|
||||||
const preferences = useTimelinePreferences();
|
const preferences = useTimelinePreferences();
|
||||||
|
const { eventTypeOptions } = useTimelineEventTypes();
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const ready = ref(false);
|
const ready = ref(false);
|
||||||
|
|
||||||
@@ -85,6 +131,15 @@ export default defineComponent({
|
|||||||
|
|
||||||
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
|
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
|
||||||
const recipes = new Map<string, Recipe>();
|
const recipes = new Map<string, Recipe>();
|
||||||
|
const filterBadgeCount = computed(() => eventTypeOptions.value.length - preferences.value.types.length);
|
||||||
|
const eventTypeFilterState = computed(() => {
|
||||||
|
return eventTypeOptions.value.map(option => {
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
checked: preferences.value.types.includes(option.value),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
interface ScrollEvent extends Event {
|
interface ScrollEvent extends Event {
|
||||||
target: HTMLInputElement;
|
target: HTMLInputElement;
|
||||||
@@ -112,7 +167,7 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sorting
|
// Preferences
|
||||||
function reverseSort() {
|
function reverseSort() {
|
||||||
if (loading.value) {
|
if (loading.value) {
|
||||||
return;
|
return;
|
||||||
@@ -122,6 +177,21 @@ export default defineComponent({
|
|||||||
initializeTimelineEvents();
|
initializeTimelineEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleEventTypeOption(option: TimelineEventType) {
|
||||||
|
if (loading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = preferences.value.types.indexOf(option);
|
||||||
|
if (index === -1) {
|
||||||
|
preferences.value.types.push(option);
|
||||||
|
} else {
|
||||||
|
preferences.value.types.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeTimelineEvents();
|
||||||
|
}
|
||||||
|
|
||||||
// Timeline Actions
|
// Timeline Actions
|
||||||
async function updateTimelineEvent(index: number) {
|
async function updateTimelineEvent(index: number) {
|
||||||
const event = timelineEvents.value[index]
|
const event = timelineEvents.value[index]
|
||||||
@@ -179,8 +249,11 @@ export default defineComponent({
|
|||||||
async function scrollTimelineEvents() {
|
async function scrollTimelineEvents() {
|
||||||
const orderBy = "timestamp";
|
const orderBy = "timestamp";
|
||||||
const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc";
|
const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc";
|
||||||
|
// eslint-disable-next-line quotes
|
||||||
|
const eventTypeValue = `["${preferences.value.types.join('", "')}"]`;
|
||||||
|
const queryFilter = `(${props.queryFilter}) AND eventType IN ${eventTypeValue}`
|
||||||
|
|
||||||
const response = await api.recipes.getAllTimelineEvents(page.value, perPage, { orderBy, orderDirection, queryFilter: props.queryFilter });
|
const response = await api.recipes.getAllTimelineEvents(page.value, perPage, { orderBy, orderDirection, queryFilter });
|
||||||
page.value += 1;
|
page.value += 1;
|
||||||
if (!response?.data) {
|
if (!response?.data) {
|
||||||
return;
|
return;
|
||||||
@@ -256,11 +329,14 @@ export default defineComponent({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
deleteTimelineEvent,
|
deleteTimelineEvent,
|
||||||
|
filterBadgeCount,
|
||||||
loading,
|
loading,
|
||||||
onScroll,
|
onScroll,
|
||||||
preferences,
|
preferences,
|
||||||
|
eventTypeFilterState,
|
||||||
recipes,
|
recipes,
|
||||||
reverseSort,
|
reverseSort,
|
||||||
|
toggleEventTypeOption,
|
||||||
timelineEvents,
|
timelineEvents,
|
||||||
updateTimelineEvent,
|
updateTimelineEvent,
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user