From 9164725468d1ea53aec899f4ddb18ccacc23961f Mon Sep 17 00:00:00 2001
From: Alex Arnaud <alex.arnaud@biblibre.com>
Date: Thu, 21 Sep 2023 07:14:56 +0000
Subject: [PATCH] dev#160166 : CkEditor placeholders plugin

This plugin adds 2 buttons (records and articles)
that print a corresponding placeholder.

dev#160166 : different to set plugin buttons on newsletter editor only

dev#160166 : replace ckeditor placeholders with bokeh entities

dev#160166 : mailrenderer widget

dev#160166 : Widget always send results Should be empty when no data selected

dev#160166 : Placeholders in renderwithwigdetContext tests
---
 ckeditor/config.js                            |   1 +
 library/Class/Article/MailRenderer.php        |  27 +++
 library/Class/Newsletter/Dispatch.php         |   2 +-
 library/Class/Newsletter/TemplateHelper.php   |  71 +++---
 library/Class/Notice/MailRenderer.php         |  32 +++
 library/Class/Url.php                         |  41 ++++
 library/ZendAfi/Form/Admin/Newsletter.php     |  55 ++---
 .../ZendAfi/View/Helper/Accueil/Kiosque.php   |   2 +-
 library/ZendAfi/View/Helper/CkEditor.php      |  23 +-
 .../Library/Widget/Carousel/Record/View.php   |   2 +-
 .../bokeh_placeholders/icons/articles.png     | Bin 0 -> 200 bytes
 .../bokeh_placeholders/icons/records.png      | Bin 0 -> 209 bytes
 .../bokeh_placeholders/lang/en.js             |   4 +
 .../bokeh_placeholders/lang/fr.js             |   4 +
 .../bokeh_placeholders/plugin.js              |  32 +++
 .../controllers/NewsletterControllerTest.php  | 223 +++++++++++++++++-
 ...wsletter_placeholder_content_expected.html |   6 +
 ...ewsletter_placeholder_content_expected.txt |  18 ++
 ...r_placeholder_content_without_articles.txt |  11 +
 ...der_content_without_catalogue_expected.txt |  14 ++
 20 files changed, 492 insertions(+), 76 deletions(-)
 create mode 100644 public/opac/js/ckeditor_plugins/bokeh_placeholders/icons/articles.png
 create mode 100644 public/opac/js/ckeditor_plugins/bokeh_placeholders/icons/records.png
 create mode 100644 public/opac/js/ckeditor_plugins/bokeh_placeholders/lang/en.js
 create mode 100644 public/opac/js/ckeditor_plugins/bokeh_placeholders/lang/fr.js
 create mode 100644 public/opac/js/ckeditor_plugins/bokeh_placeholders/plugin.js
 create mode 100644 tests/application/modules/admin/controllers/newsletter_placeholder_content_expected.html
 create mode 100644 tests/application/modules/admin/controllers/newsletter_placeholder_content_expected.txt
 create mode 100644 tests/application/modules/admin/controllers/newsletter_placeholder_content_without_articles.txt
 create mode 100644 tests/application/modules/admin/controllers/newsletter_placeholder_content_without_catalogue_expected.txt

diff --git a/ckeditor/config.js b/ckeditor/config.js
index f720071fdf4..2cff70a3a21 100644
--- a/ckeditor/config.js
+++ b/ckeditor/config.js
@@ -3,6 +3,7 @@
  * For licensing, see LICENSE.md or http://ckeditor.com/license
  */
 CKEDITOR.plugins.addExternal('bokeh_kiosk' , CKEDITOR.basePath+'../public/opac/js/ckeditor_plugins/bokeh_kiosk/','plugin.js');
+CKEDITOR.plugins.addExternal('bokeh_placeholders' , CKEDITOR.basePath+'../public/opac/js/ckeditor_plugins/bokeh_placeholders/','plugin.js');
 
 CKEDITOR.stylesSet.add('default',[
   { name:'Normal', element:'p' },
diff --git a/library/Class/Article/MailRenderer.php b/library/Class/Article/MailRenderer.php
index 9646a5cf5cb..d31c1a14803 100644
--- a/library/Class/Article/MailRenderer.php
+++ b/library/Class/Article/MailRenderer.php
@@ -43,4 +43,31 @@ class Class_Article_MailRenderer {
       '<div style="clear:both"></div>'.
       '</div>';
   }
+
+
+  public function renderWithWidget(Class_Newsletter $newsletter) :string {
+    if (($newsletter->getArticlesIds() == 0) && ($newsletter->getArticlesCategoriesIds() ==0))
+      return '';
+
+    $view = (new ZendAfi_Controller_Action_Helper_View)->init();
+    $widget = (new Class_Systeme_Widget_Widget)
+      ->loadFromSettings(['type_module' => Class_Systeme_ModulesAccueil_News::CODE,
+                          'profile_id' => 1,
+                          'preferences' =>
+                          ['layout' => 'list',
+                           'rendering' => 'card-horizontal',
+                           'description_html' => $newsletter->getDisplayFullArticle(),
+                           'id_items' => $newsletter->getArticlesIds(),
+                           'id_categorie' => $newsletter->getArticlesCategoriesIds(),
+                           'display_order'  => Class_Systeme_ModulesAccueil_News::SELECTION
+                          ]
+                          ]);
+
+    $content = (new Intonation_Template)
+      ->addHelperPath($view)
+      ->renderWidget($widget, $view);
+
+    return $content;
+  }
+
 }
diff --git a/library/Class/Newsletter/Dispatch.php b/library/Class/Newsletter/Dispatch.php
index 5416b753f51..a3555256f71 100644
--- a/library/Class/Newsletter/Dispatch.php
+++ b/library/Class/Newsletter/Dispatch.php
@@ -32,7 +32,7 @@ class Newsletter_DispatchLoader extends Storm_Model_Loader {
     $model = Class_Newsletter_Dispatch::newInstance(['newsletter' => $newsletter,
                                                      'title' => $template->getSubject(),
                                                      'body_text' => $template->getBodyText(),
-                                                     'body_html' => $template->getBodyHTML(),
+                                                     'body_html' => $template->getBodyHTML(true),
                                                      'sender' => $template->getExpediteur()]);
 
 
diff --git a/library/Class/Newsletter/TemplateHelper.php b/library/Class/Newsletter/TemplateHelper.php
index 2bf3e6a2631..658d63ac4c0 100644
--- a/library/Class/Newsletter/TemplateHelper.php
+++ b/library/Class/Newsletter/TemplateHelper.php
@@ -85,7 +85,7 @@ class Class_Newsletter_TemplateHelper {
    * /!\ maybe in cli context : cannot rely on view helpers
    * @see scripts/sendNewsletter.php
    */
-  public function getBodyHTML() : string {
+  public function getBodyHTML(bool $is_cli = false) : string {
     if ( $this->_body_html_cache )
       return $this->_body_html_cache;
 
@@ -94,6 +94,8 @@ class Class_Newsletter_TemplateHelper {
                                          ? '_renderWithWidget'
                                          : 'renderHTML',
                                          '_getUnsubscribeHTML');
+    if ($is_cli)
+      $html = preg_replace('!href="(\/[^"]+)"!','href="'.Class_Url::rootUrl().BASE_URL.'$1"', $html);
 
     return $this->_body_html_cache = $this->_inlineCSS($html);
   }
@@ -155,60 +157,67 @@ class Class_Newsletter_TemplateHelper {
                                            string $unsubscribe_function,
                                            string $implode_callback = '') : string {
 
-    $content = [ call_user_func([$this, $mail_content_function]),
-                 ...$this->_renderContent($mail_renderer_function),
-                 call_user_func([$this, $unsubscribe_function])];
+    $raw = call_user_func([$this, $mail_content_function]);
+    $entities = $this->_renderContent($mail_renderer_function);
+    $unsubscribe = call_user_func([$this, $unsubscribe_function]);
 
+    foreach (['records', 'articles'] as $element_type)
+      if ($this->_hasPlaceholder($raw, $element_type)){
+        $raw = str_replace("[newsletter_$element_type]",
+                           $this->_implode($implode_callback, $entities[$element_type] ?? []),
+                           $raw);
+        unset($entities[$element_type]);
+      }
+
+
+    $content = [$raw,
+                $this->_implode($implode_callback,
+                                array_merge($entities['records'] ?? [],
+                                            $entities['articles'] ?? [])),
+                $unsubscribe];
+
+    return $this->_implode($implode_callback, array_filter($content));
+  }
+
+
+  protected function _implode(string $implode_callback, array $content) : string {
     return $implode_callback
       ? call_user_func([$this, $implode_callback], $content)
       : implode($content);
   }
 
 
+  protected function _hasPlaceholder(string $content, string $element_type) : bool {
+    return str_contains($content, "[newsletter_$element_type]");
+  }
+
+
   protected function _renderContent(string $mail_renderer_function) :array {
-    if ($mail_renderer_function == '_renderWithWidget')
-      return $this->_renderWithWidget();
+    $content = [];
+
+    if ($mail_renderer_function == '_renderWithWidget'){
+      $content['articles'][] = (new Class_Article_MailRenderer)->renderWithWidget($this->_newsletter);
+      $content['records'][] = (new Class_Notice_MailRenderer)->renderWithWidget($this->_newsletter);
+      return $content;
+    }
 
     $records = $this->_getRecords();
 
     $articles = $this->_getArticles();
 
-    $content = [];
 
     $renderer = new Class_Notice_MailRenderer;
     foreach($records as $record)
-      $content [] = call_user_func([$renderer, $mail_renderer_function], $record);
+      $content['records'][] = call_user_func([$renderer, $mail_renderer_function], $record);
 
     $renderer = new Class_Article_MailRenderer;
     foreach($articles as $article)
-      $content [] = call_user_func([$renderer, $mail_renderer_function], $article);
+      $content['articles'][] = call_user_func([$renderer, $mail_renderer_function], $article);
 
     return $content;
   }
 
 
-  protected function _renderWithWidget() :array {
-    $view = (new ZendAfi_Controller_Action_Helper_View)->init();
-    $widget = (new Class_Systeme_Widget_Widget)
-      ->loadFromSettings(['type_module' => Class_Systeme_ModulesAccueil_News::CODE,
-                          'profile_id' => 1,
-                          'preferences' =>
-                          ['layout' => 'list',
-                           'rendering' => 'card-horizontal',
-                           'description_html' => $this->_newsletter->getDisplayFullArticle(),
-                           'id_items' => $this->_newsletter->getArticlesIds(),
-                           'id_categorie' => $this->_newsletter->getArticlesCategoriesIds(),
-                           'display_order'  => Class_Systeme_ModulesAccueil_News::SELECTION
-                          ]
-                          ]);
-
-    $content [] = (new Intonation_Template)
-      ->addHelperPath($view)
-      ->renderWidget($widget, $view);
-
-    return $content;
-  }
-
 
   protected function _getContentAsHTML() : string {
     if ( $this->_newsletter_contenu_cache)
diff --git a/library/Class/Notice/MailRenderer.php b/library/Class/Notice/MailRenderer.php
index 98e465987b8..ba07054d956 100644
--- a/library/Class/Notice/MailRenderer.php
+++ b/library/Class/Notice/MailRenderer.php
@@ -50,6 +50,38 @@ class Class_Notice_MailRenderer {
   }
 
 
+  public function renderWithWidget(Class_Newsletter $newsletter) :string {
+    if (($newsletter->getIdCatalogue() == 0) && ($newsletter->getIdPanier() ==0))
+      return '';
+
+    $view = (new ZendAfi_Controller_Action_Helper_View)->init();
+    $widget = (new Class_Systeme_Widget_Widget)
+      ->loadFromSettings(['type_module' => Intonation_Library_Widget_Carousel_Record_Definition::CODE,
+                          'profile_id' => 1,
+                          'preferences' =>
+                          ['layout' => Intonation_Library_Widget_Carousel_Definition::LISTING,
+                           'rendering' => Intonation_Library_Widget_Carousel_Definition::HORIZONTAL_CARD,
+                           'style_list' => 'vignettes',
+                           'link_to_all' => 1,
+                           'link_to_all_text' => $this->_('Voir tout'),
+                           'size' => 10,
+                           'only_img' => 0,
+                           'aleatoire' => 0,
+                           'notice_domain' => $newsletter->getIdCatalogue(),
+                           'id_catalogue' => $newsletter->getIdCatalogue(),
+                           'id_panier' => $newsletter->getIdPanier(),
+                           'display_order'  => Class_Systeme_ModulesAccueil_News::SELECTION,
+                          ]
+                          ]);
+
+    $content = (new Intonation_Template)
+      ->addHelperPath($view)
+      ->renderWidget($widget, $view);
+
+    return $content ??'';
+  }
+
+
   protected function _title(Class_Notice $record) : string {
     return $record->getTitrePrincipal() . $this->_infos($record);
   }
diff --git a/library/Class/Url.php b/library/Class/Url.php
index 65669b37404..edb774ae397 100644
--- a/library/Class/Url.php
+++ b/library/Class/Url.php
@@ -170,6 +170,47 @@ class Class_Url {
   }
 
 
+  public static function sanitizeUrlOptions(array $url_options) : array {
+    $valid_url_keys = ['controller',
+                       'module',
+                       'action',
+                       'id_profil',
+                       'profile_id',
+                       'id',
+                       'titre',
+                       'boite',
+                       'rendering',
+                       'layout',
+                       'tri',
+                       'display_order',
+                       'layout',
+                       'style_liste',
+                       'nb_notices',
+                       'only_img',
+                       'aleatoire',
+                       'nb_analyse',
+                       'op_hauteur_img',
+                       'op_transition',
+                       'op_largeur_img',
+                       'op_hauteur_boite',
+                       'op_captions',
+                       'op_autoplay',
+                       'op_visible',
+                       'op_speed',
+                       'op_auto',
+                       'op_scroll',
+                       'rss_avis',
+                       'id_catalogue',
+                       'id_panier',
+                       'articles_ids',
+                       'articles_categories_ids',
+                       'profil_redirect' ];
+
+    return array_filter($url_options,
+                        fn($key) => in_array($key, $valid_url_keys), ARRAY_FILTER_USE_KEY);
+  }
+
+
   public static function absolute($url_array_or_string = [], $name = null, $reset = false, $encode = true) {
     return (new static())->absoluteUrl($url_array_or_string, $name, $reset, $encode);
   }
diff --git a/library/ZendAfi/Form/Admin/Newsletter.php b/library/ZendAfi/Form/Admin/Newsletter.php
index 49b54c2290f..c7b14114585 100644
--- a/library/ZendAfi/Form/Admin/Newsletter.php
+++ b/library/ZendAfi/Form/Admin/Newsletter.php
@@ -46,6 +46,32 @@ class ZendAfi_Form_Admin_Newsletter extends ZendAfi_Form {
          ->addElement('checkbox', 'draft',
                       ['label' => $this->_('Brouillon ?')])
 
+         ->addElement('radio',
+                      'display_full_article',
+                      ['label' => $this->_('Afficher articles en intégralité'),
+                       'value' => $datas['display_full_article'] ?? 0,
+                       'separator' => '',
+                       'multiOptions' => [ '1' => $this->_('Oui'),
+                                           '0' => $this->_('Non')
+                       ]
+                      ])
+         ->addElement('radio',
+                      'render_with_widget',
+                      ['label' => $this->_('Afficher comme sur le site'),
+                       'value' => $datas['render_with_widget'] ?? 0,
+                       'separator' => '',
+                       'multiOptions' => [ '1' => $this->_('Oui'),
+                                           '0' => $this->_('Non')
+                       ]
+
+                      ])
+
+         ->addElement('userfile',
+                      'custom_css',
+                      ['label' => $this->_('Css personnalisée'),
+                       'value' => $datas['custom_css'] ?? '',
+                      ])
+
          ->addElement('ckeditor', 'contenu',
                       ['required' => true,
                        'allowEmpty' => false])
@@ -69,7 +95,7 @@ class ZendAfi_Form_Admin_Newsletter extends ZendAfi_Form {
                        'validators' => [new Zend_Validate_Int()]])
 
 
-         ->addDisplayGroup(['titre', 'mail_subject', 'expediteur', 'draft'],
+         ->addDisplayGroup(['titre', 'mail_subject', 'expediteur', 'draft', 'render_with_widget','display_full_article', 'custom_css'],
                            'letter',
                            ['legend' => $this->_('Lettre')])
 
@@ -87,31 +113,6 @@ class ZendAfi_Form_Admin_Newsletter extends ZendAfi_Form {
     parent::populate($datas);
 
     $this
-      ->addElement('radio',
-                   'display_full_article',
-                   ['label' => $this->_('Afficher articles en intégralité'),
-                    'value' => $datas['display_full_article'] ?? 0,
-                    'separator' => '',
-                    'multiOptions' => [ '1' => $this->_('Oui'),
-                                        '0' => $this->_('Non')
-                    ]
-                   ])
-      ->addElement('radio',
-                   'render_with_widget',
-                   ['label' => $this->_('Afficher comme sur le site'),
-                    'value' => $datas['render_with_widget'] ?? 0,
-                    'separator' => '',
-                    'multiOptions' => [ '1' => $this->_('Oui'),
-                                        '0' => $this->_('Non')
-                    ]
-
-                   ])
-
-      ->addElement('userfile',
-                   'custom_css',
-                   ['label' => $this->_('Css personnalisée'),
-                    'value' => $datas['custom_css'] ?? '',
-                   ])
 
       ->addElement('treeSelect',
                    'articles_selector',
@@ -137,7 +138,7 @@ class ZendAfi_Form_Admin_Newsletter extends ZendAfi_Form {
 
                     ]);
 
-    $this->addDisplayGroup(['render_with_widget','display_full_article','custom_css','articles_selector'],
+    $this->addDisplayGroup(['articles_selector'],
                            'articles',
                            ['legend' => $this->_('Articles')]);
     return $this;
diff --git a/library/ZendAfi/View/Helper/Accueil/Kiosque.php b/library/ZendAfi/View/Helper/Accueil/Kiosque.php
index 7e8ac554ea5..91bfdbe6fef 100644
--- a/library/ZendAfi/View/Helper/Accueil/Kiosque.php
+++ b/library/ZendAfi/View/Helper/Accueil/Kiosque.php
@@ -269,7 +269,7 @@ class ZendAfi_View_Helper_Accueil_Kiosque extends ZendAfi_View_Helper_Accueil_Ba
       : $tris[0];
 
     unset($url_options['preferences']);
-    return $this->view->tagAnchor($this->view->url($url_options, null, true),
+    return $this->view->tagAnchor($this->view->url(Class_Url::sanitizeUrlOptions($url_options), null, true),
                                   htmlentities($titre));
   }
 
diff --git a/library/ZendAfi/View/Helper/CkEditor.php b/library/ZendAfi/View/Helper/CkEditor.php
index 89aaf68ccef..1fe05e14605 100644
--- a/library/ZendAfi/View/Helper/CkEditor.php
+++ b/library/ZendAfi/View/Helper/CkEditor.php
@@ -50,16 +50,17 @@ class ZendAfi_View_Helper_CkEditor extends ZendAfi_View_Helper_BaseHelper {
 
     $config['contentsCss'] = Class_Template::current()->getContentsCss();
 
-    $config['toolbar'] = [
-      ['Preview', 'Templates', 'Source','Maximize'],
-      ['Cut','Copy','Paste','PasteFromWord'],
-      ['Undo','Redo','-','SelectAll','RemoveFormat'],
-      ['Scayt'],
-      '/',
-      ['HorizontalRule'],
-      ['Link','Unlink','Anchor'],
-      ['Image','Table','Iframe','oembed','Slideshow', 'SpecialChar','Kiosk'],
-      ];
+    $config['toolbar'] = [['Preview', 'Templates', 'Source','Maximize'],
+                          ['Cut','Copy','Paste','PasteFromWord'],
+                          ['Undo','Redo','-','SelectAll','RemoveFormat'],
+                          ['Scayt'],
+                          '/',
+                          ['HorizontalRule'],
+                          ['Link','Unlink','Anchor'],
+                          ['Image','Table','Iframe','oembed','Slideshow', 'SpecialChar','Kiosk']];
+
+    if ('newsletter' === $this->view->body_attribs['data-controller'] ?? '')
+      $config['toolbar'][7] = array_merge($config['toolbar'][7], ['Records', 'Articles']);
 
     $config['extraAllowedContent'] = [
       'audio video' => [
@@ -105,7 +106,7 @@ class ZendAfi_View_Helper_CkEditor extends ZendAfi_View_Helper_BaseHelper {
                                                        'action' => 'get-form',
                                                        'code' => Class_Systeme_ModulesAccueil_Kiosque::CODE,
                                                        'render' => 'popup']);
-    $config['extraPlugins'] = 'colordialog,bokeh_kiosk';
+    $config['extraPlugins'] = 'colordialog,bokeh_kiosk,bokeh_placeholders';
 
     $config = (new Class_AdminVar_CkEditorConfig)->injectInto($config);
 
diff --git a/library/templates/Intonation/Library/Widget/Carousel/Record/View.php b/library/templates/Intonation/Library/Widget/Carousel/Record/View.php
index 8d4319e9362..f2f2b42f181 100644
--- a/library/templates/Intonation/Library/Widget/Carousel/Record/View.php
+++ b/library/templates/Intonation/Library/Widget/Carousel/Record/View.php
@@ -89,7 +89,7 @@ class Intonation_Library_Widget_Carousel_Record_View
     $params['tri'] = $params['order'];
 
     $criteres = (new Class_CriteresRecherche)->setParams($params);
-    $result = (new Class_MoteurRecherche)->lancerRecherche($criteres);
+    $result = Class_MoteurRecherche::getInstance()->lancerRecherche($criteres);
     return Class_Notice::findAllByIds($result->fetchAllRecordsIds(),
                                       $params['limit'],
                                       1);
diff --git a/public/opac/js/ckeditor_plugins/bokeh_placeholders/icons/articles.png b/public/opac/js/ckeditor_plugins/bokeh_placeholders/icons/articles.png
new file mode 100644
index 0000000000000000000000000000000000000000..92f6dfa867e05791701458edea42ba32f568d401
GIT binary patch
literal 200
zcmeAS@N?(olHy`uVBq!ia0vp^av;pX1|+Qw)-3{3oCO|{#S9GG!XV7ZFl&wkP_W9=
z#WAE}&f95@Tnz?1EZ<`%-P8T|pZQw7iwpPBU@s@vyR9-+9_(hbgBq;1GTdNTv6-u(
z=5A&XXT!Cq>H|_SX@WL4&o;S4>B{cyG;TZms(x8i-t)wje#fR5UwyT4mT}m-LgjZG
vx0UUAw)R@!gHKn!o3S6{e{qNT-@3BPqFZ(HIDY8@UBTe#>gTe~DWM4fsoY9<

literal 0
HcmV?d00001

diff --git a/public/opac/js/ckeditor_plugins/bokeh_placeholders/icons/records.png b/public/opac/js/ckeditor_plugins/bokeh_placeholders/icons/records.png
new file mode 100644
index 0000000000000000000000000000000000000000..e8b07e1dd271a49d93623c934803dd64400b12f7
GIT binary patch
literal 209
zcmeAS@N?(olHy`uVBq!ia0vp^av;pX1|+Qw)-3{3oCO|{#S9GG!XV7ZFl&wkP_Wt4
z#WAE}&f95@d=3TzuJ=#P^>}O9*lodb{SnJLheuLdMLDBnbN+d!-Q4qK`%Fmz`^k*9
z8<^}CiY@39Ebly2d)DyLmDzII65cD1OrPT#79)Rnujtm@mf^2@FRtI`$a`dU>LSI)
zvq}$mxA(v5w0NGp?0{j0U$Vq^3GMveY5N||&0$H*5&FC6-ji7sQ9$P~c)I$ztaD0e
F0st{=OyB?j

literal 0
HcmV?d00001

diff --git a/public/opac/js/ckeditor_plugins/bokeh_placeholders/lang/en.js b/public/opac/js/ckeditor_plugins/bokeh_placeholders/lang/en.js
new file mode 100644
index 00000000000..569fb36af5c
--- /dev/null
+++ b/public/opac/js/ckeditor_plugins/bokeh_placeholders/lang/en.js
@@ -0,0 +1,4 @@
+CKEDITOR.plugins.setLang( 'bokeh_placeholders', 'en', {
+    insertRecords: 'Insert the records linked to the newsletter',
+    insertArticles: 'Insert the records linked to the newsletter',
+});
diff --git a/public/opac/js/ckeditor_plugins/bokeh_placeholders/lang/fr.js b/public/opac/js/ckeditor_plugins/bokeh_placeholders/lang/fr.js
new file mode 100644
index 00000000000..256e1dde2e9
--- /dev/null
+++ b/public/opac/js/ckeditor_plugins/bokeh_placeholders/lang/fr.js
@@ -0,0 +1,4 @@
+CKEDITOR.plugins.setLang( 'bokeh_placeholders', 'fr', {
+    insertRecords: 'Insérer les notices liées à la lettre d\'information',
+    insertArticles: 'Insérer les articles liés à la lettre d\'information',
+});
diff --git a/public/opac/js/ckeditor_plugins/bokeh_placeholders/plugin.js b/public/opac/js/ckeditor_plugins/bokeh_placeholders/plugin.js
new file mode 100644
index 00000000000..f8879b66b79
--- /dev/null
+++ b/public/opac/js/ckeditor_plugins/bokeh_placeholders/plugin.js
@@ -0,0 +1,32 @@
+CKEDITOR.plugins.add('bokeh_placeholders', {
+    lang : [ 'en', 'fr' ],
+    init: function(editor) {
+	var lang = editor.lang['bokeh_placeholders'];
+	
+	editor.addCommand('insertRecords', {
+	    exec: function(editor) {
+		editor.insertHtml('[newsletter_records]');
+	    }
+	});
+
+	editor.addCommand('insertArticles', {
+	    exec: function(editor) {
+		editor.insertHtml('[newsletter_articles]');
+	    }
+	});
+
+	editor.ui.addButton('Records', {
+	    label: lang.insertRecords,
+	    icon: this.path + 'icons/records.png',
+	    command: 'insertRecords',
+	    toolbar: 'insert'
+	});
+	
+	editor.ui.addButton('Articles', {
+	    label: lang.insertArticles,
+	    icon: this.path + 'icons/articles.png',
+	    command: 'insertArticles',
+	    toolbar: 'insert'
+	});
+    }
+});
diff --git a/tests/application/modules/admin/controllers/NewsletterControllerTest.php b/tests/application/modules/admin/controllers/NewsletterControllerTest.php
index 1fea20afbc1..affc3355a62 100644
--- a/tests/application/modules/admin/controllers/NewsletterControllerTest.php
+++ b/tests/application/modules/admin/controllers/NewsletterControllerTest.php
@@ -844,7 +844,7 @@ class Admin_NewsletterControllerSendActionTest
        ['title', 'Animations'],
        ['body_text', 'Pour les jeunes
 Lien pour se désinscrire de cette lettre d\'information : http://localhost'. BASE_URL . '/newsletter/unsubscribe/newsletter/2/user/%user.id%/hash/%hash%'],
-       ['body_html', 'Pour les jeunes<br/><a href="http://localhost'. BASE_URL . '/newsletter/unsubscribe/newsletter/2/user/%user.id%/hash/%hash%">Je ne veux plus recevoir cette lettre d\'information</a>'],
+       ['body_html', 'Pour les jeunes<br/><a href="http://localhost'. BASE_URL . '/newsletter/unsubscribe/newsletter/2/user/%user.id%/hash/%hash%">'],
        ['sender', 'bokehrulez@linuxfr.org'],
        ['created_on', '2016-07-21 11:21:38']
       ];
@@ -856,7 +856,7 @@ Lien pour se désinscrire de cette lettre d\'information : http://localhost'. BA
    * @dataProvider dispatchDatas
    */
   public function dispatchDataShouldBeCorrect($name, $expected) {
-    $this->assertEquals($expected, $this->_dispatch->$name);
+    $this->assertContains($expected, $this->_dispatch->$name);
   }
 
 
@@ -950,6 +950,9 @@ abstract class Admin_NewsletterControllerPreviewActionTestCase
 
     $this->fixture(Class_Notice::class,
                    ['id' => 42,
+                    'type_doc' => Class_TypeDoc::LOGICIEL,
+                    'clef_alpha' => 'MARTINEALAPLAGE',
+                    'facettes' => 'T5 Q1',
                     'titre_principal' => 'Martine à la plage']);
 
     $this->fixture(Class_Catalogue::class,
@@ -1012,6 +1015,217 @@ abstract class Admin_NewsletterControllerPreviewActionTestCase
 
 
 
+abstract class Admin_NewsletterControllerPreviewActionPlaceHoldersTestCase extends Admin_NewsletterControllerPreviewActionTestCase{
+  protected Class_Newsletter $_newsletter;
+  protected string $_file_path = '';
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->_buildTemplateProfil(['id' => 1]);
+
+    // This is required to make Records Widget Contains Martine
+    $search_result =
+      $this->mock()
+           ->whenCalled('fetchAllRecordsIds')
+           ->answers([42]);
+
+    Class_MoteurRecherche::setInstance($this->mock()
+                                       ->whenCalled('lancerRecherche')
+                                       ->answers($search_result));
+
+    $this->_newsletter = Class_Newsletter::find(3);
+    $this->_newsletter
+      ->setContenu("Top\n[newsletter_records]\nMiddle\n[newsletter_articles]\nfooter");
+    $this->_customizeNewsletter($this->_newsletter);
+    $this->_newsletter
+      ->save();
+    $this->dispatch('/admin/newsletter/preview/id/3');
+  }
+
+
+  public function tearDown(){
+    Class_MoteurRecherche::setInstance(null);
+
+    parent::tearDown();
+  }
+
+
+  protected function _customizeNewsletter(Class_Newsletter $newsletter){
+  }
+
+
+  /** @test */
+  public function placeholderNewsletterRecordsShouldHaveBeenRemoved() {
+    $this->assertNotXPathContentContains('//body', '[newsletter_records]');
+  }
+
+
+  /** @test */
+  public function placeholderNewsletterArticlesShouldHaveBeenRemoved() {
+    $this->assertNotXPathContentContains('//body', '[newsletter_articles]');
+  }
+
+
+  /** @test */
+  public function contentShouldBeAsExpected() {
+    $this->assertContains( str_replace('ROOT_URL', BASE_URL, file_get_contents($this->_file_path)),
+                           $this->_response->getBody());
+  }
+}
+
+
+
+
+class Admin_NewsletterControllerPreviewActionWithArticlesSelectionAndPlaceHoldersAsTextTest
+  extends Admin_NewsletterControllerPreviewActionPlaceHoldersTestCase {
+
+  protected string $_file_path =__DIR__.'/newsletter_placeholder_content_expected.txt';
+}
+
+
+
+
+class Admin_NewsletterControllerPreviewActionWithArticlesSelectionAndPlaceHoldersAsHtmlTest
+  extends Admin_NewsletterControllerPreviewActionPlaceHoldersTestCase {
+
+  protected string $_file_path =__DIR__.'/newsletter_placeholder_content_expected.html';
+}
+
+
+
+
+class Admin_NewsletterControllerPreviewActionWithPlaceHoldersWithoutCatalogueTest
+  extends Admin_NewsletterControllerPreviewActionPlaceHoldersTestCase {
+
+  protected string $_file_path = __DIR__.'/newsletter_placeholder_content_without_catalogue_expected.txt';
+
+  protected function _customizeNewsletter(Class_Newsletter $newsletter) {
+    $newsletter
+      ->setIdCatalogue(0);
+  }
+}
+
+
+
+
+
+class Admin_NewsletterControllerPreviewActionWithPlaceholdersWithoutArticlesTest
+  extends Admin_NewsletterControllerPreviewActionPlaceHoldersTestCase {
+
+  protected string $_file_path = __DIR__.'/newsletter_placeholder_content_without_articles.txt';
+
+  protected function _customizeNewsletter(Class_Newsletter $newsletter) {
+    $newsletter
+      ->setArticlesIds(0)
+      ->setArticlesCategoriesIds(0);
+  }
+}
+
+
+
+
+class Admin_NewsletterControllerPreviewActionWithPlaceHoldersAndRenderWidgetTest
+  extends Admin_NewsletterControllerPreviewActionPlaceHoldersTestCase {
+
+  protected function _customizeNewsletter(Class_Newsletter $newsletter) {
+    $newsletter
+      ->setRenderWithWidget(1);
+  }
+
+
+  /** @test */
+  public function pageShouldContainsBoiteNoticeWithTitleMartineALaPlage() {
+    $this->assertXPathContentContains('//div[@class="widget-body"]//div[@class="list-group"]//div[@class="list-group-item"]//div[@class="card"]//div[contains(@class,"card-title")]//span[@class="200a"]', 'Martine à la plage');
+  }
+
+
+  /** @test */
+  public function pageShouldContainsBoiteArticleWithTitleExpected() {
+    $this->assertXPathContentContains('//div[@class="widget-body"]//div[@class="list-group-item"]//div[@class="card"]//div[@role="heading"]//a', 'Gilets Jaunes');
+  }
+
+
+
+  /** @test */
+  public function getBodyHtmlwithCliTrueShouldNotContainsRelativeHrefs() {
+    $this->assertNotContains((new Class_Newsletter_TemplateHelper($this->_newsletter))->getBodyHTML(true),'href="/');
+  }
+
+
+  /**
+   * @group ignore
+   */
+  public function contentShouldBeAsExpected() {}
+}
+
+
+
+
+class Admin_NewsletterControllerPreviewActionWithPlaceHoldersAndRenderWidgetWithoutArticleSelectionTest
+  extends Admin_NewsletterControllerPreviewActionPlaceHoldersTestCase {
+
+  protected function _customizeNewsletter(Class_Newsletter $newsletter) {
+    $newsletter
+      ->setRenderWithWidget(1)
+      ->setArticlesIds(0)
+      ->setArticlesCategoriesIds(0);
+  }
+
+
+  /** @test */
+  public function pageShouldContainsBoiteNoticeWithTitleMartineALaPlage() {
+    $this->assertXPathContentContains('//div[@class="widget-body"]//div[@class="list-group"]//div[@class="list-group-item"]//div[@class="card"]//div[contains(@class,"card-title")]//span[@class="200a"]', 'Martine à la plage');
+  }
+
+
+  /** @test */
+  public function pageShouldNotRenderBoiteArticle() {
+    $this->assertContains("Middle\n\nfooter<br/>", $this->_response->getBody());
+  }
+
+
+  /**
+   * @group ignore
+   */
+  public function contentShouldBeAsExpected() {}
+}
+
+
+
+
+class Admin_NewsletterControllerPreviewActionWithPlaceHoldersAndRenderWidgetWithoutNoticeSelectionTest
+  extends Admin_NewsletterControllerPreviewActionPlaceHoldersTestCase {
+
+  protected function _customizeNewsletter(Class_Newsletter $newsletter) {
+    $newsletter
+      ->setRenderWithWidget(1)
+      ->setIdCatalogue(0)
+      ->setIdPanier(0);
+  }
+
+
+  /** @test */
+  public function pageShouldContainsNotRenderBoiteNotice() {
+    $this->assertContains("Top\n\nMiddle\n", $this->_response->getBody());
+  }
+
+
+  /** @test */
+  public function pageShouldContainsBoiteArticleWithTitleExpected() {
+    $this->assertXPathContentContains('//div[@class="widget-body"]//div[@class="list-group-item"]//div[@class="card"]//div[@role="heading"]//a', 'Gilets Jaunes');
+  }
+
+
+  /**
+   * @group ignore
+   */
+  public function contentShouldBeAsExpected() {}
+}
+
+
+
+
 class Admin_NewsletterControllerPreviewActionWithArticlesSelectionTest
   extends Admin_NewsletterControllerPreviewActionTestCase {
 
@@ -1071,7 +1285,7 @@ class Admin_NewsletterControllerPreviewActionWithArticlesSelectionTest
 
   /**  @test */
   public function noticeMartineALaPlageUrlShouldBeRechercheViewNotice42() {
-    $this->assertXPath('//a[contains(@href,"/recherche/viewnotice/clef//id/42")]');
+    $this->assertXPath('//a[contains(@href,"/recherche/viewnotice/clef/MARTINEALAPLAGE/id/42")]');
   }
 
 
@@ -2236,7 +2450,8 @@ class Admin_NewsletterControllerPreviewActionWithTemplateLayoutAndRenderingSelec
   /** @test */
   public function cardTitleShouldContainsLeRoiDesCons() {
     $this->assertXPathContentContains('//div[@class="list-group"]//div[contains(@class,"card-title")][contains(@style,\'color: "red"; font-weight: "bolder"\')]'
-                                      , 'Le Roi des cons');
+                                      , 'Le Roi des cons',
+    $this->_response->getBody());
   }
 
 
diff --git a/tests/application/modules/admin/controllers/newsletter_placeholder_content_expected.html b/tests/application/modules/admin/controllers/newsletter_placeholder_content_expected.html
new file mode 100644
index 00000000000..32c2cd7b53d
--- /dev/null
+++ b/tests/application/modules/admin/controllers/newsletter_placeholder_content_expected.html
@@ -0,0 +1,6 @@
+<div>
+  Top
+<div style="padding:5px"><a href="http://localhostROOT_URL/recherche/viewnotice/clef/MARTINEALAPLAGE/id/42">Martine à la plage</a><div></div><div style="clear:both"></div></div>
+Middle
+<div style="padding:5px"><h1>Le Roi des cons</h1><div>Il est beau, il est fier sur son throne.</div><div style="clear:both"></div></div><div style="padding:5px"><h1>70 millions …</h1><div>Ils sont 70 millions prêts à prendre sa place.</div><div style="clear:both"></div></div><div style="padding:5px"><h1>Gilets Jaunes</h1><div><a href="http://localhostROOT_URL/viewarticle/gilets-jaunes"> La révolution a commencé.<img src='http://localhostROOT_URL/userfiles/jaunes.gif' /> . <img SRC="http://localhostROOT_URL/userfiles/gilets.gif" /></a><a href="http://lienexterne/maquereau"><img src='http://imageexterne/maquereau.gif' /></a></div><div style="clear:both"></div></div><div style="padding:5px"><h1>L'arnaque</h1><div>La révolution a oublié de décapiter la misère et l'exploitation.</div><div style="clear:both"></div></div>
+footer<br/><a href="http://localhostROOT_URL/newsletter/unsubscribe/newsletter/3/user//hash/6dd28e9b">
\ No newline at end of file
diff --git a/tests/application/modules/admin/controllers/newsletter_placeholder_content_expected.txt b/tests/application/modules/admin/controllers/newsletter_placeholder_content_expected.txt
new file mode 100644
index 00000000000..4ad81810d03
--- /dev/null
+++ b/tests/application/modules/admin/controllers/newsletter_placeholder_content_expected.txt
@@ -0,0 +1,18 @@
+<p>
+  Top<br />
+- Martine à la plage<br />
+<br />
+Lien: http://localhostROOT_URL/recherche/viewnotice/clef/MARTINEALAPLAGE/id/42<br />
+<br />
+<br />
+Middle<br />
+- Le Roi des consIl est beau, il est fier sur son throne.Lien: http://localhostROOT_URL/cms/viewarticle/id/36<br />
+<br />
+- 70 millions …Ils sont 70 millions prêts à prendre sa place.Lien: http://localhostROOT_URL/cms/viewarticle/id/35<br />
+<br />
+- Gilets Jaunes La révolution a commencé. . Lien: http://localhostROOT_URL/cms/viewarticle/id/13<br />
+<br />
+- L'arnaqueLa révolution a oublié de décapiter la misère et l'exploitation.Lien: http://localhostROOT_URL/cms/viewarticle/id/12<br />
+<br />
+footer<br />
+Lien pour se désinscrire de cette lettre d'information : http://localhostROOT_URL/newsletter/unsubscribe/newsletter/3/user//hash/6dd28e9b</p>
diff --git a/tests/application/modules/admin/controllers/newsletter_placeholder_content_without_articles.txt b/tests/application/modules/admin/controllers/newsletter_placeholder_content_without_articles.txt
new file mode 100644
index 00000000000..b569f3acd68
--- /dev/null
+++ b/tests/application/modules/admin/controllers/newsletter_placeholder_content_without_articles.txt
@@ -0,0 +1,11 @@
+<p>
+  Top<br />
+- Martine à la plage<br />
+<br />
+Lien: http://localhostROOT_URL/recherche/viewnotice/clef/MARTINEALAPLAGE/id/42<br />
+<br />
+<br />
+Middle<br />
+<br />
+footer<br />
+Lien pour se désinscrire de cette lettre d'information : http://localhostROOT_URL/newsletter/unsubscribe/newsletter/3/user//hash/6dd28e9b</p>
diff --git a/tests/application/modules/admin/controllers/newsletter_placeholder_content_without_catalogue_expected.txt b/tests/application/modules/admin/controllers/newsletter_placeholder_content_without_catalogue_expected.txt
new file mode 100644
index 00000000000..5fa3a6f29f5
--- /dev/null
+++ b/tests/application/modules/admin/controllers/newsletter_placeholder_content_without_catalogue_expected.txt
@@ -0,0 +1,14 @@
+<p>
+  Top<br />
+<br />
+Middle<br />
+- Le Roi des consIl est beau, il est fier sur son throne.Lien: http://localhostROOT_URL/cms/viewarticle/id/36<br />
+<br />
+- 70 millions …Ils sont 70 millions prêts à prendre sa place.Lien: http://localhostROOT_URL/cms/viewarticle/id/35<br />
+<br />
+- Gilets Jaunes La révolution a commencé. . Lien: http://localhostROOT_URL/cms/viewarticle/id/13<br />
+<br />
+- L'arnaqueLa révolution a oublié de décapiter la misère et l'exploitation.Lien: http://localhostROOT_URL/cms/viewarticle/id/12<br />
+<br />
+footer<br />
+Lien pour se désinscrire de cette lettre d'information : http://localhostROOT_URL/newsletter/unsubscribe/newsletter/3/user//hash/6dd28e9b</p>
-- 
GitLab