1<?php
2// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project
3//
4// All Rights Reserved. See copyright.txt for details and a complete list of authors.
5// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details.
6// $Id$
7
8/**
9 * \brief Categories support class
10 */
11
12//this script may only be included - so its better to die if called directly.
13if (strpos($_SERVER["SCRIPT_NAME"], basename(__FILE__)) !== false) {
14	header("location: index.php");
15	exit;
16}
17
18$objectlib = TikiLib::lib('object');
19
20class CategLib extends ObjectLib
21{
22	private $parentCategories = [];
23	private $currentObjectCategories = [];
24
25	// Returns a string representing the specified category's path.
26	// The path includes all parent categories ordered from the root to the category's parent, and the category itself.
27	// The string is a double colon (::) separated concatenation of category names.
28	// Returns the empty string if the specified category does not exist.
29	function get_category_path_string($categId)
30	{
31		$category = $this->get_category($categId);
32		if ($category) {
33			return $category['categpath'];
34		} else {
35			return '';
36		}
37	}
38
39	public function set_current_object_categories($type, $objectId) {
40		$this->currentObjectCategories = $this->get_object_categories($type, $objectId);
41	}
42
43
44	public function get_current_object_categories() {
45		return $this->currentObjectCategories;
46	}
47
48	/**
49	 * Returns the path of the given category as a String in the format:
50	 * "Root Category (TOP) > 1st Subcategory > 2nd Subcategory::..."
51	 */
52	function get_category_path_string_with_root($categId)
53	{
54		$category = $this->get_category($categId);
55		$tepath = ['Top'];
56		foreach ((array)$category['tepath'] as $pathelem) {
57			$tepath[] = $pathelem;
58		}
59		return implode(" > ", $tepath);
60	}
61
62	// Returns false if the category is not found.
63	// WARNING: permissions and the category filter are not considered.
64	function get_category($categId)
65	{
66		if (! is_numeric($categId)) {
67			throw new Exception(tr('Invalid category identifier: "%0"', $categId));
68		}
69		$categories = $this->getCategories(['identifier' => (int)$categId], false, false);
70		return empty($categories) ? false : $categories[$categId];
71	}
72
73	function get_category_id($name)
74	{
75		$query = "select `categId` from `tiki_categories` where `name`=?";
76		return $this->getOne($query, [(string)$name]);
77	}
78
79	function get_category_name($categId, $real = false)
80	{
81		if ($categId === 'orphan') {
82			return tr('None');
83		}
84		if ($categId == 0) {
85			return tr('Top');
86		}
87		$query = "select `name`,`parentId` from `tiki_categories` where `categId`=?";
88		$result = $this->query($query, [(int)$categId]);
89		$res = $result->fetchRow();
90		if ($real) {
91			return $res['name'];
92		}
93		if (preg_match('/^Tracker ([0-9]+)$/', $res['name'])) {
94			$trackerId = preg_replace('/^Tracker ([0-9]+)$/', "$1", $res['name']);
95			return $this->getOne("select `name` from `tiki_trackers` where `trackerId`=?", [(int)$trackerId]);
96		}
97		if (preg_match('/^Tracker Item ([0-9]+)$/', $res['name'])) {
98			$trklib = TikiLib::lib('trk');
99			$itemId = preg_replace('/^Tracker Item ([0-9]+)$/', "$1", $res['name']);
100			return $trklib->get_isMain_value(-1, $itemId);
101		}
102		return $res['name'];
103	}
104
105	// WARNING: This removes not only the specified category, but also all its descendants.
106	function remove_category($categId)
107	{
108		$cachelib = TikiLib::lib('cache');
109
110		$parentId = $this->get_category_parent($categId);
111		$categoryName = $this->get_category_name($categId);
112		$categoryPath = $this->get_category_path_string_with_root($categId);
113		$description = $this->get_category_description($categId);
114
115		$query = "delete from `tiki_categories` where `categId`=?";
116		$ret = $this->query($query, [(int)$categId]);
117		$query = "select `catObjectId` from `tiki_category_objects` where `categId`=?";
118		$result = $this->query($query, [(int)$categId]);
119
120		while ($res = $result->fetchRow()) {
121			$object = $res["catObjectId"];
122
123			$query_cant = "select count(*) from `tiki_category_objects` where `catObjectId`=?";
124			$cant = $this->getOne($query_cant, [$object]);
125			if ($cant <= 1) {
126				$query2 = "delete from `tiki_categorized_objects` where `catObjectId`=?";
127				$result2 = $this->query($query2, [$object]);
128			}
129		}
130
131		// remove any permissions assigned to this category
132		$type = 'category';
133		$object = $type . $categId;
134		$query = "delete from `users_objectpermissions` where `objectId`=? and `objectType`=?";
135		$result = $this->query($query, [md5($object), $type]);
136
137		$query = "delete from `tiki_category_objects` where `categId`=?";
138		$result = $this->query($query, [(int)$categId]);
139		$query = "select `categId` from `tiki_categories` where `parentId`=?";
140		$result = $this->query($query, [(int)$categId]);
141
142		while ($res = $result->fetchRow()) {
143			// Recursively remove the subcategory
144			$this->remove_category($res["categId"]);
145		}
146
147		$cachelib->empty_type_cache('allcategs');
148		$cachelib->empty_type_cache('fgals_perms');
149
150		$values = ["categoryId" => $categId, "categoryName" => $categoryName, "categoryPath" => $categoryPath,
151			"description" => $description, "parentId" => $parentId, "parentName" => $this->get_category_name($parentId),
152			"action" => "category removed"];
153		$this->notify($values);
154
155		$this->remove_category_from_watchlists($categId);
156
157		$logslib = TikiLib::lib('logs');
158		$logslib->add_action(
159			'Removed',
160			$categId,
161			'category',
162			[
163				'name' => $categoryName,
164			]
165		);
166
167		TikiLib::events()->trigger('tiki.category.delete', [
168			'type' => 'category',
169			'object' => $categId,
170			'user' => $GLOBALS['user'],
171		]);
172		return $ret;
173	}
174
175	// Throws an Exception if the category name conflicts
176	function update_category($categId, $name, $description, $parentId, $tplGroupContainer = null, $tplGroupPattern = null)
177	{
178		$cachelib = TikiLib::lib('cache');
179
180		$oldCategory = $this->get_category($categId);
181		$oldCategoryName = $oldCategory['name'];
182		$oldCategoryPath = $this->get_category_path_string_with_root($categId);
183		$oldDescription = $oldCategory['description'];
184		$oldTplGroupContainerId = $oldCategory['tplGroupContainerId'];
185		$oldParentId = $oldCategory['parentId'];
186		$oldParentName = $this->get_category_name($oldParentId);
187
188		if ((strcasecmp($oldCategoryName, $name) !== 0 || $oldParentId != $parentId) && $this->exist_child_category($parentId, $name)) {
189			throw new Exception(tr('A category named %0 already exists in %1.', $name, $this->get_category_name($parentId)));
190		}
191
192		// Make sure the description fits the column width
193		if (strlen($description) > 500) {
194			$description = substr($description, 0, 500);
195		}
196
197		$categs = TikiDb::get()->table('tiki_categories');
198		$categs->update(
199			[
200				'name' => $name,
201				'description' => $description,
202				'parentId' => (int)$parentId,
203				'rootId' => (int)$this->find_root($parentId),
204				'tplGroupContainerId' => (int)$tplGroupContainer,
205				'tplGroupPattern' => $tplGroupPattern
206			],
207			[
208				'categId' => $categId,
209			]
210		);
211
212		if ($oldTplGroupContainerId != $tplGroupContainer && $oldTplGroupContainerId > 0) {
213			$info = TikiLib::lib('user')->get_groupId_info($oldTplGroupContainerId);
214			$subgroups = TikiLib::lib('user')->get_including_groups($info["groupName"]);
215			$subgroupsInfo = TikiLib::lib('user')->get_group_info($subgroups);
216			foreach ($subgroupsInfo as $item) {
217				TikiLib::lib('categ')->detach_managed_category($item["id"], [$oldTplGroupContainerId]);
218			}
219		}
220
221		$cachelib->empty_type_cache('allcategs');
222		$cachelib->empty_type_cache('fgals_perms');
223
224		$values = ["categoryId" => $categId, "categoryName" => $name, "categoryPath" => $this->get_category_path_string_with_root($categId),
225			"description" => $description, "parentId" => $parentId, "parentName" => $this->get_category_name($parentId),
226			"action" => "category updated", "oldCategoryName" => $oldCategoryName, "oldCategoryPath" => $oldCategoryPath,
227			"oldDescription" => $oldDescription, "oldParentId" => $parentId, "oldParentName" => $oldParentName];
228		$this->notify($values);
229
230		$logslib = TikiLib::lib('logs');
231		$logslib->add_action(
232			'Updated',
233			$categId,
234			'category',
235			[
236				'name' => $name,
237			]
238		);
239
240		TikiLib::events()->trigger('tiki.category.update', [
241			'type' => 'category',
242			'object' => $categId,
243			'user' => $GLOBALS['user'],
244		]);
245	}
246
247	// Throws an Exception if the category name conflicts
248	function add_category($parentId, $name, $description, $tplGroupContainer = null, $tplGroupPattern = null)
249	{
250		if ($this->exist_child_category($parentId, $name)) {
251			throw new Exception(tr('A category named %0 already exists in %1.', $name, $this->get_category_name($parentId)));
252		}
253		$cachelib = TikiLib::lib('cache');
254
255		// Make sure the description fits the column width
256		// TODO: remove length constraint then remove this. See "Quiet truncation of data in database" thread on the development list
257		if (strlen($description) > 500) {
258			$description = substr($description, 0, 500);
259		}
260
261		$categs = TikiDb::get()->table('tiki_categories');
262
263		$id = $categs->insert(
264			[
265				'name' => $name,
266				'description' => $description,
267				'parentId' => (int)$parentId,
268				'rootId' => (int)$this->find_root($parentId),
269				'tplGroupContainerId' => (int)$tplGroupContainer,
270				'tplGroupPattern' => $tplGroupPattern,
271				'hits' => 0,
272			]
273		);
274
275		$cachelib->empty_type_cache('allcategs');
276		$cachelib->empty_type_cache('fgals_perms');
277		$values = ["categoryId" => $id, "categoryName" => $name, "categoryPath" => $this->get_category_path_string_with_root($id),
278			"description" => $description, "parentId" => $parentId, "parentName" => $this->get_category_name($parentId),
279			"action" => "category created"];
280		$this->notify($values);
281
282		$logslib = TikiLib::lib('logs');
283		$logslib->add_action(
284			'Created',
285			$id,
286			'category',
287			[
288				'name' => $name,
289			]
290		);
291
292		TikiLib::events()->trigger('tiki.category.create', [
293			'type' => 'category',
294			'object' => $id,
295			'user' => $GLOBALS['user'],
296		]);
297
298		return $id;
299	}
300
301	public function detach_managed_category($groupId, $parentGroupsIds = [])
302	{
303		$attrs = TikiLib::lib('attribute')->find_objects_with('tiki.category.templatedgroupid', $groupId);
304		foreach ($attrs as $attr) {
305			$category = $this->get_category($attr['itemId']);
306			if (! empty($parentGroupsIds) && $category) { //get parent template
307				$parent = $this->get_category($category['parentId']);
308				if (! in_array($parent['tplGroupContainerId'], $parentGroupsIds)) {
309					continue;
310				}
311			}
312
313			if ($category) {
314				$category['name'] = $category['name'].' (Archived)';
315				$userlib = TikiLib::lib('user');
316				$oldCateg = $this->get_category_by_name($category['name'], 0);
317				if ($oldCateg != null) {
318					$category['name'] = $category['name'] . ' - ' . $category['categId'];
319				}
320				$this->update_category($category['categId'], $category['name'], $category['description'], null, null, null);
321				$perms = $userlib->get_object_permissions($category['categId'], 'category');
322				foreach ($perms as $perm) {
323					$userlib->remove_object_permission($perm['groupName'], $category['categId'], 'category', $category['permName']);
324				}
325				$userlib->assign_object_permission('Admins', $category['categId'], 'category', 'tiki_p_view');
326			}
327			TikiLib::lib('attribute')->set_attribute('category', $attr['itemId'], 'tiki.category.templatedgroupid', '');
328		}
329	}
330
331	public function get_managed_categories($groupId)
332	{
333		$query = "select * from `tiki_categories` where tplGroupContainerId = ?";
334		$categories = TikiLib::lib('attribute')->find_objects_with('tiki.category.templatedgroupid', $groupId);
335		$bindvars = [$groupId];
336		if(count($categories) > 0){
337			$categories = array_map(function ($item){
338				return $item["itemId"];
339			}, $categories);
340
341			$db = TikiDb::get();
342			$query .= " OR ".$db->in('categId', array_values($categories), $bindvars);
343		}
344
345		return $this->query($query, $bindvars)->result;
346	}
347
348	public function manage_sub_categories($categoryId)
349	{
350		$rolesRepo = TikiLib::lib('roles');
351		$categ = $this->get_category($categoryId);
352		$childrenCateg = $this->getCategories(['identifier' => $categoryId, 'type' => 'descendants']);
353
354		$this->get_category_descendants($categoryId);
355
356		$tplGroup = TikiLib::lib('user')->get_groupId_info($categ["tplGroupContainerId"]);
357		$groupChildren = TikiLib::lib('user')->get_group_children($tplGroup["groupName"]);
358
359		$childrenCateg = array_values(array_map(function ($item) {
360			$templatedgroupid = TikiLib::lib('attribute')->get_attribute("category", $item["categId"], "tiki.category.templatedgroupid");
361			$item["templatedgroupid"] = $templatedgroupid;
362			return $item;
363		}, $childrenCateg));
364
365		foreach ($groupChildren["data"] as $groupChild) {
366			list($categChild) = array_values(array_filter($childrenCateg, function ($item) use ($groupChild) {
367				return (int)$item["templatedgroupid"] == (int)$groupChild["id"];
368			}));
369			$name = str_replace("--groupname--", $groupChild["groupName"], $categ["tplGroupPattern"]);
370			$oldCateg = $this->get_category_by_name($name, $categoryId);
371			if ($categChild == null) { //create category
372				if ($oldCateg != null) {
373					$newCategId = $oldCateg["categId"];
374					$this->update_category($oldCateg["categId"], $name, $oldCateg["description"], $categoryId, $oldCateg["tplGroupContainerId"], $oldCateg["tplGroupPattern"]);
375				} else {
376					$newCategId = $this->add_category($categoryId, $name, "");
377				}
378				TikiLib::lib('attribute')->set_attribute("category", $newCategId, "tiki.category.templatedgroupid", $groupChild["id"]);
379			} else { //validate name
380				$this->update_category($categChild["categId"], $name, $categChild["description"], $categoryId, $categChild["tplGroupContainerId"], $categChild["tplGroupPattern"]);
381				$newCategId = $categChild["categId"];
382			}
383
384			if (isset($newCategId)) {
385				$roles = $rolesRepo->getAvailableCategoriesRoles($categoryId);
386				$appliedRoles = $rolesRepo->getSelectedCategoryRoles($newCategId);
387				$roles = array_filter($roles, function ($role) use ($appliedRoles) {
388					return empty(array_filter($appliedRoles, function ($appliedRole) use ($role) {
389						return $appliedRole["groupRoleId"] == $role["id"];
390					}));
391				});
392				foreach ($roles as $role) {
393					if (empty($role["groupId"])) {
394						$includedGroups = TikiLib::lib('user')->get_including_groups($groupChild["groupName"], false);
395						$toSelect = array_values(array_filter($includedGroups, function ($gn) use ($role) {
396							$includedGroups = TikiLib::lib('user')->get_included_groups($gn, false);
397							return in_array($role["groupName"], $includedGroups);
398						}));
399						if (! empty($toSelect)) {
400							$selectedGroup = TikiLib::lib('user')->get_group_info($toSelect[0]);
401							$rolesRepo->insertOrUpdateSelectedCategoryRole($newCategId, $categoryId, $role["id"], $selectedGroup["id"]);
402						}
403					}
404				}
405			}
406		}
407
408		return;
409	}
410
411	private function find_root($parentId)
412	{
413		$root = 0;
414
415		if ($parentId) {
416			$categs = TikiDb::get()->table('tiki_categories');
417			$root = $categs->fetchOne(
418				'rootId',
419				[
420					'categId' => $parentId,
421				]
422			);
423
424			if (! $root) {
425				$root = $parentId;
426			}
427		}
428
429		return $root;
430	}
431
432	function is_categorized($type, $itemId)
433	{
434		if (empty($itemId)) {
435			return 0;
436		}
437
438		$query = "select o.`objectId` from `tiki_categorized_objects` c, `tiki_objects` o, `tiki_category_objects` tco where c.`catObjectId`=o.`objectId` and o.`type`=? and o.`itemId`=? and tco.`catObjectId`=c.`catObjectId`";
439		$bindvars = [$type, $itemId];
440		settype($bindvars["1"], "string");
441		return $this->getOne($query, $bindvars);
442	}
443
444	// $type The object's type, which has to be one of those handled by ObjectLib's add_object().
445	// $checkHandled A boolean indicating whether only handled object types should be accepted when the object has no object record and no object information is given (legacy).
446	// Returns the object's OID, or FALSE if the object type is not handled and $checkHandled is FALSE.
447	function add_categorized_object($type, $itemId, $description = null, $name = null, $href = null, $checkHandled = false)
448	{
449		global $prefs;
450		$id = $this->add_object($type, $itemId, $checkHandled, $description, $name, $href);
451		if ($id === false) {
452			return false;
453		}
454		$query = "select `catObjectId` from `tiki_categorized_objects` where `catObjectId`=?";
455		if (! $this->getOne($query, [$id])) {
456			$query = "insert into `tiki_categorized_objects` (`catObjectId`) values (?)";
457			$this->query($query, [$id]);
458
459			$cachelib = TikiLib::lib('cache');
460			if ($prefs['categories_cache_refresh_on_object_cat'] != "n") {
461				$cachelib->empty_type_cache("allcategs");
462			}
463			$cachelib->empty_type_cache('fgals_perms');
464		}
465		return $id;
466	}
467
468	/**
469	 * categorizePage will do the required steps to categorize a wiki page
470	 *
471	 * @param mixed $pageName Page to categorize
472	 * @param mixed $categId CategoryId
473	 * @return nothing
474	 *
475	 */
476	function categorizePage($pageName, $categId, $user = '')
477	{
478		$objectlib = TikiLib::lib('object');
479
480		// Categorize the new page
481		$objectId = $objectlib->add_object('wiki page', $pageName);
482
483		$description = null;
484		$name = null;
485		$href = null;
486		$checkHandled = true;
487		$this->add_categorized_object('wiki page', $pageName, $description, $name, $href, $checkHandled);
488
489		$this->categorize($objectId, $categId, $user);
490	}
491
492	function categorize($catObjectId, $categId, $user = '')
493	{
494		global $prefs;
495		if (empty($categId)) {
496			return;
497		}
498		$query = "delete from `tiki_category_objects` where `catObjectId`=? and `categId`=?";
499		$this->query($query, [(int)$catObjectId, (int)$categId], -1, -1, false);
500
501		$query = "insert into `tiki_category_objects`(`catObjectId`,`categId`) values(?,?)";
502		$result = $this->query($query, [(int)$catObjectId, (int)$categId]);
503
504		$cachelib = TikiLib::lib('cache');
505		if ($prefs['categories_cache_refresh_on_object_cat'] != "n") {
506			$cachelib->empty_type_cache("allcategs");
507		}
508		$info = TikiLib::lib('object')->get_object_via_objectid($catObjectId);
509		if ($prefs['feature_actionlog'] == 'y') {
510			$logslib = TikiLib::lib('logs');
511			$logslib->add_action('Categorized', $info['itemId'], $info['type'], "categId=$categId", $user);
512		}
513		TikiLib::events()->trigger('tiki.object.categorized', [
514			'object' => $info['itemId'],
515			'type' => $info['type'],
516			'added' => [$categId],
517			'removed' => [],
518		]);
519		require_once 'lib/search/refresh-functions.php';
520		refresh_index($info['type'], $info['itemId']);
521		return $result;
522	}
523
524	function uncategorize($catObjectId, $categId)
525	{
526		global $prefs;
527		$query = "delete from `tiki_category_objects` where `catObjectId`=? and `categId`=?";
528		$result = $this->query($query, [(int)$catObjectId, (int)$categId], -1, -1, false);
529
530		$cachelib = TikiLib::lib('cache');
531		if ($prefs['categories_cache_refresh_on_object_cat'] != "n") {
532			$cachelib->empty_type_cache("allcategs");
533		}
534		$info = TikiLib::lib('object')->get_object_via_objectid($catObjectId);
535		if ($prefs['feature_actionlog'] == 'y') {
536			$logslib = TikiLib::lib('logs');
537			$logslib->add_action('Uncategorized', $info['itemId'], $info['type'], "categId=$categId");
538		}
539		TikiLib::events()->trigger('tiki.object.categorized', [
540			'object' => $info['itemId'],
541			'type' => $info['type'],
542			'added' => [],
543			'removed' => [$categId],
544		]);
545		require_once 'lib/search/refresh-functions.php';
546		refresh_index($info['type'], $info['itemId']);
547		return $result;
548	}
549
550	// WARNING: This may not do what you would think from the name.
551	// Returns an array of the OIDs of a set of categories.
552	// $categId is an integer.
553	// If $categId is 0, that set is the set of all categories.
554	// If $categId is the OID of a category, that set is the set of that category and its descendants.
555	function get_category_descendants($categId)
556	{
557		if ($categId) {
558			$category = $this->get_category($categId);
559			if ($category == false) {
560				return false;
561			}
562			return array_merge([$categId], $category['descendants']);
563		} else {
564			return array_keys($this->getCategories(null, false, false));
565		}
566	}
567
568	function get_category_by_name($name, $parentId)
569	{
570		$query = "select * from `tiki_categories` where `parentId`=? and `name`=?";
571		$result = $this->query($query, [$parentId, $name]);
572		return $result->fetchRow();
573	}
574
575	function list_category_objects($categId, $offset, $maxRecords, $sort_mode = 'pageName_asc', $type = '', $find = '', $deep = false, $and = false, $filter = null)
576	{
577		global $prefs;
578		$userlib = TikiLib::lib('user');
579		if ($prefs['feature_sefurl'] == 'y') {
580			include_once('tiki-sefurl.php');
581		}
582		if ($prefs['feature_trackers'] == 'y') {
583			$trklib = TikiLib::lib('trk');
584		}
585
586		// Build the condition to restrict which categories objects must be in to be returned.
587		$join = '';
588		if (is_array($categId) && $and) {
589			$categId = $this->get_jailed($categId);
590			$i = count($categId) + 1;
591			$bindWhere = [];
592			foreach ($categId as $c) {
593				if (--$i) {
594					$join .= " INNER JOIN tiki_category_objects tco$i on tco$i.`catObjectId`=o.`objectId` and tco$i.`categId`=? ";
595					$bindWhere[] = $c;
596				}
597			}
598		} elseif (is_array($categId)) {
599			$bindWhere = $categId;
600			if ($deep) {
601				foreach ($categId as $c) {
602					$bindWhere = array_merge($bindWhere, $this->get_category_descendants($c));
603				}
604			}
605
606			$bindWhere = $this->get_jailed($bindWhere);
607			$bindWhere[] = -1;
608
609			$where = " AND c.`categId` IN (" . str_repeat("?,", count($bindWhere) - 1) . "?)";
610		} else {
611			if ($deep) {
612				$bindWhere = $this->get_category_descendants($categId);
613				$bindWhere[] = $categId;
614				$bindWhere = $this->get_jailed($bindWhere);
615				$bindWhere[] = -1;
616				$where = " AND c.`categId` IN (" . str_repeat("?,", count($bindWhere) - 1) . "?)";
617			} else {
618				$bindWhere = [$categId];
619				$where = ' AND c.`categId`=? ';
620			}
621		}
622
623		// Restrict results by keyword
624		if ($find) {
625			$findesc = '%' . $find . '%';
626			$bindWhere[] = $findesc;
627			$bindWhere[] = $findesc;
628			$where .= " AND (`name` LIKE ? OR `description` LIKE ?)";
629		}
630		if (! empty($type)) {
631			if (is_array($type)) {
632				$where .= ' AND `type` in (' . implode(',', array_fill(0, count($type), '?')) . ')';
633				$bindWhere = array_merge($bindWhere, $type);
634			} else {
635				$where .= ' AND `type` =? ';
636				$bindWhere[] = $type;
637			}
638		}
639		if (! empty($filter['language']) && ! empty($type) && ($type == 'wiki' || $type == 'wiki page' || in_array('wiki', (array)$type) || in_array('wiki page', (array)$type))) {
640			$join .= 'LEFT JOIN `tiki_pages` tp ON (o.`itemId` = tp.`pageName`)';
641			if (! empty($filter['language_unspecified'])) {
642				$where .= ' AND (tp.`lang` IS NULL OR tp.`lang` = ? OR tp.`lang`=?)';
643				$bindWhere[] = '';
644			} else {
645				$where .= ' AND  tp.`lang`=?';
646			}
647			$bindWhere[] = $filter['language'];
648		}
649
650		$bindVars = $bindWhere;
651
652		$orderBy = '';
653		if ($sort_mode) {
654			if ($sort_mode != 'shuffle') {
655				$orderBy = " ORDER BY " . $this->convertSortMode($sort_mode);
656			}
657		}
658
659		// Fetch all results as was done before, but only do it once
660		$query_cant = "SELECT DISTINCT c.*, o.* FROM `tiki_category_objects` c, `tiki_categorized_objects` co, `tiki_objects` o $join WHERE c.`catObjectId`=o.`objectId` AND o.`objectId`=co.`catObjectId` $where";
661		$query = $query_cant . $orderBy;
662		$result = $this->fetchAll($query, $bindVars);
663		$cant = count($result);
664
665		if ($sort_mode == 'shuffle') {
666			shuffle($ret);
667		}
668
669		return $this->filter_object_list($result, $cant, $offset, $maxRecords);
670	}
671
672	/**
673	 * @param array $result object list
674	 * @param int $cant size of list
675	 * @param int $offset start of list
676	 * @param int $maxRecords size of page - NB: -1 will check perms etc on every object and can be very slow
677	 * @return array
678	 */
679	private function filter_object_list($result, $cant, $offset, $maxRecords)
680	{
681		global $user, $prefs;
682		$permMap = TikiLib::lib('object')->map_object_type_to_permission();
683		$groupList = $this->get_user_groups($user);
684
685		// Filter based on permissions
686		$contextMap = ['type' => 'type', 'object' => 'itemId'];
687		$contextMapMap = array_fill_keys(array_keys($permMap), $contextMap);
688
689		if ($maxRecords == -1) {
690			$requiredResult = $result;
691		} else {
692			$requiredResult = array_slice($result, $offset, $maxRecords);
693		}
694		$requiredResult = Perms::mixedFilter([], 'type', 'object', $requiredResult, $contextMapMap, $permMap);
695
696		if ($maxRecords != -1) {    // if filtered result is less than what's there look for more
697			while (count($requiredResult) < $maxRecords && count($requiredResult) < $cant) {
698				$nextResults = array_slice($result, $maxRecords, $maxRecords - count($requiredResult));
699				$nextResults = Perms::mixedFilter([], 'type', 'object', $nextResults, $contextMapMap, $permMap);
700				if (empty($nextResults)) {
701					break;
702				}
703				$requiredResult = array_merge($requiredResult, $nextResults);
704			}
705		} else {
706			$cant = count($requiredResult);
707		}
708		$result = $requiredResult;
709
710		$ret = [];
711		$objs = [];
712
713		foreach ($result as $res) {
714			if (! in_array($res['catObjectId'] . '-' . $res['categId'], $objs)) { // same object and same category
715				if (preg_match('/trackeritem/', $res['type']) && $res['description'] == '') {
716					$trklib = TikiLib::lib('trk');
717					$trackerId = preg_replace('/^.*trackerId=([0-9]+).*$/', '$1', $res['href']);
718					$res['name'] = $trklib->get_isMain_value($trackerId, $res['itemId']);
719					$filed = $trklib->get_field_id($trackerId, "description");
720					$res['description'] = $trklib->get_item_value($trackerId, $res['itemId'], $filed);
721					if (empty($res['description'])) {
722						$res['description'] = $this->getOne("select `name` from `tiki_trackers` where `trackerId`=?", [(int)$trackerId]);
723					}
724				}
725				if ($prefs['feature_sefurl'] == 'y') {
726					$type = $res['type'] == 'wiki page' ? 'wiki' : $res['type'];
727					$res['sefurl'] = filter_out_sefurl($res['href'], $type);
728				}
729				if (empty($res['name'])) {
730					$res['name'] = '#' . $res['itemId'];
731				}
732				$ret[] = $res;
733				$objs[] = $res['catObjectId'] . '-' . $res['categId'];
734			}
735		}
736
737		return [
738			"data" => $ret,
739			"cant" => $cant,
740		];
741	}
742
743	function list_orphan_objects($offset, $maxRecords, $sort_mode)
744	{
745		$orderClause = $this->convertSortMode($sort_mode);
746
747		$common = "
748			FROM
749				tiki_objects
750				LEFT JOIN tiki_category_objects ON objectId = catObjectId
751			WHERE
752				catObjectId IS NULL
753			ORDER BY $orderClause
754			";
755
756		$query = "SELECT objectId catObjectId, 0 categId, type, itemId, name, href $common";
757		$queryCount = "SELECT COUNT(*) $common";
758
759		$result = $this->fetchAll($query, [], $maxRecords, $offset);
760		$count = $this->getOne($queryCount);
761
762		return $this->filter_object_list($result, $count, $offset, $maxRecords);
763	}
764
765	// get specific object types that are not categorised
766	function get_catorphan_object_type($offset, $maxRecords, $object_type, $object_table, $object_ref, $sort_mode = null)
767	{
768		// $orderClause = $this->convertSortMode($sort_mode); // sort_mode not being used yet and may never be used?
769
770		// 1st query 'common' element to get objects that are definitely not categorised if they are not in tiki_objects - needs to be modified for wiki pages using the new method
771		if ($object_type == "wiki page") {
772			$common1 = "
773			FROM tiki_" . $object_table . "
774			WHERE pageName NOT IN
775			(SELECT itemId FROM tiki_objects WHERE type='" . $object_type . "')
776			";
777		} else {
778			$common1 = "
779			FROM tiki_" . $object_table . "
780			WHERE " . $object_ref . " NOT IN
781			(SELECT itemId FROM tiki_objects WHERE type='" . $object_type . "')
782			";
783		}
784
785		// 2nd query 'common' element to get objects that have been categorised before so are in tiki_objects but are no longer categorised plus an additional check that the object is still in the main object table and hasn't been deleted without deleting the entries in the categorisation tables
786		if ($object_type == "wiki page") {
787			$common2 = "
788			FROM
789				tiki_objects
790				LEFT JOIN tiki_category_objects ON objectId = catObjectId
791			WHERE
792				(catObjectId IS NULL and type='wiki page' and itemId IN (SELECT pageName as itemId FROM tiki_pages))
793			";
794		} else {
795			$common2 = "
796			FROM
797				tiki_objects
798				LEFT JOIN tiki_category_objects ON objectId = catObjectId
799			WHERE
800				(catObjectId IS NULL and type='" . $object_type . "' and itemId IN (SELECT " . $object_ref . " as itemId FROM tiki_" . $object_table . "))
801			";
802		}
803
804
805		//create the full queries for the results and to get the counts - modify how the query is formed dependent upon the DB field names for the different object types
806		if ($object_type == "article") {
807			$query1 = "SELECT tiki_" . $object_table . ".title as name," . $object_ref . " as dataId,tiki_" . $object_table . ".subtitle $common1";
808		} elseif ($object_type == "blog") {
809			$query1 = "SELECT tiki_" . $object_table . ".title as name," . $object_ref . " as dataId,tiki_" . $object_table . ".description $common1";
810		} elseif ($object_type == "wiki page") {
811			$query1 = "SELECT tiki_" . $object_table . ".pageName," . $object_ref . " as dataId,tiki_" . $object_table . ".description $common1";
812		} else {
813			$query1 = "SELECT tiki_" . $object_table . ".name," . $object_ref . " as dataId,tiki_" . $object_table . ".description $common1";
814		}
815		//
816		if ($object_type == "wiki page") {
817			$query2 = "SELECT name as pageName,itemId as dataId,description $common2";
818		} else {
819			$query2 = "SELECT name,itemId as dataId,description $common2";
820		}
821
822		$queryCount1 = "SELECT COUNT(*) $common1";
823		$queryCount2 = "SELECT COUNT(*) $common2";
824
825		// get results for 1st query
826		$result1 = $this->fetchAll($query1, []);
827		$count1 = $this->getOne($queryCount1);
828
829		// get results for 2nd query
830		$result2 = $this->fetchAll($query2, []);
831		$count2 = $this->getOne($queryCount2);
832
833		//merge the results for the two queries
834		$result = array_merge($result1, $result2);
835		$count = $count1 + $count2;
836		$countall = $count;
837
838		// do a simple sort on the data
839		sort($result);
840
841		// apply the maxRecord and offset if not displaying all the results
842		if ($maxRecords == -1) {
843			$requiredResult = $result;
844		} else {
845			$requiredResult = array_slice($result, $offset, $maxRecords);
846		}
847
848		if ($maxRecords != -1) {    // if filtered result is less than what's there look for more
849			while (count($requiredResult) < $maxRecords && count($requiredResult) < $count) {
850				$nextResults = array_slice($result, $maxRecords, $maxRecords - count($requiredResult));
851				if (empty($nextResults)) {
852					break;
853				}
854				$requiredResult = array_merge($requiredResult, $nextResults);
855			}
856		} else {
857			$count = count($requiredResult);
858		}
859		$result = $requiredResult;
860
861		// return the maxRecord data result and data count plus the actual total count as a single array
862		return [
863			"data" => $result,
864			"cant" => $count,
865			"countall" => $countall,
866		];
867	}
868
869	// get the parent categories of an object
870	function get_object_categories($type, $itemId, $parentId = -1, $jailed = true)
871	{
872		$ret = [];
873		if (! $itemId) {
874			return $ret;
875		}
876		if ($parentId == -1) {
877			$query = "select `categId` from `tiki_category_objects` tco, `tiki_categorized_objects` tto, `tiki_objects` o
878				where tco.`catObjectId`=tto.`catObjectId` and o.`objectId`=tto.`catObjectId` and o.`type`=? and `itemId`=?";
879			//settype($itemId,"string"); //itemId is defined as varchar
880			$bindvars = ["$type", $itemId];
881		} else {
882			$query = "select tc.`categId` from `tiki_category_objects` tco, `tiki_categorized_objects` tto, `tiki_objects` o,`tiki_categories` tc
883    		where tco.`catObjectId`=tto.`catObjectId` and o.`objectId`=tto.`catObjectId` and o.`type`=? and `itemId`=? and tc.`parentId` = ? and tc.`categId`=tco.`categId`";
884			$bindvars = ["$type", $itemId, (int)$parentId];
885		}
886		$result = $this->query($query, $bindvars);
887		while ($res = $result->fetchRow()) {
888			$ret[] = (int)$res["categId"];
889		}
890
891		if ($jailed) {
892			return $this->get_jailed($ret);
893		} else {
894			return $ret;
895		}
896	}
897
898	// WARNING: This method is very different from get_categoryobjects()
899	// Get all the objects in a category
900	// filter = array('table'=>, 'join'=>, 'filter'=>, 'bindvars'=>)
901	function get_category_objects($categId, $type = null, $filter = null)
902	{
903		$bindVars[] = (int)$categId;
904		if (! empty($type)) {
905			$where = ' and o.`type`=?';
906			$bindVars[] = $type;
907		} else {
908			$where = '';
909		}
910		if (! empty($filter)) {
911			$from = ',`' . $filter['table'] . '` ft';
912			$where .= ' and o.`itemId`=ft.`' . $filter['join'] . '` and ft.`' . $filter['filter'] . '`=?';
913			$bindVars[] .= $filter['bindvars'];
914		} else {
915			$from = '';
916		}
917		$query = "select * from `tiki_category_objects` c,`tiki_categorized_objects` co, `tiki_objects` o $from where c.`catObjectId`=co.`catObjectId` and co.`catObjectId`=o.`objectId` and c.`categId`=?" . $where;
918		return $this->fetchAll($query, $bindVars);
919	}
920
921	/**
922	 * Removes the object with the given identifer from the category with the given identifier
923	 * @param $catObjectId
924	 * @param $categId
925	 * @return bool|TikiDb_Pdo_Result|TikiDb_Adodb_Result
926	 * @throws Exception
927	 */
928	function remove_object_from_category($catObjectId, $categId)
929	{
930		return $this->remove_object_from_categories($catObjectId, [$categId]);
931	}
932
933	/**
934	 * Removes the object with the given identifier from the categories specified in the $categIds array. The array contains category identifiers.
935	 * @param $catObjectId
936	 * @param $categIds
937	 * @return bool|TikiDb_Pdo_Result|TikiDb_Adodb_Result
938	 * @throws Exception
939	 */
940	function remove_object_from_categories($catObjectId, $categIds)
941	{
942		global $prefs;
943		if (! empty($categIds)) {
944			$cachelib = TikiLib::lib('cache');
945			$query = "delete from `tiki_category_objects` where `catObjectId`=? and `categId` in (" . implode(',', array_fill(0, count($categIds), '?')) . ")";
946			$result = $this->query($query, array_merge([$catObjectId], $categIds));
947			$query = "select count(*) from `tiki_category_objects` where `catObjectId`=?";
948			$cant = $this->getOne($query, [(int)$catObjectId]);
949			if (! $cant) {
950				$query = "delete from `tiki_categorized_objects` where `catObjectId`=?";
951				$result = $this->query($query, [(int)$catObjectId]);
952			}
953			if ($prefs['categories_cache_refresh_on_object_cat'] != "n") {
954				$cachelib->empty_type_cache("allcategs");
955			}
956			$cachelib->empty_type_cache('fgals_perms');
957			return $result;
958		} else {
959			return false;
960		}
961	}
962
963	// Categorize the object of the given type and with the given unique identifier in the categories specified in the second parameter.
964	// $categIds can be a category OID or an array of category OIDs.
965	// $type The object's type, which has to be one of those handled by ObjectLib's add_object().
966	// Returns the object OID, or FALSE if the given type is not handled.
967	/**
968	 * @param $type
969	 * @param $identifier
970	 * @param $categIds
971	 * @return array|bool|mixed
972	 */
973	function categorize_any($type, $identifier, $categIds)
974	{
975		$catObjectId = $this->add_categorized_object($type, $identifier, null, null, null, true);
976		if ($catObjectId === false) {
977			return false;
978		}
979		if (! is_array($categIds)) {
980			$categIds = [$categIds];
981		}
982		foreach ($categIds as $categId) {
983			$this->categorize($catObjectId, $categId);
984		}
985
986		return $catObjectId;
987	}
988
989	// Return an array enumerating a subtree with the given root node in preorder
990	private function getSortedSubTreeNodes($root, &$categories)
991	{
992		global $prefs;
993		$subTreeNodes = [$root];
994		$childrenSubTreeNodes = [];
995		foreach ($categories[$root]['children'] as $child) {
996			$childrenSubTreeNodes[$categories[$child]['name']] = $this->getSortedSubTreeNodes($child, $categories);
997		}
998		if ($prefs['category_sort_ascii'] == 'y') {
999			uksort($childrenSubTreeNodes, ["CategLib", "cmpcatname"]);
1000		} else {
1001			ksort($childrenSubTreeNodes, SORT_LOCALE_STRING);
1002		}
1003		foreach ($childrenSubTreeNodes as $childSubTreeNodes) {
1004			$subTreeNodes = array_merge($subTreeNodes, $childSubTreeNodes);
1005		}
1006		return $subTreeNodes;
1007	}
1008
1009	/* Returns an array of categories.
1010	Each category is similar to a tiki_categories record, but with the following additional fields:
1011		"children" is an array of identifiers of the categories the category has as children.
1012		"descendants" is an array of identifiers of the categories the category has as descendants.
1013		"objects" is the number of objects directly in the category.
1014		"tepath" is an array representing the path to the category in the category tree, ordered from the ancestor to the category. Each element is the name of the represented category. Indices are category OIDs.
1015		"categpath" is a string representing the path to the category in the category tree, ordered from the ancestor to the category. Each category is separated by "::". For example, "Tiki" could have categpath "Software::Free software::Tiki".
1016		"relativePathString" defaults to categpath.
1017		When and only when filtering with a filter of type "children" or "descendants", it becomes the part of "categpath" which starts from after the filtered category rather than from a root category.
1018		For example, if filtering descendants of category "Software", the "relativePathString" of a grandchild may be "Free Software::Tiki".
1019
1020	By default, we start from all categories. This happens if the filter is NULL or if its type is set to "all".
1021	If $filter is an array with an "identifier" element or a "type" element set to "roots", starting categories are restrained.
1022	If the "type" element is set to "roots", start from the root categories.
1023	If the "type" element is unset or set to "self", start from only the designated category.
1024	If the "type" element is set to "children", start from the designated category's children.
1025	If the "type" element is set to "descendants", start from the designated category's descendants.
1026	In the last 3 cases, an "identifier" element must be present.
1027
1028	If considerCategoryFilter is true, only categories that match the category filter are returned.
1029	If considerPermissions is true, only categories that the user has the permission to view are returned.
1030	If localized is enabled, category names are translated to the user's language.
1031	*/
1032	function getCategories($filter = ['type' => 'all'], $considerCategoryFilter = true, $considerPermissions = true, $localized = true)
1033	{
1034		global $prefs;
1035		$cachelib = TikiLib::lib('cache');
1036		$cacheKey = 'all' . ($localized ? '_' . $prefs['language'] : '');
1037		if (! $ret = $cachelib->getSerialized($cacheKey, 'allcategs')) {
1038			// This generates different caches for each language. The empty key is used when no localization was requested.
1039			// This could be optimized, but for now each cache is generated from scratch.
1040
1041			$categories = [];
1042			$roots = [];
1043			$query = "select *, (select count(*) from tiki_categories_roles_available cr where tc.categId = cr.categId ) as num_roles from `tiki_categories` tc;";
1044			$result = $this->query($query, []);
1045			while ($res = $result->fetchRow()) {
1046				$id = $res["categId"];
1047				if ($prefs['category_browse_count_objects'] === 'y') {
1048					$query = "select count(*) from `tiki_category_objects` where `categId`=?";
1049					$res['objects'] = $this->getOne($query, [$id]);
1050				} else {
1051					$res['objects'] = null;
1052				}
1053				$res['children'] = [];
1054				$res['descendants'] = [];
1055				if ($localized) {
1056					$res['name'] = tr($res['name']);
1057				}
1058
1059				$categories[$id] = $res;
1060			}
1061
1062			foreach ($categories as &$category) {
1063				if ($category['parentId']) {
1064					// Link this category from its parent.
1065					$categories[$category['parentId']]['children'][] = $category['categId'];
1066				} else {
1067					// Mark as a root category.
1068					$roots[$category['name']] = $category['categId'];
1069				}
1070
1071				$path = [$category['categId'] => $category['name']];
1072
1073				$parent = $category['parentId'];
1074				while (! empty($parent)) {
1075					if (isset($categories[$parent]['name'])) {
1076						$path[$parent] = $categories[$parent]['name'];
1077					} else {
1078						$path[$parent] = "";
1079					}
1080
1081					$categories[$parent]['descendants'][] = $category['categId']; // Link this category from its ascendants for optimization.
1082					if (isset($categories[$parent]['parentId'])) {
1083						$parent = $categories[$parent]['parentId'];
1084					} else {
1085						$parent = 0;
1086					}
1087				}
1088				$path = array_reverse($path, true);
1089
1090				$category["tepath"] = $path;
1091				$category["categpath"] = implode("::", $path);
1092				$category["relativePathString"] = $category["categpath"];
1093			}
1094
1095			// Sort in preorder. Siblings are sorted by name.
1096			if ($prefs['category_sort_ascii'] == 'y') {
1097				uksort($roots, ["CategLib", "cmpcatname"]);
1098			} else {
1099				ksort($roots, SORT_LOCALE_STRING);
1100			}
1101			$sortedCategoryIdentifiers = [];
1102			foreach ($roots as $root) {
1103				$sortedCategoryIdentifiers = array_merge($sortedCategoryIdentifiers, $this->getSortedSubTreeNodes($root, $categories));
1104			}
1105			$ret = [];
1106			foreach ($sortedCategoryIdentifiers as $categoryIdentifier) {
1107				$ret[$categories[$categoryIdentifier]['categId']] = $categories[$categoryIdentifier];
1108			}
1109			unset($categories);
1110
1111			$cachelib->cacheItem($cacheKey, serialize($ret), 'allcategs');
1112			$cachelib->cacheItem('roots', serialize($roots), 'allcategs'); // Used in get_category_descendants()
1113		}
1114
1115		$type = is_null($filter) ? 'all' : (isset($filter['type']) ? $filter['type'] : 'self');
1116		if ($type != 'all') {
1117			$kept = [];
1118			if ($type != 'roots') {
1119				if (! isset($filter['identifier'])) {
1120					throw new Exception("Missing base category");
1121				}
1122				if (! empty($ret) && isset($ret[$filter['identifier']])) {
1123					$filterBaseCategory = $ret[$filter['identifier']];
1124				} else {
1125					$filterBaseCategory = null;
1126				}
1127			}
1128			switch ($type) {
1129				case 'children':
1130					$kept = $filterBaseCategory['children'] ?? [];
1131					break;
1132				case 'descendants':
1133					$kept = $filterBaseCategory['descendants'] ?? [];
1134					break;
1135				case 'roots':
1136					$kept = $cachelib->getSerialized('roots', 'allcategs');
1137					break;
1138				default:
1139					$ret = [$filter['identifier'] => $filterBaseCategory]; // Avoid array functions for optimization
1140			}
1141			if ($type != 'self') {
1142				$ret = array_intersect_key($ret, array_flip($kept));
1143
1144				if ($type != 'roots') {
1145					// Set relativePathString by stripping the length of the common ancestor plus 2 characters for the pathname separator ("::").
1146					$strippedLength = strlen($filterBaseCategory['categpath']) + 2;
1147					foreach ($ret as &$category) {
1148						$category['relativePathString'] = substr($category['categpath'], $strippedLength);
1149					}
1150				}
1151			}
1152		}
1153
1154		if ($considerCategoryFilter) {
1155			if ($jail = $this->get_jail()) {
1156				$area = [];
1157				if ($prefs['feature_areas'] === 'y') {
1158					$areaslib = TikiLib::lib('areas');
1159					$area = $areaslib->getAreaByPerspId($_SESSION['current_perspective']);
1160				}
1161				$roots = array_filter((array)$prefs['category_jail_root']); // Skip 0 and other forms of empty
1162
1163				$ret = array_filter(
1164					$ret,
1165					function ($category) use ($jail, $roots, $area) {
1166						if (in_array($category['categId'], $jail)) {
1167							return true;
1168						}
1169						if ($area && ! $area['share_common']) {
1170							return false;
1171						}
1172
1173						if ($category['rootId'] && ! in_array($category['rootId'], $roots)) {
1174							return true;
1175						} elseif (! $category['rootId'] && ! in_array($category['categId'], $roots)) {
1176							return true;
1177						}
1178
1179						return false;
1180					}
1181				);
1182			}
1183		}
1184
1185		if ($considerPermissions) {
1186			$categoryIdentifiers = array_keys($ret);
1187			if (is_null($categoryIdentifiers)) {
1188				$categoryIdentifiers = [];
1189			}
1190			Perms::bulk(['type' => 'category'], 'object', $categoryIdentifiers);
1191			foreach ($categoryIdentifiers as $categoryIdentifier) {
1192				$permissions = Perms::get(['type' => 'category', 'object' => $categoryIdentifier]);
1193				if (! $permissions->view_category) {
1194					unset($ret[$categoryIdentifier]);
1195				}
1196			}
1197		}
1198
1199		return $ret;
1200	}
1201
1202	// get categories related to a link. For Whats related module.
1203	function get_link_categories($link)
1204	{
1205		$ret = [];
1206		$parsed = parse_url($link);
1207		$urlPath = preg_split("#\/#", $parsed["path"]);
1208		$parsed["path"] = end($urlPath);
1209		if (! isset($parsed["query"])) {
1210			return ($ret);
1211		}
1212		/* not yet used. will be used to get the "base href" of a page
1213		$params=array();
1214		$a = explode('&', $parsed["query"]);
1215		for ($i=0; $i < count($a);$i++) {
1216			$b = preg_split('/=/', $a[$i]);
1217			$params[htmlspecialchars(urldecode($b[0]))]=htmlspecialchars(urldecode($b[1]));
1218		}
1219		*/
1220		$query = "select distinct co.`categId` from `tiki_objects` o, `tiki_categorized_objects` cdo, `tiki_category_objects` co  where o.`href`=? and cdo.`catObjectId`=co.`catObjectId` and o.`objectId` = cdo.`catObjectId`";
1221		$result = $this->query($query, [$parsed["path"] . "?" . $parsed["query"]]);
1222		while ($res = $result->fetchRow()) {
1223			$ret[] = $res["categId"];
1224		}
1225		return ($ret);
1226	}
1227
1228	// input is a array of category id's and return is a array of
1229	// maxRows related links with description
1230	function get_related($categories, $maxRows = 10)
1231	{
1232		global $tiki_p_admin, $user;
1233		if (count($categories) == 0) {
1234			return ([]);
1235		}
1236		$quarr = implode(",", array_fill(0, count($categories), '?'));
1237		$query = "select distinct o.`type`, o.`description`, o.`itemId`,o.`href` from `tiki_objects` o, `tiki_categorized_objects` cdo, `tiki_category_objects` co  where co.`categId` in (" . $quarr . ") and co.`catObjectId`=cdo.`catObjectId` and o.`objectId`=cdo.`catObjectId`";
1238		$result = $this->query($query, $categories);
1239		$ret = [];
1240		if ($tiki_p_admin != 'y') {
1241			$permMap = TikiLib::lib('object')->map_object_type_to_permission();
1242		}
1243		while ($res = $result->fetchRow()) {
1244			if ($tiki_p_admin == 'y' || $this->user_has_perm_on_object($user, $res['itemId'], $res['type'], $permMap[$res['type']])) {
1245				if (empty($res["description"])) {
1246					$ret[$res["href"]] = $res["type"] . ": " . $res["itemId"];
1247				} else {
1248					$ret[$res["href"]] = $res["type"] . ": " . $res["description"];
1249				}
1250			}
1251		}
1252		if (count($ret) > $maxRows) {
1253			$ret2 = [];
1254			$rand_keys = array_rand($ret, $maxRows);
1255			foreach ($rand_keys as $value) {
1256				$ret2[$value] = $ret[$value];
1257			}
1258			return ($ret2);
1259		}
1260		return ($ret);
1261	}
1262
1263	// combines the two functions above
1264	function get_link_related($link, $maxRows = 10)
1265	{
1266		return ($this->get_related($this->get_link_categories($link), $maxRows));
1267	}
1268
1269	// Moved from tikilib.php
1270	function uncategorize_object($type, $id)
1271	{
1272		global $prefs;
1273		$query = "select `catObjectId` from `tiki_categorized_objects` c, `tiki_objects` o where o.`objectId`=c.`catObjectId` and o.`type`=? and o.`itemId`=?";
1274		$catObjectId = $this->getOne($query, [(string)$type, (string)$id]);
1275
1276		if ($catObjectId) {
1277			$info = TikiLib::lib('object')->get_object_via_objectid($catObjectId);
1278
1279			$query = "select `categId` from `tiki_category_objects` where `catObjectId`=?";
1280			$result = $this->fetchAll($query, [(int)$catObjectId]);
1281			$removed = [];
1282			foreach ($result as $row) {
1283				$removed[] = $row['categId'];
1284			}
1285			$removed = array_unique($removed);
1286
1287			$query = "delete from `tiki_category_objects` where `catObjectId`=?";
1288			$this->query($query, [(int)$catObjectId]);
1289			// must keep tiki_categorized object because poll or ... can use it
1290
1291			// Refresh categories
1292			$cachelib = TikiLib::lib('cache');
1293			if ($prefs['categories_cache_refresh_on_object_cat'] != "n") {
1294				$cachelib->empty_type_cache("allcategs");
1295			}
1296			$cachelib->empty_type_cache('fgals_perms');
1297
1298			TikiLib::events()->trigger('tiki.object.categorized', [
1299				'object' => $info['itemId'],
1300				'type' => $info['type'],
1301				'added' => [],
1302				'removed' => $removed,
1303			]);
1304		}
1305	}
1306
1307	// Get a string of HTML code representing an object's category paths.
1308	// $cats: The OIDs of the categories of the object.
1309	function get_categorypath($cats)
1310	{
1311		global $prefs;
1312		$smarty = TikiLib::lib('smarty');
1313		if (! isset($prefs['categorypath_excluded'])) {
1314			return false;
1315		}
1316
1317		$excluded = [];
1318		if (is_array($prefs['categorypath_excluded'])) {
1319			$excluded = $prefs['categorypath_excluded'];
1320		} else {
1321			$excluded = preg_split('/,/', $prefs['categorypath_excluded']);
1322		}
1323		$cats = array_diff($cats, $excluded);
1324
1325		$catpath = '';
1326		foreach ($cats as $categId) {
1327			$catp = [];
1328			$info = $this->get_category($categId);
1329			if (! in_array($info['categId'], $excluded)) {
1330				$catp[$info['categId']] = $info['name'];
1331			}
1332			while ($info["parentId"] != 0) {
1333				$info = $this->get_category($info["parentId"]);
1334				if (! in_array($info['categId'], $excluded)) {
1335					$catp[$info['categId']] = $info['name'];
1336				}
1337			}
1338
1339			// Check if user has permission to view the page
1340			$perms = Perms::get(['type' => 'category', 'object' => $categId]);
1341			$canView = $perms->view_category;
1342
1343			if ($canView || in_array($prefs['categorypath_format'], ['link_or_text', 'always_text'])) {
1344				$smarty->assign('catpathShowLink', $canView && in_array($prefs['categorypath_format'], ['link_when_visible', 'link_or_text']));
1345				$smarty->assign('catp', array_reverse($catp, true));
1346				$catpath .= $smarty->fetch('categpath.tpl');
1347			}
1348		}
1349		return $catpath;
1350	}
1351
1352	// WARNING: This method is very different from get_category_objects()
1353	// Format a list of objects in the given categories, returning HTML code.
1354	function get_categoryobjects($catids, $types = "*", $sort = 'created_desc', $split = true, $sub = false, $and = false, $maxRecords = 500, $filter = null, $displayParameters = [])
1355	{
1356		global $prefs, $user;
1357		$smarty = TikiLib::lib('smarty');
1358
1359		$typetokens = [
1360			"article" => "article",
1361			"blog" => "blog",
1362			"blog post" => "blog post",
1363			"directory" => "directory",
1364			"faq" => "faq",
1365			"fgal" => "file gallery",
1366			"forum" => "forum",
1367			"igal" => "image gallery",
1368			"newsletter" => "newsletter",
1369			"poll" => "poll",
1370			"quiz" => "quiz",
1371			"survey" => "survey",
1372			"tracker" => "tracker",
1373			"wiki" => "wiki page",
1374			"calendar" => "calendar",
1375			"img" => "image",
1376			"template" => "template",
1377		];    //get_strings tra("article");tra("blog");tra("directory");tra("faq");tra("FAQ");tra("file gallery");tra("forum");tra("image gallery");tra("newsletter");
1378		//get_strings tra("poll");tra("quiz");tra("survey");tra("tracker");tra("wiki page");tra("image");tra("calendar");tra("template");
1379
1380		$typetitles = [
1381			"article" => "Articles",
1382			"blog" => "Blogs",
1383			"blog post" => "Blog Post",
1384			"directory" => "Directories",
1385			"faq" => "FAQs",
1386			"file gallery" => "File Galleries",
1387			"forum" => "Forums",
1388			"image gallery" => "Image Galleries",
1389			"newsletter" => "Newsletters",
1390			"poll" => "Polls",
1391			"quiz" => "Quizzes",
1392			"survey" => "Surveys",
1393			"tracker" => "Trackers",
1394			"wiki page" => "Wiki",
1395			"calendar" => "Calendar",
1396			"image" => "Image",
1397			"template" => "Content Templates",
1398		];
1399
1400		$out = "";
1401		$listcat = $allcats = [];
1402		$title = '';
1403		$find = "";
1404		$offset = 0;
1405		$firstpassed = false;
1406		$typesallowed = [];
1407		if (! isset($displayParameters['showTitle'])) {
1408			$displayParameters['showTitle'] = 'y';
1409		}
1410		if (! isset($displayParameters['categoryshowlink'])) {
1411			$displayParameters['categoryshowlink'] = 'y';
1412		}
1413		if (! isset($displayParameters['showtype'])) {
1414			$displayParameters['showtype'] = 'y';
1415		}
1416		if (! isset($displayParameters['one'])) {
1417			$displayParameters['one'] = 'n';
1418		}
1419		if (! isset($displayParameters['showlinks'])) {
1420			$displayParameters['showlinks'] = 'y';
1421		}
1422		if (! isset($displayParameters['showname'])) {
1423			$displayParameters['showname'] = 'y';
1424		}
1425		if (! isset($displayParameters['showdescription'])) {
1426			$displayParameters['showdescription'] = 'n';
1427		}
1428		$smarty->assign('params', $displayParameters);
1429		if ($and) {
1430			$split = false;
1431		}
1432		if ($types == '*') {
1433			$typesallowed = array_keys($typetitles);
1434		} elseif (strpos($types, '+')) {
1435			$alltypes = preg_split('/\+/', $types);
1436			foreach ($alltypes as $t) {
1437				if (isset($typetokens["$t"])) {
1438					$typesallowed[] = $typetokens["$t"];
1439				} elseif (isset($typetitles["$t"])) {
1440					$typesallowed[] = $t;
1441				}
1442			}
1443		} elseif (isset($typetokens["$types"])) {
1444			$typesallowed = [$typetokens["$types"]];
1445		} elseif (isset($typetitles["$types"])) {
1446			$typesallowed = [$types];
1447		}
1448		$out = $smarty->fetch("categobjects_title.tpl");
1449		foreach ($catids as $id) {
1450			if (! $this->user_has_perm_on_object($user, $id, 'category', 'tiki_p_view_category')) {
1451				continue;
1452			}
1453			$titles["$id"] = $this->get_category_name($id);
1454			$objectcat = [];
1455			$objectcat = $this->list_category_objects($id, $offset, $and ? -1 : $maxRecords, $sort, $types == '*' ? '' : $typesallowed, $find, $sub, false, $filter);
1456
1457			$acats = $andcat = [];
1458			foreach ($objectcat["data"] as $obj) {
1459				$type = $obj["type"];
1460				if (substr($type, 0, 7) == 'tracker') {
1461					$type = 'tracker';
1462				}
1463				if (($types == '*') || in_array($type, $typesallowed)) {
1464					if ($split or ! $firstpassed) {
1465						$listcat["$type"][] = $obj;
1466						$cats[] = $type . '.' . $obj['name'];
1467					} elseif ($and) {
1468						if (in_array($type . '.' . $obj['name'], $cats)) {
1469							$andcat["$type"][] = $obj;
1470							$acats[] = $type . '.' . $obj['name'];
1471						}
1472					} else {
1473						if (! in_array($type . '.' . $obj['name'], $cats)) {
1474							$listcat["$type"][] = $obj;
1475							$cats[] = $type . '.' . $obj['name'];
1476						}
1477					}
1478				}
1479			}
1480			if ($split) {
1481				$smarty->assign("id", $id);
1482				$smarty->assign("titles", $titles);
1483				$smarty->assign("listcat", $listcat);
1484				$smarty->assign("one", count($listcat));
1485				$out .= $smarty->fetch("categobjects.tpl");
1486				$listcat = [];
1487				$titles = [];
1488				$cats = [];
1489			} elseif ($and and $firstpassed) {
1490				$listcat = $andcat;
1491				$cats = $acats;
1492			}
1493			$firstpassed = true;
1494		}
1495		if (! $split) {
1496			$smarty->assign("id", $id);
1497			$smarty->assign("titles", $titles);
1498			$smarty->assign("listcat", $listcat);
1499			$smarty->assign("one", count($listcat));
1500			$out = $smarty->fetch("categobjects.tpl");
1501		}
1502		return $out;
1503	}
1504
1505	// Returns an array representing the last $maxRecords objects in the category with the given $categId of the given type, ordered by decreasing creation date. By default, objects of all types are returned.
1506	// Each array member is a string-indexed array with fields catObjectId, categId, type, name and href.
1507	function last_category_objects($categId, $maxRecords, $type = "")
1508	{
1509		$mid = "and `categId`=?";
1510		$bindvars = [(int)$categId];
1511		if ($type) {
1512			$mid .= " and `type`=?";
1513			$bindvars[] = $type;
1514		}
1515		$sort_mode = "created_desc";
1516		$query = "select co.`catObjectId`, `categId`, `type`, `name`, `href` from `tiki_category_objects` co, `tiki_categorized_objects` cdo, `tiki_objects` o where co.`catObjectId`=cdo.`catObjectId` and o.`objectId`=cdo.`catObjectId` $mid order by o." . $this->convertSortMode($sort_mode);
1517		$ret = $this->fetchAll($query, $bindvars, $maxRecords, 0);
1518
1519		return ['data' => $ret];
1520	}
1521
1522	// Gets a list of categories that will block objects to be seen by user, recursive
1523	function list_forbidden_categories($parentId = 0, $parentAllowed = '', $perm = 'tiki_p_view_categorized')
1524	{
1525		global $user;
1526		$userlib = TikiLib::lib('user');
1527		if (empty($parentAllowed)) {
1528			global $tiki_p_view_categorized;
1529			$parentAllowed = $tiki_p_view_categorized;
1530		}
1531
1532		$query = "select `categId` from `tiki_categories` where `parentId`=?";
1533		$result = $this->query($query, [$parentId]);
1534
1535		$forbidden = [];
1536
1537		while ($row = $result->fetchRow()) {
1538			$child = $row['categId'];
1539			if ($userlib->object_has_one_permission($child, 'category')) {
1540				if ($userlib->object_has_permission($user, $child, 'category', $perm)) {
1541					$forbidden = array_merge($forbidden, $this->list_forbidden_categories($child, 'y', $perm));
1542				} else {
1543					$forbidden[] = $child;
1544					$forbidden = array_merge($forbidden, $this->list_forbidden_categories($child, 'n', $perm));
1545				}
1546			} else {
1547				if ($parentAllowed != 'y') {
1548					$forbidden[] = $child;
1549				}
1550				$forbidden = array_merge($forbidden, $this->list_forbidden_categories($child, $parentAllowed, $perm));
1551			}
1552		}
1553		return $forbidden;
1554	}
1555
1556	/* build the portion of list join if filter by category
1557	 * categId can be a simple value, a list of values=>or between categ, array('AND'=>list values) for an AND
1558	 */
1559	function getSqlJoin($categId, $objType, $sqlObj, &$fromSql, &$whereSql, &$bindVars, $type = '?')
1560	{
1561		static $callno = 0;
1562		$callno++;
1563		$fromSql .= " inner join `tiki_objects` co$callno";
1564		$whereSql .= " AND co$callno.`type`=$type AND co$callno.`itemId`= $sqlObj ";
1565		if ($type == '?') {
1566			$bind = [$objType];
1567		} else {
1568			$bind = [];
1569		}
1570		if (isset($categId['AND']) && is_array($categId['AND'])) {
1571			$categId['AND'] = $this->get_jailed($categId['AND']);
1572			$i = 0;
1573			foreach ($categId['AND'] as $c) {
1574				$fromSql .= " inner join `tiki_category_objects` t{$callno}co$i ";
1575				$whereSql .= " AND t{$callno}co$i.`categId`= ?  AND co$callno.`objectId`=t{$callno}co$i.`catObjectId` ";
1576				++$i;
1577			}
1578			$bind = array_merge($bind, $categId['AND']);
1579		} elseif (is_array($categId)) {
1580			$categId = $this->get_jailed($categId);
1581			$fromSql .= " inner join `tiki_category_objects` tco$callno ";
1582			$whereSql .= " AND co$callno.`objectId`=tco$callno.`catObjectId` ";
1583			$whereSql .= "AND tco$callno.`categId` IN (" . implode(',', array_fill(0, count($categId), '?')) . ')';
1584			$bind = array_merge($bind, $categId);
1585		} else {
1586			$fromSql .= " inner join `tiki_category_objects` tco$callno ";
1587			$whereSql .= " AND co$callno.`objectId`=tco$callno.`catObjectId` ";
1588			$whereSql .= " AND tco$callno.`categId`= ? ";
1589			$bind[] = $categId;
1590		}
1591		if (is_array($bindVars)) {
1592			$bindVars = array_merge($bindVars, $bind);
1593		} else {
1594			$bindVars = $bind;
1595		}
1596	}
1597
1598	function exist_child_category($parentId, $name)
1599	{
1600		$query = 'select `categId` from `tiki_categories` where `parentId`=? and `name`=?';
1601		return ($this->getOne($query, [(int)$parentId, $name]));
1602	}
1603
1604	/**
1605	 * Sets watch entries for the given user and category.
1606	 */
1607	function watch_category($user, $categId, $categName)
1608	{
1609		$tikilib = TikiLib::lib('tiki');
1610		if ($categId != 0) {
1611			$name = $this->get_category_path_string_with_root($categId);
1612			$tikilib->add_user_watch(
1613				$user,
1614				'category_changed',
1615				$categId,
1616				'Category',
1617				$name,
1618				"tiki-browse_categories.php?parentId=" . $categId . "&deep=off"
1619			);
1620		}
1621	}
1622
1623
1624	/**
1625	 * Sets watch entries for the given user and category. Also includes
1626	 * all descendant categories for which the user has view permissions.
1627	 */
1628	function watch_category_and_descendants($user, $categId, $categName)
1629	{
1630		$tikilib = TikiLib::lib('tiki');
1631
1632		if ($categId != 0) {
1633			$tikilib->add_user_watch(
1634				$user,
1635				'category_changed',
1636				$categId,
1637				'Category',
1638				$categName,
1639				"tiki-browse_categories.php?parentId=" . $categId . "&deep=off"
1640			);
1641		}
1642
1643		$descendants = $this->get_category_descendants($categId);
1644		foreach ($descendants as $descendant) {
1645			if ($descendant != 0 && $this->has_view_permission($user, $descendant)) {
1646				$name = $this->get_category_path_string_with_root($descendant);
1647				$tikilib->add_user_watch(
1648					$user,
1649					'category_changed',
1650					$descendant,
1651					'Category',
1652					$name,
1653					"tiki-browse_categories.php?parentId=" . $descendant . "&deep=off"
1654				);
1655			}
1656		}
1657	}
1658
1659	function group_watch_category_and_descendants($group, $categId, $categName = null, $top = true)
1660	{
1661		$tikilib = TikiLib::lib('tiki');
1662
1663		if ($categId != 0 && $top == true) {
1664			$tikilib->add_group_watch(
1665				$group,
1666				'category_changed',
1667				$categId,
1668				'Category',
1669				$categName,
1670				"tiki-browse_categories.php?parentId=" . $categId . "&deep=off"
1671			);
1672		}
1673		$descendants = $this->get_category_descendants($categId);
1674		if ($top == false) {
1675			$length = count($descendants);
1676			$descendants = array_slice($descendants, 1, $length, true);
1677		}
1678		foreach ($descendants as $descendant) {
1679			if ($descendant != 0) {
1680				$name = $this->get_category_path_string_with_root($descendant);
1681				$tikilib->add_group_watch(
1682					$group,
1683					'category_changed',
1684					$descendant,
1685					'Category',
1686					$name,
1687					"tiki-browse_categories.php?parentId=" . $descendant . "&deep=off"
1688				);
1689			}
1690		}
1691	}
1692
1693
1694	/**
1695	 * Removes the watch entry for the given user and category.
1696	 */
1697	function unwatch_category($user, $categId)
1698	{
1699		$tikilib = TikiLib::lib('tiki');
1700
1701		$tikilib->remove_user_watch($user, 'category_changed', $categId, 'Category');
1702	}
1703
1704
1705	/**
1706	 * Removes the watch entry for the given user and category. Also
1707	 * removes all entries for the descendants of the category.
1708	 */
1709	function unwatch_category_and_descendants($user, $categId)
1710	{
1711		$tikilib = TikiLib::lib('tiki');
1712
1713		$tikilib->remove_user_watch($user, 'category_changed', $categId, 'Category');
1714		$descendants = $this->get_category_descendants($categId);
1715		foreach ($descendants as $descendant) {
1716			$tikilib->remove_user_watch($user, 'category_changed', $descendant, 'Category');
1717		}
1718	}
1719
1720	function group_unwatch_category_and_descendants($group, $categId, $top = true)
1721	{
1722		$tikilib = TikiLib::lib('tiki');
1723
1724		if ($categId != 0 && $top == true) {
1725			$tikilib->remove_group_watch($group, 'category_changed', $categId, 'Category');
1726		}
1727		$descendants = $this->get_category_descendants($categId);
1728		if ($top == false) {
1729			$length = count($descendants);
1730			$descendants = array_slice($descendants, 1, $length, true);
1731		}
1732		foreach ($descendants as $descendant) {
1733			if ($descendant != 0) {
1734				$tikilib->remove_group_watch($group, 'category_changed', $descendant, 'Category');
1735			}
1736		}
1737	}
1738
1739	/**
1740	 * Removes the category from all watchlists.
1741	 */
1742	function remove_category_from_watchlists($categId)
1743	{
1744		$query = 'delete from `tiki_user_watches` where `object`=? and `type`=?';
1745		$this->query($query, [(int)$categId, 'Category']);
1746		$query = 'delete from `tiki_group_watches` where `object`=? and `type`=?';
1747		$this->query($query, [(int)$categId, 'Category']);
1748	}
1749
1750
1751	/**
1752	 * Returns the description of the category.
1753	 */
1754	function get_category_description($categId)
1755	{
1756		$query = "select `description` from `tiki_categories` where `categId`=?";
1757		return $this->getOne($query, [(int)$categId]);
1758	}
1759
1760	/**
1761	 * Returns the parentId of the category.
1762	 */
1763	function get_category_parent($categId)
1764	{
1765		$query = "select `parentId` from `tiki_categories` where `categId`=?";
1766		return $this->getOne($query, [(int)$categId]);
1767	}
1768
1769	/**
1770	 * Returns true if the given user has view permission for the category.
1771	 */
1772	function has_view_permission($user, $categoryId)
1773	{
1774		return Perms::get(['type' => 'category', 'object' => $categoryId])->view_category;
1775	}
1776
1777	/**
1778	 * Returns true if the given user has edit permission for the category.
1779	 */
1780	function has_edit_permission($user, $categoryId)
1781	{
1782		$userlib = TikiLib::lib('user');
1783		return ($userlib->user_has_permission($user, 'tiki_p_admin')
1784			|| ($userlib->user_has_permission($user, 'tiki_p_edit') && ! $userlib->object_has_one_permission($categoryId, "category"))
1785			|| $userlib->object_has_permission($user, $categoryId, "category", "tiki_p_edit")
1786		);
1787	}
1788
1789	/**
1790	 * Notify the users, watching this category, about changes.
1791	 * The Array $values contains a selection of the following items:
1792	 * categoryId, categoryName, categoryPath, description, parentId, parentName, action
1793	 * oldCategoryName, oldCategoryPath, oldDescription, oldParendId, oldParentName,
1794	 * objectName, objectType, objectUrl
1795	 */
1796	function notify($values)
1797	{
1798		global $prefs;
1799
1800		if ($prefs['feature_user_watches'] == 'y') {
1801			include_once('lib/notifications/notificationemaillib.php');
1802			$foo = parse_url($_SERVER["REQUEST_URI"]);
1803			$machine = $this->httpPrefix(true) . dirname($foo["path"]);
1804			$values['event'] = "category_changed";
1805			sendCategoryEmailNotification($values);
1806		}
1807	}
1808
1809	/**
1810	 * Returns a categorized object.
1811	 */
1812	function get_categorized_object($cat_type, $cat_objid)
1813	{
1814		$objectlib = TikiLib::lib('object');
1815		return $objectlib->get_object($cat_type, $cat_objid);
1816	}
1817
1818	/**
1819	 * Returns a categorized object, identified via the $cat_objid.
1820	 */
1821	function get_categorized_object_via_category_object_id($cat_objid)
1822	{
1823		$objectlib = TikiLib::lib('object');
1824		return $objectlib->get_object_via_objectid($cat_objid);
1825	}
1826
1827	/**
1828	 * Returns the categories that contain the object and are in the user's watchlist.
1829	 */
1830	function get_watching_categories($objId, $objType, $user)
1831	{
1832		$tikilib = TikiLib::lib('tiki');
1833
1834		$categories = $this->get_object_categories($objType, $objId);
1835		$watchedCategories = $tikilib->get_user_watches($user, "category_changed");
1836		$result = [];
1837		foreach ($categories as $cat) {
1838			foreach ($watchedCategories as $wc) {
1839				if ($wc['object'] == $cat) {
1840					$result[] = $cat;
1841				}
1842			}
1843		}
1844		return $result;
1845	}
1846
1847	// Change an object's categories
1848	// $objId: A unique identifier of an object of the given type, for example "Foo" for Wiki page Foo.
1849	function update_object_categories($categories, $objId, $objType, $desc = null, $name = null, $href = null, $managedCategories = null, $override_perms = false)
1850	{
1851		global $prefs, $user;
1852		$userlib = TikiLib::lib('user');
1853
1854		if (empty($categories)) {
1855			$forcedcat = $userlib->get_user_group_default_category($user);
1856			if (! empty($forcedcat)) {
1857				$categories[] = $forcedcat;
1858			}
1859		}
1860
1861		$manip = new Category_Manipulator($objType, $objId);
1862		if ($override_perms) {
1863			$manip->overrideChecks();
1864		}
1865		$manip->setNewCategories($categories ? $categories : []);
1866
1867		if (is_array($managedCategories) && ! $override_perms) {
1868			$manip->setManagedCategories($managedCategories);
1869		}
1870
1871		if ($prefs['category_defaults']) {
1872			foreach ($prefs['category_defaults'] as $constraint) {
1873				$manip->addRequiredSet($this->extentCategories($constraint['categories']), $constraint['default'], $constraint['filter'], $constraint['type']);
1874			}
1875		}
1876
1877		$this->applyManipulator($manip, $objType, $objId, $desc, $name, $href);
1878
1879		if ($prefs['category_i18n_sync'] != 'n' && $prefs['feature_multilingual'] == 'y') {
1880			$multilinguallib = TikiLib::lib('multilingual');
1881			$targetCategories = $this->get_object_categories($objType, $objId, -1, false);
1882
1883			if ($objType == 'wiki page') {
1884				$translations = $multilinguallib->getTranslations($objType, $this->get_page_id_from_name($objId), $objId);
1885				$objectIdKey = 'objName';
1886			} elseif (in_array($objType, ['article'])) {    // only try on supported types
1887				$translations = $multilinguallib->getTranslations($objType, $objId);
1888				$objectIdKey = 'objId';
1889			} else {
1890				$translations = [];
1891				$objectIdKey = 'objId';
1892			}
1893
1894			$subset = $prefs['category_i18n_synced'];
1895			if (is_string($subset)) {
1896				$subset = unserialize($subset);
1897			}
1898
1899			foreach ($translations as $tr) {
1900				if (! empty($tr[$objectIdKey]) && $tr[$objectIdKey] != $objId) {
1901					$manip = new Category_Manipulator($objType, $tr[$objectIdKey]);
1902					$manip->setNewCategories($targetCategories);
1903					$manip->overrideChecks();
1904
1905					if ($prefs['category_i18n_sync'] == 'whitelist') {
1906						$manip->setManagedCategories($subset);
1907					} elseif ($prefs['category_i18n_sync'] == 'blacklist') {
1908						$manip->setUnmanagedCategories($subset);
1909					}
1910
1911					$this->applyManipulator($manip, $objType, $tr[$objectIdKey]);
1912				}
1913			}
1914		}
1915
1916		$added = $manip->getAddedCategories();
1917		$removed = $manip->getRemovedCategories();
1918
1919		TikiLib::events()->trigger('tiki.object.categorized', [
1920			'object' => $objId,
1921			'type' => $objType,
1922			'added' => $added,
1923			'removed' => $removed,
1924		]);
1925
1926		$this->notify_add($added, $name, $objType, $href);
1927		$this->notify_remove($removed, $name, $objType, $href);
1928	}
1929
1930	function notify_add($new_categories, $name, $objType, $href)
1931	{
1932		global $prefs;
1933		if ($prefs['feature_user_watches'] == 'y' && ! empty($new_categories)) {
1934			foreach ($new_categories as $categId) {
1935				$category = $this->get_category($categId);
1936				$values = ['categoryId' => $categId, 'categoryName' => $category['name'], 'categoryPath' => $this->get_category_path_string_with_root($categId),
1937					'description' => $category['description'], 'parentId' => $category['parentId'], 'parentName' => $this->get_category_name($category['parentId']),
1938					'action' => 'object entered category', 'objectName' => $name, 'objectType' => $objType, 'objectUrl' => $href];
1939				$this->notify($values);
1940			}
1941		}
1942	}
1943
1944	function notify_remove($removed_categories, $name, $objType, $href)
1945	{
1946		global $prefs;
1947		if ($prefs['feature_user_watches'] == 'y' && ! empty($removed_categories)) {
1948			foreach ($removed_categories as $categId) {
1949				$category = $this->get_category($categId);
1950				$values = ['categoryId' => $categId, 'categoryName' => $category['name'], 'categoryPath' => $this->get_category_path_string_with_root($categId),
1951					'description' => $category['description'], 'parentId' => $category['parentId'], 'parentName' => $this->get_category_name($category['parentId']),
1952					'action' => 'object leaved category', 'objectName' => $name, 'objectType' => $objType, 'objectUrl' => $href];
1953				$this->notify($values);
1954			}
1955		}
1956	}
1957
1958	private function applyManipulator($manip, $objType, $objId, $desc = null, $name = null, $href = null)
1959	{
1960		$old_categories = $this->get_object_categories($objType, $objId, -1, false);
1961		$manip->setCurrentCategories($old_categories);
1962
1963		$new_categories = $manip->getAddedCategories();
1964		$removed_categories = $manip->getRemovedCategories();
1965
1966		if (empty($new_categories) and empty($removed_categories)) { //nothing changed
1967			return;
1968		}
1969
1970		if (! $catObjectId = $this->is_categorized($objType, $objId)) {
1971			$catObjectId = $this->add_categorized_object($objType, $objId, $desc, $name, $href);
1972		}
1973
1974		global $prefs;
1975		if ($prefs["category_autogeocode_within"]) {
1976			$geocats = $this->getCategories(['identifier' => $prefs["category_autogeocode_within"], 'type' => 'descendants'], true, false);
1977		} else {
1978			$geocats = false;
1979		}
1980
1981		foreach ($new_categories as $category) {
1982			$this->categorize($catObjectId, $category);
1983			// Auto geocode if feature is on
1984			if ($geocats) {
1985				foreach ($geocats as $g) {
1986					if ($category == $g["categId"]) {
1987						$geonames = explode('::', $g["name"]);
1988						$geonames = array_reverse($geonames);
1989						$geoloc = implode(',', $geonames);
1990						$geolib = TikiLib::lib('geo');
1991						$geocode = $geolib->geocode($geoloc);
1992						if ($geocode) {
1993							$attributelib = TikiLib::lib('attribute');
1994							if ($prefs["category_autogeocode_replace"] != 'y') {
1995								$attributes = $attributelib->get_attributes($objType, $objId);
1996								if (! isset($attributes['tiki.geo.lon']) || ! isset($attributes['tiki.geo.lat'])) {
1997									$geonotexists = true;
1998								}
1999							}
2000							if ($prefs["category_autogeocode_replace"] == 'y' || isset($geonotexists) && $geonotexists) {
2001								if ($prefs["category_autogeocode_fudge"] == 'y') {
2002									$geocode = $geolib->geofudge($geocode);
2003								}
2004								$attributelib->set_attribute($objType, $objId, 'tiki.geo.lon', $geocode["lon"]);
2005								$attributelib->set_attribute($objType, $objId, 'tiki.geo.lat', $geocode["lat"]);
2006								if ($objType == 'trackeritem') {
2007									$geolib->setTrackerGeo($objId, $geocode);
2008								}
2009							}
2010						}
2011						break;
2012					}
2013				}
2014			}
2015		}
2016
2017		$this->remove_object_from_categories($catObjectId, $removed_categories);
2018	}
2019
2020	// Returns an array of OIDs of categories.
2021	// These categories are those from the specified categories whose parents are not in the set of specified categories.
2022	// $categories: An array of categories
2023	function findRoots($categories)
2024	{
2025		$candidates = [];
2026
2027		foreach ($categories as $cat) {
2028			$id = $cat['parentId'];
2029			$candidates[$id] = true;
2030		}
2031
2032		foreach ($categories as $cat) {
2033			unset($candidates[$cat['categId']]);
2034		}
2035
2036		return array_keys($candidates);
2037	}
2038
2039	function get_jailed($categories)
2040	{
2041		if ($jail = $this->get_jail()) {
2042			$existing = $this->getCategories(null, false, false, false);
2043
2044			return array_values(array_intersect($categories, array_keys($existing)));
2045		} else {
2046			return $categories;
2047		}
2048	}
2049
2050	// Returns the categories a new object should be in by default, that is none in general, or the perspective categories if the user is in a perspective.
2051	function get_default_categories()
2052	{
2053		global $prefs;
2054		if ($this->get_jail()) {
2055			// Default categories are not the entire jail including the sub-categories but only the "root" categories
2056			return is_array($prefs['category_jail']) ? $prefs['category_jail'] : [$prefs['category_jail']];
2057		} else {
2058			return [];
2059		}
2060	}
2061
2062	// Returns an array containing the ids of the passed $objects present in any of the passed $categories.
2063	function filter_objects_categories($objects, $categories)
2064	{
2065		$query = "SELECT `catObjectId` from `tiki_category_objects` where `catObjectId` in (" . implode(',', array_fill(0, count($objects), '?')) . ")";
2066		if ($categories) {
2067			$query .= " and `categId` in (" . implode(',', array_fill(0, count($categories), '?')) . ")";
2068		}
2069		$result = $this->query($query, array_merge($objects, $categories));
2070		$ret = [];
2071		while ($res = $result->fetchRow()) {
2072			$ret[] = $res["catObjectId"];
2073		}
2074		return $ret;
2075	}
2076
2077	// unassign all objects from a category
2078	function unassign_all_objects($categId)
2079	{
2080		$query = 'delete from  `tiki_category_objects` where `categId`=?';
2081		return $this->query($query, [(int)$categId]);
2082	}
2083	//
2084
2085	/**
2086	 * Move all objects from one category to another
2087	 *
2088	 * @param $from
2089	 * @param $to
2090	 * @return TikiDb_Pdo_Result
2091	 */
2092	function move_all_objects($from, $to)
2093	{
2094		$query = 'update ignore `tiki_category_objects` set `categId`=? where `categId`=?';
2095		return $this->query($query, [(int)$to, (int)$from]);
2096	}
2097
2098
2099	/**
2100	 * Assign all objects in a category to another category
2101	 *
2102	 * @param $from
2103	 * @param $to
2104	 * @return TikiDb_Pdo_Result
2105	 */
2106	function assign_all_objects($from, $to)
2107	{
2108		$query = 'insert ignore `tiki_category_objects` (`catObjectId`, `categId`) select `catObjectId`, ? from `tiki_category_objects` where `categId`=?';
2109		return $this->query($query, [(int)$to, (int)$from]);
2110	}
2111
2112	// generate category tree for use in various places (like categorize_list.php)
2113	function generate_cat_tree($categories, $canchangeall = false, $forceincat = null)
2114	{
2115		$smarty = TikiLib::lib('smarty');
2116		include_once('lib/tree/BrowseTreeMaker.php');
2117		$tree_nodes = [];
2118		$roots = $this->findRoots($categories);
2119		foreach ($categories as $c) {
2120			if (isset($c['name']) || $c['parentId'] != 0) {
2121				// if used for purposes such as find, should be able to "change" all cats
2122				if ($canchangeall) {
2123					$c['canchange'] = true;
2124				}
2125
2126				// if used in find, should force incat to check those that have been selected
2127				if (is_array($forceincat)) {
2128					$c['incat'] = in_array($c['categId'], $forceincat) ? 'y' : 'n';
2129				}
2130
2131				$smarty->assign('category_data', $c);
2132				$tree_nodes[] = [
2133					'id' => $c['categId'],
2134					'parent' => $c['parentId'],
2135					'data' => $smarty->fetch('category_tree_entry.tpl'),
2136				];
2137			}
2138		}
2139		$tm = new BrowseTreeMaker("categorize");
2140		$res = '';
2141		foreach ($roots as $root) {
2142			$res .= $tm->make_tree($root, $tree_nodes);
2143		}
2144		return $res;
2145	}
2146
2147	static function cmpcatname($a, $b)
2148	{
2149		$a = TikiLib::strtoupper(TikiLib::take_away_accent($a));
2150		$b = TikiLib::strtoupper(TikiLib::take_away_accent($b));
2151		return strcmp($a, $b);
2152	}
2153
2154	/* replace each *i in the categories array with the categories of the sudtree i + i */
2155	function extentCategories($categories)
2156	{
2157		$ret = [];
2158		foreach ($categories as $cat) {
2159			if (is_numeric($cat)) {
2160				$ret[] = $cat;
2161			} else {
2162				$cats = $this->get_category_descendants(substr($cat, 1));
2163				$ret[] = substr($cat, 1);
2164				$ret = array_merge($ret, $cats);
2165			}
2166		}
2167		$ret = array_unique($ret);
2168		return $ret;
2169	}
2170
2171	function getCustomFacets()
2172	{
2173		static $list = [];
2174
2175		if (! $list) {
2176			$list = array_filter(array_map('intval', $this->get_preference('category_custom_facets', [], true)));
2177		}
2178
2179		return $list;
2180	}
2181
2182	/**
2183	 * Provides the list of all parents for a given set of categories.
2184	 */
2185	function get_with_parents($categories)
2186	{
2187		$full = [];
2188
2189		foreach ($categories as $category) {
2190			$full = array_merge($full, $this->get_parents($category));
2191		}
2192
2193		return array_unique($full);
2194	}
2195
2196	function get_parents($categId)
2197	{
2198		if (! isset($this->parentCategories[$categId])) {
2199			$category = $this->get_category($categId);
2200			$this->parentCategories[$categId] = array_keys($category['tepath']);
2201		}
2202
2203		return $this->parentCategories[$categId];
2204	}
2205}
2206