feat: add support for pagination
This commit is contained in:
@@ -32,6 +32,7 @@ class Config:
|
|||||||
self.dependencies = set() # type: Set[str]
|
self.dependencies = set() # type: Set[str]
|
||||||
self.fields = {} # type: Dict[str, Any]
|
self.fields = {} # type: Dict[str, Any]
|
||||||
self.key_map = {} # type: Dict[str, str]
|
self.key_map = {} # type: Dict[str, str]
|
||||||
|
self.pagination = {} # type: Dict[str, Any]
|
||||||
self.order_by = None # type: Optional[List[str]]
|
self.order_by = None # type: Optional[List[str]]
|
||||||
|
|
||||||
def slugify(self, k: str) -> str:
|
def slugify(self, k: str) -> str:
|
||||||
@@ -50,6 +51,21 @@ class Config:
|
|||||||
''' This mapping replaces group keys before slugify. '''
|
''' This mapping replaces group keys before slugify. '''
|
||||||
self.key_map = key_map or {}
|
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:
|
def set_order_by(self, order_by: Optional[str]) -> None:
|
||||||
''' If specified, children will be sorted according to keys. '''
|
''' If specified, children will be sorted according to keys. '''
|
||||||
self.order_by = split_strip(order_by or '', ',') or None
|
self.order_by = split_strip(order_by or '', ',') or None
|
||||||
@@ -82,6 +98,12 @@ class Config:
|
|||||||
conf.dependencies.add(ini.filename)
|
conf.dependencies.add(ini.filename)
|
||||||
conf.set_fields(ini.section_as_dict(key + '.fields'))
|
conf.set_fields(ini.section_as_dict(key + '.fields'))
|
||||||
conf.set_key_map(ini.section_as_dict(key + '.key_map'))
|
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))
|
conf.set_order_by(ini.get(key + '.children.order_by', None))
|
||||||
return conf
|
return conf
|
||||||
|
|
||||||
|
|||||||
@@ -71,8 +71,13 @@ class GroupBy:
|
|||||||
for w in self._watcher:
|
for w in self._watcher:
|
||||||
root = builder.pad.get(w.config.root)
|
root = builder.pad.get(w.config.root)
|
||||||
for vobj in w.iter_sources(root):
|
for vobj in w.iter_sources(root):
|
||||||
|
# add original source
|
||||||
self._results.append(vobj)
|
self._results.append(vobj)
|
||||||
self.resolver.add(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()
|
self._watcher.clear()
|
||||||
|
|
||||||
def build_all(self, builder: 'Builder') -> None:
|
def build_all(self, builder: 'Builder') -> None:
|
||||||
|
|||||||
14
lektor_groupby/pagination.py
Normal file
14
lektor_groupby/pagination.py
Normal file
@@ -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)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from lektor.db import Record # isinstance
|
from lektor.db import Record # isinstance
|
||||||
from lektor.utils import build_url
|
from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Iterable
|
||||||
from typing import TYPE_CHECKING, Dict, List, Tuple, Optional, Iterable
|
from .util import build_url
|
||||||
from .vobj import VPATH, GroupBySource
|
from .vobj import VPATH, GroupBySource
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from lektor.environment import Environment
|
from lektor.environment import Environment
|
||||||
@@ -8,6 +8,21 @@ if TYPE_CHECKING:
|
|||||||
from .config import Config
|
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:
|
class Resolver:
|
||||||
'''
|
'''
|
||||||
Resolve virtual paths and urls ending in /.
|
Resolve virtual paths and urls ending in /.
|
||||||
@@ -15,7 +30,7 @@ class Resolver:
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
def __init__(self, env: 'Environment') -> None:
|
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.urlresolver(self.resolve_server_path)
|
||||||
env.virtualpathresolver(VPATH.lstrip('@'))(self.resolve_virtual_path)
|
env.virtualpathresolver(VPATH.lstrip('@'))(self.resolve_virtual_path)
|
||||||
|
|
||||||
@@ -34,7 +49,9 @@ 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.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
|
# Resolver
|
||||||
@@ -46,7 +63,8 @@ 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, rv[0]).finalize(rv[2], rv[1])
|
return GroupBySource(
|
||||||
|
node, rv.slug, rv.page).finalize(rv.config, rv.group)
|
||||||
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,8 +72,15 @@ 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
|
||||||
attr, grp, *_ = pieces
|
attr, grp, *optional_page = pieces
|
||||||
for slug, group, conf in self._data.values():
|
page = None
|
||||||
if attr == conf.key and slug == grp and path == conf.root:
|
if optional_page:
|
||||||
return GroupBySource(node, slug).finalize(conf, group)
|
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
|
return None
|
||||||
|
|||||||
@@ -37,3 +37,22 @@ def split_strip(data: str, delimiter: str = ',') -> List[str]:
|
|||||||
if x:
|
if x:
|
||||||
ret.append(x)
|
ret.append(x)
|
||||||
return ret
|
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 '/'
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ from lektor.context import get_ctx
|
|||||||
from lektor.db import _CmpHelper
|
from lektor.db import _CmpHelper
|
||||||
from lektor.environment import Expression
|
from lektor.environment import Expression
|
||||||
from lektor.sourceobj import VirtualSourceObject # subclass
|
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 typing import TYPE_CHECKING, List, Any, Optional, Iterator, Iterable
|
||||||
|
from .pagination import PaginationConfig
|
||||||
from .query import FixedRecordsQuery
|
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:
|
if TYPE_CHECKING:
|
||||||
|
from lektor.pagination import Pagination
|
||||||
from lektor.builder import Artifact
|
from lektor.builder import Artifact
|
||||||
from lektor.db import Record
|
from lektor.db import Record
|
||||||
from .config import Config
|
from .config import Config
|
||||||
@@ -27,9 +31,15 @@ class GroupBySource(VirtualSourceObject):
|
|||||||
Attributes: record, key, group, slug, children, config
|
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)
|
super().__init__(record)
|
||||||
self.key = slug
|
self.key = slug
|
||||||
|
self.page_num = page_num
|
||||||
self.__children = [] # type: List[str]
|
self.__children = [] # type: List[str]
|
||||||
self.__group_map = [] # type: List[str]
|
self.__group_map = [] # type: List[str]
|
||||||
|
|
||||||
@@ -47,8 +57,8 @@ class GroupBySource(VirtualSourceObject):
|
|||||||
-> 'GroupBySource':
|
-> 'GroupBySource':
|
||||||
self.config = config
|
self.config = config
|
||||||
# make a sorted children query
|
# make a sorted children query
|
||||||
self._children = FixedRecordsQuery(self.pad, self.__children, self.alt)
|
self._query = FixedRecordsQuery(self.pad, self.__children, self.alt)
|
||||||
self._children._order_by = config.order_by
|
self._query._order_by = config.order_by
|
||||||
# set group name
|
# set group name
|
||||||
self.group = group or most_used_key(self.__group_map)
|
self.group = group or most_used_key(self.__group_map)
|
||||||
# cleanup temporary data
|
# cleanup temporary data
|
||||||
@@ -80,6 +90,32 @@ class GroupBySource(VirtualSourceObject):
|
|||||||
report_config_error(self.config.key, field, value, e)
|
report_config_error(self.config.key, field, value, e)
|
||||||
return Ellipsis
|
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
|
# Lektor properties
|
||||||
# ---------------------
|
# ---------------------
|
||||||
@@ -87,12 +123,31 @@ class GroupBySource(VirtualSourceObject):
|
|||||||
@property
|
@property
|
||||||
def path(self) -> str:
|
def path(self) -> str:
|
||||||
# Used in VirtualSourceInfo, used to prune VirtualObjects
|
# 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
|
@property
|
||||||
def url_path(self) -> str:
|
def url_path(self) -> str:
|
||||||
# Actual path to resource as seen by the browser
|
# 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]:
|
def iter_source_filenames(self) -> Iterator[str]:
|
||||||
''' Enumerate all dependencies '''
|
''' Enumerate all dependencies '''
|
||||||
@@ -122,10 +177,10 @@ class GroupBySource(VirtualSourceObject):
|
|||||||
# Properties & Helper
|
# Properties & Helper
|
||||||
# -----------------------
|
# -----------------------
|
||||||
|
|
||||||
@property
|
@cached_property
|
||||||
def children(self) -> FixedRecordsQuery:
|
def children(self) -> FixedRecordsQuery:
|
||||||
''' Return query of children of type Record. '''
|
''' Return query of children of type Record. '''
|
||||||
return self._children
|
return self._query.request_page(self.page_num)
|
||||||
|
|
||||||
def __getitem__(self, key: str) -> Any:
|
def __getitem__(self, key: str) -> Any:
|
||||||
# Used for virtual path resolver
|
# Used for virtual path resolver
|
||||||
@@ -162,6 +217,9 @@ class GroupByBuildProgram(BuildProgram):
|
|||||||
''' Generate Build-Artifacts and write files. '''
|
''' Generate Build-Artifacts and write files. '''
|
||||||
|
|
||||||
def produce_artifacts(self) -> None:
|
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
|
url = self.source.url_path
|
||||||
if url.endswith('/'):
|
if url.endswith('/'):
|
||||||
url += 'index.html'
|
url += 'index.html'
|
||||||
@@ -172,3 +230,29 @@ class GroupByBuildProgram(BuildProgram):
|
|||||||
get_ctx().record_virtual_dependency(self.source)
|
get_ctx().record_virtual_dependency(self.source)
|
||||||
artifact.render_template_into(
|
artifact.render_template_into(
|
||||||
self.source.config.template, this=self.source)
|
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 '<GroupBySourcePage path="{}" page={}>'.format(
|
||||||
|
self.__parent.path, self.page_num)
|
||||||
|
|||||||
Reference in New Issue
Block a user