TopicsMerge::__construct()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 1
dl 0
loc 11
ccs 0
cts 0
cp 0
crap 2
rs 10
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * This file has functions in it to handle merging of two or more topics
5
 *
6
 * @package   ElkArte Forum
7
 * @copyright ElkArte Forum contributors
8
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file)
9
 *
10
 * This file contains code covered by:
11
 * copyright: 2011 Simple Machines (http://www.simplemachines.org)
12
 *
13
 * @version 2.0 Beta 1
14
 *
15
 */
16
17
namespace ElkArte;
18
19
use ElkArte\Exceptions\Exception;
20
use ElkArte\Helper\Util;
21
22
/**
23
 * This class has functions to handle merging of two or more topics
24
 * in to a single new or existing topic.
25
 *
26
 * Class TopicsMerge
27
 */
28
class TopicsMerge
29
{
30
	/** @var array For each topic a set of information (id, board, subject, poll, etc.) */
31
	public $topic_data = [];
32
33
	/** @var int[] All the boards the topics are in */
34
	public $boards = [];
35
36
	/** @var int The id_topic with the lowest id_first_msg */
37
	public $firstTopic = 0;
38
39
	/** @var int The id_board of the topic TopicsMerge::$firstTopic */
40
	public $firstBoard = 0;
41
42
	/** @var int[] Just the array of topics to merge. */
43
	private $_topics;
44
45
	/** @var int Sum of the number of views of each topic. */
46
	private $_num_views = 0;
47
48
	/** @var int If at least one of the topics is sticky */
49
	private $_is_sticky = 0;
50
51
	/** @var array An array of "totals" (number of topics/messages, unapproved, etc.) for each board involved */
52
	private $_boardTotals = [];
53
54
	/** @var int[] If any topic has a poll, the array of poll id */
55
	private $_polls = [];
56
57
	/** @var string[] List of errors occurred */
58
	private $_errors = [];
59
60
	/** @var object The database object */
61
	private $_db;
62
63
	/**
64
	 * Initialize the class with a list of topics to merge
65
	 *
66
	 * @param int[] $topics array of topics to merge into one
67
	 */
68
	public function __construct($topics)
69
	{
70
		// Prepare the vars
71
		$this->_db = database();
72
73
		// Ensure all the id's are integers
74
		$topics = array_map('intval', $topics);
75
		$this->_topics = array_filter($topics);
76
77
		// Find out some preliminary information
78
		$this->_loadTopicDetails();
79
	}
80
81
	/**
82
	 * Grabs all the details of the topics involved in the merge process and loads
83
	 * them in $this->topic_data
84
	 */
85
	protected function _loadTopicDetails(): bool
86
	{
87
		global $modSettings;
88
89
		// Joy of all joys, make sure they're not pi**ing about with unapproved topics they can't see :P
90
		$can_approve_boards = false;
91
		if ($modSettings['postmod_active'])
92
		{
93
			$can_approve_boards = empty(User::$info->mod_cache['ap']) ? boardsAllowedTo('approve_posts') : User::$info->mod_cache['ap'];
0 ignored issues
show
Bug Best Practice introduced by
The property mod_cache does not exist on ElkArte\Helper\ValuesContainer. Since you implemented __get, consider adding a @property annotation.
Loading history...
94
		}
95
96
		// Get info about the topics and polls that will be merged.
97
		$request = $this->_db->query('', '
98
			SELECT
99
				t.id_topic, t.id_board, b.id_cat, t.id_poll, t.num_views, t.is_sticky, t.approved, t.num_replies, t.unapproved_posts,
100
				m1.subject, m1.poster_time AS time_started, COALESCE(mem1.id_member, 0) AS id_member_started, COALESCE(mem1.real_name, m1.poster_name) AS name_started,
101
				m2.poster_time AS time_updated, COALESCE(mem2.id_member, 0) AS id_member_updated, COALESCE(mem2.real_name, m2.poster_name) AS name_updated
102
			FROM {db_prefix}topics AS t
103
				INNER JOIN {db_prefix}messages AS m1 ON (m1.id_msg = t.id_first_msg)
104
				INNER JOIN {db_prefix}messages AS m2 ON (m2.id_msg = t.id_last_msg)
105
				LEFT JOIN {db_prefix}members AS mem1 ON (mem1.id_member = m1.id_member)
106
				LEFT JOIN {db_prefix}members AS mem2 ON (mem2.id_member = m2.id_member)
107
				LEFT JOIN {db_prefix}boards AS b ON (b.id_board = t.id_board)
108
			WHERE t.id_topic IN ({array_int:topic_list})
109
			ORDER BY t.id_first_msg
110
			LIMIT {int:limit}',
111
			[
112
				'topic_list' => $this->_topics,
113
				'limit' => count($this->_topics),
114
			]
115
		);
116
		if ($request->num_rows() < 2)
117
		{
118
			$request->free_result();
119
120
			$this->_errors[] = ['no_topic_id', true];
121
122
			return false;
123
		}
124
125
		while (($row = $request->fetch_assoc()))
126
		{
127
			// Make a note for the board counts...
128
			if (!isset($this->_boardTotals[$row['id_board']]))
129
			{
130
				$this->_boardTotals[$row['id_board']] = [
131
					'num_posts' => 0,
132
					'num_topics' => 0,
133
					'unapproved_posts' => 0,
134
					'unapproved_topics' => 0
135
				];
136
			}
137
138
			// We can't see unapproved topics here?
139
			if ($modSettings['postmod_active'] && !$row['approved'] && $can_approve_boards != [0] && !in_array($row['id_board'], $can_approve_boards))
140
			{
141
				unset($this->_topics[$row['id_topic']]);
142
				continue;
143
			}
144
145
			if (!$row['approved'])
146
			{
147
				$this->_boardTotals[$row['id_board']]['unapproved_topics']++;
148
			}
149
			else
150
			{
151
				$this->_boardTotals[$row['id_board']]['num_topics']++;
152
			}
153
154
			$this->_boardTotals[$row['id_board']]['unapproved_posts'] += $row['unapproved_posts'];
155
			$this->_boardTotals[$row['id_board']]['num_posts'] += $row['num_replies'] + ($row['approved'] ? 1 : 0);
156
157
			$this->topic_data[$row['id_topic']] = [
158
				'id' => $row['id_topic'],
159
				'board' => $row['id_board'],
160
				'poll' => $row['id_poll'],
161
				'num_views' => $row['num_views'],
162
				'subject' => $row['subject'],
163
				'started' => [
164
					'time' => standardTime($row['time_started']),
165
					'html_time' => htmlTime($row['time_started']),
166
					'timestamp' => forum_time(true, $row['time_started']),
167
					'href' => empty($row['id_member_started']) ? '' : getUrl('profile', ['action' => 'profile', 'u' => $row['id_member_started'], 'name' => $row['name_started']]),
168
					'link' => empty($row['id_member_started']) ? $row['name_started'] : '<a href="' . getUrl('profile', ['action' => 'profile', 'u' => $row['id_member_started'], 'name' => $row['name_started']]) . '">' . $row['name_started'] . '</a>'
169
				],
170
				'updated' => [
171
					'time' => standardTime($row['time_updated']),
172
					'html_time' => htmlTime($row['time_updated']),
173
					'timestamp' => forum_time(true, $row['time_updated']),
174
					'href' => empty($row['id_member_updated']) ? '' : getUrl('profile', ['action' => 'profile', 'u' => $row['id_member_updated'], 'name' => $row['name_updated']]),
175
					'link' => empty($row['id_member_updated']) ? $row['name_updated'] : '<a href="' . getUrl('profile', ['action' => 'profile', 'u' => $row['id_member_updated'], 'name' => $row['name_updated']]) . '">' . $row['name_updated'] . '</a>'
176
				]
177
			];
178
			$this->_num_views += $row['num_views'];
179
			$this->boards[] = $row['id_board'];
180
181
			// If there's no poll, id_poll == 0...
182
			if ($row['id_poll'] > 0)
183
			{
184
				$this->_polls[] = $row['id_poll'];
185
			}
186
187
			// Store the id_topic with the lowest id_first_msg.
188
			if (empty($this->firstTopic))
189
			{
190
				$this->firstTopic = $row['id_topic'];
191
				$this->firstBoard = $row['id_board'];
192
			}
193
194
			$this->_is_sticky = max($this->_is_sticky, $row['is_sticky']);
195
		}
196
197
		$request->free_result();
198
199
		$this->boards = array_map('intval', array_values(array_unique($this->boards)));
200
201
		// If we didn't get any topics, then they've been messing with unapproved stuff.
202
		if (empty($this->topic_data))
203
		{
204
			$this->_errors[] = ['no_topic_id', true];
205
		}
206
207
		return true;
208
	}
209
210
	/**
211
	 * If errors occurred while working
212
	 *
213
	 * @return bool
214
	 */
215
	public function hasErrors(): bool
216
	{
217
		return !empty($this->_errors);
218
	}
219
220
	/**
221
	 * The first error occurred
222
	 *
223
	 * @return string
224
	 */
225
	public function firstError(): string
226
	{
227
		if (!empty($this->_errors))
228
		{
229
			$errors = array_values($this->_errors);
230
231
			return array_shift($errors);
232
		}
233
234
		return '';
235
	}
236
237
	/**
238
	 * Returns the polls information if any of the topics has a poll.
239
	 *
240
	 * @return array
241
	 */
242
	public function getPolls(): array
243
	{
244
		$polls = [];
245
246
		if (count($this->_polls) > 1)
247
		{
248
			$this->_db->fetchQuery('
249
				SELECT
250
					t.id_topic, t.id_poll, m.subject, p.question
251
				FROM {db_prefix}polls AS p
252
					INNER JOIN {db_prefix}topics AS t ON (t.id_poll = p.id_poll)
253
					INNER JOIN {db_prefix}messages AS m ON (m.id_msg = t.id_first_msg)
254
				WHERE p.id_poll IN ({array_int:polls})
255
				LIMIT {int:limit}',
256
				[
257
					'polls' => $this->_polls,
258
					'limit' => count($this->_polls),
259
				]
260
			)->fetch_callback(
261
				function ($row) use (&$polls) {
262
					$polls[] = [
263
						'id' => $row['id_poll'],
264
						'topic' => [
265
							'id' => $row['id_topic'],
266
							'subject' => $row['subject']
267
						],
268
						'question' => $row['question'],
269
						'selected' => $row['id_topic'] == $this->firstTopic
270
					];
271
				}
272
			);
273
		}
274
275
		return $polls;
276
	}
277
278
	/**
279
	 * Performs the merge operations
280
	 *
281
	 * @param array $details
282
	 * @return bool|int[]
283
	 */
284
	public function doMerge($details = [])
285
	{
286
		// Just to be sure, here we should not have any error around
287
		$this->_errors = [];
288
289
		// Determine a target board.
290
		$target_board = count($this->boards) > 1 ? (int) $details['board'] : $this->boards[0];
291
		if (!in_array($target_board, $details['accessible_boards']))
292
		{
293
			$this->_errors[] = ['no_board', true];
294
295
			return false;
296
		}
297
298
		// Determine which poll will survive and which polls won't.
299
		$target_poll = count($this->_polls) > 1 ? (int) $details['poll'] : (count($this->_polls) === 1 ? $this->_polls[0] : 0);
300
		if ($target_poll > 0 && !in_array($target_poll, $this->_polls))
301
		{
302
			$this->_errors[] = ['no_access', false];
303
304
			return false;
305
		}
306
307
		$deleted_polls = empty($target_poll) ? $this->_polls : array_diff($this->_polls, [$target_poll]);
308
309
		// Determine the subject of the newly merged topic - was a custom subject specified?
310
		if (empty($details['subject']) && $details['custom_subject'] != '')
311
		{
312
			$target_subject = strtr(Util::htmltrim(Util::htmlspecialchars($details['custom_subject'])), ["\r" => '', "\n" => '', "\t" => '']);
313
314
			// Keep checking the length.
315
			if (Util::strlen($target_subject) > 100)
316
			{
317
				$target_subject = Util::substr($target_subject, 0, 100);
318
			}
319
320
			// Nothing left - odd but pick the first topics subject.
321
			if ($target_subject === '')
322
			{
323
				$target_subject = $this->topic_data[$this->firstTopic]['subject'];
324
			}
325
		}
326
		// A subject was selected from the list.
327
		elseif (!empty($this->topic_data[(int) $details['subject']]['subject']))
328
		{
329
			$target_subject = $this->topic_data[(int) $details['subject']]['subject'];
330
		}
331
		// Nothing worked? Just take the subject of the first message.
332
		else
333
		{
334
			$target_subject = $this->topic_data[$this->firstTopic]['subject'];
335
		}
336
337
		// Get the first and last message and the number of messages...
338
		$topic_approved = 1;
339
		$first_msg = 0;
340
		$num_replies = 0;
341
		$this->_db->fetchQuery('
342
			SELECT
343
				approved, MIN(id_msg) AS first_msg, MAX(id_msg) AS last_msg, COUNT(*) AS message_count
344
			FROM {db_prefix}messages
345
			WHERE id_topic IN ({array_int:topics})
346
			GROUP BY approved
347
			ORDER BY approved DESC',
348
			[
349
				'topics' => $this->_topics,
350
			]
351
		)->fetch_callback(
352
			static function ($row) use (&$topic_approved, &$first_msg, &$num_replies, &$last_msg, &$num_unapproved) {
353
				// If this is approved or is fully unapproved.
354
				if ($row['approved'] || !isset($first_msg))
355
				{
356
					$first_msg = $row['first_msg'];
357
					$last_msg = $row['last_msg'];
358
					if ($row['approved'])
359
					{
360
						$num_replies = $row['message_count'] - 1;
361
						$num_unapproved = 0;
362
					}
363
					else
364
					{
365
						$topic_approved = 0;
366
						$num_replies = 0;
367
						$num_unapproved = $row['message_count'];
368
					}
369
				}
370
				else
371
				{
372
					// If this has a lower first_msg, then the first post is not approved, and hence the number of replies was wrong!
373
					if ($first_msg > $row['first_msg'])
374
					{
375
						$first_msg = $row['first_msg'];
376
						$num_replies++;
377
						$topic_approved = 0;
378
					}
379
380
					$num_unapproved = $row['message_count'];
381
				}
382
			}
383
		);
384
385
		// Ensure we have a board stat for the target board.
386
		if (!isset($this->_boardTotals[$target_board]))
387
		{
388
			$this->_boardTotals[$target_board] = [
389
				'num_posts' => 0,
390
				'num_topics' => 0,
391
				'unapproved_posts' => 0,
392
				'unapproved_topics' => 0
393
			];
394
		}
395
396
		// Fix the topic count stuff depending on what the new one counts as.
397
		if ($topic_approved !== 0)
398
		{
399
			$this->_boardTotals[$target_board]['num_topics']--;
400
		}
401
		else
402
		{
403
			$this->_boardTotals[$target_board]['unapproved_topics']--;
404
		}
405
406
		$this->_boardTotals[$target_board]['unapproved_posts'] -= $num_unapproved;
407
		$this->_boardTotals[$target_board]['num_posts'] -= $topic_approved !== 0 ? $num_replies + 1 : $num_replies;
408
409
		// Get the member ID of the first and last message.
410
		$request = $this->_db->fetchQuery('
411
			SELECT
412
				id_member
413
			FROM {db_prefix}messages
414
			WHERE id_msg IN ({int:first_msg}, {int:last_msg})
415
			ORDER BY id_msg
416
			LIMIT 2',
417
			[
418
				'first_msg' => $first_msg,
419
				'last_msg' => $last_msg,
420
			]
421
		);
422
		[$member_started] = $request->fetch_row();
423
		[$member_updated] = $request->fetch_row();
424
425
		// The first and last message is the same, so only row was returned.
426
		if ($member_updated === null)
427
		{
428
			$member_updated = $member_started;
429
		}
430
431
		$request->free_result();
432
433
		// Get all the message ids we are going to affect.
434
		$affected_msgs = messagesInTopics($this->_topics);
435
436
		// Assign the first topic ID to be the merged topic.
437
		$id_topic = min($this->_topics);
438
439
		$enforce_subject = Util::htmlspecialchars(trim($details['enforce_subject']));
440
441
		// Merge topic notifications.
442
		$notifications = is_array($details['notifications']) ? array_intersect($this->_topics, $details['notifications']) : [];
443
		fixMergedTopics($first_msg, $this->_topics, $id_topic, $target_board, $target_subject, $enforce_subject, $notifications);
444
445
		// Assign the properties of the newly merged topic.
446
		setTopicAttribute($id_topic, [
447
			'id_board' => $target_board,
448
			'is_sticky' => $this->_is_sticky,
449
			'approved' => $topic_approved,
450
			'id_member_started' => $member_started,
451
			'id_member_updated' => $member_updated,
452
			'id_first_msg' => $first_msg,
453
			'id_last_msg' => $last_msg,
454
			'id_poll' => $target_poll,
455
			'num_replies' => $num_replies,
456
			'unapproved_posts' => $num_unapproved,
457
			'num_views' => $this->_num_views,
458
		]);
459
460
		// Get rid of the redundant polls.
461
		if (!empty($deleted_polls))
462
		{
463
			require_once(SUBSDIR . '/Poll.subs.php');
464
			removePoll($deleted_polls);
465
		}
466
467
		$this->_updateStats($affected_msgs, $id_topic, $target_subject, $enforce_subject);
0 ignored issues
show
Bug introduced by
$enforce_subject of type string is incompatible with the type boolean expected by parameter $enforce_subject of ElkArte\TopicsMerge::_updateStats(). ( Ignorable by Annotation )

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

467
		$this->_updateStats($affected_msgs, $id_topic, $target_subject, /** @scrutinizer ignore-type */ $enforce_subject);
Loading history...
468
469
		return [$id_topic, $target_board];
470
	}
471
472
	/**
473
	 * Takes care of updating all the relevant statistics
474
	 *
475
	 * @param int[] $affected_msgs
476
	 * @param int $id_topic
477
	 * @param string $target_subject
478
	 * @param bool $enforce_subject
479
	 * @throws Exception
480
	 */
481
	protected function _updateStats($affected_msgs, $id_topic, $target_subject, $enforce_subject): void
482
	{
483
		global $modSettings;
484
485
		// Cycle through each board...
486
		foreach ($this->_boardTotals as $id_board => $stats)
487
		{
488
			decrementBoard($id_board, $stats);
489
		}
490
491
		// Determine the board the final topic resides in
492
		$topic_info = getTopicInfo($id_topic);
493
		$id_board = $topic_info['id_board'];
494
495
		// Update all the statistics.
496
		require_once(SUBSDIR . '/Topic.subs.php');
497
		updateTopicStats();
498
499
		require_once(SUBSDIR . '/Messages.subs.php');
500
		updateSubjectStats($id_topic, $target_subject);
501
		updateLastMessages($this->boards);
502
503
		logAction('merge', ['topic' => $id_topic, 'board' => $id_board]);
504
505
		// Notify people that these topics have been merged?
506
		require_once(SUBSDIR . '/Notification.subs.php');
507
		sendNotifications($id_topic, 'merge');
508
509
		// Grab the response prefix (like 'Re: ') in the default forum language.
510
		$response_prefix = response_prefix();
511
512
		// If there's a search index that needs updating, update it...
513
		$searchAPI = new Search\SearchApiWrapper(empty($modSettings['search_index']) ? '' : $modSettings['search_index']);
514
		$searchAPI->topicMerge($id_topic, $this->_topics, $affected_msgs, empty($enforce_subject) ? null : [$response_prefix, $target_subject]);
515
	}
516
}
517