Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add localization API #41

Merged
merged 14 commits into from
Dec 29, 2024
2 changes: 2 additions & 0 deletions src/npf_renderer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@

from . import exceptions, parse, objects, format

DEFAULT_LOCALIZATION = format.i18n.DEFAULT_LOCALIZATION

VERSION = "0.13.0"
2 changes: 1 addition & 1 deletion src/npf_renderer/format/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from .base import Formatter
from .base import Formatter, i18n
29 changes: 16 additions & 13 deletions src/npf_renderer/format/attribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from .. import objects


def format_link_attribution(attr: objects.attribution.LinkAttribution, url_handler: Callable):
def format_link_attribution(attr: objects.attribution.LinkAttribution, url_handler: Callable, localizer):
return dominate.tags.div(
dominate.tags.a(
urllib.parse.urlparse(attr.url).hostname,
Expand All @@ -16,43 +16,46 @@ def format_link_attribution(attr: objects.attribution.LinkAttribution, url_handl
)


def format_post_attribution(attr: objects.attribution.PostAttribution, url_handler: Callable):
def format_post_attribution(attr: objects.attribution.PostAttribution, url_handler: Callable, localizer):
return dominate.tags.div(
dominate.tags.a(
f"From ",
dominate.tags.b(attr.blog.name),
dominate.util.raw(
localizer["post_attribution"].format(dominate.tags.b(attr.blog.name).render(pretty=False))
),
href=url_handler(attr.url),
),
cls="post-attribution",
)


def format_blog_attribution(attr: objects.attribution.BlogAttribution, url_handler: Callable):
return dominate.tags.div(
def format_blog_attribution(attr: objects.attribution.BlogAttribution, url_handler: Callable, localizer):
result = dominate.tags.div(
dominate.tags.a(
f"Created by ",
dominate.tags.b(attr.name or "Anonymous"),
dominate.util.raw(
localizer["blog_attribution"].format(dominate.tags.b(attr.name or "Anonymous").render(pretty=False))
),
href=url_handler(attr.url),
),
cls="blog-attribution",
)

return result


def format_app_attribution(attr: objects.attribution.AppAttribution, url_handler: Callable):
def format_app_attribution(attr: objects.attribution.AppAttribution, url_handler: Callable, localizer):
return dominate.tags.div(
dominate.tags.a(
f"View on ",
dominate.tags.b(attr.app_name),
dominate.util.raw(localizer["app_attribution"].format(dominate.tags.b(attr.app_name).render(pretty=False))),
href=url_handler(attr.url),
),
cls="post-attribution",
)


def format_unsupported_attribution(attr: objects.attribution.UnsupportedAttribution):
def format_unsupported_attribution(attr: objects.attribution.UnsupportedAttribution, localizer):
return dominate.tags.div(
dominate.tags.p(
f"Attributed via unsupported '{attr.type_}' attribution type. Please report me.",
localizer["unsupported_attribution"].format(attr.type_),
),
cls="unknown-attribution",
)
Expand Down
149 changes: 99 additions & 50 deletions src/npf_renderer/format/base.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import datetime
import urllib.parse
from typing import Callable

import dominate.tags
import dominate.util

from . import text, image, misc, attribution
from . import text, image, misc, attribution, i18n
from .. import objects, helpers, exceptions


Expand All @@ -30,7 +31,16 @@ class HTMLTimeTag(dominate.tags.html_tag):


class Formatter(helpers.CursorIterator):
def __init__(self, content, layout=None, *, url_handler=None, forbid_external_iframes=False, truncate=True):
def __init__(
self,
content,
layout=None,
*,
localizer: dict[str, str | Callable] = i18n.DEFAULT_LOCALIZATION,
url_handler=None,
forbid_external_iframes=False,
truncate=True,
):
"""Initializes the parser with a list of content blocks (json objects) to parse"""
super().__init__(content)

Expand All @@ -43,6 +53,7 @@ def url_handler(url):
self.current_context_padding = 0
self.render_instructions = []

self.localizer = localizer # type : ignore
self.url_handler = url_handler
self.forbid_external_iframes = forbid_external_iframes
self.truncate = truncate
Expand Down Expand Up @@ -74,11 +85,8 @@ def format_unsupported(self, block):

with dominate.tags.div(cls="unsupported-content-block") as unsupported:
with dominate.tags.div(cls="unsupported-content-block-message"):
dominate.tags.h1("Unsupported content placeholder")
dominate.tags.p(
f'Hello! I\'m a placeholder for the unsupported "{block.type}" type NPF content block.'
f" Please report me!"
)
dominate.tags.h1(self.localizer["unsupported_block_header"])
dominate.tags.p(self.localizer["unsupported_block_description"])

return unsupported

Expand All @@ -87,10 +95,7 @@ def _format_image(self, block, row_length=1, override_padding=None):
figure = dominate.tags.figure(cls="image-block")

image_container = image.format_image(
block,
row_length,
url_handler=self.url_handler,
override_padding=override_padding,
block, row_length, url_handler=self.url_handler, override_padding=override_padding, localizer=self.localizer
)

figure.add(image_container)
Expand All @@ -101,15 +106,15 @@ def _format_image(self, block, row_length=1, override_padding=None):
# Add attribution HTML
if attr := block.attribution:
if isinstance(attr, objects.attribution.LinkAttribution):
figure.add(attribution.format_link_attribution(attr, self.url_handler))
figure.add(attribution.format_link_attribution(attr, self.url_handler, self.localizer))
elif isinstance(attr, objects.attribution.PostAttribution):
figure.add(attribution.format_post_attribution(attr, self.url_handler))
figure.add(attribution.format_post_attribution(attr, self.url_handler, self.localizer))
elif isinstance(attr, objects.attribution.BlogAttribution):
figure.add(attribution.format_blog_attribution(attr, self.url_handler))
figure.add(attribution.format_blog_attribution(attr, self.url_handler, self.localizer))
elif isinstance(attr, objects.attribution.AppAttribution):
figure.add(attribution.format_app_attribution(attr, self.url_handler))
figure.add(attribution.format_app_attribution(attr, self.url_handler, self.localizer))
else:
figure.add(attribution.format_unsupported_attribution(attr))
figure.add(attribution.format_unsupported_attribution(attr, self.localizer))

return figure

Expand All @@ -127,7 +132,7 @@ def _format_link(self, block):
poster_container.add(
dominate.tags.img(
srcset=srcset,
alt=block.site_name or "Link block poster",
alt=block.site_name or self.localizer["link_block_poster_alt_text"].format(site=block.url),
sizes="(max-width: 540px) 100vh, 540px",
)
)
Expand Down Expand Up @@ -203,8 +208,8 @@ def _format_video(self, block):
if not media_url.hostname.endswith(".tumblr.com"):
return self._audiovisual_link_block_fallback(
block,
title="Error: Cannot construct video player",
description="Please click me to watch on the original site",
title=self.localizer["error_link_block_fallback_native_video_player_non_tumblr_source"],
description=self.localizer["video_link_block_fallback_description"],
)

additional_attrs = {}
Expand Down Expand Up @@ -250,11 +255,15 @@ def _format_video(self, block):
if not video:
if self.forbid_external_iframes and (block.embed_html or block.embed_url or block.embed_iframe):
return self._audiovisual_link_block_fallback(
block, "Embeds are disabled", f"Please click me to watch on the original site"
block,
self.localizer["link_block_fallback_embeds_are_disabled"], # type: ignore
self.localizer["video_link_block_fallback_description"], # type: ignore
)
else:
return self._audiovisual_link_block_fallback(
block, "Error: unable to render video block", f"Please click me to watch on the original site"
block,
self.localizer["error_video_link_block_fallback_heading"], # type: ignore
self.localizer["video_link_block_fallback_description"], # type: ignore
)

video_block = dominate.tags.div(**root_video_block_attrs)
Expand Down Expand Up @@ -291,8 +300,8 @@ def _format_audio(self, block):
if not media_url.hostname.endswith(".tumblr.com"):
return self._audiovisual_link_block_fallback(
block,
title="Error: Cannot construct audio player",
description="Please click me to listen on the original site",
title=self.localizer["error_link_block_fallback_native_audio_player_non_tumblr_source"], # type: ignore
description=self.localizer["audio_link_block_fallback_description"], # type: ignore
site_name=media_url.hostname,
)

Expand Down Expand Up @@ -320,7 +329,7 @@ def _format_audio(self, block):
dominate.tags.img(
src=self.url_handler(block.poster[0].url),
srcset=", ".join(image.create_srcset(block.poster, self.url_handler)),
alt=block.title or "Audio block poster",
alt=block.title or self.localizer["fallback_audio_block_thumbnail_alt_text"],
sizes="(max-width: 540px) 100vh, 540px",
cls="ab-poster",
)
Expand Down Expand Up @@ -348,11 +357,15 @@ def _format_audio(self, block):
if not audio:
if self.forbid_external_iframes and (block.embed_html or block.embed_url):
return self._audiovisual_link_block_fallback(
block, "Embeds are disabled", f"Please click me to listen on the original site"
block,
self.localizer["link_block_fallback_embeds_are_disabled"], # type: ignore
self.localizer["audio_link_block_fallback_description"], # type: ignore
)
else:
return self._audiovisual_link_block_fallback(
block, "Error: unable to render audio block", f"Please click me to listen on the original site"
block,
self.localizer["error_audio_link_block_fallback_heading"], # type: ignore
self.localizer["audio_link_block_fallback_description"], # type: ignore
)

audio_block = dominate.tags.div(cls="audio-block")
Expand Down Expand Up @@ -391,31 +404,62 @@ def _format_poll(self, block):
poll_body.add(poll_choice)

footer = dominate.tags.footer()
with footer:
creation = datetime.datetime.fromtimestamp(block.creation_timestamp, datetime.timezone.utc)
expiration = datetime.datetime.fromtimestamp(
block.creation_timestamp + block.expires_after, datetime.timezone.utc

# creation = datetime.datetime.fromtimestamp(block.creation_timestamp, datetime.timezone.utc)

expiration = datetime.datetime.fromtimestamp(
block.creation_timestamp + block.expires_after, datetime.timezone.utc
)
now = datetime.datetime.now(datetime.timezone.utc)

# Timezone information is irrelevant
expiration = expiration.replace(tzinfo=None)
now = now.replace(tzinfo=None)

poll_metadata = dominate.tags.div(cls="poll-metadata")

if block.votes:
poll_metadata.add(
dominate.tags.span(self.localizer["plural_poll_total_votes"](block.total_votes)),
dominate.tags.span("•", cls="separator"),
)

# If not expired we display how many days till expired

if expiration > now:
# Build time duration string
remaining_time = expiration - now
duration_string = self.localizer["format_duration_func"](remaining_time) # type: ignore

poll_metadata.add(
dominate.tags.span(
dominate.util.raw(
self.localizer["poll_remaining_time"].format( # type: ignore
duration=HTMLTimeTag(
duration_string, datetime=helpers.build_duration_string(remaining_time)
).render(pretty=False)
)
)
)
)

else:
human_readable_expiration = self.localizer["format_datetime_func"](expiration) # type: ignore
formatted_expiration = expiration.strftime("%Y-%m-%dT%H:%M")

poll_metadata.add(
dominate.tags.span(
dominate.util.raw(
self.localizer["poll_ended_on"].format( # type: ignore
ended_date=HTMLTimeTag(human_readable_expiration, datetime=formatted_expiration).render(
pretty=False
)
)
)
)
)
now = datetime.datetime.now(datetime.timezone.utc)

# Timezone information is irrelevant
expiration = expiration.replace(tzinfo=None)
now = now.replace(tzinfo=None)

# If not expired we display how many days till expired
with dominate.tags.div(cls="poll-metadata"):
if block.votes:
dominate.tags.span(f"{block.total_votes} votes")
dominate.tags.span("•", cls="separator")
if expiration > now:
# Build time duration string
remaining_time = expiration - now
duration_string = helpers.build_duration_string(remaining_time)
dominate.tags.span(f"Remaining time: ", HTMLTimeTag(str(remaining_time), datetime=duration_string))
else:
formatted_expiration = expiration.strftime("%Y-%m-%dT%H:%M")
dominate.tags.span(f"Ended on: ", HTMLTimeTag(str(expiration), datetime=formatted_expiration))

footer.add(poll_metadata)
poll_block.add(poll_body, footer)

if now > expiration:
Expand Down Expand Up @@ -610,7 +654,12 @@ def format(self):

self.post.add(
dominate.tags.div(
misc.format_ask(layout.attribution, *layout_items, url_handler=self.url_handler),
misc.format_ask(
layout.attribution,
*layout_items,
url_handler=self.url_handler,
localizer=self.localizer,
),
cls="layout-ask",
)
)
Expand Down
30 changes: 30 additions & 0 deletions src/npf_renderer/format/i18n.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""This module provides the default localization strings for npf-renderer"""

from .. import helpers

DEFAULT_LOCALIZATION = {
"asker_with_no_attribution": "Anonymous",
"asker_and_ask_verb": "{name} asked:",
"unsupported_block_header": "Unsupported NPF block",
"unsupported_block_description": 'Placeholder for the unsupported "{block}" type NPF block\
Please report me over at https://github.com/syeopite/npf-renderer',
"generic_image_alt_text": "image",
"link_block_poster_alt_text": 'Preview image for "{site}"',
"link_block_fallback_embeds_are_disabled": "Embeds are disabled",
"error_video_link_block_fallback_heading": "Error: unable to render video block",
"video_link_block_fallback_description": "Please click me to watch on the original site",
"error_link_block_fallback_native_video_player_non_tumblr_source": "Error: non-tumblr source for video player",
"fallback_audio_block_thumbnail_alt_text": "Album art",
"error_audio_link_block_fallback_heading": "Error: unable to render audio block",
"audio_link_block_fallback_description": "Please click me to listen on the original site",
"error_link_block_fallback_native_audio_player_non_tumblr_source": "Error: non-tumblr source for audio player",
"plural_poll_total_votes": lambda votes: f"{votes} votes",
"poll_remaining_time": "Remaining time: {duration}",
"poll_ended_on": "Ended on: {ended_date}",
"post_attribution": "From {0}",
"blog_attribution": "Created by {0}",
"app_attribution": "View on {0}",
"unsupported_attribution": 'Attributed via an unsupported ("{0}") attribution type. Please report this over at https://github.com/syeopite/npf-renderer',
"format_duration_func": lambda duration: str(duration),
"format_datetime_func": lambda datetime: str(datetime),
}
11 changes: 9 additions & 2 deletions src/npf_renderer/format/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@ def create_srcset(media_blocks, url_handler):
return main_srcset


def format_image(image_block, row_length=1, url_handler=lambda url: url, override_padding=None, original_media=None):
def format_image(
image_block,
row_length=1,
url_handler=lambda url: url,
override_padding=None,
original_media=None,
localizer: dict = {},
):
"""Renders a ImageBlock into HTML"""

container_attributes = {"cls": "image-container"}
Expand Down Expand Up @@ -83,7 +90,7 @@ def format_image(image_block, row_length=1, url_handler=lambda url: url, overrid
srcset=", ".join(create_srcset(processed_media_blocks, url_handler)),
cls="image",
loading="lazy",
alt=image_block.alt_text or "image",
alt=image_block.alt_text or localizer["generic_image_alt_text"],
sizes=f"(max-width: 540px) {int(100 / row_length)}vh, {int(540 / row_length)}px",
**image_attributes,
)
Expand Down
Loading
Loading