Passed
Push — master ( b2c708...63f4df )
by Thomas
02:08
created

EmailImportTask::assignContent()   B

Complexity

Conditions 7
Paths 1

Size

Total Lines 44
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 1 Features 0
Metric Value
cc 7
eloc 29
c 2
b 1
f 0
nc 1
nop 3
dl 0
loc 44
rs 8.5226
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
Bug introduced by
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;
16
use TractorCow\Fluent\Extension\FluentExtension;
0 ignored issues
show
Bug introduced by
The type TractorCow\Fluent\Extension\FluentExtension 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...
17
use LeKoala\EmailTemplates\Helpers\SubsiteHelper;
18
use SilverStripe\Core\Config\Config;
19
use SilverStripe\i18n\TextCollection\i18nTextCollector;
20
21
/**
22
 * Import email templates provided from ss files
23
 *
24
 * \vendor\silverstripe\framework\templates\SilverStripe\Control\Email
25
 * \vendor\silverstripe\framework\templates\SilverStripe\Control\Email\ForgotPasswordEmail.ss
26
 *
27
 * \app\templates\Email\MySampleEmail.ss
28
 *
29
 * Finds all *Email.ss templates and imports them into the CMS
30
 * @author lekoala
31
 */
32
class EmailImportTask extends BuildTask
33
{
34
    private static $segment = 'EmailImportTask';
35
36
    protected $title = "Email import task";
37
    protected $description = "Finds all *Email.ss templates and imports them into the CMS, if they don't already exist.";
38
39
    public function run($request)
40
    {
41
        $subsiteSupport = SubsiteHelper::usesSubsite();
42
        $fluentSupport = FluentHelper::usesFluent();
43
44
        echo 'Run with ?clear=1 to clear empty database before running the task<br/>';
45
        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/>';
46
        echo 'Run with ?templates=xxx,yyy to specify which template should be imported<br/>';
47
        if ($subsiteSupport) {
48
            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/>';
49
        }
50
        if ($fluentSupport) {
51
            echo 'Run with ?locales=fr,en to choose which locale to import.<br/>';
52
        }
53
        echo '<strong>Remember to flush the templates/translations if needed</strong><br/>';
54
        echo '<hr/>';
55
56
        $overwrite = $request->getVar('overwrite');
57
        $clear = $request->getVar('clear');
58
        $templatesToImport = $request->getVar('templates');
59
        $importToSubsite = $request->getVar('subsite');
0 ignored issues
show
Unused Code introduced by
The assignment to $importToSubsite is dead and can be removed.
Loading history...
60
        $chosenLocales = $request->getVar('locales');
61
62
        // Normalize argument
63
        if ($overwrite && $overwrite != 'soft' && $overwrite != 'hard') {
64
            $overwrite = 'soft';
65
        }
66
67
        // Select which subsite to import emails to
68
        $importToSubsite = array();
69
        if ($subsiteSupport) {
70
            $subsites = array();
71
            if ($importToSubsite == 'all') {
0 ignored issues
show
introduced by
The condition $importToSubsite == 'all' is always false.
Loading history...
72
                $subsites = SubsiteHelper::listSubsites();
73
            } elseif (is_numeric($importToSubsite)) {
0 ignored issues
show
introduced by
The condition is_numeric($importToSubsite) is always false.
Loading history...
74
                $subsites = SubsiteHelper::listSubsites();
75
                $subsiteTitle = 'Subsite #' . $importToSubsite;
76
                foreach ($subsites as $subsite) {
77
                    if ($subsite->ID == $importToSubsite) {
78
                        $subsiteTitle = $subsite->Title;
79
                    }
80
                }
81
                $subsites = array(
82
                    $importToSubsite => $subsiteTitle
83
                );
84
            }
85
            if ($subsiteSupport && SubsiteHelper::currentSubsiteID()) {
86
                DB::alteration_message("Importing to current subsite. Run from main site to import other subsites at once.", "created");
87
                $subsites = array();
88
            }
89
            if (!empty($subsites)) {
90
                DB::alteration_message("Importing to subsites : " . implode(',', array_values($subsites)), "created");
91
            }
92
        }
93
94
        if ($templatesToImport) {
95
            $templatesToImport = explode(',', $templatesToImport);
96
        }
97
98
        // Do we clear our templates?
99
        if ($clear == 1) {
100
            DB::alteration_message("Clear all email templates", "created");
101
            $emailTemplates = EmailTemplate::get();
102
            foreach ($emailTemplates as $emailTemplate) {
103
                $emailTemplate->delete();
104
            }
105
        }
106
107
        $emailTemplateSingl = singleton(EmailTemplate::class);
0 ignored issues
show
Unused Code introduced by
The assignment to $emailTemplateSingl is dead and can be removed.
Loading history...
108
109
        $locales = null;
110
        if ($fluentSupport) {
111
            if (FluentHelper::isClassTranslated(EmailTemplate::class)) {
112
                $locales = Locale::get()->column('Locale');
113
114
                // We collect only one locale, restrict the list
115
                if ($chosenLocales) {
116
                    $arr = explode(',', $chosenLocales);
117
                    $locales = array();
118
                    foreach ($arr as $a) {
119
                        $a = FluentHelper::get_locale_from_lang($a);
120
                        $locales[] = $a;
121
                    }
122
                }
123
            }
124
        }
125
126
        $defaultLocale = FluentHelper::get_locale();
127
128
        $templates = $this->collectTemplates();
129
130
        // don't throw errors
131
        Config::modify()->set(i18n::class, 'missing_default_warning', false);
132
133
        foreach ($templates as $filePath) {
134
            $isOverwritten = false;
135
136
            $fileName = basename($filePath, '.ss');
137
138
            // Remove base path
139
            $relativeFilePath = str_replace(Director::baseFolder(), '', $filePath);
140
            $relativeFilePathParts = explode('/', trim($relativeFilePath, '/'));
141
142
            // Group by module
143
            $moduleName = array_shift($relativeFilePathParts);
144
            if ($moduleName == 'vendor') {
145
                $moduleVendor = array_shift($relativeFilePathParts);
146
                // get module name
147
                $moduleName = $moduleVendor . '/' . array_shift($relativeFilePathParts);
148
            }
149
150
            // remove /templates part
151
            array_shift($relativeFilePathParts);
152
            $templateName = str_replace('.ss', '', implode('/', $relativeFilePathParts));
153
154
            $templateTitle = basename($templateName);
0 ignored issues
show
Unused Code introduced by
The assignment to $templateTitle is dead and can be removed.
Loading history...
155
156
            // Create the email code (basically, the template name without "Email" at the end)
157
            $code = preg_replace('/Email$/', '', $fileName);
158
159
            if (!empty($templatesToImport) && !in_array($code, $templatesToImport)) {
160
                DB::alteration_message("Template with code <b>$code</b> was ignored.", "repaired");
161
                continue;
162
            }
163
164
            $whereCode = array(
165
                'Code' => $code
166
            );
167
            $emailTemplate = EmailTemplate::get()->filter($whereCode)->first();
168
169
            // Check if it has been modified or not
170
            $templateModified = false;
171
            if ($emailTemplate) {
172
                $templateModified = $emailTemplate->Created != $emailTemplate->LastEdited;
173
            }
174
175
            if (!$overwrite && $emailTemplate) {
176
                DB::alteration_message("Template with code <b>$code</b> already exists. Choose overwrite if you want to import again.", "repaired");
177
                continue;
178
            }
179
            if ($overwrite == 'soft' && $templateModified) {
180
                DB::alteration_message("Template with code <b>$code</b> has been modified by the user. Choose overwrite=hard to change.", "repaired");
181
                continue;
182
            }
183
184
            // Create a default title from code
185
            $title = preg_split('/(?=[A-Z])/', $code);
186
            $title = implode(' ', $title);
0 ignored issues
show
Unused Code introduced by
The assignment to $title is dead and can be removed.
Loading history...
Bug introduced by
It seems like $title can also be of type false; however, parameter $pieces of implode() does only seem to accept array, 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

186
            $title = implode(' ', /** @scrutinizer ignore-type */ $title);
Loading history...
187
188
            // Get content of the email
189
            $content = file_get_contents($filePath);
190
191
            // Analyze content to find incompatibilities
192
            $errors = self::checkContentForErrors($content);
193
            if (!empty($errors)) {
194
                echo "<div style='color:red'>Invalid syntax was found in '$relativeFilePath'. Please fix these errors before importing the template<ul>";
195
                foreach ($errors as $error) {
196
                    echo '<li>' . $error . '</li>';
197
                }
198
                echo '</ul></div>';
199
                continue;
200
            }
201
202
            // Parse language
203
            $module = ModuleLoader::getModule($moduleName);
204
            $collector = new i18nTextCollector;
205
            $entities = $collector->collectFromTemplate($content, $fileName, $module);
206
207
            /*
208
            array:1 [▼
209
            "MyEmail.SUBJECT" => "My subject"
210
            ]
211
            */
212
213
            $translationTable = array();
214
            foreach ($entities as $entity => $data) {
215
                if ($locales) {
216
                    foreach ($locales as $locale) {
217
                        i18n::set_locale($locale);
218
                        if (!isset($translationTable[$entity])) {
219
                            $translationTable[$entity] = array();
220
                        }
221
                        $translationTable[$entity][$locale] = i18n::_t($entity, $data);
222
                    }
223
                    i18n::set_locale($defaultLocale);
224
                } else {
225
                    $translationTable[$entity] = array($defaultLocale => i18n::_t($entity, $data));
226
                }
227
            }
228
229
            $contentLocale = array();
230
            // May be null
231
            if ($locales) {
232
                foreach ($locales as $locale) {
233
                    $contentLocale[$locale] = $content;
234
                }
235
            }
236
            if (!isset($contentLocale[$defaultLocale])) {
237
                $contentLocale[$defaultLocale] = $content;
238
            }
239
240
            // Now we use our translation table to manually replace _t calls into file content
241
            foreach ($translationTable as $entity => $translationData) {
242
                // use the double escape notation
243
                $escapedEntity = str_replace('\\', '\\\\\\\\', $entity);
244
                // fix dot notation
245
                $escapedEntity = str_replace('.', '\.', $escapedEntity);
246
                $baseTranslation = null;
247
248
                foreach ($translationData as $locale => $translation) {
249
                    if (!$baseTranslation && $translation) {
250
                        $baseTranslation = $translation;
251
                    }
252
                    if (!$translation) {
253
                        $translation = $baseTranslation;
254
                    }
255
                    // This regex should match old and new style
256
                    $count = 0;
257
                    $contentLocale[$locale] = preg_replace("/<%(t | _t\(')" . $escapedEntity . "( |').*?%>/ums", $translation, $contentLocale[$locale], -1, $count);
258
                    if (!$count) {
259
                        throw new Exception("Failed to replace $escapedEntity with translation $translation");
260
                    }
261
                }
262
            }
263
264
            // Create a template if necassery or mark as overwritten
265
            if (!$emailTemplate) {
266
                $emailTemplate = new EmailTemplate;
267
            } else {
268
                $isOverwritten = true;
269
            }
270
271
            // Other properties
272
            $emailTemplate->Code = $code;
273
            $emailTemplate->Category = $moduleName;
274
            if (SubsiteHelper::currentSubsiteID() && !$emailTemplate->SubsiteID) {
0 ignored issues
show
Bug Best Practice introduced by
The property SubsiteID does not exist on LeKoala\EmailTemplates\Models\EmailTemplate. Since you implemented __get, consider adding a @property annotation.
Loading history...
275
                $emailTemplate->SubsiteID = SubsiteHelper::currentSubsiteID();
0 ignored issues
show
Bug Best Practice introduced by
The property SubsiteID does not exist on LeKoala\EmailTemplates\Models\EmailTemplate. Since you implemented __set, consider adding a @property annotation.
Loading history...
276
            }
277
            // Write to main site or current subsite
278
            $emailTemplate->write();
279
280
            // Apply content to email after write to ensure we can localize properly
281
            $this->assignContent($emailTemplate, $contentLocale[$defaultLocale]);
282
283
            if (!empty($locales)) {
284
                foreach ($locales as $locale) {
285
                    $this->assignContent($emailTemplate, $contentLocale[$locale], $locale);
286
                }
287
            }
288
289
            // Reset date to allow tracking user edition (for soft/hard overwrite)
290
            $this->resetLastEditedDate($emailTemplate->ID);
291
292
            // Loop through subsites
293
            if (!empty($importToSubsite)) {
294
                SubsiteHelper::disableFilter();
295
                foreach ($subsites as $subsiteID => $subsiteTitle) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $subsites does not seem to be defined for all execution paths leading up to this point.
Loading history...
296
                    $whereCode['SubsiteID'] = $subsiteID;
297
298
                    $subsiteEmailTemplate = EmailTemplate::get()->filter($whereCode)->first();
299
300
                    $emailTemplateCopy = $emailTemplate;
301
                    $emailTemplateCopy->SubsiteID = $subsiteID;
302
                    if ($subsiteEmailTemplate) {
303
                        $emailTemplateCopy->ID = $subsiteEmailTemplate->ID;
304
                    } else {
305
                        $emailTemplateCopy->ID = 0; // New
306
                    }
307
                    $emailTemplateCopy->write();
308
309
                    $this->resetLastEditedDate($emailTemplateCopy->ID);
310
                }
311
            }
312
313
            if ($isOverwritten) {
314
                DB::alteration_message("Overwrote <b>{$emailTemplate->Code}</b>", "created");
315
            } else {
316
                DB::alteration_message("Imported <b>{$emailTemplate->Code}</b>", "created");
317
            }
318
        }
319
    }
320
321
    public static function checkContentForErrors($content)
322
    {
323
        $errors = array();
324
        if (strpos($content, '<% with') !== false) {
325
            $errors[] = 'Replace "with" blocks by plain calls to the variable';
326
        }
327
        if (strpos($content, '<% if') !== false) {
328
            $errors[] = 'If/else logic is not supported. Please create one template by use case or abstract logic into the model';
329
        }
330
        if (strpos($content, '<% loop') !== false) {
331
            $errors[] = 'Loops are not supported. Please create a helper method on the model to render the loop';
332
        }
333
        if (strpos($content, '<% sprintf') !== false) {
334
            $errors[] = 'You should not use sprintf to escape content, please use plain _t calls';
335
        }
336
        return $errors;
337
    }
338
339
    /**
340
     * Collect email from your project
341
     *
342
     * @return array
343
     */
344
    protected function collectTemplates()
345
    {
346
        $templates = glob(Director::baseFolder() . '/' . project() . '/templates/Email/*Email.ss');
347
348
        $framework = self::config()->import_framework;
349
        if ($framework) {
350
            // use ? to avoid matching plain Email.ss
351
            $templates = array_merge($templates, glob(Director::baseFolder() . '/vendor/silverstripe/framework/templates/SilverStripe/Control/Email/?*Email.ss'));
0 ignored issues
show
Bug introduced by
It seems like glob(SilverStripe\Contro...trol/Email/?*Email.ss') can also be of type false; however, parameter $array2 of array_merge() does only seem to accept array|null, 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

351
            $templates = array_merge($templates, /** @scrutinizer ignore-type */ glob(Director::baseFolder() . '/vendor/silverstripe/framework/templates/SilverStripe/Control/Email/?*Email.ss'));
Loading history...
Bug introduced by
It seems like $templates can also be of type false; however, parameter $array1 of array_merge() does only seem to accept array, 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

351
            $templates = array_merge(/** @scrutinizer ignore-type */ $templates, glob(Director::baseFolder() . '/vendor/silverstripe/framework/templates/SilverStripe/Control/Email/?*Email.ss'));
Loading history...
352
        }
353
        $extra = self::config()->extra_paths;
354
        foreach ($extra as $path) {
355
            $path = trim($path, '/');
356
            $templates = array_merge($templates, glob(Director::baseFolder() . '/' . $path . '/*Email.ss'));
357
        }
358
359
        return $templates;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $templates could also return false which is incompatible with the documented return type array. Did you maybe forget to handle an error condition?

If the returned type also contains false, it is an indicator that maybe an error condition leading to the specific return statement remains unhandled.

Loading history...
360
    }
361
362
    /**
363
     * Utility function to reset email templates last edited date
364
     *
365
     * @param int $ID
366
     * @return void
367
     */
368
    protected function resetLastEditedDate($ID)
369
    {
370
        DB::query("UPDATE `EmailTemplate` SET LastEdited = Created WHERE ID = " . $ID);
371
    }
372
373
    /**
374
     * Update a template with content
375
     *
376
     * @param EmailTemplate $emailTemplate
377
     * @param string $content The full page content with html
378
     * @param string $locale
379
     * @return void
380
     */
381
    protected function assignContent(EmailTemplate $emailTemplate, $content, $locale = '')
382
    {
383
        FluentHelper::withLocale($locale, function () use ($emailTemplate, $content) {
384
            // First assign the whole string to Content in case it's not split by zones
385
            $cleanContent = $this->cleanContent($content);
386
            $emailTemplate->Content = '';
387
            $emailTemplate->Content = $cleanContent;
388
389
            $source = '<div>' . $content . '</div>';
390
            $dom = new DomDocument('1.0', 'UTF-8');
391
            $dom->loadHTML(mb_convert_encoding($source, 'HTML-ENTITIES', 'UTF-8'));
392
393
            // Look for nodes to assign to proper fields (will overwrite content)
394
            $fields = array('Content', 'Callout', 'Subject');
395
            foreach ($fields as $field) {
396
                $node = $dom->getElementById($field);
397
                if ($node) {
398
                    $cleanContent = $this->cleanContent($this->getInnerHtml($node));
399
                    $emailTemplate->$field = '';
400
                    $emailTemplate->$field = $cleanContent;
401
                }
402
            }
403
404
            // No subject? try conventions
405
            if (!$emailTemplate->Subject) {
406
                switch ($emailTemplate->Code) {
407
                    case 'ChangePassword':
408
                        $entity = "SilverStripe\\Security\\Member.SUBJECTPASSWORDCHANGED";
409
                        break;
410
                    case 'ForgotPassword':
411
                        $entity = "SilverStripe\\Security\\Member.SUBJECTPASSWORDRESET";
412
                        break;
413
                    default:
414
                        $entity = $emailTemplate->Code . 'Email.SUBJECT';
415
                        break;
416
                }
417
                $subject = _t($entity);
418
                if ($subject) {
419
                    $emailTemplate->Subject = $subject;
420
                }
421
            }
422
423
            // Write each time within the given state
424
            $emailTemplate->write();
425
        });
426
    }
427
428
    /**
429
     * Get a clean string
430
     *
431
     * @param string $content
432
     * @return string
433
     */
434
    protected function cleanContent($content)
435
    {
436
        $content = strip_tags($content, '<p><br><br/><div><img><a><span><ul><li><strong><em><b><i><blockquote><h1><h2><h3><h4><h5><h6>');
437
        $content = str_replace("’", "'", $content);
438
        $content = trim($content);
439
        $content = nl2br($content);
440
        return $content;
441
    }
442
443
    /**
444
     * Loop over a node to extract all html
445
     *
446
     * @param DOMElement $node
447
     * @return string
448
     */
449
    protected function getInnerHtml(DOMElement $node)
450
    {
451
        $innerHTML = '';
452
        $children = $node->childNodes;
453
        foreach ($children as $child) {
454
            $innerHTML .= $child->ownerDocument->saveXML($child);
455
        }
456
        return $innerHTML;
457
    }
458
}
459