From 507b31e72c7f3f9064c261be3c2abe788dd67ea8 Mon Sep 17 00:00:00 2001 From: relikd Date: Mon, 27 Feb 2023 01:02:14 +0100 Subject: [PATCH] refactor: full rewrite of app logic (better, faster, type-safe) --- src/content/groupby/difficulty/contents.lr | 6 - src/content/groupby/rating/contents.lr | 4 + src/content/groupby/time/contents.lr | 10 - src/content/settings/contents.lr | 19 ++ src/databags/i18n+de.ini | 6 + src/databags/i18n+en.ini | 6 + src/models/cluster.ini | 6 - src/models/recipe.ini | 2 +- src/models/settings.ini | 16 + src/packages/helper/README.md | 3 - src/packages/helper/lektor_helper.py | 227 ------------- src/packages/helper/setup.py | 38 --- src/packages/html-to-tex/.gitignore | 5 - src/packages/html-to-tex/html2latex.sed | 61 ---- .../html-to-tex/lektor_html_to_tex.py | 131 -------- src/packages/html-to-tex/setup.py | 12 - src/packages/{helper => main}/.gitignore | 0 src/packages/main/lektor_main/__init__.py | 1 + .../main/lektor_main/durationcluster.py | 66 ++++ src/packages/main/lektor_main/ingredients.py | 115 +++++++ src/packages/main/lektor_main/latex.py | 302 ++++++++++++++++++ src/packages/main/lektor_main/plugin.py | 147 +++++++++ src/packages/main/lektor_main/settings.py | 54 ++++ src/packages/main/lektor_main/utils.py | 85 +++++ src/packages/main/setup.py | 21 ++ src/packages/time-duration/.gitignore | 5 - src/packages/time-duration/README.md | 4 - .../time-duration/lektor_time_duration.py | 87 ----- src/packages/time-duration/setup.py | 38 --- src/templates/cluster.html | 27 +- src/templates/macros/recipes.html | 2 +- src/templates/recipe.html | 20 +- 32 files changed, 856 insertions(+), 670 deletions(-) delete mode 100644 src/packages/helper/README.md delete mode 100644 src/packages/helper/lektor_helper.py delete mode 100644 src/packages/helper/setup.py delete mode 100644 src/packages/html-to-tex/.gitignore delete mode 100644 src/packages/html-to-tex/html2latex.sed delete mode 100644 src/packages/html-to-tex/lektor_html_to_tex.py delete mode 100755 src/packages/html-to-tex/setup.py rename src/packages/{helper => main}/.gitignore (100%) create mode 100644 src/packages/main/lektor_main/__init__.py create mode 100644 src/packages/main/lektor_main/durationcluster.py create mode 100644 src/packages/main/lektor_main/ingredients.py create mode 100644 src/packages/main/lektor_main/latex.py create mode 100644 src/packages/main/lektor_main/plugin.py create mode 100644 src/packages/main/lektor_main/settings.py create mode 100644 src/packages/main/lektor_main/utils.py create mode 100644 src/packages/main/setup.py delete mode 100644 src/packages/time-duration/.gitignore delete mode 100644 src/packages/time-duration/README.md delete mode 100644 src/packages/time-duration/lektor_time_duration.py delete mode 100644 src/packages/time-duration/setup.py diff --git a/src/content/groupby/difficulty/contents.lr b/src/content/groupby/difficulty/contents.lr index cd0ff38..b78f13d 100644 --- a/src/content/groupby/difficulty/contents.lr +++ b/src/content/groupby/difficulty/contents.lr @@ -1,9 +1,3 @@ sort_key: 10 --- group_key: difficulty ---- -xdata: - -easy -medium -hard diff --git a/src/content/groupby/rating/contents.lr b/src/content/groupby/rating/contents.lr index e407f30..e2887ef 100644 --- a/src/content/groupby/rating/contents.lr +++ b/src/content/groupby/rating/contents.lr @@ -3,3 +3,7 @@ sort_key: 15 group_key: rating --- reverse_order: yes +--- +null_fallback: ☆☆☆ +--- +name: diff --git a/src/content/groupby/time/contents.lr b/src/content/groupby/time/contents.lr index af7fc61..e999ee6 100644 --- a/src/content/groupby/time/contents.lr +++ b/src/content/groupby/time/contents.lr @@ -1,13 +1,3 @@ sort_key: 20 --- group_key: time ---- -xdata: - -15 -30 -60 -120 -180 -360 -1440 diff --git a/src/content/settings/contents.lr b/src/content/settings/contents.lr index 41b7486..63e96e5 100644 --- a/src/content/settings/contents.lr +++ b/src/content/settings/contents.lr @@ -5,3 +5,22 @@ _hidden: yes replace_frac: yes --- replace_temp: yes +--- +replace_frac_map: + +1/2 ½ +1/3 ⅓ +2/3 ⅔ +1/4 ¼ +3/4 ¾ +1/8 ⅛ +--- +duration_cluster: + +15 +30 +60 +120 +180 +360 +1440 diff --git a/src/databags/i18n+de.ini b/src/databags/i18n+de.ini index 28b5d57..cf6a691 100644 --- a/src/databags/i18n+de.ini +++ b/src/databags/i18n+de.ini @@ -1,5 +1,11 @@ [duration] label = Zeit +day = Tag +days = Tage +hour = Std +hours = Std +min = Min +mins = Min [yield] label = Menge diff --git a/src/databags/i18n+en.ini b/src/databags/i18n+en.ini index 23d0cb7..312a25c 100644 --- a/src/databags/i18n+en.ini +++ b/src/databags/i18n+en.ini @@ -1,5 +1,11 @@ [duration] label = Time +day = day +days = days +hour = hour +hours = hours +min = min +mins = min [yield] label = Yield diff --git a/src/models/cluster.ini b/src/models/cluster.ini index e699f75..689106d 100644 --- a/src/models/cluster.ini +++ b/src/models/cluster.ini @@ -39,9 +39,3 @@ label = Reverse Sort width = 1/5 type = boolean alts_enabled = false - -[fields.xdata] -label = Extended Data -description = Used for ordinal sort order or merging integer cluster -width = 1/2 -type = strings diff --git a/src/models/recipe.ini b/src/models/recipe.ini index a1e0e8e..f9c2f7a 100644 --- a/src/models/recipe.ini +++ b/src/models/recipe.ini @@ -51,7 +51,7 @@ type = string label = Ingredients / Zutaten description = 42 g Ingredient, Notes (add additional measures in settings) width = 2/3 -type = strings +type = ingredientslist [fields.tags] label = Tags / Kategorie diff --git a/src/models/settings.ini b/src/models/settings.ini index 00151eb..93fec0b 100644 --- a/src/models/settings.ini +++ b/src/models/settings.ini @@ -28,3 +28,19 @@ label = Replace 1/2 with ½, ⅔, etc. width = 1/5 type = boolean alts_enabled = false + +[fields.replace_frac_map] +label = Ingredient replacements for numbers and fractions +description = "Space separated list (default: 1/2 ½ 1/3 ⅓ 2/3 ⅔ 1/4 ¼ 3/4 ¾ 1/8 ⅛)" +width = 2/5 +type = text +default = 1/2 ½ 1/3 ⅓ 2/3 ⅔ 1/4 ¼ 3/4 ¾ 1/8 ⅛ +alts_enabled = false + +[fields.duration_cluster] +label = Time duration splits +description = "list of int ('10 30' creates three clusters: 0-9, 10-29, >30)" +width = 2/5 +type = text +default = 15, 30, 60, 120, 180, 360, 1440 +alts_enabled = false diff --git a/src/packages/helper/README.md b/src/packages/helper/README.md deleted file mode 100644 index fa419da..0000000 --- a/src/packages/helper/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Helper - -Just some python functions that are necessary for the project. diff --git a/src/packages/helper/lektor_helper.py b/src/packages/helper/lektor_helper.py deleted file mode 100644 index 023fafa..0000000 --- a/src/packages/helper/lektor_helper.py +++ /dev/null @@ -1,227 +0,0 @@ -# -*- coding: utf-8 -*- -from lektor.pluginsystem import Plugin, get_plugin -from lektor.databags import Databags -from markupsafe import Markup -from datetime import datetime -import unicodedata -import lektor_html_to_tex as tex - -# ------- -# Sorting - - -def sorted_images(obj, attr='record_label'): - return sorted(obj.attachments.images, key=lambda x: getattr(x, attr)) - - -def title_image(self, attr='record_label', small=False): - img = (sorted_images(self, attr) or [None])[0] - if img and small: - img = img.thumbnail(200, 150, mode='crop') - return img - - -def sortKeyInt(x): - return int(x[0]) if x[0] else 0 - - -def sortKeyStr(x): - return noUmlaut(x[0]).lower() - - -def groupByDictSort(dic, sorter=None, reverse=False): - if type(sorter) == list: # sort by pre-defined, ordered list - return sorted(dic, reverse=bool(reverse), key=lambda x: - sorter.index(x[0]) if x[0] in sorter else 0) - fn = sortKeyInt if sorter == 'int' else sortKeyStr - return sorted(dic, reverse=bool(reverse), key=fn) - -# ----------------------- -# Pure text manupulations - - -def noUmlaut(text): - try: - text = unicode(text, 'utf-8') - except (TypeError, NameError): - pass - text = unicodedata.normalize('NFD', text) - text = text.encode('ascii', 'ignore') - text = text.decode("utf-8") - return str(text) - - -def replaceFractions(txt): - res = ' ' - for c in u'-–—': - if c in txt: - txt = txt.replace(c, ' – ') - for x in txt.split(): - if x == '': - continue - try: - i = ['1/2', '1/3', '2/3', '1/4', '3/4', '1/8'].index(x) - res += [u'½', u'⅓', u'⅔', u'¼', u'¾', u'⅛'][i] - except ValueError: - if x == '–': - res += '–' - elif res[-1:] == '–': - res += x - else: - res += ' ' + x - return res.lstrip(' ') - - -def numFillWithText(num, fill=u'★', empty=u'☆', total=3): - num = int(num) if num else 0 - return fill * num + empty * (total - num) - -# ------------------ -# Array manipulation - - -def updateSet_if(dic, parent, parentkey, value): - try: - key = parent[parentkey] - except KeyError: - return - if not key: - key = '' - try: - dic[key] - except KeyError: - dic[key] = set() - dic[key].add(value) - -# -------------------- -# Ingredient splitting - - -def splitIngredientLine(line): - state = 1 - capture = False - indices = [0, len(line)] - for i, char in enumerate(line): - if char.isspace(): - if capture: - capture = False - indices[state] = i - state += 1 - continue - elif capture: - continue - elif state == 1 and char in u'0123456789-–—.,': - state -= 1 - elif state > 1: - break - capture = True - return indices - - -def parseIngredientLine(line, measureList=[], rep_frac=False): - idx = splitIngredientLine(line) - val = line[:idx[0]] - if rep_frac: - val = replaceFractions(val) - measure = line[idx[0]:idx[1]].lstrip() - if measure.lower() in measureList: - name = line[idx[1]:].lstrip() - # if name.startswith('of '): - # measure += ' of' - # name = name[3:] - else: - measure = '' - name = line[idx[0]:].lstrip() - note = '' - name_note = name.split(',', 1) - if len(name_note) > 1: - name, note = [x.strip() for x in name_note] - return {'value': val, 'measure': measure, 'name': name, 'note': note} - - -def replace_atref_urls(text, label=None): - if '@' not in text: - return text - result = list() - for x in text.split(): - if x[0] == '@': - x = x[1:] - result.append(u'{}'.format(x, label or x)) - else: - result.append(x) - return Markup(' '.join(result)) - -# ---------------- -# Main entry point - - -class HelperPlugin(Plugin): - name = u'Helper' - description = u'Some helper methods, filters, and templates.' - buildTime = None - settings = dict() - translations = dict() - - # ----------- - # Event hooks - # ----------- - - def on_before_build_all(self, builder, **extra): - # update project settings once per build - bag = Databags(self.env) - pad = self.env.new_pad() - for alt in self.env.load_config().iter_alternatives(): - set = pad.get('settings', alt=alt) - self.translations[alt] = bag.lookup('i18n+' + alt) - self.settings[alt] = { - 'measures': set['measures'].lower().split(), - 'replFrac': set['replace_frac'] - } - - # def on_process_template_context(self, context, **extra): - # pass - - def on_setup_env(self, **extra): - def localizeDic(alt, partA, partB=None): - if alt not in self.translations: - raise RuntimeError( - 'localize() expects first parameter to be an alternate') - if partB is None: - partA, partB = partA.split('.', 1) - return self.translations[alt][partA][partB] - - def ingredientsForRecipe(recipe, alt='en', mode='raw'): - meaList = self.settings[alt]['measures'] - repFrac = self.settings[alt]['replFrac'] - - for line in recipe['ingredients']: - line = line.strip() - if mode == 'tex': - line = tex.raw_text_to_tex(line) - if not line: - continue - elif line.endswith(':'): - yield {'group': line} - else: - yield parseIngredientLine(line, meaList, repFrac) - - def groupByAttribute(recipeList, attribute, alt='en'): - groups = dict() - for recipe in recipeList: - if attribute == 'ingredients': - for ing in ingredientsForRecipe(recipe, alt): - updateSet_if(groups, ing, 'name', recipe) - else: - updateSet_if(groups, recipe, attribute, recipe) - # groups[undefinedKey].update(groups.pop('_undefined')) - return groups.items() - - self.env.jinja_env.filters['sorted_images'] = sorted_images - self.env.jinja_env.filters['title_image'] = title_image - self.env.jinja_env.filters['rating'] = numFillWithText - self.env.jinja_env.filters['replaceFractions'] = replaceFractions - self.env.jinja_env.filters['enumIngredients'] = ingredientsForRecipe - self.env.jinja_env.filters['replaceAtRefURLs'] = replace_atref_urls - self.env.jinja_env.filters['groupByAttribute'] = groupByAttribute - self.env.jinja_env.filters['groupSort'] = groupByDictSort - self.env.jinja_env.globals['localize'] = localizeDic diff --git a/src/packages/helper/setup.py b/src/packages/helper/setup.py deleted file mode 100644 index feda639..0000000 --- a/src/packages/helper/setup.py +++ /dev/null @@ -1,38 +0,0 @@ -import ast -import io -import re - -from setuptools import setup, find_packages - -with io.open('README.md', 'rt', encoding="utf8") as f: - readme = f.read() - -_description_re = re.compile(r'description\s+=\s+(?P.*)') - -with open('lektor_helper.py', 'rb') as f: - description = str(ast.literal_eval(_description_re.search( - f.read().decode('utf-8')).group(1))) - -setup( - author=u'relikd', - author_email='oleg@relikd.de', - description=description, - keywords='Lektor plugin', - license='MIT', - long_description=readme, - long_description_content_type='text/markdown', - name='lektor-helper', - packages=find_packages(), - py_modules=['lektor_helper'], - # url='[link to your repository]', - version='0.1', - classifiers=[ - 'Framework :: Lektor', - 'Environment :: Plugins', - ], - entry_points={ - 'lektor.plugins': [ - 'helper = lektor_helper:HelperPlugin', - ] - } -) diff --git a/src/packages/html-to-tex/.gitignore b/src/packages/html-to-tex/.gitignore deleted file mode 100644 index 463960b..0000000 --- a/src/packages/html-to-tex/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -dist -build -*.pyc -*.pyo -*.egg-info diff --git a/src/packages/html-to-tex/html2latex.sed b/src/packages/html-to-tex/html2latex.sed deleted file mode 100644 index c682de0..0000000 --- a/src/packages/html-to-tex/html2latex.sed +++ /dev/null @@ -1,61 +0,0 @@ -# character set translations for LaTex special chars -s?>.?>?g -s?<.??\\par ?g -s?

??g -# Headings -s?\([^<]*\)?\\section*{\1}?g -s??\\part{?g -s??}?g -s?

?\\section*{?g -s??}?g -s?

?\\subsection*{?g -s?

?\\subsubsection*{?g -s?

?\\paragraph*{?g -s?

?\\paragraph*{?g -s?
?\\subparagraph*{?g -# UL is itemize -s?
    ?\\begin{itemize}?g -s?
?\\end{itemize}?g -s?
    ?\\begin{enumerate}?g -s?
?\\end{enumerate}?g -s?
  • ?\\item ?g -s?
  • ??g -# DL is description -s?
    ?\\begin{description}?g -s?
    ?\\end{description}?g -# closing delimiter for DT is first < or end of line which ever comes first NO -#s?
    \([^<]*\)\([^<]*\)$?\\item[\1]?g -#s?
    ??g -#s?
    ?\\item[?g -#s?
    ?]?g -s?
    \([^<]*\)
    ?\\item[\1]?g -s?
    ??g -s?
    ??g -# Italics -s?\([^<]*\)?{\\it \1}?g -s?\([^<]*\)?{\\it \1}?g -s?\([^<]*\)?{\\bf \1}?g -s?\([^<]*\)?{\\bf \1}?g -# recipe specific -s?\([^<]*\)?\\recipelink{\1}{\2}?g -# Get rid of Anchors -s?\([^<]*\)?\\external{\1}{\2}?g -s?]*>??g -s???g -# quotes (replace after href) -s?\([[:space:]]\)"\([^[:space:]]\)?\1``\2?g -s?"?''?g diff --git a/src/packages/html-to-tex/lektor_html_to_tex.py b/src/packages/html-to-tex/lektor_html_to_tex.py deleted file mode 100644 index c557887..0000000 --- a/src/packages/html-to-tex/lektor_html_to_tex.py +++ /dev/null @@ -1,131 +0,0 @@ -# -*- coding: utf-8 -*- -from lektor.pluginsystem import Plugin # , get_plugin -import mistune -import os -import time -import subprocess as shell -import lektor_helper as helper -import lektor_time_duration as timedur - -my_dir = os.path.dirname(os.path.realpath(__file__)) -f_sed_a = os.path.join(my_dir, 'html2latex.sed') - - -def sed_repl_tex(f_sed, content): - with shell.Popen(('echo', content), stdout=shell.PIPE) as ps: - o = shell.check_output(['sed', '-f', f_sed], stdin=ps.stdout) - ps.wait() - return o.decode('utf-8') - - -def html_to_tex(html): - return sed_repl_tex(f_sed_a, html) - - -def raw_text_to_tex(text): - text = text.replace('\\', '\\backslash ').replace(' ', '~') - for c in '}{%$&#_~^': - if c in text: - text = text.replace(c, '\\' + c) - return text - # return sed_repl_tex(f_sed_b, text) - - -class RecipeToTex(object): - def __init__(self, enumerator): - super(RecipeToTex, self).__init__() - bases = tuple([]) + (mistune.Renderer,) - renderer_cls = type('renderer_cls', bases, {}) - renderer = renderer_cls(escape=False) - self.mdparser = mistune.Markdown(renderer, escape=False) - self.enumerator = enumerator - - def make(self, recipe, alt): - ingredients = '' - for x in self.enumerator(recipe, alt, mode='tex'): - ingredients += '\n' + self.process_ingredient(x, alt) - ingredients = ingredients[1:] or '\\item ' - self.mdparser.renderer.record = recipe - instructions_html = self.mdparser(recipe['directions'].source) - instructions = sed_repl_tex(f_sed_a, instructions_html) - return self.process_recipe(recipe, alt, ingredients, instructions) - pass - - def process_recipe(self, recipe, alt, ingredients, instructions): - img = helper.title_image(recipe) - if img: - img = img.path[:-4] + '@200x150_crop' + img.path[-4:] - duration = recipe['time'] - host = recipe['source'].host - time = timedur.to_duration(duration, alt) if duration else '' - srcUrl = raw_text_to_tex(str(recipe['source'])) or '' - srcHost = raw_text_to_tex(host) if host else '' - return f''' -\\newrecipe{{{recipe['_slug']}}}{{{raw_text_to_tex(recipe['name'])}}} -\\meta{{{time}}}{{{recipe['yield'] or ''}}} -\\footer{{{srcUrl}}}{{{srcHost}}} - -\\begin{{ingredients}}{{{img or ''}}} -{ingredients} -\\end{{ingredients}} - -{instructions} -''' - - def process_ingredient(self, ing, alt): - grp = ing.get('group') - if grp: - return f'\\ingGroup{{{grp}}}' - - ret = '' - val = ing['value'] - meas = ing['measure'] - note = ing['note'] - ret += '\\item' - if val or meas: - sep = '~' if val and meas else '' - ret += '[{}{}{}]'.format(val or '', sep, meas or '') - ret += f' \\ingName{{{ ing["name"] }}}' # keep space in front - if note: - ret += '\\ingDetail{' - for prt in note.split(): - if prt.startswith('@../'): - ret += f' \\pagelink{{{ prt[4:].rstrip("/") }}}' - else: - ret += ' ' + prt - ret += '}' - return ret - - -class HtmlToTex(Plugin): - name = u'HTML to TEX converter' - description = u'Will convert html formatted text to (la)tex format.' - - def on_after_prune(self, builder, **extra): - maketex = bool(builder.extra_flags.get('ENABLE_PDF_EXPORT')) - print('PDF Export: ' + ('ENABLED' if maketex else 'DISABLED')) - if not maketex: - return - - dest_dir = my_dir - for x in range(3): - dest_dir = os.path.dirname(dest_dir) - dest_dir = os.path.join(dest_dir, 'extras', 'pdf-export') - - start_time = time.time() - print('PDF Export: generate tex files') - with open(os.path.join(dest_dir, 'dyn-builddir.tex'), 'w') as f: - # Export current build dir (for image search) - f.write('\\def\\builddir{' + builder.destination_path + '}') - parser = RecipeToTex(self.env.jinja_env.filters['enumIngredients']) - for alt in self.env.load_config().list_alternatives(): - tex = '' - for recipe in builder.pad.get('/recipes', alt=alt).children: - tex += parser.make(recipe, alt) - fname = os.path.join(dest_dir, f'dyn-recipes-{alt}.tex') - with open(fname, 'w') as f: - f.write(tex) - print('PDF Export: done in %.2f sec' % (time.time() - start_time)) - - def on_setup_env(self, **extra): - self.env.jinja_env.filters['raw_text_to_tex'] = raw_text_to_tex diff --git a/src/packages/html-to-tex/setup.py b/src/packages/html-to-tex/setup.py deleted file mode 100755 index 6f1c006..0000000 --- a/src/packages/html-to-tex/setup.py +++ /dev/null @@ -1,12 +0,0 @@ -from setuptools import setup - -setup( - name='lektor-html-to-tex', - py_modules=['lektor_html-to-tex'], - version='1.0', - entry_points={ - 'lektor.plugins': [ - 'html-to-tex = lektor_html_to_tex:HtmlToTex', - ] - } -) diff --git a/src/packages/helper/.gitignore b/src/packages/main/.gitignore similarity index 100% rename from src/packages/helper/.gitignore rename to src/packages/main/.gitignore diff --git a/src/packages/main/lektor_main/__init__.py b/src/packages/main/lektor_main/__init__.py new file mode 100644 index 0000000..687b6b4 --- /dev/null +++ b/src/packages/main/lektor_main/__init__.py @@ -0,0 +1 @@ +from .plugin import MainPlugin # noqa: F401 diff --git a/src/packages/main/lektor_main/durationcluster.py b/src/packages/main/lektor_main/durationcluster.py new file mode 100644 index 0000000..c62ad24 --- /dev/null +++ b/src/packages/main/lektor_main/durationcluster.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +from typing import List, Optional, Dict, Union + + +def int_to_cluster(time: Union[str, int, None], clusters: List[int]) \ + -> Optional[int]: + ''' + Choose cluster where time >= X and time < X+1 + Example: `[30, 60, 120]` -> (0-29, 30-59, 60-119, 120+) + Return values are: 36 -> 60, 12 -> 30, 120 -> 121. + ''' + time = int(time) if time else 0 + if not time: + return None + for x in clusters: + if x > time: + return x + return clusters[-1] + 1 + + +def cluster_as_str( + time: Union[str, int, None], + clusters: List[int], + translations: Dict[str, str], +) -> Optional[str]: + ''' Return descriptive duration range; 30 -> "15 min – 29 min". ''' + time = int(time) if time else 0 + if not time: + return None + for idx, x in enumerate(clusters): + if x == time: + if idx == 0: + return '<' + human_readable_duration(time, translations) + timeA = human_readable_duration(clusters[idx - 1], translations) + timeB = human_readable_duration(time - 1, translations) + return u'{} – {}'.format(timeA, timeB) + return '>' + human_readable_duration(clusters[-1], translations) + + +def human_readable_duration( + time: Union[str, int, None], translations: Dict[str, str] +) -> str: + ''' + Take an arbitrary int and return readable duration string. + For example, 16 -> "16 mins", 121 -> "2 hours 1 min" + ''' + time = int(time) if time else 0 + if (time <= 0): + return '' + + days = time // (60 * 24) + time -= days * (60 * 24) + hours = time // 60 + mins = time - hours * 60 + ret = '' + if days: + ret += f'{days} {translations["day" if days == 1 else "days"]}' + if hours: + if ret: + ret += ' ' + ret += f'{hours} {translations["hour" if hours == 1 else "hours"]}' + if mins: + if ret: + ret += ' ' + ret += f'{mins} {translations["min" if mins == 1 else "mins"]}' + return ret diff --git a/src/packages/main/lektor_main/ingredients.py b/src/packages/main/lektor_main/ingredients.py new file mode 100644 index 0000000..c274e77 --- /dev/null +++ b/src/packages/main/lektor_main/ingredients.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +from lektor.types import Type +from typing import TYPE_CHECKING, List, Optional, Any +if TYPE_CHECKING: + from lektor.db import Record + from lektor.types.base import RawValue +from .utils import replaceFractions +from .settings import IngredientConfig + + +class IngredientEntry: + @property + def isGroup(self) -> bool: + return False + + @property + def isIngredient(self) -> bool: + return False + + +class IngredientGroup(IngredientEntry): + @property + def isGroup(self) -> bool: + return True + + def __init__(self, line: str) -> None: + self.name = line + + def __repr__(self) -> str: + return ''.format(self.name) + + +class Ingredient(IngredientEntry): + @property + def isIngredient(self) -> bool: + return True + + def __init__(self, line: str, conf: IngredientConfig) -> None: + idx = Ingredient.split_raw(line) + # parse quantity + self.quantity = line[:idx[0]] + if conf.frac_map: + self.quantity = replaceFractions(self.quantity, conf.frac_map) + # parse unit + unit = line[idx[0]:idx[1]].lstrip() + if unit in conf.units: + name = line[idx[1]:].lstrip() + else: + unit, name = '', line[idx[0]:].lstrip() + self.unit = unit + # parse ingredient name + note + note = '' + name_note = name.split(',', 1) + if len(name_note) > 1: + name, note = [x.strip() for x in name_note] + self.name = name + self.note = note + + @staticmethod + def split_raw(line: str) -> List[int]: + state = 1 + capture = False + indices = [0, len(line)] + for i, char in enumerate(line): + if char.isspace(): + if capture: + capture = False + indices[state] = i + state += 1 + continue + elif capture: + continue + elif state == 1 and char in u'0123456789-–—.,': + state -= 1 + elif state > 1: + break + capture = True + return indices + + def __repr__(self) -> str: + return ''.format( + self.name, self.quantity, self.unit, self.note) + + +class IngredientsDescriptor: + def __init__(self, raw: Optional[str]) -> None: + self.raw = raw + + def parse(self, raw: str, record: 'Record') -> List[IngredientEntry]: + conf = IngredientConfig.of(record) + ret = [] # type: List[IngredientEntry] + for line in raw.splitlines(True): # we need to strip anyway + line = line.strip() + if line: + if line.endswith(':'): + ret.append(IngredientGroup(line.rstrip(':'))) + else: + ret.append(Ingredient(line, conf)) + return ret + + def __get__(self, record: 'Record', _: Any = None) -> Any: + if record is None: + return self + if not self.raw: + return [] + data = self.parse(self.raw, record) + del self.raw + return data + + +class IngredientsListType(Type): + widget = 'multiline-text' + + def value_from_raw(self, raw: 'RawValue') -> IngredientsDescriptor: + return IngredientsDescriptor(raw.value or None) diff --git a/src/packages/main/lektor_main/latex.py b/src/packages/main/lektor_main/latex.py new file mode 100644 index 0000000..0d29902 --- /dev/null +++ b/src/packages/main/lektor_main/latex.py @@ -0,0 +1,302 @@ +# -*- coding: utf-8 -*- +from lektor.build_programs import BuildProgram # subclass +from lektor.reporter import reporter # build, verbosity +from lektor.sourceobj import VirtualSourceObject # subclass +import click +import os +import re +import shutil # which, copyfile, rmtree +import subprocess as shell # run, DEVNULL +from weakref import ref +from typing import TYPE_CHECKING, List, Tuple, Generator +if TYPE_CHECKING: + from lektor.builder import Artifact, Builder, BuildState + from lektor.db import Record + from lektor.environment import Environment +from .utils import lookup_template_path + +VPATH = 'LatexPDF' +TEXER = 'lualatex' +PDF_OUT_DIR = 'out' + +# ---------------------------------------------------- +# Sed re-format html as latex +# ---------------------------------------------------- + +HTML_TO_LATEX_RULES = [ + # character set translations for LaTex special chars + (r'>.', r'>'), + (r'<.', r'<'), + (r'\\', r'\\backslash '), + (r'{', r'\\{'), + (r'}', r'\\}'), + (r'%', r'\\%'), + (r'\$', r'\\$'), + (r'&', r'\\&'), + (r'#', r'\\#'), + (r'_', r'\\_'), + (r'~', r'\\~'), + (r'\^', r'\\^'), + (r' ', r'~'), + # Paragraph borders + (r'

    ', r'\\par '), + (r'

    ', r''), + # Headings + (r'([^<]*)', r'\\section*{\1}'), + (r'', r'\\part{'), + (r'', r'}'), + (r'

    ', r'\\section*{'), + (r'', r'}'), + (r'

    ', r'\\subsection*{'), + (r'

    ', r'\\subsubsection*{'), + (r'

    ', r'\\paragraph*{'), + (r'

    ', r'\\paragraph*{'), + (r'
    ', r'\\subparagraph*{'), + # UL is itemize + (r'
      ', r'\\begin{itemize}'), + (r'
    ', r'\\end{itemize}'), + (r'
      ', r'\\begin{enumerate}'), + (r'
    ', r'\\end{enumerate}'), + (r'
  • ', r'\\item '), + (r'
  • ', r''), + # DL is description + (r'
    ', r'\\begin{description}'), + (r'
    ', r'\\end{description}'), + # closing delimiter for DT is first < or end of line which ever comes first + # (r'
    ([^<]*)<', r'\\item[\1]<'), + # (r'
    ([^<]*)$', r'\\item[\1]'), + # (r'
    ', r''), + # (r'
    ', r'\\item['), + # (r'
    ', r']'), + (r'
    ([^<]*)
    ', r'\\item[\1]'), + (r'
    ', r''), + (r'
    ', r''), + # Italics + (r'([^<]*)', r'{\\it \1}'), + (r'([^<]*)', r'{\\it \1}'), + (r'([^<]*)', r'{\\bf \1}'), + (r'([^<]*)', r'{\\bf \1}'), + # recipe specific + (r'([^<]*)', r'\\recipelink{\1}{\2}'), + # Get rid of Anchors + (r'([^<]*)', r'\\external{\1}{\2}'), + (r']*>', r''), + (r'', r''), + # quotes (replace after href) + (r'(\s)"([^\s])', r'\1``\2'), + (r'"', r"''"), +] + + +def __test__() -> None: + TEST_DATA = ''' + Hello > is < for + chars: " " & # _ ~ ^ % $ { } + \\. + +

    paragraph

    + + this is a title + this is a part +

    this is a section

    +

    this is a subsection

    +

    this is a subsubsection

    +

    this is a paragraph

    +
    this is a paragraph
    +
    this is a subparagraph
    + +
      +
    • unordered one
    • +
    • unordered two
    • +
    + +
      +
    1. ordered one
    2. +
    3. ordered two
    4. +
    + +
    +
    definition title
    +
    definition value
    +
    + + this is it + this is em + this is b + this is strong + + external anchor + recipe anchor + other anchor + + between "some" text + +

    test

    + ''' + print(Sed.apply(HTML_TO_LATEX_RULES, TEST_DATA)) + exit(0) + + +class Sed: + def __init__(self, rules: List[Tuple[str, str]]) -> None: + self._rules = [(re.compile(pattern), sub) for pattern, sub in rules] + + def replace(self, data: str) -> str: + ret = data + for regx, repl in self._rules: + ret = regx.sub(repl, ret) + return ret + + @staticmethod + def apply(rules: List[Tuple[str, str]], data: str) -> str: + ret = data + for pattern, repl in rules: + ret = re.sub(pattern, repl, ret) + return ret + + +def html_to_tex(html: str) -> str: + return Sed.apply(HTML_TO_LATEX_RULES, html) + + +def raw_text_to_tex(text: str) -> str: + if not text: + return '' + text = text.replace('\\', '\\backslash ') + for c in '}{%$&#_~^': + text = text.replace(c, '\\' + c) + return text.replace(' ', '~') + + +# ---------------------------------------------------- +# Helper methods +# ---------------------------------------------------- + +def _report_updated(msg: str) -> None: + click.echo('{} {}'.format(click.style('U', fg='green'), msg)) + + +def _report_error(msg: str) -> None: + click.echo('{} {}'.format(click.style('E', fg='red'), msg)) + + +# ---------------------------------------------------- +# PDF Build Program & Source +# ---------------------------------------------------- + +class TexSources: + enabled: bool = False + + @staticmethod + def registerBuilder(env: 'Environment', enabled: bool) -> None: + TexSources.enabled = enabled + env.add_build_program(PdfSource, PdfBuildProgram) + + @env.virtualpathresolver(VPATH) + def resolvePDF(record: 'Record', pieces: List[str]) \ + -> PdfSource: + return PdfSource(record) + + @staticmethod + def add(builder: 'Builder', record: 'Record') -> None: + if TexSources.enabled: + try: + refs = builder.__tex_files # type: ignore[attr-defined] + except AttributeError: + refs = list() + builder.__tex_files = refs # type: ignore[attr-defined] + refs.append(ref(record)) + + @staticmethod + def build(builder: 'Builder') -> None: + if not TexSources.enabled: + print(' * PDF Export: DISABLED') + return + try: + sources = builder.__tex_files # type: ignore[attr-defined] + del builder.__tex_files # type: ignore[attr-defined] + except AttributeError: + sources = [] + + if sources: + msg = f'PDF builder ({TEXER})' + with reporter.build(msg, builder): # type: ignore[attr-defined] + for rec_ref in sources: + builder.build(PdfSource(rec_ref())) + + +# ---------------------------------------------------- +# PDF Build Program & Source +# ---------------------------------------------------- + +class PdfBuildProgram(BuildProgram): + source: 'PdfSource' + + def produce_artifacts(self) -> None: + self.declare_artifact( + self.source.url_path, + sources=list(self.source.iter_source_filenames())) + + def build_artifact(self, artifact: 'Artifact') -> None: + self.source.build(self.build_state) + + +class PdfSource(VirtualSourceObject): + @property + def path(self) -> str: # type: ignore[override] + return self.record.path + '@' + VPATH # type: ignore[no-any-return] + + @property + def url_path(self) -> str: # type: ignore[override] + return self.record.url_path[:-4] + '.pdf' # type:ignore[no-any-return] + + def iter_source_filenames(self) -> Generator[str, None, None]: + template = lookup_template_path(self.record['_template'], self.pad.env) + if template: + yield template + yield from self.record.iter_source_filenames() + + def build(self, build_state: 'BuildState') -> None: + cmd_tex = shutil.which(TEXER) + if not cmd_tex: + _report_error(f'Skip PDF export. {TEXER} not found.') + return + + # filename / path variables + tex_src = build_state.get_destination_filename(self.record.url_path) + pdf_dest = build_state.get_destination_filename(self.url_path) + build_dir = build_state.builder.destination_path + tex_root = os.path.join(build_state.env.root_path, '_tex-to-pdf') + tmp_dir = os.path.join(tex_root, PDF_OUT_DIR) + pdf_src = os.path.join(tmp_dir, os.path.basename(tex_src)[:-3] + 'pdf') + + # create temporary output directory + os.makedirs(tmp_dir, exist_ok=True) + + # store build destination to resolve image paths in setup.tex + with open(os.path.join(tmp_dir, 'builddir.tex'), 'w') as fp: + fp.write('\\def\\buildDir{' + build_dir + '}') + + # run lualatex + silent = reporter.verbosity == 0 # type: ignore[attr-defined] + for i in range(1, 3): + if i > 1: + _report_updated(self.url_path.lstrip('/') + f' [{i}/2]') + p = shell.run([ + cmd_tex, # lualatex + '--halt-on-error', + '--output-directory', tmp_dir, + tex_src # tex file + ], + cwd=tex_root, # change work dir so lualatex can find setup.tex + stdout=shell.DEVNULL if silent else None, # dont spam console + input=b'') # auto-reply to stdin on error + + if p.returncode == 0: + shutil.copyfile(pdf_src, pdf_dest) + else: + _report_error(f'{TEXER} returned error code {p.returncode}') + break + + # cleanup + shutil.rmtree(tmp_dir, ignore_errors=True) diff --git a/src/packages/main/lektor_main/plugin.py b/src/packages/main/lektor_main/plugin.py new file mode 100644 index 0000000..f1730d7 --- /dev/null +++ b/src/packages/main/lektor_main/plugin.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +from lektor.databags import Databags +from lektor.db import Page # isinstance +from lektor.pluginsystem import Plugin +from datetime import datetime +from typing import TYPE_CHECKING, Any, List, Dict, Tuple, Set, Iterator +if TYPE_CHECKING: + from lektor.builder import Builder + from lektor.db import Record + +from .durationcluster import ( + int_to_cluster, cluster_as_str, human_readable_duration +) +from .ingredients import IngredientsListType +from .latex import TexSources, raw_text_to_tex, html_to_tex +from .settings import Settings +from .utils import ( + fillupText, replace_atref_urls, sorted_images, title_image, noUmlauts +) + + +class MainPlugin(Plugin): + name = 'Main Plugin' + description = 'Code snippets for recipe lekture.' + + def on_setup_env(self, **extra: Any) -> None: + # Register custom field type + self.env.add_type(IngredientsListType) + # Add jinja filter & globals + self.env.jinja_env.filters.update({ + 'asDuration': self.as_duration, # needs alt + 'asRating': self.as_rating, + 'replaceAtRefURLs': replace_atref_urls, + 'performGroupBy': self.performGroupBy, + + 'sorted_images': sorted_images, + 'title_image': title_image, + + 'latexStr': raw_text_to_tex, + 'latexHtml': html_to_tex, + }) + # self.env.jinja_env.globals.update({ + # 'str': str, + # 'dir': dir, + # 'type': type, + # 'len': len, + # 'now': datetime.now + # }) + # Latex -> PDF Build program + make_pdf = extra.get('extra_flags', {}).get('ENABLE_PDF_EXPORT', False) + TexSources.registerBuilder(self.env, enabled=make_pdf) + + def on_after_build( + self, builder: 'Builder', source: 'Record', **extra: Any + ) -> None: + if not isinstance(source, Page): + return # ignore Asset, Directory, etc. + if source.path.endswith('.tex'): # type: ignore[attr-defined] + TexSources.add(builder, source) + + def on_after_build_all(self, builder: 'Builder', **extra: Any) -> None: + # must run after all sources are built + # or else latex fails because it cannot find referenced images + TexSources.build(builder) + + ############## + # Duration # + ############## + + def _i18n(self, alt: str, key: str) -> Dict[str, str]: + # used for dependency tracking + return Databags(self.env).lookup( # type: ignore[no-any-return] + f'i18n+{alt}.{key}') + + def as_duration(self, time: int, alt: str) -> str: + return human_readable_duration(time, self._i18n(alt, 'duration')) + + def as_rating(self, x: str) -> str: + return fillupText(x, u'☆', u'★', 3) + + ##################### + # Group by filter # + ##################### + + def performGroupBy( + self, + recipes: List['Page'], + attribute: str, + reverse: bool = False, + alt: str = 'en', + ) -> Iterator[Tuple[str, Set['Page']]]: + # Pre-Processing + if attribute == 'time': + time_clusters = Settings.duration_cluster() + time_translations = self._i18n(alt, 'duration') + elif attribute == 'difficulty': + difficulty_translations = self._i18n(alt, 'difficulty') + + # Grouping + ret = dict() # type: Dict[Any, Set[Page]] + for recipe in recipes: + try: + data = recipe[attribute] or None + except KeyError: + continue + if attribute == 'time': + data = [int_to_cluster(data, time_clusters)] + elif attribute == 'ingredients' and data: + data = [x.name for x in data or [] if x.isIngredient] + else: + data = [data] + for x in data: + if x not in ret: + ret[x] = set() + ret[x].add(recipe) + + # Sorting + reverse = bool(reverse) + if attribute == 'difficulty': + order = ['easy', 'medium', 'hard'] + none_diff = -1 if reverse else 99 + + def _fn(x: Tuple) -> Any: + return order.index(x[0]) if x[0] in order else none_diff + elif attribute == 'ingredients': # sort by: str + none_ingr = 'aaaa' if reverse else 'zzzz' + + def _fn(x: Tuple) -> Any: + return noUmlauts(x[0]).lower() if x[0] else none_ingr + else: # sort by: int + none_int = 0 if reverse else 999999999 + + def _fn(x: Tuple) -> Any: + return int(x[0]) if x[0] else none_int + + result = sorted(ret.items(), reverse=bool(reverse), key=_fn) + + # Post-Processing + for group, recipe_list in result: + if attribute == 'time': + group = cluster_as_str(group, time_clusters, time_translations) + elif attribute == 'rating': + group = self.as_rating(group) + elif attribute == 'difficulty': + group = difficulty_translations.get(group) + + yield group, recipe_list diff --git a/src/packages/main/lektor_main/settings.py b/src/packages/main/lektor_main/settings.py new file mode 100644 index 0000000..0813f7f --- /dev/null +++ b/src/packages/main/lektor_main/settings.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +from lektor.context import get_ctx +from typing import TYPE_CHECKING, List, Dict, Optional, NamedTuple +if TYPE_CHECKING: + from lektor.db import Pad, Page, Record + + +class IngredientConfig(NamedTuple): + units: List[str] + frac_map: Dict[str, str] + + @staticmethod + def of(record: 'Record') -> 'IngredientConfig': + return Settings.ingredient_config(record.pad, record.alt) + + +class Settings: + @staticmethod + def load(pad: Optional['Pad'] = None, alt: Optional[str] = None) -> 'Page': + if not pad: + ctx = get_ctx() + if not ctx: + raise RuntimeError('Should never happen, missing context.') + pad = ctx.pad + if not alt: + alt = ctx.source.alt + assert(pad is not None) + # used for dependency tracking + return pad.get('/settings', alt=alt) # type: ignore[no-any-return] + + @staticmethod + def ingredient_config(pad: 'Pad', alt: str) -> IngredientConfig: + set = Settings.load(pad, alt) + mea = set['measures'].split() + frac = {} + if set['replace_frac']: + it = iter(set['replace_frac_map'].split()) + frac = dict(zip(it, it)) + return IngredientConfig(mea, frac) + + # def ingredient_config_old(record: 'Record') -> IngredientConfig: + # plugin = record.pad.env.plugins['main'] # type: MainPlugin + # cfg = plugin.get_config() # used for dependency tracking + # mea = cfg.get('measures.' + alt, '').split() + # frac = {} + # if cfg.get_bool('general.replace_frac'): + # frac = cfg.section_as_dict('replace_frac') + # return IngredientConfig(mea, frac) + + @staticmethod + def duration_cluster() -> List[int]: + # split() without args takes care of double spaces and trims each item + splits = Settings.load()['duration_cluster'].replace(',', ' ').split() + return [int(x) for x in splits] diff --git a/src/packages/main/lektor_main/utils.py b/src/packages/main/lektor_main/utils.py new file mode 100644 index 0000000..fd1abdf --- /dev/null +++ b/src/packages/main/lektor_main/utils.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +from jinja2.loaders import split_template_path # lookup_template_name() +from markupsafe import Markup +import os +import unicodedata +from typing import TYPE_CHECKING, Dict, Union, Optional, List +if TYPE_CHECKING: + from lektor.db import Page, Image + from lektor.environment import Environment + + +def fillupText( + numerator: Union[str, int, None], + empty: str = u'☆', + filled: str = u'★', + total: int = 3 +) -> str: + ''' + Create a progress-bar-like string from int or number string. + 0 -> "☆☆☆", 1 -> "★☆☆", 2 -> "★★☆", 3 -> "★★★", etc. + ''' + x = int(numerator) if numerator else 0 + return filled * x + empty * (total - x) + + +def replaceFractions(txt: str, repl_map: Dict[str, str]) -> str: + ''' Replace `1 1/2 - 3/4` with `1½–¾`, etc. ''' + res = ' ' + for c in u'-–—': + txt = txt.replace(c, ' – ') + # NOTE: `split(' ')` can contain empty values but `split()` does not! + for x in txt.split(): + if x == '–': + res += x + else: + res += repl_map.get(x) or (x if res[-1] == '–' else ' ' + x) + return res.lstrip(' ') + + +def replace_atref_urls(text: str, label: Optional[str] = None) -> str: + ''' Replace `@../recipe/` with `label` ''' + if '@' not in text: + return text + result = list() + for x in text.split(): + if x[0] == '@': + x = x[1:] + result.append(u'{}'.format(x, label or x)) + else: + result.append(x) + return Markup(' '.join(result)) + + +def sorted_images(obj: 'Page', attr: str = 'record_label') -> List['Image']: + return sorted(obj.attachments.images, + key=lambda x: getattr(x, attr)) # type:ignore[no-any-return] + + +def title_image(obj: 'Page', attr: str = 'record_label', small: bool = False) \ + -> Optional['Image']: + imgs = sorted_images(obj, attr) + img = imgs[0] if imgs else None + if img and small: + img = img.thumbnail(200, 150, mode='crop') + return img + + +def noUmlauts(text: str) -> str: + # try: + # data = unicode(text, 'utf-8') + # except (TypeError, NameError): + # pass + text = unicodedata.normalize('NFD', text) + data = text.encode('ascii', 'ignore') + text = data.decode('utf-8') + return str(text) + + +def lookup_template_path(name: str, env: 'Environment') -> Optional[str]: + pieces = split_template_path(name) + for base in env.jinja_env.loader.searchpath: + path = os.path.join(base, *pieces) + if os.path.isfile(path): + return path + return None diff --git a/src/packages/main/setup.py b/src/packages/main/setup.py new file mode 100644 index 0000000..17d7fa7 --- /dev/null +++ b/src/packages/main/setup.py @@ -0,0 +1,21 @@ +from setuptools import setup + +setup( + name='lektor-main', + packages=['lektor_main'], + entry_points={ + 'lektor.plugins': [ + 'main = lektor_main:MainPlugin', + ] + }, + author='relikd', + version='0.1', + description='Main code for this repository', + license='MIT', + python_requires='>=3.6', + keywords=['lektor', 'plugin'], + classifiers=[ + 'Environment :: Plugins', + 'Framework :: Lektor', + ], +) diff --git a/src/packages/time-duration/.gitignore b/src/packages/time-duration/.gitignore deleted file mode 100644 index 463960b..0000000 --- a/src/packages/time-duration/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -dist -build -*.pyc -*.pyo -*.egg-info diff --git a/src/packages/time-duration/README.md b/src/packages/time-duration/README.md deleted file mode 100644 index 2a3deb4..0000000 --- a/src/packages/time-duration/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# Time Duration - -This plugin converts integer numbers to a human readable duration. -E.g. 90 -> 1 hour 30 minutes \ No newline at end of file diff --git a/src/packages/time-duration/lektor_time_duration.py b/src/packages/time-duration/lektor_time_duration.py deleted file mode 100644 index b0e5696..0000000 --- a/src/packages/time-duration/lektor_time_duration.py +++ /dev/null @@ -1,87 +0,0 @@ -# -*- coding: utf-8 -*- -from lektor.pluginsystem import Plugin - -durationLocale = { - 'de': {'day': 'Tag', 'hour': 'Std', 'min': 'Min', - 'days': 'Tage', 'hours': 'Std', 'mins': 'Min'}, - 'en': {'day': 'day', 'hour': 'hour', 'min': 'min', - 'days': 'days', 'hours': 'hours', 'mins': 'min'} -} - -# ----------- -# Single Time - - -def pluralize(n, single, multi): - if n == 0: - return '' - return u'{} {}'.format(n, single if n == 1 else multi) - - -def to_duration(time, alt='en'): - time = int(time) if time else 0 - if (time <= 0): - return '' - days = time // (60 * 24) - time -= days * (60 * 24) - L = durationLocale[alt] - return ' '.join([ - pluralize(days, L['day'], L['days']), - pluralize(time // 60, L['hour'], L['hours']), - pluralize(time % 60, L['min'], L['mins'])]).strip() - -# ------------ -# Time Cluster - - -def to_time_in_cluster(time, cluster, alt='en'): - for idx, x in enumerate(cluster): - x = int(x) - if x == time: - if idx == 0: - timeB = to_duration(time, alt) - return '<' + timeB - else: - timeA = to_duration(cluster[idx - 1], alt) - timeB = to_duration(time - 1, alt) - return u'{} – {}'.format(timeA, timeB) - else: - return '>' + to_duration(cluster[-1], alt) - - -def find_in_cluster(key, clusterList=[30, 60, 120]): - key = int(key) if key else 0 - if key > 0: - for cluster in clusterList: - if key < cluster: - key = cluster - break - else: - key = clusterList[-1] + 1 - return key - - -def group_by_time_cluster(dic, arr=[30, 60, 120], reverse=False): - arr = sorted([int(x) for x in arr]) - groups = dict() - for key, recipes in dic: - key = find_in_cluster(key, arr) - if key == 0 and not reverse: - key = '' - try: - groups[key] - except KeyError: - groups[key] = set() - groups[key].update(recipes) - return sorted(groups.items(), reverse=bool(reverse), - key=lambda x: x[0] if x[0] != '' else 999999999) - - -class TimeDurationPlugin(Plugin): - name = u'Time Duration' - description = u'Convert int to duration. E.g., 90 -> "1hr 30min".' - - def on_setup_env(self, **extra): - self.env.jinja_env.filters['duration'] = to_duration - self.env.jinja_env.filters['durationCluster'] = to_time_in_cluster - self.env.jinja_env.filters['groupTimeCluster'] = group_by_time_cluster diff --git a/src/packages/time-duration/setup.py b/src/packages/time-duration/setup.py deleted file mode 100644 index ab52a49..0000000 --- a/src/packages/time-duration/setup.py +++ /dev/null @@ -1,38 +0,0 @@ -import ast -import io -import re - -from setuptools import setup, find_packages - -with io.open('README.md', 'rt', encoding="utf8") as f: - readme = f.read() - -_description_re = re.compile(r'description\s+=\s+(?P.*)') - -with open('lektor_time_duration.py', 'rb') as f: - description = str(ast.literal_eval(_description_re.search( - f.read().decode('utf-8')).group(1))) - -setup( - author=u'relikd', - author_email='oleg@relikd.de', - description=description, - keywords='Lektor plugin', - license='MIT', - long_description=readme, - long_description_content_type='text/markdown', - name='lektor-time-duration', - packages=find_packages(), - py_modules=['lektor_time_duration'], - # url='[link to your repository]', - version='0.1', - classifiers=[ - 'Framework :: Lektor', - 'Environment :: Plugins', - ], - entry_points={ - 'lektor.plugins': [ - 'time-duration = lektor_time_duration:TimeDurationPlugin', - ] - } -) diff --git a/src/templates/cluster.html b/src/templates/cluster.html index 779bf4a..5d875ed 100644 --- a/src/templates/cluster.html +++ b/src/templates/cluster.html @@ -1,33 +1,10 @@ {% extends "layout.html" %} {% block title %}{{ this.name }}{% endblock %} {% block body %} - - {%- if this.group_key in ['rating', 'time'] -%} - {%- set sortType = 'int' -%} - {%- elif this.xdata -%} - {%- set sortType = this.xdata + [''] -%} - {%- endif -%} - - {%- set all = site.query('/recipes', this.alt) | groupByAttribute(this.group_key, this.alt) | groupSort(sortType, this.reverse_order) -%} - - {%- if this.group_key == 'time' -%} - {%- set all = all | groupTimeCluster(this.xdata, this.reverse_order) -%} - {%- endif -%} -

    {{ this.name }}

    - {%- for attrib, recipes in all -%} -
    {%- if this.group_key == 'rating' -%} - {{ attrib | rating }} - {%- elif not attrib -%} - {{ this.null_fallback }} - {%- elif this.group_key == 'time' -%} - {{ attrib | durationCluster(this.xdata, this.alt) }} - {%- elif this.group_key == 'difficulty' -%} - {{ bag('i18n+' + this.alt, 'difficulty', attrib) }} - {%- else -%} - {{ attrib }} - {%- endif -%}
    + {%- for group_name, recipes in site.query('/recipes', this.alt) | performGroupBy(this.group_key, this.reverse_order, this.alt) -%} +
    {{ group_name or this.null_fallback }}
    {%- set pipe = joiner(' | ') -%} {%- for recipe in recipes | sort(attribute='name') -%} diff --git a/src/templates/macros/recipes.html b/src/templates/macros/recipes.html index c612aff..de7d040 100644 --- a/src/templates/macros/recipes.html +++ b/src/templates/macros/recipes.html @@ -8,7 +8,7 @@ {#- overlay on hover and always-visible icons #}
    -
    {{ recipe.time|duration(recipe.alt) }}
    +
    {{ recipe.time | asDuration(recipe.alt) }}
    {%- if 'raw' in recipe.tags -%}{%- endif -%} {%- if 'glutenfree' in recipe.tags -%}{%- endif -%} diff --git a/src/templates/recipe.html b/src/templates/recipe.html index 02cfee3..71b4f3b 100644 --- a/src/templates/recipe.html +++ b/src/templates/recipe.html @@ -21,29 +21,29 @@

    {{ this.name }}

    -
    {{ this.rating|rating }}
    +
    {{ this.rating | asRating }}
    {{ bag('i18n+' + this.alt, 'difficulty', this.difficulty or '_unset') }}
    -
    {{ bag('i18n+' + this.alt, 'duration.label') }}: {{ this.time|duration(this.alt) if this.time else '—' }}
    +
    {{ bag('i18n+' + this.alt, 'duration.label') }}: {{ this.time | asDuration(this.alt) or '—' }}
    {{ bag('i18n+' + this.alt, 'yield.label') }}: {{ this.yield or '—' }}

    {{ bag('i18n+' + this.alt, 'title.ingredients') }}:

      - {%- for ing in this|enumIngredients(this.alt) %} - {%- if ing['group'] %} -
    • {{ ing['group'] }}
    • + {%- for ing in this.ingredients %} + {%- if ing.isGroup %} +
    • {{ ing.name }}:
    • {%- else %}
    • - {%- if ing['value'] %}{{ ing['value'] }} {% endif -%} - {%- if ing['measure'] %}{{ ing['measure'] }} {% endif -%} - {{ ing['name'] }} - {%- if ing['note'] -%} - {{ ', ' ~ ing['note'] | replaceAtRefURLs(label=bag('i18n+' + this.alt, 'ingredients.recipeLink')) }} + {%- if ing.quantity %}{{ ing.quantity }} {% endif -%} + {%- if ing.unit %}{{ ing.unit }} {% endif -%} + {{ ing.name }} + {%- if ing.note -%} + {{ ', ' ~ ing.note | replaceAtRefURLs(label=bag('i18n+' + this.alt, 'ingredients.recipeLink')) }} {%- endif -%}
    • {%- endif %}