|
@@ -4,8 +4,9 @@
|
|
|
# All rights reserved
|
|
|
#
|
|
|
# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
|
|
|
-import itertools
|
|
|
-from itertools import chain
|
|
|
+from xml.etree.ElementTree import fromstring, ParseError
|
|
|
+from markdown import markdown
|
|
|
+from itertools import chain, product
|
|
|
from decimal import Decimal, InvalidOperation
|
|
|
from typing import List, Tuple, Union, Iterable, Callable
|
|
|
from urwid import (
|
|
@@ -35,6 +36,43 @@ from ..widgets import (
|
|
|
from ..db_utils import QueryManager
|
|
|
from . import ActivityManager, show_or_exit
|
|
|
from .Rating import Rating
|
|
|
+import yaml
|
|
|
+def change_style(style, representer):
|
|
|
+ def new_representer(dumper, data):
|
|
|
+ scalar = representer(dumper, data)
|
|
|
+ scalar.style = style
|
|
|
+ return scalar
|
|
|
+ return new_representer
|
|
|
+
|
|
|
+import yaml
|
|
|
+from yaml.representer import SafeRepresenter
|
|
|
+class folded_str(str): pass
|
|
|
+
|
|
|
+class literal_str(str): pass
|
|
|
+
|
|
|
+# represent_str does handle some corner cases, so use that
|
|
|
+# instead of calling represent_scalar directly
|
|
|
+represent_folded_str = change_style('>', SafeRepresenter.represent_str)
|
|
|
+represent_literal_str = change_style('|', SafeRepresenter.represent_str)
|
|
|
+yaml.add_representer(folded_str, represent_folded_str)
|
|
|
+yaml.add_representer(literal_str, represent_literal_str)
|
|
|
+def depth_first_elements(tree):
|
|
|
+ for e in tree:
|
|
|
+ for y in depth_first_elements(e):
|
|
|
+ yield y
|
|
|
+ yield tree
|
|
|
+
|
|
|
+def get_products_from_xhtml(md: str):
|
|
|
+ try:
|
|
|
+ xhtml = fromstring(
|
|
|
+f"""<root>
|
|
|
+{md}
|
|
|
+</root>
|
|
|
+""")
|
|
|
+ except ParseError:
|
|
|
+ return
|
|
|
+ for e in filter(lambda x: x.tag == 'strong', depth_first_elements(xhtml)):
|
|
|
+ yield e.text
|
|
|
|
|
|
def to_numbered_field(x):
|
|
|
if len(x[0].split('#', 1)) > 1:
|
|
@@ -90,6 +128,8 @@ class RecipeEditor(FocusWidget):
|
|
|
self.advance_focus(reverse=True)
|
|
|
elif key == 'ctrl delete':
|
|
|
self.clear()
|
|
|
+ elif key == 'ctrl w':
|
|
|
+ self.save()
|
|
|
else:
|
|
|
return super().keypress(size, key)
|
|
|
|
|
@@ -124,7 +164,7 @@ class RecipeEditor(FocusWidget):
|
|
|
['product', 'quantity', 'unit'],
|
|
|
map(extract_values, unzip(self.ingredients)),
|
|
|
)
|
|
|
- ret = dict(itertools.chain(
|
|
|
+ ret = dict(chain(
|
|
|
*[ map(to_named_value(n), enumerate(l)) for n,l in zipped ],
|
|
|
[ ('organic', self.organic.state) ]
|
|
|
))
|
|
@@ -204,6 +244,23 @@ class RecipeEditor(FocusWidget):
|
|
|
))))
|
|
|
))
|
|
|
|
|
|
+ def save(self):
|
|
|
+ yml = dict()
|
|
|
+ yml['ingredients'] = list(map(lambda x: ' '.join(x), filter(
|
|
|
+ lambda x: None not in map(lambda x: x or None, x), [
|
|
|
+ (
|
|
|
+ x[0].get_edit_text(),
|
|
|
+ x[1].get_text()[0],
|
|
|
+ x[2].get_edit_text(),
|
|
|
+ ) for x in self.ingredients
|
|
|
+ ])))
|
|
|
+ yml['feeds'] = int(self.feeds.get_text()[0].rsplit(':', 1)[1].strip())
|
|
|
+ yml['instructions'] = literal_str('\n'.join(map(
|
|
|
+ lambda x: x.strip(),
|
|
|
+ self.instructions.get_text()[0].splitlines()
|
|
|
+ )).strip())
|
|
|
+ with open(f'{self.fname}-modified.yaml', 'w') as f:
|
|
|
+ yaml.dump(yml, f)
|
|
|
|
|
|
|
|
|
def update(self):
|
|
@@ -244,13 +301,31 @@ class RecipeEditor(FocusWidget):
|
|
|
price[1] += _avg*quantity
|
|
|
price[2] += _max*quantity
|
|
|
self.price.set_text(f'Cost: {not_found}{price[1]:.2f}')
|
|
|
+ notice = ''
|
|
|
+ ingredients = list(filter(lambda x: x, map(lambda x: x[0].get_edit_text(), self.ingredients)))
|
|
|
+ parsed_products = list(get_products_from_xhtml(markdown(self.instructions.get_edit_text())))
|
|
|
+ if not parsed_products:
|
|
|
+ self.notice.set_text('Failed to parse recipe instructions')
|
|
|
+ return self
|
|
|
+ products = set(parsed_products)
|
|
|
+ for product in products - set(ingredients):
|
|
|
+ notice += f"Product '{product}' not found in list of ingredients\n";
|
|
|
+ for ingredient in set(ingredients) - products:
|
|
|
+ notice += f"Ingredient '{ingredient}' is not used\n";
|
|
|
+ if len(set(ingredients)) != len(ingredients):
|
|
|
+ notice += f"Some ingredients listed more than onece\n"
|
|
|
+
|
|
|
+ self.notice.set_text(notice or 'None')
|
|
|
+
|
|
|
return self
|
|
|
|
|
|
def __init__(self,
|
|
|
activity_manager: ActivityManager,
|
|
|
query_manager: QueryManager,
|
|
|
- recipe,
|
|
|
+ fname: str,
|
|
|
+ recipe: dict,
|
|
|
):
|
|
|
+ self.fname = fname
|
|
|
self.components = dict()
|
|
|
self.buttons = {
|
|
|
'clear': Button(('streak', 'Clear')),
|
|
@@ -267,14 +342,16 @@ class RecipeEditor(FocusWidget):
|
|
|
|
|
|
self.organic = NoTabCheckBox(('bg', "Organic"), state='mixed')
|
|
|
self.instructions = Edit('', edit_text=recipe['instructions'] or u'', multiline=True, allow_tab=True)
|
|
|
- self.feeds = Text(f"Feeds: {recipe['feeds'] or ''}")
|
|
|
+ self.feeds = Text(f"Serves: {recipe['feeds'] or ''}")
|
|
|
self.price = Text(f"Cost: 0")
|
|
|
+ self.notice = Text(f"")
|
|
|
|
|
|
bottom_pane = [
|
|
|
self.organic,
|
|
|
LineBox(self.instructions, title=f'Instructions'),
|
|
|
self.feeds,
|
|
|
self.price,
|
|
|
+ LineBox(self.notice, title='Errors'),
|
|
|
]
|
|
|
self.activity_manager = activity_manager
|
|
|
self.query_manager = query_manager
|
|
@@ -320,7 +397,7 @@ class RecipeEditor(FocusWidget):
|
|
|
) for idx, ingredient in enumerate(self.ingredients)
|
|
|
]
|
|
|
gutter = [
|
|
|
- *[ Divider() for _ in itertools.product(
|
|
|
+ *[ Divider() for _ in product(
|
|
|
range(3), self.ingredients[:-1]
|
|
|
)],
|
|
|
Divider(),
|