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
8class Services_Tracker_SyncController
9{
10	private $utilities;
11
12	function setUp()
13	{
14		global $prefs;
15		$this->utilities = new Services_Tracker_Utilities;
16
17		if ($prefs['feature_trackers'] != 'y') {
18			throw new Services_Exception_Disabled('feature_trackers');
19		}
20
21		if ($prefs['tracker_remote_sync'] != 'y') {
22			throw new Services_Exception_Disabled('tracker_remote_sync');
23		}
24
25		if (! Perms::get()->admin_trackers) {
26			throw new Services_Exception(tr('Reserved for tracker administrators'), 403);
27		}
28	}
29
30	function action_clone_remote($input)
31	{
32		$url = $input->url->url();
33		$remoteTracker = $input->remote_tracker_id->int();
34
35		if ($url) {
36			$url = rtrim($url, '/');
37			$tracker = $this->findTrackerInfo($url, $remoteTracker);
38
39			if (! $tracker) {
40				// Prepare the list for tracker selection
41				$trackers = $this->getRemoteTrackerList($url);
42				return [
43					'url' => $url,
44					'list' => $trackers['list'],
45				];
46			} else {
47				// Proceed with the tracker import
48				$export = $this->getRemoteTrackerFieldExport($url, $remoteTracker);
49
50				$trackerId = $this->utilities->createTracker($tracker);
51				$this->createSynchronizedFields(
52					$trackerId,
53					$export,
54					['provider' => $url, 'source' => $remoteTracker, 'last' => 0]
55				);
56				$this->utilities->createField(
57					[
58						'trackerId' => $trackerId,
59						'type' => 't',
60						'name' => tr('Remote Source'),
61						'permName' => 'syncSource',
62						'description' => tr('Automatically generated field for synchronized trackers. Contains the itemId of the remote item.'),
63						'options' => $this->utilities->buildOptions(
64							['prepend' => $url . '/item'],
65							't'
66						),
67						'isMandatory' => false,
68					]
69				);
70
71				$this->registerSynchronization($trackerId, $url, $remoteTracker);
72
73				return [
74					'trackerId' => $trackerId,
75				];
76			}
77		}
78
79		return [
80			'url' => $url,
81			'title' => tr('Clone Remote Tracker'),
82		];
83	}
84
85	function action_sync_meta($input)
86	{
87		list($trackerId, $definition, $syncInfo) = $this->readTracker($input);
88		$factory = $definition->getFieldFactory();
89
90		$export = $this->getRemoteTrackerFieldExport($syncInfo['provider'], $syncInfo['source']);
91		foreach ($export as $info) {
92			$localField = $definition->getFieldFromPermName($info['permName']);
93			if (! $localField) {
94				continue;
95			}
96
97			$handler = $factory->getHandler($info);
98			if (! $handler instanceof Tracker_Field_Synchronizable) {
99				continue;
100			}
101
102			$importable = $handler->importRemoteField($info, $syncInfo);
103			$this->utilities->updateField($trackerId, $localField['fieldId'], $importable);
104		}
105
106		return [];
107	}
108
109	function action_sync_refresh($input)
110	{
111		list($trackerId, $definition, $syncInfo) = $this->readTracker($input);
112
113		set_time_limit(0); // Expected to take a while on larger trackers
114
115		$this->utilities->clearTracker($trackerId);
116
117		$itemMap = [];
118
119		$remoteDefinition = $this->getRemoteDefinition($definition);
120		$factory = $remoteDefinition->getFieldFactory();
121		foreach ($this->getRemoteItems($syncInfo) as $item) {
122			foreach ($item['fields'] as $key => & $value) {
123				$field = $remoteDefinition->getFieldFromPermName($key);
124				if ($field && $definition->getFieldFromPermName($key)) {
125					$handler = $factory->getHandler($field);
126					$value = $handler->importRemote($value);
127				}
128			}
129
130			$item['fields']['syncSource'] = $item['itemId'];
131			$newItem = $this->utilities->insertItem($definition, $item);
132
133			$itemMap[ $item['itemId'] ] = $newItem;
134		}
135
136		if ($definition->getLanguageField()) {
137			$this->attachTranslations($syncInfo, 'trackeritem', $itemMap);
138		}
139
140		$this->registerSynchronization($trackerId, $syncInfo['provider'], $syncInfo['source']);
141		TikiLib::lib('unifiedsearch')->processUpdateQueue(count($itemMap) * 3); // Process lots of inserts
142		return [];
143	}
144
145	function action_sync_new($input)
146	{
147		list($trackerId, $definition, $syncInfo) = $this->readTracker($input);
148
149		$items = $input->items->int();
150
151		$trklib = TikiLib::lib('trk');
152		$syncField = $definition->getFieldFromPermName('syncSource');
153		$itemIds = $trklib->get_items_list($trackerId, $syncField['fieldId'], '', 'opc');
154
155		if ($items) {
156			set_time_limit(30 + 10 * count($items)); // 10 sec per item plus some initial overhead
157			$itemIds = array_intersect($itemIds, $items);
158			$table = TikiDb::get()->table('tiki_tracker_items');
159			$items = $this->utilities->getItems(['trackerId' => $trackerId, 'itemId' => $table->in($itemIds)]);
160
161			$remoteDefinition = $this->getRemoteDefinition($definition);
162			foreach ($items as $item) {
163				$remoteItemId = $this->insertRemoteItem($remoteDefinition, $definition, $item);
164
165				if ($remoteItemId) {
166					$item['fields']['syncSource'] = $remoteItemId;
167					$this->utilities->updateItem($definition, $item);
168				}
169			}
170			TikiLib::lib('unifiedsearch')->processUpdateQueue();
171
172			return [
173			];
174		} else {
175			return [
176				'trackerId' => $trackerId,
177				'sets' => ['items'],
178				'items' => $this->getItemList($itemIds),
179			];
180		}
181	}
182
183	function action_sync_edit($input)
184	{
185		list($trackerId, $definition, $syncInfo) = $this->readTracker($input);
186
187		// Collect local IDs that were modified
188		$items = TikiDb::get()->table('tiki_tracker_items');
189		$itemIds = $items->fetchColumn(
190			'itemId',
191			[
192				'trackerId' => $trackerId,
193				'created' => $items->lesserThan($syncInfo['last']),
194				'lastModif' => $items->greaterThan($syncInfo['last']),
195			]
196		);
197
198		// Collect remote IDs that were modified
199		$remoteItems = $this->getRemoteItems($syncInfo, ['modifiedSince' => $syncInfo['last']]);
200
201		$modifiedIds = [];
202		foreach ($remoteItems as $item) {
203			$modifiedIds[] = $item['itemId'];
204		}
205
206		// Map from remote ID to local ID
207		$syncField = $definition->getFieldFromPermName('syncSource');
208		$fields = TikiDb::get()->table('tiki_tracker_item_fields');
209		$itemMap = $fields->fetchMap(
210			'itemId',
211			'value',
212			['fieldId' => $syncField['fieldId'], 'value' => $fields->in($modifiedIds)]
213		);
214
215		$modifiedIds = array_keys($itemMap);
216		$automatic = array_diff($itemIds, $modifiedIds);
217		$manual = array_intersect($itemIds, $modifiedIds);
218
219		set_time_limit(30 + 10 * count($automatic) + 10 * count($manual)); // 10 sec per item plus some initial overhead
220
221		if ($input->automatic->int() || $input->manual->int()) {
222			$remoteDefinition = $this->getRemoteDefinition($definition);
223			$this->processUpdates('automatic', $automatic, $input, $definition, $remoteDefinition);
224			$this->processUpdates('manual', $manual, $input, $definition, $remoteDefinition);
225		}
226
227		$manualList = $this->getItemList($manual);
228		require_once 'lib/smarty_tiki/modifier.sefurl.php';
229		foreach ($manualList as & $item) {
230			$itemId = $item['itemId'];
231			$item['remoteUrl'] = $syncInfo['provider'] . '/' . smarty_modifier_sefurl($itemMap[$itemId], 'trackeritem');
232		}
233
234		return [
235			'trackerId' => $trackerId,
236			'sets' => ['automatic', 'manual'],
237			'automatic' => $this->getItemList($automatic),
238			'manual' => $manualList,
239		];
240	}
241
242	private function createSynchronizedFields($trackerId, $data, $syncInfo)
243	{
244		if (! $data) {
245			throw new Services_Exception(tr('Invalid data provided'), 400);
246		}
247
248		$definition = Tracker_Definition::get($trackerId);
249		$factory = $definition->getFieldFactory();
250		foreach ($data as $info) {
251			$handler = $factory->getHandler($info);
252			if ($handler instanceof Tracker_Field_Synchronizable) {
253				$importable = $handler->importRemoteField($info, $syncInfo);
254				$this->utilities->importField($trackerId, new JitFilter($importable), false);
255			}
256		}
257	}
258
259	private function getRemoteTrackerList($serviceUrl)
260	{
261		static $cache = [];
262		if (isset($cache[$serviceUrl])) {
263			return $cache[$serviceUrl];
264		}
265
266		$controller = new Services_RemoteController($serviceUrl, 'tracker');
267		$data = $controller->list_trackers();
268		return $cache[$serviceUrl] = $data;
269	}
270
271	private function getRemoteTrackerFieldExport($serviceUrl, $trackerId)
272	{
273		$controller = new Services_RemoteController($serviceUrl, 'tracker');
274		$export = $controller->export_fields(['trackerId' => $trackerId]);
275
276		return TikiLib::lib('tiki')->read_raw($export['export']);
277	}
278
279	private function findTrackerInfo($serviceUrl, $trackerId)
280	{
281		$trackers = $this->getRemoteTrackerList($serviceUrl);
282		foreach ($trackers['data'] as $info) {
283			if ($info['trackerId'] == $trackerId) {
284				unset($info['trackerId']);
285				return $info;
286			}
287		}
288	}
289
290	private function registerSynchronization($localTrackerId, $serviceUrl, $remoteTrackerId)
291	{
292		$attributelib = TikiLib::lib('attribute');
293		$attributelib->set_attribute('tracker', $localTrackerId, 'tiki.sync.provider', rtrim($serviceUrl, '/'));
294		$attributelib->set_attribute('tracker', $localTrackerId, 'tiki.sync.source', $remoteTrackerId);
295		$attributelib->set_attribute('tracker', $localTrackerId, 'tiki.sync.last', time()); // Real sync time, not tiki initial load
296	}
297
298	private function getRemoteItems($syncInfo, array $conditions = [])
299	{
300		$controller = new Services_RemoteController($syncInfo['provider'], 'tracker');
301		return $controller->getResultLoader(
302			'list_items',
303			array_merge($conditions, ['trackerId' => $syncInfo['source'], 'format' => 'raw']),
304			'offset',
305			'maxRecords',
306			'result'
307		);
308	}
309
310	private function insertRemoteItem($remoteDefinition, $definition, $item)
311	{
312		$syncInfo = $definition->getSyncInformation();
313
314		$item['trackerId'] = $syncInfo['source'];
315		$item['fields'] = $this->exportFields($item['fields'], $remoteDefinition, $definition);
316
317		$controller = new Services_RemoteController($syncInfo['provider'], 'tracker');
318		$data = $controller->insert_item($item);
319
320		if (isset($data['itemId']) && $data['itemId']) {
321			return $data['itemId'];
322		}
323	}
324
325	private function updateRemoteItem($remoteDefinition, $definition, $item)
326	{
327		$syncInfo = $definition->getSyncInformation();
328
329		$item['itemId'] = $item['fields']['syncSource'];
330		$item['trackerId'] = $syncInfo['source'];
331
332		$item['fields'] = $this->exportFields($item['fields'], $remoteDefinition, $definition);
333
334		$controller = new Services_RemoteController($syncInfo['provider'], 'tracker');
335		$controller->update_item($item);
336	}
337
338	private function exportFields($fields, $remoteDefinition, $definition)
339	{
340		unset($fields['syncSource']);
341		$factory = $definition->getFieldFactory();
342		foreach ($fields as $key => & $value) {
343			$field = $remoteDefinition->getFieldFromPermName($key);
344			if ($field && $definition->getFieldFromPermName($key)) {
345				$handler = $factory->getHandler($field);
346				$value = $handler->exportRemote($value);
347			}
348		}
349
350		return $fields;
351	}
352
353	private function attachTranslations($syncInfo, $type, $objectMap)
354	{
355		$unprocessed = $objectMap;
356		$utilities = new Services_Language_Utilities;
357
358		while (reset($unprocessed)) {
359			$remoteSource = key($unprocessed);
360
361			unset($unprocessed[$remoteSource]);
362
363			$translations = $this->getRemoteTranslations($syncInfo, $type, $remoteSource);
364			foreach ($translations as $remoteTarget) {
365				unset($unprocessed[$remoteTarget]);
366				$utilities->insertTranslation($type, $objectMap[ $remoteSource ], $objectMap[ $remoteTarget ]);
367			}
368		}
369	}
370
371	private function getRemoteTranslations($syncInfo, $type, $remoteSource)
372	{
373		$controller = new Services_RemoteController($syncInfo['provider'], 'translation');
374		$data = $controller->manage(['type' => $type, 'source' => $remoteSource]);
375
376		$out = [];
377
378		if ($data['translations']) {
379			foreach ($data['translations'] as $translation) {
380				if ($translation['objId'] != $remoteSource) {
381					$out[] = $translation['objId'];
382				}
383			}
384		}
385
386		return $out;
387	}
388
389	private function getRemoteDefinition($definition)
390	{
391		$syncInfo = $definition->getSyncInformation();
392
393		return Tracker_Definition::createFake(
394			$definition->getInformation(),
395			$this->getRemoteTrackerFieldExport($syncInfo['provider'], $syncInfo['source'])
396		);
397	}
398
399	private function readTracker($input)
400	{
401		$trackerId = $input->trackerId->int();
402		$definition = Tracker_Definition::get($trackerId);
403
404		if (! $definition) {
405			throw new Services_Exception(tr('Tracker does not exist'), 404);
406		}
407
408		$syncInfo = $definition->getSyncInformation();
409
410		if (! $syncInfo) {
411			throw new Services_Exception(tr('Tracker is not synchronized with a remote source.'), 409);
412		}
413
414		return [$trackerId, $definition, $syncInfo];
415	}
416
417	private function getItemList($itemIds)
418	{
419		$trklib = TikiLib::lib('trk');
420		require_once 'lib/smarty_tiki/modifier.sefurl.php';
421
422		$out = [];
423		foreach ($itemIds as $itemId) {
424			$out[] = [
425				'itemId' => $itemId,
426				'title' => $trklib->get_isMain_value(null, $itemId),
427				'localUrl' => smarty_modifier_sefurl($itemId, 'trackeritem'),
428			];
429		}
430
431		return $out;
432	}
433
434	private function processUpdates($inputType, & $list, $input, $definition, $remoteDefinition)
435	{
436		$values = $input->$inputType->int();
437		if (! is_array($values)) {
438			return;
439		}
440
441		$toProcess = array_intersect($list, $values);
442		$list = array_diff($list, $values);
443
444		$table = TikiDb::get()->table('tiki_tracker_items');
445		$itemList = $this->utilities->getItems(['trackerId' => $definition->getConfiguration('trackerId'), 'itemId' => $table->in($toProcess)]);
446		foreach ($itemList as $item) {
447			$this->updateRemoteItem($remoteDefinition, $definition, $item);
448			$this->utilities->removeItem($item['itemId']);
449		}
450	}
451}
452