Source code for betty.extension.cotton_candy

"""
Provide Betty's default theme.
"""

from __future__ import annotations

import re
from collections import defaultdict
from pathlib import Path
from typing import Iterable, Self, cast, TYPE_CHECKING

from jinja2 import pass_context
from typing_extensions import override

from betty import fs
from betty.assertion import (
    OptionalField,
    assert_str,
    assert_record,
    assert_path,
    assert_setattr,
)
from betty.assertion.error import AssertionFailed
from betty.config import Configuration
from betty.extension.cotton_candy.search import Index
from betty.extension.webpack import Webpack, WebpackEntryPointProvider
from betty.functools import Uniquifier
from betty.generate import Generator, GenerationContext
from betty.html import CssProvider
from betty.jinja2 import (
    Jinja2Provider,
    context_project,
    context_localizer,
    context_job_context,
    Globals,
    Filters,
)
from betty.locale import Date, Datey
from betty.locale.localizable import _, plain, Localizable
from betty.model import Entity, UserFacingEntity, GeneratedEntityId
from betty.model.ancestry import (
    Event,
    Person,
    Presence,
    is_public,
    Subject,
    HasFiles,
    File,
    Place,
)
from betty.model.event_type import StartOfLifeEventType, EndOfLifeEventType
from betty.os import link_or_copy
from betty.project import EntityReferenceSequence, EntityReference
from betty.project.extension import ConfigurableExtension, Extension, Theme
from betty.serde.dump import minimize, Dump, VoidableDump
from betty.typing import Void

if TYPE_CHECKING:
    from jinja2.runtime import Context
    from collections.abc import Sequence, AsyncIterable


class _ColorConfiguration(Configuration):
    _HEX_PATTERN = re.compile(r"^#[a-zA-Z0-9]{6}$")

    def __init__(self, hex_value: str):
        super().__init__()
        self._hex: str
        self.hex = hex_value

    def _assert_hex(self, hex_value: str) -> str:
        if not self._HEX_PATTERN.match(hex_value):
            raise AssertionFailed(
                _(
                    '"{hex_value}" is not a valid hexadecimal color, such as #ffc0cb.'
                ).format(
                    hex_value=hex_value,
                )
            )
        return hex_value

    @property
    def hex(self) -> str:
        return self._hex

    @hex.setter
    def hex(self, hex_value: str) -> None:
        self._assert_hex(hex_value)
        self._hex = hex_value

    @override
    def update(self, other: Self) -> None:
        self.hex = other.hex

    @override
    def load(self, dump: Dump) -> None:
        self._hex = (assert_str() | self._assert_hex)(dump)

    @override
    def dump(self) -> VoidableDump:
        return self._hex


[docs] class CottonCandyConfiguration(Configuration): """ Provide configuration for the :py:class:`betty.extension.cotton_candy.CottonCandy` extension. """ DEFAULT_PRIMARY_INACTIVE_COLOR = "#ffc0cb" DEFAULT_PRIMARY_ACTIVE_COLOR = "#ff69b4" DEFAULT_LINK_INACTIVE_COLOR = "#149988" DEFAULT_LINK_ACTIVE_COLOR = "#2a615a"
[docs] def __init__( self, *, featured_entities: ( Sequence[EntityReference[UserFacingEntity & Entity]] | None ) = None, primary_inactive_color: str = DEFAULT_PRIMARY_INACTIVE_COLOR, primary_active_color: str = DEFAULT_PRIMARY_ACTIVE_COLOR, link_inactive_color: str = DEFAULT_LINK_INACTIVE_COLOR, link_active_color: str = DEFAULT_LINK_ACTIVE_COLOR, logo: Path | None = None, ): super().__init__() self._featured_entities = EntityReferenceSequence["UserFacingEntity & Entity"]( featured_entities or () ) self._primary_inactive_color = _ColorConfiguration(primary_inactive_color) self._primary_active_color = _ColorConfiguration(primary_active_color) self._link_inactive_color = _ColorConfiguration(link_inactive_color) self._link_active_color = _ColorConfiguration(link_active_color) self._logo = logo
@property def featured_entities(self) -> EntityReferenceSequence[UserFacingEntity & Entity]: """ The entities featured on the front page. """ return self._featured_entities @property def primary_inactive_color(self) -> _ColorConfiguration: """ The color for inactive primary/CTA elements. """ return self._primary_inactive_color @property def primary_active_color(self) -> _ColorConfiguration: """ The color for active primary/CTA elements. """ return self._primary_active_color @property def link_inactive_color(self) -> _ColorConfiguration: """ The color for inactive hyperlinks. """ return self._link_inactive_color @property def link_active_color(self) -> _ColorConfiguration: """ The color for active hyperlinks. """ return self._link_active_color @property def logo(self) -> Path | None: """ The path to the logo. """ return self._logo @logo.setter def logo(self, logo: Path | None) -> None: self._logo = logo
[docs] @override def load(self, dump: Dump) -> None: assert_record( OptionalField("featured_entities", self.featured_entities.load), OptionalField("primary_inactive_color", self.primary_inactive_color.load), OptionalField("primary_active_color", self.primary_active_color.load), OptionalField("link_inactive_color", self.link_inactive_color.load), OptionalField("link_active_color", self.link_active_color.load), OptionalField("logo", assert_path() | assert_setattr(self, "logo")), )(dump)
[docs] @override def dump(self) -> VoidableDump: return minimize( { "featured_entities": self.featured_entities.dump(), "primary_inactive_color": self._primary_inactive_color.dump(), "primary_active_color": self._primary_active_color.dump(), "link_inactive_color": self._link_inactive_color.dump(), "link_active_color": self._link_active_color.dump(), "logo": str(self._logo) if self._logo else Void, } )
[docs] class CottonCandy( Theme, CssProvider, ConfigurableExtension[CottonCandyConfiguration], Generator, Jinja2Provider, WebpackEntryPointProvider, ): """ Provide Betty's default front-end theme. """
[docs] @override @classmethod def name(cls) -> str: return "betty.extension.CottonCandy"
[docs] @override @classmethod def depends_on(cls) -> set[type[Extension]]: return {Webpack}
[docs] @override @classmethod def comes_after(cls) -> set[type[Extension]]: from betty.extension import Maps, Trees return {Maps, Trees}
[docs] @override @classmethod def assets_directory_path(cls) -> Path: return Path(__file__).parent / "assets"
[docs] @override @classmethod def webpack_entry_point_directory_path(cls) -> Path: return Path(__file__).parent / "webpack"
[docs] @override def webpack_entry_point_cache_keys(self) -> Sequence[str]: return ( self.project.configuration.root_path, self._configuration.primary_inactive_color.hex, self._configuration.primary_active_color.hex, self._configuration.link_inactive_color.hex, self._configuration.link_active_color.hex, )
@override @property def public_css_paths(self) -> list[str]: return [ self.project.static_url_generator.generate( "css/betty.extension.CottonCandy.css" ), ]
[docs] @override @classmethod def label(cls) -> Localizable: return plain("Cotton Candy")
[docs] @override @classmethod def default_configuration(cls) -> CottonCandyConfiguration: return CottonCandyConfiguration()
[docs] @override @classmethod def description(cls) -> Localizable: return _("Cotton Candy is Betty's default theme.")
@property def logo(self) -> Path: """ The path to the logo file. """ return ( self._configuration.logo or fs.ASSETS_DIRECTORY_PATH / "public" / "static" / "betty-512x512.png" )
[docs] @override async def generate(self, job_context: GenerationContext) -> None: await link_or_copy( self.logo, self.project.configuration.www_directory_path / "logo.png" )
@override @property def globals(self) -> Globals: return { "search_index": _global_search_index, } @override @property def filters(self) -> Filters: return { "person_timeline_events": lambda person: person_timeline_events( person, self.project.configuration.lifetime_threshold ), "person_descendant_families": person_descendant_families, "associated_files": associated_files, }
@pass_context async def _global_search_index(context: Context) -> AsyncIterable[dict[str, str]]: return Index( context_project(context), context_job_context(context), context_localizer(context), ).build() def _is_person_timeline_presence(presence: Presence) -> bool: if presence.private: return False if not presence.event: return False if not presence.event.date: return False if not presence.event.date.comparable: return False return True
[docs] def person_timeline_events(person: Person, lifetime_threshold: int) -> Iterable[Event]: """ Gather all events for a person's timeline. """ yield from Uniquifier(_person_timeline_events(person, lifetime_threshold))
[docs] def person_descendant_families( person: Person, ) -> Iterable[tuple[Sequence[Person], Sequence[Person]]]: """ Gather a person's families they are a parent in. """ parents = {} children = defaultdict(set) for child in person.children: family = tuple(sorted((parent.id for parent in child.parents))) if family not in parents: parents[family] = tuple(child.parents) children[family].add(child) yield from zip(parents.values(), children.values())
[docs] def associated_files(has_files: HasFiles) -> Iterable[File]: """ Get the associated files for an entity that has files. """ yield from Uniquifier(_associated_files(has_files))
def _associated_files(has_files: HasFiles) -> Iterable[File]: yield from has_files.files if isinstance(has_files, Event): for citation in has_files.citations: yield from _associated_files(citation) if isinstance(has_files, Person): for name in has_files.names: for citation in name.citations: yield from _associated_files(citation) for presence in has_files.presences: if presence.event is not None: yield from _associated_files(presence.event) if isinstance(has_files, Place): for event in has_files.events: yield from _associated_files(event) def _person_timeline_events(person: Person, lifetime_threshold: int) -> Iterable[Event]: # Collect all associated events for a person. # Start with the person's own events for which their presence is public. for presence in person.presences: if _is_person_timeline_presence(presence): assert presence.event is not None yield presence.event continue # If the person has start- or end-of-life events, we use those to constrain associated people's events. start_dates = [] end_dates = [] for presence in person.presences: if not _is_person_timeline_presence(presence): continue assert presence.event is not None assert presence.event.date is not None if not isinstance(presence.role, Subject): continue if issubclass(presence.event.event_type, StartOfLifeEventType): start_dates.append(presence.event.date) if issubclass(presence.event.event_type, EndOfLifeEventType): end_dates.append(presence.event.date) start_date = sorted(start_dates)[0] if start_dates else None end_date = sorted(end_dates)[0] if end_dates else None # If an end-of-life event exists, but no start-of-life event, create a start-of-life date based on the end date, # minus the lifetime threshold. if start_date is None and end_date is not None: if isinstance(end_date, Date): start_date_reference = end_date else: if end_date.end is not None and end_date.end.comparable: start_date_reference = end_date.end else: assert end_date.start is not None start_date_reference = end_date.start assert start_date_reference.year is not None start_date = Date( start_date_reference.year - lifetime_threshold, start_date_reference.month, start_date_reference.day, start_date_reference.fuzzy, ) # If a start-of-life event exists, but no end-of-life event, create an end-of-life date based on the start date, # plus the lifetime threshold. if end_date is None and start_date is not None: if isinstance(start_date, Date): end_date_reference = start_date else: if start_date.start and start_date.start.comparable: end_date_reference = start_date.start else: assert start_date.end is not None end_date_reference = start_date.end assert end_date_reference.year is not None end_date = Date( end_date_reference.year + lifetime_threshold, end_date_reference.month, end_date_reference.day, end_date_reference.fuzzy, ) if start_date is None or end_date is None: reference_dates = sorted( cast(Datey, cast(Event, presence.event).date) for presence in person.presences if _is_person_timeline_presence(presence) ) if reference_dates: if not start_date: start_date = reference_dates[0] if not end_date: end_date = reference_dates[-1] if start_date is not None and end_date is not None: associated_people = filter( is_public, ( # All ancestors. *person.ancestors, # All descendants. *person.descendants, # All siblings. *person.siblings, ), ) for associated_person in associated_people: # For associated events, we are only interested in people's start- or end-of-life events. for associated_presence in associated_person.presences: if not associated_presence.event or not issubclass( associated_presence.event.event_type, (StartOfLifeEventType, EndOfLifeEventType), ): continue if isinstance(associated_presence.event.id, GeneratedEntityId): continue if not isinstance(associated_presence.role, Subject): continue if not _is_person_timeline_presence(associated_presence): continue if not associated_presence.event.date: continue if associated_presence.event.date < start_date: continue if associated_presence.event.date > end_date: continue yield associated_presence.event