Skip to content

Commit

Permalink
Merge pull request #41 from syeopite/localization-api
Browse files Browse the repository at this point in the history
Add localization API
  • Loading branch information
syeopite authored Dec 29, 2024
2 parents 8ba5ed7 + 73715f6 commit 2b9c95a
Show file tree
Hide file tree
Showing 18 changed files with 633 additions and 190 deletions.
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

0 comments on commit 2b9c95a

Please sign in to comment.