term_merge.module 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077
  1. <?php
  2. /**
  3. * @file
  4. * Provide functionality for merging taxonomy terms one into another.
  5. */
  6. /**
  7. * Constant to use in term merge action.
  8. *
  9. * Constant denotes "do not create HTTP redirect" logic for term merge action.
  10. *
  11. * @var int
  12. */
  13. define('TERM_MERGE_NO_REDIRECT', -1);
  14. /**
  15. * Implements hook_menu().
  16. */
  17. function term_merge_menu() {
  18. $items = array();
  19. $items['admin/structure/taxonomy/%taxonomy_vocabulary_machine_name/merge'] = array(
  20. 'title' => 'Merge terms',
  21. 'page callback' => 'drupal_get_form',
  22. 'page arguments' => array('term_merge_form', 3),
  23. 'access callback' => 'term_merge_access',
  24. 'access arguments' => array(3),
  25. 'file' => 'term_merge.pages.inc',
  26. 'type' => MENU_LOCAL_TASK,
  27. );
  28. $items['admin/structure/taxonomy/%taxonomy_vocabulary_machine_name/merge/default'] = array(
  29. 'title' => 'Default',
  30. 'type' => MENU_DEFAULT_LOCAL_TASK,
  31. );
  32. $items['admin/structure/taxonomy/%taxonomy_vocabulary_machine_name/merge/duplicates'] = array(
  33. 'title' => 'Merge Duplicate Terms',
  34. 'page callback' => 'drupal_get_form',
  35. 'page arguments' => array('term_merge_duplicates_form', 3),
  36. 'access callback' => 'term_merge_access',
  37. 'access arguments' => array(3),
  38. 'file' => 'term_merge.pages.inc',
  39. 'type' => MENU_LOCAL_TASK,
  40. );
  41. $items['taxonomy/term/%taxonomy_term/merge'] = array(
  42. 'title' => 'Merge Terms',
  43. 'page callback' => 'drupal_get_form',
  44. 'page arguments' => array('term_merge_form', NULL, 2),
  45. 'access callback' => 'term_merge_access',
  46. 'access arguments' => array(NULL, 2),
  47. 'file' => 'term_merge.pages.inc',
  48. 'type' => MENU_LOCAL_TASK,
  49. 'weight' => 10,
  50. );
  51. $items['taxonomy/term/%taxonomy_term/merge/default'] = array(
  52. 'title' => 'Default',
  53. 'type' => MENU_DEFAULT_LOCAL_TASK,
  54. );
  55. $items['taxonomy/term/%taxonomy_term/merge/duplicates'] = array(
  56. 'title' => 'Merge Duplicate Terms',
  57. 'page callback' => 'drupal_get_form',
  58. 'page arguments' => array('term_merge_duplicates_form', NULL, 2),
  59. 'access callback' => 'term_merge_access',
  60. 'access arguments' => array(NULL, 2),
  61. 'file' => 'term_merge.pages.inc',
  62. 'type' => MENU_LOCAL_TASK,
  63. );
  64. $items['term-merge/autocomplete/term-trunk/%taxonomy_vocabulary_machine_name'] = array(
  65. 'title' => 'Autocomplete Term Merge form term trunk',
  66. 'page callback' => 'term_merge_form_term_trunk_widget_autocomplete_autocomplete',
  67. 'page arguments' => array(3),
  68. 'access callback' => 'term_merge_access',
  69. 'access arguments' => array(3),
  70. 'file' => 'term_merge.pages.inc',
  71. 'type' => MENU_CALLBACK,
  72. );
  73. return $items;
  74. }
  75. /**
  76. * Implements hook_admin_paths().
  77. */
  78. function term_merge_admin_paths() {
  79. return array(
  80. 'taxonomy/term/*/merge' => TRUE,
  81. 'taxonomy/term/*/merge/*' => TRUE,
  82. );
  83. }
  84. /**
  85. * Implements hook_permission().
  86. */
  87. function term_merge_permission() {
  88. $permissions = array();
  89. $permissions['merge terms'] = array(
  90. 'title' => t('Merge any terms'),
  91. 'description' => t('Gives the ability to merge any taxonomy terms.'),
  92. );
  93. $vocabularies = taxonomy_get_vocabularies();
  94. foreach ($vocabularies as $vocabulary) {
  95. $permissions['merge ' . $vocabulary->machine_name . ' terms'] = array(
  96. 'title' => t('Merge %name vocabulary terms', array('%name' => $vocabulary->name)),
  97. 'description' => t('Gives the ability to merge taxonomy terms that belong to vocabulary %name.', array('%name' => $vocabulary->name)),
  98. );
  99. }
  100. return $permissions;
  101. }
  102. /**
  103. * Implements hook_action_info().
  104. */
  105. function term_merge_action_info() {
  106. return array(
  107. 'term_merge_action' => array(
  108. 'type' => 'taxonomy_term',
  109. 'label' => t('Merge term'),
  110. 'configurable' => TRUE,
  111. 'behavior' => array('view_property'),
  112. 'pass rows' => TRUE,
  113. ),
  114. );
  115. }
  116. /**
  117. * Implements hook_help().
  118. */
  119. function term_merge_help($path, $arg) {
  120. switch ($path) {
  121. // Main module help for the Term Merge module.
  122. case 'admin/help#term_merge':
  123. return '<p>' . t('Allows you to merge multiple terms into one and and at the same time update all fields referencing to the old ones.') . '</p>';
  124. break;
  125. }
  126. }
  127. /**
  128. * Implements hook_ctools_plugin_type().
  129. */
  130. function term_merge_ctools_plugin_type() {
  131. $plugins = array();
  132. $plugins['duplicate_suggestion'] = array(
  133. 'defaults' => array(
  134. 'title' => NULL,
  135. 'description' => NULL,
  136. 'hash callback' => NULL,
  137. 'weight' => 0,
  138. ),
  139. );
  140. return $plugins;
  141. }
  142. /**
  143. * Implements hook_ctools_plugin_directory().
  144. */
  145. function term_merge_ctools_plugin_directory($owner, $plugin_type) {
  146. switch ($owner) {
  147. case 'term_merge':
  148. switch ($plugin_type) {
  149. case 'duplicate_suggestion':
  150. return 'plugins/' . $plugin_type;
  151. }
  152. break;
  153. case 'synonyms':
  154. switch ($plugin_type) {
  155. case 'behavior':
  156. return 'plugins/' . $plugin_type;
  157. }
  158. break;
  159. }
  160. }
  161. /**
  162. * Implements hook_synonyms_provider_field_behavior_implementation_info().
  163. */
  164. function term_merge_synonyms_provider_field_behavior_implementation_info($behavior) {
  165. switch ($behavior) {
  166. case 'term_merge':
  167. return array(
  168. 'number_integer' => 'TextTermMergeSynonymsBehavior',
  169. 'number_decimal' => 'TextTermMergeSynonymsBehavior',
  170. 'number_float' => 'TextTermMergeSynonymsBehavior',
  171. 'text' => 'TextTermMergeSynonymsBehavior',
  172. 'taxonomy_term_reference' => 'TaxonomyTermMergeSynonymsBehavior',
  173. 'entityreference' => 'EntityReferenceTermMergeSynonymsBehavior',
  174. );
  175. break;
  176. }
  177. return array();
  178. }
  179. /**
  180. * Access callback for term merge action.
  181. *
  182. * Decide whether to grant access to an account for an operation of merging
  183. * terms in a vocabulary.
  184. *
  185. * @param object $vocabulary
  186. * Fully loaded vocabulary object inside of which term merge operation is
  187. * requested for access granting
  188. * @param object $term
  189. * Fully loaded term object which belongs to the vocabulary inside of which
  190. * term merge operation is requested for access granting. You are supposed
  191. * only to provide either $vocabulary or $term. Depending on your context it
  192. * might be more convenient for you to provide $term, and on other occasions
  193. * it might be $vocabulary of more convenience
  194. * @param object $account
  195. * Fully loaded user object who is requesting access granting for the
  196. * operation of term merging. You may provide nothing here, and the currently
  197. * logged in user will be considered
  198. *
  199. * @return bool
  200. * Whether the access for term merging operation has been granted
  201. */
  202. function term_merge_access($vocabulary = NULL, $term = NULL, $account = NULL) {
  203. if (is_null($vocabulary) && is_null($term)) {
  204. // This is no go, at least one of these 2 has to be provided.
  205. return FALSE;
  206. }
  207. if (is_null($account)) {
  208. // Falling back on currently logged in user.
  209. $account = $GLOBALS['user'];
  210. }
  211. if (is_null($vocabulary)) {
  212. $vocabulary = taxonomy_vocabulary_load($term->vid);
  213. }
  214. return user_access('merge terms', $account) || user_access('merge ' . $vocabulary->machine_name . ' terms', $account);
  215. }
  216. /**
  217. * Generate the configuration form for action "Term merge".
  218. */
  219. function term_merge_action_form($context, &$form_state) {
  220. $term_branch_value = isset($form_state['selection']) && is_array($form_state['selection']) ? $form_state['selection'] : array();
  221. $vocabulary = FALSE;
  222. if (!empty($term_branch_value)) {
  223. $vocabulary = db_select('taxonomy_term_data', 't')
  224. ->fields('t', array('vid'))
  225. ->condition('tid', reset($term_branch_value))
  226. ->execute()
  227. ->fetchField();
  228. $vocabulary = taxonomy_vocabulary_load($vocabulary);
  229. }
  230. if ($vocabulary) {
  231. $form = array();
  232. module_load_include('inc', 'term_merge', 'term_merge.pages');
  233. term_merge_form_base($form, $form_state, $vocabulary, $term_branch_value);
  234. // The "step" merge parameter does not make sense here, since the batch will
  235. // be invoked out of Term Merge scope.
  236. $form['step']['#access'] = FALSE;
  237. }
  238. else {
  239. $form['vocabulary_missing'] = array(
  240. '#markup' => '<b>' . t('Oups, something does not seem to go right. Term merge module cannot determine within which vocabulary merge is about to happen. You might want to report it to the <a href="@url">Term Merge issue queue</a>.', array(
  241. '@url' => 'https://www.drupal.org/project/issues/term_merge',
  242. )) . '</b>',
  243. );
  244. }
  245. return $form;
  246. }
  247. /**
  248. * Form submission function.
  249. *
  250. * Store information about configurable action.
  251. */
  252. function term_merge_action_submit($form, &$form_state) {
  253. return array(
  254. 'term_trunk' => $form_state['values']['term_trunk']['tid'],
  255. ) + term_merge_merge_options_submit($form, $form_state, $form);
  256. }
  257. /**
  258. * Action function. Perform action "Term Merge".
  259. */
  260. function term_merge_action($object, $context) {
  261. $term_branch = $object;
  262. $term_trunk = taxonomy_term_load($context['term_trunk']);
  263. $vocabulary = taxonomy_vocabulary_load($term_branch->vid);
  264. $term_branch_children = array();
  265. foreach (taxonomy_get_tree($term_branch->vid, $term_branch->tid) as $term) {
  266. $term_branch_children[] = $term->tid;
  267. }
  268. if ($term_branch->vid != $term_trunk->vid) {
  269. watchdog('term_merge', 'Trying to merge 2 terms (%term_branch, %term_trunk) from different vocabularies', array(
  270. '%term_branch' => $term_branch->name,
  271. '%term_trunk' => $term_trunk->name,
  272. ), WATCHDOG_WARNING);
  273. return;
  274. }
  275. if ($term_branch->tid == $term_trunk->tid) {
  276. watchdog('term_merge', 'Trying to merge a term %term into itself.', array('%term' => $term_branch->name), WATCHDOG_WARNING);
  277. return;
  278. }
  279. if (in_array($term_trunk->tid, $term_branch_children)) {
  280. watchdog('term_merge', 'Trying to merge a term %term_branch into its child %term_trunk.', array(
  281. '%term_branch' => $term_branch->name,
  282. '%term_trunk' => $term_trunk->name,
  283. ), WATCHDOG_WARNING);
  284. return;
  285. }
  286. // Defining some default values.
  287. if (!isset($context['term_branch_keep'])) {
  288. // It's easier to manually delete the unwanted terms, rather than
  289. // search for your DB back up. So by default we keep the term branch.
  290. $context['term_branch_keep'] = TRUE;
  291. }
  292. if (!isset($context['merge_fields'])) {
  293. // Initializing it with an empty array if client of this function forgot to
  294. // provide info about what fields to merge.
  295. $context['merge_fields'] = array();
  296. }
  297. if (!isset($context['keep_only_unique'])) {
  298. // Seems logical that mostly people will prefer to keep only one value in
  299. // term reference field per taxonomy term.
  300. $context['keep_only_unique'] = TRUE;
  301. }
  302. if (!isset($context['redirect']) || !module_exists('redirect')) {
  303. // This behavior requires Redirect module installed and enabled.
  304. $context['redirect'] = TERM_MERGE_NO_REDIRECT;
  305. }
  306. if (!isset($context['synonyms']) || !module_exists('synonyms')) {
  307. // This behavior requires Synonyms module installed and enabled.
  308. $context['synonyms'] = NULL;
  309. }
  310. // Calling a hook, this way we let whoever else to react and do his own extra
  311. // logic when merging of terms occurs. We prefer to call it before we handle
  312. // our own logic, because our logic might delete $term_branch and maybe a
  313. // module that implements this hook needs this term not deleted yet.
  314. module_invoke_all('term_merge', $term_trunk, $term_branch, $context);
  315. if (!empty($context['merge_fields'])) {
  316. // "Merging" the fields from $term_branch into $term_trunk where it is
  317. // possible.
  318. foreach ($context['merge_fields'] as $field_name) {
  319. // Getting the list of available languages for this field.
  320. $languages = array();
  321. if (isset($term_trunk->$field_name) && is_array($term_trunk->$field_name)) {
  322. $languages = array_merge($languages, array_keys($term_trunk->$field_name));
  323. }
  324. if (isset($term_branch->$field_name) && is_array($term_branch->$field_name)) {
  325. $languages = array_merge($languages, array_keys($term_branch->$field_name));
  326. }
  327. $languages = array_unique($languages);
  328. // Merging the data of both terms into $term_trunk.
  329. foreach ($languages as $language) {
  330. if (!isset($term_trunk->{$field_name}[$language])) {
  331. $term_trunk->{$field_name}[$language] = array();
  332. }
  333. if (!isset($term_branch->{$field_name}[$language])) {
  334. $term_branch->{$field_name}[$language] = array();
  335. }
  336. $items = array_merge($term_trunk->{$field_name}[$language], $term_branch->{$field_name}[$language]);
  337. $unique_items = array();
  338. foreach ($items as $item) {
  339. $unique_items[serialize($item)] = $item;
  340. }
  341. $items = array_values($unique_items);
  342. $term_trunk->{$field_name}[$language] = $items;
  343. }
  344. }
  345. // And now we can save $term_trunk after shifting all the fields from
  346. // $term_branch.
  347. taxonomy_term_save($term_trunk);
  348. }
  349. $result = array();
  350. foreach (term_merge_fields_with_foreign_key('taxonomy_term_data', 'tid') as $field) {
  351. $result[$field['field_name']] = array();
  352. $query = new EntityFieldQuery();
  353. // Making sure we search in the entire scope of entities.
  354. $query->addMetaData('account', user_load(1));
  355. $query->fieldCondition($field['field_name'], $field['term_merge_field_column'], $term_branch->tid);
  356. $_result = $query->execute();
  357. $result[$field['field_name']]['entities'] = $_result;
  358. $result[$field['field_name']]['column'] = $field['term_merge_field_column'];
  359. }
  360. // Now we load all entities that have fields pointing to $term_branch.
  361. foreach ($result as $field_name => $field_data) {
  362. $column = $field_data['column'];
  363. foreach ($field_data['entities'] as $entity_type => $v) {
  364. $ids = array_keys($v);
  365. $entities = entity_load($entity_type, $ids);
  366. // After we have loaded it, we alter the field to point to $term_trunk.
  367. foreach ($entities as $entity) {
  368. // What is more, we have to do it for every available language.
  369. foreach ($entity->$field_name as $language => $items) {
  370. // Keeping track of whether term trunk is already present in this
  371. // field in this language. This is useful for the option
  372. // 'keep_only_unique'.
  373. $is_trunk_added = FALSE;
  374. foreach ($entity->{$field_name}[$language] as $delta => $item) {
  375. if ($context['keep_only_unique'] && $is_trunk_added && in_array($item[$column], array($term_trunk->tid, $term_branch->tid))) {
  376. // We are instructed to keep only unique references and we already
  377. // have term trunk in this field, so we just unset value for this
  378. // delta.
  379. unset($entity->{$field_name}[$language][$delta]);
  380. }
  381. else {
  382. // Merging term references if necessary, and keep an eye on
  383. // whether we already have term trunk among this field values.
  384. switch ($item[$column]) {
  385. case $term_trunk->tid:
  386. $is_trunk_added = TRUE;
  387. break;
  388. case $term_branch->tid:
  389. $is_trunk_added = TRUE;
  390. $entity->{$field_name}[$language][$delta][$column] = $term_trunk->tid;
  391. break;
  392. }
  393. }
  394. }
  395. // Above in the code, while looping through all deltas of this field,
  396. // we might have unset some of the deltas to keep term references
  397. // unique. We should better keep deltas as a series of consecutive
  398. // numbers, because it is what it is supposed to be.
  399. $entity->{$field_name}[$language] = array_values($entity->{$field_name}[$language]);
  400. }
  401. // Integration with workbench_moderation module. Without this code, if
  402. // we save the node for which workbench moderation is enabled, then
  403. // it will go from "published" state into "draft". Though in fact we do
  404. // not change anything in the node and therefore it should persist in
  405. // published state.
  406. if (module_exists('workbench_moderation') && $entity_type == 'node') {
  407. $entity->workbench_moderation['updating_live_revision'] = TRUE;
  408. }
  409. // After updating all the references, save the entity.
  410. entity_save($entity_type, $entity);
  411. }
  412. }
  413. }
  414. // Adding term branch as synonym (Synonyms module integration).
  415. if ($context['synonyms']) {
  416. term_merge_add_entity_as_synonym($term_trunk, 'taxonomy_term', $context['synonyms'], $term_branch, 'taxonomy_term');
  417. }
  418. // It turned out we gotta go tricky with the Redirect module. If we create
  419. // redirection before deleting the branch term (if we are instructed to delete
  420. // in this action) redirect module will do its "auto-clean up" in
  421. // hook_entity_delete() and will delete our just created redirects. But at the
  422. // same time we have to get the path alias of the $term_branch before it gets
  423. // deleted. Otherwise the path alias will be deleted along with the term
  424. // itself. Similarly would be lost all redirects pointing to branch term
  425. // paths. We will redirect normal term path and its RSS feed.
  426. $redirect_paths = array();
  427. if ($context['redirect'] != TERM_MERGE_NO_REDIRECT) {
  428. $redirect_paths['taxonomy/term/' . $term_trunk->tid] = array(
  429. 'taxonomy/term/' . $term_branch->tid,
  430. );
  431. $redirect_paths['taxonomy/term/' . $term_trunk->tid . '/feed'] = array(
  432. 'taxonomy/term/' . $term_branch->tid . '/feed',
  433. );
  434. foreach ($redirect_paths as $redirect_destination => $redirect_sources) {
  435. // We create redirect from Drupal normal path, then we try to fetch its
  436. // alias. Lastly we collect a set of redirects that point to either of the
  437. // 2 former paths. Everything we were able to fetch will be redirecting to
  438. // the trunk term.
  439. $alias = drupal_get_path_alias($redirect_sources[0]);
  440. if ($alias != $redirect_sources[0]) {
  441. $redirect_sources[] = $alias;
  442. }
  443. $existing_redirects = array();
  444. foreach ($redirect_sources as $redirect_source) {
  445. foreach (redirect_load_multiple(array(), array('redirect' => $redirect_source)) as $v) {
  446. $existing_redirects[] = $v->source;
  447. }
  448. }
  449. $redirect_paths[$redirect_destination] = array_unique(array_merge($redirect_sources, $existing_redirects));
  450. }
  451. }
  452. if (!$context['term_branch_keep']) {
  453. // If we are going to delete branch term, we need firstly to make sure
  454. // all its children now have the parent of term_trunk.
  455. foreach (taxonomy_get_children($term_branch->tid, $vocabulary->vid) as $child) {
  456. $parents = taxonomy_get_parents($child->tid);
  457. // Deleting the parental link to the term that is being merged.
  458. unset($parents[$term_branch->tid]);
  459. // And putting the parental link to the term that we merge into.
  460. $parents[$term_trunk->tid] = $term_trunk;
  461. $parents = array_unique(array_keys($parents));
  462. $child->parent = $parents;
  463. taxonomy_term_save($child);
  464. }
  465. // Views module integration. We update all Views taxonomy filter handlers
  466. // configured to filter on term branch to filter on term trunk now, since
  467. // the former becomes the latter.
  468. if (module_exists('views')) {
  469. $views = views_get_all_views();
  470. foreach ($views as $view) {
  471. // For better efficiency, we keep track of whether we have updated
  472. // anything in a view, and thus whether we need to save it.
  473. $needs_saving = FALSE;
  474. // Even worse, we have to go through each display of each view.
  475. foreach ($view->display as $display_id => $display) {
  476. $view->set_display($display_id);
  477. $filters = $view->display_handler->get_handlers('filter');
  478. foreach ($filters as $filter_id => $filter_handler) {
  479. // Currently we know how to update filters only of this particular
  480. // class.
  481. if (get_class($filter_handler) == 'views_handler_filter_term_node_tid') {
  482. $filter = $view->get_item($display_id, 'filter', $filter_id);
  483. if (isset($filter['value'][$term_branch->tid])) {
  484. // Substituting term branch with term trunk.
  485. unset($filter['value'][$term_branch->tid]);
  486. $filter['value'][$term_trunk->tid] = $term_trunk->tid;
  487. $view->set_item($display_id, 'filter', $filter_id, $filter);
  488. $needs_saving = TRUE;
  489. }
  490. }
  491. }
  492. }
  493. if ($needs_saving) {
  494. $view->save();
  495. }
  496. }
  497. }
  498. // We are instructed to delete the term branch after the merge,
  499. // and so we do.
  500. taxonomy_term_delete($term_branch->tid);
  501. }
  502. // Here we do the 2nd part of integration with the Redirect module. Once the
  503. // branch term has been deleted (if deleted), we can add the redirects
  504. // without being afraid that the redirect module will delete them in its
  505. // hook_entity_delete().
  506. foreach ($redirect_paths as $redirect_destination => $redirect_sources) {
  507. foreach ($redirect_sources as $redirect_source) {
  508. $redirect = redirect_load_by_source($redirect_source);
  509. if (!$redirect) {
  510. // Seems like redirect from such URI does not exist yet, we will create
  511. // it.
  512. $redirect = new stdClass();
  513. redirect_object_prepare($redirect, array(
  514. 'source' => $redirect_source,
  515. ));
  516. }
  517. $redirect->redirect = $redirect_destination;
  518. $redirect->status_code = $context['redirect'];
  519. redirect_save($redirect);
  520. }
  521. }
  522. watchdog('term_merge', 'Successfully merged term %term_branch into term %term_trunk in vocabulary %vocabulary. Context: @context', array(
  523. '%term_branch' => $term_branch->name,
  524. '%term_trunk' => $term_trunk->name,
  525. '%vocabulary' => $vocabulary->name,
  526. '@context' => var_export($context, 1),
  527. ));
  528. }
  529. /**
  530. * Merge terms one into another using batch API.
  531. *
  532. * @param array $term_branch
  533. * A single term tid or an array of term tids to be merged, aka term branches
  534. * @param int $term_trunk
  535. * The tid of the term to merge term branches into, aka term trunk
  536. * @param array $merge_settings
  537. * Array of settings that control how merging should happen. Currently
  538. * supported settings are:
  539. * - term_branch_keep: (bool) Whether the term branches should not be
  540. * deleted, also known as "merge only occurrences" option
  541. * - merge_fields: (array) Array of field names whose values should be
  542. * merged into the values of corresponding fields of term trunk (until
  543. * each field's cardinality limit is reached)
  544. * - keep_only_unique: (bool) Whether after merging within one field only
  545. * unique taxonomy term references should be kept in other entities. If
  546. * before merging your entity had 2 values in its taxonomy term reference
  547. * field and one was pointing to term branch while another was pointing to
  548. * term trunk, after merging you will end up having your entity
  549. * referencing to the same term trunk twice. If you pass TRUE in this
  550. * parameter, only a single reference will be stored in your entity after
  551. * merging
  552. * - redirect: (int) HTTP code for redirect from $term_branch to
  553. * $term_trunk, 0 stands for the default redirect defined in Redirect
  554. * module. Use constant TERM_MERGE_NO_REDIRECT to denote not creating any
  555. * HTTP redirect. Note: this parameter requires Redirect module enabled,
  556. * otherwise it will be disregarded
  557. * - synonyms: (string) Optional field name of trunk term into which branch
  558. * terms should be added as synonyms (until field's cardinality limit
  559. * is reached). Note: this parameter requires Synonyms module enabled,
  560. * otherwise it will be disregarded
  561. * - step: (int) How many term branches to merge per script run in batch. If
  562. * you are hitting time or memory limits, decrease this parameter
  563. */
  564. function term_merge($term_branch, $term_trunk, $merge_settings = array()) {
  565. // Older versions of this module had another interface of this function,
  566. // as backward capability we still support the older interface, instead of
  567. // supplying a $merge_settings array, it was supplying all the settings as
  568. // additional function arguments.
  569. // @todo: delete this backward capability at some point.
  570. if (!is_array($merge_settings)) {
  571. $merge_settings = array(
  572. 'term_branch_keep' => $merge_settings,
  573. );
  574. }
  575. // Create an array of sources if it isn't yet.
  576. if (!is_array($term_branch)) {
  577. $term_branch = array($term_branch);
  578. }
  579. // Creating a skeleton for the merging batch.
  580. $batch = array(
  581. 'title' => t('Merging terms'),
  582. 'operations' => array(
  583. array('_term_merge_batch_process', array(
  584. $term_branch,
  585. $term_trunk,
  586. $merge_settings,
  587. )),
  588. ),
  589. 'finished' => 'term_merge_batch_finished',
  590. 'file' => drupal_get_path('module', 'term_merge') . '/term_merge.batch.inc',
  591. );
  592. // Initialize the batch process.
  593. batch_set($batch);
  594. }
  595. /**
  596. * Retrieve information about ctools plugin of type 'duplicate suggestion'.
  597. *
  598. * @param string $id
  599. * Supply here ID of the cTool plugin information about which you want to
  600. * retrieve. You may omit this argument and then information on all duplicate
  601. * suggestion plugins will be returned
  602. *
  603. * @return array
  604. * Array of information on all available duplicate suggestion plugins or if
  605. * $id was provided, then information on that plugin
  606. */
  607. function term_merge_duplicate_suggestion($id = NULL) {
  608. ctools_include('plugins');
  609. $plugins = ctools_get_plugins('term_merge', 'duplicate_suggestion', $id);
  610. if (!$id) {
  611. // Sort the list of plugins by their weight.
  612. uasort($plugins, 'drupal_sort_weight');
  613. }
  614. return $plugins;
  615. }
  616. /**
  617. * Generate and return form elements that control behavior of merge action.
  618. *
  619. * Output of this function should be used in any form that merges terms,
  620. * ensuring unified interface. It should be used in conjunction with
  621. * term_merge_merge_options_submit(), which will process the submitted values
  622. * for you and return an array of merge settings.
  623. *
  624. * @param object $vocabulary
  625. * Fully loaded taxonomy vocabulary object in which merging occurs
  626. *
  627. * @return array
  628. * Array of form elements that allow controlling term merge action
  629. *
  630. * @see term_merge_merge_options_submit()
  631. */
  632. function term_merge_merge_options_elements($vocabulary) {
  633. // @todo: it would be nice to provide some ability to supply default values
  634. // for each setting.
  635. $form = array();
  636. // Getting bundle name and a list of fields attached to this bundle for
  637. // further use down below in the code while generating form elements.
  638. $bundle = field_extract_bundle('taxonomy_term', $vocabulary);
  639. $instances = field_info_instances('taxonomy_term', $bundle);
  640. $form['term_branch_keep'] = array(
  641. '#type' => 'checkbox',
  642. '#title' => t('Only merge occurrences'),
  643. '#description' => t('Check this if you want to only merge the occurrences of the specified terms, i.e. the terms will not be deleted from your vocabulary.'),
  644. );
  645. if (!empty($instances)) {
  646. $options = array();
  647. foreach ($instances as $instance) {
  648. $options[$instance['field_name']] = $instance['label'];
  649. }
  650. $form['merge_fields'] = array(
  651. '#type' => 'checkboxes',
  652. '#title' => t('Merge Term Fields'),
  653. '#description' => t('Check the fields whose values from branch terms you want to add to the values of corresponding fields of the trunk term. <b>Important note:</b> the values will be added until the cardinality limit for the selected fields is reached and only unique values for each field will be saved.'),
  654. '#options' => $options,
  655. );
  656. }
  657. $form['keep_only_unique'] = array(
  658. '#type' => 'checkbox',
  659. '#title' => t('Keep only unique terms after merging'),
  660. '#description' => t('Sometimes after merging you may end up having a node (or any other entity) pointing twice to the same taxonomy term, tick this checkbox if want to keep only unique terms in other entities after merging.'),
  661. '#default_value' => TRUE,
  662. );
  663. if (module_exists('redirect')) {
  664. $options = array(
  665. TERM_MERGE_NO_REDIRECT => t('No redirect'),
  666. 0 => t('Default (@default)', array(
  667. '@default' => variable_get('redirect_default_status_code', 301),
  668. )),
  669. ) + redirect_status_code_options();
  670. $form['redirect'] = array(
  671. // We respect access rights defined in redirect.module here.
  672. '#access' => user_access('administer redirects'),
  673. '#type' => 'select',
  674. '#title' => t('Create Redirect'),
  675. '#description' => t('If you want to create an HTTP redirect from your branch terms to the trunk term, please, choose the HTTP redirect code here.'),
  676. '#required' => TRUE,
  677. '#options' => $options,
  678. '#default_value' => TERM_MERGE_NO_REDIRECT,
  679. );
  680. }
  681. else {
  682. $form['redirect'] = array(
  683. '#markup' => t('Enable the module ' . l('Redirect', 'http://drupal.org/project/redirect') . ' if you want to do an HTTP redirect from your term branch to the term trunk.'),
  684. );
  685. }
  686. if (module_exists('synonyms')) {
  687. $options = array('' => t('None'));
  688. if (function_exists('synonyms_behavior_get')) {
  689. // We are in Synonyms 7.x-1.5 and above.
  690. foreach (synonyms_behavior_get('term_merge', 'taxonomy_term', $vocabulary->machine_name, TRUE) as $behavior_implementation) {
  691. $options[$behavior_implementation['provider']] = $behavior_implementation['label'];
  692. }
  693. }
  694. else {
  695. // This is how we used to retrieve possible synonyms field prior to
  696. // Synonyms 7.x-1.5. TODO: this should be removed at some point.
  697. foreach (synonyms_synonyms_fields($vocabulary) as $field_name) {
  698. $options[$field_name] = $instances[$field_name]['label'];
  699. }
  700. }
  701. $form['synonyms'] = array(
  702. '#type' => 'radios',
  703. '#title' => t('Add as Synonyms'),
  704. '#description' => t('Synonyms module allows you to add branch terms as synonyms into any of fields, enabled as sources of synonyms in vocabulary. Check the field into which you would like to add branch terms as synonym. <b>Important note:</b> the values will be added until the cardinality limit for the selected field is reached.'),
  705. '#options' => $options,
  706. '#default_value' => '',
  707. );
  708. }
  709. else {
  710. $form['synonyms'] = array(
  711. '#markup' => t('Enable the module ' . l('Synonyms', 'http://drupal.org/project/synonyms') . ' if you want to be able to add branch terms as synonyms into a field of your trunk term.'),
  712. );
  713. }
  714. $form['step'] = array(
  715. '#type' => 'textfield',
  716. '#title' => t('Step'),
  717. '#description' => t('Please, specify how many terms to process per script run in batch. If you are hitting time or memory limits in your PHP, decrease this number.'),
  718. '#default_value' => 40,
  719. '#required' => TRUE,
  720. '#element_validate' => array('element_validate_integer_positive'),
  721. );
  722. return $form;
  723. }
  724. /**
  725. * Return merge settings array.
  726. *
  727. * Output of this function should be used for supplying into term_merge()
  728. * function or for triggering actions_do('term_merge_action', ...) action. This
  729. * function should be invoked in a form submit handler for a form that used
  730. * term_merge_merge_options_elements() for generating merge settings elements.
  731. * It will process data and return an array of merge settings, according to the
  732. * data user has submitted in your form.
  733. *
  734. * @param array $merge_settings_element
  735. * That part of form that was generated by term_merge_merge_options_elements()
  736. * @param array $form_state
  737. * Form state array of the submitted form
  738. * @param array $form
  739. * Form array of the submitted form
  740. *
  741. * @return array
  742. * Array of merge settings that can be used for calling term_merge() or
  743. * invoking 'term_merge_action' action
  744. *
  745. * @see term_merge_merge_options_elements()
  746. */
  747. function term_merge_merge_options_submit($merge_settings_element, &$form_state, $form) {
  748. $merge_settings = array(
  749. 'term_branch_keep' => (bool) $merge_settings_element['term_branch_keep']['#value'],
  750. 'merge_fields' => isset($merge_settings_element['merge_fields']['#value']) ? array_values(array_filter($merge_settings_element['merge_fields']['#value'])) : array(),
  751. 'keep_only_unique' => (bool) $merge_settings_element['keep_only_unique']['#value'],
  752. 'redirect' => isset($merge_settings_element['redirect']['#value']) ? $merge_settings_element['redirect']['#value'] : TERM_MERGE_NO_REDIRECT,
  753. 'synonyms' => isset($merge_settings_element['synonyms']['#value']) ? $merge_settings_element['synonyms']['#value'] : NULL,
  754. 'step' => (int) $merge_settings_element['step']['#value'],
  755. );
  756. return $merge_settings;
  757. }
  758. /**
  759. * Fetch all fields that have a foreign key to provided column.
  760. *
  761. * @param string $foreign_table
  762. * Name of the table for which to look among foreign keys of all the fields
  763. * @param string $foreign_column
  764. * Name of the column for which to look among foreign keys of all the fields
  765. *
  766. * @return array
  767. * Array of all fields that have the specified table and column within their
  768. * foreign keys. Each of the fields array will be extended to include the
  769. * following additional keys:
  770. * - term_merge_field_column: (string) Name of the field column that holds
  771. * foreign key to the provided table and column
  772. */
  773. function term_merge_fields_with_foreign_key($foreign_table, $foreign_column) {
  774. $fields = field_info_fields();
  775. $result = array();
  776. foreach ($fields as $field_name => $field_info) {
  777. foreach ($field_info['foreign keys'] as $foreign_key) {
  778. if ($foreign_key['table'] == $foreign_table) {
  779. $column = array_search($foreign_column, $foreign_key['columns']);
  780. if ($column) {
  781. $field_info['term_merge_field_column'] = $column;
  782. $result[] = $field_info;
  783. }
  784. }
  785. }
  786. }
  787. return $result;
  788. }
  789. /**
  790. * Allow to merge $synonym_entity as a synonym into $trunk_entity.
  791. *
  792. * Helpful function during various merging operations. It allows you to add a
  793. * synonym (where possible) into one entity, which will represent another entity
  794. * in the format expected by the field in which the synonym is being added.
  795. *
  796. * @param object $trunk_entity
  797. * Fully loaded entity object in which the synonym is being added
  798. * @param string $trunk_entity_type
  799. * Entity type of $trunk_entity
  800. * @param string $behavior_provider
  801. * Machine name of behavior implementation into which the synonym entity
  802. * should be merged
  803. * @param object $synonym_entity
  804. * Fully loaded entity object which will be added as a synonym
  805. * @param string $synonym_entity_type
  806. * Entity type of $synonym_entity
  807. *
  808. * @return bool
  809. * Whether synonym has been successfully added
  810. */
  811. function term_merge_add_entity_as_synonym($trunk_entity, $trunk_entity_type, $behavior_provider, $synonym_entity, $synonym_entity_type) {
  812. if ($trunk_entity_type != 'taxonomy_term') {
  813. // So far we only work with taxonomy terms.
  814. return FALSE;
  815. }
  816. $bundle = entity_extract_ids($trunk_entity_type, $trunk_entity);
  817. $bundle = $bundle[2];
  818. $behavior_implementations = synonyms_behavior_get_all_enabled($trunk_entity_type, $bundle, $behavior_provider);
  819. foreach ($behavior_implementations as $behavior_implementation) {
  820. if ($behavior_implementation['behavior'] == 'term_merge') {
  821. $behavior_implementation['object']->mergeTerm($trunk_entity, $synonym_entity, $synonym_entity_type);
  822. taxonomy_term_save($trunk_entity);
  823. return TRUE;
  824. }
  825. }
  826. return FALSE;
  827. }
  828. /**
  829. * Form API element validate function.
  830. *
  831. * Make sure term trunk is not among the selected term branches or their
  832. * children.
  833. */
  834. function term_merge_form_base_term_trunk_validate($element, &$form_state, $form) {
  835. $prohibited_trunks = array();
  836. foreach ($form['#term_merge_term_branch'] as $term_branch) {
  837. $children = taxonomy_get_tree($form['#vocabulary']->vid, $term_branch);
  838. $prohibited_trunks[] = $term_branch;
  839. foreach ($children as $child) {
  840. $prohibited_trunks[] = $child->tid;
  841. }
  842. }
  843. $value = drupal_array_get_nested_value($form_state['values'], $element['#parents']);
  844. if (in_array($value['tid'], $prohibited_trunks)) {
  845. form_error($element['tid'], t('Trunk term cannot be one of the selected branch terms or their children.'));
  846. }
  847. }
  848. /**
  849. * Supportive function.
  850. *
  851. * Generate form elements for select widget for term trunk element of the term
  852. * merge form.
  853. *
  854. * @param object $vocabulary
  855. * Fully loaded taxonomy vocabulary object
  856. * @param array $term_branch_value
  857. * Array of Taxonomy term IDs that are nominated as branch terms.
  858. */
  859. function term_merge_form_term_trunk_widget_select(&$form, &$form_state, $vocabulary, $term_branch_value) {
  860. $tree = taxonomy_get_tree($vocabulary->vid);
  861. $options = array();
  862. foreach ($tree as $v) {
  863. $options[$v->tid] = str_repeat('-', $v->depth) . $v->name . ' [tid: ' . $v->tid . ']';
  864. }
  865. if (!empty($term_branch_value)) {
  866. // We have to make sure among term_trunk there is no term_branch or any of
  867. // their children.
  868. foreach ($term_branch_value as $v) {
  869. unset($options[$v]);
  870. foreach (taxonomy_get_tree($vocabulary->vid, $v) as $child) {
  871. unset($options[$child->tid]);
  872. }
  873. }
  874. }
  875. else {
  876. // Term branch has not been selected yet.
  877. $options = array();
  878. }
  879. $form['term_trunk']['tid'] = array(
  880. '#type' => 'select',
  881. '#required' => TRUE,
  882. '#description' => t('Choose into what term you want to merge.'),
  883. '#options' => $options,
  884. );
  885. }
  886. /**
  887. * Supportive function.
  888. *
  889. * Generate form element for hierarchical select widget for term trunk element
  890. * of the term merge form.
  891. *
  892. * @param object $vocabulary
  893. * Fully loaded taxonomy vocabulary object
  894. * @param array $term_branch_value
  895. * Array of Taxonomy term IDs that are nominated as branch terms.
  896. */
  897. function term_merge_form_term_trunk_widget_hs_taxonomy(&$form, &$form_state, $vocabulary, $term_branch_value) {
  898. $form['term_trunk']['tid'] = array(
  899. '#type' => 'hierarchical_select',
  900. '#description' => t('Please select a term to merge into.'),
  901. '#required' => TRUE,
  902. '#element_validate' => array('term_merge_form_trunk_term_widget_hs_taxonomy_validate'),
  903. '#config' => array(
  904. 'module' => 'hs_taxonomy',
  905. 'params' => array(
  906. 'vid' => $vocabulary->vid,
  907. 'exclude_tid' => NULL,
  908. 'root_term' => FALSE,
  909. ),
  910. 'enforce_deepest' => 0,
  911. 'entity_count' => 0,
  912. 'require_entity' => 0,
  913. 'save_lineage' => 0,
  914. 'level_labels' => array(
  915. 'status' => 0,
  916. ),
  917. 'dropbox' => array(
  918. 'status' => 0,
  919. ),
  920. 'editability' => array(
  921. 'status' => 0,
  922. ),
  923. 'resizable' => TRUE,
  924. 'render_flat_select' => 0,
  925. ),
  926. );
  927. }
  928. /**
  929. * Supportive function.
  930. *
  931. * Generate form elements for autocomplete widget for term trunk element of the
  932. * term merge form.
  933. *
  934. * @param object $vocabulary
  935. * Fully loaded taxonomy vocabulary object
  936. * @param array $term_branch_value
  937. * Array of Taxonomy term IDs that are nominated as branch terms.
  938. */
  939. function term_merge_form_term_trunk_widget_autocomplete(&$form, &$form_state, $vocabulary, $term_branch_value) {
  940. $form['term_trunk']['tid'] = array(
  941. '#type' => 'textfield',
  942. '#description' => t("Start typing in a term's name in order to get some suggestions."),
  943. '#required' => TRUE,
  944. '#autocomplete_path' => 'term-merge/autocomplete/term-trunk/' . $vocabulary->machine_name,
  945. '#element_validate' => array('term_merge_form_trunk_term_widget_autocomplete_validate'),
  946. );
  947. }
  948. /**
  949. * Supportive function.
  950. *
  951. * Validate form element of the autocomplete widget of term trunk element of the
  952. * term merge form. Make sure the entered string is a name of one of the
  953. * existing terms in the vocabulary where the merge occurs. If term is found the
  954. * function substitutes the name with its {taxonomy_term_data}.tid as it is what
  955. * is expected from a term trunk widget to provide in its value.
  956. */
  957. function term_merge_form_trunk_term_widget_autocomplete_validate($element, &$form_state, $form) {
  958. // Field value is "name (tid)", match the tid from parenthesis.
  959. if (preg_match("/.+\((\d+)\)$/", $element['#value'], $matches)) {
  960. $term = taxonomy_term_load($matches[1]);
  961. }
  962. else {
  963. // Assume that the user didn't use the autocomplete but filled in a tid
  964. // manually.
  965. $term = taxonomy_get_term_by_name($element['#value'], $form['#vocabulary']->machine_name);
  966. $term = reset($term);
  967. }
  968. if (empty($term)) {
  969. // Seems like the user has entered a non existing name or tid in the
  970. // autocomplete textfield.
  971. form_error($element, t('There are no terms matching %value in the %vocabulary vocabulary.', array(
  972. '%value' => $element['#value'],
  973. '%vocabulary' => $form['#vocabulary']->name,
  974. )));
  975. }
  976. else {
  977. // We have to substitute the field value the term tid in order to make this
  978. // widget consistent with the interface.
  979. form_set_value($element, $term->tid, $form_state);
  980. }
  981. }
  982. /**
  983. * Supportive function.
  984. *
  985. * Validate form element of the Hierarchical Select widget of term trunk element
  986. * of the term merge form. Convert the value from array to a single tid integer
  987. * value.
  988. */
  989. function term_merge_form_trunk_term_widget_hs_taxonomy_validate($element, &$form_state, $form) {
  990. $tid = 0;
  991. if (is_array($element['#value']) && !empty($element['#value'])) {
  992. $tid = (int) array_pop($element['#value']);
  993. }
  994. form_set_value($element, $tid, $form_state);
  995. }