r42754 MediaWiki - Code Review archive

Repository:MediaWiki
Revision:r42753‎ | r42754 | r42755 >
Date:23:17, 28 October 2008
Author:nad
Status:old (Comments)
Tags:
Comment:
New extension for handling records with HTML forms
Modified paths:
  • /trunk/extensions/RecordAdmin (added) (history)
  • /trunk/extensions/RecordAdmin/SpecialRecordAdmin.php (added) (history)

Diff [purge]

Index: trunk/extensions/RecordAdmin/SpecialRecordAdmin.php
@@ -0,0 +1,481 @@
 2+<?php
 3+/**
 4+ * Extension:RecordAdmin - MediaWiki extension
 5+ *{{Category:Extensions|RecordAdmin}}{{php}}{{Category:Extensions created with Template:SpecialPage}}
 6+ * @package MediaWiki
 7+ * @subpackage Extensions
 8+ * @author Aran Dunkley [http://www.organicdesign.co.nz/nad User:Nad]
 9+ * @licence GNU General Public Licence 2.0 or later
 10+ */
 11+
 12+if (!defined('MEDIAWIKI')) die('Not an entry point.');
 13+
 14+define('RECORDADMIN_VERSION','0.1.4, 2008-10-29');
 15+
 16+$wgRecordAdminCategory = 'Records'; # Category which contains the templates used as records and having corresponding forms
 17+$wgRecordAdminUseNamespaces = false; # Whether record articles should be in a namespace of the same name as their type
 18+
 19+$wgExtensionFunctions[] = 'wfSetupRecordAdmin';
 20+
 21+$wgExtensionCredits['specialpage'][] = array(
 22+ 'name' => 'Record administration',
 23+ 'author' => '[http://www.organicdesign.co.nz/nad User:Nad]',
 24+ 'description' => 'A special page for finding and editing record articles using a form',
 25+ 'url' => 'http://www.organicdesign.co.nz/Extension:SpecialExample',
 26+ 'version' => RECORDADMIN_VERSION
 27+);
 28+
 29+require_once "$IP/includes/SpecialPage.php";
 30+
 31+/**
 32+ * Define a new class based on the SpecialPage class
 33+ */
 34+class SpecialRecordAdmin extends SpecialPage {
 35+
 36+ var $form = '';
 37+ var $types = array();
 38+ var $guid = '';
 39+
 40+ function __construct() {
 41+
 42+ # Name to use for creating a new record either via RecordAdmin or a public form
 43+ # todo: should add a hook here for custom default-naming
 44+ $this->guid = strftime('%Y%m%d', time()).'-'.substr(strtoupper(uniqid()), -5);
 45+
 46+ SpecialPage::SpecialPage(
 47+ 'RecordAdmin', # name as seen in links etc
 48+ 'sysop', # user rights required
 49+ true, # listed in special:specialpages
 50+ false, # function called by execute() - defaults to wfSpecial{$name}
 51+ false, # file included by execute() - defaults to Special{$name}.php, only used if no function
 52+ false # includable
 53+ );
 54+ }
 55+
 56+ /**
 57+ * Override SpecialPage::execute()
 58+ */
 59+ function execute($param) {
 60+ global $wgOut, $wgRequest, $wgRecordAdminCategory, $wgRecordAdminUseNamespaces;
 61+ $this->setHeaders();
 62+ $type = $wgRequest->getText('wpType') or $type = $param;
 63+ $record = $wgRequest->getText('wpRecord');
 64+ $invert = $wgRequest->getText('wpInvert');
 65+ $title = Title::makeTitle(NS_SPECIAL, 'RecordAdmin');
 66+ $wpTitle = trim($wgRequest->getText('wpTitle'));
 67+
 68+ if ($type && $wgRecordAdminUseNamespaces) {
 69+ if ($wpTitle && !ereg("^$type:.+$", $wpTitle)) $wpTitle = "$type:$wpTitle";
 70+ }
 71+
 72+ $wgOut->addHTML("<div class='center'><a href='".$title->getLocalURL()."/$type'>New $type search</a> | "
 73+ . "<a href='".$title->getLocalURL()."'>Select another record type</a></div><br>\n"
 74+ );
 75+
 76+ # Get posted form values if any
 77+ $posted = array();
 78+ foreach ($_POST as $k => $v) if (ereg('^ra_(.+)$', $k, $m)) $posted[$m[1]] = $v;
 79+
 80+ # Read in and prepare the form for this record type if one has been selected
 81+ if ($type) $this->preProcessForm($type);
 82+
 83+ # Extract the input names and types used in the form
 84+ $this->examineForm();
 85+
 86+ # Clear any default values
 87+ $this->populateForm(array());
 88+
 89+ # If no type selected, render select list of record types from Category:Records
 90+ if (empty($type)) {
 91+ $wgOut->addWikiText("== Select the type of record to search for ==\n");
 92+
 93+ # Get titles in Category:Records and build option list
 94+ $options = '';
 95+ $dbr = &wfGetDB(DB_SLAVE);
 96+ $cl = $dbr->tableName('categorylinks');
 97+ $cat = $dbr->addQuotes($wgRecordAdminCategory);
 98+ $res = $dbr->select($cl, 'cl_from', "cl_to = $cat", __METHOD__, array('ORDER BY' => 'cl_sortkey'));
 99+ while ($row = $dbr->fetchRow($res)) $options .= '<option>'.Title::newFromID($row[0])->getText().'</option>';
 100+
 101+ # Render type-selecting form
 102+ $wgOut->addHTML(wfElement('form', array('action' => $title->getLocalURL('action=submit'), 'method' => 'post'), null)
 103+ . "<select name='wpType'>$options</select> "
 104+ . wfElement('input', array('type' => 'submit', 'value' => 'Submit'))
 105+ . '</form>'
 106+ );
 107+ }
 108+
 109+ # Record type known, but no record selected, render form for searching or creating
 110+ elseif (empty($record)) {
 111+ $wgOut->addWikiText("== Find or Create a \"$type\" record ==\n");
 112+
 113+ # Process Create submission
 114+ if (count($posted) && $wgRequest->getText('wpCreate')) {
 115+ if (empty($wpTitle)) {
 116+ $wpTitle = $this->guid;
 117+ if ($wgRecordAdminUseNamespaces) $wpTitle = "$type:$wpTitle";
 118+ }
 119+ $t = Title::newFromText($wpTitle);
 120+ if (is_object($t)) {
 121+ if ($t->exists()) $wgOut->addHTML("<div class='errorbox'>Sorry, \"$wpTitle\" already exists!</div>\n");
 122+ else {
 123+
 124+ # Attempt to create the article
 125+ $article = new Article($t);
 126+ $summary = "[[Special:RecordAdmin/$type|RecordAdmin]]: New $type created";
 127+ $text = '';
 128+ foreach ($posted as $k => $v) if ($v) {
 129+ if ($this->types[$k] == 'bool') $v = 'yes';
 130+ $text .= "| $k = $v\n";
 131+ }
 132+ $text = $text ? "{{"."$type\n$text}}" : "{{"."$type}}";
 133+ $success = $article->doEdit($text, $summary, EDIT_NEW);
 134+
 135+ # Report success or error
 136+ if ($success) $wgOut->addHTML("<div class='successbox'>\"$wpTitle\" created successfully</div>\n");
 137+ else $wgOut->addHTML("<div class='errorbox'>An error occurred while attempting to create the $type!</div>\n");
 138+ }
 139+ } else $wgOut->addHTML("<div class='errorbox'>Bad title!</div>\n");
 140+ $wgOut->addHTML("<br><br><br><br>\n");
 141+ }
 142+
 143+ # Populate the search form with any posted values
 144+ $this->populateForm($posted);
 145+
 146+ # Render the form
 147+ $wgOut->addHTML(
 148+ wfElement('form', array('class' => 'recordadmin', 'action' => $title->getLocalURL('action=submit'), 'method' => 'post'), null)
 149+ .'<b>Record ID:</b> '.wfElement('input', array('name' => 'wpTitle', 'size' => 30, 'value' => $wpTitle))
 150+ .'&nbsp;&nbsp;&nbsp;'.wfElement('input', array('name' => 'wpInvert', 'type' => 'checkbox')).' Invert selection'
 151+ ."\n<br><br><hr><br>\n{$this->form}"
 152+ .wfElement('input', array('type' => 'hidden', 'name' => 'wpType', 'value' => $type))
 153+ .'<br><hr><br><table width="100%"><tr>'
 154+ .'<td>'.wfElement('input', array('type' => 'submit', 'name' => 'wpFind', 'value' => "Search")).'</td>'
 155+ .'<td>'.wfElement('input', array('type' => 'submit', 'name' => 'wpCreate', 'value' => "Create")).'</td>'
 156+ .'<td width="100%" align="left">'.wfElement('input', array('type' => 'reset', 'value' => "Reset")).'</td>'
 157+ .'</tr></table></form>'
 158+ );
 159+
 160+ # Process Find submission
 161+ if (count($posted) && $wgRequest->getText('wpFind')) {
 162+ $wgOut->addWikiText("<br>\n== Search results ==\n");
 163+
 164+ # Select records which use the template and exhibit a matching title and other fields
 165+ $records = array();
 166+ $dbr = &wfGetDB(DB_SLAVE);
 167+ $tbl = $dbr->tableName('templatelinks');
 168+ $ty = $dbr->addQuotes($type);
 169+ $res = $dbr->select($tbl, 'tl_from', "tl_namespace = 10 AND tl_title = $ty", __METHOD__);
 170+ while ($row = $dbr->fetchRow($res)) {
 171+ $t = Title::newFromID($row[0]);
 172+ if (empty($wpTitle) || eregi($wpTitle, $t->getPrefixedText())) {
 173+ $a = new Article($t);
 174+ $text = $a->getContent();
 175+ $match = true;
 176+ $r = array($t);
 177+ foreach (array_keys($this->types) as $k) {
 178+ $v = isset($posted[$k]) ? ($this->types[$k] == 'bool' ? 'yes' : $posted[$k]) : '';
 179+ $i = preg_match("|\s*\|\s*$k\s*=\s*(.*?)\s*(?=[\|\}])|si", $text, $m);
 180+ if ($v && !($i && eregi($v, $m[1]))) $match = false;
 181+ $r[$k] = isset($m[1]) ? $m[1] : '';
 182+ }
 183+ if ($invert) $match = !$match;
 184+ if ($match) $records[$t->getPrefixedText()] = $r;
 185+ }
 186+ }
 187+ $dbr->freeResult($res);
 188+
 189+ # Render search results
 190+ if (count($records)) {
 191+
 192+ # Pass1, scan the records to find the create date of each and sort by that
 193+ $sorted = array();
 194+ foreach ($records as $k => $r) {
 195+ $t = $r[0];
 196+ $id = $t->getArticleID();
 197+ $r[1] = $k;
 198+ $tbl = $dbr->tableName('revision');
 199+ $row = $dbr->selectRow(
 200+ $tbl,
 201+ 'rev_timestamp',
 202+ "rev_page = $id",
 203+ __METHOD__,
 204+ array('ORDER BY' => 'rev_timestamp')
 205+ );
 206+ $sorted[$row->rev_timestamp] = $r;
 207+ }
 208+ krsort($sorted);
 209+
 210+ $table = "<table class='sortable recordadmin $type-record'>\n<tr>
 211+ <th class='col1'>$type<br></th><th class='col2'>Created<br></th>";
 212+ foreach (array_keys($this->types) as $k) $table .= "<th class='col$k'>$k<br></th>";
 213+ $table .= "</tr>\n";
 214+ $stripe = '';
 215+ foreach ($sorted as $ts => $r) {
 216+ $ts = preg_replace('|^..(..)(..)(..)(..)(..)..$|', '$3/$2/$1&nbsp;$4:$5', $ts);
 217+ $t = $r[0];
 218+ $k = $r[1];
 219+ $stripe = $stripe ? '' : ' class="stripe"';
 220+ $table .= "<tr$stripe><td class='col1'>(<a href='".$t->getLocalURL()."'>view</a>)";
 221+ $table .= "(<a href='".$title->getLocalURL("wpType=$type&wpRecord=$k")."'>edit</a>)</td>\n";
 222+ $table .= "<td class='col2'>$ts</td>\n";
 223+ $i = 0;
 224+ foreach (array_keys($this->types) as $k) {
 225+ $v = isset($r[$k]) ? $r[$k] : '&nbsp;';
 226+ $table .= "<td class='col$k'>$v</td>";
 227+ }
 228+ $table .= "</tr>\n";
 229+ }
 230+ $table .= "</table>\n";
 231+ $wgOut->addHTML($table);
 232+ } else $wgOut->addWikiText("''No matching records found!''\n");
 233+ }
 234+ }
 235+
 236+ # A specific record has been selected, render form for updating
 237+ else {
 238+ $wgOut->addWikiText("== Editing \"$record\" ==\n");
 239+ $article = new Article(Title::newFromText($record));
 240+ $text = $article->fetchContent();
 241+
 242+ # Update article if form posted
 243+ if (count($posted)) {
 244+
 245+ # Get the location and length of the record braces to replace
 246+ foreach ($this->examineBraces($text) as $brace) if ($brace['NAME'] == $type) $braces = $brace;
 247+
 248+ # Attempt to save the article
 249+ $summary = "[[Special:RecordAdmin/$type|RecordAdmin]]: $type properties updated";
 250+ $replace = '';
 251+ foreach ($posted as $k => $v) if ($v) {
 252+ if ($this->types[$k] == 'bool') $v = 'yes';
 253+ $replace .= "| $k = $v\n";
 254+ }
 255+ $replace = $replace ? "{{"."$type\n$replace}}" : "{{"."$type}}";
 256+ $text = substr_replace($text, $replace, $braces['OFFSET'], $braces['LENGTH']);
 257+ $success = $article->doEdit($text, $summary, EDIT_UPDATE);
 258+
 259+ # Report success or error
 260+ if ($success) $wgOut->addHTML("<div class='successbox'>$type updated successfully</div>\n");
 261+ else $wgOut->addHTML("<div class='errorbox'>An error occurred during update!</div>\n");
 262+ $wgOut->addHTML("<br><br><br><br>\n");
 263+ }
 264+
 265+ # Populate the form with the current values in the article
 266+ foreach ($this->examineBraces($text) as $brace) if ($brace['NAME'] == $type) $braces = $brace;
 267+ $this->populateForm(substr($text, $braces['OFFSET'], $braces['LENGTH']));
 268+
 269+ # Render the form
 270+ $wgOut->addHTML(wfElement('form', array('class' => 'recordadmin', 'action' => $title->getLocalURL('action=submit'), 'method' => 'post'), null));
 271+ $wgOut->addHTML($this->form);
 272+ $wgOut->addHTML(wfElement('input', array('type' => 'hidden', 'name' => 'wpType', 'value' => $type)));
 273+ $wgOut->addHTML(wfElement('input', array('type' => 'hidden', 'name' => 'wpRecord', 'value' => $record)));
 274+ $wgOut->addHTML('<br><hr><br><table width="100%"><tr>'
 275+ .'<td>'.wfElement('input', array('type' => 'submit', 'value' => "Save")).'</td>'
 276+ .'<td width="100%" align="left">'.wfElement('input', array('type' => 'reset', 'value' => "Reset")).'</td>'
 277+ .'</tr></table></form>'
 278+ );
 279+ }
 280+ }
 281+
 282+ /**
 283+ * Read in and prepare the form (for use as a search filter) for passed record type
 284+ * - we're using the record's own form as a filter for searching for records
 285+ * - extract only the content from between the form tags and remove any submit inputs
 286+ */
 287+ function preProcessForm($type) {
 288+ $title = Title::newFromText($type, NS_FORM);
 289+ if ($title->exists()) {
 290+ $form = new Article($title);
 291+ $form = $form->getContent();
 292+ $form = preg_replace('#<input.+?type=[\'"]?submit["\']?.+?/(input| *)>#', '', $form); # remove submits
 293+ $form = preg_replace('#^.+?<form.+?>#s', '', $form); # remove up to and including form open
 294+ $form = preg_replace('#</form>.+?$#s', '', $form); # remove form close and everything after
 295+ $form = preg_replace('#name\s*=\s*([\'"])(.*?)\\1#s', 'name="ra_$2"', $form); # prefix input names with ra_
 296+ $form = preg_replace('#(<select.+?>)\s*(?!<option/>)#s', '$1<option selected/>', $form); # ensure all select lists have default blank
 297+ }
 298+
 299+ # Create a red link to the form if it doesn't exist
 300+ else {
 301+ $form = "<b>There is no form associated with \"$type\" records!</b>"
 302+ ."<br><br>click <a href=\"".$title->getLocalURL('action=edit')."\">here</a> to create one</div>";
 303+ }
 304+ $this->form = $form;
 305+ }
 306+
 307+
 308+ /**
 309+ * Populates the form values from the passed values
 310+ * - $form is HTML text
 311+ * - $values may be a hash or wikitext template syntax
 312+ */
 313+ function populateForm($values) {
 314+
 315+ # If values are wikitext, convert to hash
 316+ if (!is_array($values)) {
 317+ $text = $values;
 318+ $values = array();
 319+ preg_match_all("|\|\s*(.+?)\s*=\s*(.*?)\s*(?=[\|\}])|s", $text, $m);
 320+ foreach ($m[1] as $i => $k) $values[$k] = $m[2][$i];
 321+ }
 322+
 323+ # Add the values into the form's HTML depending on their type
 324+ foreach($this->types as $k => $type) {
 325+
 326+ # Get this input element's html text and position and length
 327+ preg_match("|<([a-zA-Z]+)[^<]+?name=\"ra_$k\".*?>(.*?</\\1>)?|s", $this->form, $m, PREG_OFFSET_CAPTURE);
 328+ list($html, $pos) = $m[0];
 329+ $len = strlen($html);
 330+
 331+ # Modify the element according to its type
 332+ # - clears default value, then adds new value
 333+ $v = isset($values[$k]) ? $values[$k] : '';
 334+ switch ($type) {
 335+ case 'text':
 336+ $html = preg_replace("|value\s*=\s*\".*?\"|", "", $html);
 337+ if ($v) $html = preg_replace("|(/?>)$|", " value=\"$v\" $1", $html);
 338+ break;
 339+ case 'bool':
 340+ $html = preg_replace("|checked|", "", $html);
 341+ if ($v) $html = preg_replace("|(/?>)$|", " checked $1", $html);
 342+ break;
 343+ case 'list':
 344+ $html = preg_replace("|(<option[^<>]*) selected|", "$1", $html);
 345+ if ($v) $html = preg_replace("|(?<=<option)(?=>$v</option>)|s", " selected", $html);
 346+ break;
 347+ case 'blob':
 348+ $html = preg_replace("|>.*?(?=</textarea>)|s", ">$v", $html);
 349+ break;
 350+ }
 351+
 352+ # Replace the element in the form with the modified html
 353+ $this->form = substr_replace($this->form, $html, $pos, $len);
 354+ }
 355+ }
 356+
 357+ /**
 358+ * Returns an array of types used by the passed HTML text form
 359+ * - supported types, text, select, checkbox, textarea
 360+ */
 361+ function examineForm() {
 362+ $this->types = array();
 363+ preg_match_all("|<([a-zA-Z]+)[^<]+?name=\"ra_(.+?)\".*?>|", $this->form, $m);
 364+ foreach ($m[2] as $i => $k) {
 365+ $tag = $m[1][$i];
 366+ $type = preg_match("|type\s*=\s*\"(.+?)\"|", $m[0][$i], $n) ? $n[1] : '';
 367+ switch ($tag) {
 368+ case 'input':
 369+ switch ($type) {
 370+ case 'checkbox':
 371+ $this->types[$k] = 'bool';
 372+ break;
 373+ default:
 374+ $this->types[$k] = 'text';
 375+ break;
 376+ }
 377+ break;
 378+ case 'select':
 379+ $this->types[$k] = 'list';
 380+ break;
 381+ case 'textarea':
 382+ $this->types[$k] = 'blob';
 383+ break;
 384+ }
 385+ }
 386+ }
 387+
 388+ /**
 389+ * Return array of braces used and the name, position, length and depth
 390+ * See http://www.organicdesign.co.nz/MediaWiki_code_snippets
 391+ */
 392+ function examineBraces(&$content) {
 393+ $braces = array();
 394+ $depths = array();
 395+ $depth = 1;
 396+ $index = 0;
 397+ while (preg_match('/\\{\\{\\s*([#a-z0-9_]*)|\\}\\}/is', $content, $match, PREG_OFFSET_CAPTURE, $index)) {
 398+ $index = $match[0][1]+2;
 399+ if ($match[0][0] == '}}') {
 400+ $brace =& $braces[$depths[$depth-1]];
 401+ $brace['LENGTH'] = $match[0][1]-$brace['OFFSET']+2;
 402+ $brace['DEPTH'] = $depth--;
 403+ }
 404+ else {
 405+ $depths[$depth++] = count($braces);
 406+ $braces[] = array(
 407+ 'NAME' => $match[1][0],
 408+ 'OFFSET' => $match[0][1]
 409+ );
 410+ }
 411+ }
 412+ return $braces;
 413+ }
 414+
 415+ /**
 416+ * A callback for processing public forms
 417+ */
 418+ function createRecord() {
 419+ global $wgRequest, $wgRecordAdminUseNamespaces;
 420+ $type = $wgRequest->getText('wpType');
 421+ $title = $wgRequest->getText('wpTitle');
 422+
 423+ # Get types in this kind of record from form
 424+ $this->preProcessForm($type);
 425+ $this->examineForm();
 426+
 427+ # Use guid if no title specified
 428+ if (empty($title)) {
 429+ $title = $this->guid;
 430+ if ($wgRecordAdminUseNamespaces) $title = "$type:$title";
 431+ }
 432+
 433+ # Attempt to create the article
 434+ $title = Title::newFromText($title);
 435+ if (is_object($title) && !$title->exists()) {
 436+ $article = new Article($title);
 437+ $summary = "New $type created from public form";
 438+ $text = '';
 439+ foreach ($_POST as $k => $v) if ($v && isset($this->types[$k])) {
 440+ if ($this->types[$k] == 'bool') $v = 'yes';
 441+ $text .= "| $k = $v\n";
 442+ }
 443+ $text = $text ? "{{"."$type\n$text}}" : "{{"."$type}}";
 444+ $success = $article->doEdit($text, $summary, EDIT_NEW);
 445+ }
 446+ }
 447+
 448+ # If a record was created by a public form, make last 5 digits of ID available via a tag
 449+ function expandTag($text, $argv, &$parser) {
 450+ $parser->mOutput->mCacheTime = -1;
 451+ return $this->guid ? substr($this->guid, -5) : '';
 452+ }
 453+
 454+}
 455+
 456+/**
 457+ * Called from $wgExtensionFunctions array when initialising extensions
 458+ */
 459+function wfSetupRecordAdmin() {
 460+ global $wgSpecialRecordAdmin, $wgParser, $wgLanguageCode, $wgMessageCache, $wgRequest;
 461+
 462+ # Add the messages used by the specialpage
 463+ if ($wgLanguageCode == 'en') {
 464+ $wgMessageCache->addMessages(array(
 465+ 'recordadmin' => 'Record administration'
 466+ ));
 467+ }
 468+
 469+ # Make a global singleton so methods are accessible as callbacks etc
 470+ $wgSpecialRecordAdmin = new SpecialRecordAdmin();
 471+
 472+ # Make recordID's of articles created with public forms available via recordid tag
 473+ $wgParser->setHook('recordid', array($wgSpecialRecordAdmin, 'expandTag'));
 474+
 475+ # Check if posting a public creation form
 476+ $title = Title::newFromText($wgRequest->getText('title'));
 477+ if (is_object($title) && $title->getNamespace() != NS_SPECIAL && $wgRequest->getText('wpType') && $wgRequest->getText('wpCreate'))
 478+ $wgSpecialRecordAdmin->createRecord();
 479+
 480+ # Add the specialpage to the environment
 481+ SpecialPage::addPage($wgSpecialRecordAdmin);
 482+}

Comments

#Comment by Siebrand (talk | contribs)   12:40, 29 October 2008

Compared to other recent extension code, this extension is really bad from an i18n perspective. All messages are hard coded. I'd hate to see this used as an example of the code MediaWiki allows in its repo.

#Comment by Bryan (talk | contribs)   18:05, 29 October 2008

A PHP 5 constructor that calls the parent in a PHP 4 way?

#Comment by Grondin (talk | contribs)   00:12, 31 October 2008

I'll do an i18n file soon.

Status & tagging log