BTMash

Blob of contradictions

Creating a Linkit Plugin

Written

Most folk that talk to me about linking content within a Drupal site (and use a wysiwyg module) know that I am a big fan of the Linkit module with pathologic. It provides a nice way to reference content within your site, keeps a simple url, and pathologic will convert it to the nice url. However, I recently ran into a problem with the course catalog on our campus. Each discipline as a requirements section which is currently managed by hand and we reference courses which are brought over via the migrate module. But one 2 occasions, we have had mishaps where the migrate module rolled back all of our courses (in other words, deleted) and migrated in course material. And all of these courses now had new node IDs - this meant all of our links were now broken. Linkit helped in updating those course codes, but the sheer amount of them meant that this ended up being a 6+ hour task. So we needed a new approach.

For our courses, we knew a couple of things - even though the node IDs could change, the actual course codes do not. And we have terms set up with views where we are displaying the course information (and each course has the course code as an anchor link) so perhaps a better approach would be to instead link the course via the course code to the term view page. This would require learning a few new things: letting Linkit know about the plugin via CTools (thus we are really creating a CTools plugin) and creating the plugin itself.

First off, letting CTools know you have plugins is not particularly difficult. You have to implement hook_ctools_plugin_directory($owner, $plugin_type). Within this, ctools basically asking you:
Where can I find plugins that are being requested by the module $module for the $plugin_type. In this case, we are implementing a 'Linkit' plugin of type 'linkit_plugins'. So in the end my implementation looked something like this:

  1. /**
  2.  * Implements hook_ctools_plugin_directory().
  3.  */
  4. function my_linkit_courses_plugin_ctools_plugin_directory($module, $plugin) {
  5. if ($module == 'linkit' && !empty($plugin)) {
  6. return "plugins/" . $plugin;
  7. }
  8. }

This will let ctools and linkit know to find linkit plugins in my plugins directory (where there will be another directory name 'linkit_plugins'.

Inside this linkit_plugins directory, we can now create directories for each of our plugins - since I only have 1, I can store it right at the linkit_plugins level. This file will have an array explaining what the new class is going to do along with where to find it. So in my case:

  1. <?php
  2. /**
  3.  * @file
  4.  * Linkit views plugin.
  5.  */
  6.  
  7. $plugin = array(
  8. 'ui_title' => t("Course Catalog Codes"),
  9. 'ui_description' => t('Extend Linkit course code support.'),
  10. 'entity_type' => 'node',
  11. 'handler' => array(
  12. 'class' => 'LinkitPluginCourseCode',
  13. 'file' => 'linkit_plugin_course_code.class.php',
  14. ),
  15. );

Now I am ready to implement a class called LinkitPluginCourseCode. In my case, courses are nodes of bundle 'course'. Since there is already a node plugin for Linkit, I will just create a class that extends the functionality offered by that plugin. Now I just need to override 1 function: autocomplete_callback. This is the function that determines what the user will choose as their link. In my case, I have copied the autocomplete_callback from the linkit module and modified the EntityFieldQuery so it will instead search through the field_course_code field when possible. Additionally, I want to display links to all the taxonomy term pages that the course is available under so the content editor has a choice in which discipline the course link redirects to (we have a limited number of disciplines so this format is acceptable). Below is the full code:

  1. <?php
  2. /**
  3.  * @file
  4.  * Define Linkit node plugin class for courses.
  5.  */
  6. class LinkitPluginCourseCode extends LinkitPluginNode {
  7.  
  8. /**
  9.   * The autocomplete callback function for the Linkit Entity plugin.
  10.   */
  11. function autocomplete_callback() {
  12. $field_definition = field_info_field('field_course_code');
  13. if (empty($field_definition)) {
  14. return parent::autocomplete_callback();
  15. }
  16. $matches = array();
  17. // Get the EntityFieldQuery instance.
  18. $this->getQueryInstance();
  19.  
  20. // Add the search condition to the query object.
  21. $this->query->fieldCondition('field_course_code', 'value', $this->serach_string, 'CONTAINS')
  22. ->addTag('linkit_entity_autocomplete')
  23. ->addTag('linkit_' . $this->plugin['entity_type'] . '_autocomplete');
  24.  
  25. // Add access tag for the query.
  26. // There is also a runtime access check that uses entity_access().
  27. $this->query->addTag($this->plugin['entity_type'] . '_access');
  28.  
  29. // Bundle check.
  30. if (isset($this->entity_key_bundle) && isset($this->conf['bundles']) ) {
  31. if ($bundles = array_filter($this->conf['bundles'])) {
  32. $this->query->propertyCondition($this->entity_key_bundle, $bundles, 'IN');
  33. }
  34. }
  35.  
  36. // Execute the query.
  37. $result = $this->query->execute();
  38.  
  39. if (!isset($result[$this->plugin['entity_type']])) {
  40. return array();
  41. }
  42.  
  43. $ids = array_keys($result[$this->plugin['entity_type']]);
  44.  
  45. // Load all the entities with all the ids we got.
  46. $entities = entity_load($this->plugin['entity_type'], $ids);
  47.  
  48. foreach ($entities AS $entity) {
  49. // Check the access againt the definded entity access callback.
  50. if (!entity_access('view', $this->plugin['entity_type'], $entity)) {
  51. continue;
  52. }
  53.  
  54. if ($entity->type == 'course') {
  55. $term_matches = $this->buildTermPath($entity);
  56. foreach ($term_matches as $match) {
  57. $matches[] = $match;
  58. }
  59. }
  60. else {
  61. $matches[] = array(
  62. 'title' => $this->buildLabel($entity),
  63. 'description' => $this->buildDescription($entity),
  64. 'path' => $this->buildPath($entity),
  65. 'group' => $this->buildGroup($entity),
  66. 'addClass' => $this->buildRowClass($entity),
  67. );
  68. }
  69.  
  70. }
  71. return $matches;
  72. }
  73.  
  74. /**
  75.   * Builds paths to the term page instead of the entity.
  76.   */
  77. function buildTermPath($entity) {
  78. $course_code = field_get_items('node', $entity, 'field_course_code');
  79. $course_code = $course_code[0]['safe_value'];
  80. $terms = field_get_items('node', $entity, 'field_course_discipline');
  81. $matches = array();
  82. foreach ($terms as $term) {
  83. $discipline = taxonomy_term_load($term['tid']);
  84. $entity_uri = entity_uri('taxonomy_term', $discipline);
  85. if (module_exists('pathologic')) {
  86. $uri = '/' . $entity_uri['path'] . '#' . $course_code;
  87. }
  88. else {
  89. $uri = url($entity_uri['path'], array('alias' => TRUE, 'fragment' => $course_code));
  90. }
  91. $matches[] = array(
  92. 'title' => $this->buildLabel($entity) . ' - ' . check_plain($discipline->name) . ' LINK',
  93. 'description' => $this->buildDescription($entity) . ' - ' . check_plain($discipline->name) . ' LINK',
  94. 'path' => $uri,
  95. 'group' => $this->buildGroup($entity),
  96. 'addClass' => $this->buildRowClass($entity),
  97. );
  98. }
  99. return $matches;
  100. }
  101. }

With this, the content editors are now able to link to courses based on their course id AND they are able to go to a location where they know a missing node ID will not affect the final outcome. It is not a particularly large amount of code given that it is so flexible (with this kind of scenario, it is actually possible to create even more dynamic linkit options where we could make selections more configurable. Or imagine passing what you need off to views to get your resultset. Very exciting!). And it was also a really nice introduction to creating your own CTools plugins (I certainly won't feel as afraid to write my own plugin as I used to be!).