Completed
Branch #338-Save_posting_before_movin... (90c6c5)
by Schlaefer
02:36
created

EntriesTable::toggleSolve()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
nc 4
nop 1
dl 0
loc 20
rs 9.6
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * Saito - The Threaded Web Forum
7
 *
8
 * @copyright Copyright (c) the Saito Project Developers
9
 * @link https://github.com/Schlaefer/Saito
10
 * @license http://opensource.org/licenses/MIT
11
 */
12
13
namespace App\Model\Table;
14
15
use App\Lib\Model\Table\AppTable;
16
use App\Model\Entity\Entry;
17
use App\Model\Table\CategoriesTable;
18
use Bookmarks\Model\Table\BookmarksTable;
19
use Cake\Cache\Cache;
20
use Cake\Datasource\EntityInterface;
21
use Cake\Event\Event;
22
use Cake\Http\Exception\NotFoundException;
23
use Cake\ORM\Entity;
24
use Cake\ORM\Query;
25
use Cake\Validation\Validator;
26
use Saito\App\Registry;
27
use Saito\Posting\Posting;
28
use Saito\RememberTrait;
29
use Saito\User\CurrentUser\CurrentUserInterface;
30
use Search\Manager;
31
use Stopwatch\Lib\Stopwatch;
32
33
/**
34
 * Stores postings
35
 *
36
 * Field notes:
37
 * - `edited_by` - Came from mylittleforum. @td Should by migrated to User.id.
38
 * - `name` - Came from mylittleforum. Is still used in fulltext index.
39
 *
40
 * @property BookmarksTable $Bookmarks
41
 * @property CategoriesTable $Categories
42
 * @method array treeBuild(array $postings)
43
 */
44
class EntriesTable extends AppTable
45
{
46
    use RememberTrait;
47
48
    /**
49
     * Fields for search plugin
50
     *
51
     * @var array
52
     */
53
    public $filterArgs = [
54
        'subject' => ['type' => 'like'],
55
        'text' => ['type' => 'like'],
56
        'name' => ['type' => 'like'],
57
        'category' => ['type' => 'value'],
58
    ];
59
60
    /**
61
     * field list necessary for displaying a thread_line
62
     *
63
     * Entry.text determine if Entry is n/t
64
     *
65
     * @var array
66
     */
67
    public $threadLineFieldList = [
68
        'Entries.id',
69
        'Entries.pid',
70
        'Entries.tid',
71
        'Entries.subject',
72
        'Entries.text',
73
        'Entries.time',
74
        'Entries.fixed',
75
        'Entries.last_answer',
76
        'Entries.views',
77
        'Entries.user_id',
78
        'Entries.locked',
79
        'Entries.name',
80
        'Entries.solves',
81
        'Users.username',
82
        'Categories.id',
83
        'Categories.accession',
84
        'Categories.category',
85
        'Categories.description'
86
    ];
87
88
    /**
89
     * fields additional to $threadLineFieldList to show complete entry
90
     *
91
     * @var array
92
     */
93
    public $showEntryFieldListAdditional = [
94
        'Entries.edited',
95
        'Entries.edited_by',
96
        'Entries.ip',
97
        'Entries.category_id',
98
        'Users.id',
99
        'Users.avatar',
100
        'Users.signature',
101
        'Users.user_place'
102
    ];
103
104
    protected $_defaultConfig = [
105
        'subject_maxlength' => 100
106
    ];
107
108
    /**
109
     * {@inheritDoc}
110
     */
111
    public function initialize(array $config)
112
    {
113
        $this->setPrimaryKey('id');
114
115
        $this->addBehavior('IpLogging');
116
        $this->addBehavior('Timestamp');
117
        $this->addBehavior('Tree');
118
119
        $this->addBehavior(
120
            'CounterCache',
121
            [
122
                // cache how many postings a user has
123
                'Users' => ['entry_count'],
124
                // cache how many threads a category has
125
                'Categories' => [
126
                    'thread_count' => function ($event, Entry $entity, $table, $original) {
127
                        if (!$entity->isRoot()) {
128
                            return false;
129
                        }
130
                        // posting is moved to new category…
131
                        if ($original) {
132
                            // update old category (should decrement counter)
133
                            $categoryId = $entity->getOriginal('category_id');
134
                        } else {
135
                            // update new category (increment counter)
136
                            $categoryId = $entity->get('category_id');
137
                        }
138
139
                        $query = $table->find('all', ['conditions' => [
140
                            'pid' => 0, 'category_id' => $categoryId
141
                        ]]);
142
                        $count = $query->count();
143
144
                        return $count;
145
                    }
146
                ]
147
            ]
148
        );
149
150
        $this->belongsTo('Categories', ['foreignKey' => 'category_id']);
151
        $this->belongsTo('Users', ['foreignKey' => 'user_id']);
152
153
        $this->hasMany(
154
            'Bookmarks',
155
            ['foreignKey' => 'entry_id', 'dependent' => true]
156
        );
157
    }
158
159
    /**
160
     * {@inheritDoc}
161
     */
162
    public function validationDefault(Validator $validator)
163
    {
164
        $validator->setProvider(
165
            'saito',
166
            'Saito\Validation\SaitoValidationProvider'
167
        );
168
        $validator
0 ignored issues
show
Deprecated Code introduced by
The method Cake\Validation\Validator::notEmpty() has been deprecated with message: 3.7.0 Use notEmptyString(), notEmptyArray(), notEmptyFile(), notEmptyDate(), notEmptyTime() or notEmptyDateTime() instead.

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
169
            //= category_id
170
            ->notEmpty('category_id')
171
            ->add(
172
                'category_id',
173
                [
174
                    'numeric' => ['rule' => 'numeric'],
175
                    'assoc' => [
176
                        'rule' => ['validateAssoc', 'Categories'],
177
                        'last' => true,
178
                        'provider' => 'saito'
179
                    ]
180
                ]
181
            )
182
            //= subject
183
            ->notEmpty('subject', __d('validation', 'entries.subject.notEmpty'))
184
            ->add(
185
                'subject',
186
                [
187
                    'maxLength' => [
188
                        'rule' => [$this, 'validateSubjectMaxLength'],
189
                        'message' => __d(
190
                            'validation',
191
                            'entries.subject.maxlength'
192
                        )
193
                    ]
194
                ]
195
            )
196
            //= user_id
197
            ->add('user_id', ['numeric' => ['rule' => 'numeric']])
198
            //= views
199
            ->add(
200
                'views',
201
                ['comparison' => ['rule' => ['comparison', '>=', 0]]]
202
            );
203
204
        return $validator;
205
    }
206
207
    /**
208
     * Advanced search configuration from SaitoSearch plugin
209
     *
210
     * @see https://github.com/FriendsOfCake/search
211
     *
212
     * @return Manager
213
     */
214
    public function searchManager(): Manager
215
    {
216
        /** @var Manager $searchManager */
217
        $searchManager = $this->getBehavior('Search')->searchManager();
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class Cake\ORM\Behavior as the method searchManager() does only exist in the following sub-classes of Cake\ORM\Behavior: Search\Model\Behavior\SearchBehavior. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
218
        $searchManager
219
        ->like('subject', [
220
            'before' => true,
221
            'after' => true,
222
            'fieldMode' => 'OR',
223
            'comparison' => 'LIKE',
224
            'wildcardAny' => '*',
225
            'wildcardOne' => '?',
226
            'field' => ['subject'],
227
            'filterEmpty' => true,
228
        ])
229
        ->like('text', [
230
            'before' => true,
231
            'after' => true,
232
            'fieldMode' => 'OR',
233
            'comparison' => 'LIKE',
234
            'wildcardAny' => '*',
235
            'wildcardOne' => '?',
236
            'field' => ['text'],
237
            'filterEmpty' => true,
238
        ])
239
        ->value('name', ['filterEmpty' => true]);
240
241
        return $searchManager;
242
    }
243
244
    /**
245
     * Get recent postings
246
     *
247
     * ### Options:
248
     *
249
     * - `user_id` int|<null> If provided finds only postings of that user.
250
     * - `limit` int <10> Number of postings to find.
251
     *
252
     * @param CurrentUserInterface $User User who has access to postings
253
     * @param array $options find options
254
     *
255
     * @return array Array of Postings
256
     */
257
    public function getRecentEntries(
258
        CurrentUserInterface $User,
259
        array $options = []
260
    ) {
261
        Stopwatch::start('Model->User->getRecentEntries()');
262
263
        $options += [
264
            'user_id' => null,
265
            'limit' => 10,
266
        ];
267
268
        $options['category_id'] = $User->getCategories()->getAll('read');
269
270
        $read = function () use ($options) {
271
            $conditions = [];
272
            if ($options['user_id'] !== null) {
273
                $conditions[]['Entries.user_id'] = $options['user_id'];
274
            }
275
            if ($options['category_id'] !== null) {
276
                $conditions[]['Entries.category_id IN'] = $options['category_id'];
277
            };
278
279
            $result = $this
280
                ->find(
281
                    'all',
282
                    [
283
                        'contain' => ['Users', 'Categories'],
284
                        'fields' => $this->threadLineFieldList,
285
                        'conditions' => $conditions,
286
                        'limit' => $options['limit'],
287
                        'order' => ['time' => 'DESC']
288
                    ]
289
                )
290
                // hydrating kills performance
291
                ->enableHydration(false)
292
                ->all();
293
294
            return $result;
295
        };
296
297
        $key = 'Entry.recentEntries-' . md5(serialize($options));
298
        $results = Cache::remember($key, $read, 'entries');
299
300
        $threads = [];
301
        foreach ($results as $result) {
302
            $threads[$result['id']] = Registry::newInstance(
303
                '\Saito\Posting\Posting',
304
                ['rawData' => $result]
305
            );
306
        }
307
308
        Stopwatch::stop('Model->User->getRecentEntries()');
309
310
        return $threads;
311
    }
312
313
    /**
314
     * Finds the thread-id for a posting
315
     *
316
     * @param int $id Posting-Id
317
     * @return int Thread-Id
318
     * @throws \UnexpectedValueException
319
     */
320
    public function getThreadId($id)
321
    {
322
        $entry = $this->find(
323
            'all',
324
            ['conditions' => ['id' => $id], 'fields' => 'tid']
325
        )->first();
326
        if (empty($entry)) {
327
            throw new \UnexpectedValueException(
328
                'Posting not found. Posting-Id: ' . $id
329
            );
330
        }
331
332
        return $entry->get('tid');
333
    }
334
335
    /**
336
     * Shorthand for reading an entry with full data
337
     *
338
     * @param int $primaryKey key
339
     * @param array $options options
340
     * @return mixed Posting if found false otherwise
341
     */
342
    public function get($primaryKey, $options = [])
343
    {
344
        $options += ['return' => 'Posting'];
345
        $return = $options['return'];
346
        unset($options['return']);
347
348
        /** @var Entry */
349
        $result = $this->find('entry')
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->find('entry')->wh...$primaryKey))->first(); of type Cake\Datasource\EntityInterface|array|null adds the type array to the return on line 362 which is incompatible with the return type declared by the interface Cake\Datasource\RepositoryInterface::get of type Cake\Datasource\EntityInterface.
Loading history...
350
            ->where([$this->getAlias() . '.id' => $primaryKey])
351
            ->first();
352
353
        if (!$result) {
354
            return false;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return false; (false) is incompatible with the return type declared by the interface Cake\Datasource\RepositoryInterface::get of type Cake\Datasource\EntityInterface.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
355
        }
356
357
        switch ($return) {
358
            case 'Posting':
359
                return $result->toPosting();
360
            case 'Entity':
361
            default:
362
                return $result;
363
        }
364
    }
365
366
    /**
367
     * get parent id
368
     *
369
     * @param int $id id
370
     * @return mixed
371
     * @throws \UnexpectedValueException
372
     */
373
    public function getParentId($id)
374
    {
375
        $entry = $this->find()->select('pid')->where(['id' => $id])->first();
376
        if (!$entry) {
377
            throw new \UnexpectedValueException(
378
                'Posting not found. Posting-Id: ' . $id
379
            );
380
        }
381
382
        return $entry->get('pid');
383
    }
384
385
    /**
386
     * creates a new root or child entry for a node
387
     *
388
     * fields in $data are filtered
389
     *
390
     * @param array $data data
391
     * @return EntityInterface|null on success, null otherwise
392
     */
393
    public function createPosting(array $data): ?EntityInterface
394
    {
395
        $data['time'] = bDate();
396
        $data['last_answer'] = bDate();
397
398
        $this->getValidator()->requirePresence('category_id');
399
        $this->getValidator()->requirePresence('subject');
400
        $this->getValidator()->notEmptyString('subject');
401
402
        $posting = $this->newEntity($data);
403
        $errors = $posting->getErrors();
404
        if (!empty($errors)) {
405
            return $posting;
406
        }
407
408
        $newPostingEntity = $this->save($posting);
409
        if (!$newPostingEntity) {
410
            return null;
411
        }
412
413
        $newPostingId = $newPostingEntity->get('id');
414
        $newPosting = $this->get($newPostingId);
415
416
        if ($newPosting->isRoot()) {
417
            /// posting started a new thread, so set thread-ID to posting's own ID
418
            $newPosting = $this->patchEntity(
419
                $newPostingEntity,
420
                [
421
                    /// currently only added to satisfy the validators added above
422
                    'category_id' => $data['category_id'],
423
                    'subject' => $data['subject'],
424
                    /// actual payload
425
                    'tid' => $newPostingId,
426
                ]
427
            );
428
            if (!$this->save($newPosting)) {
429
                return null;
430
            }
431
            $this->_dispatchEvent(
432
                'Model.Thread.create',
433
                [
434
                    'subject' => $newPostingId,
435
                    'data' => $newPosting
436
                ]
437
            );
438
        } else {
439
            // update last answer time of root entry
440
            // @td rise error and/or roll back on failure
441
            $this->updateAll(
442
                ['last_answer' => $newPosting->get('last_answer')],
443
                ['id' => $newPosting->get('tid')]
444
            );
445
446
            $this->_dispatchEvent(
447
                'Model.Entry.replyToEntry',
448
                [
449
                    'subject' => $newPosting->get('pid'),
450
                    'data' => $newPosting
451
                ]
452
            );
453
            $this->_dispatchEvent(
454
                'Model.Entry.replyToThread',
455
                [
456
                    'subject' => $newPosting->get('tid'),
457
                    'data' => $newPosting
458
                ]
459
            );
460
        }
461
462
        return $newPostingEntity;
463
    }
464
465
    /**
466
     * Updates a posting
467
     *
468
     * fields in $data are filtered except for $id!
469
     *
470
     * @param Entry $posting Entity
471
     * @param array $data data
472
     * @return array|mixed
473
     * @throws \InvalidArgumentException
474
     * @throws NotFoundException
475
     */
476
    public function update(Entry $posting, array $data)
477
    {
478
        $data['id'] = $posting->get('id');
479
480
        // prevents normal user of changing category of complete thread when answering
481
        // @td this should be refactored together with the change category handling in beforeSave()
482
        if (!$posting->isRoot()) {
483
            unset($data['category_id']);
484
        }
485
486
        $data['edited'] = bDate();
487
488
        // add editing validator
489
        $data['time'] = $posting->get('time');
490
        $data['user_id'] = $posting->get('user_id');
491
        $data['locked'] = $posting->get('locked');
492
        $this->getValidator()->add(
493
            'edited_by',
494
            'isEditingAllowed',
495
            ['rule' => [$this, 'validateEditingAllowed']]
496
        );
497
498
        $this->patchEntity($posting, $data);
499
        $result = $this->save($posting);
500
501
        if ($result) {
502
            $this->_dispatchEvent(
503
                'Model.Entry.update',
504
                [
505
                    'subject' => $posting->get('id'),
506
                    'data' => $posting
507
                ]
508
            );
509
        }
510
511
        return $result;
512
    }
513
514
    /**
515
     * tree of a single node and its subentries
516
     *
517
     * $options = array(
518
     *    'root' => true // performance improvements if it's a known thread-root
519
     * );
520
     *
521
     * @param int $id id
522
     * @param array $options options
523
     * @return Posting|null tree or null if nothing found
524
     */
525
    public function treeForNode(int $id, ?array $options = []): ?Posting
526
    {
527
        $options += [
528
            'root' => false,
529
            'complete' => false
530
        ];
531
532
        if ($options['root']) {
533
            $tid = $id;
534
        } else {
535
            $tid = $this->getThreadId($id);
536
        }
537
538
        $fields = null;
539
        if ($options['complete']) {
540
            $fields = array_merge(
541
                $this->threadLineFieldList,
542
                $this->showEntryFieldListAdditional
543
            );
544
        }
545
546
        $tree = $this->treesForThreads([$tid], null, $fields);
547
548
        if (!$tree) {
549
            return null;
550
        }
551
552
        $tree = reset($tree);
553
554
        //= extract subtree
555
        if ((int)$tid !== (int)$id) {
556
            $tree = $tree->getThread()->get($id);
557
        }
558
559
        return $tree;
560
    }
561
562
    /**
563
     * trees for multiple tids
564
     *
565
     * @param array $ids ids
566
     * @param array $order order
567
     * @param array $fieldlist fieldlist
568
     * @return array|null array of Postings, null if nothing found
569
     */
570
    public function treesForThreads(array $ids, ?array $order = null, array $fieldlist = null): ?array
571
    {
572
        if (empty($ids)) {
573
            return [];
574
        }
575
576
        if (empty($order)) {
577
            $order = ['last_answer' => 'ASC'];
578
        }
579
580
        if ($fieldlist === null) {
581
            $fieldlist = $this->threadLineFieldList;
582
        }
583
584
        Stopwatch::start('EntriesTable::treesForThreads() DB');
585
        $postings = $this->_getThreadEntries(
586
            $ids,
587
            ['order' => $order, 'fields' => $fieldlist]
588
        );
589
        Stopwatch::stop('EntriesTable::treesForThreads() DB');
590
591
        if (!$postings->count()) {
592
            return null;
593
        }
594
595
        Stopwatch::start('EntriesTable::treesForThreads() CPU');
596
        $threads = [];
597
        $postings = $this->treeBuild($postings);
0 ignored issues
show
Documentation introduced by
$postings is of type object<Cake\ORM\Query>, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
598
        foreach ($postings as $thread) {
599
            $id = $thread['tid'];
600
            $threads[$id] = $thread;
601
            $threads[$id] = Registry::newInstance(
602
                '\Saito\Posting\Posting',
603
                ['rawData' => $thread]
604
            );
605
        }
606
        Stopwatch::stop('EntriesTable::treesForThreads() CPU');
607
608
        return $threads;
609
    }
610
611
    /**
612
     * Returns all entries of threads $tid
613
     *
614
     * @param array $tid ids
615
     * @param array $params params
616
     * - 'fields' array of thread-ids: [1, 2, 5]
617
     * - 'order' sort order for threads ['time' => 'ASC'],
618
     * @return mixed unhydrated result set
619
     */
620
    protected function _getThreadEntries(array $tid, array $params = [])
621
    {
622
        $params += [
623
            'fields' => $this->threadLineFieldList,
624
            'order' => ['last_answer' => 'ASC']
625
        ];
626
627
        $threads = $this
628
            ->find(
629
                'all',
630
                [
631
                    'conditions' => ['tid IN' => $tid],
632
                    'contain' => ['Users', 'Categories'],
633
                    'fields' => $params['fields'],
634
                    'order' => $params['order']
635
                ]
636
            )
637
            // hydrating kills performance
638
            ->enableHydration(false);
639
640
        return $threads;
641
    }
642
643
    /**
644
     * Marks a sub-entry as solution to a root entry
645
     *
646
     * @param Entry $posting
647
     * @return bool
648
     */
649
    public function toggleSolve(Entry $posting)
650
    {
651
        if ($posting->get('solves')) {
652
            $value = 0;
653
        } else {
654
            $value = $posting->get('tid');
655
        }
656
657
        $this->patchEntity($posting, ['solves' => $value]);
658
        if (!$this->save($posting)) {
659
            return false;
660
        }
661
662
        $this->_dispatchEvent(
663
            'Model.Entry.update',
664
            ['subject' => $posting->get('id'), 'data' => $posting]
665
        );
666
667
        return true;
668
    }
669
670
    /**
671
     * {@inheritDoc}
672
     */
673
    public function toggle($id, $key)
674
    {
675
        $result = parent::toggle($id, $key);
676
        if ($key === 'locked') {
677
            $this->_threadLock($id, $result);
0 ignored issues
show
Documentation introduced by
$result is of type integer, but the function expects a boolean.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
678
        }
679
680
        $entry = $this->get($id);
681
        $this->_dispatchEvent(
682
            'Model.Entry.update',
683
            [
684
                'subject' => $entry->get('id'),
685
                'data' => $entry
686
            ]
687
        );
688
689
        return $result;
690
    }
691
692
    /**
693
     * {@inheritDoc}
694
     */
695
    public function beforeValidate(
696
        Event $event,
1 ignored issue
show
Unused Code introduced by
The parameter $event is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
697
        Entity $entity,
698
        \ArrayObject $options,
1 ignored issue
show
Unused Code introduced by
The parameter $options is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
699
        Validator $validator
0 ignored issues
show
Unused Code introduced by
The parameter $validator is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
700
    ) {
701
        //= in n/t posting delete unnecessary body text
702
        if ($entity->isDirty('text')) {
703
            $entity->set('text', rtrim($entity->get('text')));
704
        }
705
    }
706
707
    /**
708
     * Deletes posting incl. all its subposting and associated data
709
     *
710
     * @param int $id id
711
     * @throws \InvalidArgumentException
712
     * @throws \Exception
713
     * @return bool
714
     */
715
    public function treeDeleteNode($id)
716
    {
717
        $root = $this->treeForNode((int)$id);
718
719
        if (empty($root)) {
720
            throw new \Exception;
721
        }
722
723
        $nodesToDelete[] = $root;
0 ignored issues
show
Coding Style Comprehensibility introduced by
$nodesToDelete was never initialized. Although not strictly required by PHP, it is generally a good practice to add $nodesToDelete = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
724
        $nodesToDelete = array_merge($nodesToDelete, $root->getAllChildren());
725
726
        $idsToDelete = [];
727
        foreach ($nodesToDelete as $node) {
728
            $idsToDelete[] = $node->get('id');
729
        };
730
731
        $success = $this->deleteAll(['id IN' => $idsToDelete]);
732
733
        if (!$success) {
734
            return false;
735
        }
736
737
        $this->Bookmarks->deleteAll(['entry_id IN' => $idsToDelete]);
738
739
        $this->dispatchSaitoEvent(
740
            'Model.Saito.Posting.delete',
741
            ['subject' => $root, 'table' => $this]
742
        );
743
744
        return true;
745
    }
746
747
    /**
748
     * Anonymizes the entries for a user
749
     *
750
     * @param int $userId user-ID
751
     * @return void
752
     */
753
    public function anonymizeEntriesFromUser(int $userId): void
754
    {
755
        // remove username from all entries and reassign to anonyme user
756
        $success = (bool)$this->updateAll(
757
            [
758
                'edited_by' => null,
759
                'ip' => null,
760
                'name' => null,
761
                'user_id' => 0,
762
            ],
763
            ['user_id' => $userId]
764
        );
765
766
        if ($success) {
767
            $this->_dispatchEvent('Cmd.Cache.clear', ['cache' => 'Thread']);
768
        }
769
    }
770
771
    /**
772
     * Merge thread on to entry $targetId
773
     *
774
     * @param int $sourceId root-id of the posting that is merged onto another
775
     *     thread
776
     * @param int $targetId id of the posting the source-thread should be
777
     *     appended to
778
     * @return bool true if merge was successfull false otherwise
779
     */
780
    public function threadMerge($sourceId, $targetId)
781
    {
782
        $sourcePosting = $this->get($sourceId, ['return' => 'Entity']);
783
784
        // check that source is thread-root and not an subposting
785
        if (!$sourcePosting->isRoot()) {
786
            return false;
787
        }
788
789
        $targetPosting = $this->get($targetId);
790
791
        // check that target exists
792
        if (!$targetPosting) {
793
            return false;
794
        }
795
796
        // check that a thread is not merged onto itself
797
        if ($targetPosting->get('tid') === $sourcePosting->get('tid')) {
798
            return false;
799
        }
800
801
        // set target entry as new parent entry
802
        $this->patchEntity(
803
            $sourcePosting,
804
            ['pid' => $targetPosting->get('id')]
805
        );
806
        if ($this->save($sourcePosting)) {
807
            // associate all entries in source thread to target thread
808
            $this->updateAll(
809
                ['tid' => $targetPosting->get('tid')],
810
                ['tid' => $sourcePosting->get('tid')]
811
            );
812
813
            // appended source entries get category of target thread
814
            $this->_threadChangeCategory(
815
                $targetPosting->get('tid'),
816
                $targetPosting->get('category_id')
817
            );
818
819
            // update target thread last answer if source is newer
820
            $sourceLastAnswer = $sourcePosting->get('last_answer');
821
            $targetLastAnswer = $targetPosting->get('last_answer');
822
            if ($sourceLastAnswer->gt($targetLastAnswer)) {
823
                $targetRoot = $this->get(
824
                    $targetPosting->get('tid'),
825
                    ['return' => 'Entity']
826
                );
827
                $targetRoot = $this->patchEntity(
828
                    $targetRoot,
829
                    ['last_answer' => $sourceLastAnswer]
830
                );
831
                $this->save($targetRoot);
832
            }
833
834
            // propagate pinned property from target to source
835
            $isTargetPinned = $targetPosting->isLocked();
836
            $isSourcePinned = $sourcePosting->isLocked();
837
            if ($isSourcePinned !== $isTargetPinned) {
838
                $this->_threadLock($targetPosting->get('tid'), $isTargetPinned);
839
            }
840
841
            $this->_dispatchEvent(
842
                'Model.Thread.change',
843
                ['subject' => $targetPosting->get('tid')]
844
            );
845
846
            return true;
847
        }
848
849
        return false;
850
    }
851
852
    /**
853
     * Check if posting is thread-root.
854
     *
855
     * @param array $id posting-ID or posting data
856
     * @return mixed
857
     */
858
    protected function _isRoot(array $id)
859
    {
860
        if (isset($id['pid'])) {
861
            $pid = $id['pid'];
862
        } else {
863
            // @bogus (known code-path: entries/preview)
864
            if (is_array($id) && isset($id['id'])) {
865
                $id = $id['id'];
866
            } elseif (empty($id)) {
867
                throw new \InvalidArgumentException();
868
            }
869
            try {
870
                $pid = $this->getParentId($id);
871
            } catch (\Throwable $t) {
872
                $pid = null;
873
            }
874
        }
875
876
        return empty($pid);
877
    }
878
879
    /**
880
     * Implements the custom find type 'entry'
881
     *
882
     * @param Query $query query
883
     * @return Query
884
     */
885
    public function findEntry(Query $query)
886
    {
887
        $fields = array_merge(
888
            $this->threadLineFieldList,
889
            $this->showEntryFieldListAdditional
890
        );
891
        $query->select($fields)->contain(['Users', 'Categories']);
892
893
        return $query;
894
    }
895
896
    /**
897
     * Implements the custom find type 'index paginator'
898
     *
899
     * @param Query $query query
900
     * @param array $options finder options
901
     * @return Query
902
     */
903
    public function findIndexPaginator(Query $query, array $options)
904
    {
905
        $query
906
            ->select(['id', 'pid', 'tid', 'time', 'last_answer', 'fixed'])
907
            ->where(['Entries.pid' => 0]);
908
909
        if (!empty($options['counter'])) {
910
            $query->counter($options['counter']);
911
        }
912
913
        return $query;
914
    }
915
916
    /**
917
     * Un-/Locks thread: sets posting in thread $tid to $locked
918
     *
919
     * @param int $tid thread-ID
920
     * @param bool $locked flag
921
     * @return void
922
     */
923
    protected function _threadLock($tid, $locked)
924
    {
925
        $this->updateAll(['locked' => $locked], ['tid' => $tid]);
926
    }
927
928
    /**
929
     * {@inheritDoc}
930
     */
931
    public function beforeSave(Event $event, Entity $entity)
932
    {
933
        $success = true;
934
935
        //= change category of thread if category of root entry changed
936
        if ($entity->isDirty('category_id')) {
937
            /** @var Entry */
938
            $oldEntry = $this->find()
939
                ->select(['pid', 'tid', 'category_id'])
940
                ->where(['id' => $entity->get('id')])
941
                ->first();
942
943
            if ($oldEntry && $oldEntry->isRoot()) {
944
                $newCateogry = $entity->get('category_id');
945
                $oldCategory = $oldEntry->get('category_id');
946
                if ($newCateogry !== $oldCategory) {
947
                    $success = $success && $this
948
                            ->_threadChangeCategory(
949
                                $oldEntry->get('tid'),
950
                                $entity->get('category_id')
951
                            );
952
                }
953
            }
954
        }
955
956
        if (!$success) {
957
            $event->stopPropagation();
958
        }
959
    }
960
961
    /**
962
     * check editing allowed
963
     *
964
     * @param mixed $check value
965
     * @param array $context context
966
     * @return bool|void
967
     */
968
    public function validateEditingAllowed($check, $context)
969
    {
970
        /* @var \Saito\Posting\Posting $Posting */
971
        $Posting = Registry::newInstance(
972
            '\Saito\Posting\Posting',
973
            ['rawData' => $context['data']]
974
        );
975
        $forbidden = $Posting->isEditingAsCurrentUserForbidden();
976
977
        return $forbidden === false;
978
    }
979
980
    /**
981
     * check subject max length
982
     *
983
     * @param mixed $subject subject
984
     * @return bool
985
     */
986
    public function validateSubjectMaxLength($subject)
987
    {
988
        return mb_strlen($subject) <= $this->getConfig('subject_maxlength');
989
    }
990
991
    /**
992
     * Changes the category of a thread.
993
     *
994
     * Assigns the new category-id to all postings in that thread.
995
     *
996
     * @param int $tid thread-ID
997
     * @param int $newCategoryId id for new category
998
     * @return bool success
999
     * @throws NotFoundException
1000
     */
1001
    protected function _threadChangeCategory(int $tid, int $newCategoryId): bool
1002
    {
1003
        $exists = $this->Categories->exists($newCategoryId);
1004
        if (!$exists) {
1005
            throw new NotFoundException();
1006
        }
1007
        $affected = $this->updateAll(
1008
            ['category_id' => $newCategoryId],
1009
            ['tid' => $tid]
1010
        );
1011
1012
        return $affected > 0;
1013
    }
1014
}
1015