/item/yyyy/mm/title * /item/id => /item/title (optional) * /member/id => /member/name * /category/id/blogid/id => /category/name/blogid/number * /blog/id => /blog/name * * NP_FancierURL2 requirements: * - an account with an activated mod_rewrite Module * - Nucleus version 3.22 or higher * - a .htaccess file in the root of the Nucleus installtion: * * * RewriteEngine on * RewriteCond %{REQUEST_FILENAME} !-f * RewriteCond %{REQUEST_FILENAME} !-d * RewriteRule ^(.*)$ index.php?virtualpath=$1 [L] * * * History: * * NP_FancierURL2 is derived from NP_SeoURL v0.1 which is a * plugin under development for the Nucleus 3.3 version. * NP_SeoURL is based on NP_ReplaceURL from Wouter Demuynck. * * v0.1 2006-11-08 Kai Greve (http://kgblog.de): * - initial release * * v0.2 2007-01-07 Kai Greve (http://kgblog.de): * - table plug_urls is also included in the database backup * - categories are also in the look up table to support * characters from other languages * - option added: whether the date is included in item urls or not * * v0.3 2007-06-13 Kai Greve (http://kgblog.de): * - compatibility mode added which support old style fancy urls: * item/1234, member/1234, category/1234 and blog/1234 * - use the global variable $virtualpath for Nucleus version 3.3 * and higher * * v0.3eh 2007-06-18 Edmond Hui (http://edmondhui.homeip.net/blog) * - add utf-8/Chinese (un)friendly url fallback * - fix category generateURL handling * - subscribe to delete event to cleanup mapping table on item and category delete * * v0.4 2007-12-02 Kai Greve (http://kgblog.de): * - change the way in which encoding is interpreted and add a new option * for the encoding to avoid problems if mb_detect_encoding fails * - Overwerite the clearOptionValueCache function of the parent class * (NucleusPlugin) because it has a bug: doesn't set the variable * plugin_options to zero (needed to refresh the url table after * the PostPluginOptionsUpdate event) * - Repair the code that counts the entries with the same title * that are published before * * Todos: * - Category links in a multiblog environment don't be generated * proper in the Template (but they are right in the sidebar) * * Notes: * - mname (member name) and bshortname (blog short name) only allows the * characters a-z and 0-9 without spaces => they are url friendly * */ class NP_FancierURL2 extends NucleusPlugin { // SQL table name var $table; // used between Pre/PostAddItem var $tmp_title; var $tmp_timestamp; // used between Pre/PostPluginOptionsUpdate var $withdate_changed; var $encoding_changed; function getName() { return 'NP_FancierURL2'; } function getAuthor() { return 'Wouter Demuynck | Edmond Hui | Kai Greve'; } function getURL() { return 'http://plugins.nucleuscms.org/'; } function getVersion() { return '0.4eh'; } function getMinNucleusVersion() { return 322; } function getMinNucleusPatchLevel() { return 0; } function getDescription() { return 'Replaces the /item/1234 URLs by /item/yyyy/mm/urlified_title or /item/urlified_title, /member/1234 by /member/name, /category/1234 by /category/name and /blog/1234 by /blog/name.'; } function supportsFeature($what) { switch ($what) { case 'SqlTablePrefix': return 1; default: return 0; } } function getEventList() { return array('ParseURL', 'GenerateURL', 'PreAddItem', 'PostAddItem', 'PreUpdateItem', 'PostUpdateItem', 'PostAddCategory', 'PostPluginOptionsUpdate', 'PrePluginOptionsUpdate', 'PostDeleteItem', 'PostDeleteCategory'); } function init() { $this->table = sql_table('plug_urls'); } /** * Returns an array of tables to be additionally included * in the backup process */ function getTableList(){ return array($this->table); } /** * When installing the plugin, create a table for mapping the rewritten URLs * of items and fill it with initial data */ function install() { // create options $this->createOption('withdate','Include the date in URLs for items (/item/yyyy/mm/title) or not (/item/title)','yesno','yes'); $this->createOption('oldstyle','Compatibility mode: support also old style fancy urls (e.g. item/1234)','yesno','no'); $this->createOption ('encoding','Choose encoding automatic (AUTO) or manual (ISO-8859-1, UTF-8 or ASCII):', 'select', 'AUTO', 'AUTO|AUTO|ISO-8859-1|ISO-8859-1|UTF-8|UTF-8|ASCII|ASCII'); // create initial mapping $this->_createMapping(); } /** * When uninstalling the plugin, remove the table with the URL mappings from * the database */ function uninstall() { $this->_deleteMapping(); } function _createMapping() { // create mapping table sql_query( 'CREATE TABLE ' . $this->table . ' (' . ' type char(1) NOT NULL,' . ' id int(11) NOT NULL,' . ' urlpart varchar(168) NOT NULL,' . ' PRIMARY KEY (type, id)' . ' );' ); // create mappings for all existing items $r = sql_query('SELECT ititle as title, UNIX_TIMESTAMP(itime) as timestamp, inumber as itemid FROM ' . sql_table('item')); while ($o = mysql_fetch_object($r)) { $this->_addToMapping($o->title, $o->timestamp, $o->itemid); } // create mappings for all existing categories $r = sql_query('SELECT cname, catid FROM ' . sql_table('category')); while ($o = mysql_fetch_object($r)) { $this->_addCategoryToMapping($o->cname, $o->catid); } } function _deleteMapping() { sql_query('DROP TABLE ' . $this->table); } /** * Fetch the title and the timestamp of an item before it is lost during * the saving procedure */ function event_PreAddItem(&$data) { $this->tmp_title = $data['title']; $this->tmp_timestamp = $this->_dateToUnix($data['timestamp']); } /** * Add the new item to the mapping table */ function event_PostAddItem(&$data) { $this->_addToMapping($this->tmp_title, $this->tmp_timestamp, $data['itemid']); } /** * Fetch the title of an item before it is lost during the updating * procedure */ function event_PreUpdateItem(&$data) { $this->tmp_title = $data['title']; } /** * Add the updated item to the mapping table */ function event_PostUpdateItem(&$data) { $timestamp = quickQuery('SELECT UNIX_TIMESTAMP(itime) as result FROM ' . sql_table('item') . ' WHERE inumber=' . intval($data['itemid'])); $this->_updateMapping($this->tmp_title, $this->_buildUrlPart($this->tmp_title, $timestamp), $data['itemid']); } /** * Add a new category to the mapping table */ function event_PostAddCategory(&$data) { $catid=$data['catid']; $cname = $data['name']; if ($cname=='') { $q = "SELECT cname as result FROM ". sql_table ('category') . " WHERE catid ='" . $catid . "'"; $cname = quickQuery ($q); } $this->_addCategoryToMapping($cname, $catid); } /** * Add a category name to the mapping table */ function _addCategoryToMapping($cname, $catid) { $catid = intval($catid); // Build the URL part for the categroy name $urlpart=$this->_makeUrlFriendly($cname); if (strstr($urlpart, "unfriendly_utf8item") != false) { $urlpart = "category".$catid; } // Insert the URL part into the mapping table $q = 'INSERT INTO ' . $this->table . ' (type, id, urlpart) values (\'c\', \'' . $catid . '\', \'' . addslashes($urlpart) . '\')'; sql_query($q); } /** * Save information if option withdate or encoding was changed * for the PostPluginOptionsUpdate event */ function event_PrePluginOptionsUpdate(&$data) { // evaluate if option withdate was changed if ($data['optionname']=='withdate') { $this->withdate_changed = $this->getOption('withdate')!=$data['value']; } // evaluate if option encoding was changed if ($data['optionname']=='encoding') { $this->encoding_changed = $this->getOption('encoding')!=$data['value']; } } /* * Event PostPluginOptionsUpdate is used for: * - Updating the category name in the mapping table after a change * - Rebuild the mapping table if the 'withdate' option is changed */ function event_PostPluginOptionsUpdate (&$data) { // Work around for missing PostUpdateCategory event if ($data['context']== 'category'){ $q = "SELECT cname as result FROM ". sql_table ('category') . " WHERE catid ='" . $data['catid'] . "'"; $cname = quickQuery ($q); $this->_updateCategoryMapping ($this->_makeUrlFriendly($cname), $data['catid']); } // Rebuild the mapping table if the option 'withdate' (for the date // in item urls) or 'encoding' was changed if ($this->withdate_changed || $this->encoding_changed){ $this->_rebuildMapping(); } } /** * Rebuild Mapping: delete the old and create a new one */ function _rebuildMapping () { // clear the option cache in the NucleusPlugin class $this->clearOptionValueCache(); // rebuild the mapping $this->_deleteMapping(); $this->_createMapping(); } /** * Overwerite original function in the parent class (NucleusPlugin) * because it has a bug: it doesn't set the variable plugin_options * to zero which is used in the getOption function */ function clearOptionValueCache() { $this->_aOptionValues = array(); $this->plugin_options = 0; } /** * Update an urlpart of a category in the mapping table */ function _updateCategoryMapping($urlpart, $catid) { if (strstr($urlpart, "unfriendly_utf8item") != false) { $urlpart = "category".$catid; } $q = 'UPDATE ' . $this->table . ' SET urlpart=\'' . addslashes($urlpart) . '\' WHERE type=\'c\' and id=\'' . $catid . '\''; sql_query($q); } /** * Adds a given itemid/urlpart pair to the mapping table */ function _addToMapping($title, $timestamp, $itemid) { $itemid = intval($itemid); // Count entries with the same title that are published before $q = "SELECT count(inumber) as result FROM ". sql_table ('item') . " WHERE ititle ='" . addslashes($title) . "' AND UNIX_TIMESTAMP(itime) < " . $timestamp." AND inumber !='" . $itemid . "' AND idraft=0"; $number = intval(quickQuery ($q)); // Build the URL part for the item $urlpart=$this->_buildUrlPart($title,$timestamp); // If urlpart contains "unfriendly_utf8item", it means the title is unfriendly, we will use itemXXX if (strstr($urlpart, "unfriendly_utf8item") != false) { $urlpart = str_replace("unfriendly_utf8item", "item".$itemid, $urlpart); } else { // Note: we only add -X if we know the title is friendly and duplicated. // Append the number for the item title to the URL part if ($number>0) { $number++; $urlpart .= '-' . $number; } } // Insert the URL part into the mapping table $q = 'INSERT INTO ' . $this->table . ' (type, id, urlpart) values (\'i\', \'' . $itemid . '\', \'' . addslashes($urlpart) . '\')'; sql_query($q); } /** * Updates a urlpart of an itemid in the mapping table */ function _updateMapping($title, $urlpart, $itemid) { $itemid = intval($itemid); // Count entries with the same title that are published before $q = "SELECT count(inumber) as result FROM ". sql_table ('item') . " WHERE ititle ='" . addslashes($title) . "' AND inumber != " . $itemid . " AND idraft=0"; $number = intval(quickQuery ($q)); // If urlpart contains "unfriendly_utf8item", it means the title is unfriendly, we will use itemXXX if (strstr($urlpart, "unfriendly_utf8item") != false) { $urlpart = str_replace("unfriendly_utf8item", "item".$itemid, $urlpart); } else { // Note: we only add -X if we know the title is friendly and duplicated. // Append the number for the item title to the URL part if ($number>0) { $number++; $urlpart .= '-' . $number; } } $q = 'UPDATE ' . $this->table . ' SET urlpart=\'' . addslashes($urlpart) . '\' WHERE id=\'' . $itemid . '\''; sql_query($q); } /** * Parse URLs: * /item/yyyy/mm/title => $itemid * /member/name => $memberid * /category/name/blogid/number => $catid and $blogid * /blog/name => $blogid */ function event_ParseURL(&$data) { global $itemid, $memberid, $catid, $blogid, $CONF, $archivelist, $archive; // nothing to do if another plugin already parsed the URL if ($data['complete']) return; // get the requested URL if (getNucleusVersion()>=330) { // use global variable from Nucleus global $virtualpath; $parts = explode("/", addslashes($virtualpath)); } else { // resolve the requested URL directly from the GET variable $parts = explode("/", addslashes(requestVar('virtualpath'))); } if (count($parts) < 1) return; switch ($parts[0]) { case $CONF['ItemKey']: // parts: item/YYYY/MM/title = 0/1/2/3 // support old style fancy urls: item/1234 if ($this->getOption('oldstyle')=="yes") { $urlstring=$parts[1].$parts[2].$parts[3]; if (is_numeric($urlstring)) { $itemid = (int)$urlstring; $data['complete'] = true; break; } } // resolve urls: item/YYYY/MM/title and item/title $itemid = $this->_findItem($parts[1], $parts[2], $parts[3]); if ($itemid != 0) { $data['complete'] = true; } break; case $CONF['MemberKey']: // parts: member/name = 0/1 // support old style fancy urls: member/1234 if ($this->getOption('oldstyle')=="yes") { $urlstring=$parts[1].$parts[2].$parts[3]; if (is_numeric($urlstring)) { $memberid = (int)$urlstring; $data['complete'] = true; break; } } // resolve urls: member/name $memberid = intval($this->_findMemberID($parts[1])); if ($memberid != 0) { $data['complete'] = true; } break; case $CONF['CategoryKey']: // parts: category/name/blogid/number = 0/1/2/3 // support old style fancy urls: category/1234 if ($this->getOption('oldstyle')=="yes") { $urlstring=$parts[1]; if (is_numeric($urlstring)) { $catid = (int)$urlstring; $data['complete'] = true; break; } } // resolve urls: category/name/blogid/number if ($parts[2] == 'blogid'){ $blogid = intval($parts[3]); } else { $blogid = $CONF['DefaultBlog']; } $catid = intval($this->_findCategoryID($parts[1],$blogid)); if ($catid != 0) { $data['complete'] = true; } break; case $CONF['BlogKey']: // parts: blog/name = 0/1 // support old style fancy urls: blog/1234 if ($this->getOption('oldstyle')=="yes") { $urlstring=$parts[1]; if (is_numeric($urlstring)) { $blogid = (int)$urlstring; $data['complete'] = true; break; } } // resolve urls: blog/name $blogid = intval($this->_findBlogID($parts[1])); if ($blogid != 0) { $data['complete'] = true; } break; // added because the default implementation of Nucleus // only works if PATH_INFO is enabled on the server case $CONF['ArchivesKey']: // archives/1 (blogid) $archivelist = intval($parts[1]); break; // added because the default implementation of Nucleus // only works if PATH_INFO is enabled on the server case $CONF['ArchiveKey']: // two possibilities: archive/yyyy-mm or archive/1/yyyy-mm (with blogid) if (isset($parts[1])&&isset($parts[2])&&(!strstr($parts[1],'-'))) { $blogid = intval($parts[1]); $archive = $parts[2]; } else { $archive = $parts[1]; } break; default: $data['complete'] = false; } } /** * Generate URLs: * /item/yyyy/mm/title * ... */ function event_GenerateURL(&$data) { // if another plugin already generated the URL if ($data['completed']) return; global $CONF; $params = $data['params']; switch ($data['type']) { case 'item': $itemid = $params['itemid']; $query = "SELECT urlpart as result FROM " . sql_table('plug_urls') . " WHERE type='i' AND id='" . $itemid . "'"; $urlpart = quickQuery ($query); $baseurl = $CONF['Self'] . '/' . $CONF['ItemKey'] . '/' . $urlpart; $data['url'] = addLinkParams($baseurl, $params['extra']); $data['completed'] = true; break; case 'member': $memberid = $params['memberid']; $baseurl = $CONF['Self'] . '/' . $CONF['MemberKey'] . '/' . $this->_findMemberName($memberid); $data['url'] = addLinkParams($baseurl, $params['extra']); $data['completed'] = true; break; case 'category': $catid = $params['catid']; $query = "SELECT urlpart as result FROM " . sql_table('plug_urls') . " WHERE type='c' AND id='" . $catid . "'"; $urlpart = quickQuery ($query); $baseurl = $CONF['Self'] . '/' . $CONF['CategoryKey'] . '/' . $urlpart; // $params['extra'] (e.g. /blog/1) is not always initialized if (isset($params['extra'])){ $data['url'] = addLinkParams($baseurl, $params['extra']); } else { $data['url'] = $baseurl; } $data['completed'] = true; break; case 'blog': $blogid = $params['blogid']; $baseurl = $CONF['Self'] . '/' . $CONF['BlogKey'] . '/' . $this->_findBlogName($blogid); $data['url'] = addLinkParams($baseurl, $params['extra']); $data['completed'] = true; break; default: $data['completed'] = false; break; } } function event_PostDeleteItem($data) { sql_query("DELETE FROM ".sql_table('plug_urls')." WHERE type='i' AND id= '".$data['itemid']."'"); } function event_PostDeleteCategory($data) { sql_query("DELETE FROM ".sql_table('plug_urls')." WHERE type='c' AND id= '".$data['catid']."'"); } /** * Creates an URL part out of a title and a timestamp. * The resulting url part looks like "2005/07/item_title_here" */ function _buildUrlPart($title, $timestamp) { $ts = getdate($timestamp); if ($ts['mon']<10) { $lead = '0'; } else { $lead = ''; } if ($this->getOption('withdate')=='no') { $urlpart = $this->_makeUrlFriendly($title); } else { // used as default solution $urlpart = $ts['year'] . '/' . $lead . $ts['mon'] . '/' . $this->_makeUrlFriendly($title); } return $urlpart; } /** * Finds the item ID corresponding to a given URL part */ function _findItem($year, $month, $title) { if ($month=='' || $month==$CONF['CategoryKey'] || $month=='catid'){ $urlstring = $year; } else { $urlstring = $year . '/' . $month . '/' . $title; } // Find URL part in mapping table $query = "SELECT id as result FROM ". sql_table('plug_urls') ." WHERE type='i' AND urlpart='" . $urlstring . "'"; $res= quickQuery ($query); return intval($res); } /** * Finds the member ID corresponding to a given member name */ function _findMemberID($name) { $q = "SELECT mnumber as result FROM " . sql_table('member') . " WHERE mname='" . $name . "'"; return quickQuery($q); } /** * Finds the member name corresponding to a given member id * (to generate the member URL) */ function _findMemberName($id) { $q = 'SELECT mname as result FROM ' . sql_table('member') . ' WHERE mnumber=\'' . $id . '\' LIMIT 1'; return quickQuery($q); } /** * Finds the category ID corresponding to a given category name and blog id */ function _findCategoryID($name, $blog) { $query = "SELECT id as result FROM ". sql_table('plug_urls') ." WHERE type='c' AND urlpart='" . $name . "'"; $res= quickQuery ($query); return intval($res); } /** * Finds the blog ID corresponding to a given blog short name */ function _findBlogID($name) { $q = "SELECT bnumber as result FROM " . sql_table('blog') . " WHERE bshortname='" . $name . "'"; return quickQuery($q); } /** * Finds the blog short name corresponding to a given blog id */ function _findBlogName($id) { $q = "SELECT bshortname as result FROM " . sql_table('blog') . " WHERE bnumber='" . $id . "'"; return quickQuery($q); } /** * Generates an URL-friendly title. */ function _makeUrlFriendly($title) { $title = strtolower(trim($title)); $title = str_replace(' ', '-', $title); $title = str_replace("'", '-', $title); // use default encoding from option encoding // or try to evaluate character encoding $encoding = ''; $encoding_trim = trim($this->getOption ('encoding')); if ($encoding_trim!='') { $encoding = $encoding_trim; } else { if (function_exists('mb_detect_encoding')) { $encoding = mb_detect_encoding($title); } } // translate letters switch ($encoding) { case "ISO-8859-1": // What is the encoding for German?? $title = str_replace('ä', 'ae', $title); $title = str_replace('ö', 'oe', $title); $title = str_replace('ü', 'ue', $title); $title = str_replace('ß', 'sz', $title); break; case "UTF-8": break; case "ASCII": break; default: } // remove untranslated letters: // only letters, numbers and underscores are allowed $title = preg_replace("/[^a-z0-9_-]/", "", $title); // report the title is unfriendly if we failed to convert it if ($title == '') { $title = 'unfriendly_utf8item'; } return $title; } /** * Returns the item title and timestamp for a given item id. Only non-draft * and present items are considered, unless the logged in member is an admin * or author of the item. */ function _getItemDetails($itemid) { $r = sql_query('SELECT ititle, UNIX_TIMESTAMP(itime) as timestamp FROM ' . sql_table('item') . ' WHERE idraft=0 and inumber=' . intval($itemid)); $o = mysql_fetch_object($r); if ($o) return array('title' => $o->ititle, 'timestamp' => $o->timestamp); else return array('title' => '', 'timestamp' => 0); } /** * Converts a date which is formatted as 'Y-m-d H:i:s' into a unix timestamp */ function _dateToUnix($strDate) { list($date, $time) = explode(' ', $strDate); list($y, $m, $d) = explode('-', $date); list($h, $min, $s) = explode(':', $time); return mktime($h,$min,$s,$m,$d,$y); } /** * Output of the resolved item URL for the feed templates */ function doTemplateVar(&$item) { $query = "SELECT urlpart as result FROM " . sql_table('plug_urls') . " WHERE type='i' and id='" . $item->itemid . "'"; $urlpart = quickQuery ($query); echo "item/".$urlpart; } } ?>