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

Update npf-renderer to 0.12.0 #9

Merged
merged 12 commits into from
Feb 6, 2024
2 changes: 1 addition & 1 deletion assets/css/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ li.control-bar-action:hover {
transform: translateX(-50%);
overflow: hidden;
width: fit-content;
z-index: 1;
z-index: 2;
}

.control-bar-dropdown-menu li {
Expand Down
3 changes: 3 additions & 0 deletions assets/css/blog.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
}

#banner {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 10px 10px 0px 0px;
}

Expand Down
62 changes: 61 additions & 1 deletion assets/css/post-layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,64 @@ a.inline-link, a.inline-mention {
color: #6b7280;
text-transform: uppercase;
font-size: 12px;
}
}

a.toggle-poll-results {
color: unset;
text-decoration: underline;
}

.poll-block {
gap: 15px;
border: unset;

padding: 0 10px;
margin: 10px 0;
}

.poll-block > header > h3 {
font-weight: normal;
margin: 0;
font-size: 24px;
}

.poll-choice {
border: 0;
border-radius: 10px;
background: #edeeee;
padding: 8px 18px;
overflow: hidden;
}

.poll-choice > .answer {
font-weight: 550;
}

.vote-proportion {
background: #dcdcdc;
border-radius: 10px 0px 0px 10px;
}

.poll-winner >.vote-proportion {
background: #BABABA;
}

.poll-block footer {
font-size: 15px;
margin-top: 0;
color: #6b7280;
}

.toggle-poll-results {
display: block;
margin-top: 10px;
margin-bottom: 20px;
}

.toggle-poll-results:hover {
font-weight: 700;
}

.separator {
font-size: 12px;
}
110 changes: 110 additions & 0 deletions assets/js/post.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"use strict";

function requestPollResults(poll_element, pollId) {
return new Promise(function (resolve, reject) {
let post = poll_element.closest(".post");
const blogName = post.getElementsByClassName("blog-name")[0].innerHTML;
const postId = post.dataset.postId;

const pollResultsFetch = fetch(`/api/v1/poll/${blogName}/${postId}/${pollId}/results`);

pollResultsFetch.then((results) => {
return results.json();
}).then((parsed_results) => {
return resolve(parsed_results);
});
})
}

function fill_poll_results(poll_element, results) {
const sorted_poll_results = Object.entries(results.response.results).sort((a,b) => (a[1]-b[1])).reverse();

// First we must find the total number of votes and the winner(s) of the poll
let total_votes = 0;

// Answer ID to winner status and amount of votes
const processed_poll_results = {};
const winner_vote_count = sorted_poll_results[0][1];

for (let [answer_id, votes] of sorted_poll_results) {
processed_poll_results[answer_id] = {"is_winner": (winner_vote_count == votes), "votes": votes};
total_votes += votes
}

const answerIdChoiceElementArray = [];
const pollBody = poll_element.getElementsByClassName("poll-body")[0];

for (let choiceElement of pollBody.children) {
answerIdChoiceElementArray.push([choiceElement.dataset.answerId, choiceElement]);
}

for (let [answer_id, choiceElement] of answerIdChoiceElementArray) {
const answer_results = processed_poll_results[answer_id] || {"is_winner": false, "votes": 0};
const is_winner = answer_results.is_winner;
const answer_votes = answer_results.votes;

let numericalVoteProportion

if (answer_votes == 0 || total_votes == 0) {
numericalVoteProportion = 0;
} else {
numericalVoteProportion = answer_votes/total_votes;
}

const voteProportionElement = document.createElement("span");
voteProportionElement.classList.add("vote-proportion");
voteProportionElement["style"] = `width: ${((numericalVoteProportion) * 100).toFixed(3)}%;`;

const voteCountElement = document.createElement("span");
voteCountElement.classList.add("vote-count");

// A greater rounding precision is needed here
const comparison = Math.round((numericalVoteProportion) * 10000)/10000
if ((comparison > 0.001) || comparison == 0) {
voteCountElement.innerHTML = new Intl.NumberFormat("en-US", {style: "percent", maximumSignificantDigits: 3}).format(Math.round((numericalVoteProportion) * 1000)/1000);
} else {
voteCountElement.innerHTML = "< 0.1%";
}

if (is_winner) {
choiceElement.classList.add("poll-winner");
}

choiceElement.appendChild(voteProportionElement);
choiceElement.appendChild(voteCountElement);
}

const pollMetadataElement = poll_element.getElementsByClassName("poll-metadata")[0]

const totalVotesElement = document.createElement("span")
totalVotesElement.innerHTML = `${total_votes} votes`

const separatorElement = document.createElement("span")
separatorElement.innerHTML = '•'
separatorElement.classList.add("separator")

const pollTimestamp = pollMetadataElement.children[0]

const newMetadataContents = document.createDocumentFragment()
newMetadataContents.append(totalVotesElement, separatorElement, pollTimestamp.cloneNode(true))

pollTimestamp.replaceWith(newMetadataContents)

poll_element.classList.add("populated")
}

const pollBlocks = document.getElementsByClassName("poll-block");


function populate_polls() {
for (let poll of pollBlocks) {
if (poll.classList.contains("populated")) {
continue;
}

requestPollResults(poll, poll.dataset.pollId).then((answers) => {fill_poll_results(poll, answers)})
}
};

// TODO lazy load polls
populate_polls();
4 changes: 3 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,19 @@ frozenlist==1.4.1
html5tagger==1.3.0
httptools==0.6.1
idna==3.6
intervaltree==3.1.0
Jinja2==3.1.2
MarkupSafe==2.1.4
multidict==6.0.5
npf-renderer==0.11.1
npf-renderer==0.12.0
orjson==3.8.0
pycares==4.4.0
pycparser==2.21
PyYAML==6.0.1
sanic==23.3.0
sanic-ext==23.6.0
sanic-routing==23.6.0
sortedcontainers==2.4.0
tracerite==1.1.1
ujson==5.9.0
uvloop==0.19.0
Expand Down
172 changes: 172 additions & 0 deletions src/helpers/ext_npf_renderer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"""Extensions to npf-renderer to allow asynchronous code and some other custom styling"""

import dominate
import npf_renderer

from .helpers import url_handler


class NPFParser(npf_renderer.parse.Parser):
def __init__(self, content, poll_callback=None):
super().__init__(content, poll_callback)

async def _parse_poll_block(self):
poll_id = self.current.get("clientId") or self.current.get("client_id")
if poll_id is None:
raise ValueError("Invalid poll ID")

question = self.current["question"]

answers = {}
for raw_ans in self.current["answers"]:
answer_id = raw_ans.get("clientId") or raw_ans.get("client_id")
answer_text = raw_ans.get("answerText") or raw_ans.get("answer_text")

if answer_id is None or answer_text is None:
raise ValueError("Invalid poll answer")

answers[answer_id] = answer_text

votes = None
total_votes = None

if self.poll_result_callback:
callback_response = await self.poll_result_callback(poll_id)

#{answer_id: vote_count}
raw_results = callback_response["results"].items()
processed_results = sorted(raw_results, key=lambda item: -item[1])

votes_dict = {}
total_votes = 0

for index, results in enumerate(processed_results):
vote_count = results[1]
total_votes += vote_count

if index == 0:
votes_dict[results[0]] = npf_renderer.objects.poll_block.PollResult(is_winner=True, vote_count=vote_count)
else:
votes_dict[results[0]] = npf_renderer.objects.poll_block.PollResult(is_winner=False, vote_count=vote_count)

votes = npf_renderer.objects.poll_block.PollResults(
timestamp=callback_response["timestamp"],
results=votes_dict
)

creation_timestamp = self.current["timestamp"]
expires_after = self.current["settings"]["expireAfter"]

return npf_renderer.objects.poll_block.PollBlock(
poll_id=poll_id,
question=question,
answers=answers,

creation_timestamp=int(creation_timestamp),
expires_after=int(expires_after),

votes=votes,
total_votes=total_votes,
)

async def __parse_block(self):
"""Parses a content block and appends the result to self.parsed_result

Works by routing specific content types to corresponding parse methods
"""

match self.current["type"]:
case "text":
block = self._parse_text()
case "image":
block = self._parse_image_block()
case "link":
block = self._parse_link_block()
case "audio":
block = self._parse_audio_block()
case "video":
block = self._parse_video_block()
case "poll":
block = await self._parse_poll_block()
case _:
block = unsupported.Unsupported(self.current["type"])

self.parsed_result.append(block)

async def parse(self):
"""Begins the parsing chain and returns the final list of parsed objects"""
while self.next():
await self.__parse_block()

return self.parsed_result


class NPFFormatter(npf_renderer.format.Formatter):
def __init__(self, content, layout=None, blog_name=None, post_id=None, *, url_handler=None, forbid_external_iframes=False):
super().__init__(content, layout, url_handler=url_handler, forbid_external_iframes=forbid_external_iframes)

# We store the blog and post ID as to be able to render a link to
# fetch poll results for JS disabled users
self.blog_name = blog_name
self.post_id = post_id

def _format_poll(self, block):
poll_html = super()._format_poll(block)
poll_html["data-poll-id"] = block.poll_id

poll_body = poll_html[1]
for index, answer_id in enumerate(block.answers.keys()):
poll_body[index]["data-answer-id"] = answer_id

if (self.blog_name and self.post_id) and not block.votes:
poll_footer = poll_html[2]
no_script_fallback = dominate.tags.noscript(
dominate.tags.a(
"See Results",
href=f"/{self.blog_name}/{self.post_id}?fetch_polls=true",
cls="toggle-poll-results"
)
)

poll_footer.children.insert(0, no_script_fallback)

return poll_html


async def format_npf(contents, layouts=None, blog_name=None, post_id=None,*, poll_callback=None):
"""Wrapper around npf_renderer.format_npf for extra functionalities

- Replaces internal Parser and Formatter with the modified variants above
- Accepts extra arguments to add additional details to formatted results
- Automatically sets Priviblur-specific rendering arguments

Arguments (new):
blog_name:
Name of the blog the post comes from. This is used to render links to the parent post
post_id:
Unique ID of the post. This is used to render links to the parent post
"""
try:
contents = await NPFParser(contents, poll_callback=poll_callback).parse()
if layouts:
layouts = npf_renderer.parse.LayoutParser(layouts).parse()

contains_render_errors = False

formatted = NPFFormatter(
contents, layouts,
blog_name=blog_name,
post_id=post_id,
url_handler=url_handler,
forbid_external_iframes=True,
).format()

except npf_renderer.exceptions.RenderErrorDisclaimerError as e:
contains_render_errors = True
formatted = e.rendered_result
assert formatted is not None
except Exception as e:
formatted = dominate.tags.div(cls="post-body has-error")
contains_render_errors = True

return contains_render_errors, formatted.render(pretty=False)
Loading