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 |
|
|
|
|
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(); |
|
|
|
|
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') |
|
|
|
|
350
|
|
|
->where([$this->getAlias() . '.id' => $primaryKey]) |
351
|
|
|
->first(); |
352
|
|
|
|
353
|
|
|
if (!$result) { |
354
|
|
|
return false; |
|
|
|
|
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); |
|
|
|
|
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); |
|
|
|
|
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, |
|
|
|
|
697
|
|
|
Entity $entity, |
698
|
|
|
\ArrayObject $options, |
|
|
|
|
699
|
|
|
Validator $validator |
|
|
|
|
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; |
|
|
|
|
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
|
|
|
|
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.