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
', r'\\par '), + (r'
', r''), + # Headings + (r'paragraph
+ +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