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??\\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]\([^<]*\)$?\\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
+
+
+
+ - ordered one
+ - ordered two
+
+
+
+ - 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 %}