MySite: writing a content type plugin
This page is a step-by-step tutorial for extending the MySite module by writing a new type.inc file. The code in this tutorial is part of the MySite release and can be found in the download at mysite/plugins/types/book.inc.
For an updated API for MySite 5.x.2, see http://therickards.com/api
Writing book.inc for MySite
Before you start making a type include for MySite, please read:
- Drupal coding standards
- Drupal APIs
- The README.txt included in the MySite download
Planning the code
First, plan out what use cases apply to this content type. In the case of books, we don't necessarily want to show the book in its published order. It makes more sense to show recent changes to a specific book.
There can also be more than one book on a Drupal site, so the book's main node id will be the organizing identifier for the book type.
It is important to review the data structure for how Drupal retrieves a content type. In the case of books, there is a 'book' table in the database. The book table contains 4 data fields:
- vid -- the active revision number of the book node.
- nid -- the unique node id of the book node.
- parent -- the parent node id of this book page.
- weight -- the weight of this book page relative to its peers
For our use case, the important item to know is:
- Is the book node a parent (if so, parent == 0)
This tells us which nodes are books and which are pages belonging to books.
Writing the code
Part One: Required Features
1. Set up the include file like a normal Drupal file.
<?php
// $Id$Save this as 'book.inc' inside of 'mysite/plugins/types/' and you're ready to start.
2. Define the content type
This function registers your plugin with the MySite type system.
Arguments: $get_options = TRUE | FALSE.
The $get_options argument tells the function whether to return content options as part of the return value.
Returns: A $type array containing information about this content type.
/**
* Implements mysite_type_hook().
*
* Book module must be enabled for this plugin to register.
*
*/
function mysite_type_book($get_options = TRUE) {
if (module_exists('book')) {
$type = array(
'name' => t('Books'),
'description' => t('<b>Books</b>: Pages from a book collection.'),
'include' => 'book',
'prefix' => t(''),
'suffix' => t('book pages'),
'category' => t('Content'),
'weight' => 0,
'form' => FALSE,
'label' => t('Add Book Updates'),
'help' => t('You can track new pages for individual books. Type a book name in the search box, or choose from the list provided.'),
'search' => TRUE
);
if ($get_options) {
$type['options'] = mysite_type_book_options();
}
return $type;
}
}If you go to example.com/admin/settings/mysite, you will see the book type under the User Settings group, under 'Content Types'.
3. Define the activation rules for this type.
New in MySite 5.x.2 is the function mysite_type_hook_active($type). This hook is used to alert site administrators when a plugin cannot be activated. The function should check its conditions for activation and return an error message with a link to the page where the problem can be corrected.
Arguments: $type == the content type (here 'book').
Returns: An array in the format array($type => TRUE|FALSE, 'message' => l(t('Error message'), 'path/to/correct/error)).
/**
* Implements mysite_type_hook_active().
*/
function mysite_type_book_active($type) {
// some users must be able to create or edit books
$result = db_query("SELECT perm FROM {permission}");
$check = '';
while ($perms = db_fetch_object($result)) {
$check .= $perms->perm;
}
if (stristr($check, 'create new books') || stristr($check, 'edit book pages') || stristr($check, 'creat book pages')) {
return array($type => TRUE);
}
else {
return array($type => FALSE, 'message' => l(t('There are no users with create or edit book permissions.'), 'admin/user/access'));
}
}4. Define the content options for this type.
Here we write the logic for presenting Book selection options to the user on the MySite 'add content' page.
Arguments: none.
Returns: An $options array containing one entry for each content group available to add to a MySite page.
/**
* Implements mysite_type_hook_options().
*/
function mysite_type_book_options() {
$options = array();
// we are dealing with nodes, so node_access requires db_rewrite_sql here. See http://drupal.org/node/135378.
$sql = db_rewrite_sql("SELECT n.nid, n.title FROM {node} n INNER JOIN {book} b ON b.nid = n.nid WHERE b.parent =0 AND n.status = 1 AND n.type = 'book' ORDER BY n.title");
$result = db_query($sql);
$books = array();
while ($item = db_fetch_object($result)) {
$books[] = $item;
}
foreach ($books as $key => $value) {
$options['name'][] = mysite_type_book_title($value->nid, $value->title);
$options['type_id'][] = $value->nid;
$options['type'][] = 'book';
$options['icon'][] = mysite_get_icon('book', $value->nid);
}
return $options;
}The key here is understanding the structure of the book node type. (See 'planning the code' above.) The code here was copied from the blog.inc type include. The only changes are:
- Replace all references to 'blog' with 'book' (case-sensitive)
- Change the $sql statement to return a list of books.
- Change the $options['name'][] var to $value->title (blog.inc uses $value->name).
- Change the $options['type_id][] var to $value->nid (blog.inc uss $value->uid.
Now you can enable the Book type under MySite's module settings.
5. Define the content title for this type.
In some cases we access MySite without knowing the title of the content (especially during the save and block routines). The mysite_type_book_title() function tells MySite how to print a title for a specific content group.
Arguments: $type_id = the unqiue ID key for a content group (such as a $term TID or a $user uid).
$title = A string to be used as the title, after attaching the prefeix and suffix.
Returns: A $title string used to label the content group.
/**
* Implements mysite_type_hook_title().
*/
function mysite_type_book_title($type_id = NULL, $title = NULL) {
if (!empty($type_id)) {
if (is_null($title)) {
$book = node_load($type_id);
$title = $book->title;
}
$type = mysite_type_book(FALSE);
$title = $type['prefix'] .' '. $title .' '. $type['suffix'];
$title = trim(rtrim($title));
return $title;
}
drupal_set_message(t('Could not find title'), 'error');
return;
}WARNING: When fetching $type = mysite_type_book(FALSE);, make sure you set the parameter to FALSE. Failing to do so may trigger an endless logic loop.
This function tells other MySite function how to format and display a title element for a content group. If necessary, it will load the data it needs (here using node_load() and add the prefix and suffix strings.
At this point, it is safe to add book content to an individual MySite page. But notice that if you do, no content will be displayed by mysite/UID/view.
6. Define the content view
To show content for this type, implement mysite_type_book_data().
Arguments: $type_id = a numeric identifier for this group group.
Returns: A positional $items array containing data for each content item within a content group.
/**
* Implements mysite_type_hook_data().
*/
function mysite_type_book_data($type_id = NULL, $settings = NULL) {
if (!empty($type_id)) {
$sql = db_rewrite_sql("SELECT n.nid, n.changed FROM {node} n INNER JOIN {book} b ON n.nid = b.nid WHERE b.parent = %d OR n.nid = %d AND n.type = 'book' AND n.status = 1 ORDER BY n.changed DESC, b.weight");
$result = db_query_range($sql, $type_id, $type_id, 0, variable_get('mysite_elements', 5));
$data = array(
'base' => 'book/'. $type_id,
'xml' => 'book/'. $type_id .'/feed',
);
$items = array();
$i = 0;
while ($nid = db_fetch_object($result)) {
$node = node_load($nid->nid);
$items[$i]['type'] = $node->type;
$items[$i]['link'] = l($node->title, 'node/'. $nid->nid);
$items[$i]['title'] = check_plain($node->title);
$items[$i]['subtitle'] = NULL;
$items[$i]['date'] = $node->changed;
$items[$i]['uid'] = $node->uid;
$items[$i]['author'] = check_plain($node->name);
$items[$i]['teaser'] = mysite_teaser($node);
$items[$i]['nid'] = $node->nid;
$i++;
}
$data['items'] = $items;
return $data;
}
drupal_set_message(t('Could not find data'), 'error');
return;
}Again, we just borrow from blog.inc, since we are showing node content. All that we need to edit is the $sql statement. The new $sql statement is designed to return all child pages of the selected book, ordered by last update and then by their order in the book.
When dealing with content other than nodes, it will be necessary to use a different function from node_load() or a db_query to return the data that you need. In either case, the data should be formatted into a positional $items array.
When MySite outputs the content, the module iterates through the $items array and displays the results to the user.
How that content is displayed is handled by Format and Layout includes. To add a content type, you don't need to edit those.
7. Updating user data via cron
This feature is optional, but highly recommended.
There are cases in which content in a user's MySite may no longer be available. For example:
- An RSS feed is deleted by the site administrator.
- A blogger's account is deleted, or blog permissions removed.
- A forum or taxonomy term is deleted.
In these cases, users with that content type in their MySite will be left with empty content. They may wonder what happened. MySite's cron hook is designed to update users in the event of such changes.
MySite's cron invokes the mysite_type_book_clear() function in order to check user data for validity.
Arguments: $type = a string that identifies the content type.
Returns: A $data array containing MySite data to be deleted from a user's MySite record.
/**
* Implements mysite_type_hook_clear().
*/
function mysite_type_book_clear($type) {
// fetch all the active records of this type and see if they really exist in the proper table
$sql = "SELECT mid, uid, type_id, title FROM {mysite_data} WHERE type = '%s'";
$result = db_query($sql, $type);
$data = array();
while ($item = db_fetch_array($result)) {
$sql = "SELECT b.nid FROM {book} b WHERE b.parent = 0 AND b.nid = %d";
$check = db_fetch_object(db_query($sql, $item['type_id']));
if (empty($check->nid)) {
$data[$item['mid']] = $item;
}
}
return $data;
}Here, we want the $check->nid value to return a value. If it does, then the content still exists. If it does not, we pass that to the cron function, which handles deletion and sets a message for the user.
Note: The MySite cron function fires once per day. If cron is not enabled on your site, the cron function will also fire when you visit the MySite settings page.
Note: We don't wrap this query in db_rewrite_sql(), because we are just checking for the availability of the data, not access to the data. This point is debatable.
End of part one
That's it. The include works. (Test it to see.) But there's more functionality that we could add.
Part Two: Advanced Features
Advanced features add some extra UI elements to make MySite easier to use. These include search and block links, plus some AJAX features.
8. Adding a link to MySite's block
This feature is optional but recommended.
When users are navigating a site, MySite's block can alert them to content that can be added and removed from their MySite page. This feature is handled by two functions.
- mysite_type_{name}_block()
- mysite_type_{name}_block_node()
Normally, the first function is used to check the menu callback of the page, and the second function is used if the user is on a node page.
In the case of books, however, the path will always start with node, so a single check works in all cases.
We need to derive the parent_id for the book, regardless of what page we are on. That is done as follows.
Arguments: $nid = a node nid, determined by the mysite_block function.
Returns: $content as specified by hook_block. [This is slightly misleading, the passing of the $data array to the block handler is crucial.]
/**
* Implements mysite_type_hook_block_node().
*/
function mysite_type_book_block_node($nid = NULL) {
if (!is_null($nid)) {
global $user;
$result = db_query(db_rewrite_sql('SELECT n.nid, n.title, b.parent, n.type FROM {node} n INNER JOIN {book} b ON n.vid = b.vid WHERE n.nid = %d'), $nid);
$book = db_fetch_object($result);
// if no parent, this is the parent
if ($book->parent == 0) {
$book->parent = $book->nid;
}
$data = array();
$data['uid'] = $user->uid;
$data['type'] = 'book';
$data['type_id'] = $book->parent;
$data['title'] = mysite_type_book_title($book->parent, $book->name);
$content = mysite_block_handler($data);
return $content;
}
}This case turns out to be quite simple. Access control checks are handled in the mysite_block function.
9. Searching for content to add to MySite
On the 'add content' page, MySite provides a table of the top X content groups in each category as set by the site administrator.
Content can also be searched, so that a use can find content from a wider selection. Each type include defines its own search rules.
To make search work, we must include three functions: A search form, a form submit handler, and an AJAX autocomplete.
The search hook uses Drupal's FormsAPI to define how to search our book content.
Arguments: $uid = the user ID of the MySite page that content is being added to.
Return: A Drupal $form array.
/**
* Implements mysite_type_hook_search().
*
* @ingroup forms
*/
function mysite_type_book_search($uid = NULL) {
if (!is_null($uid)) {
$output .= drupal_get_form('mysite_type_book_search_form', $uid);
return $output;
}
}9a. Search form handler.
In FormsAPI for Drupal 5, each form expects a builder function. We always pass the $uid variable to this function.
/**
* FormsAPI for mysite_type_book_search
*
* @ingroup forms
*/
function mysite_type_book_search_form($uid) {
$form['add_book']['book_title'] = array('#type' => 'textfield',
'#title' => t('Book title'),
'#maxlength' => 64,
'#size' => 40,
'#description' => t('The name of the book you wish to add.'),
'#required' => TRUE,
'#autocomplete_path' => 'autocomplete/mysite/book'
);
$form['add_book']['uid'] = array('#type' => 'hidden', '#value' => $uid);
$form['add_book']['type'] = array('#type' => 'hidden', '#value' => 'book');
$form['add_book']['submit'] = array('#type' => 'submit', '#value' => t('Add Book'));
return $form;
}9b. Search submit handler.
Following the FormsAPI, define a function that process the submission of the content form.
Arguments: $form_id and $form_values, as defined by FormsAPI.
Returns: No return value. The results of the search are passed to MySite's search_handler. The $data array are the matches returned by the search.
Notice the use of LIKE and its rationale.
/**
* Implements mysite_type_hook_search_form_submit().
*
* @ingroup forms
*/
function mysite_type_book_search_form_submit($form_id, $form_values) {
// we use LIKE here in case JavaScript autocomplete support doesn't work.
// or in case the user doesn't autocomplete the form
$sql = db_rewrite_sql("SELECT n.nid, n.title FROM {node} n INNER JOIN {book} b ON b.nid = n.nid WHERE LOWER(n.title) LIKE LOWER('%s%%') AND b.parent = 0");
$result = db_query($sql, $form_values['book_title'], $book);
$count = 0;
while ($book = db_fetch_object($result)) {
$data[$count]['uid'] = $form_values['uid'];
$data[$count]['type'] = $form_values['type'];
$data[$count]['type_id'] = $book->nid;
$data[$count]['title'] = mysite_type_book_title($book->nid, $book->title);
$data[$count]['description'] = $book->title;
$count++;
}
// pass the $data to the universal handler
mysite_search_handler($data, 'book');
return;
}9c. AJAX Autocomplete for search
The autocomplete function follows the guidelines from the Drupal handbook. However, most of the heavy logic is handled by the MySite core module.
The only item that the function needs to perform is returning matches.
Arguments: $string = the text string that a user has searched for.
Returns: An array of $matches, where key == value.
/**
* Implements mysite_type_hook_autocomplete().
*
* @ingroup forms
*/
function mysite_type_book_autocomplete($string) {
$matches = array();
$sql = db_rewrite_sql("SELECT n.nid, n.title FROM {node} n INNER JOIN {book} b ON b.nid = n.nid WHERE LOWER(n.title) LIKE LOWER('%s%%') AND b.parent = 0");
$result = db_query_range($sql, $string, 0, 10);
while ($book = db_fetch_object($result)) {
$matches[$book->title] = check_plain($book->title);
}
return $matches;
}