refactor: full rewrite of app logic (better, faster, type-safe)
This commit is contained in:
@@ -1,9 +1,3 @@
|
||||
sort_key: 10
|
||||
---
|
||||
group_key: difficulty
|
||||
---
|
||||
xdata:
|
||||
|
||||
easy
|
||||
medium
|
||||
hard
|
||||
|
||||
@@ -3,3 +3,7 @@ sort_key: 15
|
||||
group_key: rating
|
||||
---
|
||||
reverse_order: yes
|
||||
---
|
||||
null_fallback: ☆☆☆
|
||||
---
|
||||
name:
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
sort_key: 20
|
||||
---
|
||||
group_key: time
|
||||
---
|
||||
xdata:
|
||||
|
||||
15
|
||||
30
|
||||
60
|
||||
120
|
||||
180
|
||||
360
|
||||
1440
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
[duration]
|
||||
label = Zeit
|
||||
day = Tag
|
||||
days = Tage
|
||||
hour = Std
|
||||
hours = Std
|
||||
min = Min
|
||||
mins = Min
|
||||
|
||||
[yield]
|
||||
label = Menge
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
[duration]
|
||||
label = Time
|
||||
day = day
|
||||
days = days
|
||||
hour = hour
|
||||
hours = hours
|
||||
min = min
|
||||
mins = min
|
||||
|
||||
[yield]
|
||||
label = Yield
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Helper
|
||||
|
||||
Just some python functions that are necessary for the project.
|
||||
@@ -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'<a href="{}">{}</a>'.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
|
||||
@@ -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<description>.*)')
|
||||
|
||||
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',
|
||||
]
|
||||
}
|
||||
)
|
||||
5
src/packages/html-to-tex/.gitignore
vendored
5
src/packages/html-to-tex/.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
dist
|
||||
build
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info
|
||||
@@ -1,61 +0,0 @@
|
||||
# character set translations for LaTex special chars
|
||||
s?>.?>?g
|
||||
s?<.?<?g
|
||||
s?\\?\\backslash ?g
|
||||
s?{?\\{?g
|
||||
s?}?\\}?g
|
||||
s?%?\\%?g
|
||||
s?\$?\\$?g
|
||||
s?&?\\&?g
|
||||
s?#?\\#?g
|
||||
s?_?\\_?g
|
||||
s?~?\\~?g
|
||||
s?\^?\\^?g
|
||||
s? ?~?g
|
||||
# Paragraph borders
|
||||
s?<p>?\\par ?g
|
||||
s?</p>??g
|
||||
# Headings
|
||||
s?<title>\([^<]*\)</title>?\\section*{\1}?g
|
||||
s?<hn>?\\part{?g
|
||||
s?</hn>?}?g
|
||||
s?<h1>?\\section*{?g
|
||||
s?</h[0-9]>?}?g
|
||||
s?<h2>?\\subsection*{?g
|
||||
s?<h3>?\\subsubsection*{?g
|
||||
s?<h4>?\\paragraph*{?g
|
||||
s?<h5>?\\paragraph*{?g
|
||||
s?<h6>?\\subparagraph*{?g
|
||||
# UL is itemize
|
||||
s?<ul>?\\begin{itemize}?g
|
||||
s?</ul>?\\end{itemize}?g
|
||||
s?<ol>?\\begin{enumerate}?g
|
||||
s?</ol>?\\end{enumerate}?g
|
||||
s?<li>?\\item ?g
|
||||
s?</li>??g
|
||||
# DL is description
|
||||
s?<dl>?\\begin{description}?g
|
||||
s?</dl>?\\end{description}?g
|
||||
# closing delimiter for DT is first < or end of line which ever comes first NO
|
||||
#s?<dt>\([^<]*\)<?\\item[\1]<?g
|
||||
#s?<dt>\([^<]*\)$?\\item[\1]?g
|
||||
#s?<dd>??g
|
||||
#s?<dt>?\\item[?g
|
||||
#s?<dd>?]?g
|
||||
s?<dt>\([^<]*\)</dt>?\\item[\1]?g
|
||||
s?<dd>??g
|
||||
s?</dd>??g
|
||||
# Italics
|
||||
s?<it>\([^<]*\)</it>?{\\it \1}?g
|
||||
s?<em>\([^<]*\)</em>?{\\it \1}?g
|
||||
s?<b>\([^<]*\)</b>?{\\bf \1}?g
|
||||
s?<strong>\([^<]*\)</strong>?{\\bf \1}?g
|
||||
# recipe specific
|
||||
s?<a href="../\([^"/]*\)/*">\([^<]*\)</a>?\\recipelink{\1}{\2}?g
|
||||
# Get rid of Anchors
|
||||
s?<a href="\(http[^"]*\)">\([^<]*\)</a>?\\external{\1}{\2}?g
|
||||
s?<a[^>]*>??g
|
||||
s?</a>??g
|
||||
# quotes (replace after href)
|
||||
s?\([[:space:]]\)"\([^[:space:]]\)?\1``\2?g
|
||||
s?"?''?g
|
||||
@@ -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
|
||||
@@ -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',
|
||||
]
|
||||
}
|
||||
)
|
||||
1
src/packages/main/lektor_main/__init__.py
Normal file
1
src/packages/main/lektor_main/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .plugin import MainPlugin # noqa: F401
|
||||
66
src/packages/main/lektor_main/durationcluster.py
Normal file
66
src/packages/main/lektor_main/durationcluster.py
Normal file
@@ -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
|
||||
115
src/packages/main/lektor_main/ingredients.py
Normal file
115
src/packages/main/lektor_main/ingredients.py
Normal file
@@ -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 '<IngredientGroup name="{}">'.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 '<Ingredient "{}" qty="{}" unit="{}" note="{}">'.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)
|
||||
302
src/packages/main/lektor_main/latex.py
Normal file
302
src/packages/main/lektor_main/latex.py
Normal file
@@ -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'<p>', r'\\par '),
|
||||
(r'</p>', r''),
|
||||
# Headings
|
||||
(r'<title>([^<]*)</title>', r'\\section*{\1}'),
|
||||
(r'<hn>', r'\\part{'),
|
||||
(r'</hn>', r'}'),
|
||||
(r'<h1>', r'\\section*{'),
|
||||
(r'</h[0-9]>', r'}'),
|
||||
(r'<h2>', r'\\subsection*{'),
|
||||
(r'<h3>', r'\\subsubsection*{'),
|
||||
(r'<h4>', r'\\paragraph*{'),
|
||||
(r'<h5>', r'\\paragraph*{'),
|
||||
(r'<h6>', r'\\subparagraph*{'),
|
||||
# UL is itemize
|
||||
(r'<ul>', r'\\begin{itemize}'),
|
||||
(r'</ul>', r'\\end{itemize}'),
|
||||
(r'<ol>', r'\\begin{enumerate}'),
|
||||
(r'</ol>', r'\\end{enumerate}'),
|
||||
(r'<li>', r'\\item '),
|
||||
(r'</li>', r''),
|
||||
# DL is description
|
||||
(r'<dl>', r'\\begin{description}'),
|
||||
(r'</dl>', r'\\end{description}'),
|
||||
# closing delimiter for DT is first < or end of line which ever comes first
|
||||
# (r'<dt>([^<]*)<', r'\\item[\1]<'),
|
||||
# (r'<dt>([^<]*)$', r'\\item[\1]'),
|
||||
# (r'<dd>', r''),
|
||||
# (r'<dt>', r'\\item['),
|
||||
# (r'<dd>', r']'),
|
||||
(r'<dt>([^<]*)</dt>', r'\\item[\1]'),
|
||||
(r'<dd>', r''),
|
||||
(r'</dd>', r''),
|
||||
# Italics
|
||||
(r'<it>([^<]*)</it>', r'{\\it \1}'),
|
||||
(r'<em>([^<]*)</em>', r'{\\it \1}'),
|
||||
(r'<b>([^<]*)</b>', r'{\\bf \1}'),
|
||||
(r'<strong>([^<]*)</strong>', r'{\\bf \1}'),
|
||||
# recipe specific
|
||||
(r'<a href="\.\./([^"/]*)/*">([^<]*)</a>', r'\\recipelink{\1}{\2}'),
|
||||
# Get rid of Anchors
|
||||
(r'<a href="(http[^"]*)">([^<]*)</a>', r'\\external{\1}{\2}'),
|
||||
(r'<a[^>]*>', r''),
|
||||
(r'</a>', r''),
|
||||
# quotes (replace after href)
|
||||
(r'(\s)"([^\s])', r'\1``\2'),
|
||||
(r'"', r"''"),
|
||||
]
|
||||
|
||||
|
||||
def __test__() -> None:
|
||||
TEST_DATA = '''
|
||||
Hello > is < for
|
||||
chars: " " & # _ ~ ^ % $ { }
|
||||
\\.
|
||||
|
||||
<p>paragraph</p>
|
||||
|
||||
<title>this is a title</title>
|
||||
<hn>this is a part</hn>
|
||||
<h1>this is a section</h1>
|
||||
<h2>this is a subsection</h2>
|
||||
<h3>this is a subsubsection</h3>
|
||||
<h4>this is a paragraph</h4>
|
||||
<h5>this is a paragraph</h5>
|
||||
<h6>this is a subparagraph</h6>
|
||||
|
||||
<ul>
|
||||
<li>unordered one</li>
|
||||
<li>unordered two</li>
|
||||
</ul>
|
||||
|
||||
<ol>
|
||||
<li>ordered one</li>
|
||||
<li>ordered two</li>
|
||||
</ol>
|
||||
|
||||
<dl>
|
||||
<dt>definition title</dt>
|
||||
<dd>definition value</dd>
|
||||
</dl>
|
||||
|
||||
<it>this is it</it>
|
||||
<em>this is em</em>
|
||||
<b>this is b</b>
|
||||
<strong>this is strong</strong>
|
||||
|
||||
<a href="http://example.org">external anchor</a>
|
||||
<a href="../recipe">recipe anchor</a>
|
||||
<a href="language">other anchor</a>
|
||||
|
||||
between "some" text
|
||||
|
||||
<p class="name">test</p>
|
||||
'''
|
||||
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)
|
||||
147
src/packages/main/lektor_main/plugin.py
Normal file
147
src/packages/main/lektor_main/plugin.py
Normal file
@@ -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
|
||||
54
src/packages/main/lektor_main/settings.py
Normal file
54
src/packages/main/lektor_main/settings.py
Normal file
@@ -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]
|
||||
85
src/packages/main/lektor_main/utils.py
Normal file
85
src/packages/main/lektor_main/utils.py
Normal file
@@ -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 `<a href="../recipe/">label</a>` '''
|
||||
if '@' not in text:
|
||||
return text
|
||||
result = list()
|
||||
for x in text.split():
|
||||
if x[0] == '@':
|
||||
x = x[1:]
|
||||
result.append(u'<a href="{}">{}</a>'.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
|
||||
21
src/packages/main/setup.py
Normal file
21
src/packages/main/setup.py
Normal file
@@ -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',
|
||||
],
|
||||
)
|
||||
5
src/packages/time-duration/.gitignore
vendored
5
src/packages/time-duration/.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
dist
|
||||
build
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info
|
||||
@@ -1,4 +0,0 @@
|
||||
# Time Duration
|
||||
|
||||
This plugin converts integer numbers to a human readable duration.
|
||||
E.g. 90 -> 1 hour 30 minutes
|
||||
@@ -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
|
||||
@@ -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<description>.*)')
|
||||
|
||||
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',
|
||||
]
|
||||
}
|
||||
)
|
||||
@@ -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 -%}
|
||||
|
||||
<h1>{{ this.name }}</h1>
|
||||
<dl class="cluster">
|
||||
{%- for attrib, recipes in all -%}
|
||||
<dt>{%- 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 -%}</dt>
|
||||
{%- for group_name, recipes in site.query('/recipes', this.alt) | performGroupBy(this.group_key, this.reverse_order, this.alt) -%}
|
||||
<dt>{{ group_name or this.null_fallback }}</dt>
|
||||
<dd>
|
||||
{%- set pipe = joiner(' | ') -%}
|
||||
{%- for recipe in recipes | sort(attribute='name') -%}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
{#- overlay on hover and always-visible icons #}
|
||||
<div class="overlay">
|
||||
<div class="hover"><div class="time">{{ recipe.time|duration(recipe.alt) }}</div></div>
|
||||
<div class="hover"><div class="time">{{ recipe.time | asDuration(recipe.alt) }}</div></div>
|
||||
<div class="icon-bar">
|
||||
{%- if 'raw' in recipe.tags -%}<i class="icon raw"></i>{%- endif -%}
|
||||
{%- if 'glutenfree' in recipe.tags -%}<i class="icon gf"></i>{%- endif -%}
|
||||
|
||||
@@ -21,29 +21,29 @@
|
||||
<h1>{{ this.name }}</h1>
|
||||
|
||||
<section id="metrics" class="small">
|
||||
<div id="rating" class="xlarge">{{ this.rating|rating }}</div>
|
||||
<div id="rating" class="xlarge">{{ this.rating | asRating }}</div>
|
||||
<div class="difficulty {{this.difficulty}}">
|
||||
<div></div><div></div><div></div>
|
||||
<span {% if not this.difficulty %}class="small"{%- endif %}>{{
|
||||
bag('i18n+' + this.alt, 'difficulty', this.difficulty or '_unset') }}</span>
|
||||
</div>
|
||||
<div>{{ bag('i18n+' + this.alt, 'duration.label') }}: {{ this.time|duration(this.alt) if this.time else '—' }}</div>
|
||||
<div>{{ bag('i18n+' + this.alt, 'duration.label') }}: {{ this.time | asDuration(this.alt) or '—' }}</div>
|
||||
<div>{{ bag('i18n+' + this.alt, 'yield.label') }}: {{ this.yield or '—' }}</div>
|
||||
</section>
|
||||
|
||||
<section id="ingredients">
|
||||
<h2>{{ bag('i18n+' + this.alt, 'title.ingredients') }}:</h2>
|
||||
<ul class="no-bullets li-lg-space">
|
||||
{%- for ing in this|enumIngredients(this.alt) %}
|
||||
{%- if ing['group'] %}
|
||||
<li class="dark-red bold mrgTopMd">{{ ing['group'] }}</li>
|
||||
{%- for ing in this.ingredients %}
|
||||
{%- if ing.isGroup %}
|
||||
<li class="dark-red bold mrgTopMd">{{ ing.name }}:</li>
|
||||
{%- else %}
|
||||
<li>
|
||||
{%- if ing['value'] %}{{ ing['value'] }} {% endif -%}
|
||||
{%- if ing['measure'] %}{{ ing['measure'] }} {% endif -%}
|
||||
<span class="light-red">{{ ing['name'] }}</span>
|
||||
{%- if ing['note'] -%}
|
||||
<span class="small italic">{{ ', ' ~ ing['note'] | replaceAtRefURLs(label=bag('i18n+' + this.alt, 'ingredients.recipeLink')) }}</span>
|
||||
{%- if ing.quantity %}{{ ing.quantity }} {% endif -%}
|
||||
{%- if ing.unit %}{{ ing.unit }} {% endif -%}
|
||||
<span class="light-red">{{ ing.name }}</span>
|
||||
{%- if ing.note -%}
|
||||
<span class="small italic">{{ ', ' ~ ing.note | replaceAtRefURLs(label=bag('i18n+' + this.alt, 'ingredients.recipeLink')) }}</span>
|
||||
{%- endif -%}
|
||||
</li>
|
||||
{%- endif %}
|
||||
|
||||
Reference in New Issue
Block a user