Skip to content

Commit

Permalink
[PP-1509] Add support for default facet (#2142)
Browse files Browse the repository at this point in the history
* Add support for specifying the Palace api-version in mimetype header when retrieving opds1 and opds2 client feeds.

* Revert OPDS1Version1 and OPDS2Version1 to current production feed format. Move support for sort links to OPD1Version2 and OPDS2Version2.

* Add support for http://palaceproject.io/terms/properties/default property on the sort and facet relations in OPDS1V2 and OPDS2V2 feeds.
  • Loading branch information
dbernstein authored Nov 5, 2024
1 parent 7411b60 commit c4ebcb7
Show file tree
Hide file tree
Showing 14 changed files with 511 additions and 159 deletions.
21 changes: 15 additions & 6 deletions src/palace/manager/feed/acquisition.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ def facet_links(
circumstances apply. You need to decide whether to call
add_entrypoint_links in addition to calling this method.
"""
for group, value, new_facets, selected in facets.facet_groups:
for group, value, new_facets, selected, default_facet in facets.facet_groups:
url = annotator.facet_url(new_facets)
if not url:
continue
Expand All @@ -160,11 +160,18 @@ def facet_links(
# system. It may be left over from an earlier version,
# or just weird junk data.
continue
yield cls.facet_link(url, str(facet_title), str(group_title), selected)
yield cls.facet_link(
url, str(facet_title), str(group_title), selected, default_facet
)

@classmethod
def facet_link(
cls, href: str, title: str, facet_group_name: str, is_active: bool
cls,
href: str,
title: str,
facet_group_name: str,
is_active: bool,
is_default: bool,
) -> Link:
"""Build a set of attributes for a facet link.
Expand All @@ -174,15 +181,17 @@ def facet_link(
e.g. "Sort By".
:param is_active: True if this is the client's currently
selected facet.
:retusrn: A dictionary of attributes, suitable for passing as
:param is_default: True if this is the default facet
:return: A dictionary of attributes, suitable for passing as
keyword arguments into OPDSFeed.add_link_to_feed.
"""
args = dict(href=href, title=title)
args["rel"] = LinkRelations.FACET_REL
args["facetGroup"] = facet_group_name
if is_active:
args["activeFacet"] = "true"
if is_default:
args["defaultFacet"] = "true"
return Link.create(**args)

def as_error_response(self, **kwargs: Any) -> OPDSFeedResponse:
Expand Down Expand Up @@ -266,7 +275,7 @@ def _entrypoint_link(

url = url_generator(entrypoint)
is_selected = entrypoint is selected_entrypoint
link = cls.facet_link(url, display_title, group_name, is_selected)
link = cls.facet_link(url, display_title, group_name, is_selected, is_default)

# Unlike a normal facet group, every link in this facet
# group has an additional attribute marking it as an entry
Expand Down
10 changes: 7 additions & 3 deletions src/palace/manager/feed/opds.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
from palace.manager.core.exceptions import BasePalaceException
from palace.manager.feed.base import FeedInterface
from palace.manager.feed.serializer.base import SerializerInterface
from palace.manager.feed.serializer.opds import OPDS1Serializer
from palace.manager.feed.serializer.opds import (
OPDS1Version1Serializer,
OPDS1Version2Serializer,
)
from palace.manager.feed.serializer.opds2 import OPDS2Serializer
from palace.manager.feed.types import FeedData, WorkEntry
from palace.manager.sqlalchemy.model.lane import FeaturedFacets
Expand All @@ -21,7 +24,8 @@ def get_serializer(
) -> SerializerInterface[Any]:
# Ordering matters for poor matches (eg. */*), so we will keep OPDS1 first
serializers: dict[str, type[SerializerInterface[Any]]] = {
"application/atom+xml": OPDS1Serializer,
"application/atom+xml; api-version=2": OPDS1Version2Serializer,
"application/atom+xml": OPDS1Version1Serializer,
"application/opds+json": OPDS2Serializer,
}
if mime_types:
Expand All @@ -30,7 +34,7 @@ def get_serializer(
)
return serializers[match]()
# Default
return OPDS1Serializer()
return OPDS1Version1Serializer()


class BaseOPDSFeed(FeedInterface):
Expand Down
121 changes: 104 additions & 17 deletions src/palace/manager/feed/serializer/opds.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import abc
import datetime
from functools import partial
from typing import Any, cast
Expand Down Expand Up @@ -33,7 +34,7 @@
"hashed_passphrase": f"{{{OPDSFeed.LCP_NS}}}hashed_passphrase",
}

ATTRIBUTE_MAPPING = {
V1_ATTRIBUTE_MAPPING = {
"vendor": f"{{{OPDSFeed.DRM_NS}}}vendor",
"scheme": f"{{{OPDSFeed.DRM_NS}}}scheme",
"username": f"{{{OPDSFeed.SIMPLIFIED_NS}}}username",
Expand All @@ -44,6 +45,11 @@
"facetGroupType": f"{{{OPDSFeed.SIMPLIFIED_NS}}}facetGroupType",
"activeFacet": f"{{{OPDSFeed.OPDS_NS}}}activeFacet",
"ratingValue": f"{{{OPDSFeed.SCHEMA_NS}}}ratingValue",
}

V2_ATTRIBUTE_MAPPING = {
**V1_ATTRIBUTE_MAPPING,
"defaultFacet": f"{{{OPDSFeed.PALACE_PROPS_NS}}}default",
"activeSort": f"{{{OPDSFeed.PALACE_PROPS_NS}}}active-sort",
}

Expand All @@ -55,18 +61,16 @@
}


def is_sort_link(link: Link) -> bool:
"""A until method that determines if the specified link is a sort link"""
def is_sort_facet(link: Link) -> bool:
"""A until method that determines if the specified link is part of a sort facet"""
return (
hasattr(link, "facetGroup")
and link.facetGroup
== FacetConstants.GROUP_DISPLAY_TITLES[FacetConstants.ORDER_FACET_GROUP_NAME]
)


class OPDS1Serializer(SerializerInterface[etree._Element], OPDSFeed):
"""An OPDS 1.2 Atom feed serializer"""

class BaseOPDS1Serializer(SerializerInterface[etree._Element], OPDSFeed, abc.ABC):
def __init__(self) -> None:
pass

Expand All @@ -79,7 +83,7 @@ def _tag(

def _attr_name(self, attr_name: str, mapping: dict[str, str] | None = None) -> str:
if not mapping:
mapping = ATTRIBUTE_MAPPING
mapping = self._get_attribute_mapping()
return mapping.get(attr_name, attr_name)

def serialize_feed(
Expand Down Expand Up @@ -118,13 +122,11 @@ def serialize_feed(
breadcrumbs.append(self._serialize_feed_entry("link", link))
serialized.append(breadcrumbs)

for link in feed.facet_links:
if is_sort_link(link):
serialized.append(self._serialize_sort_link(link))
# TODO once the clients are no longer relying on facet based sorting
# an "else" should be introduced here since we only need to provide one style of sorting links
serialized.append(self._serialize_feed_entry("link", link))
# TODO end else here
for link in self._serialize_facet_links(feed):
serialized.append(link)

for link in self._serialize_sort_links(feed):
serialized.append(link)

etree.indent(serialized)
return self.to_string(serialized)
Expand Down Expand Up @@ -314,12 +316,19 @@ def _serialize_feed_entry(
if attrib == "text":
entry.text = value
else:
attribute_mapping = self._get_attribute_mapping()
entry.set(
ATTRIBUTE_MAPPING.get(attrib, attrib),
attribute_mapping.get(attrib, attrib),
value if value is not None else "",
)
return entry

@abc.abstractmethod
def _get_attribute_mapping(self) -> dict[str, str]:
"""This method should return a mapping of object attributes found on links and objects in the FeedData
to the related attribute names defined in the OPDS specification.
"""

def _serialize_author_tag(self, tag: str, author: Author) -> etree._Element:
entry: etree._Element = self._tag(tag)
attr = partial(self._attr_name, mapping=AUTHOR_MAPPING)
Expand Down Expand Up @@ -405,13 +414,91 @@ def _serialize_data_entry(self, entry: DataEntry) -> etree._Element:
def to_string(cls, element: etree._Element) -> str:
return cast(str, etree.tostring(element, encoding="unicode"))

@abc.abstractmethod
def content_type(self) -> str:
return OPDSFeed.ACQUISITION_FEED_TYPE
"""return the content type associated with the serialization. This value should include the api-version."""

@abc.abstractmethod
def _serialize_facet_links(self, feed: FeedData) -> list[Link]:
"""This method implements serialization of the facet_links from the feed data."""

@abc.abstractmethod
def _serialize_sort_links(self, feed: FeedData) -> list[Link]:
"""This method implements serialization of the sort links from the feed data."""


class OPDS1Version1Serializer(BaseOPDS1Serializer):
"""An OPDS 1.2 Atom feed serializer. This version of the feed implements sort links as
facets rather than using the http://palaceproject.io/terms/rel/sort rel and does not support
the http://palaceproject.io/terms/properties/default property indicating default facets
"""

def _serialize_facet_links(self, feed: FeedData) -> list[Link]:
links = []
if feed.facet_links:
for link in feed.facet_links:
links.append(self._serialize_feed_entry("link", link))
return links

def _serialize_sort_links(self, feed: FeedData) -> list[Link]:
# Since this version of the serializer implements sort links as facets,
# we return an empty list of sort links.
return []

def _get_attribute_mapping(self) -> dict[str, str]:
return V1_ATTRIBUTE_MAPPING

def content_type(self) -> str:
return OPDSFeed.ACQUISITION_FEED_TYPE + "; api-version=1"


class OPDS1Version2Serializer(BaseOPDS1Serializer):
"""An OPDS 1.2 Atom feed serializer with Palace specific modifications (version 2) to support
new IOS and Android client features. Namely, this version of the feed implements sort links as
links using the http://palaceproject.io/terms/rel/sort rel. The active or selected sort link is indicated
by the http://palaceproject.io/terms/properties/active-sort property. Default facets and sort links are
inidcated by the http://palaceproject.io/terms/properties/default property.
"""

def _serialize_facet_links(self, feed: FeedData) -> list[Link]:
# serializes the non-sort related facets.
links: list[Link] = []
facet_links = feed.facet_links
if facet_links:
for link in facet_links:
# serialize all but the sort facets.
if not is_sort_facet(link):
links.append(self._serialize_feed_entry("link", link))
return links

def _serialize_sort_links(self, feed: FeedData) -> list[Link]:
# this version of the feed filters out the sort facets and
# serializes them in a way that makes use of palace extensions.
links: list[Link] = []
facet_links = feed.facet_links
if facet_links:
for link in feed.facet_links:
# select only the sort facets for serialization

if is_sort_facet(link):
links.append(self._serialize_sort_link(link))
return links

def _serialize_sort_link(self, link: Link) -> etree._Element:
sort_link = Link(
href=link.href, title=link.title, rel=AtomFeed.PALACE_REL_NS + "sort"
)
attributes: dict[str, Any] = dict()
if link.get("activeFacet", False):
sort_link.add_attributes(dict(activeSort="true"))
attributes.update(dict(activeSort="true"))
if link.get("defaultFacet", False):
attributes.update(dict(defaultFacet="true"))
sort_link.add_attributes(attributes)

return self._serialize_feed_entry("link", sort_link)

def _get_attribute_mapping(self) -> dict[str, str]:
return V2_ATTRIBUTE_MAPPING

def content_type(self) -> str:
return OPDSFeed.ACQUISITION_FEED_TYPE + "; api-version=2"
Loading

0 comments on commit c4ebcb7

Please sign in to comment.