<?php
namespace Products\NotificationsBundle\Util;
use App\Util\LocaleDateFormatter;
use App\Util\Locales;
use Cms\CoreBundle\Util\DateTimeUtils;
use DateTimeInterface;
use DateTimeZone;
use Products\NotificationsBundle\Entity\AbstractNotification;
use Products\NotificationsBundle\Entity\ContactAttempts\AbstractTransactionalContactAttempt;
use Products\NotificationsBundle\Entity\Profile;
use Products\NotificationsBundle\Entity\Student;
use Symfony\Component\HttpFoundation\ParameterBag;
use Twig\Compiler;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use Twig\Markup;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\TempNameExpression;
use Twig\Node\Node;
use Twig\Node\PrintNode;
use Twig\Node\SetNode;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;
use Twig\TwigFunction;
/**
*
*/
final class MessageContentGenerator
{
protected ArrayLoader $loader;
protected Environment $twig;
protected ParameterBag $params;
protected string $locale = Locales::RFC4646_DEFAULT;
protected ?DateTimeZone $timezone = null;
protected ?DateTimeInterface $now = null;
protected bool $passthrough = false;
public function __construct()
{
$this->loader = new ArrayLoader();
$this->twig = $this->createEnvironment();
$this->params = new ParameterBag();
$this->setTiming();
}
/**
* @param AbstractTransactionalContactAttempt $attempt
* @return self
*/
public function init(AbstractTransactionalContactAttempt $attempt): self
{
return $this
->setStudent($attempt->getStudent())
->setProfile($attempt->getProfile())
->setNotification($attempt->getMessage())
;
}
/**
* @param bool $passthrough
* @return $this
*/
public function togglePassthrough(bool $passthrough): self
{
$this->passthrough = $passthrough;
return $this;
}
/**
* @param AbstractNotification|null $notification
* @return self
*/
public function setNotification(?AbstractNotification $notification): self
{
$this->params->remove('message');
if ($notification instanceof AbstractNotification) {
$this->params->add([
'message' => [
'id' => $notification->getId(),
'uid' => $notification->getUid(),
// TODO: do we really need this anywhere?
],
]);
}
return $this;
}
/**
* @param Profile|null $profile
* @return self
*/
public function setProfile(?Profile $profile): self
{
$this->params->remove('profile');
if ($profile instanceof Profile) {
$this->params->add([
'profile' => [
'id' => $profile->getId(),
'uid' => $profile->getUid(),
'oneroster_id' => $profile->getOneRosterId(),
'given_name' => $profile->getFirstName(),
'family_name' => $profile->getLastName(),
'full_name' => $profile->getFullName(),
'censored_name' => $profile->getCensoredName(),
'sort_name' => $profile->getSortName(),
'metadata' => $profile->getMetadata(),
],
]);
}
return $this;
}
/**
* @param Student|null $student
* @return self
*/
public function setStudent(?Student $student): self
{
$this->params->remove('student');
if ($student instanceof Student) {
$this->params->add([
'student' => [
'id' => $student->getId(),
'uid' => $student->getUid(),
'oneroster_id' => $student->getOneRosterId(),
'given_name' => $student->getFirstName(),
'family_name' => $student->getLastName(),
'full_name' => $student->getFullName(),
'censored_name' => $student->getCensoredName(),
'sort_name' => $student->getSortName(),
'metadata' => $student->getMetadata(),
],
]);
}
return $this;
}
/**
* @param string|null $locale
* @return $this
*/
public function setLocale(?string $locale): self
{
$this->locale = $locale ?: Locales::RFC4646_DEFAULT;
return $this->setTiming($this->now);
}
/**
* @param DateTimeZone|string|null $timezone
* @return $this
*/
public function setTimezone($timezone): self
{
if ($timezone) {
$timezone = DateTimeUtils::timezone($timezone);
}
if ($timezone && ! $timezone instanceof DateTimeZone) {
throw new \RuntimeException();
}
$this->timezone = $timezone ?: null;
return $this->setTiming($this->now);
}
/**
* @param DateTimeInterface|null $now
* @return $this
*/
public function setTiming(?DateTimeInterface $now = null): self
{
// set now if not given and generate other key datetimes
if ($now) {
$this->now = $now;
} else {
$now = DateTimeUtils::now($this->timezone);
}
$today = DateTimeUtils::thisDay($now);
$tomorrow = DateTimeUtils::nextDay($today);
// create a formatter for the locale
$formatter = new LocaleDateFormatter(
$this->locale,
null,
null,
$this->timezone
);
// for easier debugging, pulling this out into a variable
$merges = [
// internal use, shouldn't be documented on support
// TODO: should we only allow these if on dev/staging?
'debug' => 'DEBUGGING',
'now_rfc3339' => $now->format(DATE_RFC3339),
'today_rfc3339' => $today->format(DATE_RFC3339),
'tomorrow_rfc3339' => $tomorrow->format(DATE_RFC3339),
// these should be allowed to be documented
// only certain types need formatted
// NOTE: these must use ICU formatting patterns
// @see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
'today_year' => $formatter->formatWithPattern($today, 'yyyy'),
'today_month' => $formatter->formatWithPattern($today, 'MMMM'),
'today_day' => $formatter->formatWithPattern($today, 'd'),
'today_weekday' => $formatter->formatWithPattern($today, 'EEEE'),
'today_date' => $formatter->formatWithPattern($today, 'MMMM d, yyyy'),
'today_full' => $formatter->formatWithPattern($today, 'EEEE, MMMM d, yyyy'),
'tomorrow_year' => $formatter->formatWithPattern($tomorrow, 'yyyy'),
'tomorrow_month' => $formatter->formatWithPattern($tomorrow, 'MMMM'),
'tomorrow_day' => $formatter->formatWithPattern($tomorrow, 'd'),
'tomorrow_weekday' => $formatter->formatWithPattern($tomorrow, 'EEEE'),
'tomorrow_date' => $formatter->formatWithPattern($tomorrow, 'MMMM d, yyyy'),
'tomorrow_full' => $formatter->formatWithPattern($tomorrow, 'EEEE, MMMM d, yyyy'),
];
// attach generated dates to the data set
$this->params->add($merges);
return $this;
}
/**
* @param string|null $content
* @param array|null $overrideParams
* @return string
*/
public function render(?string $content, ?array $overrideParams = null): string
{
// if no content is given, return nothing
if ( ! $content) {
return '';
}
// we may be just passing through the content
// this is done to not break chaining in certain spots...
if ($this->passthrough) {
return $content;
}
// need to add the content to the loader
$this->loader->setTemplate(
$name = sprintf(
'__string_template__%s',
hash(PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', $content, false),
),
$content,
);
return $this->twig->render(
$this->twig->createTemplate($content),
!empty($overrideParams) ? $overrideParams : $this->params->all()
);
}
/**
* @return array
*/
public function getParams(): array
{
return $this->params->all();
}
/**
* TODO: testing for custom functions and a more controlled twig environment for email template processing
*
* @return Environment
*/
protected function createEnvironment(): Environment
{
$env = new Environment(
$this->loader,
[
'strict_variables' => false,
],
);
$env->addTokenParser(new class extends AbstractTokenParser {
public function parse(Token $token)
{
$lineno = $token->getLine();
$stream = $this->parser->getStream();
// create a temporary variable to hold our stuff
$name = $this->parser->getVarName();
$ref = new TempNameExpression($name, $lineno);
$ref->setAttribute('always_defined', true);
// see if there is an expression within the tag
$expression = null;
if ( ! $stream->test(Token::BLOCK_END_TYPE)) {
$expression = $this->parser->getExpressionParser()->parseExpression();
}
$stream->expect(Token::BLOCK_END_TYPE);
// if no expression was used, we should expect an end tag
$body = null;
if ( ! $expression) {
$body = $this->parser->subparse([$this, 'decideBlockEnd'], true);
$stream->expect(Token::BLOCK_END_TYPE);
}
return new Node([
new SetNode($body !== null, $ref, $body ?? $expression, $lineno, $this->getTag()),
new TextSafeNode('<span class="notranslate">', $lineno),
new PrintNode($ref, $lineno, $this->getTag()),
new TextSafeNode('</span>', $lineno),
]);
}
public function decideBlockEnd(Token $token): bool
{
return $token->test('endnotranslate');
}
public function getTag(): string
{
return 'notranslate';
}
});
$env->addFunction(new TwigFunction(
'table',
function ($data) {
if ( ! is_array($data) || empty($data)) {
return '';
}
$headers = [];
foreach ($data as $values) {
foreach ($values as $k => $v) {
if ( ! in_array($k, $headers)) {
$headers[] = $k;
}
}
}
$html = [
'<table>',
];
$html[] = '<tr>';
foreach ($headers as $header) {
$html[] = sprintf(
'<th>%s</th>',
$header,
);
}
$html[] = '</tr>';
foreach ($data as $key => $values) {
$html[] = '<tr>';
foreach ($headers as $h) {
$html[] = sprintf(
'<td>%s</td>',
$values[$h] ?? '',
);
}
$html[] = '</tr>';
}
$html[] = '</table>';
return new Markup(
implode('', $html),
'UTF-8'
);
},
[
'is_safe' => ['html'],
],
));
$env->addFunction(new TwigFunction(
'list',
function ($data) {
if ( ! is_array($data) || empty($data)) {
return '';
}
$html = [
'<ul>',
];
foreach ($data as $value) {
if ($value) {
$html[] = sprintf(
'<li>%s</li>',
$value,
);
}
}
$html[] = '</ul>';
return new Markup(
implode('', $html),
'UTF-8'
);
},
[
'is_safe' => ['html'],
],
));
$env->addFunction(new TwigFunction(
'notranslate',
function ($data) {
if ( ! is_array($data) || empty($data)) {
return '';
}
$html = [
'<ul>',
];
foreach ($data as $value) {
if ($value) {
$html[] = sprintf(
'<li>%s</li>',
$value,
);
}
}
$html[] = '</ul>';
return new Markup(
implode('', $html),
'UTF-8'
);
},
[
'is_safe' => ['html'],
],
));
return $env;
}
}
final class TextSafeNode extends Node
{
public function __construct(string $data, int $lineno)
{
parent::__construct(['body' => new ConstantExpression($data, $lineno)], ['safe' => true], $lineno);
}
public function compile(Compiler $compiler)
{
$compiler
->addDebugInfo($this)
->write('echo ')
->raw("('' === \$tmp = ")
->subcompile($this->getNode('body'))
->raw(") ? '' : new Markup(\$tmp, \$this->env->getCharset())")
->raw(";\n")
;
}
}