diff --git a/lektor_groupby/config.py b/lektor_groupby/config.py index 9ba1817..fe9c371 100644 --- a/lektor_groupby/config.py +++ b/lektor_groupby/config.py @@ -32,6 +32,7 @@ class Config: self.dependencies = set() # type: Set[str] self.fields = {} # type: Dict[str, Any] self.key_map = {} # type: Dict[str, str] + self.pagination = {} # type: Dict[str, Any] self.order_by = None # type: Optional[List[str]] def slugify(self, k: str) -> str: @@ -50,6 +51,21 @@ class Config: ''' This mapping replaces group keys before slugify. ''' self.key_map = key_map or {} + def set_pagination( + self, + enabled: Optional[bool] = None, + per_page: Optional[int] = None, + url_suffix: Optional[str] = None, + items: Optional[str] = None, + ) -> None: + ''' Used for pagination. ''' + self.pagination = dict( + enabled=enabled, + per_page=per_page, + url_suffix=url_suffix, + items=items, + ) + def set_order_by(self, order_by: Optional[str]) -> None: ''' If specified, children will be sorted according to keys. ''' self.order_by = split_strip(order_by or '', ',') or None @@ -82,6 +98,12 @@ class Config: conf.dependencies.add(ini.filename) conf.set_fields(ini.section_as_dict(key + '.fields')) conf.set_key_map(ini.section_as_dict(key + '.key_map')) + conf.set_pagination( + enabled=ini.get_bool(key + '.pagination.enabled', None), + per_page=ini.get_int(key + '.pagination.per_page', None), + url_suffix=ini.get(key + '.pagination.url_suffix'), + items=ini.get(key + '.pagination.items'), + ) conf.set_order_by(ini.get(key + '.children.order_by', None)) return conf diff --git a/lektor_groupby/groupby.py b/lektor_groupby/groupby.py index 6dfd888..9c38d84 100644 --- a/lektor_groupby/groupby.py +++ b/lektor_groupby/groupby.py @@ -71,8 +71,13 @@ class GroupBy: for w in self._watcher: root = builder.pad.get(w.config.root) for vobj in w.iter_sources(root): + # add original source self._results.append(vobj) self.resolver.add(vobj) + # and also add pagination sources + for sub_vobj in vobj.__iter_pagination_sources__(): + self._results.append(sub_vobj) + self.resolver.add(sub_vobj) self._watcher.clear() def build_all(self, builder: 'Builder') -> None: diff --git a/lektor_groupby/pagination.py b/lektor_groupby/pagination.py new file mode 100644 index 0000000..96347ee --- /dev/null +++ b/lektor_groupby/pagination.py @@ -0,0 +1,14 @@ +from lektor import datamodel +from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: + from lektor.db import Record + + +class PaginationConfig(datamodel.PaginationConfig): + # because original method does not work for virtual sources. + @staticmethod + def get_record_for_page(source: 'Record', page_num: int) -> Any: + for_page = getattr(source, '__for_page__', None) + if callable(for_page): + return for_page(page_num) + return datamodel.PaginationConfig.get_record_for_page(source, page_num) diff --git a/lektor_groupby/resolver.py b/lektor_groupby/resolver.py index 719f8ed..baed2d3 100644 --- a/lektor_groupby/resolver.py +++ b/lektor_groupby/resolver.py @@ -1,6 +1,6 @@ from lektor.db import Record # isinstance -from lektor.utils import build_url -from typing import TYPE_CHECKING, Dict, List, Tuple, Optional, Iterable +from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Iterable +from .util import build_url from .vobj import VPATH, GroupBySource if TYPE_CHECKING: from lektor.environment import Environment @@ -8,6 +8,21 @@ if TYPE_CHECKING: from .config import Config +class ResolverEntry(NamedTuple): + slug: str + group: str + config: 'Config' + page: Optional[int] + + def equals( + self, path: str, attribute: str, group: str, page: Optional[int] + ) -> bool: + return self.slug == group \ + and self.config.key == attribute \ + and self.config.root == path \ + and self.page == page + + class Resolver: ''' Resolve virtual paths and urls ending in /. @@ -15,7 +30,7 @@ class Resolver: ''' def __init__(self, env: 'Environment') -> None: - self._data = {} # type: Dict[str, Tuple[str, str, Config]] + self._data = {} # type: Dict[str, ResolverEntry] env.urlresolver(self.resolve_server_path) env.virtualpathresolver(VPATH.lstrip('@'))(self.resolve_virtual_path) @@ -34,7 +49,9 @@ class Resolver: def add(self, vobj: GroupBySource) -> None: ''' Track new virtual object (only if slug is set). ''' if vobj.slug: - self._data[vobj.url_path] = (vobj.key, vobj.group, vobj.config) + # page_num = 1 overwrites page_num = None -> same url_path() + self._data[vobj.url_path] = ResolverEntry( + vobj.key, vobj.group, vobj.config, vobj.page_num) # ------------ # Resolver @@ -46,7 +63,8 @@ class Resolver: if isinstance(node, Record): rv = self._data.get(build_url([node.url_path] + pieces)) if rv: - return GroupBySource(node, rv[0]).finalize(rv[2], rv[1]) + return GroupBySource( + node, rv.slug, rv.page).finalize(rv.config, rv.group) return None def resolve_virtual_path(self, node: 'SourceObject', pieces: List[str]) \ @@ -54,8 +72,15 @@ class Resolver: ''' Admin UI only: Prevent server error and null-redirect. ''' if isinstance(node, Record) and len(pieces) >= 2: path = node['_path'] # type: str - attr, grp, *_ = pieces - for slug, group, conf in self._data.values(): - if attr == conf.key and slug == grp and path == conf.root: - return GroupBySource(node, slug).finalize(conf, group) + attr, grp, *optional_page = pieces + page = None + if optional_page: + try: + page = int(optional_page[0]) + except ValueError: + pass + for rv in self._data.values(): + if rv.equals(path, attr, grp, page): + return GroupBySource( + node, rv.slug, rv.page).finalize(rv.config, rv.group) return None diff --git a/lektor_groupby/util.py b/lektor_groupby/util.py index 1bdb5c5..b46280a 100644 --- a/lektor_groupby/util.py +++ b/lektor_groupby/util.py @@ -37,3 +37,22 @@ def split_strip(data: str, delimiter: str = ',') -> List[str]: if x: ret.append(x) return ret + + +def insert_before_ext(data: str, ins: str, delimiter: str = '.') -> str: + ''' Insert text before last index of delimeter (or at the end). ''' + assert delimiter in data, 'Could not insert before delimiter: ' + delimiter + idx = data.rindex(delimiter) + return data[:idx] + ins + data[idx:] + + +def build_url(parts: List[str]) -> str: + ''' Build URL similar to lektor.utils.build_url ''' + url = '' + for comp in parts: + txt = str(comp).strip('/') + if txt: + url += '/' + txt + if '.' not in url.split('/')[-1]: + url += '/' + return url or '/' diff --git a/lektor_groupby/vobj.py b/lektor_groupby/vobj.py index 1fc9697..4a7fb99 100644 --- a/lektor_groupby/vobj.py +++ b/lektor_groupby/vobj.py @@ -3,12 +3,16 @@ from lektor.context import get_ctx from lektor.db import _CmpHelper from lektor.environment import Expression from lektor.sourceobj import VirtualSourceObject # subclass -from lektor.utils import build_url +from werkzeug.utils import cached_property from typing import TYPE_CHECKING, List, Any, Optional, Iterator, Iterable +from .pagination import PaginationConfig from .query import FixedRecordsQuery -from .util import report_config_error, most_used_key +from .util import ( + report_config_error, most_used_key, insert_before_ext, build_url +) if TYPE_CHECKING: + from lektor.pagination import Pagination from lektor.builder import Artifact from lektor.db import Record from .config import Config @@ -27,9 +31,15 @@ class GroupBySource(VirtualSourceObject): Attributes: record, key, group, slug, children, config ''' - def __init__(self, record: 'Record', slug: str) -> None: + def __init__( + self, + record: 'Record', + slug: str, + page_num: Optional[int] = None + ) -> None: super().__init__(record) self.key = slug + self.page_num = page_num self.__children = [] # type: List[str] self.__group_map = [] # type: List[str] @@ -47,8 +57,8 @@ class GroupBySource(VirtualSourceObject): -> 'GroupBySource': self.config = config # make a sorted children query - self._children = FixedRecordsQuery(self.pad, self.__children, self.alt) - self._children._order_by = config.order_by + self._query = FixedRecordsQuery(self.pad, self.__children, self.alt) + self._query._order_by = config.order_by # set group name self.group = group or most_used_key(self.__group_map) # cleanup temporary data @@ -80,6 +90,32 @@ class GroupBySource(VirtualSourceObject): report_config_error(self.config.key, field, value, e) return Ellipsis + # ----------------------- + # Pagination handling + # ----------------------- + + @cached_property + def _pagination_config(self) -> 'PaginationConfig': + # Generate `PaginationConfig` once we need it + return PaginationConfig(self.record.pad.env, **self.config.pagination) + + @cached_property + def pagination(self) -> 'Pagination': + # Generate `Pagination` once we need it + return self._pagination_config.get_pagination_controller(self) + + def __iter_pagination_sources__(self) -> Iterator['GroupBySource']: + ''' If pagination enabled, yields `GroupBySourcePage` sub-pages. ''' + # Used in GroupBy.make_once() to generated paginated child sources + if self._pagination_config.enabled and self.page_num is None: + for page_num in range(self._pagination_config.count_pages(self)): + yield self.__for_page__(page_num + 1) + + def __for_page__(self, page_num: Optional[int]) -> 'GroupBySource': + ''' Get source object for a (possibly) different page number. ''' + assert page_num is not None + return GroupBySourcePage(self, page_num) + # --------------------- # Lektor properties # --------------------- @@ -87,12 +123,31 @@ class GroupBySource(VirtualSourceObject): @property def path(self) -> str: # Used in VirtualSourceInfo, used to prune VirtualObjects - return f'{self.record.path}{VPATH}/{self.config.key}/{self.key}' + vpath = f'{self.record.path}{VPATH}/{self.config.key}/{self.key}' + if self.page_num: + vpath += '/' + str(self.page_num) + return vpath @property def url_path(self) -> str: # Actual path to resource as seen by the browser - return build_url([self.record.url_path, self.slug]) # slug can be None + parts = [self.record.url_path] + # slug can be None!! + if not self.slug: + return build_url(parts) + # if pagination enabled, append pagination.url_suffix to path + if self.page_num and self.page_num > 1: + sffx = self._pagination_config.url_suffix + if '.' in self.slug.split('/')[-1]: + # default: ../slugpage2.html (use e.g.: url_suffix = .page.) + parts.append(insert_before_ext( + self.slug, sffx + str(self.page_num), '.')) + else: + # default: ../slug/page/2/index.html + parts += [self.slug, sffx, self.page_num] + else: + parts.append(self.slug) + return build_url(parts) def iter_source_filenames(self) -> Iterator[str]: ''' Enumerate all dependencies ''' @@ -122,10 +177,10 @@ class GroupBySource(VirtualSourceObject): # Properties & Helper # ----------------------- - @property + @cached_property def children(self) -> FixedRecordsQuery: ''' Return query of children of type Record. ''' - return self._children + return self._query.request_page(self.page_num) def __getitem__(self, key: str) -> Any: # Used for virtual path resolver @@ -162,6 +217,9 @@ class GroupByBuildProgram(BuildProgram): ''' Generate Build-Artifacts and write files. ''' def produce_artifacts(self) -> None: + pagination_enabled = self.source._pagination_config.enabled + if pagination_enabled and self.source.page_num is None: + return # only __iter_pagination_sources__() url = self.source.url_path if url.endswith('/'): url += 'index.html' @@ -172,3 +230,29 @@ class GroupByBuildProgram(BuildProgram): get_ctx().record_virtual_dependency(self.source) artifact.render_template_into( self.source.config.template, this=self.source) + + +class GroupBySourcePage(GroupBySource): + ''' Pagination wrapper. Redirects get attr/item to non-paginated node. ''' + + def __init__(self, parent: 'GroupBySource', page_num: int) -> None: + self.__parent = parent + self.page_num = page_num + + def __for_page__(self, page_num: Optional[int]) -> 'GroupBySource': + ''' Get source object for a (possibly) different page number. ''' + if page_num is None: + return self.__parent + if page_num == self.page_num: + return self + return GroupBySourcePage(self.__parent, page_num) + + def __getitem__(self, key: str) -> Any: + return self.__parent.__getitem__(key) + + def __getattr__(self, key: str) -> Any: + return getattr(self.__parent, key) + + def __repr__(self) -> str: + return ''.format( + self.__parent.path, self.page_num)