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\Http\Exception\NotFoundException; |
23
|
|
|
use Cake\ORM\Entity; |
24
|
|
|
use Cake\ORM\Query; |
25
|
|
|
use Cake\ORM\RulesChecker; |
26
|
|
|
use Cake\Validation\Validator; |
27
|
|
|
use Saito\Posting\PostingInterface; |
28
|
|
|
use Saito\User\CurrentUser\CurrentUserInterface; |
29
|
|
|
use Saito\Validation\SaitoValidationProvider; |
30
|
|
|
use Search\Manager; |
31
|
|
|
|
32
|
|
|
/** |
33
|
|
|
* Stores postings |
34
|
|
|
* |
35
|
|
|
* Field notes: |
36
|
|
|
* - `edited_by` - Came from mylittleforum. @td Should by migrated to User.id. |
37
|
|
|
* - `name` - Came from mylittleforum. Is still used in fulltext index. |
38
|
|
|
* |
39
|
|
|
* @property BookmarksTable $Bookmarks |
40
|
|
|
* @property CategoriesTable $Categories |
41
|
|
|
* @property DraftsTable $Drafts |
42
|
|
|
* @method array treeBuild(array $postings) |
43
|
|
|
* @method createPosting(array $data, CurrentUserInterface $CurrentUser) |
44
|
|
|
* @method updatePosting(Entry $posting, array $data, CurrentUserInterface $CurrentUser) |
45
|
|
|
* @method array prepareChildPosting(BasicPostingInterface $parent, array $data) |
46
|
|
|
* @method array getRecentPostings(CurrentUserInterface $CU, ?array $options = []) |
47
|
|
|
* @method bool deletePosting(int $id) |
48
|
|
|
* @method array postingsForThreads(array $tids, ?array $order = null, ?CurrentUserInterface $CU) |
49
|
|
|
* @method PostingInterface postingsForThread(int $tid, ?bool $complete = false, ?CurrentUserInterface $CU) |
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 |
|
|
|
|
140
|
|
|
->notEmpty('category_id', $categoryRequiredL10N) |
141
|
|
|
->requirePresence('category_id', 'create', $categoryRequiredL10N) |
142
|
|
|
->add( |
143
|
|
|
'category_id', |
144
|
|
|
[ |
145
|
|
|
'numeric' => ['rule' => 'numeric'], |
146
|
|
|
'assoc' => [ |
147
|
|
|
'rule' => ['validateAssoc', 'Categories'], |
148
|
|
|
'last' => true, |
149
|
|
|
'provider' => 'saito' |
150
|
|
|
] |
151
|
|
|
] |
152
|
|
|
); |
153
|
|
|
|
154
|
|
|
/// last_answer |
155
|
|
|
$validator |
156
|
|
|
->requirePresence('last_answer', 'create') |
157
|
|
|
->notEmptyDateTime('last_answer', null, 'create'); |
158
|
|
|
|
159
|
|
|
/// name |
160
|
|
|
$validator |
161
|
|
|
->requirePresence('name', 'create') |
162
|
|
|
->notEmptyString('name', null, 'create'); |
163
|
|
|
|
164
|
|
|
/// pid |
165
|
|
|
$validator->requirePresence('pid', 'create'); |
166
|
|
|
|
167
|
|
|
/// subject |
168
|
|
|
$subjectRequiredL10N = __('vld.entries.subject.notEmpty'); |
169
|
|
|
$validator |
170
|
|
|
->notEmptyString('subject', $subjectRequiredL10N) |
171
|
|
|
->requirePresence('subject', 'create', $subjectRequiredL10N) |
172
|
|
|
->add( |
173
|
|
|
'subject', |
174
|
|
|
[ |
175
|
|
|
'maxLength' => [ |
176
|
|
|
'rule' => ['maxLength', $this->getConfig('subject_maxlength')], |
177
|
|
|
'message' => __('vld.entries.subject.maxlength') |
178
|
|
|
] |
179
|
|
|
] |
180
|
|
|
); |
181
|
|
|
|
182
|
|
|
/// time |
183
|
|
|
$validator |
184
|
|
|
->requirePresence('time', 'create') |
185
|
|
|
->notEmptyDateTime('time', null, 'create'); |
186
|
|
|
|
187
|
|
|
/// user_id |
188
|
|
|
$validator |
189
|
|
|
->requirePresence('user_id', 'create') |
190
|
|
|
->add('user_id', ['numeric' => ['rule' => 'numeric']]); |
191
|
|
|
|
192
|
|
|
/// views |
193
|
|
|
$validator->add( |
194
|
|
|
'views', |
195
|
|
|
['comparison' => ['rule' => ['comparison', '>=', 0]]] |
196
|
|
|
); |
197
|
|
|
|
198
|
|
|
return $validator; |
199
|
|
|
} |
200
|
|
|
|
201
|
|
|
/** |
202
|
|
|
* {@inheritDoc} |
203
|
|
|
*/ |
204
|
|
|
public function buildRules(RulesChecker $rules) |
205
|
|
|
{ |
206
|
|
|
$rules = parent::buildRules($rules); |
207
|
|
|
|
208
|
|
|
$rules->addUpdate( |
209
|
|
|
function ($entity) { |
210
|
|
|
if ($entity->isDirty('category_id')) { |
211
|
|
|
return $entity->isRoot(); |
212
|
|
|
} |
213
|
|
|
|
214
|
|
|
return true; |
215
|
|
|
}, |
216
|
|
|
'checkCategoryChangeOnlyOnRootPostings', |
217
|
|
|
[ |
218
|
|
|
'errorField' => 'category_id', |
219
|
|
|
'message' => 'Cannot change category on non-root-postings.', |
220
|
|
|
] |
221
|
|
|
); |
222
|
|
|
|
223
|
|
|
return $rules; |
224
|
|
|
} |
225
|
|
|
|
226
|
|
|
/** |
227
|
|
|
* Advanced search configuration from SaitoSearch plugin |
228
|
|
|
* |
229
|
|
|
* @see https://github.com/FriendsOfCake/search |
230
|
|
|
* |
231
|
|
|
* @return Manager |
232
|
|
|
*/ |
233
|
|
|
public function searchManager(): Manager |
234
|
|
|
{ |
235
|
|
|
/** @var Manager $searchManager */ |
236
|
|
|
$searchManager = $this->getBehavior('Search')->searchManager(); |
|
|
|
|
237
|
|
|
$searchManager |
238
|
|
|
->like('subject', [ |
239
|
|
|
'before' => true, |
240
|
|
|
'after' => true, |
241
|
|
|
'fieldMode' => 'OR', |
242
|
|
|
'comparison' => 'LIKE', |
243
|
|
|
'wildcardAny' => '*', |
244
|
|
|
'wildcardOne' => '?', |
245
|
|
|
'field' => ['subject'], |
246
|
|
|
'filterEmpty' => true, |
247
|
|
|
]) |
248
|
|
|
->like('text', [ |
249
|
|
|
'before' => true, |
250
|
|
|
'after' => true, |
251
|
|
|
'fieldMode' => 'OR', |
252
|
|
|
'comparison' => 'LIKE', |
253
|
|
|
'wildcardAny' => '*', |
254
|
|
|
'wildcardOne' => '?', |
255
|
|
|
'field' => ['text'], |
256
|
|
|
'filterEmpty' => true, |
257
|
|
|
]) |
258
|
|
|
->value('name', ['filterEmpty' => true]); |
259
|
|
|
|
260
|
|
|
return $searchManager; |
261
|
|
|
} |
262
|
|
|
|
263
|
|
|
/** |
264
|
|
|
* Shorthand for reading an entry with full da516ta |
265
|
|
|
* |
266
|
|
|
* @param int $primaryKey key |
267
|
|
|
* @param array $options options |
268
|
|
|
* @return mixed Posting if found false otherwise |
269
|
|
|
*/ |
270
|
|
|
public function get($primaryKey, $options = []) |
271
|
|
|
{ |
272
|
|
|
/** @var Entry */ |
273
|
|
|
$result = $this->find('entry', ['complete' => true]) |
274
|
|
|
->where([$this->getAlias() . '.id' => $primaryKey]) |
275
|
|
|
->first(); |
276
|
|
|
|
277
|
|
|
// @td throw exception here |
278
|
|
|
return empty($result) ? false : $result; |
|
|
|
|
279
|
|
|
} |
280
|
|
|
|
281
|
|
|
/** |
282
|
|
|
* Implements the custom find type 'entry' |
283
|
|
|
* |
284
|
|
|
* @param Query $query query |
285
|
|
|
* @param array $options options |
286
|
|
|
* - 'complete' bool controls fieldset selected as in getFieldset($complete) |
287
|
|
|
* @return Query |
288
|
|
|
*/ |
289
|
|
|
public function findEntry(Query $query, array $options = []) |
290
|
|
|
{ |
291
|
|
|
$options += ['complete' => false]; |
292
|
|
|
$query |
293
|
|
|
->select($this->getFieldset($options['complete'])) |
294
|
|
|
->contain(['Users', 'Categories']); |
295
|
|
|
|
296
|
|
|
return $query; |
297
|
|
|
} |
298
|
|
|
|
299
|
|
|
/** |
300
|
|
|
* Get list of fields required to display posting.:w |
301
|
|
|
* |
302
|
|
|
* You don't want to fetch every field for performance reasons. |
303
|
|
|
* |
304
|
|
|
* @param bool $complete Threadline if false; Full posting if true |
305
|
|
|
* @return array The fieldset |
306
|
|
|
*/ |
307
|
|
|
public function getFieldset(bool $complete = false): array |
308
|
|
|
{ |
309
|
|
|
// field list necessary for displaying a thread_line |
310
|
|
|
$threadLineFieldList = [ |
311
|
|
|
'Categories.accession', |
312
|
|
|
'Categories.category', |
313
|
|
|
'Categories.description', |
314
|
|
|
'Categories.id', |
315
|
|
|
'Entries.fixed', |
316
|
|
|
'Entries.id', |
317
|
|
|
'Entries.last_answer', |
318
|
|
|
'Entries.locked', |
319
|
|
|
'Entries.name', |
320
|
|
|
'Entries.pid', |
321
|
|
|
'Entries.solves', |
322
|
|
|
'Entries.subject', |
323
|
|
|
// Entry.text determines if Entry is n/t |
324
|
|
|
'Entries.text', |
325
|
|
|
'Entries.tid', |
326
|
|
|
'Entries.time', |
327
|
|
|
'Entries.user_id', |
328
|
|
|
'Entries.views', |
329
|
|
|
'Users.username', |
330
|
|
|
]; |
331
|
|
|
|
332
|
|
|
// fields additional to $threadLineFieldList to show complete entry |
333
|
|
|
$showEntryFieldListAdditional = [ |
334
|
|
|
'Entries.category_id', |
335
|
|
|
'Entries.edited', |
336
|
|
|
'Entries.edited_by', |
337
|
|
|
'Entries.ip', |
338
|
|
|
'Users.avatar', |
339
|
|
|
'Users.id', |
340
|
|
|
'Users.signature', |
341
|
|
|
'Users.user_place' |
342
|
|
|
]; |
343
|
|
|
|
344
|
|
|
$fields = $threadLineFieldList; |
345
|
|
|
if ($complete) { |
346
|
|
|
$fields = array_merge($fields, $showEntryFieldListAdditional); |
347
|
|
|
} |
348
|
|
|
|
349
|
|
|
return $fields; |
350
|
|
|
} |
351
|
|
|
|
352
|
|
|
/** |
353
|
|
|
* Finds the thread-IT for a posting. |
354
|
|
|
* |
355
|
|
|
* @param int $id Posting-Id |
356
|
|
|
* @return int Thread-Id |
357
|
|
|
* @throws RecordNotFoundException If posting isn't found |
358
|
|
|
*/ |
359
|
|
|
public function getThreadId($id) |
360
|
|
|
{ |
361
|
|
|
$entry = $this->find( |
362
|
|
|
'all', |
363
|
|
|
['conditions' => ['id' => $id], 'fields' => 'tid'] |
364
|
|
|
)->first(); |
365
|
|
|
if (empty($entry)) { |
366
|
|
|
throw new RecordNotFoundException( |
367
|
|
|
'Posting not found. Posting-Id: ' . $id |
368
|
|
|
); |
369
|
|
|
} |
370
|
|
|
|
371
|
|
|
return $entry->get('tid'); |
372
|
|
|
} |
373
|
|
|
|
374
|
|
|
/** |
375
|
|
|
* creates a new root or child entry for a node |
376
|
|
|
* |
377
|
|
|
* fields in $data are filtered |
378
|
|
|
* |
379
|
|
|
* @param array $data data |
380
|
|
|
* @return Entry|null on success, null otherwise |
381
|
|
|
*/ |
382
|
|
|
public function createEntry(array $data): ?Entry |
383
|
|
|
{ |
384
|
|
|
$data['time'] = bDate(); |
385
|
|
|
$data['last_answer'] = bDate(); |
386
|
|
|
|
387
|
|
|
/** @var Entry */ |
388
|
|
|
$posting = $this->newEntity($data); |
389
|
|
|
$errors = $posting->getErrors(); |
390
|
|
|
if (!empty($errors)) { |
391
|
|
|
return $posting; |
392
|
|
|
} |
393
|
|
|
|
394
|
|
|
$posting = $this->save($posting); |
395
|
|
|
if (!$posting) { |
396
|
|
|
return null; |
397
|
|
|
} |
398
|
|
|
|
399
|
|
|
$id = $posting->get('id'); |
400
|
|
|
/** @var Entry */ |
401
|
|
|
$posting = $this->get($id, ['return' => 'Entity']); |
402
|
|
|
|
403
|
|
|
if ($posting->isRoot()) { |
404
|
|
|
// posting started a new thread, so set thread-ID to posting's own ID |
405
|
|
|
/** @var Entry */ |
406
|
|
|
$posting = $this->patchEntity($posting, ['tid' => $id]); |
|
|
|
|
407
|
|
|
if (!$this->save($posting)) { |
408
|
|
|
return $posting; |
409
|
|
|
} |
410
|
|
|
|
411
|
|
|
$this->_dispatchEvent('Model.Thread.create', ['subject' => $id, 'data' => $posting]); |
412
|
|
|
} else { |
413
|
|
|
// update last answer time of root entry |
414
|
|
|
$this->updateAll( |
415
|
|
|
['last_answer' => $posting->get('last_answer')], |
416
|
|
|
['id' => $posting->get('tid')] |
417
|
|
|
); |
418
|
|
|
|
419
|
|
|
$eventData = ['subject' => $posting->get('pid'), 'data' => $posting]; |
420
|
|
|
$this->_dispatchEvent('Model.Entry.replyToEntry', $eventData); |
421
|
|
|
$this->_dispatchEvent('Model.Entry.replyToThread', $eventData); |
422
|
|
|
} |
423
|
|
|
|
424
|
|
|
return $posting; |
425
|
|
|
} |
426
|
|
|
|
427
|
|
|
/** |
428
|
|
|
* Updates a posting |
429
|
|
|
* |
430
|
|
|
* fields in $data are filtered except for $id! |
431
|
|
|
* |
432
|
|
|
* @param Entry $posting Entity |
433
|
|
|
* @param array $data data |
434
|
|
|
* @return Entry|null |
435
|
|
|
*/ |
436
|
|
|
public function updateEntry(Entry $posting, array $data): ?Entry |
437
|
|
|
{ |
438
|
|
|
$data['id'] = $posting->get('id'); |
439
|
|
|
$data['edited'] = bDate(); |
440
|
|
|
|
441
|
|
|
/** @var Entry */ |
442
|
|
|
$patched = $this->patchEntity($posting, $data); |
443
|
|
|
$errors = $patched->getErrors(); |
444
|
|
|
if (!empty($errors)) { |
445
|
|
|
return $patched; |
446
|
|
|
} |
447
|
|
|
|
448
|
|
|
/** @var Entry */ |
449
|
|
|
$new = $this->save($posting); |
450
|
|
|
if (empty($new)) { |
451
|
|
|
return null; |
452
|
|
|
} |
453
|
|
|
|
454
|
|
|
$this->_dispatchEvent( |
455
|
|
|
'Model.Entry.update', |
456
|
|
|
['subject' => $posting->get('id'), 'data' => $posting] |
457
|
|
|
); |
458
|
|
|
|
459
|
|
|
return $new; |
460
|
|
|
} |
461
|
|
|
|
462
|
|
|
/** |
463
|
|
|
* Marks a sub-entry as solution to a root entry |
464
|
|
|
* |
465
|
|
|
* @param Entry $posting posting to toggle |
466
|
|
|
* @return bool success |
467
|
|
|
*/ |
468
|
|
|
public function toggleSolve(Entry $posting) |
469
|
|
|
{ |
470
|
|
|
if ($posting->get('solves')) { |
471
|
|
|
$value = 0; |
472
|
|
|
} else { |
473
|
|
|
$value = $posting->get('tid'); |
474
|
|
|
} |
475
|
|
|
|
476
|
|
|
$this->patchEntity($posting, ['solves' => $value]); |
477
|
|
|
if (!$this->save($posting)) { |
478
|
|
|
return false; |
479
|
|
|
} |
480
|
|
|
|
481
|
|
|
$this->_dispatchEvent( |
482
|
|
|
'Model.Entry.update', |
483
|
|
|
['subject' => $posting->get('id'), 'data' => $posting] |
484
|
|
|
); |
485
|
|
|
|
486
|
|
|
return true; |
487
|
|
|
} |
488
|
|
|
|
489
|
|
|
/** |
490
|
|
|
* {@inheritDoc} |
491
|
|
|
*/ |
492
|
|
|
public function toggle($id, $key) |
493
|
|
|
{ |
494
|
|
|
$result = parent::toggle($id, $key); |
495
|
|
|
if ($key === 'locked') { |
496
|
|
|
$this->_threadLock($id, $result); |
|
|
|
|
497
|
|
|
} |
498
|
|
|
|
499
|
|
|
$entry = $this->get($id); |
500
|
|
|
$this->_dispatchEvent( |
501
|
|
|
'Model.Entry.update', |
502
|
|
|
[ |
503
|
|
|
'subject' => $entry->get('id'), |
504
|
|
|
'data' => $entry |
505
|
|
|
] |
506
|
|
|
); |
507
|
|
|
|
508
|
|
|
return $result; |
509
|
|
|
} |
510
|
|
|
|
511
|
|
|
/** |
512
|
|
|
* {@inheritDoc} |
513
|
|
|
*/ |
514
|
|
|
public function beforeMarshal(Event $event, \ArrayObject $data, \ArrayObject $options) |
515
|
|
|
{ |
516
|
|
|
/// Trim whitespace on subject and text |
517
|
|
|
$toTrim = ['subject', 'text']; |
518
|
|
|
foreach ($toTrim as $field) { |
519
|
|
|
if (!empty($data[$field])) { |
520
|
|
|
$data[$field] = trim($data[$field]); |
521
|
|
|
} |
522
|
|
|
} |
523
|
|
|
} |
524
|
|
|
|
525
|
|
|
/** |
526
|
|
|
* Deletes posting incl. all its subposting and associated data |
527
|
|
|
* |
528
|
|
|
* @param array $idsToDelete Entry ids which should be deleted |
529
|
|
|
* @return bool |
530
|
|
|
*/ |
531
|
|
|
public function deleteWithIds(array $idsToDelete): bool |
532
|
|
|
{ |
533
|
|
|
$success = $this->deleteAll(['id IN' => $idsToDelete]); |
534
|
|
|
|
535
|
|
|
if (!$success) { |
536
|
|
|
return false; |
537
|
|
|
} |
538
|
|
|
|
539
|
|
|
// @td Should be covered by dependent assoc. Add tests. |
540
|
|
|
$this->Bookmarks->deleteAll(['entry_id IN' => $idsToDelete]); |
541
|
|
|
|
542
|
|
|
$this->dispatchSaitoEvent( |
543
|
|
|
'Model.Saito.Postings.delete', |
544
|
|
|
['subject' => $idsToDelete, 'table' => $this] |
545
|
|
|
); |
546
|
|
|
|
547
|
|
|
return true; |
548
|
|
|
} |
549
|
|
|
|
550
|
|
|
/** |
551
|
|
|
* Anonymizes the entries for a user |
552
|
|
|
* |
553
|
|
|
* @param int $userId user-ID |
554
|
|
|
* @return void |
555
|
|
|
*/ |
556
|
|
|
public function anonymizeEntriesFromUser(int $userId): void |
557
|
|
|
{ |
558
|
|
|
// remove username from all entries and reassign to anonyme user |
559
|
|
|
$success = (bool)$this->updateAll( |
560
|
|
|
[ |
561
|
|
|
'edited_by' => null, |
562
|
|
|
'ip' => null, |
563
|
|
|
'name' => null, |
564
|
|
|
'user_id' => 0, |
565
|
|
|
], |
566
|
|
|
['user_id' => $userId] |
567
|
|
|
); |
568
|
|
|
|
569
|
|
|
if ($success) { |
570
|
|
|
$this->_dispatchEvent('Cmd.Cache.clear', ['cache' => 'Thread']); |
571
|
|
|
} |
572
|
|
|
} |
573
|
|
|
|
574
|
|
|
/** |
575
|
|
|
* Merge thread on to entry $targetId |
576
|
|
|
* |
577
|
|
|
* @param int $sourceId root-id of the posting that is merged onto another |
578
|
|
|
* thread |
579
|
|
|
* @param int $targetId id of the posting the source-thread should be |
580
|
|
|
* appended to |
581
|
|
|
* @return bool true if merge was successfull false otherwise |
582
|
|
|
*/ |
583
|
|
|
public function threadMerge($sourceId, $targetId) |
584
|
|
|
{ |
585
|
|
|
$sourcePosting = $this->get($sourceId, ['return' => 'Entity']); |
586
|
|
|
|
587
|
|
|
// check that source is thread-root and not an subposting |
588
|
|
|
if (!$sourcePosting->isRoot()) { |
589
|
|
|
return false; |
590
|
|
|
} |
591
|
|
|
|
592
|
|
|
$targetPosting = $this->get($targetId); |
593
|
|
|
|
594
|
|
|
// check that target exists |
595
|
|
|
if (!$targetPosting) { |
596
|
|
|
return false; |
597
|
|
|
} |
598
|
|
|
|
599
|
|
|
// check that a thread is not merged onto itself |
600
|
|
|
if ($targetPosting->get('tid') === $sourcePosting->get('tid')) { |
601
|
|
|
return false; |
602
|
|
|
} |
603
|
|
|
|
604
|
|
|
// set target entry as new parent entry |
605
|
|
|
$this->patchEntity( |
606
|
|
|
$sourcePosting, |
|
|
|
|
607
|
|
|
['pid' => $targetPosting->get('id')] |
608
|
|
|
); |
609
|
|
|
if ($this->save($sourcePosting)) { |
|
|
|
|
610
|
|
|
// associate all entries in source thread to target thread |
611
|
|
|
$this->updateAll( |
612
|
|
|
['tid' => $targetPosting->get('tid')], |
613
|
|
|
['tid' => $sourcePosting->get('tid')] |
614
|
|
|
); |
615
|
|
|
|
616
|
|
|
// appended source entries get category of target thread |
617
|
|
|
$this->_threadChangeCategory( |
618
|
|
|
$targetPosting->get('tid'), |
619
|
|
|
$targetPosting->get('category_id') |
620
|
|
|
); |
621
|
|
|
|
622
|
|
|
// update target thread last answer if source is newer |
623
|
|
|
$sourceLastAnswer = $sourcePosting->get('last_answer'); |
624
|
|
|
$targetLastAnswer = $targetPosting->get('last_answer'); |
625
|
|
|
if ($sourceLastAnswer->gt($targetLastAnswer)) { |
626
|
|
|
$targetRoot = $this->get( |
627
|
|
|
$targetPosting->get('tid'), |
628
|
|
|
['return' => 'Entity'] |
629
|
|
|
); |
630
|
|
|
$targetRoot = $this->patchEntity( |
631
|
|
|
$targetRoot, |
|
|
|
|
632
|
|
|
['last_answer' => $sourceLastAnswer] |
633
|
|
|
); |
634
|
|
|
$this->save($targetRoot); |
635
|
|
|
} |
636
|
|
|
|
637
|
|
|
// propagate pinned property from target to source |
638
|
|
|
$isTargetPinned = $targetPosting->isLocked(); |
639
|
|
|
$isSourcePinned = $sourcePosting->isLocked(); |
640
|
|
|
if ($isSourcePinned !== $isTargetPinned) { |
641
|
|
|
$this->_threadLock($targetPosting->get('tid'), $isTargetPinned); |
642
|
|
|
} |
643
|
|
|
|
644
|
|
|
$this->_dispatchEvent( |
645
|
|
|
'Model.Thread.change', |
646
|
|
|
['subject' => $targetPosting->get('tid')] |
647
|
|
|
); |
648
|
|
|
|
649
|
|
|
return true; |
650
|
|
|
} |
651
|
|
|
|
652
|
|
|
return false; |
653
|
|
|
} |
654
|
|
|
|
655
|
|
|
/** |
656
|
|
|
* Implements the custom find type 'index paginator' |
657
|
|
|
* |
658
|
|
|
* @param Query $query query |
659
|
|
|
* @param array $options finder options |
660
|
|
|
* @return Query |
661
|
|
|
*/ |
662
|
|
|
public function findIndexPaginator(Query $query, array $options) |
663
|
|
|
{ |
664
|
|
|
$query |
665
|
|
|
->select(['id', 'pid', 'tid', 'time', 'last_answer', 'fixed']) |
666
|
|
|
->where(['Entries.pid' => 0]); |
667
|
|
|
|
668
|
|
|
if (!empty($options['counter'])) { |
669
|
|
|
$query->counter($options['counter']); |
670
|
|
|
} |
671
|
|
|
|
672
|
|
|
return $query; |
673
|
|
|
} |
674
|
|
|
|
675
|
|
|
/** |
676
|
|
|
* Un-/Locks thread: sets posting in thread $tid to $locked |
677
|
|
|
* |
678
|
|
|
* @param int $tid thread-ID |
679
|
|
|
* @param bool $locked flag |
680
|
|
|
* @return void |
681
|
|
|
*/ |
682
|
|
|
protected function _threadLock($tid, $locked) |
683
|
|
|
{ |
684
|
|
|
$this->updateAll(['locked' => $locked], ['tid' => $tid]); |
685
|
|
|
} |
686
|
|
|
|
687
|
|
|
/** |
688
|
|
|
* {@inheritDoc} |
689
|
|
|
*/ |
690
|
|
|
public function beforeSave(Event $event, Entity $entity) |
691
|
|
|
{ |
692
|
|
|
$success = true; |
693
|
|
|
|
694
|
|
|
/// change category of thread if category of root entry changed |
695
|
|
|
if (!$entity->isNew() && $entity->isDirty('category_id')) { |
696
|
|
|
$success &= $this->_threadChangeCategory( |
697
|
|
|
// rules checks that only roots are allowed to change category, so tid = id |
698
|
|
|
$entity->get('id'), |
699
|
|
|
$entity->get('category_id') |
700
|
|
|
); |
701
|
|
|
} |
702
|
|
|
|
703
|
|
|
if (!$success) { |
704
|
|
|
$event->stopPropagation(); |
705
|
|
|
} |
706
|
|
|
} |
707
|
|
|
|
708
|
|
|
/** |
709
|
|
|
* {@inheritDoc} |
710
|
|
|
*/ |
711
|
|
|
public function afterSave(Event $event, Entity $entity, \ArrayObject $options) |
712
|
|
|
{ |
713
|
|
|
if ($entity->isNew()) { |
714
|
|
|
$this->Drafts->deleteDraftForPosting($entity); |
715
|
|
|
} |
716
|
|
|
} |
717
|
|
|
|
718
|
|
|
/** |
719
|
|
|
* Changes the category of a thread. |
720
|
|
|
* |
721
|
|
|
* Assigns the new category-id to all postings in that thread. |
722
|
|
|
* |
723
|
|
|
* @param int $tid thread-ID |
724
|
|
|
* @param int $newCategoryId id for new category |
725
|
|
|
* @return bool success |
726
|
|
|
* @throws NotFoundException |
727
|
|
|
*/ |
728
|
|
|
protected function _threadChangeCategory(int $tid, int $newCategoryId): bool |
729
|
|
|
{ |
730
|
|
|
$exists = $this->Categories->exists($newCategoryId); |
731
|
|
|
if (!$exists) { |
732
|
|
|
throw new NotFoundException(); |
733
|
|
|
} |
734
|
|
|
$affected = $this->updateAll( |
735
|
|
|
['category_id' => $newCategoryId], |
736
|
|
|
['tid' => $tid] |
737
|
|
|
); |
738
|
|
|
|
739
|
|
|
return $affected > 0; |
740
|
|
|
} |
741
|
|
|
} |
742
|
|
|
|
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.