Compare commits

...

123 Commits

Author SHA1 Message Date
vabene1111
cffe116145 Merge branch 'develop' 2022-02-05 11:31:29 +01:00
vabene1111
65eb80dbe6 Merge pull request #1484 from kristof-mattei/patch-1
Fixed JS parsing error on settings page
2022-02-05 11:21:59 +01:00
Kristof Mattei
3b946e512c Aligned formatting. 2022-02-04 14:04:05 -08:00
Kristof Mattei
d2a6409381 Fixed parsing error, } should've been included 2022-02-04 14:02:50 -08:00
Tomasz Klimczak
262a1f0064 Translated using Weblate (Polish)
Currently translated at 99.6% (297 of 298 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/pl/
2022-02-04 19:31:33 +00:00
vabene1111
fd026154d8 changed step header default and fixed ingredient rendering error 2022-02-04 17:11:35 +01:00
vabene1111
7f427c2d1f validate 0 servings in frontend 2022-02-04 16:51:16 +01:00
vabene1111
4fe5290b15 Merge branch 'feature/export-progress' into develop 2022-02-04 14:29:56 +01:00
vabene1111
d45e3b8e60 some tweaks for new exporter 2022-02-03 18:00:02 +01:00
vabene1111
a3fa01d8d3 Merge branch 'feature/export-progress' of https://github.com/vabene1111/recipes into feature/export-progress 2022-02-03 15:59:18 +01:00
vabene1111
9a746b5397 Merge branch 'develop' into feature/export-progress 2022-02-03 15:59:11 +01:00
vabene1111
ba3c0b933c update vue cli 2022-02-03 15:59:02 +01:00
vabene1111
87164e894a Merge pull request #1281 from TiagoRascazzi/develop
PDF export improvement
2022-02-03 15:58:06 +01:00
vabene1111
d01cb26c4a length filter for add to old shopping list 2022-02-03 15:46:59 +01:00
vabene1111
3501bcadb1 removed old cook log modal 2022-02-03 15:44:57 +01:00
vabene1111
1cf4f9cb4d Merge pull request #1475 from TandoorRecipes/dependabot/pip/django-cleanup-6.0.0
Bump django-cleanup from 5.2.0 to 6.0.0
2022-02-03 15:22:45 +01:00
dependabot[bot]
be24ee7922 Bump django-cleanup from 5.2.0 to 6.0.0
Bumps [django-cleanup](https://github.com/un1t/django-cleanup) from 5.2.0 to 6.0.0.
- [Release notes](https://github.com/un1t/django-cleanup/releases)
- [Changelog](https://github.com/un1t/django-cleanup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/un1t/django-cleanup/compare/5.2.0...6.0.0)

---
updated-dependencies:
- dependency-name: django-cleanup
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-03 14:22:23 +00:00
vabene1111
5e2c3d6ad2 Merge pull request #1477 from TandoorRecipes/dependabot/pip/pillow-9.0.1
Bump pillow from 9.0.0 to 9.0.1
2022-02-03 15:22:17 +01:00
vabene1111
129bf16e8c Merge pull request #1479 from TandoorRecipes/dependabot/pip/microdata-0.8.0
Bump microdata from 0.7.2 to 0.8.0
2022-02-03 15:22:13 +01:00
vabene1111
ec97b1edae Merge pull request #1480 from TandoorRecipes/dependabot/pip/recipe-scrapers-13.12.1
Bump recipe-scrapers from 13.10.1 to 13.12.1
2022-02-03 15:22:08 +01:00
dependabot[bot]
16a0ea07c7 Bump pillow from 9.0.0 to 9.0.1
Bumps [pillow](https://github.com/python-pillow/Pillow) from 9.0.0 to 9.0.1.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/9.0.0...9.0.1)

---
updated-dependencies:
- dependency-name: pillow
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-03 14:21:50 +00:00
dependabot[bot]
3ba70683d9 Bump recipe-scrapers from 13.10.1 to 13.12.1
Bumps [recipe-scrapers](https://github.com/hhursev/recipe-scrapers) from 13.10.1 to 13.12.1.
- [Release notes](https://github.com/hhursev/recipe-scrapers/releases)
- [Commits](https://github.com/hhursev/recipe-scrapers/compare/13.10.1...13.12.1)

---
updated-dependencies:
- dependency-name: recipe-scrapers
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-03 14:21:37 +00:00
dependabot[bot]
f07f3e183d Bump microdata from 0.7.2 to 0.8.0
Bumps [microdata](https://github.com/edsu/microdata) from 0.7.2 to 0.8.0.
- [Release notes](https://github.com/edsu/microdata/releases)
- [Commits](https://github.com/edsu/microdata/compare/v0.7.2...v0.8.0)

---
updated-dependencies:
- dependency-name: microdata
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-03 14:21:28 +00:00
vabene1111
5d75220312 Merge pull request #1478 from TandoorRecipes/dependabot/pip/requests-2.27.1
Bump requests from 2.27.0 to 2.27.1
2022-02-03 15:21:09 +01:00
vabene1111
c136319719 Merge pull request #1474 from TandoorRecipes/dependabot/pip/boto3-1.20.47
Bump boto3 from 1.20.27 to 1.20.47
2022-02-03 15:21:01 +01:00
dependabot[bot]
c75b666b17 Bump boto3 from 1.20.27 to 1.20.47
Bumps [boto3](https://github.com/boto/boto3) from 1.20.27 to 1.20.47.
- [Release notes](https://github.com/boto/boto3/releases)
- [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst)
- [Commits](https://github.com/boto/boto3/compare/1.20.27...1.20.47)

---
updated-dependencies:
- dependency-name: boto3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-03 13:42:36 +00:00
dependabot[bot]
fdc0dfaa15 Bump requests from 2.27.0 to 2.27.1
Bumps [requests](https://github.com/psf/requests) from 2.27.0 to 2.27.1.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.27.0...v2.27.1)

---
updated-dependencies:
- dependency-name: requests
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-03 13:42:07 +00:00
vabene1111
7f84186b5b Merge pull request #1456 from TandoorRecipes/dependabot/npm_and_yarn/vue/vue/compiler-sfc-3.2.29
Bump @vue/compiler-sfc from 3.2.26 to 3.2.29 in /vue
2022-02-03 14:41:52 +01:00
vabene1111
bc72086912 Merge pull request #1455 from TandoorRecipes/dependabot/npm_and_yarn/vue/typescript-4.5.5
Bump typescript from 4.5.4 to 4.5.5 in /vue
2022-02-03 14:41:47 +01:00
vabene1111
a41e5b362a Merge pull request #1454 from TandoorRecipes/dependabot/npm_and_yarn/vue/core-js-3.20.3
Bump core-js from 3.20.2 to 3.20.3 in /vue
2022-02-03 14:41:42 +01:00
vabene1111
d4ebbc0b63 Merge pull request #1452 from TandoorRecipes/dependabot/pip/boto3-1.20.46
Bump boto3 from 1.20.27 to 1.20.46
2022-02-03 14:41:37 +01:00
vabene1111
fccb2650f5 Merge pull request #1451 from TandoorRecipes/dependabot/pip/django-hcaptcha-0.2.0
Bump django-hcaptcha from 0.1.0 to 0.2.0
2022-02-03 14:41:32 +01:00
vabene1111
e4f74af9c0 Merge pull request #1450 from TandoorRecipes/dependabot/pip/django-crispy-forms-1.14.0
Bump django-crispy-forms from 1.13.0 to 1.14.0
2022-02-03 14:41:29 +01:00
vabene1111
982cde5623 Merge pull request #1449 from TandoorRecipes/dependabot/pip/pyppeteer-1.0.2
Bump pyppeteer from 0.2.6 to 1.0.2
2022-02-03 14:41:23 +01:00
vabene1111
66949356ea Merge pull request #1448 from TandoorRecipes/dependabot/pip/django-cors-headers-3.11.0
Bump django-cors-headers from 3.10.1 to 3.11.0
2022-02-03 14:41:19 +01:00
vabene1111
6952e10390 Merge branch 'develop' 2022-02-03 14:40:24 +01:00
vabene1111
ed99da2d1e Merge pull request #1472 from smilerz/updated_dockerignore
updated .dockerignore
2022-02-03 14:33:18 +01:00
vabene1111
ed852b3246 Merge pull request #1437 from MaxJa4/improvement/wait_for_db
[Docker] Web_recipes waits for db_recipes to be ready
2022-02-03 14:32:37 +01:00
vabene1111
eec0a49cd6 Merge pull request #1435 from MaxJa4/docs/headers
[Docs] Added example configs and header information
2022-02-03 14:30:20 +01:00
vabene1111
382c08dc0c Merge pull request #1445 from thumm/feature/ldap_cacertfile
Add new env parameter to set ldap ca cert file
2022-02-03 14:28:29 +01:00
Mario Dvorsek
231d1695ff Translated using Weblate (Slovenian)
Currently translated at 15.3% (78 of 509 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/sl/
2022-02-02 15:31:32 +00:00
Sven
97febe9aa1 Translated using Weblate (German)
Currently translated at 96.1% (546 of 568 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/de/
2022-02-02 15:31:32 +00:00
Chris Scoggins
c5a435905b remove vue source 2022-02-02 07:50:27 -06:00
vabene1111
86e34593d5 Merge pull request #1464 from StephenBrown2/make_header
Fix Make_Header/Make_Ingredient text and reference
2022-02-01 17:40:47 +01:00
Stephen Brown II
3961c684f9 Fix Make_Header/Make_Ingredient text and reference 2022-02-01 09:17:30 -06:00
vabene1111
b2a415b333 Merge pull request #1447 from smilerz/mealplan_shopping_useability
Mealplan shopping useability
2022-02-01 08:28:59 +01:00
dependabot[bot]
1e417fee97 Bump @vue/compiler-sfc from 3.2.26 to 3.2.29 in /vue
Bumps [@vue/compiler-sfc](https://github.com/vuejs/core/tree/HEAD/packages/compiler-sfc) from 3.2.26 to 3.2.29.
- [Release notes](https://github.com/vuejs/core/releases)
- [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vuejs/core/commits/v3.2.29/packages/compiler-sfc)

---
updated-dependencies:
- dependency-name: "@vue/compiler-sfc"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-01 00:10:38 +00:00
dependabot[bot]
47d7c846a3 Bump typescript from 4.5.4 to 4.5.5 in /vue
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 4.5.4 to 4.5.5.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v4.5.4...v4.5.5)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-01 00:10:13 +00:00
dependabot[bot]
3b236ea04e Bump core-js from 3.20.2 to 3.20.3 in /vue
Bumps [core-js](https://github.com/zloirock/core-js) from 3.20.2 to 3.20.3.
- [Release notes](https://github.com/zloirock/core-js/releases)
- [Changelog](https://github.com/zloirock/core-js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zloirock/core-js/compare/v3.20.2...v3.20.3)

---
updated-dependencies:
- dependency-name: core-js
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-01 00:09:45 +00:00
dependabot[bot]
2ec8bcce8b Bump boto3 from 1.20.27 to 1.20.46
Bumps [boto3](https://github.com/boto/boto3) from 1.20.27 to 1.20.46.
- [Release notes](https://github.com/boto/boto3/releases)
- [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst)
- [Commits](https://github.com/boto/boto3/compare/1.20.27...1.20.46)

---
updated-dependencies:
- dependency-name: boto3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-01 00:03:44 +00:00
dependabot[bot]
966cda2371 Bump django-hcaptcha from 0.1.0 to 0.2.0
Bumps [django-hcaptcha](https://github.com/AndrejZbin) from 0.1.0 to 0.2.0.

---
updated-dependencies:
- dependency-name: django-hcaptcha
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-01 00:03:35 +00:00
dependabot[bot]
fcb1de4b93 Bump django-crispy-forms from 1.13.0 to 1.14.0
Bumps [django-crispy-forms](https://github.com/django-crispy-forms/django-crispy-forms) from 1.13.0 to 1.14.0.
- [Release notes](https://github.com/django-crispy-forms/django-crispy-forms/releases)
- [Changelog](https://github.com/django-crispy-forms/django-crispy-forms/blob/main/CHANGELOG.md)
- [Commits](https://github.com/django-crispy-forms/django-crispy-forms/compare/1.13.0...1.14.0)

---
updated-dependencies:
- dependency-name: django-crispy-forms
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-01 00:03:31 +00:00
dependabot[bot]
ca61764d2d Bump pyppeteer from 0.2.6 to 1.0.2
Bumps [pyppeteer](https://github.com/pyppeteer/pyppeteer) from 0.2.6 to 1.0.2.
- [Release notes](https://github.com/pyppeteer/pyppeteer/releases)
- [Changelog](https://github.com/pyppeteer/pyppeteer/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/pyppeteer/pyppeteer/compare/0.2.6...1.0.2)

---
updated-dependencies:
- dependency-name: pyppeteer
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-01 00:03:26 +00:00
dependabot[bot]
a5946b49f8 Bump django-cors-headers from 3.10.1 to 3.11.0
Bumps [django-cors-headers](https://github.com/adamchainz/django-cors-headers) from 3.10.1 to 3.11.0.
- [Release notes](https://github.com/adamchainz/django-cors-headers/releases)
- [Changelog](https://github.com/adamchainz/django-cors-headers/blob/main/HISTORY.rst)
- [Commits](https://github.com/adamchainz/django-cors-headers/compare/3.10.1...3.11.0)

---
updated-dependencies:
- dependency-name: django-cors-headers
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-01 00:03:23 +00:00
Chris Scoggins
13d144345e adjust height of viewport in mobile shopping view 2022-01-31 16:30:54 -06:00
Chris Scoggins
b633be9c13 fix display issues 2022-01-31 16:24:40 -06:00
Chris Scoggins
f45e09a5a5 add units to search fields 2022-01-31 14:41:18 -06:00
Chris Scoggins
5b3a0a6e29 refactor IngredientComponent, move shopping logic to card 2022-01-31 14:05:01 -06:00
Thomas Schumm
505bac514f Add new env parameter to set ldap ca cert file 2022-01-31 19:34:32 +01:00
MaxJa4
39c3ce7ab2 More emphasis on waiting for migrations 2022-01-31 15:42:48 +01:00
MaxJa4
419821733c Optimized healthcheck timings
1s faster interval (to increase retry speed on faster systems), 1s shorter timeout (since it db should react in a lot less than 1s), increased retries to 12 total so all adds up to 60s of potential retry-time for older systems (e.g. RPI 3)
2022-01-31 14:27:39 +01:00
vabene1111
8216d0c025 Merge pull request #1439 from oliviapinson/develop
Simple font size change
2022-01-31 11:41:29 +01:00
dudel
98128fabab Translated using Weblate (German)
Currently translated at 95.9% (545 of 568 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/de/
2022-01-30 15:07:01 +00:00
Olivia Pinson
2d36db7822 Simple font size change
Adjusted font size so longer months won't affect arrow buttons. I think this helps the meal plan calendar look more aesthetic.
2022-01-29 22:44:19 -05:00
MaxJa4
300d132266 Removed retry process since it's fixed now
Removed retry process since it's fixed now by improvement/wait_for_db
2022-01-30 01:24:17 +01:00
MaxJa4
6330d15ebe Update docker-compose.yml 2022-01-30 01:20:54 +01:00
MaxJa4
d7d37f9908 Update docker-compose.yml 2022-01-30 01:20:33 +01:00
MaxJa4
fb29db7aad Web container waits for DB container to be ready
Fixes the setup issue almost all Raspberry Pi users have and probably some others with slow or low spec systems.
The web_recipes container waits for db_recipes to be healthy.
db_recipes is healthy, as soon as postgres is completely ready.

Note: ps_isready doesn't work here for some reason.

Tested multiple times with a Raspberry Pi 4.
2022-01-30 01:19:50 +01:00
MaxJa4
76dac29f1c Added raspberry pi setup issue FAQ
Added raspberry pi setup issue FAQ/info
2022-01-30 00:28:27 +01:00
Chris Scoggins
e00794bbdf review shopping list in MealPlan modal 2022-01-29 14:10:14 -06:00
Chris Scoggins
a7796cbf5c fix shopping list sharing 2022-01-29 11:59:06 -06:00
Chris Scoggins
e2f8f29ec8 refactor list_from_recipe as class RecipeShoppingEditor 2022-01-29 10:28:01 -06:00
MaxJa4
6e8729bb58 Added example configs and header information
Added example configs for plain local nginx and Apache2.
Also added FAQ section for all generic issues when not setting all required headers correctly.
Added section for required headers in the Docker installation docs.
2022-01-28 17:44:52 +01:00
vabene1111
a0892470e1 Merge branch 'feature/export-progress' into develop 2022-01-28 15:41:49 +01:00
vabene1111
9fcfa17004 Merge pull request #1377 from MaxJa4/docs/docker-installation
Enhancement of installation instruction docs
2022-01-28 15:35:20 +01:00
vabene1111
58f1ce0331 fixed rendering issue with single step recipes 2022-01-28 15:26:11 +01:00
vabene1111
20b4c4fb36 fixed using undeclared unused field 2022-01-28 15:21:07 +01:00
vabene1111
965e1664af Merge pull request #1411 from TheHaf/feature/addPortionSizeModifiersToGui
Feature/add portion size modifiers to gui
2022-01-28 15:17:05 +01:00
vabene1111
8232c77ef6 Merge pull request #1432 from smilerz/mealplan_shopping_useability
fix servings reactivity
2022-01-28 15:16:01 +01:00
Chris Scoggins
85bbcb0010 fix servings reactivity 2022-01-28 07:38:36 -06:00
vabene1111
338d8459de Merge pull request #1429 from smilerz/fix_shopping_sharing
fix #1426
2022-01-28 08:54:08 +01:00
Mario Dvorsek
fbf9a81121 Translated using Weblate (Slovenian)
Currently translated at 97.2% (283 of 291 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/sl/
2022-01-28 00:07:08 +00:00
Alex Hanselmann
1f80936805 Translated using Weblate (German)
Currently translated at 93.1% (529 of 568 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/de/
2022-01-28 00:07:08 +00:00
Chris Scoggins
8d424d668d fix #1426 2022-01-27 16:46:46 -06:00
vabene1111
b2fcdaa14e wont load by default otherwise 2022-01-27 14:15:38 +01:00
vabene1111
d4d949b870 Merge pull request #1421 from TandoorRecipes/minor_fixes
Minor fixes
2022-01-27 08:02:06 +01:00
smilerz
759ae99b56 Update en.json 2022-01-26 18:58:45 -06:00
smilerz
7104b5b109 sort SupermarketCategory queryset
fixes #1422
2022-01-26 17:31:22 -06:00
smilerz
331a949623 fixed saving shopping shared users 2022-01-26 17:27:52 -06:00
smilerz
cd733d3190 Update ShoppingListView.vue 2022-01-26 17:10:24 -06:00
Chris Scoggins
6e4bb64b4e fix translation on ModelListMenu 2022-01-26 15:00:21 -06:00
Chris Scoggins
4a48019885 fix duplicate shopping entries when using old list 2022-01-26 14:47:33 -06:00
Chris Scoggins
47823132f0 add ignore_shopping && additional help 2022-01-26 12:57:29 -06:00
Leni
bb5c8bbbf1 Translated using Weblate (German)
Currently translated at 100.0% (291 of 291 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/de/
2022-01-26 14:07:05 +00:00
Rene
5a0a1ca6a9 Translated using Weblate (German)
Currently translated at 100.0% (291 of 291 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/de/
2022-01-26 14:07:05 +00:00
Leni
19cc1e11b9 Translated using Weblate (German)
Currently translated at 91.5% (520 of 568 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/de/
2022-01-26 14:07:05 +00:00
Rene
c070c5b0ed Translated using Weblate (German)
Currently translated at 91.5% (520 of 568 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/de/
2022-01-26 14:07:05 +00:00
Chris Scoggins
2e2080d8d1 fix Recipe Counts on food/keyword/unit serializer 2022-01-25 16:48:30 -06:00
TheHaf
381a7e76be Use custom input component in recipe view 2022-01-25 22:04:21 +01:00
TheHaf
6c619ab628 Add custom input component with spin button look&feel 2022-01-25 22:03:49 +01:00
vabene1111
ae14dde13d propperly fixed setting 2022-01-25 18:33:12 +01:00
vabene1111
e33cf08fca Revert "use old system to define frontend settings"
This reverts commit f2e9f50d94.
2022-01-25 18:26:51 +01:00
vabene1111
f2e9f50d94 use old system to define frontend settings 2022-01-25 18:14:25 +01:00
vabene1111
75259ec230 corrected comment 2022-01-25 17:38:35 +01:00
vabene1111
f581f17308 Merge branch 'master' into develop 2022-01-25 15:30:36 +01:00
vabene1111
8c49e6ba18 another small migration fix 2022-01-25 15:29:36 +01:00
MaxJa4
075c88e5e8 Merge branch 'develop' into docs/docker-installation 2022-01-23 01:38:04 +01:00
MaxJa4
47dd3118b1 Enhanced manual installation docs 2022-01-19 19:42:38 +01:00
MaxJa4
2e85b01242 Enhanced kubernetes installation docs 2022-01-19 19:38:11 +01:00
MaxJa4
119379028d Enhanced unraid installation docs 2022-01-19 19:33:40 +01:00
MaxJa4
b8bb146422 Enhanced synology installation docs 2022-01-19 19:28:44 +01:00
MaxJa4
71a2f1955e Enhancement of docker install docs 2022-01-19 19:13:33 +01:00
MaxJa4
6b154b05a6 [Docs] Attempt to fix .yml inclusion
File inclusion broke some time ago. Changed back to original format like inclusion-plugin docs suggest.
2022-01-19 18:39:46 +01:00
Tiago Rascazzi
f8c744e301 Merge branch 'TandoorRecipes-develop' into develop 2022-01-12 11:56:27 -05:00
Tiago Rascazzi
a7770bda5b Merge with main 2022-01-12 11:56:11 -05:00
Tiago Rascazzi
fef9bcb1e1 Added date to filename 2022-01-11 15:44:10 -05:00
TiagoRascazzi
88e9e39c73 Merge branch 'TandoorRecipes:develop' into develop 2022-01-11 14:07:13 -05:00
Tiago Rascazzi
16b357e11e Added printReady selector 2022-01-08 14:44:28 -05:00
Tiago Rascazzi
7c48c13dce Added export from url args 2022-01-08 13:37:26 -05:00
Tiago Rascazzi
68eccd3c05 Merge branch 'develop' of https://github.com/TiagoRascazzi/recipes into develop 2022-01-08 12:31:14 -05:00
Tiago Rascazzi
33d1022a73 Increase number of result for multiselect in export 2022-01-08 12:30:42 -05:00
TiagoRascazzi
08e6833c12 Removed comment 2022-01-08 11:10:48 -05:00
Tiago Rascazzi
9c873127a5 Added loading page 2022-01-08 11:07:33 -05:00
89 changed files with 3580 additions and 1801 deletions

View File

@@ -1,4 +1,4 @@
node_modules
**/node_modules
npm-debug.log
Dockerfile*
docker-compose*
@@ -12,6 +12,21 @@ LICENSE
.env.template
.github
.idea
.prettierignore
LICENSE.md
docs
update.sh
update.sh
.pytest_cache
cookbook/tests
mediafiles
staticfiles
db.sqlite3
pytest.ini
vue/**/*.vue
vue/**/*.ts
**/.openapi-generator
mkdocs.yml
vue/babel.config*
vue/package.json
vue/tsconfig.json
vue/src/utils/openapi

View File

@@ -146,7 +146,12 @@ REVERSE_PROXY_AUTH=0
#AUTH_LDAP_BIND_DN=
#AUTH_LDAP_BIND_PASSWORD=
#AUTH_LDAP_USER_SEARCH_BASE_DN=
#AUTH_LDAP_TLS_CACERTFILE=
# Enables exporting PDF (see export docs)
# Disabled by default, uncomment to enable
# ENABLE_PDF_EXPORT=1
# Recipe exports are cached for a certain time by default, adjust time if needed
# EXPORT_FILE_CACHE_DURATION=600

View File

@@ -205,9 +205,9 @@ class CustomIsShared(permissions.BasePermission):
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
# temporary hack to make old shopping list work with new shopping list
if obj.__class__.__name__ == 'ShoppingList':
return is_object_shared(request.user, obj) or obj.created_by in list(request.user.get_shopping_share())
# # temporary hack to make old shopping list work with new shopping list
# if obj.__class__.__name__ in ['ShoppingList', 'ShoppingListEntry']:
# return is_object_shared(request.user, obj) or obj.created_by in list(request.user.get_shopping_share())
return is_object_shared(request.user, obj)

View File

@@ -8,7 +8,7 @@ from django.utils import timezone
from django.utils.translation import gettext as _
from cookbook.helper.HelperFunctions import Round, str2bool
from cookbook.models import (Ingredient, ShoppingListEntry, ShoppingListRecipe,
from cookbook.models import (Ingredient, MealPlan, Recipe, ShoppingListEntry, ShoppingListRecipe,
SupermarketCategoryRelation)
from recipes import settings
@@ -38,118 +38,272 @@ def shopping_helper(qs, request):
return qs.order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')
# TODO refactor as class
def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False):
"""
Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
:param list_recipe: Modify an existing ShoppingListRecipe
:param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required
:param mealplan: alternatively use a mealplan recipe as source of ingredients
:param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
:param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
:param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list
"""
r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
if not r:
raise ValueError(_("You must supply a recipe or mealplan"))
class RecipeShoppingEditor():
def __init__(self, user, space, **kwargs):
self.created_by = user
self.space = space
self._kwargs = {**kwargs}
created_by = created_by or getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', None)
if not created_by:
raise ValueError(_("You must supply a created_by"))
self.mealplan = self._kwargs.get('mealplan', None)
if type(self.mealplan) in [int, float]:
self.mealplan = MealPlan.objects.filter(id=self.mealplan, space=self.space)
self.id = self._kwargs.get('id', None)
try:
servings = float(servings)
except (ValueError, TypeError):
servings = getattr(mealplan, 'servings', 1.0)
self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space)
servings_factor = servings / r.servings
if self._shopping_list_recipe:
# created_by needs to be sticky to original creator as it is 'their' shopping list
# changing shopping list created_by can shift some items to new owner which may not share in the other direction
self.created_by = getattr(self._shopping_list_recipe.entries.first(), 'created_by', self.created_by)
shared_users = list(created_by.get_shopping_share())
shared_users.append(created_by)
if list_recipe:
created = False
else:
list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
created = True
self.recipe = getattr(self._shopping_list_recipe, 'recipe', None) or self._kwargs.get('recipe', None) or getattr(self.mealplan, 'recipe', None)
if type(self.recipe) in [int, float]:
self.recipe = Recipe.objects.filter(id=self.recipe, space=self.space)
related_step_ing = []
if servings == 0 and not created:
list_recipe.delete()
return []
elif ingredients:
ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
else:
ingredients = Ingredient.objects.filter(step__recipe=r, space=space)
try:
self.servings = float(self._kwargs.get('servings', None))
except (ValueError, TypeError):
self.servings = getattr(self._shopping_list_recipe, 'servings', None) or getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', None)
if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand:
ingredients = ingredients.exclude(food__onhand_users__id__in=[x.id for x in shared_users])
@property
def _servings_factor(self):
return self.servings / self.recipe.servings
if related := created_by.userpreference.mealplan_autoinclude_related:
# TODO: add levels of related recipes (related recipes of related recipes) to use when auto-adding mealplans
related_recipes = r.get_related_recipes()
@property
def _shared_users(self):
return [*list(self.created_by.get_shopping_share()), self.created_by]
for x in related_recipes:
# related recipe is a Step serving size is driven by recipe serving size
# TODO once/if Steps can have a serving size this needs to be refactored
if exclude_onhand:
# if steps are used more than once in a recipe or subrecipe - I don' think this results in the desired behavior
related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]).values_list('id', flat=True)
else:
related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True)
@staticmethod
def get_shopping_list_recipe(id, user, space):
return ShoppingListRecipe.objects.filter(id=id).filter(Q(shoppinglist__space=space) | Q(entries__space=space)).filter(
Q(shoppinglist__created_by=user)
| Q(shoppinglist__shared=user)
| Q(entries__created_by=user)
| Q(entries__created_by__in=list(user.get_shopping_share()))
).prefetch_related('entries').first()
x_ing = []
if ingredients.filter(food__recipe=x).exists():
for ing in ingredients.filter(food__recipe=x):
if exclude_onhand:
x_ing = Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users])
else:
x_ing = Ingredient.objects.filter(step__recipe=x, space=space)
for i in [x for x in x_ing]:
ShoppingListEntry.objects.create(
list_recipe=list_recipe,
food=i.food,
unit=i.unit,
ingredient=i,
amount=i.amount * Decimal(servings_factor),
created_by=created_by,
space=space,
)
# dont' add food to the shopping list that are actually recipes that will be added as ingredients
ingredients = ingredients.exclude(food__recipe=x)
def get_recipe_ingredients(self, id, exclude_onhand=False):
if exclude_onhand:
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space).exclude(food__onhand_users__id__in=[x.id for x in self._shared_users])
else:
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space)
add_ingredients = list(ingredients.values_list('id', flat=True)) + related_step_ing
if not append:
existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe)
# delete shopping list entries not included in ingredients
existing_list.exclude(ingredient__in=ingredients).delete()
# add shopping list entries that did not previously exist
add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
@property
def _include_related(self):
return self.created_by.userpreference.mealplan_autoinclude_related
# if servings have changed, update the ShoppingListRecipe and existing Entries
if servings <= 0:
servings = 1
@property
def _exclude_onhand(self):
return self.created_by.userpreference.mealplan_autoexclude_onhand
if not created and list_recipe.servings != servings:
update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True))
list_recipe.servings = servings
list_recipe.save()
for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients):
sle.amount = sle.ingredient.amount * Decimal(servings_factor)
def create(self, **kwargs):
ingredients = kwargs.get('ingredients', None)
exclude_onhand = not ingredients and self._exclude_onhand
if servings := kwargs.get('servings', None):
self.servings = float(servings)
if mealplan := kwargs.get('mealplan', None):
self.mealplan = mealplan
self.recipe = mealplan.recipe
elif recipe := kwargs.get('recipe', None):
self.recipe = recipe
if not self.servings:
self.servings = getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', 1.0)
self._shopping_list_recipe = ShoppingListRecipe.objects.create(recipe=self.recipe, mealplan=self.mealplan, servings=self.servings)
if ingredients:
self._add_ingredients(ingredients=ingredients)
else:
if self._include_related:
related = self.recipe.get_related_recipes()
self._add_ingredients(self.get_recipe_ingredients(self.recipe.id, exclude_onhand=exclude_onhand).exclude(food__recipe__in=related))
for r in related:
self._add_ingredients(self.get_recipe_ingredients(r.id, exclude_onhand=exclude_onhand).exclude(food__recipe__in=related))
else:
self._add_ingredients(self.get_recipe_ingredients(self.recipe.id, exclude_onhand=exclude_onhand))
return True
def add(self, **kwargs):
return
def edit(self, servings=None, ingredients=None, **kwargs):
if servings:
self.servings = servings
self._delete_ingredients(ingredients=ingredients)
if self.servings != self._shopping_list_recipe.servings:
self.edit_servings()
self._add_ingredients(ingredients=ingredients)
return True
def edit_servings(self, servings=None, **kwargs):
if servings:
self.servings = servings
if id := kwargs.get('id', None):
self._shopping_list_recipe = self.get_shopping_list_recipe(id, self.created_by, self.space)
if not self.servings:
raise ValueError(_("You must supply a servings size"))
if self._shopping_list_recipe.servings == self.servings:
return True
for sle in ShoppingListEntry.objects.filter(list_recipe=self._shopping_list_recipe):
sle.amount = sle.ingredient.amount * Decimal(self._servings_factor)
sle.save()
self._shopping_list_recipe.servings = self.servings
self._shopping_list_recipe.save()
return True
# add any missing Entries
for i in [x for x in add_ingredients if x.food]:
def delete(self, **kwargs):
try:
self._shopping_list_recipe.delete()
return True
except:
return False
ShoppingListEntry.objects.create(
list_recipe=list_recipe,
food=i.food,
unit=i.unit,
ingredient=i,
amount=i.amount * Decimal(servings_factor),
created_by=created_by,
space=space,
)
def _add_ingredients(self, ingredients=None):
if not ingredients:
return
elif type(ingredients) == list:
ingredients = Ingredient.objects.filter(id__in=ingredients)
existing = self._shopping_list_recipe.entries.filter(ingredient__in=ingredients).values_list('ingredient__pk', flat=True)
add_ingredients = ingredients.exclude(id__in=existing)
# return all shopping list items
return list_recipe
for i in [x for x in add_ingredients if x.food]:
ShoppingListEntry.objects.create(
list_recipe=self._shopping_list_recipe,
food=i.food,
unit=i.unit,
ingredient=i,
amount=i.amount * Decimal(self._servings_factor),
created_by=self.created_by,
space=self.space,
)
# deletes shopping list entries not in ingredients list
def _delete_ingredients(self, ingredients=None):
if not ingredients:
return
to_delete = self._shopping_list_recipe.entries.exclude(ingredient__in=ingredients)
ShoppingListEntry.objects.filter(id__in=to_delete).delete()
self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space)
# # TODO refactor as class
# def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False):
# """
# Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
# :param list_recipe: Modify an existing ShoppingListRecipe
# :param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required
# :param mealplan: alternatively use a mealplan recipe as source of ingredients
# :param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
# :param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
# :param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list
# """
# r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
# if not r:
# raise ValueError(_("You must supply a recipe or mealplan"))
# created_by = created_by or getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', None)
# if not created_by:
# raise ValueError(_("You must supply a created_by"))
# try:
# servings = float(servings)
# except (ValueError, TypeError):
# servings = getattr(mealplan, 'servings', 1.0)
# servings_factor = servings / r.servings
# shared_users = list(created_by.get_shopping_share())
# shared_users.append(created_by)
# if list_recipe:
# created = False
# else:
# list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
# created = True
# related_step_ing = []
# if servings == 0 and not created:
# list_recipe.delete()
# return []
# elif ingredients:
# ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
# else:
# ingredients = Ingredient.objects.filter(step__recipe=r, food__ignore_shopping=False, space=space)
# if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand:
# ingredients = ingredients.exclude(food__onhand_users__id__in=[x.id for x in shared_users])
# if related := created_by.userpreference.mealplan_autoinclude_related:
# # TODO: add levels of related recipes (related recipes of related recipes) to use when auto-adding mealplans
# related_recipes = r.get_related_recipes()
# for x in related_recipes:
# # related recipe is a Step serving size is driven by recipe serving size
# # TODO once/if Steps can have a serving size this needs to be refactored
# if exclude_onhand:
# # if steps are used more than once in a recipe or subrecipe - I don' think this results in the desired behavior
# related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]).values_list('id', flat=True)
# else:
# related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True)
# x_ing = []
# if ingredients.filter(food__recipe=x).exists():
# for ing in ingredients.filter(food__recipe=x):
# if exclude_onhand:
# x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users])
# else:
# x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__ignore_shopping=True)
# for i in [x for x in x_ing]:
# ShoppingListEntry.objects.create(
# list_recipe=list_recipe,
# food=i.food,
# unit=i.unit,
# ingredient=i,
# amount=i.amount * Decimal(servings_factor),
# created_by=created_by,
# space=space,
# )
# # dont' add food to the shopping list that are actually recipes that will be added as ingredients
# ingredients = ingredients.exclude(food__recipe=x)
# add_ingredients = list(ingredients.values_list('id', flat=True)) + related_step_ing
# if not append:
# existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe)
# # delete shopping list entries not included in ingredients
# existing_list.exclude(ingredient__in=ingredients).delete()
# # add shopping list entries that did not previously exist
# add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
# add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
# # if servings have changed, update the ShoppingListRecipe and existing Entries
# if servings <= 0:
# servings = 1
# if not created and list_recipe.servings != servings:
# update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True))
# list_recipe.servings = servings
# list_recipe.save()
# for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients):
# sle.amount = sle.ingredient.amount * Decimal(servings_factor)
# sle.save()
# # add any missing Entries
# for i in [x for x in add_ingredients if x.food]:
# ShoppingListEntry.objects.create(
# list_recipe=list_recipe,
# food=i.food,
# unit=i.unit,
# ingredient=i,
# amount=i.amount * Decimal(servings_factor),
# created_by=created_by,
# space=space,
# )
# # return all shopping list items
# return list_recipe

View File

@@ -32,11 +32,12 @@ class Default(Integration):
return None
def get_file_from_recipe(self, recipe):
export = RecipeExportSerializer(recipe).data
return 'recipe.json', JSONRenderer().render(export).decode("utf-8")
def get_files_from_recipes(self, recipes, cookie):
def get_files_from_recipes(self, recipes, el, cookie):
export_zip_stream = BytesIO()
export_zip_obj = ZipFile(export_zip_stream, 'w')
@@ -50,13 +51,20 @@ class Default(Integration):
recipe_stream.write(data)
recipe_zip_obj.writestr(filename, recipe_stream.getvalue())
recipe_stream.close()
try:
recipe_zip_obj.writestr(f'image{get_filetype(r.image.file.name)}', r.image.file.read())
except ValueError:
pass
recipe_zip_obj.close()
export_zip_obj.writestr(str(r.pk) + '.zip', recipe_zip_stream.getvalue())
el.exported_recipes += 1
el.msg += self.get_recipe_processed_msg(r)
el.save()
export_zip_obj.close()
return [[ 'export.zip', export_zip_stream.getvalue() ]]
return [[ self.get_export_file_name(), export_zip_stream.getvalue() ]]

View File

@@ -1,9 +1,12 @@
import time
import datetime
import json
import traceback
import uuid
from io import BytesIO, StringIO
from zipfile import BadZipFile, ZipFile
from django.core.cache import cache
import datetime
from bs4 import Tag
from django.core.exceptions import ObjectDoesNotExist
@@ -18,6 +21,7 @@ from cookbook.forms import ImportExportBase
from cookbook.helper.image_processing import get_filetype, handle_image
from cookbook.models import Keyword, Recipe
from recipes.settings import DEBUG
from recipes.settings import EXPORT_FILE_CACHE_DURATION
class Integration:
@@ -61,35 +65,44 @@ class Integration:
space=request.space
)
def do_export(self, recipes):
"""
Perform the export based on a list of recipes
:param recipes: list of recipe objects
:return: HttpResponse with the file of the requested export format that is directly downloaded (When that format involve multiple files they are zipped together)
"""
files = self.get_files_from_recipes(recipes, self.request.COOKIES)
if len(files) == 1:
filename, file = files[0]
export_filename = filename
export_file = file
def do_export(self, recipes, el):
else:
export_filename = "export.zip"
export_stream = BytesIO()
export_obj = ZipFile(export_stream, 'w')
with scope(space=self.request.space):
el.total_recipes = len(recipes)
el.cache_duration = EXPORT_FILE_CACHE_DURATION
el.save()
for filename, file in files:
export_obj.writestr(filename, file)
files = self.get_files_from_recipes(recipes, el, self.request.COOKIES)
export_obj.close()
export_file = export_stream.getvalue()
if len(files) == 1:
filename, file = files[0]
export_filename = filename
export_file = file
else:
#zip the files if there is more then one file
export_filename = self.get_export_file_name()
export_stream = BytesIO()
export_obj = ZipFile(export_stream, 'w')
for filename, file in files:
export_obj.writestr(filename, file)
export_obj.close()
export_file = export_stream.getvalue()
cache.set('export_file_'+str(el.pk), {'filename': export_filename, 'file': export_file}, EXPORT_FILE_CACHE_DURATION)
el.running = False
el.save()
response = HttpResponse(export_file, content_type='application/force-download')
response['Content-Disposition'] = 'attachment; filename="' + export_filename + '"'
return response
def import_file_name_filter(self, zip_info_object):
"""
Since zipfile.namelist() returns all files in all subdirectories this function allows filtering of files
@@ -126,7 +139,7 @@ class Integration:
for d in data_list:
recipe = self.get_recipe_from_file(d)
recipe.keywords.add(self.keyword)
il.msg += f'{recipe.pk} - {recipe.name} \n'
il.msg += self.get_recipe_processed_msg(recipe)
self.handle_duplicates(recipe, import_duplicates)
il.imported_recipes += 1
il.save()
@@ -151,7 +164,7 @@ class Integration:
else:
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
recipe.keywords.add(self.keyword)
il.msg += f'{recipe.pk} - {recipe.name} \n'
il.msg += self.get_recipe_processed_msg(recipe)
self.handle_duplicates(recipe, import_duplicates)
il.imported_recipes += 1
il.save()
@@ -166,7 +179,7 @@ class Integration:
try:
recipe = self.get_recipe_from_file(d)
recipe.keywords.add(self.keyword)
il.msg += f'{recipe.pk} - {recipe.name} \n'
il.msg += self.get_recipe_processed_msg(recipe)
self.handle_duplicates(recipe, import_duplicates)
il.imported_recipes += 1
il.save()
@@ -183,7 +196,7 @@ class Integration:
try:
recipe = self.get_recipe_from_file(d)
recipe.keywords.add(self.keyword)
il.msg += f'{recipe.pk} - {recipe.name} \n'
il.msg += self.get_recipe_processed_msg(recipe)
self.handle_duplicates(recipe, import_duplicates)
il.imported_recipes += 1
il.save()
@@ -193,7 +206,7 @@ class Integration:
else:
recipe = self.get_recipe_from_file(f['file'])
recipe.keywords.add(self.keyword)
il.msg += f'{recipe.pk} - {recipe.name} \n'
il.msg += self.get_recipe_processed_msg(recipe)
self.handle_duplicates(recipe, import_duplicates)
except BadZipFile:
il.msg += 'ERROR ' + _(
@@ -260,7 +273,7 @@ class Integration:
"""
raise NotImplementedError('Method not implemented in integration')
def get_files_from_recipes(self, recipes, cookie):
def get_files_from_recipes(self, recipes, el, cookie):
"""
Takes a list of recipe object and converts it to a array containing each file.
Each file is represented as an array [filename, data] where data is a string of the content of the file.
@@ -279,3 +292,10 @@ class Integration:
log.msg += exception.msg
if DEBUG:
traceback.print_exc()
def get_export_file_name(self, format='zip'):
return "export_{}.{}".format(datetime.datetime.now().strftime("%Y-%m-%d"), format)
def get_recipe_processed_msg(self, recipe):
return f'{recipe.pk} - {recipe.name} \n'

View File

@@ -11,22 +11,25 @@ from cookbook.helper.image_processing import get_filetype
from cookbook.integration.integration import Integration
from cookbook.serializer import RecipeExportSerializer
import django.core.management.commands.runserver as runserver
from cookbook.models import ExportLog
from asgiref.sync import sync_to_async
import django.core.management.commands.runserver as runserver
import logging
class PDFexport(Integration):
def get_recipe_from_file(self, file):
raise NotImplementedError('Method not implemented in storage integration')
async def get_files_from_recipes_async(self, recipes, cookie):
async def get_files_from_recipes_async(self, recipes, el, cookie):
cmd = runserver.Command()
browser = await launch(
handleSIGINT=False,
handleSIGTERM=False,
handleSIGHUP=False,
ignoreHTTPSErrors=True
ignoreHTTPSErrors=True,
)
cookies = {'domain': cmd.default_addr, 'name': 'sessionid', 'value': cookie['sessionid'], }
@@ -39,17 +42,28 @@ class PDFexport(Integration):
}
}
page = await browser.newPage()
await page.emulateMedia('print')
await page.setCookie(cookies)
files = []
for recipe in recipes:
await page.goto('http://' + cmd.default_addr + ':' + cmd.default_port + '/view/recipe/' + str(recipe.id), {'waitUntil': 'networkidle0', })
page = await browser.newPage()
await page.emulateMedia('print')
await page.setCookie(cookies)
await page.goto('http://'+cmd.default_addr+':'+cmd.default_port+'/view/recipe/'+str(recipe.id), {'waitUntil': 'domcontentloaded'})
await page.waitForSelector('#printReady');
files.append([recipe.name + '.pdf', await page.pdf(options)])
await page.close();
el.exported_recipes += 1
el.msg += self.get_recipe_processed_msg(recipe)
await sync_to_async(el.save, thread_sensitive=True)()
await browser.close()
return files
def get_files_from_recipes(self, recipes, cookie):
return asyncio.run(self.get_files_from_recipes_async(recipes, cookie))
def get_files_from_recipes(self, recipes, el, cookie):
return asyncio.run(self.get_files_from_recipes_async(recipes, el, cookie))

View File

@@ -88,12 +88,16 @@ class RecipeSage(Integration):
return data
def get_files_from_recipes(self, recipes, cookie):
def get_files_from_recipes(self, recipes, el, cookie):
json_list = []
for r in recipes:
json_list.append(self.get_file_from_recipe(r))
return [['export.json', json.dumps(json_list)]]
el.exported_recipes += 1
el.msg += self.get_recipe_processed_msg(r)
el.save()
return [[self.get_export_file_name('json'), json.dumps(json_list)]]
def split_recipe_file(self, file):
return json.loads(file.read().decode("utf-8"))

View File

@@ -87,10 +87,14 @@ class Saffron(Integration):
return recipe.name+'.txt', data
def get_files_from_recipes(self, recipes, cookie):
def get_files_from_recipes(self, recipes, el, cookie):
files = []
for r in recipes:
filename, data = self.get_file_from_recipe(r)
files.append([ filename, data ])
el.exported_recipes += 1
el.msg += self.get_recipe_processed_msg(r)
el.save()
return files

View File

@@ -15,8 +15,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-01-18 14:52+0100\n"
"PO-Revision-Date: 2022-01-20 22:47+0000\n"
"Last-Translator: Sebastian Weber <tandoor@web3r.de>\n"
"PO-Revision-Date: 2022-02-02 15:31+0000\n"
"Last-Translator: Sven <tr@sutikal.de>\n"
"Language-Team: German <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/de/>\n"
"Language: de\n"
@@ -24,7 +24,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.8\n"
"X-Generator: Weblate 4.10.1\n"
#: .\cookbook\filters.py:23 .\cookbook\templates\forms\ingredients.html:34
#: .\cookbook\templates\space.html:50 .\cookbook\templates\stats.html:28
@@ -104,22 +104,16 @@ msgid ""
"Enables support for fractions in ingredient amounts (e.g. convert decimals "
"to fractions automatically)"
msgstr ""
"Unterstützung für Brüche in Zutaten aktivieren. Dadurch werden Dezimalzahlen "
"mit Brüchen ersetzt, z.B. 0.5 mit ½."
"Unterstützung für Brüche in Zutaten aktivieren (dadurch werden Dezimalzahlen "
"automatisch mit Brüchen ersetzt)"
#: .\cookbook\forms.py:78
msgid "Display nutritional energy amounts in joules instead of calories"
msgstr "Nährwerte in Joule statt Kalorien anzeigen"
#: .\cookbook\forms.py:79
#, fuzzy
#| msgid ""
#| "Users with whom newly created meal plan/shopping list entries should be "
#| "shared by default."
msgid "Users with whom newly created meal plans should be shared by default."
msgstr ""
"Nutzer, mit denen neue Pläne und Einkaufslisten standardmäßig geteilt werden "
"sollen."
msgstr "Nutzer, mit denen neue Essenspläne standardmäßig geteilt werden sollen."
#: .\cookbook\forms.py:80
msgid "Users with whom to share shopping lists."
@@ -157,11 +151,11 @@ msgstr "Navigationsleiste wird oben angeheftet."
#: .\cookbook\forms.py:90 .\cookbook\forms.py:496
msgid "Automatically add meal plan ingredients to shopping list."
msgstr ""
msgstr "Fügt die Zutaten des Speiseplans automatisch zur Einkaufsliste hinzu."
#: .\cookbook\forms.py:91
msgid "Exclude ingredients that are on hand."
msgstr ""
msgstr "Zutaten, die vorrätig sind, ausschließen."
#: .\cookbook\forms.py:108
msgid ""
@@ -319,8 +313,7 @@ msgid ""
"degrade search quality depending on language"
msgstr ""
"Felder bei welchen Akzente ignoriert werden. Das aktivieren dieser Option "
"kann die Suchqualität abhängig von der Sprache verbessern oder "
"verschlechtern."
"kann die Suchqualität je nach Sprache verbessern oder verschlechtern"
#: .\cookbook\forms.py:450
msgid ""
@@ -388,26 +381,36 @@ msgid ""
"Users will see all items you add to your shopping list. They must add you "
"to see items on their list."
msgstr ""
"Die Benutzer sehen alle Artikel, die Sie auf Ihre Einkaufsliste setzen. Die "
"Benutzer müssen Sie hinzufügen, damit Sie Artikel auf der Liste der Benutzer "
"sehen können."
#: .\cookbook\forms.py:497
msgid ""
"When adding a meal plan to the shopping list (manually or automatically), "
"include all related recipes."
msgstr ""
"Wenn Sie einen Essensplan zur Einkaufsliste hinzufügen (manuell oder "
"automatisch), fügen Sie alle zugehörigen Rezepte hinzu."
#: .\cookbook\forms.py:498
msgid ""
"When adding a meal plan to the shopping list (manually or automatically), "
"exclude ingredients that are on hand."
msgstr ""
"Wenn Sie einen Essensplan zur Einkaufsliste hinzufügen (manuell oder "
"automatisch), schließen Sie Zutaten aus, die Sie gerade zur Hand haben."
#: .\cookbook\forms.py:499
msgid "Default number of hours to delay a shopping list entry."
msgstr ""
"Voreingestellte Anzahl von Stunden für die Verzögerung eines "
"Einkaufslisteneintrags."
#: .\cookbook\forms.py:500
msgid "Filter shopping list to only include supermarket categories."
msgstr ""
"Nur für den Supermarkt konfigurierte Kategorien in Einkaufsliste anzeigen."
#: .\cookbook\forms.py:501
msgid "Days of recent shopping list entries to display."
@@ -416,54 +419,54 @@ msgstr ""
#: .\cookbook\forms.py:502
msgid "Mark food 'On Hand' when checked off shopping list."
msgstr ""
"Lebensmittel als vorrätig markieren, wenn es in der Einkaufliste abgehakt "
"wurde."
#: .\cookbook\forms.py:503
msgid "Delimiter to use for CSV exports."
msgstr ""
msgstr "Separator für CSV-Export."
#: .\cookbook\forms.py:504
msgid "Prefix to add when copying list to the clipboard."
msgstr ""
msgstr "Zusatz wird der in die Zwischenablage kopierten Liste vorangestellt."
#: .\cookbook\forms.py:508
#, fuzzy
#| msgid "New Shopping List"
msgid "Share Shopping List"
msgstr "Neue Einkaufsliste"
msgstr "Einkaufsliste teilen"
#: .\cookbook\forms.py:509
msgid "Autosync"
msgstr ""
msgstr "Automatischer Abgleich"
#: .\cookbook\forms.py:510
msgid "Auto Add Meal Plan"
msgstr ""
msgstr "automatisch dem Menüplan hinzufügen"
#: .\cookbook\forms.py:511
msgid "Exclude On Hand"
msgstr ""
msgstr "Ausgenommen Vorrätiges"
#: .\cookbook\forms.py:512
msgid "Include Related"
msgstr ""
msgstr "dazugehörend"
#: .\cookbook\forms.py:513
msgid "Default Delay Hours"
msgstr ""
msgstr "Standardmäßige Verzögerung in Stunden"
#: .\cookbook\forms.py:514
#, fuzzy
#| msgid "Select Supermarket"
msgid "Filter to Supermarket"
msgstr "Supermarkt auswählen"
msgstr "Supermarkt filtern"
#: .\cookbook\forms.py:515
msgid "Recent Days"
msgstr ""
msgstr "Vergangene Tage"
#: .\cookbook\forms.py:516
msgid "CSV Delimiter"
msgstr ""
msgstr "CSV Trennzeichen"
#: .\cookbook\forms.py:517 .\cookbook\templates\shopping_list.html:322
msgid "List Prefix"
@@ -471,7 +474,7 @@ msgstr "Listenpräfix"
#: .\cookbook\forms.py:518
msgid "Auto On Hand"
msgstr ""
msgstr "Automatisch als vorrätig markieren"
#: .\cookbook\forms.py:528
msgid "Reset Food Inheritance"
@@ -482,16 +485,12 @@ msgid "Reset all food to inherit the fields configured."
msgstr ""
#: .\cookbook\forms.py:541
#, fuzzy
#| msgid "Food that should be replaced."
msgid "Fields on food that should be inherited by default."
msgstr "Zutat, die ersetzt werden soll."
msgstr "Zutaten, die standardmäßig übernommen werden sollen."
#: .\cookbook\forms.py:542
#, fuzzy
#| msgid "Show recently viewed recipes on search page."
msgid "Show recipe counts on search filters"
msgstr "Zuletzt angeschaute Rezepte bei der Suche anzeigen."
msgstr "Rezeptanzahl im Suchfiltern anzeigen"
#: .\cookbook\helper\AllAuthCustomAdapter.py:36
msgid ""
@@ -527,17 +526,15 @@ msgstr ""
#: .\cookbook\helper\recipe_search.py:473
msgid "One of queryset or hash_key must be provided"
msgstr ""
msgstr "Es muss die Abfrage oder der Hash_Key angeben werden"
#: .\cookbook\helper\shopping_helper.py:54
#, fuzzy
#| msgid "You must provide at least a recipe or a title."
msgid "You must supply a recipe or mealplan"
msgstr "Mindestens ein Rezept oder ein Titel müssen angegeben werden."
msgstr "Mindestens ein Rezept oder ein Essensplan müssen angegeben werden"
#: .\cookbook\helper\shopping_helper.py:58
msgid "You must supply a created_by"
msgstr ""
msgstr "Die Angabe der Verfassers ist notwendig"
#: .\cookbook\helper\template_helper.py:61
#: .\cookbook\helper\template_helper.py:63
@@ -730,7 +727,7 @@ msgstr "Stichwort Alias"
#: .\cookbook\serializer.py:175
msgid "A user is required"
msgstr ""
msgstr "Ein Benutzername ist notwendig"
#: .\cookbook\serializer.py:195
msgid "File uploads are not enabled for this Space."
@@ -742,7 +739,7 @@ msgstr "Du hast Dein Datei-Uploadlimit erreicht."
#: .\cookbook\serializer.py:962
msgid "Existing shopping list to update"
msgstr ""
msgstr "Bestehende Einkaufliste, die aktualisiert werden soll"
#: .\cookbook\serializer.py:964
msgid ""
@@ -758,10 +755,11 @@ msgstr ""
#: .\cookbook\serializer.py:973
msgid "Amount of food to add to the shopping list"
msgstr ""
"Menge des Lebensmittels, welches der Einkaufsliste hinzugefügt werden soll"
#: .\cookbook\serializer.py:974
msgid "ID of unit to use for the shopping list"
msgstr ""
msgstr "ID der Einheit, die für die Einkaufsliste verwendet werden soll"
#: .\cookbook\serializer.py:975
msgid "When set to true will delete all food from active shopping lists."
@@ -1332,11 +1330,11 @@ msgstr "Abbrechen"
#: .\cookbook\templates\generic\edit_template.html:32
msgid "View"
msgstr "Anschauen"
msgstr "Ansicht"
#: .\cookbook\templates\generic\edit_template.html:36
msgid "Delete original file"
msgstr "Original löschen"
msgstr "Originaldatei löschen"
#: .\cookbook\templates\generic\list_template.html:6
#: .\cookbook\templates\generic\list_template.html:22
@@ -2045,10 +2043,8 @@ msgid "Search-Settings"
msgstr "Sucheinstellungen"
#: .\cookbook\templates\settings.html:56
#, fuzzy
#| msgid "Search-Settings"
msgid "Shopping-Settings"
msgstr "Sucheinstellungen"
msgstr "Einstellungen Einkaufsliste"
#: .\cookbook\templates\settings.html:65
msgid "Name Settings"
@@ -2161,10 +2157,8 @@ msgid "Perfect for large Databases"
msgstr "Ideal für große Datenbanken"
#: .\cookbook\templates\settings.html:207
#, fuzzy
#| msgid "Shopping List"
msgid "Shopping Settings"
msgstr "Einkaufsliste"
msgstr "Einstellungen Einkaufsliste"
#: .\cookbook\templates\setup.html:6 .\cookbook\templates\system.html:5
msgid "Cookbook Setup"
@@ -2774,12 +2768,12 @@ msgstr ""
#: .\cookbook\views\api.py:470
#, python-brace-format
msgid "{obj.name} was removed from the shopping list."
msgstr ""
msgstr "{obj.name} wurde von der Einkaufsliste entfernt."
#: .\cookbook\views\api.py:475 .\cookbook\views\api.py:726
#, python-brace-format
msgid "{obj.name} was added to the shopping list."
msgstr ""
msgstr "{obj.name} wurde der Einkaufsliste hinzugefügt."
#: .\cookbook\views\api.py:587
msgid "ID of recipe a step is part of. For multiple repeat parameter."
@@ -2805,11 +2799,11 @@ msgstr ""
#: .\cookbook\views\api.py:634
msgid "ID of unit a recipe should have."
msgstr ""
msgstr "ID der Einheit, die ein Rezept haben sollte."
#: .\cookbook\views\api.py:635
msgid "Rating a recipe should have. [0 - 5]"
msgstr ""
msgstr "Bewertung, die ein Rezept haben sollte. [ 0 - 5]"
#: .\cookbook\views\api.py:636
msgid "ID of book a recipe should be in. For multiple repeat parameter."
@@ -2897,7 +2891,7 @@ msgstr ""
#: .\cookbook\views\api.py:1082
msgid "Connection Refused."
msgstr ""
msgstr "Verbindung fehlgeschlagen."
#: .\cookbook\views\api.py:1091
msgid "No useable data could be found."
@@ -2984,6 +2978,8 @@ msgid ""
"The PDF Exporter is not enabled on this instance as it is still in an "
"experimental state."
msgstr ""
"Der PDF-Exporter ist in dieser Instanz nicht aktiviert, da er sich noch in "
"einem experimentellen Zustand befindet."
#: .\cookbook\views\import_export.py:132
msgid "Exporting is not implemented for this provider"

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
"PO-Revision-Date: 2021-11-16 06:06+0000\n"
"Last-Translator: Luka <storek00@gmail.com>\n"
"PO-Revision-Date: 2022-02-02 15:31+0000\n"
"Last-Translator: Mario Dvorsek <mario.dvorsek@gmail.com>\n"
"Language-Team: Slovenian <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/sl/>\n"
"Language: sl\n"
@@ -18,7 +18,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n"
"%100==4 ? 2 : 3;\n"
"X-Generator: Weblate 4.8\n"
"X-Generator: Weblate 4.10.1\n"
#: .\cookbook\filters.py:23 .\cookbook\templates\base.html:125
#: .\cookbook\templates\forms\ingredients.html:34
@@ -33,47 +33,47 @@ msgstr "Privzeta enota"
#: .\cookbook\forms.py:55
msgid "Use fractions"
msgstr ""
msgstr "Uporabi ulomke/frakcije"
#: .\cookbook\forms.py:56
msgid "Use KJ"
msgstr ""
msgstr "Uporabi KJ"
#: .\cookbook\forms.py:57
msgid "Theme"
msgstr ""
msgstr "Tema"
#: .\cookbook\forms.py:58
msgid "Navbar color"
msgstr ""
msgstr "Barva navigacijske vrstice"
#: .\cookbook\forms.py:59
msgid "Sticky navbar"
msgstr ""
msgstr "Lepljiva navigacijska vrstica"
#: .\cookbook\forms.py:60
msgid "Default page"
msgstr ""
msgstr "Privzeta stran"
#: .\cookbook\forms.py:61
msgid "Show recent recipes"
msgstr ""
msgstr "Prikaži nedavne recepte"
#: .\cookbook\forms.py:62
msgid "Search style"
msgstr ""
msgstr "Vrsta iskalnika"
#: .\cookbook\forms.py:63
msgid "Plan sharing"
msgstr ""
msgstr "Deli planer"
#: .\cookbook\forms.py:64
msgid "Ingredient decimal places"
msgstr ""
msgstr "Decimalno mesto pri sestavini"
#: .\cookbook\forms.py:65
msgid "Shopping list auto sync period"
msgstr ""
msgstr "Čas avtomatske sinhronizacije pri nakupovalnem listku"
#: .\cookbook\forms.py:66 .\cookbook\templates\recipe_view.html:21
#: .\cookbook\templates\space.html:62 .\cookbook\templates\stats.html:47
@@ -85,38 +85,44 @@ msgid ""
"Color of the top navigation bar. Not all colors work with all themes, just "
"try them out!"
msgstr ""
"Barva zgornje vrstice za krmarjenje. Ne delujejo vse barve z vsemi temami!"
#: .\cookbook\forms.py:72
msgid "Default Unit to be used when inserting a new ingredient into a recipe."
msgstr ""
"Privzeta enota, ki se uporablja pri vstavljanju nove sestavine v recept."
#: .\cookbook\forms.py:74
msgid ""
"Enables support for fractions in ingredient amounts (e.g. convert decimals "
"to fractions automatically)"
msgstr ""
"Omogoča podporo ulomkom/frakcijam v količinah sestavin (npr. samodejno "
"pretvori decimalke v ulomke)"
#: .\cookbook\forms.py:76
msgid "Display nutritional energy amounts in joules instead of calories"
msgstr ""
msgstr "Prikazuj hranilne energijske količine v joules namesto v kalorijah"
#: .\cookbook\forms.py:78
msgid ""
"Users with whom newly created meal plan/shopping list entries should be "
"shared by default."
msgstr ""
"Uporabniki, s katerimi je privzeto deljen novo ustvarjen načrt ali "
"nakupovalni listek."
#: .\cookbook\forms.py:80
msgid "Show recently viewed recipes on search page."
msgstr ""
msgstr "Prikaži nedavno videne recepte na iskalniku."
#: .\cookbook\forms.py:81
msgid "Number of decimals to round ingredients."
msgstr ""
msgstr "Število decimalk, ki so zaokrožene pri sestavinah."
#: .\cookbook\forms.py:82
msgid "If you want to be able to create and see comments underneath recipes."
msgstr ""
msgstr "V primeru, da želite ustvariti in videti komentarje pod recepti."
#: .\cookbook\forms.py:84
msgid ""
@@ -125,21 +131,28 @@ msgid ""
"Useful when shopping with multiple people but might use a little bit of "
"mobile data. If lower than instance limit it is reset when saving."
msgstr ""
"Nastavitev na 0 bo onemogočila avtomatsko sinhronizacijo. V pogledu "
"nakupovalnega listka, se seznam osvežuje vsake toliko sekund, če nekdo drug "
"naredi spremembo. To je najbolj uporabno, če nakupovalni listek delimo z "
"večimi osebami. Paziti je potrebno, saj porabi nekaj podatkov v mobilnem "
"omrežju."
#: .\cookbook\forms.py:87
msgid "Makes the navbar stick to the top of the page."
msgstr ""
msgstr "Nastavi navigacijsko vrstico na vrh strani."
#: .\cookbook\forms.py:103
msgid ""
"Both fields are optional. If none are given the username will be displayed "
"instead"
msgstr ""
"Obe polji sta opcijski. V primeru, da ju pustimo prazni bo prikazano "
"uporabniško ime"
#: .\cookbook\forms.py:124 .\cookbook\forms.py:289
#: .\cookbook\templates\url_import.html:158
msgid "Name"
msgstr ""
msgstr "Ime"
#: .\cookbook\forms.py:125 .\cookbook\forms.py:290
#: .\cookbook\templates\space.html:39 .\cookbook\templates\stats.html:24
@@ -150,47 +163,51 @@ msgstr "Ključne besede"
#: .\cookbook\forms.py:126
msgid "Preparation time in minutes"
msgstr ""
msgstr "Priprava v minutah"
#: .\cookbook\forms.py:127
msgid "Waiting time (cooking/baking) in minutes"
msgstr ""
msgstr "Čas čakanja v minutah"
#: .\cookbook\forms.py:128 .\cookbook\forms.py:259 .\cookbook\forms.py:291
msgid "Path"
msgstr ""
msgstr "Pot"
#: .\cookbook\forms.py:129
msgid "Storage UID"
msgstr ""
msgstr "UID shrambe"
#: .\cookbook\forms.py:157
msgid "Default"
msgstr ""
msgstr "Privzeto"
#: .\cookbook\forms.py:168 .\cookbook\templates\url_import.html:94
msgid ""
"To prevent duplicates recipes with the same name as existing ones are "
"ignored. Check this box to import everything."
msgstr ""
"V primeru, da želite preprečiti dvojnike receptov z enakim imenom kot so "
"obstoječi. Če želite uvoziti vse, potrdite to polje."
#: .\cookbook\forms.py:190
msgid "Add your comment: "
msgstr ""
msgstr "Dodaj komentar: "
#: .\cookbook\forms.py:205
msgid "Leave empty for dropbox and enter app password for nextcloud."
msgstr ""
msgstr "Pusti prazno za dropbox in vnesi geslo za nextcloud."
#: .\cookbook\forms.py:212
msgid "Leave empty for nextcloud and enter api token for dropbox."
msgstr ""
msgstr "Pusti prazno za nextcloud in vnesi API žeton za dropbox."
#: .\cookbook\forms.py:221
msgid ""
"Leave empty for dropbox and enter only base url for nextcloud (<code>/remote."
"php/webdav/</code> is added automatically)"
msgstr ""
"Pusti prazno za dropbox in vnesi URL za nextcloud (<code>/remote.php/webdav/"
"</code> je dodano avtomatsko)"
#: .\cookbook\forms.py:258 .\cookbook\views\edit.py:166
msgid "Storage"
@@ -198,33 +215,35 @@ msgstr "Shramba"
#: .\cookbook\forms.py:260
msgid "Active"
msgstr ""
msgstr "Aktivno"
#: .\cookbook\forms.py:265
msgid "Search String"
msgstr ""
msgstr "Iskalni niz"
#: .\cookbook\forms.py:292
msgid "File ID"
msgstr ""
msgstr "ID datoteke"
#: .\cookbook\forms.py:313
msgid "You must provide at least a recipe or a title."
msgstr ""
msgstr "Vpisati moraš vsaj recept ali naslov."
#: .\cookbook\forms.py:326
msgid "You can list default users to share recipes with in the settings."
msgstr ""
msgstr "Seznam uporabnikov za deljenje receptov lahko vidiš v nastavitvah."
#: .\cookbook\forms.py:327
msgid ""
"You can use markdown to format this field. See the <a href=\"/docs/markdown/"
"\">docs here</a>"
msgstr ""
"Lahko uporabiš \"markdown\", da urediš to polje. Preveri <a href=\"/docs/"
"markdown/\">tukaj</a>"
#: .\cookbook\forms.py:353
msgid "Maximum number of users for this space reached."
msgstr ""
msgstr "Maksimalno število uporabnikov za ta prostor je doseženo."
#: .\cookbook\forms.py:359
msgid "Email address already taken!"
@@ -235,56 +254,71 @@ msgid ""
"An email address is not required but if present the invite link will be sent "
"to the user."
msgstr ""
"E-poštni naslov ni potreben, vendar če je vnešeno, bo povabilo poslano do "
"uporabnika."
#: .\cookbook\forms.py:382
msgid "Name already taken."
msgstr ""
msgstr "Ime je že zasedeno."
#: .\cookbook\forms.py:393
msgid "Accept Terms and Privacy"
msgstr ""
msgstr "Sprejmi pogoje uporabe"
#: .\cookbook\forms.py:425
msgid ""
"Determines how fuzzy a search is if it uses trigram similarity matching (e."
"g. low values mean more typos are ignored)."
msgstr ""
"Določa, kakšno je iskanje, če uporablja trigram podobnost ujemanje (npr. "
"nizke vrednosti pomenijo več, tipkanje se prezre)."
#: .\cookbook\forms.py:435
msgid ""
"Select type method of search. Click <a href=\"/docs/search/\">here</a> for "
"full desciption of choices."
msgstr ""
"Izberi metodo iskanja. Klikni <a href=\"/docs/search/\">tukaj</a> za "
"prikaz vseh izbir."
#: .\cookbook\forms.py:436
msgid ""
"Use fuzzy matching on units, keywords and ingredients when editing and "
"importing recipes."
msgstr ""
"Pri urejanju in uvozu receptov uporabite mehka ujemanja na enotah, ključnih "
"besedah in sestavinah."
#: .\cookbook\forms.py:438
msgid ""
"Fields to search ignoring accents. Selecting this option can improve or "
"degrade search quality depending on language"
msgstr ""
"Polja za iskanje prezrtih naglasov. Če izberete to možnost, lahko izboljšate "
"ali poslabšate kakovost iskanja, odvisno od jezika"
#: .\cookbook\forms.py:440
msgid ""
"Fields to search for partial matches. (e.g. searching for 'Pie' will return "
"'pie' and 'piece' and 'soapie')"
msgstr ""
"Polja za iskanje delnih ujemajev. (npr. iskanje \"Pie\" vrne \"pie\" in "
"\"piece\" in \"soapie\")"
#: .\cookbook\forms.py:442
msgid ""
"Fields to search for beginning of word matches. (e.g. searching for 'sa' "
"will return 'salad' and 'sandwich')"
msgstr ""
"Polja za iskanje začetka ujemanja besed. (npr. iskanje \"sa\" vrne \"salad\" "
"in \"sandwich\")"
#: .\cookbook\forms.py:444
msgid ""
"Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) "
"Note: this option will conflict with 'web' and 'raw' methods of search."
msgstr ""
"Polja za \"mehko\" iskanje. (npr. iskanje \"recpie\" bo našlo \"recipe\".)"
#: .\cookbook\forms.py:446
msgid ""

View File

@@ -10,9 +10,10 @@ class Migration(migrations.Migration):
]
operations = [
migrations.AddField(
model_name='space',
name='show_facet_count',
field=models.BooleanField(default=False),
),
# migrations.AddField(
# model_name='space',
# name='show_facet_count',
# field=models.BooleanField(default=False),
# ),
# removed due to quick fix in 0159 migration to maintain correct order
]

View File

@@ -0,0 +1,20 @@
from django.db import migrations
from cookbook.models import SearchFields
def create_searchfields(apps, schema_editor):
SearchFields.objects.create(name='Units', field='steps__ingredients__unit__name')
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0167_userpreference_left_handed'),
]
operations = [
migrations.RunPython(
create_searchfields
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 3.2.11 on 2022-02-03 15:03
import cookbook.models
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cookbook', '0168_add_unit_searchfields'),
]
operations = [
migrations.CreateModel(
name='ExportLog',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('type', models.CharField(max_length=32)),
('running', models.BooleanField(default=True)),
('msg', models.TextField(default='')),
('total_recipes', models.IntegerField(default=0)),
('exported_recipes', models.IntegerField(default=0)),
('cache_duration', models.IntegerField(default=0)),
('possibly_not_expired', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('space', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space')),
],
bases=(models.Model, cookbook.models.PermissionModelMixin),
),
]

View File

@@ -609,7 +609,7 @@ class NutritionInformation(models.Model, PermissionModelMixin):
)
proteins = models.DecimalField(default=0, decimal_places=16, max_digits=32)
calories = models.DecimalField(default=0, decimal_places=16, max_digits=32)
source = models.CharField( max_length=512, default="", null=True, blank=True)
source = models.CharField(max_length=512, default="", null=True, blank=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
@@ -852,11 +852,12 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
def __str__(self):
return f'Shopping list entry {self.id}'
# TODO deprecate
def get_shared(self):
return self.shoppinglist_set.first().shared.all()
try:
return self.shoppinglist_set.first().shared.all()
except AttributeError:
return self.created_by.userpreference.shopping_share.all()
# TODO deprecate
def get_owner(self):
try:
return self.created_by or self.shoppinglist_set.first().created_by
@@ -881,6 +882,12 @@ class ShoppingList(ExportModelOperationsMixin('shopping_list'), models.Model, Pe
def __str__(self):
return f'Shopping list {self.id}'
def get_shared(self):
try:
return self.shared.all() or self.created_by.userpreference.shopping_share.all()
except AttributeError:
return []
class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, PermissionModelMixin):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
@@ -995,6 +1002,25 @@ class ImportLog(models.Model, PermissionModelMixin):
def __str__(self):
return f"{self.created_at}:{self.type}"
class ExportLog(models.Model, PermissionModelMixin):
type = models.CharField(max_length=32)
running = models.BooleanField(default=True)
msg = models.TextField(default="")
total_recipes = models.IntegerField(default=0)
exported_recipes = models.IntegerField(default=0)
cache_duration = models.IntegerField(default=0)
possibly_not_expired = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
space = models.ForeignKey(Space, on_delete=models.CASCADE)
def __str__(self):
return f"{self.created_at}:{self.type}"
class BookmarkletImport(ExportModelOperationsMixin('bookmarklet_import'), models.Model, PermissionModelMixin):
html = models.TextField()

View File

@@ -13,9 +13,9 @@ from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.fields import empty
from cookbook.helper.HelperFunctions import str2bool
from cookbook.helper.shopping_helper import list_from_recipe
from cookbook.helper.shopping_helper import RecipeShoppingEditor
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food,
FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType,
FoodInheritField, ImportLog, ExportLog, Ingredient, Keyword, MealPlan, MealType,
NutritionInformation, Recipe, RecipeBook, RecipeBookEntry,
RecipeImport, ShareLink, ShoppingList, ShoppingListEntry,
ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory,
@@ -33,7 +33,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
images = None
image = serializers.SerializerMethodField('get_image')
numrecipe = serializers.ReadOnlyField(source='count_recipes_test')
numrecipe = serializers.ReadOnlyField(source='recipe_count')
def get_fields(self, *args, **kwargs):
fields = super().get_fields(*args, **kwargs)
@@ -58,9 +58,6 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
if obj.recipe_image:
return MEDIA_URL + obj.recipe_image
def count_recipes(self, obj):
return Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).count()
class CustomDecimalField(serializers.Field):
"""
@@ -161,7 +158,7 @@ class FoodInheritFieldSerializer(WritableNestedModelSerializer):
class Meta:
model = FoodInheritField
fields = ('id', 'name', 'field', )
fields = ('id', 'name', 'field',)
read_only_fields = ['id']
@@ -169,6 +166,11 @@ class UserPreferenceSerializer(WritableNestedModelSerializer):
food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', many=True, allow_null=True, required=False, read_only=True)
plan_share = UserNameSerializer(many=True, allow_null=True, required=False, read_only=True)
shopping_share = UserNameSerializer(many=True, allow_null=True, required=False)
food_children_exist = serializers.SerializerMethodField('get_food_children_exist')
def get_food_children_exist(self, obj):
space = getattr(self.context.get('request', None), 'space', None)
return Food.objects.filter(depth__gt=0, space=space).exists()
def create(self, validated_data):
if not validated_data.get('user', None):
@@ -180,10 +182,10 @@ class UserPreferenceSerializer(WritableNestedModelSerializer):
class Meta:
model = UserPreference
fields = (
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_kj', 'search_style', 'show_recent', 'plan_share',
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_fractions', 'use_kj', 'search_style', 'show_recent', 'plan_share',
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay',
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix',
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed'
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'food_children_exist'
)
@@ -429,7 +431,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
model = Food
fields = (
'id', 'name', 'description', 'shopping', 'recipe', 'food_onhand', 'supermarket_category',
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name'
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping'
)
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
@@ -658,7 +660,8 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
validated_data['created_by'] = self.context['request'].user
mealplan = super().create(validated_data)
if self.context['request'].data.get('addshopping', False):
list_from_recipe(mealplan=mealplan, servings=validated_data['servings'], created_by=validated_data['created_by'], space=validated_data['space'])
SLR = RecipeShoppingEditor(user=validated_data['created_by'], space=validated_data['space'])
SLR.create(mealplan=mealplan, servings=validated_data['servings'])
return mealplan
class Meta:
@@ -690,13 +693,10 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
) + f' ({value:.2g})'
def update(self, instance, validated_data):
if 'servings' in validated_data:
list_from_recipe(
list_recipe=instance,
servings=validated_data['servings'],
created_by=self.context['request'].user,
space=self.context['request'].space
)
# TODO remove once old shopping list
if 'servings' in validated_data and self.context.get('view', None).__class__.__name__ != 'ShoppingListViewSet':
SLR = RecipeShoppingEditor(user=self.context['request'].user, space=self.context['request'].space)
SLR.edit_servings(servings=validated_data['servings'], id=instance.id)
return super().update(instance, validated_data)
class Meta:
@@ -726,9 +726,9 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
def run_validation(self, data):
if self.root.instance.__class__.__name__ == 'ShoppingListEntry':
if (
data.get('checked', False)
and self.root.instance
and not self.root.instance.checked
data.get('checked', False)
and self.root.instance
and not self.root.instance.checked
):
# if checked flips from false to true set completed datetime
data['completed_at'] = timezone.now()
@@ -764,7 +764,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', 'recipe_mealplan',
'created_by', 'created_at', 'completed_at', 'delay_until'
)
read_only_fields = ('id', 'created_by', 'created_at',)
read_only_fields = ('id', 'created_by', 'created_at',)
# TODO deprecate
@@ -850,6 +850,20 @@ class ImportLogSerializer(serializers.ModelSerializer):
read_only_fields = ('created_by',)
class ExportLogSerializer(serializers.ModelSerializer):
def create(self, validated_data):
validated_data['created_by'] = self.context['request'].user
validated_data['space'] = self.context['request'].space
return super().create(validated_data)
class Meta:
model = ExportLog
fields = ('id', 'type', 'msg', 'running', 'total_recipes', 'exported_recipes', 'cache_duration', 'possibly_not_expired', 'created_by', 'created_at')
read_only_fields = ('created_by',)
class AutomationSerializer(serializers.ModelSerializer):
def create(self, validated_data):

View File

@@ -6,8 +6,9 @@ from django.contrib.postgres.search import SearchVector
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import translation
from django_scopes import scope
from cookbook.helper.shopping_helper import list_from_recipe
from cookbook.helper.shopping_helper import RecipeShoppingEditor
from cookbook.managers import DICTIONARY
from cookbook.models import (Food, FoodInheritField, Ingredient, MealPlan, Recipe,
ShoppingListEntry, Step)
@@ -104,28 +105,31 @@ def update_food_inheritance(sender, instance=None, created=False, **kwargs):
@receiver(post_save, sender=MealPlan)
def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs):
if not instance:
return
user = instance.get_owner()
if not user.userpreference.mealplan_autoadd_shopping:
with scope(space=instance.space):
slr_exists = instance.shoppinglistrecipe_set.exists()
if not created and slr_exists:
for x in instance.shoppinglistrecipe_set.all():
# assuming that permissions checks for the MealPlan have happened upstream
if instance.servings != x.servings:
SLR = RecipeShoppingEditor(id=x.id, user=user, space=instance.space)
SLR.edit_servings(servings=instance.servings)
# list_recipe = list_from_recipe(list_recipe=x, servings=instance.servings, space=instance.space)
elif not user.userpreference.mealplan_autoadd_shopping or not instance.recipe:
return
if not created and instance.shoppinglistrecipe_set.exists():
for x in instance.shoppinglistrecipe_set.all():
if instance.servings != x.servings:
list_recipe = list_from_recipe(list_recipe=x, servings=instance.servings, space=instance.space)
elif created:
if created:
# if creating a mealplan - perform shopping list activities
kwargs = {
'mealplan': instance,
'space': instance.space,
'created_by': user,
'servings': instance.servings
}
list_recipe = list_from_recipe(**kwargs)
# kwargs = {
# 'mealplan': instance,
# 'space': instance.space,
# 'created_by': user,
# 'servings': instance.servings
# }
SLR = RecipeShoppingEditor(user=user, space=instance.space)
SLR.create(mealplan=instance, servings=instance.servings)
# user = self.context['request'].user
# if user.userpreference.shopping_add_onhand:
# if checked := validated_data.get('checked', None):
# instance.food.onhand_users.add(*user.userpreference.shopping_share.all(), user)
# elif checked == False:
# instance.food.onhand_users.remove(*user.userpreference.shopping_share.all(), user)
# list_recipe = list_from_recipe(**kwargs)

View File

@@ -1140,3 +1140,10 @@
min-width: 28rem;
}
}
@media print{
#switcher{
display: none;
}
}

View File

@@ -1,25 +1,33 @@
{% extends "base.html" %}
{% load crispy_forms_filters %}
{% load i18n %}
{% load render_bundle from webpack_loader %}
{% load static %}
{% load i18n %}
{% load l10n %}
{% block title %}{% trans 'Export Recipes' %}{% endblock %}
{% block extra_head %}
{{ form.media }}
{% block content %}
<div id="app">
<export-view></export-view>
</div>
{% endblock %}
{% block script %}
{% if debug %}
<script src="{% url 'js_reverse' %}"></script>
{% else %}
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
{% endif %}
<script type="application/javascript">
window.EXPORT_ID = {{pk}};
window.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}';
</script>
{% render_bundle 'export_view' %}
{% endblock %}
{% block content %}
<h2>{% trans 'Export' %}</h2>
<div class="row">
<div class="col col-md-12">
<form action="." method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success" type="submit"><i class="fas fa-file-export"></i> {% trans 'Export' %}
</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% load render_bundle from webpack_loader %}
{% load static %}
{% load i18n %}
{% load l10n %}
{% block title %}{% trans 'Export' %}{% endblock %}
{% block content %}
<div id="app">
<export-response-view></export-response-view>
</div>
{% endblock %}
{% block script %}
{% if debug %}
<script src="{% url 'js_reverse' %}"></script>
{% else %}
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
{% endif %}
<script type="application/javascript">
window.EXPORT_ID = {{pk}};
window.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}'
</script>
{% render_bundle 'export_response_view' %}
{% endblock %}

View File

@@ -1,81 +0,0 @@
{% load i18n %}
{% comment %} TODO: Deprecate {% endcomment %}
<div class="modal" tabindex="-1" role="dialog" id="id_modal_cook_log">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans 'Log Recipe Cooking' %}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>{% trans 'All fields are optional and can be left empty.' %}</p>
<form>
<label for="id_log_servings">{% trans 'Servings' %} </label>
<input class="form-control" type="number" id="id_log_servings">
<br/>
<label for="id_log_rating">{% trans 'Rating' %} - <span id="id_rating_show">0/5</span></label>
<input type="range" class="custom-range" min="0" max="5" id="id_log_rating" name="log_rating"
value="0">
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{% trans 'Close' %}</button>
<button type="button" class="btn btn-primary" onclick="logCook()">{% trans 'Save' %}</button>
</div>
</div>
</div>
</div>
<script type="application/javascript">
function openCookLogModal(id) {
let modal = $('#id_modal_cook_log')
modal.data('recipe_id', id)
modal.modal('show')
}
//TODO there is definitely a nicer way to do this than this ugly shit
function logCook() {
let modal = $('#id_modal_cook_log')
let rating = $('#id_log_rating')
let id = modal.data('recipe_id');
let url = "{% url 'api_log_cooking' recipe_id=12345 %}".replace(/12345/, id);
let val_servings = $('#id_log_servings').val()
if (val_servings !== '' && val_servings !== 0) {
url += '?s=' + val_servings
}
let val_rating = rating.val()
if (val_rating !== '' && val_rating !== 0) {
if (val_servings !== '' && val_servings !== 0) {
url += '&'
} else {
url += '?'
}
url += 'r=' + val_rating
}
let request = new XMLHttpRequest();
request.onreadystatechange = function () {
};
request.open("GET", url, true);
request.send();
modal.modal('hide')
}
$('#id_log_rating').on("input", () => {
let rating = $('#id_log_rating')
$('#id_rating_show').html(rating.val() + '/5')
});
</script>

View File

@@ -94,6 +94,5 @@
{% trans "Log in to view recipes" %} <br/>
</div>
{% endif %}
{% include 'include/log_cooking.html' %}
{% endblock %}

View File

@@ -217,13 +217,13 @@
</div>
<script type="application/javascript">
$(function () {
$(function() {
$('#id_search-trigram_threshold').get(0).type = 'range';
})
});
function applyPreset (preset){
$('#id_search-preset').val(preset)
$('#search_form_button').click()
function applyPreset(preset) {
$('#id_search-preset').val(preset);
$('#search_form_button').click();
}
function copyToken() {
@@ -239,29 +239,30 @@
}
// Change hash for page-reload
$('.nav-tabs a').on('shown.bs.tab', function (e) {
$('.nav-tabs a').on('shown.bs.tab', function(e) {
window.location.hash = e.target.hash;
})
{% comment %}
// listen for events
{% comment %} $(document).ready(function(){
$(document).ready(function() {
hideShow()
// call hideShow when the user clicks on the mealplan_autoadd checkbox
$("#id_shopping-mealplan_autoadd_shopping").click(function(event){
hideShow()
$("#id_shopping-mealplan_autoadd_shopping").click(function(event) {
hideShow();
});
})
function hideShow(){
if(document.getElementById('id_shopping-mealplan_autoadd_shopping').checked == true)
{
$('#div_id_shopping-mealplan_autoexclude_onhand').show();
$('#div_id_shopping-mealplan_autoinclude_related').show();
}
else
{
function hideShow() {
if(document.getElementById('id_shopping-mealplan_autoadd_shopping').checked == true) {
$('#div_id_shopping-mealplan_autoexclude_onhand').show();
$('#div_id_shopping-mealplan_autoinclude_related').show();
}
else {
$('#div_id_shopping-mealplan_autoexclude_onhand').hide();
$('#div_id_shopping-mealplan_autoinclude_related').hide();
} {% endcomment %}
}
}
{% endcomment %}
</script>
{% endblock %}

View File

@@ -834,7 +834,7 @@
this.$http.get('{% url 'api:recipe-detail' 123456 %}'.replace('123456', recipe.id)).then((response) => {
for (let s of response.data.steps) {
for (let i of s.ingredients) {
if (!i.is_header && i.food !== null && i.food.food_onhand === false) {
if (!i.is_header && i.food !== null && !i.food.ignore_food) {
this.shopping_list.entries.push({
'list_recipe': slr.id,
'food': i.food,

View File

@@ -156,6 +156,32 @@ def test_sharing(request, shared, count, sle_2, sle, u1_s1):
# confirm shared user sees their list and the list that's shared with them
assert len(json.loads(r.content)) == count
# test shared user can mark complete
x = shared_client.patch(
reverse(DETAIL_URL, args={sle[0].id}),
{'checked': True},
content_type='application/json'
)
r = json.loads(shared_client.get(reverse(LIST_URL)).content)
assert len(r) == count
# count unchecked entries
if not x.status_code == 404:
count = count-1
assert [x['checked'] for x in r].count(False) == count
# test shared user can delete
x = shared_client.delete(
reverse(
DETAIL_URL,
args={sle[1].id}
)
)
r = json.loads(shared_client.get(reverse(LIST_URL)).content)
assert len(r) == count
# count unchecked entries
if not x.status_code == 404:
count = count-1
assert [x['checked'] for x in r].count(False) == count
def test_completed(sle, u1_s1):
# check 1 entry

View File

@@ -164,7 +164,7 @@ def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u
assert len(r) == sle_count
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
# test removing 2 items from shopping list
# test removing 3 items from shopping list
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
{'list_recipe': list_recipe, 'ingredients': keep_ing},
content_type='application/json'

View File

@@ -0,0 +1,25 @@
import pytest
from django.contrib import auth
from django.urls import reverse
from cookbook.forms import ImportExportBase
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.models import ExportLog
@pytest.fixture
def obj_1(space_1, u1_s1):
return ExportLog.objects.create(type=ImportExportBase.DEFAULT, running=False, created_by=auth.get_user(u1_s1), space=space_1, exported_recipes=10, total_recipes=10)
@pytest.mark.parametrize("arg", [
['a_u', 302],
['g1_s1', 302],
['u1_s1', 200],
['a1_s1', 200],
['u1_s2', 404],
['a1_s2', 404],
])
def test_export_file_cache(arg, request, obj_1):
c = request.getfixturevalue(arg[0])
assert c.get(reverse('view_export_file', args=[obj_1.pk])).status_code == arg[1]

View File

@@ -21,6 +21,7 @@ router.register(r'cook-log', api.CookLogViewSet)
router.register(r'food', api.FoodViewSet)
router.register(r'food-inherit-field', api.FoodInheritFieldViewSet)
router.register(r'import-log', api.ImportLogViewSet)
router.register(r'export-log', api.ExportLogViewSet)
router.register(r'ingredient', api.IngredientViewSet)
router.register(r'keyword', api.KeywordViewSet)
router.register(r'meal-plan', api.MealPlanViewSet)
@@ -74,6 +75,8 @@ urlpatterns = [
path('import/', import_export.import_recipe, name='view_import'),
path('import-response/<int:pk>/', import_export.import_response, name='view_import_response'),
path('export/', import_export.export_recipe, name='view_export'),
path('export-response/<int:pk>/', import_export.export_response, name='view_export_response'),
path('export-file/<int:pk>/', import_export.export_file, name='view_export_file'),
path('view/recipe/<int:pk>', views.recipe_view, name='view_recipe'),
path('view/recipe/<int:pk>/<slug:share>', views.recipe_view, name='view_recipe'),

View File

@@ -41,9 +41,9 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, Cus
from cookbook.helper.recipe_html_import import get_recipe_from_source
from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch, old_search
from cookbook.helper.recipe_url_import import get_from_scraper
from cookbook.helper.shopping_helper import list_from_recipe, shopping_helper
from cookbook.helper.shopping_helper import RecipeShoppingEditor, shopping_helper
from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField,
ImportLog, Ingredient, Keyword, MealPlan, MealType, Recipe, RecipeBook,
ImportLog, ExportLog, Ingredient, Keyword, MealPlan, MealType, Recipe, RecipeBook,
RecipeBookEntry, ShareLink, ShoppingList, ShoppingListEntry,
ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory,
SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile,
@@ -54,7 +54,7 @@ from cookbook.provider.nextcloud import Nextcloud
from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema
from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializer,
CookLogSerializer, FoodInheritFieldSerializer, FoodSerializer,
FoodShoppingUpdateSerializer, ImportLogSerializer,
FoodShoppingUpdateSerializer, ImportLogSerializer, ExportLogSerializer,
IngredientSerializer, KeywordSerializer, MealPlanSerializer,
MealTypeSerializer, RecipeBookEntrySerializer,
RecipeBookSerializer, RecipeImageSerializer,
@@ -118,7 +118,7 @@ class ExtendedRecipeMixin():
# add a recipe count annotation to the query
# explanation on construction https://stackoverflow.com/a/43771738/15762829
recipe_count = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).values(recipe_filter).annotate(count=Count('pk')).values('count')
queryset = queryset.annotate(recipe_count_test=Coalesce(Subquery(recipe_count), 0))
queryset = queryset.annotate(recipe_count=Coalesce(Subquery(recipe_count), 0))
# add a recipe image annotation to the query
image_subquery = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
@@ -153,11 +153,15 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
)
else:
# TODO have this check unaccent search settings or other search preferences?
filter = Q(name__icontains=query)
if any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
filter |= Q(name__unaccent__icontains=query)
self.queryset = (
self.queryset
.annotate(starts=Case(When(name__istartswith=query, then=(Value(100))),
default=Value(0))) # put exact matches at the top of the result set
.filter(name__icontains=query).order_by('-starts', 'name')
.filter(filter).order_by('-starts', 'name')
)
updated_at = self.request.query_params.get('updated_at', None)
@@ -400,7 +404,7 @@ class SupermarketCategoryViewSet(viewsets.ModelViewSet, StandardFilterMixin):
permission_classes = [CustomIsUser]
def get_queryset(self):
self.queryset = self.queryset.filter(space=self.request.space)
self.queryset = self.queryset.filter(space=self.request.space).order_by('name')
return super().get_queryset()
@@ -644,7 +648,6 @@ class RecipeViewSet(viewsets.ModelViewSet):
schema = QueryParamAutoSchema()
def get_queryset(self):
if self.detail:
self.queryset = self.queryset.filter(space=self.request.space)
return super().get_queryset()
@@ -717,16 +720,27 @@ class RecipeViewSet(viewsets.ModelViewSet):
obj = self.get_object()
ingredients = request.data.get('ingredients', None)
servings = request.data.get('servings', None)
list_recipe = ShoppingListRecipe.objects.filter(id=request.data.get('list_recipe', None)).first()
if servings is None:
servings = getattr(list_recipe, 'servings', obj.servings)
# created_by needs to be sticky to original creator as it is 'their' shopping list
# changing shopping list created_by can shift some items to new owner which may not share in the other direction
created_by = getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', request.user)
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
list_from_recipe(list_recipe=list_recipe, recipe=obj, ingredients=ingredients, servings=servings, space=request.space, created_by=created_by)
list_recipe = request.data.get('list_recipe', None)
mealplan = request.data.get('mealplan', None)
SLR = RecipeShoppingEditor(request.user, request.space, id=list_recipe, recipe=obj, mealplan=mealplan)
return Response(content, status=status.HTTP_204_NO_CONTENT)
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
http_status = status.HTTP_204_NO_CONTENT
if servings and servings <= 0:
result = SLR.delete()
elif list_recipe:
result = SLR.edit(servings=servings, ingredients=ingredients)
else:
result = SLR.create(servings=servings, ingredients=ingredients)
if not result:
content = {'msg': ('An error occurred')}
http_status = status.HTTP_500_INTERNAL_SERVER_ERROR
else:
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
http_status = status.HTTP_204_NO_CONTENT
return Response(content, status=http_status)
@decorators.action(
detail=True,
@@ -847,6 +861,17 @@ class ImportLogViewSet(viewsets.ModelViewSet):
return self.queryset.filter(space=self.request.space)
class ExportLogViewSet(viewsets.ModelViewSet):
queryset = ExportLog.objects
serializer_class = ExportLogSerializer
permission_classes = [CustomIsUser]
pagination_class = DefaultPagination
def get_queryset(self):
return self.queryset.filter(space=self.request.space)
class BookmarkletImportViewSet(viewsets.ModelViewSet):
queryset = BookmarkletImport.objects
serializer_class = BookmarkletImportSerializer

View File

@@ -1,10 +1,11 @@
import re
import threading
from io import BytesIO
from django.core.cache import cache
from django.contrib import messages
from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import render
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext as _
@@ -29,7 +30,7 @@ from cookbook.integration.recipesage import RecipeSage
from cookbook.integration.rezkonv import RezKonv
from cookbook.integration.saffron import Saffron
from cookbook.integration.pdfexport import PDFexport
from cookbook.models import Recipe, ImportLog, UserPreference
from cookbook.models import Recipe, ImportLog, ExportLog, UserPreference
from recipes import settings
@@ -123,25 +124,57 @@ def export_recipe(request):
if form.cleaned_data['all']:
recipes = Recipe.objects.filter(space=request.space, internal=True).all()
if form.cleaned_data['type'] == ImportExportBase.PDF and not settings.ENABLE_PDF_EXPORT:
messages.add_message(request, messages.ERROR, _('The PDF Exporter is not enabled on this instance as it is still in an experimental state.'))
return render(request, 'export.html', {'form': form})
integration = get_integration(request, form.cleaned_data['type'])
return integration.do_export(recipes)
except NotImplementedError:
messages.add_message(request, messages.ERROR, _('Exporting is not implemented for this provider'))
if form.cleaned_data['type'] == ImportExportBase.PDF and not settings.ENABLE_PDF_EXPORT:
return JsonResponse({'error': _('The PDF Exporter is not enabled on this instance as it is still in an experimental state.')})
el = ExportLog.objects.create(type=form.cleaned_data['type'], created_by=request.user, space=request.space)
t = threading.Thread(target=integration.do_export, args=[recipes, el])
t.setDaemon(True)
t.start()
return JsonResponse({'export_id': el.pk})
except NotImplementedError:
return JsonResponse(
{
'error': True,
'msg': _('Importing is not implemented for this provider')
},
status=400
)
else:
form = ExportForm(space=request.space)
pk = ''
recipe = request.GET.get('r')
if recipe:
if re.match(r'^([0-9])+$', recipe):
if recipe := Recipe.objects.filter(pk=int(recipe), space=request.space).first():
form = ExportForm(initial={'recipes': recipe}, space=request.space)
pk = Recipe.objects.filter(pk=int(recipe), space=request.space).first().pk
return render(request, 'export.html', {'form': form})
return render(request, 'export.html', {'pk': pk})
@group_required('user')
def import_response(request, pk):
return render(request, 'import_response.html', {'pk': pk})
@group_required('user')
def export_response(request, pk):
return render(request, 'export_response.html', {'pk': pk})
@group_required('user')
def export_file(request, pk):
el = get_object_or_404(ExportLog, pk=pk, space=request.space)
cacheData = cache.get(f'export_file_{el.pk}')
if cacheData is None:
el.possibly_not_expired = False
el.save()
return render(request, 'export_response.html', {'pk': pk})
response = HttpResponse(cacheData['file'], content_type='application/force-download')
response['Content-Disposition'] = 'attachment; filename="' + cacheData['filename'] + '"'
return response

View File

@@ -260,7 +260,7 @@ def shopping_list(request, pk=None): # TODO deprecate
recipes = []
for r in html_list:
r = r.replace('[', '').replace(']', '')
if re.match(r'^([0-9])+,([0-9])+[.]*([0-9])*$', r): # vulnerable to DoS
if len(r) < 10000 and re.match(r'^([0-9])+,([0-9])+[.]*([0-9])*$', r):
rid, multiplier = r.split(',')
if recipe := Recipe.objects.filter(pk=int(rid), space=request.space).first():
recipes.append({'recipe': recipe.id, 'multiplier': multiplier})

View File

@@ -18,6 +18,17 @@ Open Tandoor, open the menu behind the three vertical dots at the top right, sel
#### Microsoft Edge
Open Tandoor, open the menu behind the three horizontal dots at the top right, select `Apps > Install Tandoor Recipes`
## Why is Tandoor not working correctly?
If you just set up your Tandoor instance and you're having issues like...
- Links not working
- CSRF errors
- CORS errors
- No recipes are loading
... then make sure, that you have set [all required headers](install/docker.md#required-headers) in your reverse proxy correctly.
If that doesn't fix it, you can also refer to the appropriate sub section in the [reverse proxy documentation](install/docker.md#reverse-proxy) and verify your general webserver configuration.
## Why am I getting CSRF Errors?
If you are getting CSRF Errors this is most likely due to a reverse proxy not passing the correct headers.
@@ -34,6 +45,10 @@ The other common issue is that the recommended nginx container is removed from t
If removed, the nginx webserver needs to be replaced by something else that servers the /mediafiles/ directory or
`GUNICORN_MEDIA` needs to be enabled to allow media serving by the application container itself.
## Why is Tandoor not working on my Raspberry Pi?
Please refer to [here](install/docker.md#setup-issues-on-raspberry-pi).
## How can I create users?
To create a new user click on your name (top right corner) and select system. There click on invite links and create a new invite link.

View File

@@ -96,6 +96,7 @@ AUTH_LDAP_USER_SEARCH_FILTER_STR=(uid=%(user)s)
AUTH_LDAP_USER_ATTR_MAP={'first_name': 'givenName', 'last_name': 'sn', 'email': 'mail'}
AUTH_LDAP_ALWAYS_UPDATE_USER=1
AUTH_LDAP_CACHE_TIMEOUT=3600
AUTH_LDAP_TLS_CACERTFILE=/etc/ssl/certs/own-ca.pem
```
## Reverse Proxy Authentication

View File

@@ -21,26 +21,26 @@ if your favorite one is missing.
Overview of the capabilities of the different integrations.
| Integration | Import | Export | Images |
|--------------------| ------ | ------ | ------ |
| Default | ✔️ | ✔️ | ✔️ |
| Nextcloud | ✔️ | ⌚ | ✔️ |
| Mealie | ✔️ | ⌚ | ✔️ |
| Chowdown | ✔️ | ⌚ | ✔️ |
| Safron | ✔️ | ✔ | ❌ |
| Paprika | ✔️ | ⌚ | ✔️ |
| ChefTap | ✔️ | ❌ | ❌ |
| Pepperplate | ✔️ | ⌚ | ❌ |
| RecipeSage | ✔️ | ✔️ | ✔️ |
| Domestica | ✔️ | ⌚ | ✔️ |
| MealMaster | ✔️ | ❌ | ❌ |
| RezKonv | ✔️ | ❌ | ❌ |
| OpenEats | ✔️ | ❌ | ⌚ |
| Plantoeat | ✔️ | ❌ | ✔ |
| CookBookApp | ✔️ | ⌚ | ✔️ |
| CopyMeThat | ✔️ | ❌ | ✔️ |
| PDF (experimental) | ⌚️ | ✔ | ✔️ |
|--------------------| ------ | -- | ------ |
| Default | ✔️ | ✔️ | ✔️ |
| Nextcloud | ✔️ | ⌚ | ✔️ |
| Mealie | ✔️ | ⌚ | ✔️ |
| Chowdown | ✔️ | ⌚ | ✔️ |
| Safron | ✔️ | ✔ | ❌ |
| Paprika | ✔️ | ⌚ | ✔️ |
| ChefTap | ✔️ | ❌ | ❌ |
| Pepperplate | ✔️ | ⌚ | ❌ |
| RecipeSage | ✔️ | ✔️ | ✔️ |
| Domestica | ✔️ | ⌚ | ✔️ |
| MealMaster | ✔️ | ❌ | ❌ |
| RezKonv | ✔️ | ❌ | ❌ |
| OpenEats | ✔️ | ❌ | ⌚ |
| Plantoeat | ✔️ | ❌ | ✔ |
| CookBookApp | ✔️ | ⌚ | ✔️ |
| CopyMeThat | ✔️ | ❌ | ✔️ |
| PDF (experimental) | ⌚️ | ✔ | ✔️ |
✔ = implemented, ❌ = not implemented and not possible/planned, ⌚ = not yet implemented
= implemented, ❌ = not implemented and not possible/planned, ⌚ = not yet implemented
## Default
The default integration is the built in (and preferred) way to import and export recipes.

View File

@@ -1,12 +1,12 @@
!!! success "Recommended Installation"
Setting up this application using Docker is recommended. This does not mean that other options are bad, just that
support is much easier for this setup.
Setting up this application using Docker is recommended. This does not mean that other options are bad, just that
support is much easier for this setup.
It is possible to install this application using many Docker configurations.
It is possible to install this application using many different Docker configurations.
Please read the instructions/notes on each example carefully and decide if this is the way for you.
Please read the instructions on each example carefully and decide if this is the way for you.
## Docker
## **Docker**
The docker image (`vabene1111/recipes`) simply exposes the application on the container's port `8080`.
@@ -32,75 +32,84 @@ Please make sure, if you run your image this way, to consult
the [.env.template](https://raw.githubusercontent.com/vabene1111/recipes/master/.env.template)
file in the GitHub repository to verify if additional environment variables are required for your setup.
### Versions
Also, don't forget to replace the placeholders for ```SECRET_KEY``` and ```POSTGRES_PASSWORD```!
There are different versions (tags) released on docker hub.
## **Versions**
There are different versions (tags) released on [Docker Hub](https://hub.docker.com/r/vabene1111/recipes/tags).
- **latest** Default image. The one you should use if you don't know that you need anything else.
- **beta** Partially stable version that gets updated every now and then. Expect to have some problems.
- **develop** If you want the most bleeding edge version with potentially many breaking changes feel free to use this version (I don't recommend it!).
- **develop** If you want the most bleeding edge version with potentially many breaking changes feel free to use this version (not recommended!).
- **X.Y.Z** each released version has its own image. If you need to revert to an old version or want to make sure you stay on one specific use these tags.
!!! danger "No Downgrading"
There is currently no way to migrate back to an older version as there is no mechanism to downgrade the database.
You could probably do it but I cannot help you with that. Choose wisely if you want to use the unstable images.
That said **beta** should usually be working if you like frequent updates and new stuff.
There is currently no way to migrate back to an older version as there is no mechanism to downgrade the database.
You could probably do it but I cannot help you with that. Choose wisely if you want to use the unstable images.
That said **beta** should usually be working if you like frequent updates and new stuff.
## Docker Compose
## **Docker Compose**
The main, and also recommended, installation option is to install this application using Docker Compose.
The main, and also recommended, installation option for this application is Docker Compose.
1. Choose your `docker-compose.yml` from the examples below.
2. Download the `.env` configuration file with `wget`, then **edit it accordingly** (you NEED to set `SECRET_KEY` and `POSTGRES_PASSWORD`).
```shell
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template -O .env
```
3. Start your container using `docker-compose up -d`.
2. Download the `.env` configuration file with `wget`
```shell
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template -O .env
```
3. **Edit it accordingly** (you NEED to set `SECRET_KEY` and `POSTGRES_PASSWORD`).
4. Start your container using `docker-compose up -d`.
### Plain
### **Plain**
This configuration exposes the application through an nginx web server on port 80 of your machine.
This configuration exposes the application through a containerized nginx web server on port 80 of your machine.
Be aware that having some other web server or container running on your host machine on port 80 will block this from working.
```shell
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/plain/docker-compose.yml
```
```yaml
{ % include "./docker/plain/docker-compose.yml" % }
```
~~~yaml
{% include "./docker/plain/docker-compose.yml" %}
~~~
### Reverse Proxy
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
### **Reverse Proxy**
Most deployments will likely use a reverse proxy.
If your reverse proxy is not listed here, please refer to [Others](https://docs.tandoor.dev/install/docker/#others).
If your reverse proxy is not listed below, please refer to chapter [Others](#others).
#### Traefik
#### **Traefik**
If you use traefik, this configuration is the one for you.
If you use Traefik, this configuration is the one for you.
!!! info
Traefik can be a little confusing to setup.
Please refer to [their excellent documentation](https://doc.traefik.io/traefik/). If that does not help,
[this little example](traefik.md) might be for you.
Traefik can be a little confusing to setup.
Please refer to [their excellent documentation](https://doc.traefik.io/traefik/). If that does not help,
[this little example](traefik.md) might be for you.
```shell
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/traefik-nginx/docker-compose.yml
```
```yaml
{ % include "./docker/traefik-nginx/docker-compose.yml" % }
```
~~~yaml
{% include "./docker/traefik-nginx/docker-compose.yml" %}
~~~
#### nginx-proxy
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
#### **jwilder's Nginx-proxy**
This is a docker compose example using [jwilder's nginx reverse proxy](https://github.com/jwilder/docker-gen)
in combination with [jrcs's letsencrypt companion](https://hub.docker.com/r/jrcs/letsencrypt-nginx-proxy-companion/).
Please refer to the appropriate documentation on how to setup the reverse proxy and networks.
Remember to add the appropriate environment variables to `.env` file:
Remember to add the appropriate environment variables to the `.env` file:
```
VIRTUAL_HOST=
@@ -112,11 +121,14 @@ LETSENCRYPT_EMAIL=
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/nginx-proxy/docker-compose.yml
```
```yaml
{ % include "./docker/nginx-proxy/docker-compose.yml" % }
```
~~~yaml
{% include "./docker/nginx-proxy/docker-compose.yml" %}
~~~
#### Nginx Swag by LinuxServer
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
#### **Nginx Swag by LinuxServer**
[This container](https://github.com/linuxserver/docker-swag) is an all in one solution created by LinuxServer.io.
@@ -140,15 +152,114 @@ Please refer to the [appropriate documentation](https://github.com/linuxserver/d
For step-by-step instructions to set this up from scratch, see [this example](swag.md).
### Others
#### **Pure Nginx**
If you use none of the above mentioned reverse proxies or want to use an existing one on your host machine (like a local nginx or Caddy), simply use the [PLAIN](https://docs.tandoor.dev/install/docker/#plain) setup above and change the outbound port to one of your liking.
If you have Nginx installed locally on your host system without using any third party integration like Swag or similar, this is for you.
You can use the Docker-Compose file from [Plain](#plain).
!!!warning "Adjust Docker-Compose file"
Replace `80:80` with `PORT:80` with PORT being your desired outward-facing port.
In the nginx config example below, 8080 is used.
An example configuration with LetsEncrypt to get you started can be seen below.
Please note, that since every setup is different, you might need to adjust some things.
!!!warning "Placeholders"
Don't forget to replace the domain and port.
```nginx
server {
if ($host = recipes.mydomain.tld) { # replace domain
return 301 https://$host$request_uri;
}
server_name recipes.mydomain.tld; # replace domain
listen 80;
return 404;
}
server {
server_name recipes.mydomain.tld; # replace domain
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/recipes.mydomain.tld/fullchain.pem; # replace domain
ssl_certificate_key /etc/letsencrypt/live/recipes.mydomain.tld/privkey.pem; # replace domain
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_set_header Host $http_host; # try $host instead if this doesn't work
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:8080; # replace port
proxy_redirect http://127.0.0.1:8080 https://recipes.domain.tld; # replace port and domain
}
}
```
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
#### **Apache**
You can use the Docker-Compose file from [Plain](#plain).
!!!warning "Adjust Docker-Compose file"
Replace `80:80` with `PORT:80` with PORT being your desired outward-facing port.
In the Apache config example below, 8080 is used.
If you use e.g. LetsEncrypt for SSL encryption, you can use the example configuration from [solaris7590](https://github.com/TandoorRecipes/recipes/issues/1312#issuecomment-1020034375) below.
!!!warning "Placeholders"
Don't forget to replace the domain and port.
```apache
<IfModule mod_ssl.c>
<VirtualHost *:80>
ServerAdmin webmaster@mydomain.de # replace domain
ServerName mydomain.de # replace domain
Redirect permanent / https://mydomain.de/ # replace domain
</VirtualHost>
<VirtualHost *:443>
ServerAdmin webmaster@mydomain.de # replace domain
ServerName mydomain.de # replace domain
SSLEngine on
RequestHeader set X-Forwarded-Proto "https"
Header always set Access-Control-Allow-Origin "*"
ProxyPreserveHost On
ProxyRequests Off
ProxyPass / http://localhost:8080/ # replace port
ProxyPassReverse / http://localhost:8080/ # replace port
SSLCertificateFile /etc/letsencrypt/live/mydomain.de/fullchain.pem # replace domain/path
SSLCertificateKeyFile /etc/letsencrypt/live/mydomain.de/privkey.pem # replace domain/path
Include /etc/letsencrypt/options-ssl-apache.conf
ErrorLog ${APACHE_LOG_DIR}/recipes_error.log
CustomLog ${APACHE_LOG_DIR}/recipes_access.log combined
</VirtualHost>
</IfModule>
```
If you're having issues with the example configuration above, you can try [beedaddy](https://github.com/TandoorRecipes/recipes/issues/1312#issuecomment-1015252663)'s example config.
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
#### **Others**
If you use none of the above mentioned reverse proxies or want to use an existing one on your host machine (like a local nginx or Caddy), simply use the [Plain](#plain) setup above and change the outbound port to one of your liking.
An example port config (inside the respective docker-compose.yml) would be: `8123:80` instead of the `80:80` or if you want to be sure, that Tandoor is **just** accessible via your proxy and don't wanna bother with your firewall, then `127.0.0.1:8123:80` is a viable option too.
## Additional Information
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
### Nginx vs Gunicorn
## **Additional Information**
### **Nginx vs Gunicorn**
All examples use an additional `nginx` container to serve mediafiles and act as the forward facing webserver.
This is **technically not required** but **very much recommended**.
@@ -158,20 +269,20 @@ the WSGi server that handles the Python execution, explicitly state that it is n
You will also likely not see any decrease in performance or a lot of space used as nginx is a very light container.
!!! info
Even if you run behind a reverse proxy as described above, using an additional nginx container is the recommended option.
Even if you run behind a reverse proxy as described above, using an additional nginx container is the recommended option.
If you run a small private deployment and don't care about performance, security and whatever else feel free to run
without a ngix container.
without a nginx container.
!!! warning
When running without nginx make sure to enable `GUNICORN_MEDIA` in the `.env`. Without it, media files will be uploaded
but not shown on the page.
When running without nginx make sure to enable `GUNICORN_MEDIA` in the `.env`. Without it, media files will be uploaded
but not shown on the page.
For additional information please refer to the [0.9.0 Release](https://github.com/vabene1111/recipes/releases?after=0.9.0)
and [Issue 201](https://github.com/vabene1111/recipes/issues/201) where these topics have been discussed.
See also refer to the [official gunicorn docs](https://docs.gunicorn.org/en/stable/deploy.html).
### Nginx Config
### **Nginx Config**
In order to give the user (you) the greatest amount of freedom when choosing how to deploy this application the
webserver is not directly bundled with the Docker image.
@@ -186,14 +297,54 @@ to the host system and from there into the nginx container.
This is not really a clean solution, but I could not find any better alternative that provided the same amount of
usability. If you know of any better way, feel free to open an issue.
### Volumes vs Bind Mounts
### **Volumes vs Bind Mounts**
Since I personally prefer to have my data where my `docker-compose.yml` resides, bind mounts are used in the example
configuration files for all user generated data (e.g. Postgresql and media files).
Please note that [there is a difference in functionality](https://docs.docker.com/storage/volumes/)
between the two and you cannot always simply interchange them.
!!!warning
Please note that [there is a difference in functionality](https://docs.docker.com/storage/volumes/)
between the two and you cannot always simply interchange them.
You can move everything to volumes if you prefer it this way, **but you cannot convert the nginx config file to a bind
mount.**
If you do so you will have to manually create the nginx config file and restart the container once after creating it.
### **Required Headers**
Please be sure to supply all required headers in your nginx/Apache/Caddy/... configuration!
nginx:
```nginx
location / {
proxy_set_header Host $http_host; # try $host instead if this doesn't work
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:8080; # replace port
proxy_redirect http://127.0.0.1:8080 https://recipes.domain.tld; # replace port and domain
}
```
Apache:
```apache
RequestHeader set X-Forwarded-Proto "https"
Header always set Access-Control-Allow-Origin "*"
ProxyPreserveHost On
ProxyRequests Off
ProxyPass / http://localhost:8080/ # replace port
ProxyPassReverse / http://localhost:8080/ # replace port
```
### **Setup issues on Raspberry Pi**
!!!info
Always wait at least 2-3 minutes after the very first start, since migrations will take some time!
If you're having issues with installing Tandoor on your Raspberry Pi or similar device,
follow these instructions:
- Stop all Tandoor containers (`docker-compose down`)
- Delete local database folder (usually 'postgresql' in the same folder as your 'docker-compose.yml' file)
- Start Tandoor containers again (`docker-compose up -d`)
- Wait for at least 2-3 minutes and then check if everything is working now (migrations can take quite some time!)
- If not, check logs of the web_recipes container with `docker logs <container_name>` and make sure that all migrations are indeed already done

View File

@@ -9,6 +9,11 @@ services:
- ./.env
networks:
- default
healthcheck:
test: ["CMD-SHELL", "psql -U $$POSTGRES_USER -d $$POSTGRES_DB --list || exit 1"]
interval: 4s
timeout: 1s
retries: 12
web_recipes:
image: vabene1111/recipes
@@ -20,7 +25,8 @@ services:
- nginx_config:/opt/recipes/nginx/conf.d
- ./mediafiles:/opt/recipes/mediafiles
depends_on:
- db_recipes
db_recipes:
condition: service_healthy
networks:
- default

View File

@@ -7,6 +7,11 @@ services:
- ./postgresql:/var/lib/postgresql/data
env_file:
- ./.env
healthcheck:
test: ["CMD-SHELL", "psql -U $$POSTGRES_USER -d $$POSTGRES_DB --list || exit 1"]
interval: 4s
timeout: 1s
retries: 12
web_recipes:
image: vabene1111/recipes
@@ -18,7 +23,8 @@ services:
- nginx_config:/opt/recipes/nginx/conf.d
- ./mediafiles:/opt/recipes/mediafiles
depends_on:
- db_recipes
db_recipes:
condition: service_healthy
nginx_recipes:
image: nginx:mainline-alpine
@@ -36,4 +42,4 @@ services:
volumes:
nginx_config:
staticfiles:
staticfiles:

View File

@@ -9,6 +9,11 @@ services:
- ./.env
networks:
- default
healthcheck:
test: ["CMD-SHELL", "psql -U $$POSTGRES_USER -d $$POSTGRES_DB --list || exit 1"]
interval: 4s
timeout: 1s
retries: 12
web_recipes:
image: vabene1111/recipes
@@ -20,7 +25,8 @@ services:
- nginx_config:/opt/recipes/nginx/conf.d
- ./mediafiles:/opt/recipes/mediafiles
depends_on:
- db_recipes
db_recipes:
condition: service_healthy
networks:
- default
@@ -51,4 +57,4 @@ networks:
volumes:
nginx_config:
staticfiles:
staticfiles:

View File

@@ -1,22 +1,26 @@
**!!! info "Community Contributed" This guide was contributed by the community and is neither officially supported, nor updated or tested.**
!!! info "Community Contributed"
This guide was contributed by the community and is neither officially supported, nor updated or tested.
# K8s Setup
## K8s Setup
This is a setup which should be sufficient for production use. Be sure to replace the default secrets!
# Files
## Files
## 10-configmap.yaml
### 10-configmap.yaml
The nginx config map. This is loaded as nginx.conf in the nginx sidecar to configure nginx to deliver static content.
## 15-secrets.yaml
### 15-secrets.yaml
The secrets **replace them!!** This file is only here for a quick start. Be aware that changing secrets after installation will be messy and is not documented here. **You should set new secrets before the installation.** As you are reading this document **before** the installation ;-)
!!! warning "Contains secrets"
**Replace them!**
Create your own postgresql passwords and the secret key for the django app
This file is only here for a quick start. Be aware that changing secrets after installation will be messy and is not documented here. **You should set new secrets before the installation.** As you are reading this document **before** the installation ;-)
see also [Managing Secrets using kubectl](https://kubernetes.io/docs/tasks/configmap-secret/managing-secret-using-kubectl/)
Create your own postgresql passwords and the secret key for the django app.
See also [Managing Secrets using kubectl](https://kubernetes.io/docs/tasks/configmap-secret/managing-secret-using-kubectl/)
**Replace** `db-password`, `postgres-user-password` and `secret-key` **with something - well - secret :-)**
@@ -35,37 +39,37 @@ kubectl create secret generic recipes \
--from-file=secret-key=./secret-key.txt
~~~
## 20-service-account.yml
### 20-service-account.yml
Creating service account `recipes` for deployment and stateful set.
## 30-pvc.yaml
### 30-pvc.yaml
The creation of the persistent volume claims for media and static content. May you want to increase the size. This expects to have a storage class installed.
## 40-sts-postgresql.yaml
### 40-sts-postgresql.yaml
The PostgreSQL stateful set, based on a bitnami image. It runs a init container as root to do the preparations. The postgres container itself runs as a lower privileged user. The recipes app uses the database super user (postgres) as the recipes app is doing some db migrations on startup, which needs super user privileges.
## 45-service-db.yaml
### 45-service-db.yaml
Creating the database service.
## 50-deployment.yaml
### 50-deployment.yaml
The deployment first fires up a init container to do the database migrations and file modifications. This init container runs as root. The init container runs part of the [boot.sh](https://github.com/TandoorRecipes/recipes/blob/develop/boot.sh) script from the `vabene1111/recipes` image.
The deployment then runs two containers, the recipes-nginx and the recipes container which runs the gunicorn app. The nginx container gets it's nginx.conf via config map to deliver static content `/static` and `/media`. The guincorn container gets it's secret key and the database password from the secret `recipes`. `gunicorn` runs as user `nobody`.
## 60-service.yaml
### 60-service.yaml
Creating the app service.
## 70-ingress.yaml
### 70-ingress.yaml
Setting up the ingress for the recipes service. Requests for static content `/static` and `/media` are send to the nginx container, everything else to gunicorn. TLS setup via cert-manager is prepared. You have to **change the host** from `recipes.local` to your specific domain.
# Conclusion
## Conclusion
All in all:
@@ -80,16 +84,16 @@ I tried the setup with [kind](https://kind.sigs.k8s.io/) and it runs well on my
There is a warning, when you check your system as super user:
**Media Serving Warning**
Serving media files directly using gunicorn/python is not recommend! Please follow the steps described here to update your installation.
!!! warning "Media Serving Warning"
Serving media files directly using gunicorn/python is not recommend! Please follow the steps described here to update your installation.
I don't know how this check works, but this warning is simply wrong! ;-) Media and static files are routed by ingress to the nginx container - I promise :-)
# Updates
## Updates
These manifests are tested against Release 1.0.1. Newer versions may not work without changes.
# Apply the manifets
## Apply the manifets
To apply the manifest with kubectl, use the following command:

View File

@@ -21,25 +21,29 @@ Create virtual env: `python3.9 -m venv /var/www/recipes`
Install Javascript Tools
```shell
apt install nodejs
npm install --global yarn
sudo apt install nodejs
sudo npm install --global yarn
```
### Install postgresql requirements
`sudo apt install libpq-dev postgresql`
```shell
sudo apt install libpq-dev postgresql
```
###Install project requirements
!!! warning "Update"
Dependencies change with most updates so the following steps need to be re-run with every update or else the application might stop working.
See section **Updating** below
See section [Updating](#updating) below.
Using binaries from the virtual env:
`/var/www/recipes/bin/pip3.9 install -r requirements.txt`
```shell
/var/www/recipes/bin/pip3.9 install -r requirements.txt
```
You will also need to install front end requirements and build them. For this navigate to the `./vue`folder and run
You will also need to install front end requirements and build them. For this navigate to the `./vue` folder and run
```shell
yarn install
@@ -48,7 +52,9 @@ yarn build
## Setup postgresql
`sudo -u postgres psql`
```shell
sudo -u postgres psql
```
In the psql console:
@@ -73,6 +79,7 @@ wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template
```
Things to edit:
- `SECRET_KEY`: use something secure.
- `POSTGRES_HOST`: probably 127.0.0.1.
- `POSTGRES_PASSWORD`: the password we set earlier when setting up djangodb.

View File

@@ -5,19 +5,18 @@ Many people appear to host this application on their Synology NAS. The following
@therealschimmi in [this issue discussion](https://github.com/vabene1111/recipes/issues/98#issuecomment-643062907).
There is also this
([word](https://github.com/vabene1111/recipes/files/6708738/Tandoor.on.a.Synology.Disk.Station.docx),
([word](https://github.com/vabene1111/recipes/files/6708738/Tandoor.on.a.Synology.Disk.Station.docx),
[pdf](https://github.com/vabene1111/recipes/files/6901601/Tandoor.on.a.Synology.Disk.Station.pdf)) awesome and
very detailed guide provided by @DiversityBug.
There are, as always, most likely other ways to do this but this can be used as a starting point for your
setup. Since I cannot test it myself feedback and improvements are always very welcome.
## Instructions
## **Instructions**
Basic guide to setup `vabenee1111/recipes docker` container on Synology NAS
1. Login to Synology DSM through your browser
Basic guide to setup `vabenee1111/recipes` docker container on Synology NAS.
### 1. Login to Synology DSM through your browser
- Install Docker through package center
- Optional: Create a shared folder for your docker projects, they have to store data somewhere outside the containers
- Create a folder somewhere, I suggest naming it 'recipes' and storing it in the dedicated docker folder
@@ -25,28 +24,30 @@ Basic guide to setup `vabenee1111/recipes docker` container on Synology NAS
![grafik](https://user-images.githubusercontent.com/66269214/84472395-63042580-ac87-11ea-8779-37555210e47a.png)
2. Download templates
- vabene1111 gives you a few samples for various setups to work with. I chose to use the plain setup for now.
- Open https://github.com/vabene1111/recipes/tree/develop/docs/install/docker
- Download docker-compose.yml to your recipes folder
- Open https://github.com/vabene1111/recipes/tree/develop/nginx/conf.d
- Download Recipes.conf to your conf.d folder
- Open https://github.com/vabene1111/recipes/blob/develop/.env.template
- Copy the text and save it as '.env' to your recipes folder (no filename extension!)
- Add a POSTGRES_PASSWORD
- Once done, it should look like this:
### 2. Download templates
!!!info
vabene1111 gives you a few samples for various setups to work with. I chose to use the plain setup for now.
* Open https://github.com/vabene1111/recipes/tree/develop/docs/install/docker ([link](https://github.com/vabene1111/recipes/tree/develop/docs/install/docker))
* Download docker-compose.yml to your recipes folder ([direct link to plain](https://github.com/TandoorRecipes/recipes/raw/develop/docs/install/docker/plain/docker-compose.yml))
* Open https://github.com/vabene1111/recipes/tree/develop/nginx/conf.d ([link](https://github.com/vabene1111/recipes/tree/develop/nginx/conf.d))
* Download Recipes.conf to your conf.d folder ([direct link](https://raw.githubusercontent.com/TandoorRecipes/recipes/develop/nginx/conf.d/Recipes.conf))
* Open https://github.com/vabene1111/recipes/blob/develop/.env.template ([link](https://github.com/vabene1111/recipes/blob/develop/.env.template))
* Copy the text and save it as ```.env``` to your recipes folder (no filename extension!)
* Add a ```POSTGRES_PASSWORD```
* Once done, it should look like this:
![grafik](https://user-images.githubusercontent.com/66269214/84471828-75319400-ac86-11ea-97e1-42bcb166720e.png)
3. Edit docker-compose.yml
- Open docker-compose.yml in a text editor
- This file tells docker how to setup recipes. Docker will create three containers for recipes to work, recipes, nginx and postgresql. They are all required and need to store and share data through the folders you created before.
- Edit line 26, this line specifies which external synology port will point to which internal docker port. Chose a free port to use and replace the first number with it. You will open recipes by browsing to http://your.synology.ip:chosen.port, e.g. http://192.168.1.1:2000
- If you want to use port 2000 you would edit to 2000:80
### 3. Edit docker-compose.yml
* Open docker-compose.yml in a text editor
* This file tells docker how to setup recipes. Docker will create three containers for recipes to work, recipes, nginx and postgresql. They are all required and need to store and share data through the folders you created before.
* Edit line 26, this line specifies which external synology port will point to which internal docker port. Chose a free port to use and replace the first number with it. You will open recipes by browsing to http://your.synology.ip:chosen.port, e.g. http://192.168.1.1:2000
* If you want to use port 2000 you would edit to 2000:80
4. SSH into your Synology
### 4. SSH into your Synology
- You need to access your Synology through SSH
- execute following commands
- Execute following commands
- `ssh root@your.synology.ip` connect to your synology. root password is the same as admin password, sometimes root access is not possible for whatever reason, then replace root with admin
- `cd /volume1/docker/recipes` access the folder where you store docker-compose.yml
- `docker-compose up -d` this starts your containers according to your docker-compose.yml. if you logged in with admin you will have to use `sudo docker-compose up -d` instead, it will ask for the admin password again.
@@ -57,10 +58,10 @@ Creating recipes_nginx_recipes_1 ... done
Creating recipes_db_recipes_1 ... done
Creating recipes_web_recipes_1 ... done
```
- Browse to 192.168.1.1:2000 or whatever your IP and port are
- While the containers are starting and doing whatever they need to do, you might still get HTTP errors e.g. 500 or 502. Just be patient and try again in a moment
* Browse to 192.168.1.1:2000 or whatever your IP and port are
* While the containers are starting and doing whatever they need to do, you might still get HTTP errors e.g. 500 or 502. Just be patient and try again in a moment
5. Firewall
### 5. Firewall
You need to set up firewall rules in order for the recipes_web container to be able to connect to the recipes_db container.
- Control Panel -> Security -> Firewall -> Edit Rules -> Create
@@ -71,8 +72,9 @@ You need to set up firewall rules in order for the recipes_web container to be a
- Action: Allow
- Save and make sure it's above the deny rules
6. Additional SSL Setup
Easiest way is to do it via Reverse Proxy
### 6. Additional SSL Setup
Easiest way is to do it via Reverse Proxy.
- Control Panel -> Login Portal (renamed Since DSM 7, previously Application Portal) -> Advanced -> Reverse Proxy
- Create
- insert name

View File

@@ -10,18 +10,25 @@ unraid forum where he gives additional information.
## Installation
Recipes for unRAID is avialble via Community Applications.
You will first need to install Community Applications (CA) by following the directions here:
https://forums.unraid.net/topic/38582-plug-in-community-applications/
### Install Community Applications
After that, you can go to the "Apps" tab in unRAID and search for Recipes and locate the Recipes container and install it.
Tandoor for unRAID is available via `Community Applications`.
You will first need to install `Community Applications (CA)` by following the directions here:
[Unraid forums](https://forums.unraid.net/topic/38582-plug-in-community-applications/)
### Locate and install Tandoor Recipes
After that, you can go to the "Apps" tab in unRAID and search for `Tandoor Recipes`, locate the correct container and install it.
![image](https://user-images.githubusercontent.com/724777/111038251-faa0cb00-83f5-11eb-9807-37815de8d795.png)
The default settings should by fine for most users, just be sure to enter a secret key that is randomly generated.
Then choose apply.
### Configure settings
The default settings should be fine for most users, just be sure to enter a secret key that is randomly generated.
Then click `Apply`.
![image](https://user-images.githubusercontent.com/724777/97094856-f3377b80-1626-11eb-98d5-e4b871a420f0.png)
After the container installs, click on the Recipes icon and click the WebUI button to launch the web user interface.
### Access website
After the container is installed, click on the `Tandoor Recipes` icon and click the WebUI button to launch the web user interface.
Set the container to auto-start if you wish.
![image](https://user-images.githubusercontent.com/724777/111038276-16a46c80-83f6-11eb-866b-b3bc9a2efb87.png)

View File

@@ -138,6 +138,7 @@ ENABLE_SIGNUP = bool(int(os.getenv('ENABLE_SIGNUP', False)))
ENABLE_METRICS = bool(int(os.getenv('ENABLE_METRICS', False)))
ENABLE_PDF_EXPORT = bool(int(os.getenv('ENABLE_PDF_EXPORT', False)))
EXPORT_FILE_CACHE_DURATION = int(os.getenv('EXPORT_FILE_CACHE_DURATION', 600))
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
@@ -186,6 +187,8 @@ if LDAP_AUTH:
}
AUTH_LDAP_ALWAYS_UPDATE_USER = bool(int(os.getenv('AUTH_LDAP_ALWAYS_UPDATE_USER', True)))
AUTH_LDAP_CACHE_TIMEOUT = int(os.getenv('AUTH_LDAP_CACHE_TIMEOUT', 3600))
if 'AUTH_LDAP_TLS_CACERTFILE' in os.environ:
AUTH_LDAP_GLOBAL_OPTIONS = { ldap.OPT_X_TLS_CACERTFILE: os.getenv('AUTH_LDAP_TLS_CACERTFILE') }
AUTHENTICATION_BACKENDS += [
'django.contrib.auth.backends.ModelBackend',
@@ -425,3 +428,4 @@ EMAIL_USE_TLS = bool(int(os.getenv('EMAIL_USE_TLS', False)))
EMAIL_USE_SSL = bool(int(os.getenv('EMAIL_USE_SSL', False)))
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'webmaster@localhost')
ACCOUNT_EMAIL_SUBJECT_PREFIX = os.getenv('ACCOUNT_EMAIL_SUBJECT_PREFIX', '[Tandoor Recipes] ') # allauth sender prefix

View File

@@ -2,8 +2,8 @@ Django==3.2.11
cryptography==36.0.1
django-annoying==0.10.6
django-autocomplete-light==3.8.2
django-cleanup==5.2.0
django-crispy-forms==1.13.0
django-cleanup==6.0.0
django-crispy-forms==1.14.0
django-filter==21.1
django-tables2==2.4.1
djangorestframework==3.13.1
@@ -13,10 +13,10 @@ bleach-allowlist==1.0.3
gunicorn==20.1.0
lxml==4.7.1
Markdown==3.3.6
Pillow==9.0.0
Pillow==9.0.1
psycopg2-binary==2.9.3
python-dotenv==0.19.2
requests==2.27.0
requests==2.27.1
simplejson==3.17.6
six==1.16.0
webdavclient3==3.14.6
@@ -25,22 +25,22 @@ icalendar==4.0.9
pyyaml==6.0
uritemplate==4.1.1
beautifulsoup4==4.10.0
microdata==0.7.2
microdata==0.8.0
Jinja2==3.0.3
django-webpack-loader==1.4.1
django-js-reverse==0.9.1
django-allauth==0.47.0
recipe-scrapers==13.10.1
recipe-scrapers==13.12.1
django-scopes==1.2.0
pytest==6.2.5
pytest-django==4.5.2
django-treebeard==4.5.1
django-cors-headers==3.10.1
django-cors-headers==3.11.0
django-storages==1.12.3
boto3==1.20.27
boto3==1.20.47
django-prometheus==2.2.0
django-hCaptcha==0.1.0
django-hCaptcha==0.2.0
python-ldap==3.4.0
django-auth-ldap==4.0.0
pytest-factoryboy==2.1.0
pyppeteer==0.2.6
pyppeteer==1.0.2

View File

@@ -15,7 +15,7 @@
"@riophae/vue-treeselect": "^0.4.0",
"axios": "^0.24.0",
"bootstrap-vue": "^2.21.2",
"core-js": "^3.20.2",
"core-js": "^3.20.3",
"html2pdf.js": "^0.10.1",
"lodash": "^4.17.21",
"moment": "^2.29.1",
@@ -44,13 +44,13 @@
"@vue/cli-plugin-eslint": "~4.5.15",
"@vue/cli-plugin-pwa": "~4.5.13",
"@vue/cli-plugin-typescript": "^4.5.15",
"@vue/cli-service": "~4.5.13",
"@vue/compiler-sfc": "^3.2.20",
"@vue/cli-service": "~4.5.15",
"@vue/compiler-sfc": "^3.2.29",
"@vue/eslint-config-typescript": "^10.0.0",
"babel-eslint": "^10.1.0",
"eslint": "^7.28.0",
"eslint-plugin-vue": "^8.0.3",
"typescript": "~4.5.2",
"typescript": "~4.5.5",
"vue-cli-plugin-i18n": "^2.1.1",
"webpack-bundle-tracker": "1.4.0",
"workbox-expiration": "^6.3.0",

View File

@@ -0,0 +1,145 @@
<template>
<div id="app">
<br/>
<template v-if="export_info !== undefined">
<template v-if="export_info.running">
<h5 style="text-align: center">{{ $t('Exporting') }}...</h5>
<b-progress :max="export_info.total_recipes">
<b-progress-bar :value="export_info.exported_recipes" :label="`${export_info.exported_recipes}/${export_info.total_recipes}`"></b-progress-bar>
</b-progress>
<loading-spinner :size="25"></loading-spinner>
</template>
<div class="row">
<div class="col col-md-12" v-if="!export_info.running">
<span>{{ $t('Export_finished') }}! </span> <a :href="`${resolveDjangoUrl('viewExport') }`">{{ $t('Return to export') }} </a><br><br>
{{ $t('If download did not start automatically: ') }}
<template v-if="export_info.expired">
<a disabled><del>{{ $t('Download') }}</del></a> ({{ $t('Expired') }})
</template>
<a v-else :href="`/export-file/${export_id}/`" ref="downloadAnchor" >{{ $t('Download') }}</a>
<br>
{{ $t('The link will remain active for') }}
<template v-if="export_info.cache_duration > 3600">
{{ export_info.cache_duration/3600 }}{{ $t('hr') }}
</template>
<template v-else-if="export_info.cache_duration > 60">
{{ export_info.cache_duration/60 }}{{ $t('min') }}
</template>
<template v-else>
{{ export_info.cache_duration }}{{ $t('sec') }}
</template>
<br>
</div>
</div>
<br/>
<div class="row">
<div class="col col-md-12">
<label for="id_textarea">{{ $t('Information') }}</label>
<textarea id="id_textarea" ref="output_text" class="form-control" style="height: 50vh"
v-html="export_info.msg"
disabled></textarea>
</div>
</div>
<br/>
<br/>
</template>
</div>
</template>
<script>
import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import {ResolveUrlMixin, makeToast, ToastMixin} from "@/utils/utils";
import LoadingSpinner from "@/components/LoadingSpinner";
import {ApiApiFactory} from "@/utils/openapi/api.ts";
Vue.use(BootstrapVue)
export default {
name: 'ExportResponseView',
mixins: [
ResolveUrlMixin,
ToastMixin,
],
components: {
LoadingSpinner
},
data() {
return {
export_id: window.EXPORT_ID,
export_info: undefined,
}
},
mounted() {
this.refreshData()
this.$i18n.locale = window.CUSTOM_LOCALE
this.dynamicIntervalTimeout = 250 //initial refresh rate
this.run = setTimeout(this.dynamicInterval.bind(this), this.dynamicIntervalTimeout)
},
methods: {
dynamicInterval: function(){
//update frequently at start but slowdown as it takes longer
this.dynamicIntervalTimeout = Math.round(this.dynamicIntervalTimeout*((1+Math.sqrt(5))/2))
if(this.dynamicIntervalTimeout > 5000) this.dynamicIntervalTimeout = 5000
clearInterval(this.run);
this.run = setInterval(this.dynamicInterval.bind(this), this.dynamicIntervalTimeout);
if ((this.export_id !== null) && window.navigator.onLine && this.export_info.running) {
this.refreshData()
let el = this.$refs.output_text
el.scrollTop = el.scrollHeight;
if(this.export_info.expired)
makeToast(this.$t("Error"), this.$t("The download link is expired!"), "danger")
}
},
startDownload: function(){
this.$refs['downloadAnchor'].click()
},
refreshData: function () {
let apiClient = new ApiApiFactory()
apiClient.retrieveExportLog(this.export_id).then(result => {
this.export_info = result.data
this.export_info.expired = !this.export_info.possibly_not_expired
if(!this.export_info.running)
this.$nextTick(()=>{ this.startDownload(); } )
})
}
}
}
</script>
<style>
</style>

View File

@@ -0,0 +1,18 @@
import Vue from 'vue'
import App from './ExportResponseView.vue'
import i18n from '@/i18n'
Vue.config.productionTip = false
// TODO move this and other default stuff to centralized JS file (verify nothing breaks)
let publicPath = localStorage.STATIC_URL + 'vue/'
if (process.env.NODE_ENV === 'development') {
publicPath = 'http://localhost:8080/'
}
export default __webpack_public_path__ = publicPath // eslint-disable-line
new Vue({
i18n,
render: h => h(App),
}).$mount('#app')

View File

@@ -0,0 +1,174 @@
<template>
<div id="app">
<h2>{{ $t('Export') }}</h2>
<div class="row">
<div class="col col-md-12">
<br/>
<!-- TODO get option dynamicaly -->
<select class="form-control" v-model="recipe_app">
<option value="DEFAULT">Default</option>
<option value="SAFFRON">Saffron</option>
<option value="RECIPESAGE">Recipe Sage</option>
<option value="PDF">PDF (experimental)</option>
</select>
<br/>
<b-form-checkbox v-model="export_all" @change="disabled_multiselect=$event" name="check-button" switch style="margin-top: 1vh">
{{ $t('All recipes') }}
</b-form-checkbox>
<multiselect
:searchable="true"
:disabled="disabled_multiselect"
v-model="recipe_list"
:options="recipes"
:close-on-select="false"
:clear-on-select="true"
:hide-selected="true"
:preserve-search="true"
placeholder="Select Recipes"
:taggable="false"
label="name"
track-by="id"
id="id_recipes"
:multiple="true"
:loading="recipes_loading"
@search-change="searchRecipes">
</multiselect>
<br/>
<button @click="exportRecipe()" class="btn btn-primary shadow-none"><i class="fas fa-file-export"></i> {{ $t('Export') }}
</button>
</div>
</div>
</div>
</template>
<script>
import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import LoadingSpinner from "@/components/LoadingSpinner";
import {StandardToasts, makeToast, resolveDjangoUrl} from "@/utils/utils";
import Multiselect from "vue-multiselect";
import {ApiApiFactory} from "@/utils/openapi/api.ts";
import axios from "axios";
Vue.use(BootstrapVue)
export default {
name: 'ExportView',
/*mixins: [
ResolveUrlMixin,
ToastMixin,
],*/
components: {Multiselect},
data() {
return {
export_id: window.EXPORT_ID,
loading: false,
disabled_multiselect: false,
recipe_app: 'DEFAULT',
recipe_list: [],
recipes_loading: false,
recipes: [],
export_all: false,
}
},
mounted() {
if(this.export_id)
this.insertRequested()
else
this.searchRecipes('')
},
methods: {
insertRequested: function(){
let apiFactory = new ApiApiFactory()
this.recipes_loading = true
apiFactory.retrieveRecipe(this.export_id).then((response) => {
this.recipes_loading = false
this.recipe_list.push(response.data)
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
}).then(e => this.searchRecipes(''))
},
searchRecipes: function (query) {
let apiFactory = new ApiApiFactory()
this.recipes_loading = true
let maxResultLenght = 1000
apiFactory.listRecipes(query, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, 1, maxResultLenght).then((response) => {
this.recipes = response.data.results;
this.recipes_loading = false
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
})
},
exportRecipe: function () {
if (this.recipe_list.length < 1 && this.export_all == false) {
makeToast(this.$t("Error"), this.$t("Select at least one recipe"), "danger")
return;
}
this.error = undefined
this.loading = true
let formData = new FormData();
formData.append('type', this.recipe_app);
formData.append('all', this.export_all)
for (var i = 0; i < this.recipe_list.length; i++) {
formData.append('recipes', this.recipe_list[i].id);
}
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded';
axios.post(resolveDjangoUrl('view_export',), formData).then((response) => {
if (response.data['error'] !== undefined){
makeToast(this.$t("Error"), response.data['error'],"warning")
}else{
window.location.href = resolveDjangoUrl('view_export_response', response.data['export_id'])
}
}).catch((err) => {
this.error = err.data
this.loading = false
console.log(err)
makeToast(this.$t("Error"), this.$t("There was an error loading a resource!"), "warning")
})
},
}
}
</script>
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
<style>
</style>

View File

@@ -0,0 +1,18 @@
import Vue from 'vue'
import App from './ExportView.vue'
import i18n from '@/i18n'
Vue.config.productionTip = false
// TODO move this and other default stuff to centralized JS file (verify nothing breaks)
let publicPath = localStorage.STATIC_URL + 'vue/'
if (process.env.NODE_ENV === 'development') {
publicPath = 'http://localhost:8080/'
}
export default __webpack_public_path__ = publicPath // eslint-disable-line
new Vue({
i18n,
render: h => h(App),
}).$mount('#app')

View File

@@ -54,20 +54,14 @@
<div class="col-12 col-md-3 calender-options">
<h5>{{ $t("Planner_Settings") }}</h5>
<b-form>
<b-form-group id="UomInput" :label="$t('Period')" :description="$t('Plan_Period_To_Show')"
label-for="UomInput">
<b-form-select id="UomInput" v-model="settings.displayPeriodUom"
:options="options.displayPeriodUom"></b-form-select>
<b-form-group id="UomInput" :label="$t('Period')" :description="$t('Plan_Period_To_Show')" label-for="UomInput">
<b-form-select id="UomInput" v-model="settings.displayPeriodUom" :options="options.displayPeriodUom"></b-form-select>
</b-form-group>
<b-form-group id="PeriodInput" :label="$t('Periods')"
:description="$t('Plan_Show_How_Many_Periods')" label-for="PeriodInput">
<b-form-select id="PeriodInput" v-model="settings.displayPeriodCount"
:options="options.displayPeriodCount"></b-form-select>
<b-form-group id="PeriodInput" :label="$t('Periods')" :description="$t('Plan_Show_How_Many_Periods')" label-for="PeriodInput">
<b-form-select id="PeriodInput" v-model="settings.displayPeriodCount" :options="options.displayPeriodCount"></b-form-select>
</b-form-group>
<b-form-group id="DaysInput" :label="$t('Starting_Day')" :description="$t('Starting_Day')"
label-for="DaysInput">
<b-form-select id="DaysInput" v-model="settings.startingDayOfWeek"
:options="dayNames"></b-form-select>
<b-form-group id="DaysInput" :label="$t('Starting_Day')" :description="$t('Starting_Day')" label-for="DaysInput">
<b-form-select id="DaysInput" v-model="settings.startingDayOfWeek" :options="dayNames"></b-form-select>
</b-form-group>
<b-form-group id="WeekNumInput" :label="$t('Week_Numbers')">
<b-form-checkbox v-model="settings.displayWeekNumbers" name="week_num">
@@ -80,23 +74,18 @@
<h5>{{ $t("Meal_Types") }}</h5>
<div>
<draggable :list="meal_types" group="meal_types" :empty-insert-threshold="10" @sort="sortMealTypes()" ghost-class="ghost">
<b-card no-body class="mt-1 list-group-item p-2" style="cursor:move" v-for="(meal_type, index) in meal_types" v-hover
:key="meal_type.id">
<b-card no-body class="mt-1 list-group-item p-2" style="cursor: move" v-for="(meal_type, index) in meal_types" v-hover :key="meal_type.id">
<b-card-header class="p-2 border-0">
<div class="row">
<div class="col-2">
<button type="button" class="btn btn-lg shadow-none"><i
class="fas fa-arrows-alt-v"></i></button>
<button type="button" class="btn btn-lg shadow-none"><i class="fas fa-arrows-alt-v"></i></button>
</div>
<div class="col-10">
<h5 class="mt-1 mb-1">
{{ meal_type.icon }} {{
meal_type.name
}}<span class="float-right text-primary" style="cursor:pointer"
><i class="fa"
v-bind:class="{ 'fa-pen': !meal_type.editing, 'fa-save': meal_type.editing }"
@click="editOrSaveMealType(index)" aria-hidden="true"></i
></span>
{{ meal_type.icon }} {{ meal_type.name
}}<span class="float-right text-primary" style="cursor: pointer"
><i class="fa" v-bind:class="{ 'fa-pen': !meal_type.editing, 'fa-save': meal_type.editing }" @click="editOrSaveMealType(index)" aria-hidden="true"></i
></span>
</h5>
</div>
</div>
@@ -104,26 +93,19 @@
<b-card-body class="p-4" v-if="meal_type.editing">
<div class="form-group">
<label>{{ $t("Name") }}</label>
<input class="form-control" placeholder="Name" v-model="meal_type.name"/>
<input class="form-control" placeholder="Name" v-model="meal_type.name" />
</div>
<div class="form-group">
<emoji-input :field="'icon'" :label="$t('Icon')"
:value="meal_type.icon"></emoji-input>
<emoji-input :field="'icon'" :label="$t('Icon')" :value="meal_type.icon"></emoji-input>
</div>
<div class="form-group">
<label>{{ $t("Color") }}</label>
<input class="form-control" type="color" name="Name"
:value="meal_type.color"
@change="meal_type.color = $event.target.value"/>
<input class="form-control" type="color" name="Name" :value="meal_type.color" @change="meal_type.color = $event.target.value" />
</div>
<b-form-checkbox id="checkbox-1" v-model="meal_type.default"
name="default_checkbox" class="mb-2">
<b-form-checkbox id="checkbox-1" v-model="meal_type.default" name="default_checkbox" class="mb-2">
{{ $t("Default") }}
</b-form-checkbox>
<button class="btn btn-danger" @click="deleteMealType(index)">{{
$t("Delete")
}}
</button>
<button class="btn btn-danger" @click="deleteMealType(index)">{{ $t("Delete") }}</button>
<button class="btn btn-primary float-right" @click="editOrSaveMealType(index)">
{{ $t("Save") }}
</button>
@@ -147,15 +129,16 @@
openEntryEdit(contextData.originalItem.entry)
"
>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pen"></i> {{
$t("Edit")
}}</a>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pen"></i> {{ $t("Edit") }}</a>
</ContextMenuItem>
<ContextMenuItem
v-if="contextData.originalItem.entry.recipe != null"
@click="$refs.menu.close();openRecipe(contextData.originalItem.entry.recipe)">
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pizza-slice"></i>
{{ $t("Recipe") }}</a>
v-if="contextData && contextData.originalItem && contextData.originalItem.entry.recipe != null"
@click="
$refs.menu.close()
openRecipe(contextData.originalItem.entry.recipe)
"
>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pizza-slice"></i> {{ $t("Recipe") }}</a>
</ContextMenuItem>
<ContextMenuItem
@click="
@@ -163,8 +146,7 @@
moveEntryLeft(contextData)
"
>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-left"></i>
{{ $t("Move") }}</a>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-left"></i> {{ $t("Move") }}</a>
</ContextMenuItem>
<ContextMenuItem
@click="
@@ -172,8 +154,7 @@
moveEntryRight(contextData)
"
>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-right"></i>
{{ $t("Move") }}</a>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-right"></i> {{ $t("Move") }}</a>
</ContextMenuItem>
<ContextMenuItem
@click="
@@ -189,8 +170,7 @@
addToShopping(contextData)
"
>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-shopping-cart"></i>
{{ $t("Add_to_Shopping") }}</a>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-shopping-cart"></i> {{ $t("Add_to_Shopping") }}</a>
</ContextMenuItem>
<ContextMenuItem
@click="
@@ -198,15 +178,12 @@
deleteEntry(contextData)
"
>
<a class="dropdown-item p-2 text-danger" href="javascript:void(0)"><i class="fas fa-trash"></i>
{{ $t("Delete") }}</a>
<a class="dropdown-item p-2 text-danger" href="javascript:void(0)"><i class="fas fa-trash"></i> {{ $t("Delete") }}</a>
</ContextMenuItem>
</template>
</ContextMenu>
<meal-plan-edit-modal
:entry="entryEditing"
:entryEditing_initial_recipe="entryEditing_initial_recipe"
:entry-editing_initial_meal_type="entryEditing_initial_meal_type"
:modal_title="modal_title"
:edit_modal_show="edit_modal_show"
@save-entry="editEntry"
@@ -230,10 +207,11 @@
<div class="col-12 mt-1" v-if="shopping_list.length > 0">
<b-button-group>
<b-button variant="success" @click="saveShoppingList"
><i class="fas fa-external-link-alt"></i>
><i class="fas fa-external-link-alt"></i>
{{ $t("Open") }}
</b-button>
<b-button variant="danger" @click="shopping_list = []"><i class="fa fa-trash"></i>
<b-button variant="danger" @click="shopping_list = []"
><i class="fa fa-trash"></i>
{{ $t("Clear") }}
</b-button>
</b-button-group>
@@ -243,46 +221,37 @@
</div>
</template>
<transition name="slide-fade">
<div class="row fixed-bottom p-2 b-1 border-top text-center" style="background: rgba(255, 255, 255, 0.6)"
v-if="current_tab === 0">
<div class="row fixed-bottom p-2 b-1 border-top text-center" style="background: rgba(255, 255, 255, 0.6)" v-if="current_tab === 0">
<div class="col-md-3 col-6">
<button class="btn btn-block btn-success shadow-none" @click="createEntryClick(new Date())"><i
class="fas fa-calendar-plus"></i> {{ $t("Create") }}
</button>
<button class="btn btn-block btn-success shadow-none" @click="createEntryClick(new Date())"><i class="fas fa-calendar-plus"></i> {{ $t("Create") }}</button>
</div>
<div class="col-md-3 col-6">
<button class="btn btn-block btn-primary shadow-none" v-b-toggle.sidebar-shopping><i
class="fas fa-shopping-cart"></i> {{ $t("Shopping_list") }}
</button>
<button class="btn btn-block btn-primary shadow-none" v-b-toggle.sidebar-shopping><i class="fas fa-shopping-cart"></i> {{ $t("Shopping_list") }}</button>
</div>
<div class="col-md-3 col-6">
<a class="btn btn-block btn-primary shadow-none" :href="iCalUrl"
><i class="fas fa-download"></i>
><i class="fas fa-download"></i>
{{ $t("Export_To_ICal") }}
</a>
</div>
<div class="col-md-3 col-6">
<button class="btn btn-block btn-primary shadow-none disabled" v-b-tooltip.focus.top
:title="$t('Coming_Soon')">
<button class="btn btn-block btn-primary shadow-none disabled" v-b-tooltip.focus.top :title="$t('Coming_Soon')">
{{ $t("Auto_Planner") }}
</button>
</div>
<div class="col-12 d-flex justify-content-center mt-2 d-block d-md-none">
<b-button-toolbar key-nav aria-label="Toolbar with button groups">
<b-button-group class="mx-1">
<b-button v-html="'<<'"
@click="setShowDate($refs.header.headerProps.previousPeriod)"></b-button>
<b-button v-html="'<<'" @click="setShowDate($refs.header.headerProps.previousPeriod)"></b-button>
<b-button v-html="'<'" @click="setStartingDay(-1)"></b-button>
</b-button-group>
<b-button-group class="mx-1">
<b-button @click="setShowDate($refs.header.headerProps.currentPeriod)"><i
class="fas fa-home"></i></b-button>
<b-button @click="setShowDate($refs.header.headerProps.currentPeriod)"><i class="fas fa-home"></i></b-button>
<b-form-datepicker button-only button-variant="secondary"></b-form-datepicker>
</b-button-group>
<b-button-group class="mx-1">
<b-button v-html="'>'" @click="setStartingDay(1)"></b-button>
<b-button v-html="'>>'"
@click="setShowDate($refs.header.headerProps.nextPeriod)"></b-button>
<b-button v-html="'>>'" @click="setShowDate($refs.header.headerProps.nextPeriod)"></b-button>
</b-button-group>
</b-button-toolbar>
</div>
@@ -293,7 +262,7 @@
<script>
import Vue from "vue"
import {BootstrapVue} from "bootstrap-vue"
import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import ContextMenu from "@/components/ContextMenu/ContextMenu"
@@ -307,11 +276,11 @@ import moment from "moment"
import draggable from "vuedraggable"
import VueCookies from "vue-cookies"
import {ApiMixin, StandardToasts, ResolveUrlMixin} from "@/utils/utils"
import {CalendarView, CalendarMathMixin} from "vue-simple-calendar/src/components/bundle"
import {ApiApiFactory} from "@/utils/openapi/api"
import { ApiMixin, StandardToasts, ResolveUrlMixin } from "@/utils/utils"
import { CalendarView, CalendarMathMixin } from "vue-simple-calendar/src/components/bundle"
import { ApiApiFactory } from "@/utils/openapi/api"
const {makeToast} = require("@/utils/utils")
const { makeToast } = require("@/utils/utils")
Vue.prototype.moment = moment
Vue.use(BootstrapVue)
@@ -349,12 +318,12 @@ export default {
current_context_menu_item: null,
options: {
displayPeriodUom: [
{text: this.$t("Week"), value: "week"},
{ text: this.$t("Week"), value: "week" },
{
text: this.$t("Month"),
value: "month",
},
{text: this.$t("Year"), value: "year"},
{ text: this.$t("Year"), value: "year" },
],
displayPeriodCount: [1, 2, 3],
entryEditing: {
@@ -385,20 +354,6 @@ export default {
return this.$t("Edit_Meal_Plan_Entry")
}
},
entryEditing_initial_recipe: function () {
if (this.entryEditing.recipe != null) {
return [this.entryEditing.recipe]
} else {
return []
}
},
entryEditing_initial_meal_type: function () {
if (this.entryEditing.meal_type != null) {
return [this.entryEditing.meal_type]
} else {
return []
}
},
plan_items: function () {
let items = []
this.plan_entries.forEach((entry) => {
@@ -412,7 +367,7 @@ export default {
dayNames: function () {
let options = []
this.getFormattedWeekdayNames(this.userLocale, "long", 0).forEach((day, index) => {
options.push({text: day, value: index})
options.push({ text: day, value: index })
})
return options
},
@@ -455,7 +410,7 @@ export default {
},
methods: {
openRecipe: function (recipe) {
window.open(this.resolveDjangoUrl('view_recipe', recipe.id))
window.open(this.resolveDjangoUrl("view_recipe", recipe.id))
},
addToShopping(entry) {
if (entry.originalItem.entry.recipe !== null) {
@@ -491,7 +446,7 @@ export default {
let apiClient = new ApiApiFactory()
apiClient
.createMealType({name: this.$t("Meal_Type")})
.createMealType({ name: this.$t("Meal_Type") })
.then((e) => {
this.periodChangedCallback(this.current_period)
})
@@ -879,7 +834,7 @@ having to override as much.
}
.ghost {
opacity: 0.5;
background: #c8ebfb;
opacity: 0.5;
background: #c8ebfb;
}
</style>

View File

@@ -18,7 +18,7 @@
<h3>
<!-- <span><b-button variant="link" size="sm" class="text-dark shadow-none"><i class="fas fa-chevron-down"></i></b-button></span> -->
<model-menu />
<span>{{ this.this_model.name }}</span>
<span>{{ $t(this.this_model.name) }}</span>
<span v-if="apiName !== 'Step'">
<b-button variant="link" @click="startAction({ action: 'new' })">
<i class="fas fa-plus-circle fa-2x"></i>

View File

@@ -430,7 +430,7 @@
v-if="!ingredient.is_header"
@click="ingredient.is_header = true">
<i class="fas fa-heading fa-fw"></i>
{{ $t("Make_header") }}
{{ $t("Make_Header") }}
</button>
<button type="button" class="dropdown-item"
@@ -736,8 +736,8 @@ export default {
}
this.recipe.servings = Math.floor(this.recipe.servings) // temporary fix until a proper framework for frontend input validation is established
if (this.recipe.servings === "" || isNaN(this.recipe.servings)) {
this.recipe.servings = 0
if (this.recipe.servings === "" || isNaN(this.recipe.servings) || this.recipe.servings===0 ) {
this.recipe.servings = 1
}
apiFactory
@@ -791,7 +791,7 @@ export default {
let empty_step = {
instruction: "",
ingredients: [],
show_as_header: true,
show_as_header: false,
time_visible: false,
ingredients_visible: true,
instruction_visible: true,

View File

@@ -1,6 +1,6 @@
<template>
<div id="app" style="margin-bottom: 4vh">
<RecipeSwitcher ref="ref_recipe_switcher"/>
<RecipeSwitcher ref="ref_recipe_switcher" />
<div class="row">
<div class="col-12 col-xl-8 col-lg-10 offset-xl-2 offset-lg-1">
<div class="row">
@@ -8,21 +8,15 @@
<div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-8 mt-3 mb-3">
<b-input-group>
<b-input
class="form-control form-control-lg form-control-borderless form-control-search"
v-model="search.search_input" v-bind:placeholder="$t('Search')"></b-input>
<b-input class="form-control form-control-lg form-control-borderless form-control-search" v-model="search.search_input" v-bind:placeholder="$t('Search')"></b-input>
<b-input-group-append>
<b-button v-b-tooltip.hover :title="$t('show_sql')" @click="showSQL()"
v-if="debug && ui.sql_debug">
<b-button v-b-tooltip.hover :title="$t('show_sql')" @click="showSQL()" v-if="debug && ui.sql_debug">
<i class="fas fa-bug" style="font-size: 1.5em"></i>
</b-button>
<b-button variant="light" v-b-tooltip.hover :title="$t('Random Recipes')"
@click="openRandom()">
<b-button variant="light" v-b-tooltip.hover :title="$t('Random Recipes')" @click="openRandom()">
<i class="fas fa-dice-five" style="font-size: 1.5em"></i>
</b-button>
<b-button v-b-toggle.collapse_advanced_search v-b-tooltip.hover
:title="$t('Advanced Settings')"
v-bind:variant="!searchFiltered(true) ? 'primary' : 'danger'">
<b-button v-b-toggle.collapse_advanced_search v-b-tooltip.hover :title="$t('Advanced Settings')" v-bind:variant="!searchFiltered(true) ? 'primary' : 'danger'">
<!-- TODO consider changing this icon to a filter -->
<i class="fas fa-caret-down" v-if="!search.advanced_search_visible"></i>
<i class="fas fa-caret-up" v-if="search.advanced_search_visible"></i>
@@ -32,18 +26,15 @@
</div>
</div>
<b-collapse id="collapse_advanced_search" class="mt-2 shadow-sm"
v-model="search.advanced_search_visible">
<b-collapse id="collapse_advanced_search" class="mt-2 shadow-sm" v-model="search.advanced_search_visible">
<div class="card">
<div class="card-body p-4">
<div class="row">
<div class="col-md-3">
<a class="btn btn-primary btn-block text-uppercase"
:href="resolveDjangoUrl('new_recipe')">{{ $t("New_Recipe") }}</a>
<a class="btn btn-primary btn-block text-uppercase" :href="resolveDjangoUrl('new_recipe')">{{ $t("New_Recipe") }}</a>
</div>
<div class="col-md-3">
<a class="btn btn-primary btn-block text-uppercase"
:href="resolveDjangoUrl('data_import_url')">{{ $t("Import") }}</a>
<a class="btn btn-primary btn-block text-uppercase" :href="resolveDjangoUrl('data_import_url')">{{ $t("Import") }}</a>
</div>
<div class="col-md-3">
<button
@@ -62,92 +53,57 @@
</div>
<div class="col-md-3">
<button id="id_settings_button"
class="btn btn-primary btn-block text-uppercase"><i
class="fas fa-cog fa-lg m-1"></i></button>
<button id="id_settings_button" class="btn btn-primary btn-block text-uppercase"><i class="fas fa-cog fa-lg m-1"></i></button>
</div>
</div>
<b-popover target="id_settings_button" triggers="click" placement="bottom">
<b-tabs content-class="mt-1" small>
<b-tab :title="$t('Settings')" active>
<b-form-group v-bind:label="$t('Recently_Viewed')"
label-for="popover-input-1" label-cols="6" class="mb-3">
<b-form-input type="number" v-model="ui.recently_viewed"
id="popover-input-1" size="sm"></b-form-input>
<b-form-group v-bind:label="$t('Recently_Viewed')" label-for="popover-input-1" label-cols="6" class="mb-3">
<b-form-input type="number" v-model="ui.recently_viewed" id="popover-input-1" size="sm"></b-form-input>
</b-form-group>
<b-form-group v-bind:label="$t('Recipes_per_page')"
label-for="popover-input-page-count" label-cols="6"
class="mb-3">
<b-form-input type="number" v-model="ui.page_size"
id="popover-input-page-count"
size="sm"></b-form-input>
<b-form-group v-bind:label="$t('Recipes_per_page')" label-for="popover-input-page-count" label-cols="6" class="mb-3">
<b-form-input type="number" v-model="ui.page_size" id="popover-input-page-count" size="sm"></b-form-input>
</b-form-group>
<b-form-group v-bind:label="$t('Meal_Plan')" label-for="popover-input-2"
label-cols="6" class="mb-3">
<b-form-checkbox switch v-model="ui.show_meal_plan"
id="popover-input-2" size="sm"></b-form-checkbox>
<b-form-group v-bind:label="$t('Meal_Plan')" label-for="popover-input-2" label-cols="6" class="mb-3">
<b-form-checkbox switch v-model="ui.show_meal_plan" id="popover-input-2" size="sm"></b-form-checkbox>
</b-form-group>
<b-form-group v-if="ui.show_meal_plan"
v-bind:label="$t('Meal_Plan_Days')"
label-for="popover-input-5" label-cols="6" class="mb-3">
<b-form-input type="number" v-model="ui.meal_plan_days"
id="popover-input-5" size="sm"></b-form-input>
<b-form-group v-if="ui.show_meal_plan" v-bind:label="$t('Meal_Plan_Days')" label-for="popover-input-5" label-cols="6" class="mb-3">
<b-form-input type="number" v-model="ui.meal_plan_days" id="popover-input-5" size="sm"></b-form-input>
</b-form-group>
<b-form-group v-bind:label="$t('Sort_by_new')"
label-for="popover-input-3" label-cols="6" class="mb-3">
<b-form-checkbox switch v-model="ui.sort_by_new"
id="popover-input-3" size="sm"></b-form-checkbox>
<b-form-group v-bind:label="$t('Sort_by_new')" label-for="popover-input-3" label-cols="6" class="mb-3">
<b-form-checkbox switch v-model="ui.sort_by_new" id="popover-input-3" size="sm"></b-form-checkbox>
</b-form-group>
<div class="row" style="margin-top: 1vh">
<div class="col-12">
<a :href="resolveDjangoUrl('view_settings') + '#search'">{{
$t("Search Settings")
}}</a>
<a :href="resolveDjangoUrl('view_settings') + '#search'">{{ $t("Search Settings") }}</a>
</div>
</div>
</b-tab>
<b-tab title="Expert Settings">
<b-form-group v-bind:label="$t('remember_search')"
label-for="popover-rem-search" label-cols="6"
class="mb-3">
<b-form-checkbox switch v-model="ui.remember_search"
id="popover-rem-search"
size="sm"></b-form-checkbox>
<b-form-group v-bind:label="$t('remember_search')" label-for="popover-rem-search" label-cols="6" class="mb-3">
<b-form-checkbox switch v-model="ui.remember_search" id="popover-rem-search" size="sm"></b-form-checkbox>
</b-form-group>
<b-form-group v-if="ui.remember_search"
v-bind:label="$t('remember_hours')"
label-for="popover-input-rem-hours" label-cols="6"
class="mb-3">
<b-form-input type="number" v-model="ui.remember_hours"
id="popover-rem-hours" size="sm"></b-form-input>
<b-form-group v-if="ui.remember_search" v-bind:label="$t('remember_hours')" label-for="popover-input-rem-hours" label-cols="6" class="mb-3">
<b-form-input type="number" v-model="ui.remember_hours" id="popover-rem-hours" size="sm"></b-form-input>
</b-form-group>
<b-form-group v-bind:label="$t('tree_select')"
label-for="popover-input-treeselect" label-cols="6"
class="mb-3">
<b-form-checkbox switch v-model="ui.tree_select"
id="popover-input-treeselect"
size="sm"></b-form-checkbox>
<b-form-group v-bind:label="$t('tree_select')" label-for="popover-input-treeselect" label-cols="6" class="mb-3">
<b-form-checkbox switch v-model="ui.tree_select" id="popover-input-treeselect" size="sm"></b-form-checkbox>
</b-form-group>
<b-form-group v-if="debug" v-bind:label="$t('sql_debug')"
label-for="popover-input-sqldebug" label-cols="6"
class="mb-3">
<b-form-checkbox switch v-model="ui.sql_debug"
id="popover-input-sqldebug"
size="sm"></b-form-checkbox>
<b-form-group v-if="debug" v-bind:label="$t('sql_debug')" label-for="popover-input-sqldebug" label-cols="6" class="mb-3">
<b-form-checkbox switch v-model="ui.sql_debug" id="popover-input-sqldebug" size="sm"></b-form-checkbox>
</b-form-group>
</b-tab>
</b-tabs>
<div class="row" style="margin-top: 1vh">
<div class="col-12" style="text-align: right">
<b-button size="sm" variant="secondary" style="margin-right: 8px"
@click="$root.$emit('bv::hide::popover')">{{ $t("Close") }}
</b-button>
<b-button size="sm" variant="secondary" style="margin-right: 8px" @click="$root.$emit('bv::hide::popover')">{{ $t("Close") }} </b-button>
</div>
</div>
</b-popover>
@@ -182,12 +138,8 @@
></generic-multiselect>
<b-input-group-append>
<b-input-group-text>
<b-form-checkbox v-model="search.search_keywords_or"
name="check-button"
@change="refreshData(false)"
class="shadow-none" switch>
<span class="text-uppercase"
v-if="search.search_keywords_or">{{ $t("or") }}</span>
<b-form-checkbox v-model="search.search_keywords_or" name="check-button" @change="refreshData(false)" class="shadow-none" switch>
<span class="text-uppercase" v-if="search.search_keywords_or">{{ $t("or") }}</span>
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
</b-form-checkbox>
</b-input-group-text>
@@ -209,7 +161,7 @@
:flat="true"
:auto-load-root-options="false"
searchNested
:placeholder="$t('Ingredients')"
:placeholder="$t('Foods')"
:normalizer="normalizer"
@input="refreshData(false)"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
@@ -226,13 +178,8 @@
></generic-multiselect>
<b-input-group-append>
<b-input-group-text>
<b-form-checkbox v-model="search.search_foods_or"
name="check-button"
@change="refreshData(false)"
class="shadow-none" switch>
<span class="text-uppercase" v-if="search.search_foods_or">{{
$t("or")
}}</span>
<b-form-checkbox v-model="search.search_foods_or" name="check-button" @change="refreshData(false)" class="shadow-none" switch>
<span class="text-uppercase" v-if="search.search_foods_or">{{ $t("or") }}</span>
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
</b-form-checkbox>
</b-input-group-text>
@@ -256,13 +203,8 @@
></generic-multiselect>
<b-input-group-append>
<b-input-group-text>
<b-form-checkbox v-model="search.search_books_or"
name="check-button"
@change="refreshData(false)"
class="shadow-none" tyle="width: 100%" switch>
<span class="text-uppercase" v-if="search.search_books_or">{{
$t("or")
}}</span>
<b-form-checkbox v-model="search.search_books_or" name="check-button" @change="refreshData(false)" class="shadow-none" tyle="width: 100%" switch>
<span class="text-uppercase" v-if="search.search_books_or">{{ $t("or") }}</span>
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
</b-form-checkbox>
</b-input-group-text>
@@ -299,9 +241,7 @@
<div class="row">
<div class="col col-md-12 text-right" style="margin-top: 2vh">
<span class="text-muted">
{{ $t("Page") }} {{ search.pagination_page }}/{{
Math.ceil(pagination_count / ui.page_size)
}}
{{ $t("Page") }} {{ search.pagination_page }}/{{ Math.ceil(pagination_count / ui.page_size) }}
<a href="#" @click="resetSearch"><i class="fas fa-times-circle"></i> {{ $t("Reset") }}</a>
</span>
</div>
@@ -309,24 +249,18 @@
<div class="row">
<div class="col col-md-12">
<div
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-gap: 0.8rem">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-gap: 0.8rem">
<template v-if="!searchFiltered()">
<recipe-card v-bind:key="`mp_${m.id}`" v-for="m in meal_plans" :recipe="m.recipe"
:meal_plan="m" :footer_text="m.meal_type_name"
footer_icon="far fa-calendar-alt"></recipe-card>
<recipe-card v-bind:key="`mp_${m.id}`" v-for="m in meal_plans" :recipe="m.recipe" :meal_plan="m" :footer_text="m.meal_type_name" footer_icon="far fa-calendar-alt"></recipe-card>
</template>
<recipe-card v-for="r in recipes" v-bind:key="r.id" :recipe="r"
:footer_text="isRecentOrNew(r)[0]"
:footer_icon="isRecentOrNew(r)[1]"></recipe-card>
<recipe-card v-for="r in recipes" v-bind:key="r.id" :recipe="r" :footer_text="isRecentOrNew(r)[0]" :footer_icon="isRecentOrNew(r)[1]"></recipe-card>
</div>
</div>
</div>
<div class="row" style="margin-top: 2vh" v-if="!random_search">
<div class="col col-md-12">
<b-pagination pills v-model="search.pagination_page" :total-rows="pagination_count"
:per-page="ui.page_size" @change="pageChange" align="center"></b-pagination>
<b-pagination pills v-model="search.pagination_page" :total-rows="pagination_count" :per-page="ui.page_size" @change="pageChange" align="center"></b-pagination>
</div>
</div>
</div>
@@ -337,26 +271,22 @@
<script>
import Vue from "vue"
import {BootstrapVue} from "bootstrap-vue"
import { BootstrapVue } from "bootstrap-vue"
import VueCookies from "vue-cookies"
import "bootstrap-vue/dist/bootstrap-vue.css"
import moment from "moment"
import _debounce from "lodash/debounce"
import VueCookies from "vue-cookies"
Vue.use(VueCookies)
import {ApiMixin, ResolveUrlMixin} from "@/utils/utils"
import { ApiMixin, ResolveUrlMixin } from "@/utils/utils"
import LoadingSpinner from "@/components/LoadingSpinner" // TODO: is this deprecated?
import RecipeCard from "@/components/RecipeCard"
import GenericMultiselect from "@/components/GenericMultiselect"
import {Treeselect, LOAD_CHILDREN_OPTIONS} from "@riophae/vue-treeselect" //TODO: delete
import { Treeselect, LOAD_CHILDREN_OPTIONS } from "@riophae/vue-treeselect" //TODO: delete
import "@riophae/vue-treeselect/dist/vue-treeselect.css" //TODO: delete
import RecipeSwitcher from "@/components/Buttons/RecipeSwitcher"
Vue.use(VueCookies)
Vue.use(BootstrapVue)
let SEARCH_COOKIE_NAME = "search_settings"
@@ -365,7 +295,7 @@ let UI_COOKIE_NAME = "_uisearch_settings"
export default {
name: "RecipeSearchView",
mixins: [ResolveUrlMixin, ApiMixin],
components: {GenericMultiselect, RecipeCard, Treeselect, RecipeSwitcher},
components: { GenericMultiselect, RecipeCard, Treeselect, RecipeSwitcher },
data() {
return {
// this.Models and this.Actions inherited from ApiMixin
@@ -416,12 +346,12 @@ export default {
}
}
return [
{id: 5, label: "⭐⭐⭐⭐⭐" + ratingCount(this.facets.Ratings?.["5.0"] ?? 0)},
{id: 4, label: "⭐⭐⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["4.0"] ?? 0)},
{id: 3, label: "⭐⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["3.0"] ?? 0)},
{id: 2, label: "⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["2.0"] ?? 0)},
{id: 1, label: "⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["1.0"] ?? 0)},
{id: 0, label: this.$t("Unrated") + ratingCount(this.facets.Ratings?.["0.0"] ?? 0)},
{ id: 5, label: "⭐⭐⭐⭐⭐" + ratingCount(this.facets.Ratings?.["5.0"] ?? 0) },
{ id: 4, label: "⭐⭐⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["4.0"] ?? 0) },
{ id: 3, label: "⭐⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["3.0"] ?? 0) },
{ id: 2, label: "⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["2.0"] ?? 0) },
{ id: 1, label: "⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["1.0"] ?? 0) },
{ id: 0, label: this.$t("Unrated") + ratingCount(this.facets.Ratings?.["0.0"] ?? 0) },
]
},
},
@@ -435,42 +365,43 @@ export default {
}
let urlParams = new URLSearchParams(window.location.search)
if (urlParams.has("keyword")) {
this.search.search_keywords = []
this.facets.Keywords = []
for (let x of urlParams.getAll("keyword")) {
let initial_keyword = {id: Number.parseInt(x), name: "loading..."}
let initial_keyword = { id: Number.parseInt(x), name: "loading..." }
this.search.search_keywords.push(initial_keyword)
this.genericAPI(this.Models.KEYWORD, this.Actions.FETCH, {id: initial_keyword.id}).then((response) => {
let kw_index = this.search.search_keywords.findIndex((k => k.id === initial_keyword.id))
this.$set(this.search.search_keywords, kw_index, response.data)
this.$set(this.facets.Keywords, kw_index, response.data)
}).catch((err) => {
if (err.response.status === 404) {
let kw_index = this.search.search_keywords.findIndex((k => k.id === initial_keyword.id))
this.search.search_keywords.splice(kw_index, 1)
this.facets.Keywords.splice(kw_index, 1)
this.refreshData(false)
}
})
this.genericAPI(this.Models.KEYWORD, this.Actions.FETCH, { id: initial_keyword.id })
.then((response) => {
let kw_index = this.search.search_keywords.findIndex((k) => k.id === initial_keyword.id)
this.$set(this.search.search_keywords, kw_index, response.data)
this.$set(this.facets.Keywords, kw_index, response.data)
})
.catch((err) => {
if (err.response.status === 404) {
let kw_index = this.search.search_keywords.findIndex((k) => k.id === initial_keyword.id)
this.search.search_keywords.splice(kw_index, 1)
this.facets.Keywords.splice(kw_index, 1)
this.refreshData(false)
}
})
}
}
this.facets.Foods = []
for (let x of this.search.search_foods) {
this.facets.Foods.push({id: x, name: "loading..."})
this.facets.Foods.push({ id: x, name: "loading..." })
}
this.facets.Keywords = []
for (let x of this.search.search_keywords) {
this.facets.Keywords.push({id: x, name: "loading..."})
this.facets.Keywords.push({ id: x, name: "loading..." })
}
this.facets.Books = []
for (let x of this.search.search_books) {
this.facets.Books.push({id: x, name: "loading..."})
this.facets.Books.push({ id: x, name: "loading..." })
}
this.loadMealPlan()
@@ -526,7 +457,7 @@ export default {
this.pagination_count = result.data.count
this.facets = result.data.facets
this.recipes = this.removeDuplicates(result.data.results, (recipe) => recipe.id)
this.recipes = [...this.removeDuplicates(result.data.results, (recipe) => recipe.id)]
if (!this.searchFiltered()) {
// if meal plans are being shown - filter out any meal plan recipes from the recipe list
let mealPlans = []
@@ -606,28 +537,27 @@ export default {
if (!this.ui.tree_select) {
return
}
let params = {hash: hash}
let params = { hash: hash }
if (facet) {
params[facet] = id
}
return this.genericGetAPI("api_get_facets", params).then((response) => {
this.facets = {...this.facets, ...response.data.facets}
this.facets = { ...this.facets, ...response.data.facets }
})
},
showSQL: function () {
let params = this.buildParams()
this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => {
})
this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => {})
},
// TODO refactor to combine with load KeywordChildren
loadFoodChildren({action, parentNode, callback}) {
loadFoodChildren({ action, parentNode, callback }) {
if (action === LOAD_CHILDREN_OPTIONS) {
if (this.facets?.cache_key) {
this.getFacets(this.facets.cache_key, "food", parentNode.id).then(callback())
}
}
},
loadKeywordChildren({action, parentNode, callback}) {
loadKeywordChildren({ action, parentNode, callback }) {
if (action === LOAD_CHILDREN_OPTIONS) {
if (this.facets?.cache_key) {
this.getFacets(this.facets.cache_key, "keyword", parentNode.id).then(callback())
@@ -657,7 +587,7 @@ export default {
pageSize: this.ui.page_size,
}
if (!this.searchFiltered()) {
params.options = {query: {last_viewed: this.ui.recently_viewed}}
params.options = { query: { last_viewed: this.ui.recently_viewed } }
}
return params
},

View File

@@ -65,15 +65,7 @@
<i class="fas fa-pizza-slice fa-2x text-primary"></i>
</div>
<div class="my-auto" style="padding-right: 4px">
<input
style="text-align: right; border-width: 0px; border: none; padding: 0px; padding-left: 0.5vw; padding-right: 8px; max-width: 80px"
value="1"
maxlength="3"
min="0"
type="number"
class="form-control form-control-lg"
v-model.number="servings"
/>
<CustomInputSpinButton v-model.number="servings" />
</div>
<div class="my-auto">
<span class="text-primary">
@@ -101,13 +93,14 @@
:servings="servings"
:header="true"
@checked-state-changed="updateIngredientCheckedState"
@change-servings="servings = $event"
/>
</div>
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-6 order-md-2">
<div class="row">
<div class="col-12">
<img class="img img-fluid rounded" :src="recipe.image" style="max-height: 30vh" :alt="$t('Recipe_Image')" v-if="recipe.image !== null" />
<img class="img img-fluid rounded" :src="recipe.image" style="max-height: 30vh" :alt="$t('Recipe_Image')" v-if="recipe.image !== null" @load="onImgLoad" />
</div>
</div>
@@ -174,6 +167,7 @@ import StepComponent from "@/components/StepComponent"
import KeywordsComponent from "@/components/KeywordsComponent"
import NutritionComponent from "@/components/NutritionComponent"
import RecipeSwitcher from "@/components/Buttons/RecipeSwitcher"
import CustomInputSpinButton from "@/components/CustomInputSpinButton"
Vue.prototype.moment = moment
@@ -195,6 +189,7 @@ export default {
LoadingSpinner,
AddRecipeToBook,
RecipeSwitcher,
CustomInputSpinButton,
},
computed: {
ingredient_factor: function () {
@@ -246,6 +241,9 @@ export default {
this.start_time = moment().format("yyyy-MM-DDTHH:mm")
}
if(recipe.image === null) this.printReady()
this.recipe = this.rootrecipe = recipe
this.servings = this.servings_cache[this.rootrecipe.id] = recipe.servings
this.loading = false
@@ -272,13 +270,20 @@ export default {
this.servings = this.servings_cache?.[e.id] ?? e.servings
}
},
printReady: function(){
const template = document.createElement("template");
template.id = "printReady";
document.body.appendChild(template);
},
onImgLoad: function(){
this.printReady()
},
},
}
</script>
<style>
#app > div > div{
#app > div > div {
break-inside: avoid;
}
}
</style>

View File

@@ -123,7 +123,7 @@
<div class="collapse" :id="'section-' + sectionID(x, i)" visible role="tabpanel" :class="{ show: x == 'false' }">
<!-- passing an array of values to the table grouped by Food -->
<transition-group name="slide-fade">
<div v-for="(entries, x) in Object.entries(s)" :key="x">
<div class="mx-4" v-for="(entries, x) in Object.entries(s)" :key="x">
<transition name="slide-fade" mode="out-in">
<ShoppingLineItem
:entries="entries[1]"
@@ -190,6 +190,9 @@
<td class="block-inline">
<b-form-input min="1" type="number" :debounce="300" :value="r.recipe_mealplan.servings" @input="updateServings($event, r.list_recipe)"></b-form-input>
</td>
<td>
<i class="btn text-primary far fa-eye fa-lg px-2 border-0" variant="link" :title="$t('view_recipe')" @click="editRecipeList($event, r)" />
</td>
<td>
<i class="btn text-danger fas fa-trash fa-lg px-2 border-0" variant="link" :title="$t('Delete')" @click="deleteRecipe($event, r.list_recipe)" />
</td>
@@ -401,14 +404,14 @@
</div>
<div v-if="settings.mealplan_autoadd_shopping">
<div class="row">
<div class="col col-md-6">{{ $t("mealplan_autoadd_shopping") }}</div>
<div class="col col-md-6">{{ $t("mealplan_autoexclude_onhand") }}</div>
<div class="col col-md-6 text-right">
<input type="checkbox" class="form-control settings-checkbox" v-model="settings.mealplan_autoexclude_onhand" @change="saveSettings" />
</div>
</div>
<div class="row sm mb-3">
<div class="col">
<em class="small text-muted">{{ $t("mealplan_autoadd_shopping_desc") }}</em>
<em class="small text-muted">{{ $t("mealplan_autoexclude_onhand_desc") }}</em>
</div>
</div>
</div>
@@ -432,7 +435,10 @@
<div class="col col-md-6 text-right">
<generic-multiselect
size="sm"
@change="settings.shopping_share = $event.valsaveSettings()"
@change="
settings.shopping_share = $event.val
saveSettings()
"
:model="Models.USER"
:initial_selection="settings.shopping_share"
label="username"
@@ -557,6 +563,26 @@
</div>
</b-tab>
</b-tabs>
<transition name="slided-fade">
<div class="row fixed-bottom p-2 b-1 border-top text-center d-flex d-md-none" style="background: rgba(255, 255, 255, 0.6)" v-if="current_tab === 0">
<div class="col-6">
<a class="btn btn-block btn-success shadow-none" @click="entrymode = !entrymode"
><i class="fas fa-cart-plus"></i>
{{ $t("New Entry") }}
</a>
</div>
<div class="col-6">
<b-dropdown id="dropdown-dropup" block dropup variant="primary" class="shadow-none">
<template #button-content> <i class="fas fa-download"></i> {{ $t("Export") }} </template>
<DownloadPDF dom="#shoppinglist" name="shopping.pdf" :label="$t('download_pdf')" icon="far fa-file-pdf" />
<DownloadCSV :items="csvData" :delim="settings.csv_delim" name="shopping.csv" :label="$t('download_csv')" icon="fas fa-file-csv" />
<CopyToClipboard :items="csvData" :settings="settings" :label="$t('copy_to_clipboard')" icon="fas fa-clipboard-list" />
<CopyToClipboard :items="csvData" :settings="settings" format="table" :label="$t('copy_markdown_table')" icon="fab fa-markdown" />
</b-dropdown>
</div>
</div>
</transition>
<b-popover target="id_filters_button" triggers="click" placement="bottomleft" :title="$t('Filters')">
<div>
<b-form-group v-bind:label="$t('GroupBy')" label-for="popover-input-1" label-cols="6" class="mb-1">
@@ -637,26 +663,7 @@
</ContextMenuItem>
</template>
</ContextMenu>
<transition name="slided-fade">
<div class="row fixed-bottom p-2 b-1 border-top text-center d-flex d-md-none" style="background: rgba(255, 255, 255, 0.6)" v-if="current_tab === 0">
<div class="col-6">
<a class="btn btn-block btn-success shadow-none" @click="entrymode = !entrymode"
><i class="fas fa-cart-plus"></i>
{{ $t("New Entry") }}
</a>
</div>
<div class="col-6">
<b-dropdown id="dropdown-dropup" block dropup variant="primary" class="shadow-none">
<template #button-content> <i class="fas fa-download"></i> {{ $t("Export") }} </template>
<DownloadPDF dom="#shoppinglist" name="shopping.pdf" :label="$t('download_pdf')" icon="far fa-file-pdf" />
<DownloadCSV :items="csvData" :delim="settings.csv_delim" name="shopping.csv" :label="$t('download_csv')" icon="fas fa-file-csv" />
<CopyToClipboard :items="csvData" :settings="settings" :label="$t('copy_to_clipboard')" icon="fas fa-clipboard-list" />
<CopyToClipboard :items="csvData" :settings="settings" format="table" :label="$t('copy_markdown_table')" icon="fab fa-markdown" />
</b-dropdown>
</div>
</div>
</transition>
<shopping-modal v-if="new_recipe.id" :recipe="new_recipe" :servings="parseInt(add_recipe_servings)" :modal_id="new_recipe.id" @finish="finishShopping" />
<shopping-modal v-if="new_recipe.id" :recipe="new_recipe" :servings="parseInt(add_recipe_servings)" :modal_id="new_recipe.id" @finish="finishShopping" :list_recipe="new_recipe.list_recipe" />
</div>
</template>
@@ -908,6 +915,7 @@ export default {
},
},
mounted() {
console.log(screen.height)
this.getShoppingList()
this.getSupermarkets()
this.getShoppingCategories()
@@ -922,8 +930,8 @@ export default {
this.$nextTick(function () {
if (this.$cookies.isKey(SETTINGS_COOKIE_NAME)) {
this.entry_mode_simple = this.$cookies.get(SETTINGS_COOKIE_NAME)
this.selected_supermarket = localStorage.getItem("shopping_v2_selected_supermarket") || undefined
}
this.selected_supermarket = localStorage.getItem("shopping_v2_selected_supermarket") || undefined
})
},
methods: {
@@ -1398,11 +1406,23 @@ export default {
window.removeEventListener("offline", this.updateOnlineStatus)
},
addRecipeToShopping() {
console.log(this.new_recipe)
this.$bvModal.show(`shopping_${this.new_recipe.id}`)
},
finishShopping() {
this.add_recipe_servings = 1
this.new_recipe = { id: undefined }
this.edit_recipe_list = undefined
this.getShoppingList()
},
editRecipeList(e, r) {
this.new_recipe = { id: r.recipe_mealplan.recipe, name: r.recipe_mealplan.recipe_name, servings: r.recipe_mealplan.servings, list_recipe: r.list_recipe }
this.$nextTick(function () {
this.$bvModal.show(`shopping_${this.new_recipe.id}`)
})
// this.$bvModal.show(`shopping_${this.new_recipe.id}`)
},
},
directives: {
hover: {
@@ -1464,14 +1484,25 @@ export default {
font-size: 20px;
}
@media (max-width: 768px) {
@media screen and (max-width: 768px) {
#shoppinglist {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow-y: scroll;
overflow-x: hidden;
height: 65vh;
height: 6vh;
padding-right: 8px !important;
}
}
@media screen and (min-height: 700px) and (max-width: 768px) {
#shoppinglist {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow-y: scroll;
overflow-x: hidden;
height: 72vh;
padding-right: 8px !important;
}
}

View File

@@ -0,0 +1,58 @@
<template>
<span><i class="mx-1 far fa-question-circle text-muted" @click="this_help.show = !this_help.show" /></span>
</template>
<script>
import Vue from "vue"
import VueCookies from "vue-cookies"
Vue.use(VueCookies)
let HELP_COOKIE_NAME = "help_settings"
export default {
name: "HelpBadge",
props: {
component: { type: String, required: true },
},
data() {
return {
help: {},
default: {
show: true,
},
this_help: undefined,
}
},
mounted() {
this.$nextTick(function () {
if (this.$cookies.isKey(HELP_COOKIE_NAME)) {
this.help = Object.assign({}, this.help, this.$cookies.get(HELP_COOKIE_NAME))
}
this.this_help = Object.assign({}, this.default, this.help?.[this.component])
})
},
watch: {
help: {
handler() {
this.$cookies.set(HELP_COOKIE_NAME, this.help)
},
deep: true,
},
this_help: {
handler() {
this.help[this.component] = Object.assign({}, this.this_help)
this.$cookies.set(HELP_COOKIE_NAME, this.help)
},
deep: true,
},
"this_help.show": function () {
if (this.this_help.show) {
this.$emit("show")
} else {
this.$emit("hide")
}
},
},
methods: {},
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<span>
<span v-if="!item.ignore_shopping">
<b-button class="btn text-decoration-none px-1 border-0" variant="link" :id="`shopping${item.id}`" @click="addShopping()">
<i
class="fas"

View File

@@ -5,21 +5,21 @@
<template #button-content>
<i class="fas fa-chevron-down"></i>
</template>
<b-dropdown-item :href="resolveDjangoUrl('list_food')"> <i class="fas fa-leaf fa-fw"></i> {{ Models["FOOD"].name }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_food')"> <i class="fas fa-leaf fa-fw"></i> {{ $t(Models["FOOD"].name) }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_keyword')"> <i class="fas fa-tags fa-fw"></i> {{ Models["KEYWORD"].name }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_keyword')"> <i class="fas fa-tags fa-fw"></i> {{ $t(Models["KEYWORD"].name) }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_unit')"> <i class="fas fa-balance-scale fa-fw"></i> {{ Models["UNIT"].name }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_unit')"> <i class="fas fa-balance-scale fa-fw"></i> {{ $t(Models["UNIT"].name) }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket')"> <i class="fas fa-store-alt fa-fw"></i> {{ Models["SUPERMARKET"].name }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket')"> <i class="fas fa-store-alt fa-fw"></i> {{ $t(Models["SUPERMARKET"].name) }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket_category')"> <i class="fas fa-cubes fa-fw"></i> {{ Models["SHOPPING_CATEGORY"].name }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket_category')"> <i class="fas fa-cubes fa-fw"></i> {{ $t(Models["SHOPPING_CATEGORY"].name) }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_automation')"> <i class="fas fa-robot fa-fw"></i> {{ Models["AUTOMATION"].name }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_automation')"> <i class="fas fa-robot fa-fw"></i> {{ $t(Models["AUTOMATION"].name) }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_user_file')"> <i class="fas fa-file fa-fw"></i> {{ Models["USERFILE"].name }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_user_file')"> <i class="fas fa-file fa-fw"></i> {{ $t(Models["USERFILE"].name) }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_step')"> <i class="fas fa-puzzle-piece fa-fw"></i>{{ Models["STEP"].name }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_step')"> <i class="fas fa-puzzle-piece fa-fw"></i>{{ $t(Models["STEP"].name) }} </b-dropdown-item>
</b-dropdown>
</span>
</template>

View File

@@ -0,0 +1,86 @@
// code taken from https://github.com/bootstrap-vue/bootstrap-vue/issues/4977#issuecomment-740215609 and modified
<template>
<b-input-group>
<b-input-group-prepend>
<b-button variant="outline-primary" class="py-0 px-2" size="sm" @click="valueChange(value - 1)">
<b-icon icon="dash" font-scale="1.6" />
</b-button>
</b-input-group-prepend>
<b-form-input
style="text-align: right; border-width: 0px; border: none; padding: 0px; padding-left: 0.5vw; padding-right: 8px; width: 50px"
variant="outline-primary"
:size="size"
:value="value"
type="number"
min="0"
class="border-secondary text-center"
number
@update="valueChange"
/>
<b-input-group-append>
<b-button variant="outline-primary" class="py-0 px-2" size="sm" @click="valueChange(value + 1)">
<b-icon icon="plus" font-scale="1.6" />
</b-button>
</b-input-group-append>
</b-input-group>
</template>
<script>
import { BIcon, BIconDash, BIconPlus } from 'bootstrap-vue'
export default {
name: 'CustomInputSpinButton',
components: {
BIcon,
/* eslint-disable vue/no-unused-components */
BIconDash,
BIconPlus
},
props: {
size: {
type: String,
required: false,
default: 'md',
validator: function (value) {
return ['sm', 'md', 'lg'].includes(value)
}
},
value: {
type: Number,
required: true
}
},
methods: {
valueChange (newValue) {
if (newValue <= 0) {
this.$emit('input', 0)
} else {
this.$emit('input', newValue)
}
}
}
}
</script>
<style scoped>
/* Remove up and down arrows inside number input */
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type=number] {
-moz-appearance: textfield;
}
</style>

View File

@@ -3,7 +3,7 @@
v-model="selected_objects"
:options="objects"
:close-on-select="true"
:clear-on-select="true"
:clear-on-select="multiple"
:hide-selected="multiple"
:preserve-search="true"
:internal-search="false"
@@ -35,7 +35,7 @@ export default {
// this.Models and this.Actions inherited from ApiMixin
loading: false,
objects: [],
selected_objects: [],
selected_objects: undefined,
}
},
props: {
@@ -48,7 +48,7 @@ export default {
},
label: { type: String, default: "name" },
parent_variable: { type: String, default: undefined },
limit: { type: Number, default: 10 },
limit: { type: Number, default: 25 },
sticky_options: {
type: Array,
default() {
@@ -61,6 +61,10 @@ export default {
return []
},
},
initial_single_selection: {
type: Object,
default: undefined,
},
multiple: { type: Boolean, default: true },
allow_create: { type: Boolean, default: false },
create_placeholder: { type: String, default: "You Forgot to Add a Tag Placeholder" },
@@ -71,18 +75,37 @@ export default {
// watch it
this.selected_objects = newVal
},
initial_single_selection: function (newVal, oldVal) {
// watch it
this.selected_objects = newVal
},
clear: function (newVal, oldVal) {
this.selected_objects = []
if (this.multiple || !this.initial_single_selection) {
this.selected_objects = []
} else {
this.selected_objects = undefined
}
},
},
mounted() {
this.search("")
this.selected_objects = this.initial_selection
if (this.multiple || !this.initial_single_selection) {
this.selected_objects = this.initial_selection
} else {
this.selected_objects = this.initial_single_selection
}
},
computed: {
lookupPlaceholder() {
return this.placeholder || this.model.name || this.$t("Search")
},
nothingSelected() {
if (this.multiple || !this.initial_single_selection) {
return this.selected_objects.length === 0 && this.initial_selection.length === 0
} else {
return !this.selected_objects && !this.initial_single_selection
}
},
},
methods: {
// this.genericAPI inherited from ApiMixin
@@ -95,8 +118,9 @@ export default {
}
this.genericAPI(this.model, this.Actions.LIST, options).then((result) => {
this.objects = this.sticky_options.concat(result.data?.results ?? result.data)
if (this.selected_objects.length === 0 && this.initial_selection.length === 0 && this.objects.length > 0) {
if (this.nothingSelected && this.objects.length > 0) {
this.objects.forEach((item) => {
// select default items when present in object
if ("default" in item) {
if (item.default) {
if (this.multiple) {
@@ -109,6 +133,7 @@ export default {
}
})
}
// this.removeMissingItems() # This removes items that are on another page of results
})
},
selectionChanged: function () {
@@ -121,6 +146,13 @@ export default {
this.search("")
}, 750)
},
// removeMissingItems: function () {
// if (this.multiple) {
// this.selected_objects = this.selected_objects.filter((x) => !this.objects.map((y) => y.id).includes(x))
// } else {
// this.selected_objects = this.objects.filter((x) => x.id === this.selected_objects.id)[0]
// }
// },
},
}
</script>

View File

@@ -7,7 +7,7 @@
</template>
<template v-else>
<td class="d-print-non" v-if="detailed && !add_shopping_mode" @click="done">
<td class="d-print-non" v-if="detailed && !show_shopping" @click="done">
<i class="far fa-check-circle text-success" v-if="ingredient.checked"></i>
<i class="far fa-check-circle text-primary" v-if="!ingredient.checked"></i>
</td>
@@ -34,19 +34,20 @@
</td>
<td v-else-if="show_shopping" class="text-right text-nowrap">
<b-button
v-if="!ingredient.food.ignore_shopping"
class="btn text-decoration-none fas fa-shopping-cart px-2 user-select-none"
variant="link"
v-b-popover.hover.click.blur.html.top="{ title: ShoppingPopover, variant: 'outline-dark' }"
:class="{
'text-success': shopping_status === true,
'text-muted': shopping_status === false,
'text-warning': shopping_status === null,
'text-success': ingredient.shopping_status === true,
'text-muted': ingredient.shopping_status === false,
'text-warning': ingredient.shopping_status === null,
}"
/>
<span class="px-2">
<span v-if="!ingredient.food.ignore_shopping" class="px-2">
<input type="checkbox" class="align-middle" v-model="shop" @change="changeShopping" />
</span>
<on-hand-badge :item="ingredient.food" />
<on-hand-badge v-if="!ingredient.food.ignore_shopping" :item="ingredient.food" />
</td>
</template>
</tr>
@@ -63,111 +64,49 @@ export default {
ingredient: Object,
ingredient_factor: { type: Number, default: 1 },
detailed: { type: Boolean, default: true },
recipe_list: { type: Number }, // ShoppingListRecipe ID, to filter ShoppingStatus
show_shopping: { type: Boolean, default: false },
add_shopping_mode: { type: Boolean, default: false },
shopping_list: {
type: Array,
default() {
return []
},
}, // list of unchecked ingredients in shopping list
},
mixins: [ResolveUrlMixin, ApiMixin],
data() {
return {
checked: false,
shopping_status: null, // in any shopping list: boolean + null=in shopping list, but not for this recipe
shopping_items: [],
shop: false, // in shopping list for this recipe: boolean
dirty: undefined,
}
},
watch: {
ShoppingListAndFilter: {
immediate: true,
handler(newVal, oldVal) {
// this whole sections is overly complicated
// trying to infer status of shopping for THIS recipe and THIS ingredient
// without know which recipe it is.
// If refactored:
// ## Needs to handle same recipe (multiple mealplans) being in shopping list multiple times
// ## Needs to handle same recipe being added as ShoppingListRecipe AND ingredients added from recipe as one-off
let filtered_list = this.shopping_list
// if a recipe list is provided, filter the shopping list
if (this.recipe_list) {
filtered_list = filtered_list.filter((x) => x.list_recipe == this.recipe_list)
}
// how many ShoppingListRecipes are there for this recipe?
let count_shopping_recipes = [...new Set(filtered_list.map((x) => x.list_recipe))].length
let count_shopping_ingredient = filtered_list.filter((x) => x.ingredient == this.ingredient.id).length
if (count_shopping_recipes >= 1) {
// This recipe is in the shopping list
this.shop = false // don't check any boxes until user selects a shopping list to edit
if (count_shopping_ingredient >= 1) {
this.shopping_status = true // ingredient is in the shopping list - probably (but not definitely, this ingredient)
} else if (this.ingredient?.food?.shopping) {
this.shopping_status = null // food is in the shopping list, just not for this ingredient/recipe
} else {
// food is not in any shopping list
this.shopping_status = false
}
} else {
// there are not recipes in the shopping list
// set default value
this.shop = !this.ingredient?.food?.food_onhand && !this.ingredient?.food?.recipe
this.$emit("add-to-shopping", { item: this.ingredient, add: this.shop })
// mark checked if the food is in the shopping list for this ingredient/recipe
if (count_shopping_ingredient >= 1) {
// ingredient is in this shopping list (not entirely sure how this could happen?)
this.shopping_status = true
} else if (count_shopping_ingredient == 0 && this.ingredient?.food?.shopping) {
// food is in the shopping list, just not for this ingredient/recipe
this.shopping_status = null
} else {
// the food is not in any shopping list
this.shopping_status = false
}
}
if (this.add_shopping_mode) {
// if we are in add shopping mode (e.g. recipe_shopping_modal) start with all checks marked
// except if on_hand (could be if recipe too?)
this.shop = !this.ingredient?.food?.food_onhand && !this.ingredient?.food?.recipe
}
},
ingredient: {
handler() {},
deep: true,
},
"ingredient.shop": function (newVal) {
this.shop = newVal
},
},
mounted() {},
mounted() {
this.shop = this.ingredient?.shop
},
computed: {
ShoppingListAndFilter() {
// hack to watch the shopping list and the recipe list at the same time
return this.shopping_list.map((x) => x.id).join(this.recipe_list)
},
ShoppingPopover() {
if (this.shopping_status == false) {
if (this.ingredient?.shopping_status == false) {
return this.$t("NotInShopping", { food: this.ingredient.food.name })
} else {
let list = this.shopping_list.filter((x) => x.food.id == this.ingredient.food.id)
let category = this.$t("Category") + ": " + this.ingredient?.food?.supermarket_category?.name ?? this.$t("Undefined")
let category = this.$t("Category") + ": " + this.ingredient?.category ?? this.$t("Undefined")
let popover = []
list.forEach((x) => {
;(this.ingredient?.shopping_list ?? []).forEach((x) => {
popover.push(
[
"<tr style='border-bottom: 1px solid #ccc'>",
"<td style='padding: 3px;'><em>",
x?.recipe_mealplan?.name ?? "",
x?.mealplan ?? "",
"</em></td>",
"<td style='padding: 3px;'>",
x?.amount ?? "",
"</td>",
"<td style='padding: 3px;'>",
x?.unit?.name ?? "" + "</td>",
x?.unit ?? "" + "</td>",
"<td style='padding: 3px;'>",
x?.food?.name ?? "",
x?.food ?? "",
"</td></tr>",
].join("")
)

View File

@@ -13,7 +13,7 @@
</h4>
</div>
</div>
<div class="row text-right" v-if="ShoppingRecipes.length > 1">
<div class="row text-right" v-if="ShoppingRecipes.length > 1 && !add_shopping_mode">
<div class="col col-md-6 offset-md-6 text-right">
<b-form-select v-model="selected_shoppingrecipe" :options="ShoppingRecipes" size="sm"></b-form-select>
</div>
@@ -31,14 +31,11 @@
</tr>
<template v-for="i in s.ingredients">
<ingredient-component
:ingredient="i"
:ingredient="prepareIngredient(i)"
:ingredient_factor="ingredient_factor"
:key="i.id"
:show_shopping="show_shopping"
:shopping_list="shopping_list"
:add_shopping_mode="add_shopping_mode"
:detailed="detailed"
:recipe_list="selected_shoppingrecipe"
@checked-state-changed="$emit('checked-state-changed', $event)"
@add-to-shopping="addShopping($event)"
/>
@@ -59,6 +56,7 @@ import "bootstrap-vue/dist/bootstrap-vue.css"
import IngredientComponent from "@/components/IngredientComponent"
import { ApiMixin, StandardToasts } from "@/utils/utils"
import ShoppingListViewVue from "../apps/ShoppingListView/ShoppingListView.vue"
Vue.use(BootstrapVue)
@@ -79,6 +77,7 @@ export default {
detailed: { type: Boolean, default: true },
header: { type: Boolean, default: false },
add_shopping_mode: { type: Boolean, default: false },
recipe_list: { type: Number, default: undefined },
},
data() {
return {
@@ -107,13 +106,14 @@ export default {
watch: {
ShoppingRecipes: function (newVal, oldVal) {
if (newVal.length === 0 || this.add_shopping_mode) {
this.selected_shoppingrecipe = undefined
this.selected_shoppingrecipe = this.recipe_list
} else if (newVal.length === 1) {
this.selected_shoppingrecipe = newVal[0].value
}
},
selected_shoppingrecipe: function (newVal, oldVal) {
this.update_shopping = this.shopping_list.filter((x) => x.list_recipe === newVal).map((x) => x.ingredient)
this.$emit("change-servings", this.ShoppingRecipes.filter((x) => x.value === this.selected_shoppingrecipe)[0].servings)
},
},
mounted() {
@@ -132,6 +132,7 @@ export default {
let ingredient_list = this.steps
.map((x) => x.ingredients)
.flat()
.filter((x) => (x.food !== null && x.food !== undefined))
.map((x) => x.food.id)
let params = {
@@ -140,13 +141,31 @@ export default {
}
this.genericAPI(this.Models.SHOPPING_LIST, this.Actions.LIST, params).then((result) => {
this.shopping_list = result.data
if (this.add_shopping_mode) {
if (this.recipe_list) {
this.$emit(
"starting-cart",
this.shopping_list.filter((x) => x.list_recipe === this.recipe_list).map((x) => x.ingredient)
)
} else {
this.$emit(
"starting-cart",
this.steps
.map((x) => x.ingredients)
.flat()
.filter((x) => x?.food?.food_onhand == false && x?.food?.ignore_shopping == false)
.map((x) => x.id)
)
}
}
})
}
},
saveShopping: function (del_shopping = false) {
let servings = this.servings
if (del_shopping) {
servings = 0
servings = -1
}
let params = {
id: this.recipe,
@@ -155,7 +174,7 @@ export default {
servings: servings,
}
this.genericAPI(this.Models.RECIPE, this.Actions.SHOPPING, params)
.then(() => {
.then((result) => {
if (del_shopping) {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
} else if (this.selected_shoppingrecipe) {
@@ -164,13 +183,6 @@ export default {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
}
})
.then(() => {
if (!this.add_shopping_mode) {
return this.getShopping(false)
} else {
this.$emit("shopping-added")
}
})
.catch((err) => {
if (del_shopping) {
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
@@ -186,13 +198,51 @@ export default {
// ALERT: this will all break if ingredients are re-used between recipes
if (e.add) {
this.update_shopping.push(e.item.id)
this.shopping_list.push({
id: Math.random(),
amount: e.item.amount,
ingredient: e.item.id,
food: e.item.food,
list_recipe: this.selected_shoppingrecipe,
})
} else {
this.update_shopping = this.update_shopping.filter((x) => x !== e.item.id)
this.update_shopping = [...this.update_shopping.filter((x) => x !== e.item.id)]
this.shopping_list = [...this.shopping_list.filter((x) => !(x.ingredient === e.item.id && x.list_recipe === this.selected_shoppingrecipe))]
}
if (this.add_shopping_mode) {
this.$emit("add-to-shopping", e)
}
},
prepareIngredient: function (i) {
let shopping = this.shopping_list.filter((x) => x.ingredient === i.id)
let selected_list = this.shopping_list.filter((x) => x.list_recipe === this.selected_shoppingrecipe && x.ingredient === i.id)
// checked = in the selected shopping list OR if in shoppping mode without a selected recipe, the default value true unless it is ignored or onhand
let checked = selected_list.length > 0 || (this.add_shopping_mode && !this.selected_shoppingrecipe && !i?.food?.ignore_recipe && !i?.food?.food_onhand)
let shopping_status = false // not in shopping list
if (shopping.length > 0) {
if (selected_list.length > 0) {
shopping_status = true // in shopping list for *this* recipe
} else {
shopping_status = null // in shopping list but not *this* recipe
}
}
return {
...i,
shop: checked,
shopping_status: shopping_status, // possible values: true, false, null
category: i.food?.supermarket_category?.name,
shopping_list: shopping.map((x) => {
return {
mealplan: x?.recipe_mealplan?.name,
amount: x.amount,
food: x.food?.name,
unit: x.unit?.name,
}
}),
}
},
},
}
</script>

View File

@@ -134,7 +134,7 @@ export default {
flex-flow: row nowrap;
min-height: 1.5em;
line-height: 1;
font-size: 1.5em;
font-size: 1em;
}
.period-span-1 {

View File

@@ -71,9 +71,7 @@ export default {
image_placeholder: window.IMAGE_PLACEHOLDER,
}
},
mounted() {
console.log(this.value)
},
mounted() {},
computed: {
entry: function () {
return this.value.originalItem

View File

@@ -25,7 +25,7 @@
<b-form-group>
<generic-multiselect
@change="selectRecipe"
:initial_selection="entryEditing_initial_recipe"
:initial_single_selection="entryEditing.recipe"
:label="'name'"
:model="Models.RECIPE"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
@@ -45,7 +45,7 @@
v-bind:placeholder="$t('Meal_Type')"
:limit="10"
:multiple="false"
:initial_selection="entryEditing_initial_meal_type"
:initial_single_selection="entryEditing.meal_type"
:allow_create="true"
:create_placeholder="$t('Create_New_Meal_Type')"
@new="createMealType"
@@ -76,12 +76,16 @@
<small tabindex="-1" class="form-text text-muted">{{ $t("Share") }}</small>
</b-form-group>
<b-input-group v-if="!autoMealPlan">
<b-form-checkbox id="AddToShopping" v-model="entryEditing.addshopping" />
<b-form-checkbox id="AddToShopping" v-model="mealplan_settings.addshopping" />
<small tabindex="-1" class="form-text text-muted">{{ $t("AddToShopping") }}</small>
</b-input-group>
<b-input-group v-if="mealplan_settings.addshopping">
<b-form-checkbox id="reviewShopping" v-model="mealplan_settings.reviewshopping" />
<small tabindex="-1" class="form-text text-muted">{{ $t("review_shopping") }}</small>
</b-input-group>
</div>
<div class="col-lg-6 d-none d-lg-block d-xl-block">
<recipe-card :recipe="entryEditing.recipe" v-if="entryEditing.recipe != null" :detailed="false"></recipe-card>
<recipe-card v-if="entryEditing.recipe" :recipe="entryEditing.recipe" :detailed="false"></recipe-card>
</div>
</div>
<div class="row mt-3 mb-3">
@@ -99,22 +103,22 @@
<script>
import Vue from "vue"
import VueCookies from "vue-cookies"
import { BootstrapVue } from "bootstrap-vue"
import GenericMultiselect from "@/components/GenericMultiselect"
import { ApiMixin, getUserPreference } from "@/utils/utils"
const { ApiApiFactory } = require("@/utils/openapi/api")
const { StandardToasts } = require("@/utils/utils")
Vue.use(BootstrapVue)
Vue.use(VueCookies)
let MEALPLAN_COOKIE_NAME = "mealplan_settings"
export default {
name: "MealPlanEditModal",
props: {
entry: Object,
entryEditing_initial_recipe: Array,
entryEditing_initial_meal_type: Array,
entryEditing_inital_servings: Number,
modal_title: String,
modal_id: {
@@ -137,18 +141,36 @@ export default {
missing_recipe: false,
missing_meal_type: false,
default_plan_share: [],
mealplan_settings: {
addshopping: false,
reviewshopping: false,
},
}
},
watch: {
entry: {
handler() {
this.entryEditing = Object.assign({}, this.entry)
if (this.entryEditing_inital_servings) {
this.entryEditing.servings = this.entryEditing_inital_servings
}
},
deep: true,
},
entryEditing: {
handler(newVal) {},
deep: true,
},
mealplan_settings: {
handler(newVal) {
this.$cookies.set(MEALPLAN_COOKIE_NAME, this.mealplan_settings)
},
deep: true,
},
entryEditing_inital_servings: function (newVal) {
this.entryEditing.servings = newVal
},
},
mounted: function () {},
computed: {
@@ -158,6 +180,9 @@ export default {
},
methods: {
showModal() {
if (this.$cookies.isKey(MEALPLAN_COOKIE_NAME)) {
this.mealplan_settings = Object.assign({}, this.mealplan_settings, this.$cookies.get(MEALPLAN_COOKIE_NAME))
}
let apiClient = new ApiApiFactory()
apiClient.listUserPreferences().then((result) => {
@@ -180,8 +205,10 @@ export default {
cancel = true
}
if (!cancel) {
console.log("saving", { ...this.mealplan_settings, ...this.entryEditing })
this.$bvModal.hide(`edit-modal`)
this.$emit("save-entry", this.entryEditing)
this.$emit("save-entry", { ...this.mealplan_settings, ...this.entryEditing })
console.log("after emit", { ...this.mealplan_settings, ...this.entryEditing }.addshopping)
}
},
deleteEntry() {

View File

@@ -1,34 +1,34 @@
<template>
<div>
<b-form-checkbox v-model="new_value">{{label}}</b-form-checkbox>
<b-form-checkbox v-model="new_value">{{ label }}</b-form-checkbox>
<em v-if="help" class="small text-muted">{{ help }}</em>
</div>
</template>
<script>
export default {
name: 'CheckboxInput',
props: {
field: {type: String, default: 'You Forgot To Set Field Name'},
label: {type: String, default: 'Checkbox Field'},
value: {type: Boolean, default: false},
show_move: {type: Boolean, default: false},
show_merge: {type: Boolean, default: false},
},
data() {
return {
new_value: undefined,
}
},
mounted() {
this.new_value = this.value
},
watch: {
'new_value': function () {
this.$root.$emit('change', this.field, this.new_value)
name: "CheckboxInput",
props: {
field: { type: String, default: "You Forgot To Set Field Name" },
label: { type: String, default: "Checkbox Field" },
value: { type: Boolean, default: false },
show_move: { type: Boolean, default: false },
show_merge: { type: Boolean, default: false },
help: { type: String, default: undefined },
},
},
methods: {
}
data() {
return {
new_value: undefined,
}
},
mounted() {
this.new_value = this.value
},
watch: {
new_value: function () {
this.$root.$emit("change", this.field, this.new_value)
},
},
methods: {},
}
</script>
</script>

View File

@@ -2,22 +2,26 @@
<div>
<b-modal :id="'modal_' + id" @hidden="cancelAction">
<template v-slot:modal-title>
<h4>{{ form.title }}</h4>
<h4 class="d-inline">{{ form.title }}</h4>
<help-badge v-if="form.show_help" @show="show_help = true" @hide="show_help = false" :component="`GenericModal${form.title}`" />
</template>
<div v-for="(f, i) in form.fields" v-bind:key="i">
<p v-if="visibleCondition(f, 'instruction')">{{ f.label }}</p>
<lookup-input v-if="visibleCondition(f, 'lookup')" :form="f" :model="listModel(f.list)" @change="storeValue" />
<checkbox-input class="mb-3" v-if="visibleCondition(f, 'checkbox')" :label="f.label" :value="f.value" :field="f.field" />
<text-input v-if="visibleCondition(f, 'text')" :label="f.label" :value="f.value" :field="f.field" :placeholder="f.placeholder" />
<lookup-input v-if="visibleCondition(f, 'lookup')" :form="f" :model="listModel(f.list)" @change="storeValue" :help="showHelp && f.help" />
<checkbox-input class="mb-3" v-if="visibleCondition(f, 'checkbox')" :label="f.label" :value="f.value" :field="f.field" :help="showHelp && f.help" />
<text-input v-if="visibleCondition(f, 'text')" :label="f.label" :value="f.value" :field="f.field" :placeholder="f.placeholder" :help="showHelp && f.help" :subtitle="f.subtitle" />
<choice-input v-if="visibleCondition(f, 'choice')" :label="f.label" :value="f.value" :field="f.field" :options="f.options" :placeholder="f.placeholder" />
<emoji-input v-if="visibleCondition(f, 'emoji')" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
<file-input v-if="visibleCondition(f, 'file')" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
<small-text v-if="visibleCondition(f, 'smalltext')" :value="f.value" />
</div>
<template v-slot:modal-footer>
<b-button class="float-right mx-1" variant="secondary" v-on:click="cancelAction">{{ $t("Cancel") }}</b-button>
<b-button class="float-right mx-1" variant="primary" v-on:click="doAction">{{ form.ok_label }}</b-button>
<div class="row w-100 justify-content-end">
<div class="col-auto">
<b-button class="mx-1" variant="secondary" v-on:click="cancelAction">{{ $t("Cancel") }}</b-button>
<b-button class="mx-1" variant="primary" v-on:click="doAction">{{ form.ok_label }}</b-button>
</div>
</div>
</template>
</b-modal>
</div>
@@ -31,7 +35,7 @@ import { getForm, formFunctions } from "@/utils/utils"
Vue.use(BootstrapVue)
import { ApiApiFactory } from "@/utils/openapi/api"
import { ApiMixin, StandardToasts, ToastMixin } from "@/utils/utils"
import { ApiMixin, StandardToasts, ToastMixin, getUserPreference } from "@/utils/utils"
import CheckboxInput from "@/components/Modals/CheckboxInput"
import LookupInput from "@/components/Modals/LookupInput"
import TextInput from "@/components/Modals/TextInput"
@@ -39,10 +43,11 @@ import EmojiInput from "@/components/Modals/EmojiInput"
import ChoiceInput from "@/components/Modals/ChoiceInput"
import FileInput from "@/components/Modals/FileInput"
import SmallText from "@/components/Modals/SmallText"
import HelpBadge from "@/components/Badges/Help"
export default {
name: "GenericModalForm",
components: { FileInput, CheckboxInput, LookupInput, TextInput, EmojiInput, ChoiceInput, SmallText },
components: { FileInput, CheckboxInput, LookupInput, TextInput, EmojiInput, ChoiceInput, SmallText, HelpBadge },
mixins: [ApiMixin, ToastMixin],
props: {
model: { required: true, type: Object },
@@ -73,6 +78,7 @@ export default {
form: {},
dirty: false,
special_handling: false,
show_help: true,
}
},
mounted() {
@@ -83,11 +89,19 @@ export default {
buttonLabel() {
return this.buttons[this.action].label
},
showHelp() {
if (this.show_help) {
return true
} else {
return undefined
}
},
},
watch: {
show: function () {
if (this.show) {
this.form = getForm(this.model, this.action, this.item1, this.item2)
if (this.form?.form_function) {
this.form = formFunctions[this.form.form_function](this.form)
}
@@ -256,15 +270,33 @@ export default {
let type_match = field?.type == field_type
let checks = true
if (type_match && field?.condition) {
if (field.condition?.condition === "exists") {
if ((this.item1[field.condition.field] != undefined) === field.condition.value) {
checks = true
} else {
checks = false
}
const value = this.item1[field?.condition?.field]
const preference = getUserPreference(field?.condition?.field)
console.log("condition", field?.condition?.condition)
switch (field?.condition?.condition) {
case "field_exists":
if ((value != undefined) === field.condition.value) {
checks = true
} else {
checks = false
}
break
case "preference__array_exists":
if (preference?.length > 0 === field.condition.value) {
checks = true
} else {
checks = false
}
break
case "preference_equals":
if (preference === field.condition.value) {
checks = true
} else {
checks = false
}
break
}
}
return type_match && checks
},
},

View File

@@ -19,6 +19,7 @@
@new="addNew"
>
</generic-multiselect>
<em v-if="help" class="small text-muted">{{ help }}</em>
</b-form-group>
</div>
</template>
@@ -47,6 +48,7 @@ export default {
class_list: { type: String, default: "mb-3" },
show_label: { type: Boolean, default: true },
clear: { type: Number },
help: { type: String, default: undefined },
},
data() {
return {

View File

@@ -1,6 +1,6 @@
<template>
<div>
<b-modal :id="`shopping_${this.modal_id}`" hide-footer @show="loadRecipe">
<b-modal :id="`shopping_${this.modal_id}`" @show="loadRecipe">
<template v-slot:modal-title
><h4>{{ $t("Add_Servings_to_Shopping", { servings: recipe_servings }) }}</h4></template
>
@@ -16,10 +16,11 @@
:recipe="recipe.id"
:ingredient_factor="ingredient_factor"
:servings="recipe_servings"
:show_shopping="true"
:add_shopping_mode="true"
:recipe_list="list_recipe"
:header="false"
@add-to-shopping="addShopping($event)"
@starting-cart="add_shopping = $event"
/>
</b-collapse>
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
@@ -34,10 +35,11 @@
:recipe="r.recipe.id"
:ingredient_factor="ingredient_factor"
:servings="recipe_servings"
:show_shopping="true"
:add_shopping_mode="true"
:recipe_list="list_recipe"
:header="false"
@add-to-shopping="addShopping($event)"
@starting-cart="add_shopping = [...add_shopping, ...$event]"
/>
</b-collapse>
</b-card>
@@ -46,18 +48,20 @@
</b-card>
</div>
<b-input-group class="my-3">
<b-input-group-prepend is-text>
{{ $t("Servings") }}
</b-input-group-prepend>
<template #modal-footer="">
<b-input-group class="mr-3">
<b-input-group-prepend is-text>
{{ $t("Servings") }}
</b-input-group-prepend>
<b-form-spinbutton min="1" v-model="recipe_servings" inline style="height: 3em"></b-form-spinbutton>
<b-form-spinbutton min="1" v-model="recipe_servings" inline style="height: 3em"></b-form-spinbutton>
<b-input-group-append>
<b-button variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t("Cancel") }} </b-button>
<b-button variant="success" @click="saveShopping">{{ $t("Save") }} </b-button>
</b-input-group-append>
</b-input-group>
<b-input-group-append>
<b-button variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t("Cancel") }} </b-button>
<b-button variant="success" @click="saveShopping">{{ $t("Save") }} </b-button>
</b-input-group-append>
</b-input-group>
</template>
</b-modal>
</div>
</template>
@@ -80,6 +84,8 @@ export default {
recipe: { required: true, type: Object },
servings: { type: Number, default: undefined },
modal_id: { required: true, type: Number },
mealplan: { type: Number, default: undefined },
list_recipe: { type: Number, default: undefined },
},
data() {
return {
@@ -120,14 +126,6 @@ export default {
this.steps = result.data.steps
// ALERT: this will all break if ingredients are re-used between recipes
// ALERT: this also doesn't quite work right if the same recipe appears multiple time in the related recipes
this.add_shopping = [
...this.add_shopping,
...this.steps
.map((x) => x.ingredients)
.flat()
.filter((x) => !x?.food?.food_onhand)
.map((x) => x.id),
]
if (!this.recipe_servings) {
this.recipe_servings = result.data?.servings
}
@@ -154,18 +152,20 @@ export default {
})
return Promise.all(promises)
})
.then(() => {
this.add_shopping = [
...this.add_shopping,
...this.related_recipes
.map((x) => x.steps)
.flat()
.map((x) => x.ingredients)
.flat()
.filter((x) => !x.food.override_ignore)
.map((x) => x.id),
]
})
// .then(() => {
// if (!this.list_recipe) {
// this.add_shopping = [
// ...this.add_shopping,
// ...this.related_recipes
// .map((x) => x.steps)
// .flat()
// .map((x) => x.ingredients)
// .flat()
// .filter((x) => !x.food.override_ignore)
// .map((x) => x.id),
// ]
// }
// })
})
},
addShopping: function (e) {
@@ -181,6 +181,8 @@ export default {
id: this.recipe.id,
ingredients: this.add_shopping,
servings: this.recipe_servings,
mealplan: this.mealplan,
list_recipe: this.list_recipe,
}
let apiClient = new ApiApiFactory()
apiClient

View File

@@ -2,6 +2,8 @@
<div>
<b-form-group v-bind:label="label" class="mb-3">
<b-form-input v-model="new_value" type="text" :placeholder="placeholder"></b-form-input>
<em v-if="help" class="small text-muted">{{ help }}</em>
<small v-if="subtitle" class="text-muted">{{ subtitle }}</small>
</b-form-group>
</div>
</template>
@@ -14,7 +16,8 @@ export default {
label: { type: String, default: "Text Field" },
value: { type: String, default: "" },
placeholder: { type: String, default: "You Should Add Placeholder Text" },
show_merge: { type: Boolean, default: false },
help: { type: String, default: undefined },
subtitle: { type: String, default: undefined },
},
data() {
return {

View File

@@ -1,102 +1,81 @@
<template>
<div>
<div class="dropdown d-print-none">
<a class="btn shadow-none" href="javascript:void(0);" role="button" id="dropdownMenuLink"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<a class="btn shadow-none" href="javascript:void(0);" role="button" id="dropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-ellipsis-v fa-lg"></i>
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuLink">
<a class="dropdown-item" :href="resolveDjangoUrl('edit_recipe', recipe.id)"><i
class="fas fa-pencil-alt fa-fw"></i> {{ $t("Edit") }}</a>
<a class="dropdown-item" :href="resolveDjangoUrl('edit_recipe', recipe.id)"><i class="fas fa-pencil-alt fa-fw"></i> {{ $t("Edit") }}</a>
<a class="dropdown-item" :href="resolveDjangoUrl('edit_convert_recipe', recipe.id)"
v-if="!recipe.internal"><i class="fas fa-exchange-alt fa-fw"></i> {{ $t("convert_internal") }}</a>
<a class="dropdown-item" :href="resolveDjangoUrl('edit_convert_recipe', recipe.id)" v-if="!recipe.internal"><i class="fas fa-exchange-alt fa-fw"></i> {{ $t("convert_internal") }}</a>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="$bvModal.show(`id_modal_add_book_${modal_id}`)"><i
class="fas fa-bookmark fa-fw"></i> {{ $t("Manage_Books") }}
</button>
<button class="dropdown-item" @click="$bvModal.show(`id_modal_add_book_${modal_id}`)"><i class="fas fa-bookmark fa-fw"></i> {{ $t("Manage_Books") }}</button>
</a>
<a class="dropdown-item"
:href="`${resolveDjangoUrl('view_shopping')}?r=[${recipe.id},${servings_value}]`"
v-if="recipe.internal" target="_blank" rel="noopener noreferrer">
<a class="dropdown-item" :href="`${resolveDjangoUrl('view_shopping')}?r=[${recipe.id},${servings_value}]`" v-if="recipe.internal" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shopping-cart fa-fw"></i> {{ $t("Add_to_Shopping") }}
</a>
<a class="dropdown-item" v-if="recipe.internal" @click="addToShopping" href="#"> <i
class="fas fa-shopping-cart fa-fw"></i> {{ $t("create_shopping_new") }} </a>
<a class="dropdown-item" v-if="recipe.internal" @click="addToShopping" href="#"> <i class="fas fa-shopping-cart fa-fw"></i> {{ $t("create_shopping_new") }} </a>
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i
class="fas fa-calendar fa-fw"></i> {{ $t("Add_to_Plan") }} </a>
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i class="fas fa-calendar fa-fw"></i> {{ $t("Add_to_Plan") }} </a>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="$bvModal.show(`id_modal_cook_log_${modal_id}`)"><i
class="fas fa-clipboard-list fa-fw"></i> {{ $t("Log_Cooking") }}
</button>
<button class="dropdown-item" @click="$bvModal.show(`id_modal_cook_log_${modal_id}`)"><i class="fas fa-clipboard-list fa-fw"></i> {{ $t("Log_Cooking") }}</button>
</a>
<a href="javascript:void(0);">
<button class="dropdown-item" onclick="window.print()"><i class="fas fa-print fa-fw"></i>
<button class="dropdown-item" onclick="window.print()">
<i class="fas fa-print fa-fw"></i>
{{ $t("Print") }}
</button>
</a>
<a class="dropdown-item" :href="resolveDjangoUrl('view_export') + '?r=' + recipe.id" target="_blank"
rel="noopener noreferrer"><i class="fas fa-file-export fa-fw"></i> {{ $t("Export") }}</a>
<a class="dropdown-item" :href="resolveDjangoUrl('view_export') + '?r=' + recipe.id" target="_blank" rel="noopener noreferrer"><i class="fas fa-file-export fa-fw"></i> {{ $t("Export") }}</a>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="pinRecipe()"><i class="fas fa-thumbtack fa-fw"></i>
<button class="dropdown-item" @click="pinRecipe()">
<i class="fas fa-thumbtack fa-fw"></i>
{{ $t("Pin") }}
</button>
</a>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="createShareLink()" v-if="recipe.internal"><i
class="fas fa-share-alt fa-fw"></i> {{ $t("Share") }}
</button>
<button class="dropdown-item" @click="createShareLink()" v-if="recipe.internal"><i class="fas fa-share-alt fa-fw"></i> {{ $t("Share") }}</button>
</a>
</div>
</div>
<cook-log :recipe="recipe" :modal_id="modal_id"></cook-log>
<add-recipe-to-book :recipe="recipe" :modal_id="modal_id"></add-recipe-to-book>
<add-recipe-to-book :recipe="recipe" :modal_id="modal_id" :entryEditing_inital_servings="servings_value"></add-recipe-to-book>
<shopping-modal :recipe="recipe" :servings="servings_value" :modal_id="modal_id" :mealplan="undefined" />
<b-modal :id="`modal-share-link_${modal_id}`" v-bind:title="$t('Share')" hide-footer>
<div class="row">
<div class="col col-md-12">
<label v-if="recipe_share_link !== undefined">{{ $t("Public share link") }}</label>
<input ref="share_link_ref" class="form-control" v-model="recipe_share_link"/>
<b-button class="mt-2 mb-3 d-none d-md-inline" variant="secondary"
@click="$bvModal.hide(`modal-share-link_${modal_id}`)">{{ $t("Close") }}
</b-button>
<b-button class="mt-2 mb-3 ml-md-2" variant="primary" @click="copyShareLink()">{{
$t("Copy")
}}
</b-button>
<b-button class="mt-2 mb-3 ml-2 float-right" variant="success" @click="shareIntend()">{{
$t("Share")
}} <i class="fa fa-share-alt"></i></b-button>
<input ref="share_link_ref" class="form-control" v-model="recipe_share_link" />
<b-button class="mt-2 mb-3 d-none d-md-inline" variant="secondary" @click="$bvModal.hide(`modal-share-link_${modal_id}`)">{{ $t("Close") }} </b-button>
<b-button class="mt-2 mb-3 ml-md-2" variant="primary" @click="copyShareLink()">{{ $t("Copy") }} </b-button>
<b-button class="mt-2 mb-3 ml-2 float-right" variant="success" @click="shareIntend()">{{ $t("Share") }} <i class="fa fa-share-alt"></i></b-button>
</div>
</div>
</b-modal>
<meal-plan-edit-modal
:entry="entryEditing"
:entryEditing_initial_recipe="[recipe]"
:entryEditing_inital_servings="recipe.servings"
:entry-editing_initial_meal_type="[]"
:entryEditing_inital_servings="servings_value"
@save-entry="saveMealPlan"
:modal_id="`modal-meal-plan_${modal_id}`"
:allow_delete="false"
:modal_title="$t('Create_Meal_Plan_Entry')"
></meal-plan-edit-modal>
<shopping-modal :recipe="recipe" :servings="servings_value" :modal_id="modal_id"/>
</div>
</template>
<script>
import {makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts} from "@/utils/utils"
import { makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts } from "@/utils/utils"
import CookLog from "@/components/CookLog"
import axios from "axios"
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"
@@ -104,7 +83,7 @@ import MealPlanEditModal from "@/components/MealPlanEditModal"
import ShoppingModal from "@/components/Modals/ShoppingModal"
import moment from "moment"
import Vue from "vue"
import {ApiApiFactory} from "@/utils/openapi/api"
import { ApiApiFactory } from "@/utils/openapi/api"
Vue.prototype.moment = moment
@@ -137,6 +116,7 @@ export default {
},
},
entryEditing: {},
mealplan: undefined,
}
},
props: {
@@ -149,20 +129,36 @@ export default {
mounted() {
this.servings_value = this.servings === -1 ? this.recipe.servings : this.servings
},
watch: {
recipe: {
handler() {},
deep: true,
},
servings: function (newVal) {
this.servings_value = parseInt(newVal)
},
},
methods: {
pinRecipe: function () {
let pinnedRecipes = JSON.parse(localStorage.getItem('pinned_recipes')) || []
pinnedRecipes.push({id: this.recipe.id, name: this.recipe.name})
localStorage.setItem('pinned_recipes', JSON.stringify(pinnedRecipes))
let pinnedRecipes = JSON.parse(localStorage.getItem("pinned_recipes")) || []
pinnedRecipes.push({ id: this.recipe.id, name: this.recipe.name })
localStorage.setItem("pinned_recipes", JSON.stringify(pinnedRecipes))
},
saveMealPlan: function (entry) {
entry.date = moment(entry.date).format("YYYY-MM-DD")
let reviewshopping = entry.addshopping && entry.reviewshopping
entry.addshopping = entry.addshopping && !entry.reviewshopping
let apiClient = new ApiApiFactory()
apiClient
.createMealPlan(entry)
.then((result) => {
this.$bvModal.hide(`modal-meal-plan_${this.modal_id}`)
if (reviewshopping) {
this.mealplan = result.data.id
this.servings_value = result.data.servings
this.addToShopping()
}
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
.catch((error) => {
@@ -173,7 +169,9 @@ export default {
this.entryEditing = this.options.entryEditing
this.entryEditing.recipe = this.recipe
this.entryEditing.date = moment(new Date()).format("YYYY-MM-DD")
this.$bvModal.show(`modal-meal-plan_${this.modal_id}`)
this.$nextTick(function () {
this.$bvModal.show(`modal-meal-plan_${this.modal_id}`)
})
},
createShareLink: function () {
axios

View File

@@ -1,334 +1,311 @@
<template>
<div id="shopping_line_item">
<b-row align-h="start">
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0"
v-if="settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i>
</div>
</b-button>
</b-col>
<b-col cols="1" class="align-items-center d-flex">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true"
@click.stop="$emit('open-context-menu', $event, entries)">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
:class="settings.left_handed ? 'dropdown-spacing' : ''"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret">
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
</b-col>
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
</b-col>
<b-col cols="8" md="9">
<b-row class="d-flex h-100">
<b-col cols="5" md="3" class="d-flex align-items-center" v-if="Object.entries(formatAmount).length == 1">
<strong class="mr-1">{{ Object.entries(formatAmount)[0][1] }}</strong> {{
Object.entries(formatAmount)[0][0]
}}
</b-col>
<b-col cols="5" md="3" class="d-flex flex-column" v-if="Object.entries(formatAmount).length != 1">
<div class="small" v-for="(x, i) in Object.entries(formatAmount)" :key="i">{{ x[1] }} &ensp;
{{ x[0] }}
</div>
</b-col>
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
{{ formatFood }}
</b-col>
<b-col cols="3" data-html2canvas-ignore="true"
class="align-items-center d-none d-md-flex justify-content-end">
<b-button size="sm" @click="showDetails = !showDetails" class="p-0 mr-0 mr-md-2 p-md-2 text-decoration-none"
variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate"
:class="showDetails ? 'rotated' : ''"></i> <span
class="d-none d-md-inline-block">{{ $t('Details') }}</span>
</div>
</b-button>
</b-col>
</b-row>
</b-col>
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none"
v-if="!settings.left_handed">
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i>
</div>
</b-button>
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
</b-col>
</b-row>
<b-row align-h="center" class="d-none d-md-flex">
<b-col cols="12">
<div class="small text-muted text-truncate">{{ formatHint }}</div>
</b-col>
</b-row>
<!-- detail rows -->
<div class="card no-body mb-1 pt-2 align-content-center shadow-sm" v-if="showDetails">
<div v-for="(e, x) in entries" :key="e.id">
<b-row class="small justify-content-around">
<b-col cols="auto" md="4" class="overflow-hidden text-nowrap">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn btn-link btn-sm m-0 p-0 pl-2"
style="text-overflow: ellipsis"
@click.stop="openRecipeCard($event, e)"
@mouseover="openRecipeCard($event, e)">
{{ formatOneRecipe(e) }}
</button>
</b-col>
<b-col cols="auto" md="4" class="text-muted">{{ formatOneMealPlan(e) }}</b-col>
<b-col cols="auto" md="4" class="text-muted text-right overflow-hidden text-nowrap pr-4">
{{ formatOneCreatedBy(e) }}
<div v-if="formatOneCompletedAt(e)">{{ formatOneCompletedAt(e) }}</div>
</b-col>
</b-row>
<div id="shopping_line_item">
<b-row align-h="start">
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0"
v-if="settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
:checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
</b-col>
<b-col cols="1" class="align-items-center d-flex">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true"
@click.stop="$emit('open-context-menu', $event, e)">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
:class="settings.left_handed ? 'dropdown-spacing' : ''"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret">
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
</b-col>
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
</b-col>
<b-col cols="8" md="9">
<b-row class="d-flex align-items-center h-100">
<b-col cols="5" md="3" class="d-flex align-items-center">
<strong class="mr-1">{{ formatOneAmount(e) }}</strong> {{ formatOneUnit(e) }}
</b-col>
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
{{ formatOneFood(e) }}
</b-col>
<b-col cols="12" class="d-flex d-md-none">
<div class="small text-muted text-truncate" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div>
</b-col>
</b-row>
</b-col>
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none"
v-if="!settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
:checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
</b-col>
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0" v-if="settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i></div>
</b-button>
</b-col>
<b-col cols="1" class="align-items-center d-flex">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true" @click.stop="$emit('open-context-menu', $event, entries)">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
:class="settings.left_handed ? 'dropdown-spacing' : ''"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
>
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
</b-col>
<b-col cols="1" class="px-1 justify-content-center align-items-center d-none d-md-flex">
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
</b-col>
<b-col cols="8" md="9">
<b-row class="d-flex h-100">
<b-col cols="5" md="3" class="d-flex align-items-center" v-if="Object.entries(formatAmount).length == 1">
<strong class="mr-1">{{ Object.entries(formatAmount)[0][1] }}</strong> {{ Object.entries(formatAmount)[0][0] }}
</b-col>
<b-col cols="5" md="3" class="d-flex flex-column" v-if="Object.entries(formatAmount).length != 1">
<div class="small" v-for="(x, i) in Object.entries(formatAmount)" :key="i">
{{ x[1] }} &ensp;
{{ x[0] }}
</div>
</b-col>
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
{{ formatFood }}
</b-col>
<b-col cols="3" data-html2canvas-ignore="true" class="align-items-center d-none d-md-flex justify-content-end">
<b-button size="sm" @click="showDetails = !showDetails" class="p-0 mr-0 mr-md-2 p-md-2 text-decoration-none" variant="link">
<div class="text-nowrap">
<i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i> <span class="d-none d-md-inline-block">{{ $t("Details") }}</span>
</div>
</b-button>
</b-col>
</b-row>
</b-col>
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none" v-if="!settings.left_handed">
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i></div>
</b-button>
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
</b-col>
</b-row>
<hr class="w-75" v-if="x !== entries.length -1"/>
<div class="pb-4" v-if="x === entries.length -1"></div>
</div>
</div>
<hr class="m-1" v-if="!showDetails"/>
<ContextMenu ref="recipe_card" triggers="click, hover" :title="$t('Filters')" style="max-width: 300">
<template #menu="{ contextData }" v-if="recipe">
<ContextMenuItem>
<RecipeCard :recipe="contextData" :detail="false"></RecipeCard>
</ContextMenuItem>
<ContextMenuItem @click="$refs.menu.close()">
<b-form-group label-cols="9" content-cols="3" class="text-nowrap m-0 mr-2">
<template #label>
<a class="dropdown-item p-2" href="#"><i class="fas fa-pizza-slice"></i> {{ $t("Servings") }}</a>
</template>
<div @click.prevent.stop>
<b-form-input class="mt-2" min="0" type="number" v-model="servings"></b-form-input>
<b-row align-h="center" class="d-none d-md-flex">
<b-col cols="12">
<div class="small text-muted text-truncate">{{ formatHint }}</div>
</b-col>
</b-row>
<!-- detail rows -->
<div class="card no-body mb-1 pt-2 align-content-center shadow-sm" v-if="showDetails">
<div v-for="(e, x) in entries" :key="e.id">
<b-row class="small justify-content-around">
<b-col cols="auto" md="4" class="overflow-hidden text-nowrap">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn btn-link btn-sm m-0 p-0 pl-2"
style="text-overflow: ellipsis"
@click.stop="openRecipeCard($event, e)"
@mouseover="openRecipeCard($event, e)"
>
{{ formatOneRecipe(e) }}
</button>
</b-col>
<b-col cols="auto" md="4" class="text-muted">{{ formatOneMealPlan(e) }}</b-col>
<b-col cols="auto" md="4" class="text-muted text-right overflow-hidden text-nowrap pr-4">
{{ formatOneCreatedBy(e) }}
<div v-if="formatOneCompletedAt(e)">{{ formatOneCompletedAt(e) }}</div>
</b-col>
</b-row>
<b-row align-h="start">
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0" v-if="settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
</b-col>
<b-col cols="1" class="align-items-center d-flex">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true" @click.stop="$emit('open-context-menu', $event, e)">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
:class="settings.left_handed ? 'dropdown-spacing' : ''"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
>
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
</b-col>
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
</b-col>
<b-col cols="8" md="9">
<b-row class="d-flex align-items-center h-100">
<b-col cols="5" md="3" class="d-flex align-items-center">
<strong class="mr-1">{{ formatOneAmount(e) }}</strong> {{ formatOneUnit(e) }}
</b-col>
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
{{ formatOneFood(e) }}
</b-col>
<b-col cols="12" class="d-flex d-md-none">
<div class="small text-muted text-truncate" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div>
</b-col>
</b-row>
</b-col>
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none" v-if="!settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
</b-col>
</b-row>
<hr class="w-75" v-if="x !== entries.length - 1" />
<div class="pb-4" v-if="x === entries.length - 1"></div>
</div>
</b-form-group>
</ContextMenuItem>
</template>
</ContextMenu>
</div>
</div>
<hr class="m-1" v-if="!showDetails" />
<ContextMenu ref="recipe_card" triggers="click, hover" :title="$t('Filters')" style="max-width: 300">
<template #menu="{ contextData }" v-if="recipe">
<ContextMenuItem>
<RecipeCard :recipe="contextData" :detail="false"></RecipeCard>
</ContextMenuItem>
<ContextMenuItem @click="$refs.menu.close()">
<b-form-group label-cols="9" content-cols="3" class="text-nowrap m-0 mr-2">
<template #label>
<a class="dropdown-item p-2" href="#"><i class="fas fa-pizza-slice"></i> {{ $t("Servings") }}</a>
</template>
<div @click.prevent.stop>
<b-form-input class="mt-2" min="0" type="number" v-model="servings"></b-form-input>
</div>
</b-form-group>
</ContextMenuItem>
</template>
</ContextMenu>
</div>
</template>
<script>
import Vue from "vue"
import {BootstrapVue} from "bootstrap-vue"
import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import ContextMenu from "@/components/ContextMenu/ContextMenu"
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
import {ApiMixin} from "@/utils/utils"
import { ApiMixin } from "@/utils/utils"
import RecipeCard from "./RecipeCard.vue"
Vue.use(BootstrapVue)
export default {
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
// or i'm capturing it incorrectly
name: "ShoppingLineItem",
mixins: [ApiMixin],
components: {RecipeCard, ContextMenu, ContextMenuItem},
props: {
entries: {
type: Array,
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
// or i'm capturing it incorrectly
name: "ShoppingLineItem",
mixins: [ApiMixin],
components: { RecipeCard, ContextMenu, ContextMenuItem },
props: {
entries: {
type: Array,
},
settings: Object,
groupby: { type: String },
},
settings: Object,
groupby: {type: String},
},
data() {
return {
showDetails: false,
recipe: undefined,
servings: 1,
}
},
computed: {
formatAmount: function () {
let amount = {}
this.entries.forEach((entry) => {
let unit = entry?.unit?.name ?? "----"
if (entry.amount) {
if (amount[unit]) {
amount[unit] += entry.amount
} else {
amount[unit] = entry.amount
}
data() {
return {
showDetails: false,
recipe: undefined,
servings: 1,
}
})
for (const [k, v] of Object.entries(amount)) {
amount[k] = Math.round(v * 100 + Number.EPSILON) / 100 // javascript hack to force rounding at 2 places
}
return amount
},
formatCategory: function () {
return this.formatOneCategory(this.entries[0]) || this.$t("Undefined")
},
formatChecked: function () {
return this.entries.map((x) => x.checked).every((x) => x === true)
},
formatHint: function () {
if (this.groupby == "recipe") {
return this.formatCategory
} else {
return this.formatRecipe
}
},
formatFood: function () {
return this.formatOneFood(this.entries[0])
},
formatUnit: function () {
return this.formatOneUnit(this.entries[0])
},
formatRecipe: function () {
if (this.entries?.length == 1) {
return this.formatOneMealPlan(this.entries[0]) || ""
} else {
let mealplan_name = this.entries.filter((x) => x?.recipe_mealplan?.name)
// return [this.formatOneMealPlan(mealplan_name?.[0]), this.$t("CountMore", { count: this.entries?.length - 1 })].join(" ")
return mealplan_name
.map((x) => {
return this.formatOneMealPlan(x)
computed: {
formatAmount: function () {
let amount = {}
this.entries.forEach((entry) => {
let unit = entry?.unit?.name ?? "----"
if (entry.amount) {
if (amount[unit]) {
amount[unit] += entry.amount
} else {
amount[unit] = entry.amount
}
}
})
.join(" - ")
}
},
formatNotes: function () {
if (this.entries?.length == 1) {
return this.formatOneNote(this.entries[0]) || ""
}
return ""
},
},
watch: {},
mounted() {
this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0
},
methods: {
// this.genericAPI inherited from ApiMixin
for (const [k, v] of Object.entries(amount)) {
amount[k] = Math.round(v * 100 + Number.EPSILON) / 100 // javascript hack to force rounding at 2 places
}
return amount
},
formatCategory: function () {
return this.formatOneCategory(this.entries[0]) || this.$t("Undefined")
},
formatChecked: function () {
return this.entries.map((x) => x.checked).every((x) => x === true)
},
formatHint: function () {
if (this.groupby == "recipe") {
return this.formatCategory
} else {
return this.formatRecipe
}
},
formatFood: function () {
return this.formatOneFood(this.entries[0])
},
formatUnit: function () {
return this.formatOneUnit(this.entries[0])
},
formatRecipe: function () {
if (this.entries?.length == 1) {
return this.formatOneMealPlan(this.entries[0]) || ""
} else {
let mealplan_name = this.entries.filter((x) => x?.recipe_mealplan?.name)
// return [this.formatOneMealPlan(mealplan_name?.[0]), this.$t("CountMore", { count: this.entries?.length - 1 })].join(" ")
formatDate: function (datetime) {
if (!datetime) {
return
}
return Intl.DateTimeFormat(window.navigator.language, {
dateStyle: "short",
timeStyle: "short"
}).format(Date.parse(datetime))
return mealplan_name
.map((x) => {
return this.formatOneMealPlan(x)
})
.join(" - ")
}
},
formatNotes: function () {
if (this.entries?.length == 1) {
return this.formatOneNote(this.entries[0]) || ""
}
return ""
},
},
formatOneAmount: function (item) {
return item?.amount ?? 1
watch: {},
mounted() {
this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0
},
formatOneUnit: function (item) {
return item?.unit?.name ?? ""
methods: {
// this.genericAPI inherited from ApiMixin
formatDate: function (datetime) {
if (!datetime) {
return
}
return Intl.DateTimeFormat(window.navigator.language, {
dateStyle: "short",
timeStyle: "short",
}).format(Date.parse(datetime))
},
formatOneAmount: function (item) {
return item?.amount ?? 1
},
formatOneUnit: function (item) {
return item?.unit?.name ?? ""
},
formatOneCategory: function (item) {
return item?.food?.supermarket_category?.name
},
formatOneCompletedAt: function (item) {
if (!item.completed_at) {
return false
}
return [this.$t("Completed"), "@", this.formatDate(item.completed_at)].join(" ")
},
formatOneFood: function (item) {
return item.food.name
},
formatOneDelayUntil: function (item) {
if (!item.delay_until || (item.delay_until && item.checked)) {
return false
}
return [this.$t("DelayUntil"), "-", this.formatDate(item.delay_until)].join(" ")
},
formatOneMealPlan: function (item) {
return item?.recipe_mealplan?.name ?? ""
},
formatOneRecipe: function (item) {
return item?.recipe_mealplan?.recipe_name ?? ""
},
formatOneNote: function (item) {
if (!item) {
item = this.entries[0]
}
return [item?.recipe_mealplan?.mealplan_note, item?.ingredient_note].filter(String)
},
formatOneCreatedBy: function (item) {
return [this.$t("Added_by"), item?.created_by.username, "@", this.formatDate(item.created_at)].join(" ")
},
openRecipeCard: function (e, item) {
this.genericAPI(this.Models.RECIPE, this.Actions.FETCH, { id: item.recipe_mealplan.recipe }).then((result) => {
let recipe = result.data
recipe.steps = undefined
this.recipe = true
this.$refs.recipe_card.open(e, recipe)
})
},
updateChecked: function (e, item) {
let update = undefined
if (!item) {
update = { entries: this.entries.map((x) => x.id), checked: !this.formatChecked }
} else {
update = { entries: [item], checked: !item.checked }
}
this.$emit("update-checkbox", update)
},
},
formatOneCategory: function (item) {
return item?.food?.supermarket_category?.name
},
formatOneCompletedAt: function (item) {
if (!item.completed_at) {
return false
}
return [this.$t("Completed"), "@", this.formatDate(item.completed_at)].join(" ")
},
formatOneFood: function (item) {
return item.food.name
},
formatOneDelayUntil: function (item) {
if (!item.delay_until || (item.delay_until && item.checked)) {
return false
}
return [this.$t("DelayUntil"), "-", this.formatDate(item.delay_until)].join(" ")
},
formatOneMealPlan: function (item) {
return item?.recipe_mealplan?.name ?? ""
},
formatOneRecipe: function (item) {
return item?.recipe_mealplan?.recipe_name ?? ""
},
formatOneNote: function (item) {
if (!item) {
item = this.entries[0]
}
return [item?.recipe_mealplan?.mealplan_note, item?.ingredient_note].filter(String)
},
formatOneCreatedBy: function (item) {
return [this.$t("Added_by"), item?.created_by.username, "@", this.formatDate(item.created_at)].join(" ")
},
openRecipeCard: function (e, item) {
this.genericAPI(this.Models.RECIPE, this.Actions.FETCH, {id: item.recipe_mealplan.recipe}).then((result) => {
let recipe = result.data
recipe.steps = undefined
this.recipe = true
this.$refs.recipe_card.open(e, recipe)
})
},
updateChecked: function (e, item) {
let update = undefined
if (!item) {
update = {entries: this.entries.map((x) => x.id), checked: !this.formatChecked}
} else {
update = {entries: [item], checked: !item.checked}
}
this.$emit("update-checkbox", update)
},
},
}
</script>
@@ -344,34 +321,34 @@ export default {
/* border-bottom: 1px solid #000; /* …and with a border on the top */
/* } */
.checkbox-control {
font-size: 0.6rem
font-size: 0.6rem;
}
.checkbox-control-mobile {
font-size: 1rem
font-size: 1rem;
}
.rotate {
-moz-transition: all 0.25s linear;
-webkit-transition: all 0.25s linear;
transition: all 0.25s linear;
-moz-transition: all 0.25s linear;
-webkit-transition: all 0.25s linear;
transition: all 0.25s linear;
}
.rotated {
-moz-transform: rotate(90deg);
-webkit-transform: rotate(90deg);
transform: rotate(90deg);
-moz-transform: rotate(90deg);
-webkit-transform: rotate(90deg);
transform: rotate(90deg);
}
.unit-badge-lg {
font-size: 1rem !important;
font-weight: 500 !important;
font-size: 1rem !important;
font-weight: 500 !important;
}
@media (max-width: 768px) {
.dropdown-spacing {
padding-left: 0 !important;
padding-right: 0 !important;
}
.dropdown-spacing {
padding-left: 0 !important;
padding-right: 0 !important;
}
}
</style>

View File

@@ -32,9 +32,8 @@
<div class="row">
<!-- ingredients table -->
<div class="col col-md-4">
<table class="table table-sm"
v-if="step.ingredients.length > 0 && (recipe.steps.length > 1 || force_ingredients)">
<div class="col col-md-4" v-if="step.ingredients.length > 0 && (recipe.steps.length > 1 || force_ingredients)">
<table class="table table-sm" >
<ingredients-card :steps="[step]" :ingredient_factor="ingredient_factor"
@checked-state-changed="$emit('checked-state-changed', $event)"/>
</table>

View File

@@ -127,8 +127,8 @@
"Hide_as_header": "Keine Überschrift",
"Copy_template_reference": "Template Referenz kopieren",
"Step_Type": "Schritt Typ",
"Make_Ingredient": "In Zutat wandeln",
"Make_Header": "In Überschrift wandeln",
"Make_Ingredient": "In Zutat wandeln",
"Enable_Amount": "Menge aktivieren",
"Disable_Amount": "Menge deaktivieren",
"Add_Step": "Schritt hinzufügen",
@@ -186,7 +186,6 @@
"Edit_Meal_Plan_Entry": "Eintrag bearbeiten",
"Create_New_Meal_Type": "Neue Mahlzeit",
"Create_Meal_Plan_Entry": "Neuer Eintrag",
"Make_header": "Erstelle Überschrift",
"Color": "Farbe",
"New_Meal_Type": "Neue Mahlzeit",
"Periods": "Zeiträume",
@@ -249,5 +248,48 @@
"Search Settings": "Sucheinstellungen",
"shopping_auto_sync_desc": "Bei 0 wird Auto-Sync deaktiviert. Beim Betrachten einer Einkaufsliste wird die Liste alle gesetzten Sekunden aktualisiert, um mögliche Änderungen anderer zu zeigen. Nützlich, wenn mehrere Personen einkaufen und mobile Daten nutzen.",
"MoveCategory": "Verschieben nach: ",
"mealplan_autoadd_shopping_desc": "Essensplan-Zutaten automatisch zur Einkaufsliste hinzufügen."
"mealplan_autoadd_shopping_desc": "Essensplan-Zutaten automatisch zur Einkaufsliste hinzufügen.",
"Pin": "Pin",
"mark_complete": "Vollständig markieren",
"shopping_add_onhand_desc": "Markiere Lebensmittel als \"Vorrätig\", wenn von der Einkaufsliste abgehakt wurden.",
"left_handed": "Linkshändermodus",
"left_handed_help": "Optimiert die Benutzeroberfläche für die Bedienung mit der linken Hand.",
"FoodInherit": "Lebensmittel vererbbare Felder",
"SupermarketCategoriesOnly": "Nur Supermarkt Kategorien",
"InheritWarning": "{food} ist auf Vererbung gesetzt ist, Änderungen werden möglicherweise nicht gespeichert.",
"mealplan_autoexclude_onhand_desc": "Wenn Sie einen Essensplan zur Einkaufsliste hinzufügen (manuell oder automatisch), schließen Sie Zutaten aus, die gerade vorrätig sind.",
"mealplan_autoinclude_related_desc": "Wenn Sie einen Essensplan zur Einkaufsliste hinzufügen (manuell oder automatisch), fügen Sie alle zugehörigen Rezepte hinzu.",
"default_delay_desc": "Voreingestellte Anzahl von Stunden für die Verzögerung eines Einkaufslisteneintrags.",
"filter_to_supermarket": "Auf Supermarkt filtern",
"err_move_self": "Element kann nicht auf sich selbst verschoben werden",
"nothing": "Nichts zu tun",
"err_merge_self": "Element kann nicht mit sich selbst zusammengeführt werden",
"show_sql": "SQL anzeigen",
"filter_to_supermarket_desc": "Standardmäßig wird die Einkaufsliste so gefiltert, dass sie nur Kategorien für den ausgewählten Supermarkt enthält.",
"CategoryName": "Kategorie Name",
"SupermarketName": "Supermarkt Name",
"CategoryInstruction": "Ziehen Sie Kategorien, um die Reihenfolge zu ändern, in der die Kategorien in der Einkaufsliste erscheinen.",
"shopping_recent_days_desc": "Tage der letzten Einträge in der Einkaufsliste, die angezeigt werden sollen.",
"shopping_recent_days": "Letzte Tage",
"create_shopping_new": "Zur NEUEN Einkaufsliste hinzufügen",
"download_pdf": "PDF herunterladen",
"download_csv": "CSV herunterladen",
"csv_delim_help": "Trennzeichen für CSV-Exporte.",
"csv_delim_label": "CSV-Trennzeichen",
"SuccessClipboard": "Einkaufsliste wurde in die Zwischenablage kopiert",
"copy_to_clipboard": "In die Zwischenablage kopieren",
"csv_prefix_help": "Präfix, das beim Kopieren der Liste in die Zwischenablage hinzugefügt wird.",
"csv_prefix_label": "Listenpräfix",
"copy_markdown_table": "Als Markdown-Tabelle kopieren",
"in_shopping": "In Einkaufsliste",
"DelayUntil": "Verzögerung bis",
"QuickEntry": "Schnelleinstieg",
"shopping_add_onhand": "Automatisch vorrätig",
"related_recipes": "Ähnliche Rezepte",
"today_recipes": "Rezepte des Tages",
"sql_debug": "SQL Debug",
"remember_search": "Suchbegriff merken",
"remember_hours": "Stunden zu erinnern",
"tree_select": "Baum-Auswahl verwenden",
"CountMore": "...+{count} weitere"
}

View File

@@ -59,8 +59,8 @@
"Move_Down": "Move down",
"Step_Name": "Step Name",
"Step_Type": "Step Type",
"Make_header": "Make_Header",
"Make_Ingredient": "Make_Ingredient",
"Make_Header": "Make Header",
"Make_Ingredient": "Make Ingredient",
"Enable_Amount": "Enable Amount",
"Disable_Amount": "Disable Amount",
"Add_Step": "Add Step",
@@ -289,5 +289,12 @@
"remember_hours": "Hours to Remember",
"tree_select": "Use Tree Selection",
"left_handed": "Left-handed mode",
"left_handed_help": "Will optimize the UI for use with your left hand."
"left_handed_help": "Will optimize the UI for use with your left hand.",
"OnHand_help": "Food is in inventory and will not be automatically added to a shopping list.",
"ignore_shopping_help": "Never add food to the shopping list (e.g. water)",
"shopping_category_help": "Supermarkets can be ordered and filtered by Shopping Category according to the layout of the aisles.",
"food_recipe_help": "Linking a recipe here will include the linked recipe in any other recipe that use this food",
"Foods": "Foods",
"review_shopping": "Review shopping entries before saving",
"view_recipe": "View Recipe"
}

View File

@@ -55,8 +55,8 @@
"Move_Down": "Siirry alas",
"Step_Name": "Vaiheen Nimi",
"Step_Type": "Vaiheen Tyyppi",
"Make_header": "Valmista_Otsikko",
"Make_Ingredient": "Valmista_Ainesosa",
"Make_Header": "Valmista Otsikko",
"Make_Ingredient": "Valmista Ainesosa",
"Enable_Amount": "Ota Määrä käyttöön",
"Disable_Amount": "Poista Määrä käytöstä",
"Add_Step": "Lisää Vaihe",

View File

@@ -139,6 +139,7 @@
"Key_Ctrl": "Ctrl",
"Add_nutrition_recipe": "Ajouter les valeurs nutritionelles à la recette",
"Remove_nutrition_recipe": "Supprimer les valeurs nutritionelles de la recette",
"Make_Header": "Créer une en-tête",
"Make_Ingredient": "Créer un ingrédient",
"Enable_Amount": "Activer la quantité",
"Disable_Amount": "Désactiver la quantité",
@@ -151,7 +152,6 @@
"create_rule": "et créer une automatisation",
"Automate": "Automatiser",
"Create_New_Meal_Type": "Ajouter un nouveau type de repas",
"Make_header": "Créer une en-tête",
"No_Results": "Aucun résultat",
"Type": "Type",
"Unit": "Unité",

View File

@@ -184,7 +184,6 @@
"Title_or_Recipe_Required": "Sono richiesti titolo o ricetta",
"Create_Meal_Plan_Entry": "Crea voce nel piano alimentare",
"Edit_Meal_Plan_Entry": "Modifica voce del piano alimentare",
"Make_header": "Crea Intestazione",
"Color": "Colore",
"New_Meal_Type": "Nuovo tipo di pasto",
"Select_File": "Seleziona file",

View File

@@ -110,8 +110,8 @@
"Move_Down": "Verplaats omlaag",
"Step_Name": "Stap Naam",
"Step_Type": "Stap Type",
"Make_Header": "Maak_Koptekst",
"Make_Ingredient": "Maak_Ingrediënt",
"Make_Header": "Maak Koptekst",
"Make_Ingredient": "Maak Ingrediënt",
"Enable_Amount": "Schakel hoeveelheid in",
"Disable_Amount": "Schakel hoeveelheid uit",
"Add_Step": "Voeg Stap toe",
@@ -171,7 +171,6 @@
"Title": "Titel",
"Week": "Week",
"Month": "Maand",
"Make_header": "Maak dit de koptekst",
"Color": "Kleur",
"New_Meal_Type": "Nieuw Maaltype",
"Image": "Afbeelding",

View File

@@ -172,7 +172,6 @@
"Year": "Rok",
"Planner_Settings": "Ustawienia terminarza",
"Planner": "Terminarz",
"Make_header": "Utwórz nagłówek",
"New_Meal_Type": "Nowy rodzaj posiłku",
"Select_File": "Wybierz plik",
"Image": "Obraz",
@@ -285,5 +284,18 @@
"related_recipes": "Powiązane przepisy",
"today_recipes": "Dzisiejsze przepisy",
"Search Settings": "Ustawienia wyszukiwania",
"Pin": "Pin"
"Pin": "Pin",
"left_handed_help": "Zoptymalizuje interfejs użytkownika do użytku lewą ręką.",
"food_recipe_help": "Powiązanie tutaj przepisu będzie skutkowało połączenie przepisu z każdym innym przepisem, który używa tego jedzenia",
"Foods": "Żywność",
"view_recipe": "Zobacz przepis",
"left_handed": "Tryb dla leworęcznych",
"OnHand_help": "Żywność jest w spiżarni i nie zostanie automatycznie dodana do listy zakupów.",
"ignore_shopping_help": "Nigdy nie dodawaj żywności do listy zakupów (np. wody)",
"shopping_category_help": "Z supermarketów można zamawiać i filtrować według kategorii zakupów zgodnie z układem alejek.",
"review_shopping": "Przejrzyj wpisy zakupów przed zapisaniem",
"sql_debug": "Debugowanie SQL",
"remember_search": "Zapamiętaj wyszukiwanie",
"remember_hours": "Godziny do zapamiętania",
"tree_select": "Użyj drzewa wyboru"
}

View File

@@ -55,7 +55,7 @@
"Move_Down": "",
"Step_Name": "",
"Step_Type": "",
"Make_header": "",
"Make_Header": "",
"Make_Ingredient": "",
"Enable_Amount": "",
"Disable_Amount": "",

View File

@@ -53,8 +53,8 @@
"Move_Down": "Перенести вниз",
"Step_Name": "Имя шага",
"Step_Type": "Тип шага",
"Make_header": "Создание_Заголовка",
"Make_Ingredient": "Создание_инградиента",
"Make_Header": "Создание Заголовка",
"Make_Ingredient": "Создание инградиента",
"Enable_Amount": "Активировать Количество",
"Disable_Amount": "Деактивировать количество",
"Add_Step": "Добавить шаг",

View File

@@ -1,210 +1,288 @@
{
"warning_feature_beta": "Ta funkcija je trenutno v stanju BETA (testiranje). Pri uporabi te funkcije pričakujte napake in morebitne prelomne spremembe v prihodnosti (morda izgubite podatke, povezane s to funkcijo).",
"err_fetching_resource": "",
"err_creating_resource": "",
"err_updating_resource": "",
"err_deleting_resource": "",
"success_fetching_resource": "",
"success_creating_resource": "",
"success_updating_resource": "",
"success_deleting_resource": "",
"file_upload_disabled": "",
"step_time_minutes": "",
"confirm_delete": "",
"import_running": "",
"all_fields_optional": "",
"convert_internal": "",
"show_only_internal": "",
"show_split_screen": "",
"Log_Recipe_Cooking": "",
"External_Recipe_Image": "",
"Add_to_Shopping": "",
"Add_to_Plan": "",
"Step_start_time": "",
"Sort_by_new": "",
"Table_of_Contents": "",
"Recipes_per_page": "",
"Show_as_header": "",
"Hide_as_header": "",
"Add_nutrition_recipe": "",
"Remove_nutrition_recipe": "",
"Copy_template_reference": "",
"Save_and_View": "",
"Manage_Books": "",
"Meal_Plan": "",
"Select_Book": "",
"Select_File": "",
"Recipe_Image": "",
"Import_finished": "",
"View_Recipes": "",
"Log_Cooking": "",
"err_fetching_resource": "Napaka pri pridobivanju vira!",
"err_creating_resource": "Napaka pri ustvarjanju vira!",
"err_updating_resource": "Napaka pri posodabljanju vira!",
"err_deleting_resource": "Napaka pri brisanju vira!",
"success_fetching_resource": "Pridobivanje vira je bilo uspešno!",
"success_creating_resource": "Ustvarjanje vira je bilo uspešno!",
"success_updating_resource": "Posodabljanje vira je bilo uspešno!",
"success_deleting_resource": "Brisanje vira je bilo uspešno!",
"file_upload_disabled": "Nalaganje datoteke ni omogočeno za tvoj prostor.",
"step_time_minutes": "Časovni korak v minutah",
"confirm_delete": "Ali si prepričan da želiš izbrisati {object}?",
"import_running": "Uvoz poteka, prosim počakaj!",
"all_fields_optional": "Vsa polja so opcijska in jih lahko pustiš prazne.",
"convert_internal": "Pretvori v interni recept",
"show_only_internal": "Prikaži samo interne recepte",
"show_split_screen": "Deljen pogled",
"Log_Recipe_Cooking": "Logiraj recept za kuhanje",
"External_Recipe_Image": "Zunanja slika recepta",
"Add_to_Shopping": "Dodaj v nakupovalni listek",
"Add_to_Plan": "Dodaj v načrt",
"Step_start_time": "Začetni čas koraka",
"Sort_by_new": "Razvrsti po novih",
"Table_of_Contents": "Kazalo vsebine",
"Recipes_per_page": "Receptov na stran",
"Show_as_header": "Prikaži kot glavo",
"Hide_as_header": "Skrij kot glavo",
"Add_nutrition_recipe": "Receptu dodaj hranilno vrednost",
"Remove_nutrition_recipe": "Receptu izbriši hranilno vrednost",
"Copy_template_reference": "Kopiraj referenco vzorca",
"Save_and_View": "Shrani in poglej",
"Manage_Books": "Upravljaj knjige",
"Meal_Plan": "Načrt obroka",
"Select_Book": "Izberi knjigo",
"Select_File": "Izberi datoteko",
"Recipe_Image": "Slika recepta",
"Import_finished": "Uvoz je končan",
"View_Recipes": "Preglej recepte",
"Log_Cooking": "Zgodovina kuhanja",
"New_Recipe": "Nov Recept",
"Url_Import": "",
"Reset_Search": "",
"Recently_Viewed": "",
"Load_More": "",
"New_Keyword": "",
"Delete_Keyword": "",
"Edit_Keyword": "",
"Url_Import": "URL uvoz",
"Reset_Search": "Ponastavi iskalnik",
"Recently_Viewed": "Nazadnje videno",
"Load_More": "Naloži več",
"New_Keyword": "Nova ključna beseda",
"Delete_Keyword": "Izbriši ključno besedo",
"Edit_Keyword": "Uredi ključno besedo",
"Edit_Recipe": "Uredi Recept",
"Move_Keyword": "",
"Merge_Keyword": "",
"Hide_Keywords": "",
"Hide_Recipes": "",
"Move_Up": "",
"Move_Down": "",
"Step_Name": "",
"Step_Type": "",
"Make_header": "",
"Make_Ingredient": "",
"Enable_Amount": "",
"Disable_Amount": "",
"Add_Step": "",
"Keywords": "",
"Move_Keyword": "Premakni ključno besedo",
"Merge_Keyword": "Združi ključno besedo",
"Hide_Keywords": "Skrij ključno besedo",
"Hide_Recipes": "Skrij recept",
"Move_Up": "Premakni navzgor",
"Move_Down": "Premakni navzdol",
"Step_Name": "Ime koraka",
"Step_Type": "Tip koraka",
"Make_Header": "Ustvari glavo",
"Make_Ingredient": "Ustvari sestavino",
"Enable_Amount": "Omogoči količino",
"Disable_Amount": "Onemogoči količino",
"Add_Step": "Dodaj korak",
"Keywords": "Ključne besede",
"Books": "Knjige",
"Proteins": "",
"Fats": "",
"Carbohydrates": "",
"Calories": "",
"Energy": "",
"Nutrition": "",
"Proteins": "Beljakovine",
"Fats": "Maščobe",
"Carbohydrates": "Ogljikovi hidrati",
"Calories": "Kalorije",
"Energy": "Energija",
"Nutrition": "Prehrana",
"Date": "Datum",
"Share": "Deli",
"Automation": "",
"Parameter": "",
"Export": "",
"Copy": "",
"Rating": "",
"Close": "",
"Cancel": "",
"Link": "",
"Add": "",
"New": "",
"Note": "",
"Success": "",
"Failure": "",
"Ingredients": "",
"Supermarket": "",
"Categories": "",
"Category": "",
"Selected": "",
"min": "",
"Servings": "",
"Waiting": "",
"Preparation": "",
"External": "",
"Size": "",
"Files": "",
"File": "",
"Edit": "",
"Image": "",
"Automation": "Avtomatizacija",
"Parameter": "Parameter",
"Export": "Izvoz",
"Copy": "Kopiraj",
"Rating": "Ocena",
"Close": "Zapri",
"Cancel": "Prekini",
"Link": "Hiperpovezava",
"Add": "Dodaj",
"New": "Nov",
"Note": "Opomba",
"Success": "Uspešno",
"Failure": "Napaka",
"Ingredients": "Sestavine",
"Supermarket": "Supermarket",
"Categories": "Kategorije",
"Category": "Kategorija",
"Selected": "Izbrano",
"min": "min",
"Servings": "Porcije",
"Waiting": "Čakanje",
"Preparation": "Priprava",
"External": "Zunanje",
"Size": "Velikost",
"Files": "Datoteke",
"File": "Datoteka",
"Edit": "Uredi",
"Image": "Slika",
"Delete": "Izbriši",
"Open": "Odpri",
"Ok": "Odpri",
"Save": "Shrani",
"Step": "",
"Step": "Korak",
"Search": "Iskanje",
"Import": "Uvozi",
"Print": "Natisni",
"Settings": "",
"or": "",
"and": "",
"Information": "",
"Settings": "Nastavitve",
"or": "ali",
"and": "in",
"Information": "Informacija",
"Download": "Prenesi",
"Create": "",
"Create": "Ustvari",
"Advanced Search Settings": "",
"View": "",
"View": "Pogled",
"Recipes": "Recepti",
"Move": "",
"Merge": "",
"Parent": "",
"delete_confirmation": "",
"move_confirmation": "",
"merge_confirmation": "",
"create_rule": "",
"move_selection": "",
"merge_selection": "",
"Move": "Premakni",
"Merge": "Združi",
"Parent": "Starš",
"delete_confirmation": "Ste prepričani da želite odstraniti {source}?",
"move_confirmation": "Premakni <i>{child}</i> k staršu <i>{parent}</i>",
"merge_confirmation": "Zamenjaj <i>{source}</i> z/s <i>{target}</i>",
"create_rule": "in ustvari avtomatizacijo",
"move_selection": "Izberi starša {type} za premik v {source}.",
"merge_selection": "Zamenjaj vse dogodge {source} z izbranim {type}.",
"Root": "",
"Ignore_Shopping": "",
"Shopping_Category": "",
"Edit_Food": "",
"Move_Food": "",
"New_Food": "",
"Hide_Food": "",
"Food_Alias": "",
"Unit_Alias": "",
"Keyword_Alias": "",
"Delete_Food": "",
"No_ID": "",
"Meal_Plan_Days": "",
"merge_title": "",
"move_title": "",
"Ignore_Shopping": "Prezri nakup",
"Shopping_Category": "Kategorija nakupa",
"Edit_Food": "Uredi hrano",
"Move_Food": "Premakni hrano",
"New_Food": "Nova hrana",
"Hide_Food": "Skrij hrano",
"Food_Alias": "Vzdevek hrane",
"Unit_Alias": "Vzdevek enote",
"Keyword_Alias": "Vzdevek ključne besede",
"Delete_Food": "Izbriši hrano",
"No_ID": "ID ni najden, ne morem izbrisati.",
"Meal_Plan_Days": "Načrt za prihodnje obroke",
"merge_title": "Združi {type}",
"move_title": "Premakni {type}",
"Food": "Hrana",
"Recipe_Book": "",
"del_confirmation_tree": "",
"delete_title": "",
"create_title": "",
"edit_title": "",
"Name": "",
"Type": "",
"Description": "",
"Recipe": "",
"Recipe_Book": "Knjiga receptov",
"del_confirmation_tree": "Si prepričan/a, da želiš izbrisati {source} in vse podkategorije?",
"delete_title": "Izbriši {type}",
"create_title": "Novo {type}",
"edit_title": "Uredi {type}",
"Name": "Ime",
"Type": "Tip",
"Description": "Opis",
"Recipe": "Recept",
"tree_root": "",
"Icon": "",
"Unit": "",
"No_Results": "",
"New_Unit": "",
"Create_New_Shopping Category": "",
"Icon": "Ikona",
"Unit": "Enota",
"No_Results": "Ni rezultatov",
"New_Unit": "Nova enota",
"Create_New_Shopping Category": "Ustvari novo kategorijo nakupovalnega listka",
"Create_New_Food": "Dodaj Novo Hrano",
"Create_New_Keyword": "",
"Create_New_Unit": "",
"Create_New_Meal_Type": "",
"and_up": "",
"Instructions": "",
"Unrated": "",
"Automate": "",
"Empty": "",
"Key_Ctrl": "",
"Key_Shift": "",
"Time": "",
"Text": "",
"Create_New_Keyword": "Dodaj novo ključno besedo",
"Create_New_Unit": "Dodaj novo enoto",
"Create_New_Meal_Type": "Dodaj nov tip obroka",
"and_up": "& gor",
"Instructions": "Navodila",
"Unrated": "Neocenjeno",
"Automate": "Avtomatiziraj",
"Empty": "Prazno",
"Key_Ctrl": "Ctrl",
"Key_Shift": "Shift",
"Time": "Čas",
"Text": "Tekst",
"Shopping_list": "Nakupovalni Seznam",
"Create_Meal_Plan_Entry": "",
"Edit_Meal_Plan_Entry": "",
"Title": "",
"Create_Meal_Plan_Entry": "Ustvari vnos za načrtovan obrok",
"Edit_Meal_Plan_Entry": "Spremeni vnos za načrtovan obrok",
"Title": "Naslov",
"Week": "Teden",
"Month": "Mesec",
"Year": "Leto",
"Planner": "",
"Planner_Settings": "",
"Period": "",
"Plan_Period_To_Show": "",
"Periods": "",
"Plan_Show_How_Many_Periods": "",
"Starting_Day": "",
"Meal_Types": "",
"Meal_Type": "",
"Clone": "",
"Drag_Here_To_Delete": "",
"Meal_Type_Required": "",
"Title_or_Recipe_Required": "",
"Planner": "Planer",
"Planner_Settings": "Nastavitve planerja",
"Period": "Obdobje",
"Plan_Period_To_Show": "Prikaži, tedne, mesece ali leta",
"Periods": "Obdobja",
"Plan_Show_How_Many_Periods": "Koliko obdobij prikažem",
"Starting_Day": "Začetni dan v tednu",
"Meal_Types": "Tipi obroka",
"Meal_Type": "Tip obroka",
"Clone": "Kloniraj",
"Drag_Here_To_Delete": "Povleci sem za izbris",
"Meal_Type_Required": "Tip obroka je obvezen",
"Title_or_Recipe_Required": "Zahtevan je naslov ali izbran recept",
"Color": "Barva",
"New_Meal_Type": "",
"Week_Numbers": "",
"Show_Week_Numbers": "",
"Export_As_ICal": "",
"Export_To_ICal": "",
"Cannot_Add_Notes_To_Shopping": "",
"Added_To_Shopping_List": "",
"Shopping_List_Empty": "",
"Next_Period": "",
"Previous_Period": "",
"Current_Period": "",
"New_Meal_Type": "Nov tip obroka",
"Week_Numbers": "Števila tednov",
"Show_Week_Numbers": "Prikaži število tednov?",
"Export_As_ICal": "Izvozi trenutno obdobje v iCal format",
"Export_To_ICal": "Izvoz.ics",
"Cannot_Add_Notes_To_Shopping": "Opombe ne moreš dodati v nakupovalni listek",
"Added_To_Shopping_List": "Dodano v nakupovalni listek",
"Shopping_List_Empty": "Tvoj nakupovalni listek je trenutno prazen. Stvari lahko dodaš preko menija za načrt obroka (desni klik na kartico ali levi klik na ikono za meni)",
"Next_Period": "Naslednje obdobje",
"Previous_Period": "Prejšnje obdobje",
"Current_Period": "Trenutno obdobje",
"Next_Day": "Naslednji Dan",
"Previous_Day": "Prejšnji Dan",
"Coming_Soon": "",
"Auto_Planner": "",
"New_Cookbook": "",
"Hide_Keyword": "",
"Clear": ""
"Coming_Soon": "Kmalu",
"Auto_Planner": "Avto-planer",
"New_Cookbook": "Nova kuharska knjiga",
"Hide_Keyword": "Skrij ključne besede",
"Clear": "Počisti",
"Pin": "Pripni",
"err_moving_resource": "Napaka pri premikanju vira!",
"err_merging_resource": "Napaka pri združevanju vira!",
"Shopping_Categories": "Kategorije nakupa",
"IngredientInShopping": "Ta sestavina je v tvojem nakupovalnem listku.",
"RemoveFoodFromShopping": "Odstrani {food} iz nakupovalnega listka",
"SupermarketCategoriesOnly": "Prikaži samo trgovinske kategorije",
"DelayFor": "Zamakni za {hours} ur",
"OfflineAlert": "Si v offline načinu, nakupovalni listek se mogoče ne bo sinhroniziral.",
"shopping_share_desc": "Uporabniki bodo videli vse elemente, ki si jih dodal v nakupovalni listek. Morajo te dodati, da vidiš njihove elemente na listku.",
"shopping_auto_sync_desc": "Nastavitev na 0 bo onemogoča avtomatsko sinhronizacijo. Pri ogledu nakupovalnega seznama se seznam posodablja vsakih nekaj sekund za sinhronizacijo sprememb, ki jih je morda naredil nekdo drug. Uporabno pri nakupovanju z več ljudmi, vendar bo uporabljalo mobilne podatke.",
"filter_to_supermarket_desc": "Privzeto, razvrsti nakupovalni listek, da vključi samo označene trgovine.",
"SuccessClipboard": "Nakupovalni listek je kopiran v odložišče",
"left_handed": "Način za levičarje",
"left_handed_help": "Optimizira grafični vmesnik za levičarje.",
"success_moving_resource": "Premikanje vira je bilo uspešno!",
"success_merging_resource": "Združevanje vira je bilo uspešno!",
"Added_by": "Dodano s strani",
"AddToShopping": "Dodaj nakupovlanemu listku",
"NotInShopping": "{food} ni v tvojem nakupovalnem listku.",
"OnHand": "Trenutno imam v roki",
"FoodOnHand": "Imaš {food} v roki.",
"FoodNotOnHand": "Nimaš {food} v roki.",
"Undefined": "Nedefiniran",
"AddFoodToShopping": "Dodaj {food} v nakupovalni listek",
"DeleteShoppingConfirm": "Si prepričan/a, da želiš odstraniti VSO {food} iz nakupovalnega listka?",
"Inherit": "Podeduj",
"InheritFields": "Podeduj vrednosti polja",
"FoodInherit": "Podedovana polja hrane",
"ShowUncategorizedFood": "Prikaži nedefinirano",
"GroupBy": "Združi po",
"MoveCategory": "Premakni v: ",
"CountMore": "...+{count} več",
"IgnoreThis": "Nikoli avtomatsko ne dodaj {food} v nakup",
"Warning": "Opozorilo",
"NoCategory": "Nobena kategorija ni izbrana.",
"InheritWarning": "{food} je nastavljena na dedovanje, spremembe morda ne bodo trajale.",
"ShowDelayed": "Prikaži zamaknjene elemente",
"Completed": "Končano",
"shopping_share": "Deli nakupovalni listek",
"shopping_auto_sync": "Avtomatska sinhronizacija",
"mealplan_autoadd_shopping": "Avtomatsko dodaj obrok v načrt",
"mealplan_autoexclude_onhand": "Izključi hrano v roki",
"mealplan_autoinclude_related": "Dodaj povezane recepte",
"default_delay": "Privzete ure za zamik",
"mealplan_autoadd_shopping_desc": "Avtomatsko dodaj sestavine načrtovanega obroka v nakupovalni listek.",
"mealplan_autoinclude_related_desc": "Pri dodajanju načrta obrokov na nakupovalni seznam (ročno ali samodejno) vključi sestavine, ki so povezane z receptom.",
"mealplan_autoexclude_onhand_desc": "Pri dodajanju načrta obrokov na nakupovalni seznam (ročno ali samodejno) izključite sestavine, ki so trenutno v roki.",
"err_move_self": "Ne morem premakniti elementa v samega sebe",
"nothing": "Ni kaj za narediti",
"err_merge_self": "Ne morem združiti elementa v samega sebe",
"show_sql": "Prikaži SQL",
"CategoryName": "Ime kategorije",
"SupermarketName": "Ime trgovine",
"CategoryInstruction": "Povleci kategorije za spremembo vrstnega reda v nakupovalnem listku.",
"shopping_recent_days_desc": "Dnevi nedavnih vnosov na seznamu za nakupovanje, ki jih želite prikazati.",
"shopping_recent_days": "Nedavni dnevi",
"create_shopping_new": "Dodaj v NOV nakupovalni listek",
"download_pdf": "Prenesi PDF",
"download_csv": "Prenesi CSV",
"csv_delim_help": "Ločilo za CSV izvoz.",
"csv_delim_label": "CSV ločilo",
"copy_to_clipboard": "Kopiraj v odložiče",
"csv_prefix_help": "Dodana prepona, ko kopiramo nakupovalni listek v odložišče.",
"csv_prefix_label": "Prepona seznama",
"copy_markdown_table": "Kopiraj kot Markdown tabela",
"in_shopping": "V nakupovalnem listku",
"DelayUntil": "Zamakni do",
"shopping_add_onhand": "Avtomatsko v roki",
"related_recipes": "Povezani recepti",
"today_recipes": "Današnji recepti",
"mark_complete": "Označi končano",
"QuickEntry": "Hitri vnos",
"Search Settings": "Išči nastavitev",
"sql_debug": "SQL razhroščevanje",
"remember_search": "Zapomni si iskanje",
"remember_hours": "Ure, ki si jih zapomni",
"tree_select": "Uporabi drevesno označbo"
}

View File

@@ -185,7 +185,8 @@
"Plan_Show_How_Many_Periods": "要显示多少个周期",
"Starting_Day": "一周中的第一天",
"Meal_Types": "用餐类型",
"Make_header": "显示注意事项",
"Make_Header": "显示注意事项",
"Make_Ingredient": "显示材料",
"Color": "颜色",
"New_Meal_Type": "新用餐类型",
"Pin": "固定",
@@ -210,7 +211,6 @@
"Previous_Day": "前一天",
"remember_hours": "需要记住的时间",
"tree_select": "使用树形选择",
"Make_Ingredient": "显示材料",
"Note": "笔记",
"Added_on": "添加到",
"AddToShopping": "添加到购物清单",

View File

@@ -59,7 +59,7 @@ export class Models {
// MODELS - inherits and takes precedence over MODEL_TYPES and ACTIONS
static FOOD = {
name: i18n.t("Food"), // *OPTIONAL* : parameters will be built model -> model_type -> default
name: "Food", // *OPTIONAL* : parameters will be built model -> model_type -> default
apiName: "Food", // *REQUIRED* : the name that is used in api.ts for this model
model_type: this.TREE, // *OPTIONAL* : model specific params for api, if not present will attempt modeltype_create then default_create
paginated: true,
@@ -76,15 +76,17 @@ export class Models {
// REQUIRED: unordered array of fields that can be set during create
create: {
// if not defined partialUpdate will use the same parameters, prepending 'id'
params: [["name", "description", "recipe", "food_onhand", "supermarket_category", "inherit", "inherit_fields"]],
params: [["name", "description", "recipe", "food_onhand", "supermarket_category", "inherit", "inherit_fields", "ignore_shopping"]],
form: {
show_help: true,
name: {
form_field: true,
type: "text",
field: "name",
label: i18n.t("Name"),
placeholder: "",
subtitle_field: "full_name",
},
description: {
form_field: true,
@@ -99,12 +101,21 @@ export class Models {
field: "recipe",
list: "RECIPE",
label: i18n.t("Recipe"),
help_text: i18n.t("food_recipe_help"),
},
shopping: {
onhand: {
form_field: true,
type: "checkbox",
field: "food_onhand",
label: i18n.t("OnHand"),
help_text: i18n.t("OnHand_help"),
},
ignore_shopping: {
form_field: true,
type: "checkbox",
field: "ignore_shopping",
label: i18n.t("Ignore_Shopping"),
help_text: i18n.t("ignore_shopping_help"),
},
shopping_category: {
form_field: true,
@@ -113,6 +124,7 @@ export class Models {
list: "SHOPPING_CATEGORY",
label: i18n.t("Shopping_Category"),
allow_create: true,
help_text: i18n.t("shopping_category_help"),
},
inherit_fields: {
form_field: true,
@@ -121,12 +133,7 @@ export class Models {
field: "inherit_fields",
list: "FOOD_INHERIT_FIELDS",
label: i18n.t("InheritFields"),
condition: { field: "parent", value: true, condition: "exists" },
},
full_name: {
form_field: true,
type: "smalltext",
field: "full_name",
condition: { field: "food_children_exist", value: true, condition: "preference_equals" },
},
form_function: "FoodCreateDefault",
},
@@ -136,12 +143,12 @@ export class Models {
},
}
static FOOD_INHERIT_FIELDS = {
name: i18n.t("FoodInherit"),
name: "FoodInherit",
apiName: "FoodInheritField",
}
static KEYWORD = {
name: i18n.t("Keyword"), // *OPTIONAL: parameters will be built model -> model_type -> default
name: "Keyword", // *OPTIONAL: parameters will be built model -> model_type -> default
apiName: "Keyword",
model_type: this.TREE,
paginated: true,
@@ -184,7 +191,7 @@ export class Models {
}
static UNIT = {
name: i18n.t("Unit"),
name: "Unit",
apiName: "Unit",
paginated: true,
create: {
@@ -210,7 +217,7 @@ export class Models {
}
static SHOPPING_LIST = {
name: i18n.t("Shopping_list"),
name: "Shopping_list",
apiName: "ShoppingListEntry",
list: {
params: ["id", "checked", "supermarket", "options"],
@@ -239,7 +246,7 @@ export class Models {
}
static RECIPE_BOOK = {
name: i18n.t("Recipe_Book"),
name: "Recipe_Book",
apiName: "RecipeBook",
create: {
params: [["name", "description", "icon"]],
@@ -269,7 +276,7 @@ export class Models {
}
static SHOPPING_CATEGORY = {
name: i18n.t("Shopping_Category"),
name: "Shopping_Category",
apiName: "SupermarketCategory",
create: {
params: [["name", "description"]],
@@ -293,7 +300,7 @@ export class Models {
}
static SHOPPING_CATEGORY_RELATION = {
name: i18n.t("Shopping_Category_Relation"),
name: "Shopping_Category_Relation",
apiName: "SupermarketCategoryRelation",
create: {
params: [["category", "supermarket", "order"]],
@@ -317,7 +324,7 @@ export class Models {
}
static SUPERMARKET = {
name: i18n.t("Supermarket"),
name: "Supermarket",
apiName: "Supermarket",
ordered_tags: [{ field: "category_to_supermarket", label: "category::name", color: "info" }],
create: {
@@ -360,7 +367,7 @@ export class Models {
}
static AUTOMATION = {
name: i18n.t("Automation"),
name: "Automation",
apiName: "Automation",
paginated: true,
list: {
@@ -423,7 +430,7 @@ export class Models {
}
static RECIPE = {
name: i18n.t("Recipe"),
name: "Recipe",
apiName: "Recipe",
list: {
params: ["query", "keywords", "foods", "units", "rating", "books", "keywordsOr", "foodsOr", "booksOr", "internal", "random", "_new", "page", "pageSize", "options"],
@@ -439,7 +446,7 @@ export class Models {
}
static USER_NAME = {
name: i18n.t("User"),
name: "User",
apiName: "User",
list: {
params: ["filter_list"],
@@ -447,7 +454,7 @@ export class Models {
}
static MEAL_TYPE = {
name: i18n.t("Meal_Type"),
name: "Meal_Type",
apiName: "MealType",
list: {
params: ["filter_list"],
@@ -455,7 +462,7 @@ export class Models {
}
static MEAL_PLAN = {
name: i18n.t("Meal_Plan"),
name: "Meal_Plan",
apiName: "MealPlan",
list: {
params: ["options"],
@@ -463,7 +470,7 @@ export class Models {
}
static USERFILE = {
name: i18n.t("File"),
name: "File",
apiName: "UserFile",
paginated: false,
list: {
@@ -492,13 +499,13 @@ export class Models {
},
}
static USER = {
name: i18n.t("User"),
name: "User",
apiName: "User",
paginated: false,
}
static STEP = {
name: i18n.t("Step"),
name: "Step",
apiName: "Step",
list: {
params: ["recipe", "query", "page", "pageSize", "options"],

File diff suppressed because it is too large Load Diff

View File

@@ -156,7 +156,7 @@ export function getUserPreference(pref = undefined) {
return undefined
}
if (pref) {
return user_preference[pref]
return user_preference?.[pref]
}
return user_preference
}
@@ -389,6 +389,8 @@ export function getForm(model, action, item1, item2) {
}
if (value?.form_field) {
value["value"] = item1?.[value?.field] ?? undefined
value["help"] = item1?.[value?.help_text_field] ?? value?.help_text ?? undefined
value["subtitle"] = item1?.[value?.subtitle_field] ?? value?.subtitle ?? undefined
form.fields.push({
...value,
...{

View File

@@ -17,6 +17,14 @@ const pages = {
entry: "./src/apps/ImportResponseView/main.js",
chunks: ["chunk-vendors"],
},
export_response_view: {
entry: "./src/apps/ExportResponseView/main.js",
chunks: ["chunk-vendors"],
},
export_view: {
entry: "./src/apps/ExportView/main.js",
chunks: ["chunk-vendors"],
},
supermarket_view: {
entry: "./src/apps/SupermarketView/main.js",
chunks: ["chunk-vendors"],

View File

@@ -1737,7 +1737,7 @@
resolved "https://registry.yarnpkg.com/@vue/cli-plugin-vuex/-/cli-plugin-vuex-4.5.15.tgz#466c1f02777d02fef53a9bb49a36cc3a3bcfec4e"
integrity sha512-fqap+4HN+w+InDxlA3hZTOGE0tzBTgXhKLoDydhywqgmhQ1D9JA6Feh94ze6tG8DsWX58/ujYUqA8jAz17FJtg==
"@vue/cli-service@~4.5.13":
"@vue/cli-service@~4.5.15":
version "4.5.15"
resolved "https://registry.yarnpkg.com/@vue/cli-service/-/cli-service-4.5.15.tgz#0e9a186d51550027d0e68e95042077eb4d115b45"
integrity sha512-sFWnLYVCn4zRfu45IcsIE9eXM0YpDV3S11vlM2/DVbIPAGoYo5ySpSof6aHcIvkeGsIsrHFpPHzNvDZ/efs7jA==
@@ -1818,47 +1818,47 @@
semver "^6.1.0"
strip-ansi "^6.0.0"
"@vue/compiler-core@3.2.26":
version "3.2.26"
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.2.26.tgz#9ab92ae624da51f7b6064f4679c2d4564f437cc8"
integrity sha512-N5XNBobZbaASdzY9Lga2D9Lul5vdCIOXvUMd6ThcN8zgqQhPKfCV+wfAJNNJKQkSHudnYRO2gEB+lp0iN3g2Tw==
"@vue/compiler-core@3.2.29":
version "3.2.29"
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.2.29.tgz#b06097ab8ff0493177c68c5ea5b63d379a061097"
integrity sha512-RePZ/J4Ub3sb7atQw6V6Rez+/5LCRHGFlSetT3N4VMrejqJnNPXKUt5AVm/9F5MJriy2w/VudEIvgscCfCWqxw==
dependencies:
"@babel/parser" "^7.16.4"
"@vue/shared" "3.2.26"
"@vue/shared" "3.2.29"
estree-walker "^2.0.2"
source-map "^0.6.1"
"@vue/compiler-dom@3.2.26":
version "3.2.26"
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.2.26.tgz#c7a7b55d50a7b7981dd44fc28211df1450482667"
integrity sha512-smBfaOW6mQDxcT3p9TKT6mE22vjxjJL50GFVJiI0chXYGU/xzC05QRGrW3HHVuJrmLTLx5zBhsZ2dIATERbarg==
"@vue/compiler-dom@3.2.29":
version "3.2.29"
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.2.29.tgz#ad0ead405bd2f2754161335aad9758aa12430715"
integrity sha512-y26vK5khdNS9L3ckvkqJk/78qXwWb75Ci8iYLb67AkJuIgyKhIOcR1E8RIt4mswlVCIeI9gQ+fmtdhaiTAtrBQ==
dependencies:
"@vue/compiler-core" "3.2.26"
"@vue/shared" "3.2.26"
"@vue/compiler-core" "3.2.29"
"@vue/shared" "3.2.29"
"@vue/compiler-sfc@^3.2.20":
version "3.2.26"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.2.26.tgz#3ce76677e4aa58311655a3bea9eb1cb804d2273f"
integrity sha512-ePpnfktV90UcLdsDQUh2JdiTuhV0Skv2iYXxfNMOK/F3Q+2BO0AulcVcfoksOpTJGmhhfosWfMyEaEf0UaWpIw==
"@vue/compiler-sfc@^3.2.29":
version "3.2.29"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.2.29.tgz#f76d556cd5fca6a55a3ea84c88db1a2a53a36ead"
integrity sha512-X9+0dwsag2u6hSOP/XsMYqFti/edvYvxamgBgCcbSYuXx1xLZN+dS/GvQKM4AgGS4djqo0jQvWfIXdfZ2ET68g==
dependencies:
"@babel/parser" "^7.16.4"
"@vue/compiler-core" "3.2.26"
"@vue/compiler-dom" "3.2.26"
"@vue/compiler-ssr" "3.2.26"
"@vue/reactivity-transform" "3.2.26"
"@vue/shared" "3.2.26"
"@vue/compiler-core" "3.2.29"
"@vue/compiler-dom" "3.2.29"
"@vue/compiler-ssr" "3.2.29"
"@vue/reactivity-transform" "3.2.29"
"@vue/shared" "3.2.29"
estree-walker "^2.0.2"
magic-string "^0.25.7"
postcss "^8.1.10"
source-map "^0.6.1"
"@vue/compiler-ssr@3.2.26":
version "3.2.26"
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.2.26.tgz#fd049523341fbf4ab5e88e25eef566d862894ba7"
integrity sha512-2mywLX0ODc4Zn8qBoA2PDCsLEZfpUGZcyoFRLSOjyGGK6wDy2/5kyDOWtf0S0UvtoyVq95OTSGIALjZ4k2q/ag==
"@vue/compiler-ssr@3.2.29":
version "3.2.29"
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.2.29.tgz#37b15b32dcd2f6b410bb61fca3f37b1a92b7eb1e"
integrity sha512-LrvQwXlx66uWsB9/VydaaqEpae9xtmlUkeSKF6aPDbzx8M1h7ukxaPjNCAXuFd3fUHblcri8k42lfimHfzMICA==
dependencies:
"@vue/compiler-dom" "3.2.26"
"@vue/shared" "3.2.26"
"@vue/compiler-dom" "3.2.29"
"@vue/shared" "3.2.29"
"@vue/component-compiler-utils@^3.1.0", "@vue/component-compiler-utils@^3.1.2":
version "3.3.0"
@@ -1890,21 +1890,21 @@
resolved "https://registry.yarnpkg.com/@vue/preload-webpack-plugin/-/preload-webpack-plugin-1.1.2.tgz#ceb924b4ecb3b9c43871c7a429a02f8423e621ab"
integrity sha512-LIZMuJk38pk9U9Ur4YzHjlIyMuxPlACdBIHH9/nGYVTsaGKOSnSuELiE8vS9wa+dJpIYspYUOqk+L1Q4pgHQHQ==
"@vue/reactivity-transform@3.2.26":
version "3.2.26"
resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.26.tgz#6d8f20a4aa2d19728f25de99962addbe7c4d03e9"
integrity sha512-XKMyuCmzNA7nvFlYhdKwD78rcnmPb7q46uoR00zkX6yZrUmcCQ5OikiwUEVbvNhL5hBJuvbSO95jB5zkUon+eQ==
"@vue/reactivity-transform@3.2.29":
version "3.2.29"
resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.29.tgz#a08d606e10016b7cf588d1a43dae4db2953f9354"
integrity sha512-YF6HdOuhdOw6KyRm59+3rML8USb9o8mYM1q+SH0G41K3/q/G7uhPnHGKvspzceD7h9J3VR1waOQ93CUZj7J7OA==
dependencies:
"@babel/parser" "^7.16.4"
"@vue/compiler-core" "3.2.26"
"@vue/shared" "3.2.26"
"@vue/compiler-core" "3.2.29"
"@vue/shared" "3.2.29"
estree-walker "^2.0.2"
magic-string "^0.25.7"
"@vue/shared@3.2.26":
version "3.2.26"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.26.tgz#7acd1621783571b9a82eca1f041b4a0a983481d9"
integrity sha512-vPV6Cq+NIWbH5pZu+V+2QHE9y1qfuTq49uNWw4f7FDEeZaDU2H2cx5jcUZOAKW7qTrUS4k6qZPbMy1x4N96nbA==
"@vue/shared@3.2.29":
version "3.2.29"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.29.tgz#07dac7051117236431d2f737d16932aa38bbb925"
integrity sha512-BjNpU8OK6Z0LVzGUppEk0CMYm/hKDnZfYdjSmPOs0N+TR1cLKJAkDwW8ASZUvaaSLEi6d3hVM7jnWnX+6yWnHw==
"@vue/web-component-wrapper@^1.2.0":
version "1.3.0"
@@ -3447,10 +3447,10 @@ core-js@^2.4.0, core-js@^2.5.0:
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==
core-js@^3.20.2, core-js@^3.6.0, core-js@^3.6.5, core-js@^3.7.0, core-js@^3.8.3:
version "3.20.2"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.20.2.tgz#46468d8601eafc8b266bd2dd6bf9dee622779581"
integrity sha512-nuqhq11DcOAbFBV4zCbKeGbKQsUDRqTX0oqx7AttUBuqe3h20ixsE039QHelbL6P4h+9kytVqyEtyZ6gsiwEYw==
core-js@^3.20.3, core-js@^3.6.0, core-js@^3.6.5, core-js@^3.7.0, core-js@^3.8.3:
version "3.20.3"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.20.3.tgz#c710d0a676e684522f3db4ee84e5e18a9d11d69a"
integrity sha512-vVl8j8ph6tRS3B8qir40H7yw7voy17xL0piAjlbBUsH7WIfzoedL/ZOr1OV9FyZQLWXsayOJyV4tnRyXR85/ag==
core-util-is@1.0.2:
version "1.0.2"
@@ -10251,10 +10251,10 @@ typedarray@^0.0.6:
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
typescript@~4.5.2:
version "4.5.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.4.tgz#a17d3a0263bf5c8723b9c52f43c5084edf13c2e8"
integrity sha512-VgYs2A2QIRuGphtzFV7aQJduJ2gyfTljngLzjpfW9FoYZF6xuw1W0vW9ghCKLfcWrCFxK81CSGRAvS1pn4fIUg==
typescript@~4.5.5:
version "4.5.5"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3"
integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==
uc.micro@^1.0.1, uc.micro@^1.0.5:
version "1.0.6"