3 Commits

Author SHA1 Message Date
relikd
eb0a60ab33 v0.9.7 2022-04-22 14:43:07 +02:00
relikd
c149831808 keep order of vgroups 2022-04-19 23:21:20 +02:00
relikd
7f28c53107 gitignore rename dist-env 2022-04-13 22:27:33 +02:00
9 changed files with 96 additions and 67 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
.DS_Store .DS_Store
/dist-env/ /env-publish/
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

View File

@@ -2,4 +2,4 @@
name = GroupBy Examples name = GroupBy Examples
[packages] [packages]
lektor-groupby = 0.9.6 lektor-groupby = 0.9.7

View File

@@ -279,11 +279,14 @@ This is useful if you do not want to create subpages but rather an index page co
This can be done in combination with the next use-case: This can be done in combination with the next use-case:
```jinja2 ```jinja2
{%- for x in this|vgroups('TestA', 'TestB', recursive=True)|unique|sort %} {%- for x in this|vgroups(keys=['TestA', 'TestB'], fields=[], flows=[], recursive=True)|unique|sort %}
<a href="{{ x|url }}">({{ x.group }})</a> <a href="{{ x|url }}">({{ x.group }})</a>
{%- endfor %} {%- endfor %}
``` ```
You can query the groups of any parent node (including those without slug). You can query the groups of any parent node (including those without slug).
[`templates/page.html`](./templates/page.html) uses this.
The keys (`'TestA', 'TestB'`) can be omitted which will return all groups of all attributes (you can still filter them with `x.config.key == 'TestC'`). The keys (`'TestA', 'TestB'`) can be omitted which will return all groups of all attributes (you can still filter them with `x.config.key == 'TestC'`).
Refer to [`templates/page.html`](./templates/page.html) for usage. The `fields` and `flows` params are also optional.
With these you can match groups in `args.key.fieldKey` and `args.key.flowKey`.
For example, if you have a “tags” field and an “additional-tags” field and you only want to show one in a preview.

View File

@@ -1,13 +1,19 @@
from lektor.context import get_ctx from lektor.context import get_ctx
from typing import TYPE_CHECKING, Iterator from typing import TYPE_CHECKING, Union, Iterable, Iterator
from weakref import WeakSet import weakref
if TYPE_CHECKING: if TYPE_CHECKING:
from lektor.builder import Builder from lektor.builder import Builder
from lektor.db import Record from lektor.db import Record
from .groupby import GroupBy from .groupby import GroupBy
from .model import FieldKeyPath
from .vobj import GroupBySource from .vobj import GroupBySource
class WeakVGroupsList(list):
def add(self, strong: 'FieldKeyPath', weak: 'GroupBySource') -> None:
super().append((strong, weakref.ref(weak)))
class GroupByRef: class GroupByRef:
@staticmethod @staticmethod
def of(builder: 'Builder') -> 'GroupBy': def of(builder: 'Builder') -> 'GroupBy':
@@ -22,7 +28,7 @@ class GroupByRef:
class VGroups: class VGroups:
@staticmethod @staticmethod
def of(record: 'Record') -> WeakSet: def of(record: 'Record') -> WeakVGroupsList:
''' '''
Return the (weak) set of virtual objects of a page. Return the (weak) set of virtual objects of a page.
Creates a new set if it does not exist yet. Creates a new set if it does not exist yet.
@@ -30,13 +36,19 @@ class VGroups:
try: try:
wset = record.__vgroups # type: ignore[attr-defined] wset = record.__vgroups # type: ignore[attr-defined]
except AttributeError: except AttributeError:
wset = WeakSet() wset = WeakVGroupsList()
record.__vgroups = wset # type: ignore[attr-defined] record.__vgroups = wset # type: ignore[attr-defined]
return wset # type: ignore[no-any-return] return wset # type: ignore[no-any-return]
@staticmethod @staticmethod
def iter(record: 'Record', *keys: str, recursive: bool = False) \ def iter(
-> Iterator['GroupBySource']: record: 'Record',
keys: Union[str, Iterable[str], None] = None,
*,
fields: Union[str, Iterable[str], None] = None,
flows: Union[str, Iterable[str], None] = None,
recursive: bool = False
) -> Iterator['GroupBySource']:
''' Extract all referencing groupby virtual objects from a page. ''' ''' Extract all referencing groupby virtual objects from a page. '''
ctx = get_ctx() ctx = get_ctx()
if not ctx: if not ctx:
@@ -48,12 +60,24 @@ class VGroups:
# manage config dependencies # manage config dependencies
for dep in groupby.dependencies: for dep in groupby.dependencies:
ctx.record_dependency(dep) ctx.record_dependency(dep)
# prepare filter
if isinstance(keys, str):
keys = [keys]
if isinstance(fields, str):
fields = [fields]
if isinstance(flows, str):
flows = [flows]
# find groups # find groups
proc_list = [record] proc_list = [record]
while proc_list: while proc_list:
page = proc_list.pop(0) page = proc_list.pop(0)
if recursive and hasattr(page, 'children'): if recursive and hasattr(page, 'children'):
proc_list.extend(page.children) # type: ignore[attr-defined] proc_list.extend(page.children) # type: ignore[attr-defined]
for vobj in VGroups.of(page): for key, vobj in VGroups.of(page):
if not keys or vobj.config.key in keys: if fields and key.fieldKey not in fields:
yield vobj continue
if flows and key.flowKey not in flows:
continue
if keys and vobj().config.key not in keys:
continue
yield vobj()

View File

@@ -44,7 +44,7 @@ class GroupBy:
return return
# initialize remaining (enabled) watchers # initialize remaining (enabled) watchers
for w in self._watcher: for w in self._watcher:
w.initialize(builder.pad.db) w.initialize(builder.pad)
# iterate over whole build tree # iterate over whole build tree
queue = builder.pad.get_all_roots() # type: List[SourceObject] queue = builder.pad.get_all_roots() # type: List[SourceObject]
while queue: while queue:

View File

@@ -15,7 +15,7 @@ class Resolver:
''' '''
def __init__(self, env: 'Environment') -> None: def __init__(self, env: 'Environment') -> None:
self._data = {} # type: Dict[str, Tuple[str, Config]] self._data = {} # type: Dict[str, Tuple[str, str, Config]]
env.urlresolver(self.resolve_server_path) env.urlresolver(self.resolve_server_path)
env.virtualpathresolver(VPATH.lstrip('@'))(self.resolve_virtual_path) env.virtualpathresolver(VPATH.lstrip('@'))(self.resolve_virtual_path)
@@ -34,7 +34,7 @@ class Resolver:
def add(self, vobj: GroupBySource) -> None: def add(self, vobj: GroupBySource) -> None:
''' Track new virtual object (only if slug is set). ''' ''' Track new virtual object (only if slug is set). '''
if vobj.slug: if vobj.slug:
self._data[vobj.url_path] = (vobj.group, vobj.config) self._data[vobj.url_path] = (vobj.key, vobj.group, vobj.config)
# ------------ # ------------
# Resolver # Resolver
@@ -46,7 +46,7 @@ class Resolver:
if isinstance(node, Record): if isinstance(node, Record):
rv = self._data.get(build_url([node.url_path] + pieces)) rv = self._data.get(build_url([node.url_path] + pieces))
if rv: if rv:
return GroupBySource(node, group=rv[0], config=rv[1]) return GroupBySource(node, rv[0]).finalize(rv[2], rv[1])
return None return None
def resolve_virtual_path(self, node: 'SourceObject', pieces: List[str]) \ def resolve_virtual_path(self, node: 'SourceObject', pieces: List[str]) \
@@ -54,9 +54,8 @@ class Resolver:
''' Admin UI only: Prevent server error and null-redirect. ''' ''' Admin UI only: Prevent server error and null-redirect. '''
if isinstance(node, Record) and len(pieces) >= 2: if isinstance(node, Record) and len(pieces) >= 2:
path = node['_path'] # type: str path = node['_path'] # type: str
key, grp, *_ = pieces attr, grp, *_ = pieces
for group, conf in self._data.values(): for slug, group, conf in self._data.values():
if key == conf.key and path == conf.root: if attr == conf.key and slug == grp and path == conf.root:
if conf.slugify(group) == grp: return GroupBySource(node, slug).finalize(conf, group)
return GroupBySource(node, group, conf)
return None return None

View File

@@ -4,8 +4,7 @@ from lektor.environment import Expression
from lektor.sourceobj import VirtualSourceObject # subclass from lektor.sourceobj import VirtualSourceObject # subclass
from lektor.utils import build_url from lektor.utils import build_url
from typing import TYPE_CHECKING, Dict, List, Any, Optional, Iterator from typing import TYPE_CHECKING, Dict, List, Any, Optional, Iterator
from .backref import VGroups from .util import report_config_error, most_used_key
from .util import report_config_error
if TYPE_CHECKING: if TYPE_CHECKING:
from lektor.builder import Artifact from lektor.builder import Artifact
from lektor.db import Record from lektor.db import Record
@@ -25,18 +24,29 @@ class GroupBySource(VirtualSourceObject):
Attributes: record, key, group, slug, children, config Attributes: record, key, group, slug, children, config
''' '''
def __init__( def __init__(self, record: 'Record', slug: str) -> None:
self,
record: 'Record',
group: str,
config: 'Config',
children: Optional[Dict['Record', List[Any]]] = None,
) -> None:
super().__init__(record) super().__init__(record)
self.key = config.slugify(group) self.key = slug
self.group = group self._group_map = [] # type: List[str]
self._children = {} # type: Dict[Record, List[Any]]
def append_child(self, child: 'Record', extra: Any, group: str) -> None:
if child not in self._children:
self._children[child] = [extra]
else:
self._children[child].append(extra)
# _group_map is later used to find most used group
self._group_map.append(group)
# -------------------------
# Evaluate Extra Fields
# -------------------------
def finalize(self, config: 'Config', group: Optional[str] = None) \
-> 'GroupBySource':
self.config = config self.config = config
self._children = children or {} # type: Dict[Record, List[Any]] self.group = group or most_used_key(self._group_map)
del self._group_map
# evaluate slug Expression # evaluate slug Expression
if config.slug and '{key}' in config.slug: if config.slug and '{key}' in config.slug:
self.slug = config.slug.replace('{key}', self.key) self.slug = config.slug.replace('{key}', self.key)
@@ -48,9 +58,7 @@ class GroupBySource(VirtualSourceObject):
# extra fields # extra fields
for attr, expr in config.fields.items(): for attr, expr in config.fields.items():
setattr(self, attr, self._eval(expr, field='fields.' + attr)) setattr(self, attr, self._eval(expr, field='fields.' + attr))
# back-ref return self
for child in self._children:
VGroups.of(child).add(self)
def _eval(self, value: Any, *, field: str) -> Any: def _eval(self, value: Any, *, field: str) -> Any:
''' Internal only: evaluates Lektor config file field expression. ''' ''' Internal only: evaluates Lektor config file field expression. '''

View File

@@ -1,10 +1,10 @@
from typing import TYPE_CHECKING, Dict, List, Tuple, Any, Union, NamedTuple from typing import TYPE_CHECKING, Dict, List, Tuple, Any, Union, NamedTuple
from typing import Optional, Callable, Iterator, Generator from typing import Optional, Callable, Iterator, Generator
from .backref import VGroups
from .model import ModelReader from .model import ModelReader
from .util import most_used_key
from .vobj import GroupBySource from .vobj import GroupBySource
if TYPE_CHECKING: if TYPE_CHECKING:
from lektor.db import Database, Record from lektor.db import Pad, Record
from .config import Config from .config import Config
from .model import FieldKeyPath from .model import FieldKeyPath
@@ -44,12 +44,12 @@ class Watcher:
self.callback = fn self.callback = fn
return _decorator return _decorator
def initialize(self, db: 'Database') -> None: def initialize(self, pad: 'Pad') -> None:
''' Reset internal state. You must initialize before each build! ''' ''' Reset internal state. You must initialize before each build! '''
assert callable(self.callback), 'No grouping callback provided.' assert callable(self.callback), 'No grouping callback provided.'
self._model_reader = ModelReader(db, self.config.key, self.flatten) self._model_reader = ModelReader(pad.db, self.config.key, self.flatten)
self._state = {} # type: Dict[str, Dict[Record, List[Any]]] self._root_record = pad.get(self._root) # type: Record
self._group_map = {} # type: Dict[str, List[str]] self._state = {} # type: Dict[str, GroupBySource]
def should_process(self, node: 'Record') -> bool: def should_process(self, node: 'Record') -> bool:
''' Check if record path is being watched. ''' ''' Check if record path is being watched. '''
@@ -77,39 +77,34 @@ class Watcher:
del _gen del _gen
def _persist( def _persist(
self, self, record: 'Record', key: 'FieldKeyPath', obj: Union[str, tuple]
record: 'Record',
key: 'FieldKeyPath',
obj: Union[str, tuple]
) -> str: ) -> str:
''' Update internal state. Return slugified string. ''' ''' Update internal state. Return slugified string. '''
group = obj if isinstance(obj, str) else obj[0] if isinstance(obj, str):
slug = self.config.slugify(group) group, extra = obj, key.fieldKey
# init group-key
if slug not in self._state:
self._state[slug] = {}
self._group_map[slug] = []
# _group_map is later used to find most used group
self._group_map[slug].append(group)
# init group extras
if record not in self._state[slug]:
self._state[slug][record] = []
# append extras (or default value)
if isinstance(obj, tuple):
self._state[slug][record].append(obj[1])
else: else:
self._state[slug][record].append(key.fieldKey) group, extra = obj
slug = self.config.slugify(group)
if slug not in self._state:
src = GroupBySource(self._root_record, slug)
self._state[slug] = src
else:
src = self._state[slug]
src.append_child(record, extra, group)
# reverse reference
VGroups.of(record).add(key, src)
return slug return slug
def iter_sources(self, root: 'Record') -> Iterator[GroupBySource]: def iter_sources(self, root: 'Record') -> Iterator[GroupBySource]:
''' Prepare and yield GroupBySource elements. ''' ''' Prepare and yield GroupBySource elements. '''
for key, children in self._state.items(): for vobj in self._state.values():
group = most_used_key(self._group_map[key]) yield vobj.finalize(self.config)
yield GroupBySource(root, group, self.config, children=children)
# cleanup. remove this code if you'd like to iter twice # cleanup. remove this code if you'd like to iter twice
del self._model_reader del self._model_reader
del self._root_record
del self._state del self._state
del self._group_map
def __repr__(self) -> str: def __repr__(self) -> str:
return '<GroupByWatcher key="{}" enabled={} callback={}>'.format( return '<GroupByWatcher key="{}" enabled={} callback={}>'.format(

View File

@@ -13,7 +13,7 @@ setup(
}, },
author='relikd', author='relikd',
url='https://github.com/relikd/lektor-groupby-plugin', url='https://github.com/relikd/lektor-groupby-plugin',
version='0.9.6', version='0.9.7',
description='Cluster arbitrary records with field attribute keyword.', description='Cluster arbitrary records with field attribute keyword.',
long_description=longdesc, long_description=longdesc,
long_description_content_type="text/markdown", long_description_content_type="text/markdown",