Issues (68)

src/Tasks/EmailImportTask.php (6 issues)

1
<?php
2
3
namespace LeKoala\EmailTemplates\Tasks;
4
5
use Exception;
6
use DOMElement;
7
use DOMDocument;
8
use SilverStripe\ORM\DB;
9
use SilverStripe\i18n\i18n;
10
use SilverStripe\Dev\BuildTask;
11
use SilverStripe\Control\Director;
12
use TractorCow\Fluent\Model\Locale;
0 ignored issues
show
The type TractorCow\Fluent\Model\Locale was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
13
use SilverStripe\Core\Manifest\ModuleLoader;
14
use LeKoala\EmailTemplates\Helpers\FluentHelper;
15
use LeKoala\EmailTemplates\Models\EmailTemplate;
0 ignored issues
show
The type LeKoala\EmailTemplates\Models\EmailTemplate was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
16
use LeKoala\EmailTemplates\Helpers\SubsiteHelper;
17
use SilverStripe\Core\Config\Config;
18
use SilverStripe\i18n\TextCollection\i18nTextCollector;
19
20
/**
21
 * Import email templates provided from ss files
22
 *
23
 * \vendor\silverstripe\framework\templates\SilverStripe\Control\Email
24
 * \vendor\silverstripe\framework\templates\SilverStripe\Control\Email\ForgotPasswordEmail.ss
25
 *
26
 * \app\templates\Email\MySampleEmail.ss
27
 *
28
 * Finds all *Email.ss templates and imports them into the CMS
29
 * @author lekoala
30
 */
31
class EmailImportTask extends BuildTask
32
{
33
    private static $segment = 'EmailImportTask';
34
35
    protected $title = "Email import task";
36
    protected $description = "Finds all *Email.ss templates and imports them into the CMS, if they don't already exist.";
37
38
    public function run($request)
39
    {
40
        $subsiteSupport = SubsiteHelper::usesSubsite();
41
        $fluentSupport = FluentHelper::usesFluent();
42
43
        echo 'Run with ?clear=1 to clear empty database before running the task<br/>';
44
        echo 'Run with ?overwrite=soft|hard to overwrite templates that exists in the cms. Soft will replace template if not modified by the user, hard will replace template even if modified by user.<br/>';
45
        echo 'Run with ?templates=xxx,yyy to specify which template should be imported<br/>';
46
        if ($subsiteSupport) {
47
            echo 'Run with ?subsite=all|subsiteID to create email templates in all subsites (including main site) or only in the chosen subsite (if a subsite is active, it will be used by default).<br/>';
48
        }
49
        if ($fluentSupport) {
50
            echo 'Run with ?locales=fr,en to choose which locale to import.<br/>';
51
        }
52
        echo '<strong>Remember to flush the templates/translations if needed</strong><br/>';
53
        echo '<hr/>';
54
55
        $overwrite = $request->getVar('overwrite');
56
        $clear = $request->getVar('clear');
57
        $templatesToImport = $request->getVar('templates');
58
        $importToSubsite = $request->getVar('subsite');
59
        $chosenLocales = $request->getVar('locales');
60
61
        // Normalize argument
62
        if ($overwrite && $overwrite != 'soft' && $overwrite != 'hard') {
63
            $overwrite = 'soft';
64
        }
65
66
        // Select which subsite to import emails to
67
        $subsites = [];
68
        if ($subsiteSupport) {
69
            if ($importToSubsite == 'all') {
70
                $subsites = SubsiteHelper::listSubsites();
71
            } elseif (is_numeric($importToSubsite)) {
72
                $subsites = SubsiteHelper::listSubsites();
73
                $subsiteTitle = 'Subsite #' . $importToSubsite;
74
                foreach ($subsites as $subsite) {
75
                    if ($subsite->ID == $importToSubsite) {
76
                        $subsiteTitle = $subsite->Title;
77
                    }
78
                }
79
                $subsites = [
80
                    $importToSubsite => $subsiteTitle
81
                ];
82
            }
83
            if (SubsiteHelper::currentSubsiteID()) {
84
                DB::alteration_message("Importing to current subsite. Run from main site to import other subsites at once.", "created");
85
                $subsites = [];
86
            }
87
            if (!empty($subsites)) {
88
                DB::alteration_message("Importing to subsites : " . implode(',', array_values($subsites)), "created");
89
            }
90
        }
91
92
        if ($templatesToImport) {
93
            $templatesToImport = explode(',', $templatesToImport);
94
        }
95
96
        // Do we clear our templates?
97
        if ($clear == 1) {
98
            DB::alteration_message("Clear all email templates", "created");
99
            $emailTemplates = EmailTemplate::get();
100
            foreach ($emailTemplates as $emailTemplate) {
101
                $emailTemplate->delete();
102
            }
103
        }
104
105
        $emailTemplateSingl = singleton(EmailTemplate::class);
0 ignored issues
show
The assignment to $emailTemplateSingl is dead and can be removed.
Loading history...
106
107
        $locales = null;
108
        if ($fluentSupport && class_exists('TractorCow\Fluent\Model\Locale')) {
109
            if (FluentHelper::isClassTranslated(EmailTemplate::class)) {
110
                $locales = Locale::get()->column('Locale');
111
112
                // We collect only one locale, restrict the list
113
                if ($chosenLocales) {
114
                    $arr = explode(',', $chosenLocales);
115
                    $locales = [];
116
                    foreach ($arr as $a) {
117
                        $a = FluentHelper::get_locale_from_lang($a);
118
                        $locales[] = $a;
119
                    }
120
                }
121
            }
122
        }
123
124
        $defaultLocale = FluentHelper::get_locale();
125
126
        $templates = $this->collectTemplates();
127
128
        // don't throw errors
129
        Config::modify()->set(i18n::class, 'missing_default_warning', false);
130
131
        foreach ($templates as $filePath) {
132
            $isOverwritten = false;
133
134
            $fileName = basename($filePath, '.ss');
135
136
            // Remove base path
137
            $relativeFilePath = str_replace(Director::baseFolder(), '', $filePath);
138
            $relativeFilePathParts = explode('/', trim($relativeFilePath, '/'));
139
140
            // Group by module
141
            $moduleName = array_shift($relativeFilePathParts);
142
            if ($moduleName == 'vendor') {
143
                $moduleVendor = array_shift($relativeFilePathParts);
144
                // get module name
145
                $moduleName = $moduleVendor . '/' . array_shift($relativeFilePathParts);
146
            }
147
148
            // remove /templates part
149
            array_shift($relativeFilePathParts);
150
            $templateName = str_replace('.ss', '', implode('/', $relativeFilePathParts));
151
152
            $templateTitle = basename($templateName);
0 ignored issues
show
The assignment to $templateTitle is dead and can be removed.
Loading history...
153
154
            // Create the email code (basically, the template name without "Email" at the end)
155
            $code = preg_replace('/Email$/', '', $fileName);
156
157
            if (!empty($templatesToImport) && !in_array($code, $templatesToImport)) {
158
                DB::alteration_message("Template with code <b>$code</b> was ignored.", "repaired");
159
                continue;
160
            }
161
162
            $whereCode = [
163
                'Code' => $code
164
            ];
165
            $emailTemplate = EmailTemplate::get()->filter($whereCode)->first();
166
167
            // Check if it has been modified or not
168
            $templateModified = false;
169
            if ($emailTemplate) {
170
                $templateModified = $emailTemplate->Created != $emailTemplate->LastEdited;
171
            }
172
173
            if (!$overwrite && $emailTemplate) {
174
                DB::alteration_message("Template with code <b>$code</b> already exists. Choose overwrite if you want to import again.", "repaired");
175
                continue;
176
            }
177
            if ($overwrite == 'soft' && $templateModified) {
178
                DB::alteration_message("Template with code <b>$code</b> has been modified by the user. Choose overwrite=hard to change.", "repaired");
179
                continue;
180
            }
181
182
            // Create a default title from code
183
            $title = preg_split('/(?=[A-Z])/', $code);
184
            $title = implode(' ', $title);
0 ignored issues
show
The assignment to $title is dead and can be removed.
Loading history...
185
186
            // Get content of the email
187
            $content = file_get_contents($filePath);
188
189
            // Analyze content to find incompatibilities
190
            $errors = self::checkContentForErrors($content);
191
            if (!empty($errors)) {
192
                echo "<div style='color:red'>Invalid syntax was found in '$relativeFilePath'. Please fix these errors before importing the template<ul>";
193
                foreach ($errors as $error) {
194
                    echo '<li>' . $error . '</li>';
195
                }
196
                echo '</ul></div>';
197
                continue;
198
            }
199
200
            // Parse language
201
            $module = ModuleLoader::getModule($moduleName);
202
            $collector = new i18nTextCollector;
203
            $entities = $collector->collectFromTemplate($content, $fileName, $module);
204
205
            /*
206
            array:1 [▼
207
            "MyEmail.SUBJECT" => "My subject"
208
            ]
209
            */
210
211
            $translationTable = [];
212
            foreach ($entities as $entity => $data) {
213
                if ($locales) {
214
                    foreach ($locales as $locale) {
215
                        i18n::set_locale($locale);
216
                        if (!isset($translationTable[$entity])) {
217
                            $translationTable[$entity] = [];
218
                        }
219
                        $translationTable[$entity][$locale] = i18n::_t($entity, $data);
220
                    }
221
                    i18n::set_locale($defaultLocale);
222
                } else {
223
                    $translationTable[$entity] = [$defaultLocale => i18n::_t($entity, $data)];
224
                }
225
            }
226
227
            $contentLocale = [];
228
            // May be null
229
            if ($locales) {
230
                foreach ($locales as $locale) {
231
                    $contentLocale[$locale] = $content;
232
                }
233
            }
234
            if (!isset($contentLocale[$defaultLocale])) {
235
                $contentLocale[$defaultLocale] = $content;
236
            }
237
238
            // Now we use our translation table to manually replace _t calls into file content
239
            foreach ($translationTable as $entity => $translationData) {
240
                // use the double escape notation
241
                $escapedEntity = str_replace('\\', '\\\\\\\\', $entity);
242
                // fix dot notation
243
                $escapedEntity = str_replace('.', '\.', $escapedEntity);
244
                $baseTranslation = null;
245
246
                foreach ($translationData as $locale => $translation) {
247
                    if (!$baseTranslation && $translation) {
248
                        $baseTranslation = $translation;
249
                    }
250
                    if (!$translation) {
251
                        $translation = $baseTranslation;
252
                    }
253
                    // This regex should match old and new style
254
                    $count = 0;
255
                    $contentLocale[$locale] = preg_replace("/<%(t | _t\(')" . $escapedEntity . "( |').*?%>/ums", $translation, $contentLocale[$locale], -1, $count);
256
                    if (!$count) {
257
                        throw new Exception("Failed to replace $escapedEntity with translation $translation");
258
                    }
259
                }
260
            }
261
262
            // Create a template if necassery or mark as overwritten
263
            if (!$emailTemplate) {
264
                $emailTemplate = new EmailTemplate;
265
            } else {
266
                $isOverwritten = true;
267
            }
268
269
            // Other properties
270
            $emailTemplate->Code = $code;
271
            $emailTemplate->Category = $moduleName;
272
            if (SubsiteHelper::currentSubsiteID() && !$emailTemplate->SubsiteID) {
273
                $emailTemplate->SubsiteID = SubsiteHelper::currentSubsiteID();
274
            }
275
            // Write to main site or current subsite
276
            $emailTemplate->write();
277
278
            // Apply content to email after write to ensure we can localize properly
279
            $this->assignContent($emailTemplate, $contentLocale[$defaultLocale]);
280
281
            if (!empty($locales)) {
282
                foreach ($locales as $locale) {
283
                    $this->assignContent($emailTemplate, $contentLocale[$locale], $locale);
284
                }
285
            }
286
287
            // Reset date to allow tracking user edition (for soft/hard overwrite)
288
            $this->resetLastEditedDate($emailTemplate->ID);
289
290
            // Loop through subsites
291
            if (!empty($importToSubsite)) {
292
                SubsiteHelper::disableFilter();
293
                foreach ($subsites as $subsiteID => $subsiteTitle) {
294
                    $whereCode['SubsiteID'] = $subsiteID;
295
296
                    $subsiteEmailTemplate = EmailTemplate::get()->filter($whereCode)->first();
297
298
                    $emailTemplateCopy = $emailTemplate;
299
                    $emailTemplateCopy->SubsiteID = $subsiteID;
300
                    if ($subsiteEmailTemplate) {
301
                        $emailTemplateCopy->ID = $subsiteEmailTemplate->ID;
302
                    } else {
303
                        $emailTemplateCopy->ID = 0; // New
304
                    }
305
                    $emailTemplateCopy->write();
306
307
                    $this->resetLastEditedDate($emailTemplateCopy->ID);
308
                }
309
            }
310
311
            if ($isOverwritten) {
312
                DB::alteration_message("Overwrote <b>{$emailTemplate->Code}</b>", "created");
313
            } else {
314
                DB::alteration_message("Imported <b>{$emailTemplate->Code}</b>", "created");
315
            }
316
        }
317
    }
318
319
    public static function checkContentForErrors($content)
320
    {
321
        $errors = [];
322
        if (strpos($content, '<% with') !== false) {
323
            $errors[] = 'Replace "with" blocks by plain calls to the variable';
324
        }
325
        if (strpos($content, '<% if') !== false) {
326
            $errors[] = 'If/else logic is not supported. Please create one template by use case or abstract logic into the model';
327
        }
328
        if (strpos($content, '<% loop') !== false) {
329
            $errors[] = 'Loops are not supported. Please create a helper method on the model to render the loop';
330
        }
331
        if (strpos($content, '<% sprintf') !== false) {
332
            $errors[] = 'You should not use sprintf to escape content, please use plain _t calls';
333
        }
334
        return $errors;
335
    }
336
337
    /**
338
     * Collect email from your project
339
     *
340
     * @return array
341
     */
342
    protected function collectTemplates()
343
    {
344
        $templates = glob(Director::baseFolder() . '/' . project() . '/templates/Email/*Email.ss');
345
346
        $framework = self::config()->get('import_framework');
347
        if ($framework) {
348
            // use ? to avoid matching plain Email.ss
349
            $templates = array_merge($templates, glob(Director::baseFolder() . '/vendor/silverstripe/framework/templates/SilverStripe/Control/Email/?*Email.ss'));
350
        }
351
        $extra = self::config()->get('extra_paths');
352
        foreach ($extra as $path) {
353
            $path = trim($path, '/');
354
            $templates = array_merge($templates, glob(Director::baseFolder() . '/' . $path . '/*Email.ss'));
355
        }
356
357
        return $templates;
358
    }
359
360
    /**
361
     * Utility function to reset email templates last edited date
362
     *
363
     * @param int $ID
364
     * @return void
365
     */
366
    protected function resetLastEditedDate($ID)
367
    {
368
        DB::query("UPDATE `EmailTemplate` SET LastEdited = Created WHERE ID = " . $ID);
369
    }
370
371
    /**
372
     * Update a template with content
373
     *
374
     * @param EmailTemplate $emailTemplate
375
     * @param string $content The full page content with html
376
     * @param string $locale
377
     * @return void
378
     */
379
    protected function assignContent(EmailTemplate $emailTemplate, $content, $locale = '')
380
    {
381
        FluentHelper::withLocale($locale, function () use ($emailTemplate, $content) {
382
            // First assign the whole string to Content in case it's not split by zones
383
            $cleanContent = $this->cleanContent($content);
384
            $emailTemplate->Content = '';
385
            $emailTemplate->Content = $cleanContent;
386
387
            $source = '<!DOCTYPE html><html><body>' . $content . '</body></html>';
388
            $dom = new DomDocument('1.0', 'UTF-8');
389
            $dom->loadHTML(mb_convert_encoding($source, 'HTML-ENTITIES', 'UTF-8'));
0 ignored issues
show
It seems like mb_convert_encoding($sou...TML-ENTITIES', 'UTF-8') can also be of type array; however, parameter $source of DOMDocument::loadHTML() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

389
            $dom->loadHTML(/** @scrutinizer ignore-type */ mb_convert_encoding($source, 'HTML-ENTITIES', 'UTF-8'));
Loading history...
390
391
            // Look for nodes to assign to proper fields (will overwrite content)
392
            $fields = ['Content', 'Callout', 'Subject'];
393
            foreach ($fields as $field) {
394
                $node = $dom->getElementById($field);
395
                if ($node) {
396
                    $cleanContent = $this->cleanContent($this->getInnerHtml($node));
397
                    $emailTemplate->$field = '';
398
                    $emailTemplate->$field = $cleanContent;
399
                }
400
            }
401
402
            // No subject? try conventions
403
            if (!$emailTemplate->Subject) {
404
                switch ($emailTemplate->Code) {
405
                    case 'ChangePassword':
406
                        $entity = "SilverStripe\\Security\\Member.SUBJECTPASSWORDCHANGED";
407
                        break;
408
                    case 'ForgotPassword':
409
                        $entity = "SilverStripe\\Security\\Member.SUBJECTPASSWORDRESET";
410
                        break;
411
                    default:
412
                        $entity = $emailTemplate->Code . 'Email.SUBJECT';
413
                        break;
414
                }
415
                $subject = _t($entity);
416
                if ($subject) {
417
                    $emailTemplate->Subject = $subject;
418
                }
419
            }
420
421
            // Write each time within the given state
422
            $emailTemplate->write();
423
        });
424
    }
425
426
    /**
427
     * Get a clean string
428
     *
429
     * @param string $content
430
     * @return string
431
     */
432
    protected function cleanContent($content)
433
    {
434
        $content = strip_tags($content, '<p><br><br/><div><img><a><span><ul><li><strong><em><b><i><blockquote><h1><h2><h3><h4><h5><h6>');
435
        $content = str_replace("’", "'", $content);
436
        $content = trim($content);
437
        $content = nl2br($content);
438
        return $content;
439
    }
440
441
    /**
442
     * Loop over a node to extract all html
443
     *
444
     * @param DOMElement $node
445
     * @return string
446
     */
447
    protected function getInnerHtml(DOMElement $node)
448
    {
449
        $innerHTML = '';
450
        $children = $node->childNodes;
451
        foreach ($children as $child) {
452
            $innerHTML .= $child->ownerDocument->saveXML($child);
453
        }
454
        return $innerHTML;
455
    }
456
}
457