Passed
Push — develop ( 2ed109...a94368 )
by Schlaefer
05:00
created

EntriesTable::toggleSolve()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 19
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 11
nc 4
nop 1
dl 0
loc 19
rs 9.9
c 1
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 App\Model\Table\DraftsTable;
19
use Bookmarks\Model\Table\BookmarksTable;
20
use Cake\Datasource\Exception\RecordNotFoundException;
21
use Cake\Event\Event;
22
use Cake\ORM\Entity;
23
use Cake\ORM\Query;
24
use Cake\ORM\RulesChecker;
25
use Cake\Validation\Validator;
26
use Saito\Posting\PostingInterface;
27
use Saito\User\CurrentUser\CurrentUserInterface;
28
use Saito\Validation\SaitoValidationProvider;
29
use Search\Manager;
30
31
/**
32
 * Stores postings
33
 *
34
 * Field notes:
35
 * - `edited_by` - Came from mylittleforum. @td Should by migrated to User.id.
36
 * - `name` - Came from mylittleforum. Is still used in fulltext index.
37
 *
38
 * @property BookmarksTable $Bookmarks
39
 * @property CategoriesTable $Categories
40
 * @property DraftsTable $Drafts
41
 * @method array treeBuild(array $postings)
42
 * @method createPosting(array $data, CurrentUserInterface $CurrentUser)
43
 * @method updatePosting(Entry $posting, array $data, CurrentUserInterface $CurrentUser)
44
 * @method array prepareChildPosting(BasicPostingInterface $parent, array $data)
45
 * @method array getRecentPostings(CurrentUserInterface $CU, ?array $options = [])
46
 * @method bool deletePosting(int $id)
47
 * @method array postingsForThreads(array $tids, ?array $order = null, ?CurrentUserInterface $CU)
48
 * @method PostingInterface postingsForThread(int $tid, ?bool $complete = false, ?CurrentUserInterface $CU)
49
 * @method threadMerge(int $sourceId, int $targetId)
50
 */
51
class EntriesTable extends AppTable
52
{
53
    /**
54
     * Max subject length.
55
     *
56
     * Constrained to 191 due to InnoDB index max-length on MySQL 5.6.
57
     */
58
    public const SUBJECT_MAXLENGTH = 191;
59
60
    /**
61
     * Fields for search plugin
62
     *
63
     * @var array
64
     */
65
    public $filterArgs = [
66
        'subject' => ['type' => 'like'],
67
        'text' => ['type' => 'like'],
68
        'name' => ['type' => 'like'],
69
        'category' => ['type' => 'value'],
70
    ];
71
72
    protected $_defaultConfig = [
73
        'subject_maxlength' => 100
74
    ];
75
76
    /**
77
     * {@inheritDoc}
78
     */
79
    public function initialize(array $config)
80
    {
81
        $this->setPrimaryKey('id');
82
83
        $this->addBehavior('Posting');
84
        $this->addBehavior('IpLogging');
85
        $this->addBehavior('Timestamp');
86
87
        $this->addBehavior(
88
            'CounterCache',
89
            [
90
                // cache how many postings a user has
91
                'Users' => ['entry_count'],
92
                // cache how many threads a category has
93
                'Categories' => [
94
                    'thread_count' => function ($event, Entry $entity, $table, $original) {
95
                        if (!$entity->isRoot()) {
96
                            return false;
97
                        }
98
                        // posting is moved to new category…
99
                        if ($original) {
100
                            // update old category (should decrement counter)
101
                            $categoryId = $entity->getOriginal('category_id');
102
                        } else {
103
                            // update new category (increment counter)
104
                            $categoryId = $entity->get('category_id');
105
                        }
106
107
                        $query = $table->find('all', ['conditions' => [
108
                            'pid' => 0, 'category_id' => $categoryId
109
                        ]]);
110
                        $count = $query->count();
111
112
                        return $count;
113
                    }
114
                ]
115
            ]
116
        );
117
118
        $this->belongsTo('Categories', ['foreignKey' => 'category_id']);
119
        $this->belongsTo('Users', ['foreignKey' => 'user_id']);
120
121
        $this->hasMany(
122
            'Bookmarks',
123
            ['foreignKey' => 'entry_id', 'dependent' => true]
124
        );
125
126
        // Releation never queried. Just for quick access to the table.
127
        $this->hasOne('Drafts');
128
    }
129
130
    /**
131
     * {@inheritDoc}
132
     */
133
    public function validationDefault(Validator $validator)
134
    {
135
        $validator->setProvider('saito', SaitoValidationProvider::class);
136
137
        /// category_id
138
        $categoryRequiredL10N = __('vld.entries.categories.notEmpty');
139
        $validator
0 ignored issues
show
Deprecated Code introduced by
The function Cake\Validation\Validator::notEmpty() has been deprecated: 3.7.0 Use notEmptyString(), notEmptyArray(), notEmptyFile(), notEmptyDate(), notEmptyTime() or notEmptyDateTime() instead. ( Ignorable by Annotation )

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

139
        /** @scrutinizer ignore-deprecated */ $validator

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

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

Loading history...
140
            ->notEmpty('category_id', $categoryRequiredL10N)
141
            ->requirePresence('category_id', 'create', $categoryRequiredL10N);
142
143
        /// last_answer
144
        $validator
145
            ->requirePresence('last_answer', 'create')
146
            ->notEmptyDateTime('last_answer', null, 'create');
147
148
        /// name
149
        $validator
150
            ->requirePresence('name', 'create')
151
            ->notEmptyString('name', null, 'create');
152
153
        /// pid
154
        $validator->requirePresence('pid', 'create');
155
156
        /// subject
157
        $subjectRequiredL10N = __('vld.entries.subject.notEmpty');
158
        $validator
159
            ->notEmptyString('subject', $subjectRequiredL10N)
160
            ->requirePresence('subject', 'create', $subjectRequiredL10N)
161
            ->add(
162
                'subject',
163
                [
164
                    'maxLength' => [
165
                        'rule' => ['maxLength', $this->getConfig('subject_maxlength')],
166
                        'message' => __('vld.entries.subject.maxlength')
167
                    ]
168
                ]
169
            );
170
171
        /// time
172
        $validator
173
            ->requirePresence('time', 'create')
174
            ->notEmptyDateTime('time', null, 'create');
175
176
        /// user_id
177
        $validator
178
            ->requirePresence('user_id', 'create')
179
            ->add('user_id', ['numeric' => ['rule' => 'numeric']]);
180
181
        /// views
182
        $validator->add(
183
            'views',
184
            ['comparison' => ['rule' => ['comparison', '>=', 0]]]
185
        );
186
187
        return $validator;
188
    }
189
190
    /**
191
     * {@inheritDoc}
192
     */
193
    public function buildRules(RulesChecker $rules)
194
    {
195
        $rules = parent::buildRules($rules);
196
197
        $rules->add(
198
            function ($entity) {
199
                if (!$entity->isDirty('solves') || empty($entity->get('solves') > 0)) {
200
                    return true;
201
                }
202
203
                return !$entity->isRoot();
204
            },
205
            'checkSolvesOnlyOnAnswers',
206
            [
207
                'errorField' => 'solves',
208
                'message' => 'Root postings cannot mark themself solved.',
209
            ]
210
        );
211
212
        $rules->add(
213
            function ($entity) {
214
                if (!$entity->isDirty('solves') || empty($entity->get('solves') > 0)) {
215
                    return true;
216
                }
217
218
                return !$entity->isRoot();
219
            },
220
            'checkSolvesOnlyOnAnswers',
221
            [
222
                'errorField' => 'solves',
223
                'message' => 'Root postings cannot mark themself solved.',
224
            ]
225
        );
226
227
        return $rules;
228
    }
229
230
    /**
231
     * {@inheritDoc}
232
     */
233
    public function afterSave(Event $event, Entity $entity, \ArrayObject $options)
0 ignored issues
show
Unused Code introduced by
The parameter $options is not used and could be removed. ( Ignorable by Annotation )

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

233
    public function afterSave(Event $event, Entity $entity, /** @scrutinizer ignore-unused */ \ArrayObject $options)

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

Loading history...
234
    {
235
        if ($entity->isNew()) {
236
            $this->Drafts->deleteDraftForPosting($entity);
237
238
            /** @var Entry */
239
            $posting = $this->get($entity->get('id'));
240
            if ($posting->isRoot()) {
0 ignored issues
show
Bug introduced by
The method isRoot() does not exist on Cake\Datasource\EntityInterface. It seems like you code against a sub-type of Cake\Datasource\EntityInterface such as App\Model\Entity\Entry. ( Ignorable by Annotation )

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

240
            if ($posting->/** @scrutinizer ignore-call */ isRoot()) {
Loading history...
241
                /// New thread: set thread-ID to posting's own ID.
242
                $patched = $this->patchEntity($posting, ['tid' => $entity->get('id')]);
243
                if (!$this->save($patched)) {
244
                    $event->stopPropagation();
245
                }
246
                // Set it in the entity returned by the the save
247
                $entity->set('tid', $entity->get('id'));
248
            } else {
249
                /// New answer: update last answer time of root entry
250
                // @td Is this really necessary?
251
                $this->updateAll(
252
                    ['last_answer' => $posting->get('last_answer')],
253
                    ['id' => $posting->get('tid')]
254
                );
255
            }
256
        }
257
    }
258
259
    /**
260
     * Advanced search configuration from SaitoSearch plugin
261
     *
262
     * @see https://github.com/FriendsOfCake/search
263
     *
264
     * @return Manager
265
     */
266
    public function searchManager(): Manager
267
    {
268
        /** @var Manager $searchManager */
269
        $searchManager = $this->getBehavior('Search')->searchManager();
0 ignored issues
show
Bug introduced by
The method searchManager() does not exist on Cake\ORM\Behavior. It seems like you code against a sub-type of Cake\ORM\Behavior such as Search\Model\Behavior\SearchBehavior. ( Ignorable by Annotation )

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

269
        $searchManager = $this->getBehavior('Search')->/** @scrutinizer ignore-call */ searchManager();
Loading history...
270
        $searchManager
271
        ->like('subject', [
272
            'before' => true,
273
            'after' => true,
274
            'fieldMode' => 'OR',
275
            'comparison' => 'LIKE',
276
            'wildcardAny' => '*',
277
            'wildcardOne' => '?',
278
            'field' => ['subject'],
279
            'filterEmpty' => true,
280
        ])
281
        ->like('text', [
282
            'before' => true,
283
            'after' => true,
284
            'fieldMode' => 'OR',
285
            'comparison' => 'LIKE',
286
            'wildcardAny' => '*',
287
            'wildcardOne' => '?',
288
            'field' => ['text'],
289
            'filterEmpty' => true,
290
        ])
291
        ->value('name', ['filterEmpty' => true]);
292
293
        return $searchManager;
294
    }
295
296
    /**
297
     * Shorthand for reading an entry with full da516ta
298
     *
299
     * @param int $primaryKey key
300
     * @param array $options options
301
     * @return mixed Posting if found false otherwise
302
     */
303
    public function get($primaryKey, $options = [])
304
    {
305
        /** @var Entry */
306
        $result = $this->find('entry', ['complete' => true])
307
            ->where([$this->getAlias() . '.id' => $primaryKey])
308
            ->first();
309
310
        // @td throw exception here
311
        return empty($result) ? false : $result;
0 ignored issues
show
Bug Best Practice introduced by
The expression return empty($result) ? false : $result also could return the type array|false which is incompatible with the return type mandated by Cake\Datasource\RepositoryInterface::get() of Cake\Datasource\EntityInterface.
Loading history...
312
    }
313
314
    /**
315
     * Implements the custom find type 'entry'
316
     *
317
     * @param Query $query query
318
     * @param array $options options
319
     * - 'complete' bool controls fieldset selected as in getFieldset($complete)
320
     * @return Query
321
     */
322
    public function findEntry(Query $query, array $options = [])
323
    {
324
        $options += ['complete' => false];
325
        $query
326
            ->select($this->getFieldset($options['complete']))
327
            ->contain(['Users', 'Categories']);
328
329
        return $query;
330
    }
331
332
    /**
333
     * Get list of fields required to display posting.:w
334
     *
335
     * You don't want to fetch every field for performance reasons.
336
     *
337
     * @param bool $complete Threadline if false; Full posting if true
338
     * @return array The fieldset
339
     */
340
    public function getFieldset(bool $complete = false): array
341
    {
342
        // field list necessary for displaying a thread_line
343
        $threadLineFieldList = [
344
            'Categories.accession',
345
            'Categories.category',
346
            'Categories.description',
347
            'Categories.id',
348
            'Entries.fixed',
349
            'Entries.id',
350
            'Entries.last_answer',
351
            'Entries.locked',
352
            'Entries.name',
353
            'Entries.pid',
354
            'Entries.solves',
355
            'Entries.subject',
356
            // Entry.text determines if Entry is n/t
357
            'Entries.text',
358
            'Entries.tid',
359
            'Entries.time',
360
            'Entries.user_id',
361
            'Entries.views',
362
            'Users.username',
363
        ];
364
365
        // fields additional to $threadLineFieldList to show complete entry
366
        $showEntryFieldListAdditional = [
367
            'Entries.category_id',
368
            'Entries.edited',
369
            'Entries.edited_by',
370
            'Entries.ip',
371
            'Users.avatar',
372
            'Users.id',
373
            'Users.signature',
374
            'Users.user_type',
375
            'Users.user_place'
376
        ];
377
378
        $fields = $threadLineFieldList;
379
        if ($complete) {
380
            $fields = array_merge($fields, $showEntryFieldListAdditional);
381
        }
382
383
        return $fields;
384
    }
385
386
    /**
387
     * Finds the thread-IT for a posting.
388
     *
389
     * @param int $id Posting-Id
390
     * @return int Thread-Id
391
     * @throws RecordNotFoundException If posting isn't found
392
     */
393
    public function getThreadId($id)
394
    {
395
        $entry = $this->find(
396
            'all',
397
            ['conditions' => ['id' => $id], 'fields' => 'tid']
398
        )->first();
399
        if (empty($entry)) {
400
            throw new RecordNotFoundException(
401
                'Posting not found. Posting-Id: ' . $id
402
            );
403
        }
404
405
        return $entry->get('tid');
406
    }
407
408
    /**
409
     * creates a new root or child entry for a node
410
     *
411
     * fields in $data are filtered
412
     *
413
     * @param array $data data
414
     * @return Entry|null on success, null otherwise
415
     */
416
    public function createEntry(array $data): ?Entry
417
    {
418
        $data['time'] = bDate();
419
        $data['last_answer'] = bDate();
420
421
        /** @var Entry */
422
        $posting = $this->newEntity($data);
423
        $errors = $posting->getErrors();
424
        if (!empty($errors)) {
425
            return $posting;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $posting returns the type Cake\Datasource\EntityInterface which includes types incompatible with the type-hinted return App\Model\Entity\Entry|null.
Loading history...
426
        }
427
428
        /** @var Entry */
429
        $posting = $this->save($posting);
430
        if (empty($posting)) {
431
            return null;
432
        }
433
434
        $eventData = ['subject' => $posting->get('pid'), 'data' => $posting];
435
        $this->dispatchDbEvent('Model.Entry.replyToEntry', $eventData);
436
437
        return $posting;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $posting returns the type Cake\Datasource\EntityInterface which includes types incompatible with the type-hinted return App\Model\Entity\Entry|null.
Loading history...
438
    }
439
440
    /**
441
     * Updates a posting with new data
442
     *
443
     * @param Entry $posting Entity
444
     * @param array $data data
445
     * @return Entry|null
446
     */
447
    public function updateEntry(Entry $posting, array $data): ?Entry
448
    {
449
        $data['id'] = $posting->get('id');
450
451
        /** @var Entry */
452
        $patched = $this->patchEntity($posting, $data);
453
        $errors = $patched->getErrors();
454
        if (!empty($errors)) {
455
            return $patched;
456
        }
457
458
        /** @var Entry */
459
        $new = $this->save($posting);
460
        if (empty($new)) {
461
            return null;
462
        }
463
464
        $this->dispatchDbEvent(
465
            'Model.Entry.update',
466
            ['subject' => $posting->get('id'), 'data' => $posting]
467
        );
468
469
        return $new;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $new returns the type Cake\Datasource\EntityInterface which includes types incompatible with the type-hinted return App\Model\Entity\Entry|null.
Loading history...
470
    }
471
472
    /**
473
     * {@inheritDoc}
474
     */
475
    public function beforeMarshal(Event $event, \ArrayObject $data, \ArrayObject $options)
0 ignored issues
show
Unused Code introduced by
The parameter $event is not used and could be removed. ( Ignorable by Annotation )

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

475
    public function beforeMarshal(/** @scrutinizer ignore-unused */ Event $event, \ArrayObject $data, \ArrayObject $options)

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

Loading history...
Unused Code introduced by
The parameter $options is not used and could be removed. ( Ignorable by Annotation )

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

475
    public function beforeMarshal(Event $event, \ArrayObject $data, /** @scrutinizer ignore-unused */ \ArrayObject $options)

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

Loading history...
476
    {
477
        /// Trim whitespace on subject and text
478
        $toTrim = ['subject', 'text'];
479
        foreach ($toTrim as $field) {
480
            if (!empty($data[$field])) {
481
                $data[$field] = trim($data[$field]);
482
            }
483
        }
484
    }
485
486
    /**
487
     * Deletes posting incl. all its subposting and associated data
488
     *
489
     * @param array $idsToDelete Entry ids which should be deleted
490
     * @return bool
491
     */
492
    public function deleteWithIds(array $idsToDelete): bool
493
    {
494
        $success = $this->deleteAll(['id IN' => $idsToDelete]);
495
496
        if (!$success) {
497
            return false;
498
        }
499
500
        // @td Should be covered by dependent assoc. Add tests.
501
        $this->Bookmarks->deleteAll(['entry_id IN' => $idsToDelete]);
502
503
        $this->dispatchSaitoEvent(
504
            'Model.Saito.Postings.delete',
505
            ['subject' => $idsToDelete, 'table' => $this]
506
        );
507
508
        return true;
509
    }
510
511
    /**
512
     * Anonymizes the entries for a user
513
     *
514
     * @param int $userId user-ID
515
     * @return void
516
     */
517
    public function anonymizeEntriesFromUser(int $userId): void
518
    {
519
        // remove username from all entries and reassign to anonyme user
520
        $success = (bool)$this->updateAll(
521
            [
522
                'edited_by' => null,
523
                'ip' => null,
524
                'name' => null,
525
                'user_id' => 0,
526
            ],
527
            ['user_id' => $userId]
528
        );
529
530
        if ($success) {
531
            $this->dispatchDbEvent('Cmd.Cache.clear', ['cache' => 'Thread']);
532
        }
533
    }
534
535
    /**
536
     * Implements the custom find type 'index paginator'
537
     *
538
     * @param Query $query query
539
     * @param array $options finder options
540
     * @return Query
541
     */
542
    public function findIndexPaginator(Query $query, array $options)
543
    {
544
        $query
545
            ->select(['id', 'pid', 'tid', 'time', 'last_answer', 'fixed'])
546
            ->where(['Entries.pid' => 0]);
547
548
        if (!empty($options['counter'])) {
549
            $query->counter($options['counter']);
550
        }
551
552
        return $query;
553
    }
554
}
555