FormAction.php 26.7 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
<?php
/**
 * Created by PhpStorm.
 * User: crose
 * Date: 5/29/16
 * Time: 5:24 PM
 */

namespace qfq;

require_once(__DIR__ . '/../Constants.php');
12
require_once(__DIR__ . '/../database/Database.php');
13
14
require_once(__DIR__ . '/../store/Store.php');
require_once(__DIR__ . '/../Evaluate.php');
15
require_once(__DIR__ . '/../report/SendMail.php');
16
require_once(__DIR__ . '/../helper/HelperFormElement.php');
17
require_once(__DIR__ . '/../exceptions/UserFormException.php');
18

19

20
21
22
23
/**
 * Class formAction
 * @package qfq
 */
24
class FormAction {
25
26

//    private $feSpecNative = array(); // copy of all formElement.class='native' of the loaded form
27
28
29
30
    /**
     * @var Evaluate instantiated class
     */
    protected $evaluate = null;  // copy of the loaded form
Carsten  Rose's avatar
Carsten Rose committed
31

32
    private $formSpec = array();
33
    private $primaryTableName = '';
Carsten  Rose's avatar
Carsten Rose committed
34

35
36
37
38
    /**
     * @var Database
     */
    private $db = null;
Carsten  Rose's avatar
Carsten Rose committed
39

40
41
42
43
44
45
46
47
48
    /**
     * @var Store
     */
    private $store = null;

    /**
     * @param array $formSpec
     * @param Database $db
     * @param bool|false $phpUnit
49
50
     * @throws CodeException
     * @throws UserFormException
51
     * @throws UserReportException
52
53
54
     */
    public function __construct(array $formSpec, Database $db, $phpUnit = false) {
        $this->formSpec = $formSpec;
55
        $this->primaryTableName = Support::setIfNotSet($formSpec, F_TABLE_NAME);
56
57
58
59
60
61
62
63
64
65
66

        $this->db = $db;

        $this->store = Store::getInstance('', $phpUnit);

        $this->evaluate = new Evaluate($this->store, $this->db);

    }

    /**
     * @param integer $recordId
Carsten  Rose's avatar
Carsten Rose committed
67
68
     * @param array $feSpecAction
     * @param string $feTypeList
69
70
71
     *         On FormLoad: FE_TYPE_BEFORE_LOAD, FE_TYPE_AFTER_LOAD
     *         Before Save: FE_TYPE_BEFORE_SAVE, FE_TYPE_BEFORE_INSERT, FE_TYPE_BEFORE_UPDATE, FE_TYPE_BEFORE_DELETE
     *         After Save: FE_TYPE_AFTER_SAVE, FE_TYPE_AFTER_INSERT, FE_TYPE_AFTER_UPDATE, FE_TYPE_AFTER_DELETE
Carsten  Rose's avatar
Carsten Rose committed
72
     *
73
74
75
     * @return int: ACTION_ELEMENT_MODIFIED if there are potential changes on the DB like fired SQL statements,
     *              ACTION_ELEMENT_NO_CHANGE if nothing happened
     *              ACTION_ELEMENT_DELETED:  if a record has been deleted (only in recursive calls, not the initial one)
76
77
     * @throws CodeException
     * @throws DbException
Carsten  Rose's avatar
Carsten Rose committed
78
     * @throws DownloadException
79
     * @throws UserFormException
Carsten  Rose's avatar
Carsten Rose committed
80
     * @throws UserReportException
Carsten  Rose's avatar
Carsten Rose committed
81
82
83
     * @throws \PhpOffice\PhpSpreadsheet\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
84
     */
85
    public function elements($recordId, array $feSpecAction, $feTypeList) {
86

87
        $rc = ACTION_ELEMENT_NO_CHANGE;
88

89
90
91
        // Iterate over all Action FormElements
        foreach ($feSpecAction as $fe) {

92
93
94
            // Preparation for Log, Debug
            $this->store->setVar(SYSTEM_FORM_ELEMENT, Logger::formatFormElementName($fe), STORE_SYSTEM);

95
            $fe = HelperFormElement::initActionFormElement($fe);
96

97
            // Only process FE elements of types listed in $feTypeList. Skip all other
98
99
100
101
            if (false === Support::findInSet($fe[FE_TYPE], $feTypeList)) {
                continue;
            }

102
103
104
            if (isset($fe[FE_FILL_STORE_VAR])) {
                $rows = $this->evaluate->parse($fe[FE_FILL_STORE_VAR]);
                if (is_array($rows)) {
105
                    $this->store->appendToStore($rows[0], STORE_VAR);
106
107
108
109
110
111
                } else {
                    if (!empty($rows)) {
                        throw new UserFormException("Invalid statement for 'fillStoreVar': " . $fe[FE_FILL_STORE_VAR], ERROR_INVALID_OR_MISSING_PARAMETER);
                    }
                }
                $fe[FE_FILL_STORE_VAR] = ''; // do not process the same later on.
112
113
            }

114
115
            // Process templateGroup action elements
            if (isset($fe[FE_ID_CONTAINER]) && $fe[FE_ID_CONTAINER] > 0) {
116
                // Get native 'templateGroup'-FE - to retrieve MAX_LENGTH
117
118
119
120
121
122
                $templateGroup = $this->db->sql(SQL_FORM_ELEMENT_TEMPLATE_GROUP_FE_ID, ROW_EXPECT_1, [$fe[FE_ID_CONTAINER]],
                    "In FormElement.id=" . $fe[FE_ID] . ", feIdContainer=" . $fe[FE_ID_CONTAINER] . '  should point to a templateGroup');

                if (isset($templateGroup[FE_TYPE]) && $templateGroup[FE_TYPE] == FE_TYPE_TEMPLATE_GROUP) {
                    $maxCopies = HelperFormElement::tgGetMaxLength($templateGroup[FE_MAX_LENGTH]);
                    $fe[FE_ID_CONTAINER] = 0; // Flag to make the nested TG unnested and therefore the SQLs are fired.
123
124
//                    for ($ii = $maxCopies; $ii > 0; $ii--) { // Iterate backwards: deleting records starts at the end and doesn't affect remaining counting
                    $correctDeleteIndex = 0;
125
                    for ($ii = 1; $ii <= $maxCopies; $ii++) {
126
127
128
129
130
131
132
133
134
135
136
137
138
                        $feNew = OnArray::arrayValueReplace($fe, FE_TEMPLATE_GROUP_NAME_PATTERN, $ii - $correctDeleteIndex);
                        $feNew = OnArray::arrayValueReplace($feNew, FE_TEMPLATE_GROUP_NAME_PATTERN_0, $ii - 1 - $correctDeleteIndex);
                        switch ($this->elements($recordId, [$feNew], $feTypeList)) {
                            case ACTION_ELEMENT_MODIFIED:
                                $rc = ACTION_ELEMENT_MODIFIED;
                                break;
                            case ACTION_ELEMENT_DELETED:
                                $rc = ACTION_ELEMENT_MODIFIED;
                                $correctDeleteIndex++;
                                break;
                            case ACTION_ELEMENT_NO_CHANGE:
                            default:
                                break;
139
140
                        }
                    }
141
                    continue; // skip to next FormElement
142
143
                }
            }
144

145
146
147
148
149
150
151
            switch ($fe[FE_TYPE]) {
                case FE_TYPE_BEFORE_LOAD:
                case FE_TYPE_AFTER_LOAD:
                case FE_TYPE_AFTER_DELETE:  # Main record is already deleted. Do not try to load it again.
                    break;
                default:
                    // Always work on recent data: previous actions might have modified the data.
152
                    $this->store->fillStoreWithRecord($this->primaryTableName, $recordId, $this->db, $this->formSpec[F_PRIMARY_KEY]);
153
154
            }

155
156
157
158
            if (!$this->checkRequiredList($fe)) {
                continue;
            }

159
160
161
162
163
            if (isset($fe[FE_FILL_STORE_LDAP])) {
                $keyNames = [F_LDAP_SERVER, F_LDAP_BASE_DN, F_LDAP_ATTRIBUTES, F_LDAP_SEARCH, F_LDAP_TIME_LIMIT];
                $fe = OnArray::copyArrayItemsIfNotAlreadyExist($this->formSpec, $fe, $keyNames);

                // Extract necessary elements
164
                $config = OnArray::getArrayItems($fe, [FE_LDAP_SERVER, FE_LDAP_BASE_DN, FE_LDAP_SEARCH, FE_LDAP_ATTRIBUTES, FE_LDAP_USE_BIND_CREDENTIALS]);
165
166
                $config = $this->evaluate->parseArray($config);

167
                if ($fe[FE_LDAP_USE_BIND_CREDENTIALS] == 1) {
168
169
170
171
                    $config[SYSTEM_LDAP_1_RDN] = $this->store->getVar(SYSTEM_LDAP_1_RDN, STORE_SYSTEM);
                    $config[SYSTEM_LDAP_1_PASSWORD] = $this->store->getVar(SYSTEM_LDAP_1_PASSWORD, STORE_SYSTEM);
                }

172
173
174
175
176
                $ldap = new Ldap();
                $arr = $ldap->process($config, '', MODE_LDAP_SINGLE);
                $this->store->setStore($arr, STORE_LDAP, true);
            }

177
            if ($fe[FE_TYPE] === FE_TYPE_SENDMAIL) {
178
                $this->doSendMail($fe);
179
180
181
                //no further processing of current element necessary.
                continue;
            }
182
183
184

            $this->validate($fe);

185
186
187
188
189
190
191
192
193
            $rcTmp = $this->doSlave($fe, $recordId);
            switch ($rcTmp) {
                case ACTION_ELEMENT_MODIFIED:
                case ACTION_ELEMENT_DELETED:
                    $rc = $rcTmp;
                    break;
                default:
                    break;
            }
194
        }
195

196
        return $rc;
197
198
    }

199
    /**
200
     * Process all FormElements given in the `requiredList` identified by their name.
201
202
203
204
     * If none is empty in STORE_FORM return true, else false.
     * If none FormElement is specified, return true.
     *
     * @param array $fe
Carsten  Rose's avatar
Carsten Rose committed
205
     *
206
     * @return bool  true if none FE is specified or all specified are non empty.
207
     * @throws CodeException
Carsten  Rose's avatar
Carsten Rose committed
208
     * @throws UserFormException
209
210
     */
    private function checkRequiredList(array $fe) {
211

212
213
        if (!isset($fe[FE_REQUIRED_LIST]) || $fe[FE_REQUIRED_LIST] === '') {
            return true;
214
215
        }

216
217
218
219
        $arr = explode(',', $fe[FE_REQUIRED_LIST]);
        foreach ($arr as $key) {

            $key = trim($key);
220
            $val = $this->store->getVar($key, STORE_FORM, SANITIZE_ALLOW_ALL);
221
222
223
224

            if ($val === false || $val === '' || $val === '0') {
                return false;
            }
225
226
        }

227
        return true;
228
229
    }

230
231
    /**
     * @param array $feSpecAction
232
233
     * @throws CodeException
     * @throws DbException
Carsten  Rose's avatar
Carsten Rose committed
234
     * @throws DownloadException
235
236
     * @throws UserFormException
     * @throws UserReportException
237
238
239
     * @throws \PhpOffice\PhpSpreadsheet\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
240
     */
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
    private function doSendMail(array $feSpecAction) {

        $args = array();

        $args[] = SENDMAIL_TOKEN_RECEIVER . PARAM_TOKEN_DELIMITER . $this->evaluate->parse($feSpecAction[FE_SENDMAIL_TO]);
        $args[] = SENDMAIL_TOKEN_SENDER . PARAM_TOKEN_DELIMITER . $this->evaluate->parse($feSpecAction[FE_SENDMAIL_FROM]);
        $args[] = SENDMAIL_TOKEN_SUBJECT . PARAM_TOKEN_DELIMITER . $this->evaluate->parse($feSpecAction[FE_SENDMAIL_SUBJECT]);
        $args[] = SENDMAIL_TOKEN_BODY . PARAM_TOKEN_DELIMITER . $this->evaluate->parse($feSpecAction[FE_VALUE]);
        $args[] = SENDMAIL_TOKEN_REPLY_TO . PARAM_TOKEN_DELIMITER . $this->evaluate->parse($feSpecAction[FE_SENDMAIL_REPLY_TO]);
        $autoSubmit = ($this->evaluate->parse($feSpecAction[FE_SENDMAIL_FLAG_AUTO_SUBMIT]) === 'off') ? 'off' : 'on';
        $args[] = SENDMAIL_TOKEN_FLAG_AUTO_SUBMIT . PARAM_TOKEN_DELIMITER . $autoSubmit;
        $args[] = SENDMAIL_TOKEN_GR_ID . PARAM_TOKEN_DELIMITER . $this->evaluate->parse($feSpecAction[FE_SENDMAIL_GR_ID]);
        $args[] = SENDMAIL_TOKEN_X_ID . PARAM_TOKEN_DELIMITER . $this->evaluate->parse($feSpecAction[FE_SENDMAIL_X_ID]);
        $args[] = SENDMAIL_TOKEN_RECEIVER_CC . PARAM_TOKEN_DELIMITER . $this->evaluate->parse($feSpecAction[FE_SENDMAIL_CC]);
        $args[] = SENDMAIL_TOKEN_RECEIVER_BCC . PARAM_TOKEN_DELIMITER . $this->evaluate->parse($feSpecAction[FE_SENDMAIL_BCC]);
        $args[] = SENDMAIL_TOKEN_SRC . PARAM_TOKEN_DELIMITER . "FormId: " . $feSpecAction[FE_FORM_ID] . ", FormElementId: " . $feSpecAction['id'];
        $args[] = SENDMAIL_TOKEN_X_ID2 . PARAM_TOKEN_DELIMITER . $this->evaluate->parse($feSpecAction[FE_SENDMAIL_X_ID2]);
        $args[] = SENDMAIL_TOKEN_X_ID3 . PARAM_TOKEN_DELIMITER . $this->evaluate->parse($feSpecAction[FE_SENDMAIL_X_ID3]);
259
260
261
        $args[] = SENDMAIL_TOKEN_BODY_MODE . PARAM_TOKEN_DELIMITER . $this->evaluate->parse($feSpecAction[FE_SENDMAIL_BODY_MODE]);
        $args[] = SENDMAIL_TOKEN_BODY_HTML_ENTITY . PARAM_TOKEN_DELIMITER . $this->evaluate->parse($feSpecAction[FE_SENDMAIL_BODY_HTML_ENTITY]);
        $args[] = SENDMAIL_TOKEN_SUBJECT_HTML_ENTITY . PARAM_TOKEN_DELIMITER . $this->evaluate->parse($feSpecAction[FE_SENDMAIL_SUBJECT_HTML_ENTITY]);
262
        $args[] = $this->evaluate->parse($feSpecAction[FE_SENDMAIL_ATTACHMENT]);
263
264

        // Mail: send
265
266
267
        $sendMail = new SendMail();
        $mailConfig = $sendMail->parseStringToArray(implode(PARAM_DELIMITER, $args));
        $sendMail->process($mailConfig);
268
269
    }

270
271
272
273
    /**
     * If there is a query defined in fe.parameter.FE_SQL_VALIDATE: fire them.
     * Count the selected records and compare them with fe.parameter.FE_EXPECT_RECORDS.
     * If match: everything is fine, do nothing.
274
     * Else throw UserFormException with error message of fe.parameter.FE_MESSAGE_FAIL
275
276
     *
     * @param array $fe
Carsten  Rose's avatar
Carsten Rose committed
277
     *
Carsten  Rose's avatar
Carsten Rose committed
278
279
     * @throws CodeException
     * @throws DbException
280
     * @throws UserFormException
Carsten  Rose's avatar
Carsten Rose committed
281
     * @throws UserReportException
282
283
284
285
     */
    private function validate(array $fe) {

        // Is there something to check?
286
        if ($fe[FE_SQL_VALIDATE] === '') {
287
288
289
            return;
        }

290
291
292
        if($fe[FE_EXPECT_RECORDS]===''){
            throw new UserFormException("Missing parameter '" . FE_EXPECT_RECORDS . "'", ERROR_MISSING_EXPECT_RECORDS);
        }
293
        $expect = $this->evaluate->parse($fe[FE_EXPECT_RECORDS]);
294

295
        if ($fe[FE_MESSAGE_FAIL] === '') {
296
            throw new UserFormException("Missing parameter '" . FE_MESSAGE_FAIL . "'", ERROR_MISSING_MESSAGE_FAIL);
297
298
299
300
301
302
303
304
        }

        // Do the check
        $result = $this->evaluate->parse($fe[FE_SQL_VALIDATE]);
        if (!is_array($result)) {
            throw new UserFormException("Expected an array for '" . FE_SQL_VALIDATE . "', got a scalar. Please check for {{!...", ERROR_EXPECTED_ARRAY);
        }

305
306
307
308
309
310
        // If there is at least one record count given, who matches: return 'check succeeded'
        $countRecordsArr = explode(',', $expect);
        foreach ($countRecordsArr AS $count) {
            if (count($result) == $count) {
                return; // check succesfully passed
            }
311
312
        }

313
        $msg = $this->evaluate->parse($fe[FE_MESSAGE_FAIL]); // Replace possible dynamic parts
314

315
        // Throw user error message
316
317
        throw new UserFormException(json_encode([ERROR_MESSAGE_TO_USER => 'validate() failed', ERROR_MESSAGE_SUPPORT => $msg]), ERROR_REPORT_FAILED_ACTION);

318
319
    }

320
    /**
Carsten  Rose's avatar
Carsten Rose committed
321
322
     * Create the slave record. First try to evaluate a slaveId. Depending if the slaveId > 0 choose `sqlUpdate` or
     * `sqlInsert`
323
324
     *
     * @param array $fe
Carsten  Rose's avatar
Carsten Rose committed
325
     * @param int $recordId
Carsten  Rose's avatar
Carsten Rose committed
326
     *
327
328
329
330
     * @return int  ACTION_ELEMENT_MODIFIED if there are potential(!) changes on the DB like INSERT / UPDATE,
     *              ACTION_ELEMENT_NO_CHANGE if nothing happened
     *              ACTION_ELEMENT_DELETED:  if a record has been deleted
     *              $sqlAfter won't affect the $rc.
331
     * @throws CodeException
332
     * @throws DbException
333
     * @throws UserFormException
Carsten  Rose's avatar
Carsten Rose committed
334
     * @throws UserReportException
335
     */
336
    private function doSlave(array $fe, $recordId) {
337

338
339
        $rcStatus = ACTION_ELEMENT_NO_CHANGE;

340
        // Get the slaveId
341
        $slaveId = $this->evaluate->parse($fe[FE_SLAVE_ID]);
342
343
344
345
346
347
348
349
350
351
352

        if ($slaveId === '' && $fe[FE_NAME] !== '') {
            // if the current action element has the same name as a real master record column: take that value as an id
            $slaveId = $this->store->getVar($fe[FE_NAME], STORE_RECORD);
        }

        if ($slaveId === '' || $slaveId === false) {
            $slaveId = 0;
        }

        // Store the slaveId: it's used and replaced in the update statement.
353
354
355
356
        $this->store->setVar(VAR_SLAVE_ID, $slaveId, STORE_VAR, true);

        // If given: fire a sqlBefore query
        $this->evaluate->parse($fe[FE_SQL_BEFORE]);
357

358
359
        $doInsert = ($slaveId == 0);
        $doUpdate = ($slaveId != 0);
360
        $doDelete = ($slaveId != 0) && $fe[FE_SQL_DELETE] != '';
361
362
363
364
365
366
367
368
369

        $flagHonor = isset($fe[FE_SQL_HONOR_FORM_ELEMENTS]) && $fe[FE_SQL_HONOR_FORM_ELEMENTS] != '';
        if ($flagHonor) {
            $filled = $this->checkFormElements($fe[FE_SQL_HONOR_FORM_ELEMENTS]);
            $doInsert = $filled && $doInsert;
            $doUpdate = $filled && $doUpdate;
            $doDelete = !$filled && $doDelete;
        }

370
        // Fire slave query
371
        if ($doInsert) {
372
            $slaveId = $this->evaluate->parse($fe[FE_SQL_INSERT]);
373
374
            // Store the slaveId: might be used later
            $this->store->setVar(VAR_SLAVE_ID, $slaveId, STORE_VAR, true);
375
            $rcStatus = ACTION_ELEMENT_MODIFIED;
376
377
378
        }

        if ($doUpdate) {
379
            $this->evaluate->parse($fe[FE_SQL_UPDATE]);
380
            $rcStatus = ACTION_ELEMENT_MODIFIED;
381
382
        }

383
384
385
386
        // Fire a delete query
        if ($doDelete) {
            $this->evaluate->parse($fe[FE_SQL_DELETE]);
            $slaveId = 0;
387
            $rcStatus = ACTION_ELEMENT_DELETED;
388
389
        }

390
391
        // Check if there is a column with the same name as the 'action'-FormElement.
        if (false !== $this->store->getVar($fe[FE_NAME], STORE_RECORD)) {
392
            // After an insert or update, propagate the (new) slave id to the master record.
393
394
395
            $this->db->sql("UPDATE " . $this->primaryTableName . " SET " . $fe[FE_NAME] . " = $slaveId WHERE id = ? LIMIT 1", ROW_REGULAR, [$recordId]);
        }

396
        // If given: fire a $sqlAfter query. $sqlAfter won't affect $rc
397
398
        $this->evaluate->parse($fe[FE_SQL_AFTER]);

399
        return $rcStatus;
400
    }
401

402
403
404
405
406
    /**
     * Iterates over list of FormElement-names and check STORE_FORM if there is a corresponding value. If at least one
     * of the give elements is non empty, return true. If all elements are empty, return false.
     *
     * @param string $listOfFormElementNames E.g.: 'city, street, number'
Carsten  Rose's avatar
Carsten Rose committed
407
408
409
     *
     * @return bool true if at lease one of the named elements is non empty on STORE_FORM (use SANATIZE_ALLOW_ALL to
     *              perform the check)
410
     * @throws CodeException
Carsten  Rose's avatar
Carsten Rose committed
411
     * @throws UserFormException
412
413
414
415
416
417
418
419
420
421
     */
    private function checkFormElements($listOfFormElementNames) {
        $arr = explode(',', $listOfFormElementNames);

        foreach ($arr as $key) {
            $value = $this->store->getVar(trim($key), STORE_FORM . STORE_EMPTY, SANITIZE_ALLOW_ALL);
            if ($value != '') {
                return true;
            }
        }
Carsten  Rose's avatar
Carsten Rose committed
422

423
424
        return false;
    }
425

Carsten  Rose's avatar
Carsten Rose committed
426
427
428
429
430
    /**
     * Will be called for each master record (clipboard).
     * Process all FE.type='paste' for the given master record in clipboard.
     * Will store the clipboard in STORE_PARENT.
     *
Carsten  Rose's avatar
Carsten Rose committed
431
432
     * @param array $feSpecAction - all FE.class='action' - just process 'paste'
     * @param string $recordSourceTable - table name from where to copy the source records
Carsten  Rose's avatar
Carsten Rose committed
433
     * @param string $recordDestinationTable - table name where the records will be duplicated to.
Carsten  Rose's avatar
Carsten Rose committed
434
     * @param string $sub - on the highest level an empty string. It's a filter, value comes from
Carsten  Rose's avatar
Carsten Rose committed
435
     *                                       FE.name, to specify sub-sub copy rules.
Carsten  Rose's avatar
Carsten Rose committed
436
     * @param array $clipboard
Carsten  Rose's avatar
Carsten Rose committed
437
     *
Carsten  Rose's avatar
Carsten Rose committed
438
     * @throws CodeException
439
     * @throws DbException
Carsten  Rose's avatar
Carsten Rose committed
440
     * @throws UserFormException
441
     * @throws UserReportException
Carsten  Rose's avatar
Carsten Rose committed
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
     */
    public function doAllFormElementPaste(array $feSpecAction, $recordSourceTable, $recordDestinationTable, $sub, array $clipboard) {

        # process all paste records
        foreach ($feSpecAction as $formElement) {

            // Set the clipboard as the parent record. Update always the latest created Ids
            $this->store->setStore($clipboard, STORE_PARENT_RECORD, true);

            // Only process FE elements of types listed in $feTypeList. Skip all other.
            if (false === Support::findInSet($formElement[FE_TYPE], FE_TYPE_PASTE) || $formElement[FE_LABEL] != $sub) {
                continue;
            }

            // Preparation for Log, Debug
            $this->store->setVar(SYSTEM_FORM_ELEMENT, Logger::formatFormElementName($formElement), STORE_SYSTEM);

            $formElement = HelperFormElement::initActionFormElement($formElement);

            if (!empty($formElement[FE_RECORD_DESTINATION_TABLE])) {
                $recordDestinationTable = $formElement[FE_RECORD_DESTINATION_TABLE];
                $recordSourceTable = (empty($formElement[FE_RECORD_SOURCE_TABLE])) ? $recordDestinationTable : $formElement[FE_RECORD_SOURCE_TABLE];
            }

            $newValues = $this->evaluate->parse($formElement[FE_SQL1]);

            # Dupliziere den Record. RC ist die ID des neu erzeugten Records.
469
            $lastInsertId = $this->prepareDuplicate($feSpecAction, $formElement, $newValues, $recordSourceTable, $recordDestinationTable, $sub, $clipboard);
Carsten  Rose's avatar
Carsten Rose committed
470
471
472
473
474
475
476
477
478
479
480
481
482

            # Lege die Record ID im Array ab, damit spaetere 'paste' Records diese entsprechend einsetzen koennen.
            # Nur falls ein Name angegeben ist und dieser !='id' ist.
            if ($formElement[FE_NAME] !== '' && $formElement[FE_NAME] != COLUMN_ID) {
                $clipboard[$formElement[FE_NAME]] = $lastInsertId;
            }
        }
    } # doAllFormElementPaste()


    /**
     *
     *
Carsten  Rose's avatar
Carsten Rose committed
483
     * @param array $feSpecActionAll - all FE.class='action' - just process 'paste'
484
     * @param array $feSpecAction
Carsten  Rose's avatar
Carsten Rose committed
485
     * @param array $updateRecords - array of records: 'id' is the source.id, all other fields will replace
Carsten  Rose's avatar
Carsten Rose committed
486
     *                                       source columns.
Carsten  Rose's avatar
Carsten Rose committed
487
     * @param        $recordSourceTable - table name from where to copy the source records
Carsten  Rose's avatar
Carsten Rose committed
488
     * @param        $recordDestinationTable - table name where the records will be duplicated to.
Carsten  Rose's avatar
Carsten Rose committed
489
     * @param string $sub - on the highest level an empty string. It's a filter, value comes from
Carsten  Rose's avatar
Carsten Rose committed
490
     *                                       FE.name, to specify sub-sub copy rules.
Carsten  Rose's avatar
Carsten Rose committed
491
     * @param array $clipboard -
Carsten  Rose's avatar
Carsten Rose committed
492
493
494
495
     * @return int - lastInsertId
     * @throws CodeException
     * @throws DbException
     * @throws UserFormException
496
     * @throws UserReportException
Carsten  Rose's avatar
Carsten Rose committed
497
     */
498
499
500
    private function prepareDuplicate(array $feSpecActionAll, array $feSpecAction, array $updateRecords, $recordSourceTable, $recordDestinationTable, $sub, array $clipboard) {
        $translateMap = array();
        $field = $feSpecAction[FE_NAME];
Carsten  Rose's avatar
Carsten Rose committed
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523

        // Sometimes there is no query at all.
        if (count($updateRecords) == 0) {
            return (0);
        }

        // Iterate (for the given Paste FE) all updateRecords: duplicate each.
        $lastInsertId = 0;
        foreach ($updateRecords as $newColumns) {

            // will be used in sub paste's
//            $clipboard["_src_id"] = $newColumns[COLUMN_ID];

            $rowSrc = $this->db->sql("SELECT * FROM $recordSourceTable WHERE id=?", ROW_EXPECT_1, [$newColumns[COLUMN_ID]]);

            $this->checkNCopyFiles($rowSrc, $newColumns);

            foreach ($newColumns as $key => $val) {
                $rowSrc[$key] = $val;
            }

            $lastInsertId = $this->copyRecord($rowSrc, $recordDestinationTable);
            $clipboard[$field] = $lastInsertId;
524
            $translateMap[$newColumns[COLUMN_ID]] = $lastInsertId;
Carsten  Rose's avatar
Carsten Rose committed
525
526
527
528
529
530

            // Set the clipboard as the primary record as long as secondaries are created.
            $this->store->setStore($clipboard, STORE_PARENT_RECORD, true);

            # Do subqueries
            if ($sub == "") {
531
                $this->doAllFormElementPaste($feSpecActionAll, $recordSourceTable, $recordDestinationTable, $field, $clipboard);
Carsten  Rose's avatar
Carsten Rose committed
532
533
534
            }
        }

535
536
537
538
539
        // If necessary: correct table self referencing id columns
        if (!empty($feSpecAction[FE_TRANSLATE_ID_COLUMN])) {
            $this->translateId($translateMap, $feSpecAction[FE_TRANSLATE_ID_COLUMN], $recordDestinationTable);
        }

Carsten  Rose's avatar
Carsten Rose committed
540
541
542
543
        return $lastInsertId;

    } // prepareDuplicate()

544
545
546
547
548
549
550
551
552
553
554
555
556
    /**
     * Translate table self referencing columns to the new values.
     * Rerun on all new records. Search and translate old id's (copied) to the new generated id's.
     *
     * Example with FormElement:  id, feIdContainer, type
     *
     *     Original:  [1,2,'input'], [2,3,'templateGroup'], [3,0, 'pill']
     *   Duplicated:  [4,2,'input'], [5,3,'templateGroup'], [6,0, 'pill']
     *  TranslateId:  [4,5,'input'], [5,6,'templateGroup'], [6,0, 'pill']
     *
     * @param array $translateMap array with old id's as keys, and new id's as their value
     * @param string $translateIdColumn column name to update. E.g. FormElement.feIdContainer, Ggroup.grId, ...
     * @param string $tableName
557
558
     * @throws CodeException
     * @throws DbException
Carsten  Rose's avatar
Carsten Rose committed
559
     * @throws UserFormException
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
     */
    private function translateId(array $translateMap, $translateIdColumn, $tableName) {

        foreach ($translateMap as $oldId => $newId) {

            $row = $this->db->sql("SELECT $translateIdColumn FROM $tableName WHERE id=$newId", ROW_EXPECT_1);

            if (!empty($row[$translateIdColumn])) {
                $newNewId = $translateMap[$row[$translateIdColumn]];
                $this->db->sql("UPDATE $tableName SET $translateIdColumn=$newNewId WHERE id=$newId LIMIT 1");
            }

        }
    }

Carsten  Rose's avatar
Carsten Rose committed
575
576
577
    /**
     * @param array $rowSrc
     * @param array $rowDest
Carsten  Rose's avatar
Carsten Rose committed
578
     *
Carsten  Rose's avatar
Carsten Rose committed
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
     * @throws UserFormException
     */
    private function checkNCopyFiles(array $rowSrc, array $rowDest) {

        foreach ($rowSrc as $key => $val) {

            // Skip non 'special file column'.
            if (false === strpos($key, COLUMN_PATH_FILE_NAME)) {
                continue;
            }

            // If a/b) the target is empty, c) src & dest is equal, d) src is not a file: there is nothing to copy.
            if (empty($rowDest[$key]) || ($val === $rowDest[$key]) || !is_file($val)) {
                continue;
            }

            Support::mkDirParent($rowDest[$key]);
            if (!copy($val, $rowDest[$key])) {
597
598
599
                throw new UserFormException(
                    json_encode([ERROR_MESSAGE_TO_USER => 'Error copy file', ERROR_MESSAGE_SUPPORT => "Error copy file from [$val] to [" . $rowDest[$key] . "]"]),
                    ERROR_IO_COPY_FILE);
Carsten  Rose's avatar
Carsten Rose committed
600
601
602
603
604
605
606
607
608
609
            }
        }
    }

    /**
     * Copy $row to $destable.
     * Copy only values which have a column in $destTable.
     * If there is nothing to copy - Do nothing.
     * Columns with name 'id', 'modified' or 'created' are skipped.
     *
Carsten  Rose's avatar
Carsten Rose committed
610
     * @param array $row
Carsten  Rose's avatar
Carsten Rose committed
611
     * @param string $destTable
Carsten  Rose's avatar
Carsten Rose committed
612
     *
Carsten  Rose's avatar
Carsten Rose committed
613
614
615
     * @return int - lastInsertId
     * @throws CodeException
     * @throws DbException
Carsten  Rose's avatar
Carsten Rose committed
616
     * @throws UserFormException
Carsten  Rose's avatar
Carsten Rose committed
617
618
619
620
621
622
623
624
625
626
     */
    function copyRecord(array $row, $destTable) {
        $keys = array();
        $values = array();
        $placeholder = array();

        $columns = $this->db->sql("SHOW FIELDS FROM " . $destTable);

        // Process all columns of destTable
        foreach ($columns as $col) {
627

Carsten  Rose's avatar
Carsten Rose committed
628
            $key = $col[COLUMN_FIELD];
Carsten  Rose's avatar
Carsten Rose committed
629
            // Only copy columns which exist on source AND destination.
630
631
632
            if (!isset($row[$key])) {
                continue;
            }
Carsten  Rose's avatar
Carsten Rose committed
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
            $val = $row[$key];

            switch ($key) {
                case COLUMN_ID:
                    continue 2;
                case COLUMN_MODIFIED:
                case COLUMN_CREATED:
                    $keys[] = $key;
                    $placeholder[] = 'NOW()';
                    continue 2;
            }

            if (isset($row[$key])) {
                $keys[] = $key;
                $values[] = $val;
                $placeholder[] = '?';
            }
        }

        // If there is nothing to write: return
        if (count($values) == 0) {
            return (0);
        }

        $keyString = implode(',', $keys);
        $valueString = implode(',', $placeholder);

        $sql = "INSERT INTO $destTable ($keyString) VALUES ($valueString)";

        return $this->db->sql($sql, ROW_REGULAR, $values);

    } # copyRecord()
665
}