Issues (1686)

sources/ElkArte/TopicsMerge.php (2 issues)

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

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