src/Products/NotificationsBundle/Util/MessageContentGenerator.php line 186

Open in your IDE?
  1. <?php
  2. namespace Products\NotificationsBundle\Util;
  3. use App\Util\LocaleDateFormatter;
  4. use App\Util\Locales;
  5. use Cms\CoreBundle\Util\DateTimeUtils;
  6. use DateTimeInterface;
  7. use DateTimeZone;
  8. use Products\NotificationsBundle\Entity\AbstractNotification;
  9. use Products\NotificationsBundle\Entity\ContactAttempts\AbstractTransactionalContactAttempt;
  10. use Products\NotificationsBundle\Entity\Profile;
  11. use Products\NotificationsBundle\Entity\Student;
  12. use Symfony\Component\HttpFoundation\ParameterBag;
  13. use Twig\Compiler;
  14. use Twig\Environment;
  15. use Twig\Loader\ArrayLoader;
  16. use Twig\Markup;
  17. use Twig\Node\Expression\ConstantExpression;
  18. use Twig\Node\Expression\TempNameExpression;
  19. use Twig\Node\Node;
  20. use Twig\Node\PrintNode;
  21. use Twig\Node\SetNode;
  22. use Twig\Token;
  23. use Twig\TokenParser\AbstractTokenParser;
  24. use Twig\TwigFunction;
  25. /**
  26.  *
  27.  */
  28. final class MessageContentGenerator
  29. {
  30.     protected ArrayLoader $loader;
  31.     protected Environment $twig;
  32.     protected ParameterBag $params;
  33.     protected string $locale Locales::RFC4646_DEFAULT;
  34.     protected ?DateTimeZone $timezone null;
  35.     protected ?DateTimeInterface $now null;
  36.     protected bool $passthrough false;
  37.     public function __construct()
  38.     {
  39.         $this->loader = new ArrayLoader();
  40.         $this->twig $this->createEnvironment();
  41.         $this->params = new ParameterBag();
  42.         $this->setTiming();
  43.     }
  44.     /**
  45.      * @param AbstractTransactionalContactAttempt $attempt
  46.      * @return self
  47.      */
  48.     public function init(AbstractTransactionalContactAttempt $attempt): self
  49.     {
  50.         return $this
  51.             ->setStudent($attempt->getStudent())
  52.             ->setProfile($attempt->getProfile())
  53.             ->setNotification($attempt->getMessage())
  54.         ;
  55.     }
  56.     /**
  57.      * @param bool $passthrough
  58.      * @return $this
  59.      */
  60.     public function togglePassthrough(bool $passthrough): self
  61.     {
  62.         $this->passthrough $passthrough;
  63.         return $this;
  64.     }
  65.     /**
  66.      * @param AbstractNotification|null $notification
  67.      * @return self
  68.      */
  69.     public function setNotification(?AbstractNotification $notification): self
  70.     {
  71.         $this->params->remove('message');
  72.         if ($notification instanceof AbstractNotification) {
  73.             $this->params->add([
  74.                 'message' => [
  75.                     'id' => $notification->getId(),
  76.                     'uid' => $notification->getUid(),
  77.                     // TODO: do we really need this anywhere?
  78.                 ],
  79.             ]);
  80.         }
  81.         return $this;
  82.     }
  83.     /**
  84.      * @param Profile|null $profile
  85.      * @return self
  86.      */
  87.     public function setProfile(?Profile $profile): self
  88.     {
  89.         $this->params->remove('profile');
  90.         if ($profile instanceof Profile) {
  91.             $this->params->add([
  92.                 'profile' => [
  93.                     'id' => $profile->getId(),
  94.                     'uid' => $profile->getUid(),
  95.                     'oneroster_id' => $profile->getOneRosterId(),
  96.                     'given_name' => $profile->getFirstName(),
  97.                     'family_name' => $profile->getLastName(),
  98.                     'full_name' => $profile->getFullName(),
  99.                     'censored_name' => $profile->getCensoredName(),
  100.                     'sort_name' => $profile->getSortName(),
  101.                     'metadata' => $profile->getMetadata(),
  102.                 ],
  103.             ]);
  104.         }
  105.         return $this;
  106.     }
  107.     /**
  108.      * @param Student|null $student
  109.      * @return self
  110.      */
  111.     public function setStudent(?Student $student): self
  112.     {
  113.         $this->params->remove('student');
  114.         if ($student instanceof Student) {
  115.             $this->params->add([
  116.                 'student' => [
  117.                     'id' => $student->getId(),
  118.                     'uid' => $student->getUid(),
  119.                     'oneroster_id' => $student->getOneRosterId(),
  120.                     'given_name' => $student->getFirstName(),
  121.                     'family_name' => $student->getLastName(),
  122.                     'full_name' => $student->getFullName(),
  123.                     'censored_name' => $student->getCensoredName(),
  124.                     'sort_name' => $student->getSortName(),
  125.                     'metadata' => $student->getMetadata(),
  126.                 ],
  127.             ]);
  128.         }
  129.         return $this;
  130.     }
  131.     /**
  132.      * @param string|null $locale
  133.      * @return $this
  134.      */
  135.     public function setLocale(?string $locale): self
  136.     {
  137.         $this->locale $locale ?: Locales::RFC4646_DEFAULT;
  138.         return $this->setTiming($this->now);
  139.     }
  140.     /**
  141.      * @param DateTimeZone|string|null $timezone
  142.      * @return $this
  143.      */
  144.     public function setTimezone($timezone): self
  145.     {
  146.         if ($timezone) {
  147.             $timezone DateTimeUtils::timezone($timezone);
  148.         }
  149.         if ($timezone && ! $timezone instanceof DateTimeZone) {
  150.             throw new \RuntimeException();
  151.         }
  152.         $this->timezone $timezone ?: null;
  153.         return $this->setTiming($this->now);
  154.     }
  155.     /**
  156.      * @param DateTimeInterface|null $now
  157.      * @return $this
  158.      */
  159.     public function setTiming(?DateTimeInterface $now null): self
  160.     {
  161.         // set now if not given and generate other key datetimes
  162.         if ($now) {
  163.             $this->now $now;
  164.         } else {
  165.             $now DateTimeUtils::now($this->timezone);
  166.         }
  167.         $today DateTimeUtils::thisDay($now);
  168.         $tomorrow DateTimeUtils::nextDay($today);
  169.         // create a formatter for the locale
  170.         $formatter = new LocaleDateFormatter(
  171.             $this->locale,
  172.             null,
  173.             null,
  174.             $this->timezone
  175.         );
  176.         // for easier debugging, pulling this out into a variable
  177.         $merges = [
  178.             // internal use, shouldn't be documented on support
  179.             // TODO: should we only allow these if on dev/staging?
  180.             'debug' => 'DEBUGGING',
  181.             'now_rfc3339' => $now->format(DATE_RFC3339),
  182.             'today_rfc3339' => $today->format(DATE_RFC3339),
  183.             'tomorrow_rfc3339' => $tomorrow->format(DATE_RFC3339),
  184.             // these should be allowed to be documented
  185.             // only certain types need formatted
  186.             // NOTE: these must use ICU formatting patterns
  187.             // @see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
  188.             'today_year' => $formatter->formatWithPattern($today'yyyy'),
  189.             'today_month' => $formatter->formatWithPattern($today'MMMM'),
  190.             'today_day' => $formatter->formatWithPattern($today'd'),
  191.             'today_weekday' => $formatter->formatWithPattern($today'EEEE'),
  192.             'today_date' => $formatter->formatWithPattern($today'MMMM d, yyyy'),
  193.             'today_full' => $formatter->formatWithPattern($today'EEEE, MMMM d, yyyy'),
  194.             'tomorrow_year' => $formatter->formatWithPattern($tomorrow'yyyy'),
  195.             'tomorrow_month' => $formatter->formatWithPattern($tomorrow'MMMM'),
  196.             'tomorrow_day' => $formatter->formatWithPattern($tomorrow'd'),
  197.             'tomorrow_weekday' => $formatter->formatWithPattern($tomorrow'EEEE'),
  198.             'tomorrow_date' => $formatter->formatWithPattern($tomorrow'MMMM d, yyyy'),
  199.             'tomorrow_full' => $formatter->formatWithPattern($tomorrow'EEEE, MMMM d, yyyy'),
  200.         ];
  201.         // attach generated dates to the data set
  202.         $this->params->add($merges);
  203.         return $this;
  204.     }
  205.     /**
  206.      * @param string|null $content
  207.      * @param array|null $overrideParams
  208.      * @return string
  209.      */
  210.     public function render(?string $content, ?array $overrideParams null): string
  211.     {
  212.         // if no content is given, return nothing
  213.         if ( ! $content) {
  214.             return '';
  215.         }
  216.         // we may be just passing through the content
  217.         // this is done to not break chaining in certain spots...
  218.         if ($this->passthrough) {
  219.             return $content;
  220.         }
  221.         // need to add the content to the loader
  222.         $this->loader->setTemplate(
  223.             $name sprintf(
  224.                 '__string_template__%s',
  225.                 hash(PHP_VERSION_ID 80100 'sha256' 'xxh128'$contentfalse),
  226.             ),
  227.             $content,
  228.         );
  229.         return $this->twig->render(
  230.             $this->twig->createTemplate($content),
  231.             !empty($overrideParams) ? $overrideParams $this->params->all()
  232.         );
  233.     }
  234.     /**
  235.      * @return array
  236.      */
  237.     public function getParams(): array
  238.     {
  239.         return $this->params->all();
  240.     }
  241.     /**
  242.      * TODO: testing for custom functions and a more controlled twig environment for email template processing
  243.      *
  244.      * @return Environment
  245.      */
  246.     protected function createEnvironment(): Environment
  247.     {
  248.         $env = new Environment(
  249.             $this->loader,
  250.             [
  251.                 'strict_variables' => false,
  252.             ],
  253.         );
  254.         $env->addTokenParser(new class extends AbstractTokenParser {
  255.             public function parse(Token $token)
  256.             {
  257.                 $lineno $token->getLine();
  258.                 $stream $this->parser->getStream();
  259.                 // create a temporary variable to hold our stuff
  260.                 $name $this->parser->getVarName();
  261.                 $ref = new TempNameExpression($name$lineno);
  262.                 $ref->setAttribute('always_defined'true);
  263.                 // see if there is an expression within the tag
  264.                 $expression null;
  265.                 if ( ! $stream->test(Token::BLOCK_END_TYPE)) {
  266.                     $expression $this->parser->getExpressionParser()->parseExpression();
  267.                 }
  268.                 $stream->expect(Token::BLOCK_END_TYPE);
  269.                 // if no expression was used, we should expect an end tag
  270.                 $body null;
  271.                 if ( ! $expression) {
  272.                     $body $this->parser->subparse([$this'decideBlockEnd'], true);
  273.                     $stream->expect(Token::BLOCK_END_TYPE);
  274.                 }
  275.                 return new Node([
  276.                     new SetNode($body !== null$ref$body ?? $expression$lineno$this->getTag()),
  277.                     new TextSafeNode('<span class="notranslate">'$lineno),
  278.                     new PrintNode($ref$lineno$this->getTag()),
  279.                     new TextSafeNode('</span>'$lineno),
  280.                 ]);
  281.             }
  282.             public function decideBlockEnd(Token $token): bool
  283.             {
  284.                 return $token->test('endnotranslate');
  285.             }
  286.             public function getTag(): string
  287.             {
  288.                 return 'notranslate';
  289.             }
  290.         });
  291.         $env->addFunction(new TwigFunction(
  292.             'table',
  293.             function ($data) {
  294.                 if ( ! is_array($data) || empty($data)) {
  295.                     return '';
  296.                 }
  297.                 $headers = [];
  298.                 foreach ($data as $values) {
  299.                     foreach ($values as $k => $v) {
  300.                         if ( ! in_array($k$headers)) {
  301.                             $headers[] = $k;
  302.                         }
  303.                     }
  304.                 }
  305.                 $html = [
  306.                     '<table>',
  307.                 ];
  308.                 $html[] = '<tr>';
  309.                 foreach ($headers as $header) {
  310.                     $html[] = sprintf(
  311.                         '<th>%s</th>',
  312.                         $header,
  313.                     );
  314.                 }
  315.                 $html[] = '</tr>';
  316.                 foreach ($data as $key => $values) {
  317.                     $html[] = '<tr>';
  318.                     foreach ($headers as $h) {
  319.                         $html[] = sprintf(
  320.                             '<td>%s</td>',
  321.                             $values[$h] ?? '',
  322.                         );
  323.                     }
  324.                     $html[] = '</tr>';
  325.                 }
  326.                 $html[] = '</table>';
  327.                 return new Markup(
  328.                     implode(''$html),
  329.                     'UTF-8'
  330.                 );
  331.             },
  332.             [
  333.                 'is_safe' => ['html'],
  334.             ],
  335.         ));
  336.         $env->addFunction(new TwigFunction(
  337.             'list',
  338.             function ($data) {
  339.                 if ( ! is_array($data) || empty($data)) {
  340.                     return '';
  341.                 }
  342.                 $html = [
  343.                     '<ul>',
  344.                 ];
  345.                 foreach ($data as $value) {
  346.                     if ($value) {
  347.                         $html[] = sprintf(
  348.                             '<li>%s</li>',
  349.                             $value,
  350.                         );
  351.                     }
  352.                 }
  353.                 $html[] = '</ul>';
  354.                 return new Markup(
  355.                     implode(''$html),
  356.                     'UTF-8'
  357.                 );
  358.             },
  359.             [
  360.                 'is_safe' => ['html'],
  361.             ],
  362.         ));
  363.         $env->addFunction(new TwigFunction(
  364.             'notranslate',
  365.             function ($data) {
  366.                 if ( ! is_array($data) || empty($data)) {
  367.                     return '';
  368.                 }
  369.                 $html = [
  370.                     '<ul>',
  371.                 ];
  372.                 foreach ($data as $value) {
  373.                     if ($value) {
  374.                         $html[] = sprintf(
  375.                             '<li>%s</li>',
  376.                             $value,
  377.                         );
  378.                     }
  379.                 }
  380.                 $html[] = '</ul>';
  381.                 return new Markup(
  382.                     implode(''$html),
  383.                     'UTF-8'
  384.                 );
  385.             },
  386.             [
  387.                 'is_safe' => ['html'],
  388.             ],
  389.         ));
  390.         return $env;
  391.     }
  392. }
  393. final class TextSafeNode extends Node
  394. {
  395.     public function __construct(string $dataint $lineno)
  396.     {
  397.         parent::__construct(['body' => new ConstantExpression($data$lineno)], ['safe' => true], $lineno);
  398.     }
  399.     public function compile(Compiler $compiler)
  400.     {
  401.         $compiler
  402.             ->addDebugInfo($this)
  403.             ->write('echo ')
  404.             ->raw("('' === \$tmp = ")
  405.             ->subcompile($this->getNode('body'))
  406.             ->raw(") ? '' : new Markup(\$tmp, \$this->env->getCharset())")
  407.             ->raw(";\n")
  408.         ;
  409.     }
  410. }