sync with CVS
authorDominique Hazaël-Massieux <dom@w3.org>
Fri, 26 Nov 2010 15:06:40 +0100
changeset 0 8f567f29340d
child 1 69000a8e604d
sync with CVS
src/actions.php
src/agenda.php
src/changelog.php
src/config.php
src/index.php
src/issues.php
src/noedit.html
src/objects.phi
src/options.php
src/product.php
src/tag.php
src/trackerlib.phi
src/user.php
src/users.php
trackbot/README
trackbot/ircbot.py
trackbot/irclib.py
trackbot/trackbot-commands.txt
trackbot/trackbot-ng
trackbot/xmltramp.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/actions.php	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,164 @@
+<?php
+/**
+ * HTTP interface to Tracker - list of actions
+ * @package Tracker
+ */
+
+ require_once("objects.phi");
+ require_once("trackerlib.phi");
+ lastModificationTime(filemtime(__FILE__));
+
+// WGid parameter set in URI
+ $wgid = $_GET["wgid"];
+ $wg = new TrackerWorkingGroup($wgid);
+ if (!$wg->loadConfig()) {
+ 	header("404 Not found");
+ 	header("Content-Type: text/plain");
+ 	echo "No config found for group $wgid";
+ 	exit();
+ }
+ $u = false;
+ // Checking access control
+ if ($wg->acls!='public') {
+ 	$logonid = checkCredentials();
+ 	$u = new TrackerUser(0,$logonid);
+ 	$_SERVER["userid"] = $u->id;
+ 	if ($wg->acls=='team' && !$u->isMemberOf(102) &&  !$u->isMemberOf($wg->id)) {
+ 		WriteErrorpage("Unauthorized","These pages are restricted to the W3C Staff.","",403);
+ 		exit();
+ 	} else if ($wg->acls=='member' && !$u->isMemberOf(105) &&  !$u->isMemberOf($wg->id)) {
+ 		WriteErrorpage("Unauthorized","These pages are restricted to W3C Members.","",403);
+ 		exit();
+ 	}
+ }
+
+ $changes = "";
+ if ($_SERVER["REQUEST_METHOD"]=="POST" && array_key_exists("actions",$_POST)
+ // checking at least one action was selected
+ && is_array($_POST['actions']) && count($_POST['actions'])
+ // Checking at least one operation was requested
+ && ($_POST["status"]!=-1 || $_POST["due"]!='No change' || $_POST["product"]!=-1)) {
+ 		// Checking credentials
+ 		// Only WG participants are allowed
+ 	if (!$u) {
+ 		$logonid = checkCredentials();
+ 		$u = new TrackerUser(0,$logonid);
+ 		$_SERVER["userid"] = $u->id;
+ 	}
+ 	if (!$u->isMemberOf($wgid)) {
+ 		WriteErrorpage("Unauthorized","Editing actions is reserved to the participants
+ 		in the ".htmlify($wg->name).".","",403);
+ 		exit();
+ 	}
+ 	$newstatus = false;
+ 	$newduedate = false;
+ 	$newproductid = false;
+ 	$changelog = array();
+ 	$editedactions = array();
+ 	if (intval($_POST["status"])!=-1) {
+ 		$newstatus = intval($_POST["status"]);
+ 		$changelog[]="have their status now set to ".$humanReadableState[$newstatus];
+ 		 
+ 	}
+ 	if ($_POST["due"]!='No change') {
+ 		$newduedate = strtotime($_POST["due"]);
+ 		$changelog[]="have their due date set to ".gmdate("F, j Y",$newduedate);
+ 	}
+ 	if (intval($_POST["product"])!=-1) {
+ 		$newproductid =$_POST["product"];
+ 		$p = new Product();
+ 		if (!$p->load($newproductid,$wg->id)) {
+ 			$newproductid = false;
+ 		} else {
+ 			$changelog[]="are associated with product &quot;".htmlify($p->name)."&quot;";
+ 		}
+ 	}
+ 	foreach (array_keys($_POST["actions"]) as $actionid) {
+ 		$action = new Action();
+ 		$action->load($actionid,$wg->id);
+ 		if ($action->update(false,false,$newduedate,$newstatus,false,$newproductid,$u->id)) {
+ 			$editedactions[]=$action->id;
+ 		}
+  	}
+  	$changes = "ACTION-".implode(', ACTION-',$editedactions)." ".implode(', ',$changelog).".";
+ }
+ 
+ $status = ANY;
+ $overdue = false;
+ if (array_key_exists('state',$_GET)) {
+ 	switch($_GET['state']) {
+ 		case 'open':
+ 			$status = OPEN;
+ 			break;
+ 		case 'overdue':
+ 			$status = OPEN;
+ 			$overdue =true;
+ 			break;
+ 		case 'closed':
+ 			$status = CLOSED;
+ 			break;
+ 		case 'pendingreview':
+ 			$status = PENDINGREVIEW;
+ 			break;
+ 		default:
+ 			$status = ANY;
+ 	}
+ }
+  
+ $editRights = ($u ? $u->isMemberOf($wg->wgid) : true);
+ 	
+ $qualifier = ($overdue ? "overdue" : $humanReadableState[$status]); 
+ 
+ // Output starts
+ WriteHTMLTop(ucfirst($qualifier)." Actions",&$wg);
+ 	if ($changes) {
+ 		echo "<p class='info'>$changes</p>";
+ 	}
+
+	$actions = $wg->listActionItems($status,$overdue);
+	$sort = (array_key_exists('sort',$_GET) ? $_GET["sort"]: null);
+	switch($sort) {
+		case 'status':
+			$actions->sortByProperty('status');
+			break;
+		case 'due':
+			$actions->sortByProperty('duedate');
+			break;
+		case 'owner':
+			$actions->sortByProperty('owner','family');
+			break;
+		default:
+			$sort='id';			
+	}
+	echo "<form action='' method='post'>";
+	?>
+	<fieldset class='actions'>
+	<legend>Apply the following changes to selected action items:</legend>
+<ul><li><label>Mark as <select name='status'>
+<option value='-1'>No status change</option>
+<option value='<?php echo constant("OPEN");?>'>Open</option>
+<option value='<?php echo constant("PENDINGREVIEW");?>'>Pending review</option>
+<option value='<?php echo constant("CLOSED");?>'>Closed</option>
+</select></label></li>
+<li><label>Update due date to: 
+<input class="w8em format-y-m-d divider-dash highlight-days-67  no-transparency"
+type="text" name="due" value="No change" size="10" /></label></li>
+<li><label>Associate to product: <select name='product'>
+<option value='-1'>No change</option>
+<?php
+$products = $wg->listProducts();
+foreach($products->list as $p) {
+	echo "<option title='".htmlify($p->name)."' value='".$p->id."'>".
+	elipsize(htmlify($p->name),35)."</option>\n";
+}
+?></select></label></li>
+</ul>
+<p><input type='submit' value='Apply' /></p>
+</fieldset>
+<?php
+	displayactionslist($actions,$qualifier,true,$sort,$editRights);
+	echo "</form>";
+	echo "<script type='text/javascript'>addSelectAllButtons('actions')</script>\n";
+	echo "<p><a href='". $wg->uribase. "actions/new'>Add a new action item</a>.
+	 See <a href='". $wg->uribase. "actions/'>all the action items</a></p>";
+WriteHTMLFoot('$Id: actions.php,v 1.14 2009/08/06 13:54:35 dom Exp $',$wg);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/agenda.php	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,209 @@
+<?php
+require_once("trackerlib.phi");
+require_once("objects.phi");
+$bygroup = array_key_exists("bygroup", $_GET);
+$wgid = $_GET["wgid"];
+$wg = new TrackerWorkingGroup($wgid);
+
+if (!$wg->loadConfig()) {
+	WriteErrorpage("Unknown group","The group with id $wgid is not known in Tracker","","404");
+}
+
+if ($wg->acls!='public') {
+	$logonid = checkCredentials();
+	$u = new TrackerUser(0,$logonid);
+	if ($wg->acls=='team' && !$u->isMemberOf(102)&&  !$u->isMemberOf($wg->id)) {
+		WriteErrorpage("Unauthorized","These pages are restricted to the W3C Staff.","",403);
+		exit();
+	} else if ($wg->acls=='member' && !$u->isMemberOf(105)&&  !$u->isMemberOf($wg->id)) {
+		WriteErrorpage("Unauthorized","These pages are restricted to W3C Members.","",403);
+		exit();
+	}
+}
+
+
+function print_action($action,$wrapper="li") {
+	global $humanReadableState;
+	echo "<".$wrapper."><a href='actions/".$action->id."/edit'
+		 title='Edit ACTION-".$action->id."'><img width='8' height='12' 
+		 src='/2002/09/wbs/icons/stock_edit2' alt=' (edit)'  /></a><a href='actions/".$action->id."'>ACTION-".$action->id.
+	"</a> on ".htmlify($action->owner->name).": ".htmlify($action->title).
+	" - due <span class='date".
+	($action->duedate < time() && $action->status ==OPEN ? " overdue" : "")."'>".
+	gmdate("Y-m-d",$action->duedate)
+	."</span> - ".$humanReadableState[$action->status];
+
+	$highlights = $action->listHighlight();
+	if (count($highlights)) {
+		echo "<ul>\n";
+		foreach ($highlights as $h) {
+			echo "<li>".$h->summary()."</li>\n";
+		}
+		echo "</ul>\n";
+	}
+	echo "</".$wrapper.">";
+}
+
+WriteHTMLTop("Input for Agenda Planning for the ".htmlify($wg->name),$wg);
+
+
+$titleShowed = false;
+$products = $wg->listProducts();
+$products->sortByActionDueDate();
+$dueBefore = time()+24*3600*7;
+if (array_key_exists('duebefore',$_GET)) {
+ 	$dueBefore = strtotime($_GET["duebefore"]);
+}
+$dueBeforePicker = "<label><input type='text' size='10' name='duebefore' "
+	." value='".($dueBefore ? htmlify(gmdate("Y-m-d",$dueBefore)) : "")."'"
+	." class='w8em format-y-m-d divider-dash highlight-days-67  no-transparency' /></label>"
+	. "<input type='submit' value='Filter' />";
+
+	if ($bygroup) {
+		echo "<p>This is the view of issues grouped by products;
+		 see also the <a href='agenda'>view of actions groups by issues ordered by due dates</a>.</p>";
+foreach ($products->list as $product) {
+	$productShowed = false;
+$issues = $product->listIssues(array(OPEN,PENDINGREVIEW));
+$issues->sortByActionDueDate();
+foreach($issues->list as $issue) {
+	$actions = $issue->listActionItems(array(OPEN,PENDINGREVIEW), $dueBefore);
+	if (count($actions->list)) {
+		if (!$titleShowed) {
+			echo "<h2 id='act_by_iss'>Open issues with open and pending review action items</h2>\n";
+			echo "<form action=''><p>Only showing action items due before "
+			      .$dueBeforePicker."</p></form>";
+			echo "<dl>";
+			$titleShowed = true;
+		}
+		if (!$productShowed) {
+			echo "<dt><strong>".htmlify($product->name)."</strong></dt><dd><dl>";
+			$productShowed = true;
+		}
+		echo "<dt>";
+		echo "<strong>".htmlify($issue->title)."</strong>";
+		echo " (<a href='issues/".$issue->id."'>ISSUE-".$issue->id."</a>".
+			($issue->nickname ? " ".htmlify($issue->nickname)."" : "").")";			
+		echo "</dt>";
+		foreach ($actions->list as $action) {
+		 print_action($action,"dd") ;
+		}
+		
+	}
+}
+$actions = $product->listOrphelinActionItems($dueBefore);
+if (count($actions->list)) {
+		if (!$titleShowed) {
+			echo "<h2 id='act_by_iss'>Open issues with open and pending review action items</h2>\n";
+			echo "<form action=''><p>Only showing action items due before ".$dueBeforePicker."</p></form>";
+			echo "<dl>";
+			$titleShowed = true;
+		}
+		if (!$productShowed) {
+			echo "<dt><strong>".htmlify($product->name)."</strong></dt><dd><dl>";
+			$productShowed = true;
+		}
+		echo "<dt>Unbound actions:</dt>";
+		foreach ($actions->list as $action) {
+		 print_action($action,"dd") ;
+		}
+	}
+	if ($productShowed) {
+		echo "</dl></dd>";
+	}
+}
+$noproductShowed = false;
+$issues = $wg->listOrphelinIssues();
+	} else {
+		echo "<p>This is the view of actions grouped by issues ordered by due dates;
+		 see also the <a href='agenda?bygroup'>view of issues groups by products</a>.</p>";
+		
+		$issues = $wg->listIssues(array(OPEN,PENDINGREVIEW));
+	}
+if (count($issues->list)) {
+	$issues->sortByActionDueDate();
+	foreach($issues->list as $issue) {
+		$actions = $issue->listActionItems(array(OPEN,PENDINGREVIEW),$dueBefore);
+		if (count($actions->list)) {
+			if (!$titleShowed) {
+				echo "<h2 id='act_by_iss'>Open issues with open and pending review action items</h2>\n";
+				echo "<form action=''><p>Only showing action items due before ".$dueBeforePicker."</p></form>";
+				echo "<dl>";
+				$titleShowed = true;
+				if (!$bygroup) {
+					echo "<dt><strong>Grouped by issues</strong></dt><dd><dl>";
+				}
+			}
+			if ($noproductShowed && $bygroup) {
+				echo "<dt><strong>Unbound issues</strong></dt><dd><dl>";
+				$noproductShowed = true;
+			}
+			echo "<dt>";
+			echo "<strong>".htmlify($issue->title)."</strong>";
+			echo " (<a href='issues/".$issue->id."'>ISSUE-".$issue->id."</a>".
+			($issue->nickname ? " ".htmlify($issue->nickname)."" : "").")";			
+			echo "</dt>";
+			foreach ($actions->list as $action) {
+				print_action($action,"dd") ;
+			}
+		}
+	}
+}
+if ($noproductShowed || !$bygroup) {
+	echo "</dl></dd>";
+}
+if ($titleShowed) {
+	echo "</dl>";
+}
+
+$titleShowed = false;
+$unassignedactions = $wg->listActionItems(array(OPEN,PENDINGREVIEW),false, $dueBefore);
+foreach($unassignedactions->list as $action) {
+	if ((!$bygroup || !is_object($action->product)) && !(is_object($action->issue))) {
+		if (!$titleShowed) {
+			echo "<h2>Open actions not associated to any issue"
+			  .($bygroup ? "/product" : "")."</h2>\n<ul>";
+			$titleShowed = true;
+		}
+		print_action($action);
+	}
+}
+if ($titleShowed) {
+	echo "</ul>\n";
+}
+
+echo "<h2>Action Items Pending Review</h2>\n";
+$pr = $wg->listActionItems(PENDINGREVIEW);
+displayactionslist($pr,"pending review");
+
+echo "<h2>Overdue action items</h2>\n";
+$overdue = $wg->listActionItems(OPEN,true);
+displayactionslist($overdue,"overdue");
+
+echo "<h2>Action items due next week</h2>\n";
+$nextweekactions = $wg->listActionItems(OPEN,false,time()+3600*24*7);
+displayactionslist($nextweekactions,"upcoming");
+
+echo "<h2>Issues discussed over the last week</h2>\n";
+$hotissues = $wg->listIssues(ANY,true);
+displayissueslist($hotissues,"recently discussed");
+
+if (substr_count($wg->issueProcess,'R')) {
+	$raisedissues = $wg->listIssues(RAISED);
+	if (count($raisedissues->list)) {
+	echo "<h2>Raised Issues</h2>\n";
+	displayissueslist($raisedissues,"raised");
+	}
+}
+
+if (substr_count($wg->issueProcess,'P')) {
+	$pendingissues = $wg->listIssues(PENDINGREVIEW);
+	if (count($pendingissues->list)) {
+	  echo "<h2>Pending Review Issues</h2>\n";
+	  echo "<p>The following issues are candidate for closing.</p>\n";	
+	  displayissueslist($pendingissues,"pending review");
+	}
+}
+
+WriteHTMLFoot('$Id: agenda.php,v 1.60 2010/11/21 17:34:07 dom Exp $',$wg);
+?>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/changelog.php	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,168 @@
+<?php
+/**
+ * HTTP interface to Tracker - list of actions
+ * @package Tracker
+ */
+
+ require_once("objects.phi");
+ require_once("trackerlib.phi");
+ lastModificationTime(filemtime(__FILE__));
+
+// WGid parameter set in URI
+ $wgid = $_GET["wgid"];
+ $wg = new TrackerWorkingGroup($wgid);
+ if (!$wg->loadConfig()) {
+ 	header("404 Not found");
+ 	header("Content-Type: text/plain");
+ 	echo "No config found for group $wgid";
+ 	exit();
+ }
+ $u = false;
+ // Checking access control
+ if ($wg->acls!='public') {
+ 	$logonid = checkCredentials();
+ 	$u = new TrackerUser(0,$logonid);
+ 	$_SERVER["userid"] = $u->id;
+ 	if ($wg->acls=='team' && !$u->isMemberOf(102)&&  !$u->isMemberOf($wg->id)) {
+ 		WriteErrorpage("Unauthorized","These pages are restricted to the W3C Staff.","",403);
+ 		exit();
+ 	} else if ($wg->acls=='member' && !$u->isMemberOf(105)&&  !$u->isMemberOf($wg->id)) {
+ 		WriteErrorpage("Unauthorized","These pages are restricted to W3C Members.","",403);
+ 		exit();
+ 	}
+ }
+
+ // The default start of the log is a week ago
+ $datemin = time()-7*3600*24;
+ if (array_key_exists('datemin',$_GET)) {
+ 	$datemin = strtotime($_GET["datemin"]);
+ }
+ 
+ // The default end of the log is now
+ $datemax = time();
+ if (array_key_exists('datemax',$_GET)) {
+ 	$datemax = strtotime($_GET["datemax"]);
+ }
+ 
+ $changedactions = $wg->listActionItems(ANY,false,false,array($datemin,$datemax));
+ $changedissues = $wg->listIssues(ANY,false,array($datemin,$datemax));
+ 
+ 
+ // Output starts
+ WriteHTMLTop("Changelog between ".gmdate("F j, Y",$datemin)." and ".gmdate("F j, Y",$datemax),&$wg);
+ echo "<p><a href='#issues'>Issues</a> | <a href='#actions'>Actions</a>.</p>";
+ ?>
+ <form action=''>
+ 	<p>Get changelog <label>from 
+ 	 <input type='text' size='10' name='datemin'
+ 	 <?php echo " value='".htmlify(gmdate("Y-m-d",$datemin))."'";?>
+ 	  class="w8em format-y-m-d divider-dash highlight-days-67  no-transparency"/></label>
+ 	 <label>to <input size='10' type='text' name='datemax' 
+ 	 <?php echo " value='".htmlify(gmdate("Y-m-d",$datemax))."'";?>
+ 	 class="w8em format-y-m-d divider-dash highlight-days-67  no-transparency"/></label>
+ 	<label>for <select name='user'><option value=''>everyone</option>
+ 	<?php
+ 	$users = $wg->listMembers();
+ 	foreach($users as $u) {
+ 		
+ 	}
+ 	?>
+ 	</select></label> 
+ 	<input type='submit' value='Get results'/></p>
+ </form> 
+ <?php
+ echo "<h2 id='issues'>Issues</h2>";
+if (count($changedissues->list)) {
+	echo "<dl class='changelist'>";
+ 	foreach($changedissues->list as $issue) {
+ 		echo "<dt><a href='".$wg->uribase."issues/".$issue->id."'"
+ 		." title='".htmlify($issue->title)."'"
+ 		.">ISSUE-".$issue->id."</a>"
+ 		.($issue->nickname ? " [".htmlify($issue->nickname)."]" : "")
+ 		." (".$issue->_readableStatus[$issue->status].")</dt>\n";
+ 		
+ 		$changes = $issue->listChanges($datemin,$datemax);
+ 		if (count($changes)) {
+ 			echo "<dd><ul>";
+ 			$currentTime = 0;
+ 			foreach($changes as $note) {
+ 				if ($currentTime!=$note->entered) {
+ 					if ($currentTime) {
+ 						echo "</li>\n";
+ 					}
+ 					echo "<li>".gmdate("Y-m-d H:i:s",$note->entered).":";
+ 					$currentTime = $note->entered;
+ 				}
+ 				echo "<br /> *";
+ 				if ($note->author) {
+ 					echo "<em>&lt;".htmlify($note->author->name)."&gt;</em> ";
+ 				}
+ 				echo nl2br(addlinks($note->note)); 	
+ 			}
+ 			echo "</li>\n";
+ 			echo "</ul></dd>\n";
+ 		}
+ 		$emails = $issue->listEmails($datemin,$datemax);
+ 		if (count($emails)) {
+ 			echo "<dd><ul class='email'>";
+ 			foreach($emails as $email) {
+				echo "<li>".gmdate("Y-m-d",$email->sent).": "
+ 				."<a href=\"" . $email->uri . "\">" 
+ 				. htmlify($email->subject) . "</a> (from " .
+ 				 $email->sender . ")</li>\n"; 			}
+ 			echo "</ul></dd>\n";
+ 		}	 		
+ 	}
+ 	echo "</dl>";
+} else {
+	echo "<p>No issues were modified in this period.</p>";
+}
+ echo "<h2 id='actions'>Actions</h2>";
+
+ if (count($changedactions->list)) {
+	echo "<dl class='changelist'>";
+ 	foreach($changedactions->list as $action) {
+ 		echo "<dt><a href='".$wg->uribase."actions/".$action->id."'"
+ 		.">ACTION-".$action->id."</a>: "
+ 		.htmlify($action->owner->name)." to ".htmlify($action->title)
+ 		." (".$action->_readableStatus[$action->status].")</dt>\n";
+ 		$changes = $action->listChanges($datemin,$datemax);
+ 		if (count($changes)) {
+ 			echo "<dd><ul>";
+ 			$currentTime = 0;
+ 			foreach($changes as $note) {
+ 				if ($currentTime!=$note->entered) {
+ 					if ($currentTime) {
+ 						echo "</li>\n";
+ 					}
+ 					echo "<li>".gmdate("Y-m-d H:i:s",$note->entered).":";
+ 					$currentTime = $note->entered;
+ 				}
+ 				echo "<br /> * ";
+ 				if ($note->author) {
+ 					echo "<em>&lt;".htmlify($note->author->name)."&gt;</em> ";
+ 				}
+ 				echo nl2br(addlinks($note->note));
+ 			}
+ 			echo "</li>\n";
+ 			echo "</ul></dd>\n";
+ 		}
+ 		$emails = $action->listEmails($datemin,$datemax);
+ 		if (count($emails)) {
+ 			echo "<dd><ul class='email'>";
+ 			foreach($emails as $email) {
+ 				echo "<li>".gmdate("Y-m-d",$email->sent).": "
+ 				."<a href=\"" . $email->uri . "\">" 
+ 				. htmlify($email->subject) . "</a> (from " .
+ 				 $email->sender . ")</li>\n";
+ 			}
+ 			echo "</ul></dd>\n";
+ 		}	
+ 	}
+ 	echo "</dl>";
+} else {
+	echo "<p>No actions were modified in this period.</p>";
+}
+ 
+ WriteHTMLFoot('$Id: changelog.php,v 1.30 2009/08/06 13:55:41 dom Exp $',$wg);
+?>
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/config.php	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,27 @@
+<?
+require_once("trackerlib.phi");
+require_once("objects.phi");
+$tracker = new TrackerApp();
+$wgs = $tracker->listWorkingGroups();
+
+Header("Content-Type: text/xml; charset=utf-8");
+echo "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"; 
+echo "<trackerconfig>\n";
+foreach($wgs as $wg) {
+	$wg->loadConfig();
+	$wg->loadExtraConfig();	
+    echo "  <tracker wgid='".(int) $wg->id."'>\n".
+      "    <mailinglist email='".htmlify($wg->mailinglist)."' />\n".
+      "    <uribase uri='".htmlify($wg->uribase)."' />\n".
+      "    <irc channel='".htmlify($wg->ircchannel)."' />\n".
+      "    <minutes acl='".htmlify($wg->minutesacls)."' />\n".
+      "    <conference id='".htmlify($wg->conferenceid)."' name='".htmlify($wg->name)."' />\n".
+      "    <watchedmailinglists>\n";
+  foreach ($wg->watchedlists as $list) {
+  	echo "      <watchedmailinglist email='".htmlify($list)."' />\n";
+  }
+  echo "   </watchedmailinglists>\n";
+  echo "  </tracker>\n";
+}
+echo "</trackerconfig>\n";
+?>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/index.php	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,1597 @@
+<?php
+/**
+ * HTTP interface to Tracker
+ * @package Tracker
+ */
+
+require_once("objects.phi");
+require_once("trackerlib.phi");
+lastModificationTime(filemtime(__FILE__));
+
+// WGid parameter set in URI
+$wgid = $_GET["wgid"];
+$wg = new TrackerWorkingGroup($wgid);
+
+
+// Parameters:  (probably incomplete)
+// do =
+//      summary -- show a summary of issues and actions
+//      showissue  -- show a particular issue
+//      resolutions -- show resolutions
+//      newaction -- form for creating a new action
+//      newissue -- form for creating a new issue
+//      editaction -- form for editing an action
+//      editissue -- form for editing an issue
+//      createaction -- create the action specified by new
+//      createissue -- create the issue specified by new
+//      updateaction -- updates the action specified by edit
+//      updateissue -- updates the issue specified by edit
+
+//      attachissueemail -- insert the attached email to an issue
+//      attachactionemail -- insert the attached email to an issue
+
+
+if (!$wg->loadConfig()) {
+	header("404 Not found");
+	header("Content-Type: text/plain");
+	echo "No config found for group $wgid";
+	exit();
+}
+
+
+function pagetitle($do) {
+	global $pageheaders;
+	if (array_key_exists($do, $pageheaders)) {
+		return $pageheaders[$do];
+	} else {
+		return "Unknown (" . $do . ")";
+	}
+}
+
+
+// association between action and page title/header
+$pageheaders = array(
+"summary" => $wg->name. " Issue/Action Summary",
+"resolutions" => "Resolutions",
+"showissue" => "ISSUE",
+"editissue" => "Edit ISSUE",
+"showaction" => "ACTION",
+"editaction" => "Edit ACTION",
+"newproduct" => "Create a new Product",
+"newissue" => "Create a new issue",
+"newaction" => "Create a new action",
+"createissue" => "Created ISSUE",
+"createproduct" => "Created Product",
+"createaction" => "Created ACTION",
+"updateissue" => "Updated ISSUE",
+"updateaction" => "Updated ACTION",
+"products" => "Products",
+"showproduct" => "Details on Product"
+);
+
+
+function showissues($open,$product="all") {
+	global $wg;
+	if ($product!="all") {
+		$p = new Product();
+		// Sends 404 if can't load  @@@
+		$p->load($product,$wg->id);
+		$issues = $p->listIssues($open);
+	} else {
+		$issues = $wg->listIssues($open);
+	}
+        if (is_array($open)) {
+          $qualifier = "open and raised";
+        } else {
+          global $humanReadableState;
+          $qualifier = $humanReadableState[$open];
+        }
+	displayissueslist($issues,$qualifier);
+}
+
+
+
+// integrate into TrackUser? probably not
+function showusercalendar($userid,$wgid=0) {
+	global $wg;
+	$user = new TrackerUser($userid);
+	$actions = $user->listActionItems($wg->id,false,array(OPEN));
+	echo("BEGIN:VCALENDAR\n");
+	echo("VERSION:2.0\n");
+	echo("X-WR-CALNAME:" . $wg->name. " Actions for " . $user->name . "\n");
+	echo("PRODID:-//W3C//Tracker//EN\n");
+	echo("X-WR-TIMEZONE:US/Eastern\n");
+	echo("CALSCALE:GREGORIAN\n");
+
+	foreach($actions->list as $a) {
+		echo("BEGIN:VTODO\n");
+		echo("DTSTART;VALUE=DATE:" . gmdate("Ymd", $a->opened) . "\n");
+		echo("DUE;VALUE=DATE:" . gmdate("Ymd", $a->duedate) . "\n");
+		echo("DTSTAMP:" . gmdate("Ymd\THis\Z") . "\n");
+		echo("SUMMARY: " . $a->title . " (ACTION-" . $a->id. " for " . $wg->name. ")\n");
+		echo("UID:".$wg->uribase."actions/".$a->id."\n");
+		echo("URL:".$wg->uribase."actions/".$a->id."\n");
+		echo("END:VTODO\n");
+	}
+	echo("END:VCALENDAR\n");
+}
+
+
+// show the actions
+
+function showactions($open,$overdue=false,$product=0) {
+	global $wg;
+	$qualifier = ($overdue ? "overdue" : ($open==OPEN ? "open" : 
+				($open == ANY ? "" :
+	              ($open==CLOSED ? "closed" : 
+	               (is_array($open) ? "open and pending review" : "pending review")))));
+	if (!$product) {
+		$actions = $wg->listActionItems($open,$overdue);
+	} else {
+		$p = new Product();
+		// @@@ returns 404 if non existing
+		$p->load($product,$wg->id);
+		$actions = $p->listActionItems();
+	}
+	displayactionslist($actions,$qualifier);
+	echo "<p><a href='". $wg->uribase. "actions/new'>Add a new action item</a>.</p>";
+}
+
+
+/* Not in use in the API in this time, thus not translated
+ function newemail() {
+ global $TRACKBASE;
+ ?>
+ <form method="post" action="<?= $TRACKBASE; ?>">
+ <div>    <input type="hidden" name="do" value="attachissueemail"/>
+
+ <p>
+ ISSUE id:<br/>
+ <input type="text" name="id" value="" size="10"/>
+ </p>
+
+ <p>
+ Email URI:<br/>
+ <input type="text" name="uri" value="" size="80"/>
+ </p>
+
+ <p>
+ Email Subject:<br/>
+ <input type="text" name="subject" value="" size="80"/>
+ </p>
+
+ <p>
+ Email Sender:<br/>
+ <input type="text" name="sender" value="" size="40"/>
+ </p>
+
+ <p>
+ Email Date:<br/>
+ <input type="text" name="date" value="" size="16"/>
+ </p>
+
+ <p>
+ <input type="submit" name="submit"/>
+ </p></div>
+ </form>
+ <?php
+ }
+ */
+
+function newproduct() {
+	global $wg;
+	?>
+
+<p>Use this page to create a new product - a product is typically a
+deliverable of the group.</p>
+<form method="post" action="<?= $wg->uribase; ?>">
+<div><input type="hidden" name="do" value="createproduct" />
+
+<p>Name:<br />
+<input type="text" name="name" value="" size="80" /></p>
+<p><input type="submit" name="Submit" value="Create" /></p>
+</div>
+</form>
+	<?php
+}
+
+
+function newissue() {
+	global $wg;
+	$products = $wg->listProducts();
+	$users = $wg->listMembers();
+
+	?>
+
+<p>Use this page to raise all new issues.</p>
+<p>Fill in all the fields and submit.
+<?php
+if ($wg->mailinglist) {
+ echo "This will cause an email to be sent to ".$wg->mailinglist." for discussion (or to a different list depending on the selected product).";
+} else {
+ echo "Tracker is configured not to send mail on new issues.";	
+}
+?>
+</p>
+<form method="post" action="<?= $wg->uribase; ?>">
+<div><input type="hidden" name="do" value="createissue" />
+
+<p>Nickname: <br />
+<input type="text" name="nickname" value="" size="20" /></p>
+
+<p>Title:<br />
+<input type="text" name="title" value="" size="80" /></p>
+
+<p>Product:<br />
+<select name="product">
+<option value="0">None</option>
+<?php
+foreach ($products->list as $product) {
+	echo("<option value=\"" . $product->id . "\">" . htmlify($product->name)
+	  .($product->mailinglist ? " (notified to ".htmlify($product->mailinglist).")" : "") 
+	  ."</option>\n");
+}
+?>
+</select></p>
+
+<p>Raised By:<br />
+<select name="raisedby">
+
+<?php
+$highlightedUser = 0;
+if ($_SERVER["userid"]) {
+	$highlightedUser = $_SERVER["userid"];
+}
+
+//  '
+foreach ($users as $user) {
+	echo("<option value=\"" . $user->id . 
+	"\" ".
+	($user->id==$highlightedUser ? " selected='selected'" : "").
+	">" . htmlify($user->name) . "</option>\n");
+}
+?>
+</select></p>
+
+<p>Description: (be as verbose as you like - this all goes into the
+email)<br />
+<textarea name="description" cols="80" rows="13"></textarea></p>
+
+<p><input type="submit" name="Submit" value="Create" /></p>
+</div>
+</form>
+<?php
+}
+
+function newaction() {
+	global $wg;
+	$issues = $wg->listIssues(OPEN);
+	$products = $wg->listProducts();
+	$users = $wg->listMembers();
+	?>
+<form method="post" action="<?= $wg->uribase; ?>">
+<div><input type="hidden" name="do" value="createaction" />
+
+<p>Title:<br />
+<input type="text" name="action" value="" size="80" /></p>
+
+<p>Person:<br />
+<select name="user">
+<?php
+echo ("<option value='0'>Unassigned</option>\n");
+foreach ($users as $user) {
+	echo("<option value=\"" . $user->id . "\">" . htmlify($user->name) . "</option>\n");
+}
+?>
+</select></p>
+
+<p>Due Date:<br />
+<input type="text" name="due" value="<?php echo gmdate('Y-m-d',time() + 3600*7*24); ?>" size="16" /> <br />
+(accepts formats such as "2005-05-17", "+1 week", "14 August 2005" and
+"next Thursday")</p>
+
+<p>Associated Issue:<br />
+<select name='issue'>
+	<option value=''>none</option>
+	<?php
+	foreach ($issues->list as $issue) {
+		echo("<option ");
+		echo("value=\"" . $issue->id . "\">".($issue->nickname ? htmlify($issue->nickname) : "ISSUE-".$issue->id) . "</option>\n");
+	}
+	?>
+</select></p>
+<p><strong>Or</strong> Associated Product:<br />
+<select name='product'>
+	<option value=''>none</option>
+	<?php
+	foreach ($products->list as $product) {
+		echo("<option ");
+		echo("value=\"" . $product->id . "\">" . htmlify($product->name) . "</option>\n");
+	}
+	?>
+</select></p>
+
+<p><input type="submit" name="submit" value="Create Action" /></p>
+</div>
+</form>
+	<?php
+}
+
+function editTags($emails,$notes) {
+	if (count($emails)) {
+          echo("<h2>Related emails:</h2>\n");
+          echo("<ol>\n");
+          foreach($emails as $email) {
+          	$highlighted = $email->hasTag("@highlight");
+            echo("<li>".($highlighted ? "<strong class='hl'>" : "")
+            .$email->summary()
+            .($highlighted ? "</strong>" : "")
+            ." (from " . $email->sender . " on " . gmdate("Y-m-d",$email->sent) . ")"
+            ."<label><input type='checkbox' value='@highlight'"
+            ."name='".(!$highlighted ? "emailtag" : "delemailtag")."[".$email->id."]'"            
+            ." />". ($highlighted ? "remove " : "")."highlight</label>"
+            ."</li>\n");
+          }
+          echo("</ol>\n");
+	} else {
+          echo("<p>No related emails.</p>\n");
+	}
+	if (count($notes)  > 0) {
+          echo("<h2>Related notes:</h2>\n");
+          foreach($notes as $note) {
+          	$highlighted = $note->hasTag("@highlight");
+            echo("<div class=\"user\">\n");
+            echo("<p>"  
+             .($highlighted ? "<strong class='hl'>" : "")
+             .$note->summary()
+             .($highlighted ? "</strong>" : "")
+             ." <label><input type='checkbox' "
+             ." name='".(!$highlighted ? "notetag" : "delnotetag")."[".$note->id."]'"
+             ." value='@highlight' />". ($highlighted ? "remove " : "")." highlight</label>"
+             ."</p>\n");
+            echo("</div>\n");
+          }
+	} else {
+          echo("<p>No User notes.</p>\n");
+	}	
+}
+
+function editissue($issue) {
+	global $wg;
+	$products = $wg->listProducts();
+	$users =$wg->listMembers();
+
+	// Preserve former users showing when editing an issue
+	if (!in_array($issue->owner->id, $users)) {
+	   $users[] = new User($issue->owner->id);
+	}
+	?>
+<form method="post" action="<?= $wg->uribase; ?>">
+<div><input type="hidden" name="do" value="updateissue" /> <input
+	type="hidden" name="id" value="<?= $issue->id; ?>" />
+<p>Nickname: <br />
+<input type="text" name="nickname"
+	value="<?= htmlify($issue->nickname); ?>" size="20" /></p>
+<p>Title:<br />
+<input type="text" name="title" value="<?= htmlify($issue->title); ?>"
+	size="80" /></p>
+
+<p>State:<br />
+<select name="state">
+<?php
+if (strstr($wg->issueProcess,'R')) {
+echo("<option ");
+if ($issue->status == RAISED) echo("selected=\"selected\" ");
+echo("value=\"".RAISED."\">RAISED</option>\n");
+}
+echo("<option ");
+if ($issue->status == OPEN) echo("selected=\"selected\" ");
+echo("value=\"".OPEN."\">OPEN</option>\n");
+if (strstr($wg->issueProcess,'P')) {
+  echo("<option ");
+  if ($issue->status == PENDINGREVIEW) echo("selected=\"selected\" ");
+  echo("value=\"".PENDINGREVIEW."\">PENDINGREVIEW</option>\n");
+}
+echo("<option ");
+if ($issue->status == CLOSED) echo("selected=\"selected\" ");
+echo("value=\"".CLOSED."\">CLOSED</option>\n");
+if (strstr($wg->issueProcess,'T')) {
+  echo("<option ");
+  if ($issue->status == POSTPONED) echo("selected=\"selected\" ");
+  echo("value=\"".POSTPONED."\">POSTPONED</option>\n");
+}
+
+?>
+</select></p>
+
+<p>Product:<br />
+<select name="product">
+    <option value="0">None</option>
+<?php
+foreach ($products->list as $product) {
+	echo("<option ");
+	if ($issue->product->id == $product->id) echo("selected=\"selected\" ");
+	echo("value=\"" . $product->id . "\">" . htmlify($product->name) . "</option>\n");
+}
+?>
+</select></p>
+
+<p>Raised By:<br />
+<select name="raisedby">
+<?php
+$highlightedUser = $issue->owner->id;
+foreach ($users as $user) {
+	echo("<option ");
+	if ($user->id == $highlightedUser) echo("selected=\"selected\" ");
+	echo("value=\"" . $user->id . "\">" . htmlify($user->name) . "</option>\n");
+}
+?>
+</select></p>
+
+<p>Description:<br />
+<textarea name="description" cols="80" rows="7"><?= htmlify($issue->description); ?></textarea>
+</p>
+
+<p>Add notes <em>(no markup allowed, URIs get automatically hyperlinked)</em>:<br />
+<textarea name="notes" cols="80" rows="7"></textarea></p>
+
+<p><input type="submit" name="submit" value="Update" /></p>
+</div>
+
+
+<?php
+	$emails = $issue->listEmails();
+	$notes = $issue->listUserNotes();
+	
+	editTags($emails,$notes);
+	echo "</form>";
+}
+
+
+function editaction($action) {
+	global $wg;
+	$users = $wg->listMembers();
+	$issues = $wg->listIssues(array(RAISED,OPEN,PENDINGREVIEW));
+	$closedissues = $wg->listIssues(array(CLOSED,POSTPONED));
+	$products = $wg->listProducts();
+
+	?>
+<form method="post" action="<?= $wg->uribase; ?>">
+<div><input type="hidden" name="do" value="updateaction" /> <input
+	type="hidden" name="id" value="<?= $action->id; ?>" />
+
+<p>Title:<br />
+<input type="text" name="action" value="<?= htmlify($action->title); ?>"
+	size="80" /></p>
+
+<p>State:<br />
+<select name="state">
+<?php
+echo("<option ");
+if ($action->status == OPEN) echo("selected=\"selected\" ");
+echo("value=\"".OPEN."\">OPEN</option>\n");
+echo("<option ");
+if ($action->status == CLOSED) echo("selected=\"selected\" ");
+echo("value=\"".CLOSED."\">CLOSED</option>\n");
+echo("<option ");
+if ($action->status == PENDINGREVIEW) echo("selected=\"selected\" ");
+echo("value=\"".PENDINGREVIEW."\">Pending review</option>\n");
+?>
+</select></p>
+
+<p>Person:<br />
+<select name="user">
+<?php
+echo ("<option value='0'>Unassigned</option>\n");
+	// Preserve former users showing when editing an issue
+	if (!in_array($action->owner->id, $users)) {
+	   $users[] = new User($action->owner->id);
+	}
+foreach ($users as $user) {
+	echo("<option ");
+	if ($action->owner->id == $user->id) echo("selected=\"selected\" ");
+	echo("value=\"" . $user->id . "\">" . htmlify($user->name) . "</option>\n");
+}
+?>
+</select></p>
+
+
+<p>Due Date:<br />
+<input type="text" name="due" class="w8em format-y-m-d divider-dash highlight-days-67  no-transparency"
+	value="<?= gmdate('Y-m-d',$action->duedate) ?>" size="20" /> <br />
+(accepts formats such as "2005-05-17", "+1 week", "14 August 2005" and
+"next Thursday")</p>
+<p>Associated Issue:<br />
+<select name='issue'>
+	<option value=''>none</option>
+<optgroup label="Open issues">
+	<?php
+	foreach ($issues->list as $issue) {
+		echo("<option ");
+		if ($action->issue && $action->issue->id == $issue->id) echo("selected=\"selected\" ");
+		echo("value=\"" . $issue->id . "\">ISSUE-".$issue->id. ($issue->nickname ? ": ".htmlify($issue->nickname) : "")."</option>\n");
+	}
+	?>
+</optgroup>
+<optgroup label="Closed issues">
+	<?php
+	foreach ($closedissues->list as $issue) {
+		echo("<option ");
+		if ($action->issue && $action->issue->id == $issue->id) echo("selected=\"selected\" ");
+		echo("value=\"" . $issue->id . "\">ISSUE-".$issue->id. ($issue->nickname ? ": ".htmlify($issue->nickname) : "") . "</option>\n");
+	}
+	?>
+</optgroup>
+</select></p>
+<p><strong>Or</strong> Associated Product:<br />
+<select name='product'>
+	<option value=''>none</option>
+	<?php
+	foreach ($products->list as $product) {
+		echo("<option ");
+		if ($action->product && $action->product->id == $product->id) echo("selected=\"selected\" ");
+		echo("value=\"" . $product->id . "\">" . htmlify($product->name) . "</option>\n");
+	}
+	?>
+</select></p>
+
+<p>Add notes <em>(no markup allowed, URIs get automatically hyperlinked)</em>:<br />
+<textarea name="notes" cols="80" rows="7"></textarea></p>
+
+<p><input type="submit" name="submit" value="Update" /></p>
+</div>
+	<?php
+	$emails = $action->listEmails();
+	$notes = $action->listUserNotes();
+	
+	editTags($emails,$notes);
+	echo "</form>";
+}
+
+
+function displayissue($issue,$showchangelog=false) {
+	global $wg;
+
+	displayissueinfo($issue,$showchangelog);
+	if (!$showchangelog) {
+		echo "<p>Display <a href='".
+			htmlify($wg->uribase)."issues/".htmlify($issue->id)."?changelog'>change log</a>"
+			." <a href='".htmlify($wg->uribase)."issues/".htmlify($issue->id).".atom'>"
+			."<img src='/2008/site/images/icons/atom'
+			 alt='ATOM feed' width='12' height='12' /></a>"
+			."</p>\n" ;
+	}
+
+}
+
+function displayissuechangelogasatom($id) {
+	global $wg;
+	$issue = new Issue();
+	$issue->load($id,$wg->id);
+	$changelog = $issue->listChangelog(false);
+	$updated = '2009-11-16T14:35:00Z';
+	if (count($changelog)  > 0) {
+		$n = $changelog[0];
+		$updated = gmdate("Y-m-d\TH:i:s\Z",$n->entered);
+	}
+	echo '<feed xmlns="http://www.w3.org/2005/Atom">';
+ 	echo '<title>'.htmlify($wg->name)
+ 		.'’s ISSUE-'.$id.($issue->nickname ? ' ('.htmlify($issue->nickname).')' : '')
+ 		.' changelog </title>';
+ 	echo '<link rel="self" href="'.htmlify($wg->uribase."issues/$id.atom").'"/>';
+ 	echo '<link rel="related" href="'.htmlify($wg->uribase."issues/$id").'"/>';
+ 	echo '<updated>'.htmlify($updated).'</updated>';
+ 	echo '<author><name>'.htmlify($wg->name).'’s Tracker</name></author>';
+ 	echo '<id>'.htmlify($wg->uribase."issues/$id.atom").'</id>';
+ 	if (count($changelog)  > 0) {
+		foreach($changelog as $note) {
+ 		echo '<entry>';
+   		echo '<title>'.htmlify($note->note).'</title>';
+   		echo '<id>'.htmlify($wg->uribase."issues/".$id."#".gmdate("Y-m-d\TH:i:s\Z",$note->entered)).'</id>';
+   		echo '<updated>'. htmlify(gmdate("Y-m-d\TH:i:s\Z",$note->entered)) . '</updated>';
+   		if ($note->author) {
+   			echo '<author><name>'.htmlify($note->author->name).'</name></author>';
+   		}
+   		echo '<content type="xhtml">';
+   		echo"<div xmlns='http://www.w3.org/1999/xhtml'>" . htmlify(gmdate("Y-m-d H:i:s",$note->entered)) . ": " .
+   			 nl2br(addlinks($note->note)) . ($note->author ? " [".htmlify($note->author->name)."]" : "")."</div>\n";
+   		echo '</content>';
+		echo "</entry>\n";
+		}
+ 	}
+ 	echo '</feed>';
+}
+
+function displayaction($action,$showchangelog=false) {
+	global $wg;
+	displayactioninfo($action,$showchangelog);
+	if (!$showchangelog) {
+		echo "<p>Display <a href='".$wg->uribase."/actions/".$action->id."?changelog'>change log</a>.</p>\n" ;
+	}
+
+}
+
+function apiaction($id) {
+	global $wg;
+	$action = new Action();
+	// @@@ returns 404 if can't load
+	$action->load($id,$wg->id);
+	echo $action->toXML(false,true);
+}
+
+function apiissue($id) {
+	global $wg;
+	$issue = new Issue();
+	// @@@ returns 404 if can't load
+	$issue->load($id,$wg->id);
+	echo $issue->toXML();
+}
+
+function apiresolution($id) {
+	global $wg;
+	$resolution = new Resolution();
+	// @@@ returns 404 if can't load
+	$resolution->load($id,$wg->id);
+	echo $resolution->toXML();
+}
+
+/**
+ * Prints HTML markup detailing the issue
+ *
+ * @param Issue $issue
+ * @param boolean $showchangelog whether or not to show the changelog
+ */
+function displayissueinfo($issue,$showchangelog=false) {
+	global $wg;
+	echo("<h2>" . htmlify($issue->nickname) . "</h2>\n");
+	echo("<h2>" . addlinks($issue->title) . "</h2>\n");
+	echo("<dl>\n");
+	echo("<dt>State:</dt>\n<dd>");
+        echo strtoupper($issue->_readableStatus[$issue->status]);
+	echo("</dd>\n");
+	echo("<dt>Product:</dt>\n<dd>" . htmlify($issue->product->name) . "</dd>\n");
+	echo("<dt>Raised by:</dt>\n<dd><a href='".$wg->uribase."users/" 
+		.htmlify($action->owner->id)."'>" . htmlify($issue->owner->name) . "</a></dd>\n");
+	echo("<dt>Opened on:</dt>\n<dd>" . gmdate("Y-m-d",$issue->created) . "</dd>\n");
+
+	echo("<dt>Description:</dt>\n<dd>" . nl2br(addlinks($issue->description)) . "</dd>\n");
+
+	echo("<dt>Related Actions Items:</dt>\n");
+	$actions = $issue->listActionItems();
+	global $humanReadableState;
+	if (count($actions->list)) {
+		echo "<ul>";
+		foreach($actions->list as $action) {
+			echo "<li>".($action->status==CLOSED  ? "<del class='closed'>": "").
+			"<a href='".htmlify($wg->uribase)."actions/".$action->id."'>ACTION-".$action->id.
+			"</a> on ".htmlify($action->owner->name)." to ".htmlify($action->title)." - due ".
+			"<span class='date".($action->duedate < time() && $action->status==OPEN ? 
+			" overdue":"")."'>".
+			gmdate('Y-m-d',$action->duedate)."</span>, ".
+			$humanReadableState[$action->status].
+			($action->status==CLOSED  ? "</del>": "")."</li>\n";
+		}
+		echo "</ul>\n";
+	} else {
+		echo "<dd>No related actions</dd>\n";
+	}
+	$emails = $issue->listEmails();
+	$notes = $issue->listUserNotes();
+
+	if ($showchangelog) {
+		$changelog = $issue->listChangelog();
+	} else {
+		$changelog = false;
+	}
+	
+	_listAnnotations($emails,$notes,$changelog);
+	
+}
+
+function _listAnnotations($emails,$notes,$changelog) {
+	echo("<dt>Related emails:</dt>\n");
+	if (count($emails)) {
+		echo("<dd><ol>\n");
+		foreach ($emails as $email) {
+			$highlighted = $email->hasTag("@highlight");
+			echo "<li id='e".htmlify($email->id)."'>".($highlighted ? "<strong  class='hl'>" : "") 
+				.$email->summary()
+				.($highlighted ? "</strong>" : "")
+				."</li>\n";
+		}
+		echo("</ol></dd>\n");
+	} else {
+		echo("<dd>No related emails</dd>\n");
+	}
+	echo("</dl>\n");
+	echo("<h2>Related notes:</h2>\n");
+	if (count($notes)) {
+		foreach ($notes as $note) {
+			$highlighted = $note->hasTag("@highlight");
+			echo "<div class=\"user".($highlighted ? " highlighted" : "")."\">\n";
+			
+			echo "<p id='n".htmlify($note->id)."'>"  
+			.($highlighted ? "<strong class='hl'>" : "") 			 
+			. $note->summary()
+			.($highlighted ? "</strong>" : "")  
+			."</p>\n";
+			echo "</div>\n";
+		}
+	} else {
+		echo("<p>No additional notes.</p>\n");
+	}
+	if ($changelog) {
+		echo("<h2>Changelog:</h2>\n");
+		if (count($changelog)  > 0) {
+			foreach($changelog as $note) {
+				echo("<div class=\"system\">\n");
+				echo("<p>" . $note->summary()."</p>\n");
+				echo("</div>\n");
+			}
+		} else {
+			echo("<p>No recorded changelog.</p>\n");
+		}
+		
+	}
+}
+
+function displayactioninfo($action,$showchangelog=false) {
+	global $wg;
+	$id = $action->id;
+
+	if (isset($_REQUEST["v"])) {
+		echo("<p><a href=\"" .$wg->uribase. "actions/" . $id . "/edit\">Edit this action</a></p>\n");
+	}
+
+	echo("<h2>" . addlinks($action->title) . "</h2>\n");
+	echo("<dl>\n");
+	echo("<dt>State:</dt>\n<dd>");
+	global $humanReadableState;
+	echo $humanReadableState[$action->status];
+	echo("</dd>\n");
+	echo("<dt>Person:</dt>\n<dd><a href='".$wg->uribase."users/" 
+		.htmlify($action->owner->id)."' title='Tracker summary for "
+		.htmlify($action->owner->name)."'>" . htmlify($action->owner->name) 
+		. "</a></dd>\n");
+	$duein = ceil(($action->duedate - time ())/(3600*24));
+	echo("<dt>Due on:</dt>\n<dd>" . gmdate('F j, Y',$action->duedate) 
+		. ( $duein > 1 ? " — $duein days from now" : ($duein > 0 ? " — tomorrow" :
+			($duein==0 ? " — today" : "")))
+		. "</dd>\n");
+	echo("<dt>Created on:</dt>\n<dd>" . gmdate('F j, Y',$action->opened) . "</dd>\n");
+	// Not used at this time? @@@
+	//  echo("<dt>URI:</dt>\n<dd></dd>\n");
+	if ($action->issue) {
+		echo "<dt>Associated Issue:</dt>\n<dd><a href='".$wg->uribase.'issues/'.$action->issue->id."'>".($action->issue->nickname ? htmlify($action->issue->nickname) : "ISSUE-".$action->issue->id)."</a></dd>\n" ;
+	} else if ($action->product) {
+		echo "<dt>Associated Product:</dt>\n<dd><a href='".$wg->uribase.'products/'.$action->product->id."'>".htmlify($action->product->name)."</a></dd>\n" ;
+	}
+
+	$emails = $action->listEmails();
+	$notes = $action->listUserNotes();
+
+	if ($showchangelog) {
+		$changelog = $action->listChangelog();
+	} else {
+		$changelog = false;
+	}
+	_listAnnotations($emails,$notes,$changelog);
+}
+
+
+/* Uncalled, so not yet translated at this time@@@
+ function displaylog($id) {
+ global $TRACKWG;
+ $query = "SELECT * FROM notes WHERE id = " . $id ." AND wgid=$TRACKWG AND rectype='issue'";
+ $result = mysql_query($query);
+ echo("<h2>Related notes/history:</h2>\n");
+ while ($row = mysql_fetch_array($result, MYSQL_BOTH)) {
+ if ($row['type'] == 1) {
+ echo("<div class=\"system\">\n");
+ } else {
+ echo("<div class=\"user\">\n");
+ }
+ echo("<p>" . $row["entered"] . ": " . $row["note"] . "</p>\n");
+ echo("</div>\n");
+ }
+ }
+ */
+
+function createproduct() {
+	global $wg;
+	$product = new Product();
+	// set userid as possible @@@
+	$userid = $_SERVER["userid"];
+	// @@@ reacts on false return
+	$product->create($wg->id,$_POST["name"],$userid);
+	echo "<p>Product ".htmlify($product->name)." added to database.</p>\n" ;
+	showproducts();
+}
+
+
+function createissue() {
+	global $wg;
+	$issue = new Issue();
+	// set public flag on a request per request basis @@@
+	$public = false;
+	// set userid as possible @@@
+	$userid = $_SERVER["userid"];
+	// @@@ reacts on false return
+	$issue->create($wg->id,$_POST["title"],$_POST["raisedby"],$_POST["description"]
+		,$_POST["product"],$_POST["nickname"],$public,$wg->defaultIssueState, $userid);
+
+	echo("<p><a href='".$wg->uribase."issues/".$issue->id."'>ISSUE-".$issue->id."</a> added to database</p>\n");
+
+	displayissueinfo($issue);
+	$to = ($issue->product->mailinglist ? $issue->product->mailinglist : $wg->mailinglist);
+	if ($to) {
+		$subject = "ISSUE-" . $issue->id .($issue->nickname ? " (".$issue->nickname.")" : "").": " . $issue->title.($issue->product->name ? " [".$issue->product->name."]" : "");
+		$message = $subject . "\n\n";
+		$message .=$wg->uribase. "issues/" . $issue->id . "\n\n";
+		$message .= "Raised by: " . $issue->owner->name . "\n";
+		$message .= "On product: " . $issue->product->name . "\n\n";
+		$message .= str_replace("\r\n", "\n", $issue->description) . "\n\n";
+		$headers = "From: " . $wg->name. " Issue Tracker <sysbot+tracker@w3.org>\r\n";
+		$headers .= "Reply-To: " . $wg->name. " WG <" . $to . ">\r\n";
+		$headers .= "MIME-Version: 1.0\r\n";
+		$headers .= "Content-Type: text/plain; charset=utf-8\r\n";
+		$headers .= "Content-Transfer-Encoding: 8bit\r\n";
+		$returnpath = "-fbounce+tracker@w3.org";
+		mail($to, $subject, $message, $headers, $returnpath);
+	}
+}
+
+function createaction($returnType="html") {
+	global $wg;
+
+	$due = strtotime($_POST["due"]);
+	if ($due == -1) {
+		$due = strtotime("now");
+	}
+	$action = new Action();
+	// @@@ Get $userid when available
+	$userid= 0;
+	$productid=0;
+	$issueid=0;
+	if ($_POST["issue"]) {
+		$issueid=intval($_POST['issue']);
+	} else if ($_POST['product']) {
+		$productid=intval($_POST['product']);
+	}
+
+	if ($action->create($wg->id,$_POST["action"],$_POST["user"],$due,$issueid,$productid,$userid)) {
+		echo("<p>ACTION-".$action->id." added to database - <a href='actions/".$action->id."/edit'>edit</a>.</p>\n");
+
+		echo("<h1><a href=\"" .$wg->uribase. "actions/" . $action->id . "\">ACTION-" . $action->id . "</a></h1>\n");
+
+		displayactioninfo($action);
+	} else {
+		// @@@ error message
+		echo "<p>Action creation failed, please contact sysreq@w3.org.</p>";
+	}
+
+}
+
+
+function apinewaction() {
+	global $wg;
+
+	$due = strtotime($_POST["due"]);
+	if ($due == -1) {
+		$due = strtotime("now");
+	}
+
+	$action = new Action();
+	// @@@ Get $userid when available
+	$userid= 0;
+
+	if ($action->create($wg->id,$_POST["action"],$_POST["user"],$due,0,0,$userid)) {
+		echo "<?xml version='1.0' encoding='utf-8'?>";
+		echo $action->toXML();
+	} else {
+		echo("<error>\n");
+		echo("Action not created\n");
+		echo("</error>\n");
+	}
+}
+
+
+function apinoteaction() {
+	global $wg;
+
+	$action = new Action();
+	// @@@ Get $userid when available, eg if we have irc nick[s] to uid function
+	$userid= 0;
+	$action->load($_POST["action"],$wg->id);
+	if ($action->addUserNote($_POST["note"],$userid)) {
+		echo $action->toXML();
+	} else {
+		echo("<error>\n");
+		echo("No notes added to Action\n");
+		echo("</error>\n");
+	}
+}
+
+function apidueaction() {
+	global $wg;
+	$action = new Action();
+	// @@@ Get $userid when available, eg if we have irc nick[s] to uid function
+	$userid= 0;
+	$action->load($_POST["action"],$wg->id);
+	$due = strtotime($_POST["due"]);
+	if ($action->update(false,false,$due,false,false,false,$userid)) {
+		echo $action->toXML();
+	} else {
+		echo("<error>\n");
+		echo("Action due date not updated\n");
+		echo("</error>\n");
+	}
+}
+
+function apicloseaction() {
+	global $wg;
+
+	$action = new Action();
+	// @@@ Get $userid when available
+	$userid= 0;
+	$action->load($_POST["action"],$wg->id);
+	if ($action->close()) {
+		echo $action->toXML();
+	} else {
+		echo("<error>\n");
+		echo("Action not closed\n");
+		echo("</error>\n");
+	}
+}
+
+function apireopenaction() {
+	global $wg;
+
+	$action = new Action();
+	// @@@ Get $userid when available
+	$userid= 0;
+	$action->load($_POST["action"],$wg->id);
+	if ($action->reopen()) {
+		echo $action->toXML();
+	} else {
+		echo("<error>\n");
+		echo("Action not re-opened\n");
+		echo("</error>\n");
+	}
+}
+
+function apinewissue() {
+	global $wg;
+
+	$issue = new Issue();
+
+	if ($issue->create($wg->id,$_POST["issue"],0,'',0,'',0,$wg->defaultIssueState,0)) {
+		echo $issue->toXML();
+	} else {
+		echo("<error>\n");
+		echo("Issue not created\n");
+		echo("</error>\n");
+	}
+	
+	if ($to = $wg->mailinglist) {
+		$subject = "ISSUE-" . $issue->id .($issue->nickname ? " (".$issue->nickname.")" : "").": " . $issue->title.($issue->product->name ? " [".$issue->product->name."]" : "");
+		$message = $subject . "\n\n";
+		$message .=$wg->uribase. "issues/" . $issue->id . "\n\n";
+		$message .= "Raised by: " . $issue->owner->name . "\n";
+		$message .= "On product: " . $issue->product->name . "\n\n";
+		$message .= str_replace("\r\n", "\n", $issue->description) . "\n\n";
+		$headers = "From: " . $wg->name. " Issue Tracker <sysbot+tracker@w3.org>\r\n";
+		$headers .= "Reply-To: " . $wg->name. " WG <" . $wg->mailinglist . ">\r\n";
+		$headers .= "MIME-Version: 1.0\r\n";
+		$headers .= "Content-Type: text/plain; charset=utf-8\r\n";
+		$headers .= "Content-Transfer-Encoding: 8bit\r\n";
+		$returnpath = "-fbounce+tracker@w3.org";
+		mail($to, $subject, $message, $headers, $returnpath);
+	}	
+}
+
+
+function apinoteissue() {
+	global $wg;
+
+	$issue = new Issue();
+	// @@@ Get $userid when available, eg if we have irc nick[s] to uid function
+	$userid= 0;
+	$issue->load($_POST["issue"],$wg->id);
+	if ($issue->addUserNote($_POST["note"],$userid)) {
+		echo $issue->toXML();
+	} else {
+		echo("<error>\n");
+		echo("No notes added to Issue\n");
+		echo("</error>\n");
+	}
+}
+
+
+function updateissue() {
+	global $wg;
+	$issue = new Issue();
+	// @@@ a nicer HTTP interface would have the id in _GET, rather than _POST
+	$issue->load($_POST["id"],$wg->id);
+
+	$userid = $_SERVER["userid"];
+	$issue->update($_POST["title"],$_POST["raisedby"],$_POST["description"],$_POST["product"],$_POST["state"],$_POST["nickname"],$userid);
+
+	if ($_POST['notes'] != "") {
+		$issue->addUserNote($_POST['notes'],$userid);
+	}
+
+	_addTags($wg->id);
+	echo("<p><a href='".$wg->uribase."issues/".$issue->id."'>ISSUE-".$issue->id."</a> updated in database</p>\n");
+	editissue($issue);
+}
+
+
+function apicloseissue() {
+	global $wg;
+
+	$issue = new Issue();
+	// @@@ Get $userid when available
+	$userid= 0;
+	$issue->load($_POST["issue"],$wg->id);
+	if ($issue->close()) {
+		echo $issue->toXML();
+	} else {
+		echo("<error>\n");
+		echo("Issue not closed\n");
+		echo("</error>\n");
+	}
+}
+
+function apireopenissue() {
+	global $wg;
+
+	$issue = new Issue();
+	// @@@ Get $userid when available
+	$userid= 0;
+	$issue->load($_POST["issue"],$wg->id);
+	if ($issue->reopen()) {
+		echo $issue->toXML();
+	} else {
+		echo("<error>\n");
+		echo("Issue not re-opened\n");
+		echo("</error>\n");
+	}
+}
+
+
+function _addTags($wgid) {
+	if (array_key_exists('emailtag',$_POST) && is_array($_POST['emailtag'])) {
+		foreach($_POST['emailtag'] as $emailid=>$tag) {
+			$email = new Email($emailid,$wgid,'','','',0);
+			$email->addTag($tag);
+		}
+	}
+	if (array_key_exists('delemailtag',$_POST) && is_array($_POST['delemailtag'])) {
+		foreach($_POST['delemailtag'] as $emailid=>$tag) {
+			$email = new Email($emailid,$wgid,'','','',0);
+			$email->removeTag($tag);
+		}
+	}	
+	if (array_key_exists('notetag',$_POST) && is_array($_POST['notetag'])) {
+		foreach($_POST['notetag'] as $noteid=>$tag) {
+			$note = new Note($noteid,$wgid,"","");
+			$note->addTag($tag);
+		}
+	}
+	if (array_key_exists('delnotetag',$_POST) && is_array($_POST['delnotetag'])) {
+		foreach($_POST['delnotetag'] as $noteid=>$tag) {
+			$note = new Note($noteid,$wgid,"","");
+			$note->removeTag($tag);
+		}
+	}
+	
+}
+
+function updateaction() {
+	global $wg;
+	$due = strtotime($_POST["due"]);
+	if ($due == -1) {
+		$due = strtotime("now");
+	}
+	$action = new Action();
+	// @@@ a nicer HTTP interface would have the id in _GET, rather than _POST
+	$action->load($_POST["id"],$wg->id);
+
+	$userid = $_SERVER["userid"];
+	$productid=0;
+	$issueid=0;
+	if ($_POST["issue"]) {
+		$issueid=intval($_POST['issue']);
+	} else if ($_POST['product']) {
+		$productid=intval($_POST['product']);
+	}
+	$action->update($_POST["action"],$_POST["user"],$due,$_POST["state"],$issueid,$productid,$userid);
+	if ($_POST['notes'] != "") {
+		$action->addUserNote($_POST['notes'],$userid);
+	}
+	_addTags($wg->id);
+	
+	echo("<p><a href='".$wg->uribase."actions/".$action->id."'>ACTION-".$action->id."</a> updated in database</p>\n");
+
+
+
+	editaction($action);
+}
+
+function attachissueemail() {
+	global $wg;
+	$issue = new Issue();
+	// @@@ id of issue should come _GET, not _POST
+	$issue->load($_POST["id"],$wg->id);
+	// Dealing with bug in escaping in Apache for MID redirects
+	// See http://lists.w3.org/Archives/Team/sysreq/2007Jul/0430.html
+	$uri = $_POST["uri"];
+	$miduri = 'http://www.w3.org/mid/';
+	if (substr($uri,0,strlen($miduri))==$miduri) {
+		$uri = $miduri . urlencode(urlencode(str_replace($miduri,'',$uri)));
+	}
+	if ($issue->addEmail($uri,$_POST["subject"],$_POST["sender"],$_POST["date"])) {
+		echo("<p>Email added to database</p>\n");
+	} else {
+		// should return 500 @@@
+		echo "<p>Fail to add email to database.</p>\n" ;
+	}
+}
+
+function attachactionemail() {
+	global $wg;
+	$action = new Action();
+	// @@@ id of issue should come _GET, not _POST
+	$action->load($_POST["id"],$wg->id);
+	// Dealing with bug in escaping in Apache for MID redirects
+	// See http://lists.w3.org/Archives/Team/sysreq/2007Jul/0430.html
+	$uri = $_POST["uri"];
+	$miduri = 'http://www.w3.org/mid/';
+	if (substr($uri,0,strlen($miduri))==$miduri) {
+		$uri = $miduri . urlencode(urlencode(str_replace($miduri,'',$uri)));
+	}
+	if ($action->addEmail($uri,$_POST["subject"],$_POST["sender"],$_POST["date"])) {
+		echo("<p>Email added to database</p>\n");
+	} else {
+		// should return 500 @@@
+		echo "<p>Fail to add email to database.</p>\n" ;
+	}
+}
+
+
+function addresolution() {
+	global $wg;
+	$resolution = new Resolution();
+	if ($resolution->create($wg->id,$_POST["title"],$_POST["uri"],$_POST["made"])) {
+		echo("<p>Resolution added to database</p>\n");
+	} else {
+		// @@@ returns 500
+		echo "<p>Failed to add resolution to database.</p>\n" ;
+	}
+}
+
+
+
+function showsummary() {
+	global $wg;
+	$issues = $wg->listIssues();
+	if (substr_count($wg->issueProcess,'R')) {
+		$raisedissues = $wg->listIssues(RAISED);
+	}
+	$openissues = $wg->listIssues(OPEN);
+	if (substr_count($wg->issueProcess,'P')) {
+		$pendingissues = $wg->listIssues(PENDINGREVIEW);
+	}
+	$actions = $wg->listActionItems();
+	$openactions = $wg->listActionItems(OPEN);
+	$overdueactions = $wg->listActionItems(OPEN,true);
+	$users = $wg->listMembers();
+	$products = $wg->listProducts();
+
+	echo("<h2>Issues</h2>\n"); 
+	echo "<!-- DBG: ".$wg->defaultIssueState."-->\n";
+	echo("<ul>\n");
+	if (substr_count($wg->issueProcess,'R')) {
+        echo("<li><a href=\"issues/raised\">Raised Issues</a>: ". count($raisedissues->list) . "</li>\n");
+	}
+	echo("<li><a href=\"issues/open\">Open Issues</a>: ". count($openissues->list) . "</li>\n");
+	if (substr_count($wg->issueProcess,'P')) {
+		echo("<li><a href=\"issues/pendingreview\">Pending Review Issues</a>: ". 
+		count($pendingissues->list) . "</li>\n");
+	}
+	echo("<li><a href=\"issues\">Total Issues</a>: " . count($issues->list) . "</li>\n");
+	echo("</ul>\n");
+
+	echo("<h2>Actions</h2>\n");
+	echo("<ul>\n");
+	echo("<li><a href=\"actions/open\">Open Actions</a>: ". count($openactions->list) . "</li>\n");
+	echo("<li><a href=\"actions/overdue\">Overdue Actions</a>: ". count($overdueactions->list) . "</li>\n");
+	echo("<li><a href=\"actions\">Total Actions</a>: " . count($actions->list) . "</li>\n");
+	echo("</ul>\n");
+
+
+
+	echo("<h2>Users</h2>\n");
+	if (count($users)) {
+		echo("<ul>\n");
+		foreach ($users as $user) {
+			$useractions = $user->listActionItems($wg->id);
+			$useroverdueactions = $user->listActionItems($wg->id,true);
+			echo "<li><a href='users/".$user->id."'>".htmlify($user->name)."</a> has ".count($useractions->list)." actions";
+			if (count($useroverdueactions->list)) {
+				echo " (<span class=\"overdue\">" . count($useroverdueactions->list)." overdue</span>)";
+			}
+			echo "</li>\n" ;
+		}
+		echo("</ul>\n");
+	} else {
+		echo("<p>No users</p>\n");
+	}
+
+	echo("<h2>Products</h2>\n");
+
+	if (count($products->list) == 0) {
+		echo("<p>No known products.</p>\n");
+	} else {
+		echo("<ul>\n");
+		foreach ($products->list as $product) {
+			echo("<li><a href=\"products/" . $product->id . "\">" . htmlify($product->name) . "</a></li>\n");
+		}
+		echo("</ul>\n");
+	}
+
+}
+
+
+function apiusers() {
+	global $wg;
+	$users = $wg->listMembers();
+	echo("<users>\n");
+	if (is_array($users)) {
+		foreach ($users as $user ) {
+			echo $user->toXML();
+		}
+	}
+	echo("</users>\n");
+}
+
+
+function apiissues($full=false) {
+	global $wg;
+	$issues = $wg->listIssues();
+	echo("<issues>\n");
+	foreach ($issues->list as $issue) {
+		echo $issue->toXML($full);
+	}
+	echo("</issues>\n");
+}
+
+function apiactions($full=false) {
+	global $wg;
+	$actions = $wg->listActionItems();
+	echo("<actions>\n");
+	foreach ($actions->list as $action) {
+		echo $action->toXML($full);
+	}
+	echo("</actions>\n");
+}
+
+
+function xmlrow($name, $row) {
+	echo("<" . $name . ">" . iconv('ISO-8859-1','utf-8',htmlify(unescape($row[$name]))) . "</" . $name . ">\n");
+}
+
+function apidump($full=false) {
+	echo("<database>\n");
+	apiusers();
+	apiissues($full);
+	apiactions($full);
+	echo("</database>\n");
+}
+
+// show the products
+
+function showproducts() {
+	global $wg;
+	$products = $wg->listProducts();
+
+	echo("<p>There are ". count($products->list) . " products listed in the system.</p>\n");
+	?>
+
+<table>
+	<thead>
+		<tr>
+			<th>ID</th>
+			<th>Name</th>
+			<th>Open Issues</th>
+			<th>Open Actions</th>
+			<th>Associated mailing list</th>
+		</tr>
+	</thead>
+	<tbody>
+	<?php
+	$count = 0;
+	foreach($products->list as $product) {
+		echo("<tr class=\"");
+		if ($count % 2 == 0) {
+			echo("even\">");
+		} else {
+			echo("odd\">");
+		}
+		$count++;
+		echo "<td>".$product->id."
+		<a href='".$wg->uribase."products/".$product->id."/edit' title='Edit Product ".$product->id."'>
+		<img width='8' height='12' src='/2002/09/wbs/icons/stock_edit2' alt=' (edit)'  />
+		</a></td>\n" ;
+		echo "<td><a href='".$wg->uribase."products/".$product->id."'>".htmlify($product->name)."</a></td>\n" ;
+
+		$issues = $product->listIssues(array(RAISED,OPEN,PENDINGREVIEW));
+		echo("<td>" . count($issues->list) . "</td>\n");
+		$actions = $product->listActionItems();
+		echo("<td>" . count($actions->list) . "</td>\n");
+		echo "<td>".($product->mailinglist ? htmlify($product->mailinglist) : "Group default")."</td>\n";
+		echo("</tr>\n");
+	}
+	?>
+	</tbody>
+</table>
+	<?php
+		echo "<p><a href='products/new'>Add a new product</a></p>";
+}
+
+function showresolutions() {
+	global $wg;
+	$resolutions = $wg->listResolutions();
+
+	echo("<p>There are ". count($resolutions->list) . " resolutions listed in the system.</p>\n");
+	if (count($resolutions->list)) {
+		echo("<ol>\n");
+		foreach($resolutions->list as $resolution) {
+			echo("<li>");
+			echo("<a href=\"". $resolution->uri . "\">" . htmlify($resolution->title) . "</a> (on " . gmdate("Y-m-d",$resolution->made) . ")");
+			echo("</li>");
+		}
+		echo("</ol>\n");
+	}
+}
+
+// initialise some things
+//getproducts();
+//getusers();
+
+$do = $_REQUEST["do"];
+if ($do == "") {
+	$do = "summary";
+}
+
+$state = (array_key_exists('state',$_REQUEST) ? $_REQUEST["state"] : null);
+$id = (array_key_exists('id',$_REQUEST) ? $_REQUEST["id"]: null);
+$user = (array_key_exists('user',$_REQUEST) ? $_REQUEST["user"]: null);
+
+$product = (array_key_exists('product',$_REQUEST) ? $_REQUEST["product"]: null);
+
+if ($do == "icalendar") {
+	header("Content-Type: text/calendar");
+
+	showusercalendar($user);
+
+} else if ($do == "apidump") {
+	header("Content-Type: application/xml");
+	apidump(array_key_exists("full",$_GET));
+
+} else if ($do == "apiusers") {
+	header("Content-Type: application/xml");
+	apiusers();
+
+} else if ($do == "apinewaction") {
+	header("Content-Type: application/xml");
+	apinewaction();
+} else if ($do == "apinewissue") {
+	header("Content-Type: application/xml");
+	apinewissue();
+} else if ($do == "apicloseaction") {
+	header("Content-Type: application/xml");
+	apicloseaction();
+} else if ($do == "apireopenaction") {
+	header("Content-Type: application/xml");
+	apireopenaction();
+} else if ($do == "apicloseissue") {
+	header("Content-Type: application/xml");
+	apicloseissue();
+} else if ($do == "apireopenissue") {
+	header("Content-Type: application/xml");
+	apireopenissue();	
+} else if ($do == "apinoteaction") {
+	header("Content-Type: application/xml");
+	apinoteaction();
+} else if ($do == "apinoteissue") {
+	header("Content-Type: application/xml");
+	apinoteissue();
+} else if ($do == "apidueaction") {
+	header("Content-Type: application/xml");
+	apidueaction();	
+} else if ($do == "apiaction") {
+	header("Content-Type: application/xml");
+	apiaction($id);
+
+} else if ($do == "apiissue") {
+	header("Content-Type: application/xml");
+	apiissue($id);
+
+} else if ($do == "apiresolution") {
+	header("Content-Type: application/xml");
+	apiresolution($id);
+
+} else {
+
+	$require_auth = array("newissue","newaction","newproduct","editaction","editissue","updateissue","updateaction","createissue","createproduct","createaction");
+	// TODO Needed for mailwatch; probably ought to require Team-IP Auth
+	$skip_auth = array("attachissueemail","attachactionemail","addresolution");
+	if (in_array($do,$require_auth)) {
+		$logonid = checkCredentials();
+		$_SERVER["userid"] = $logonid; 
+		$u = new TrackerUser(0,$logonid);
+		$_SERVER["userid"] = $u->id;
+		if (!$u->isMemberOf($wg->id) && !$u->isMemberOf(109)) {
+			WriteErrorpage("Unauthorized","Editing is restricted to participants to the ".htmlify($wg->name),"",403);
+			exit();
+		}
+	} else {
+		$feed = '';
+		if (!in_array($do,$skip_auth) ) {			 
+			// TODO Take into acount IP Address? $GLOBALS['REMOTE_ADDR']
+			if ($wg->acls!='public') {
+				$logonid = checkCredentials();
+				$u = new TrackerUser(0,$logonid);
+				$_SERVER["userid"] = $u->id;
+				if ($wg->acls=='team' && !$u->isMemberOf(102) && !$u->isMemberOf($wg->id)) {
+					WriteErrorpage("Unauthorized","These pages are restricted to the W3C Staff.","",403);
+					exit();
+				} else if ($wg->acls=='member' && !$u->isMemberOf(105) && !$u->isMemberOf($wg->id)) {
+					WriteErrorpage("Unauthorized","These pages are restricted to W3C Members.","",403);
+					exit();
+				}
+			}
+		}
+	}
+
+	if ($do == "issuefeed") {
+		header("Content-Type: application/atom+xml");
+		displayissuechangelogasatom($id);
+		exit();
+	}
+	header("Cache-Control: no-cache");
+	header("Content-Type: text/html; charset=utf-8");
+	$title = "";
+	if ($do == "issues" or $do == "actions") {
+		if ($state == "open") {
+			$title.="Open ";
+		} else if ($state == "closed") {
+			$title.="Closed ";
+		} else if ($state == "pendingreview") {
+			$title.="Pending Review ";
+		}
+	}
+	if ($do == "actions" and $state == "overdue") {
+		$title.="Overdue ";
+	}
+	$title.=pagetitle($do);
+	if ($do == "showissue" || $do == "editissue" ) {
+		$issue = new Issue();
+		if (!$issue->load($id,$wg->id)) {
+			WriteErrorpage("Not found","No issue with id $id found for the ".$wg->name,"",404);
+			exit();	
+		}		
+		$title.="-" . $id.": ".htmlify($issue->title);
+	}
+	 if ($do == "showaction" || $do == "editaction") {
+		$action = new Action();
+		$action->load($id,$wg->id);
+		if (!$action->load($id,$wg->id)) {
+			WriteErrorpage("Not found","No action with id $id found for the ".$wg->name,"",404);
+			exit();	
+		}		
+		$title.="-" . $id.": ".htmlify($action->title);
+	 }
+	
+	if ($do == "showproduct" and $product != "") {
+		$p = new Product();
+	        if (!$p->load($product,$wg->id)) {
+		        WriteErrorpage("Not found","No product with id $product found for the ".$wg->name,"",404);
+			exit();
+		}
+		$title.= " ". htmlify($p->name);
+	}
+	if ($do == "actions" and $user != "") {
+		$u = new TrackerUser($user);
+		$title.=" on " . htmlify($u->name);
+	}
+        $operation = array();
+	switch($do) {
+		case 'showissue':
+			$operation = array('link'=>$wg->uribase.'issues/'.$id.'/edit','text'=>"Edit this issue");
+			$feed=$wg->uribase.'issues/'.$id.'.atom';
+			break;
+		case 'updateaction':
+		case 'showaction':
+			$operation = array('link'=>$wg->uribase.'actions/'.$id.'/edit','text'=>"Edit this action");
+			break;
+		case 'showproduct':
+			if (intval($product)) {
+				$operation = array('link'=>$wg->uribase.'products/'.$product.'/edit','text'=>"Edit this product");
+			}
+			break;
+	}
+
+	WriteHTMLTop($title,&$wg,$operation,array(),0,$feed);
+
+
+	if ($do == "products") {
+		showproducts();
+	} else if ($do == "showproduct" && intval($product)) {
+		$status = array(OPEN,RAISED,PENDINGREVIEW);
+		$issuequalifier="Open, Raised and Pending Review";
+		$actionqualifier="Open";
+		if ($_GET['status']=='all') {
+			$status = ANY;
+			$issuequalifier="All";
+			$actionqualifier="All";
+		}		
+		echo "<h2>".$issuequalifier." Issues</h2>\n";
+		echo "<p>New issues for this product are notified to "
+		     .htmlify($product->mailinglist ? $product->mailinglist : $wg->mailinglist)
+		     ." (ask <a href='mailto:sysreq@w3.org'>sysreq@w3.org</a> to set it to a different list).</p>";
+		showissues($status,$product);
+		echo "<h2>".$actionqualifier." Actions</h2>\n";
+		showactions($status,false,$product);
+		if ($_GET['status']=='all') {
+			echo "<p>See only <a href='".htmlify($wg->uribase)."products/".$product."'>open and raised issues and actions</a>.</p>";
+		} else {
+			echo "<p>See <a href='".htmlify($wg->uribase)."products/".$product."/all'>all issues and actions for this product</a>.</p>";
+		}
+	} else if ($do == "newproduct") {
+		newproduct();
+	} else if ($do == "createproduct") {
+		createproduct();
+	} else if ($do == "showissue") {
+		$showchangelog=isset($_GET["changelog"]);
+		displayissue($issue,$showchangelog);
+	} else if ($do == "editissue") {
+		editissue($issue);
+	} else if ($do == "updateissue") {
+		updateissue();
+	} else if ($do == "newissue") {
+		newissue();
+	} else if ($do == "createissue") {
+		createissue();
+	} else if ($do == "showaction") {
+		$showchangelog=isset($_GET["changelog"]);
+		displayaction($action,$showchangelog);
+	} else if ($do == "editaction") {
+		editaction($action);
+	} else if ($do == "updateaction") {
+		updateaction();
+	} else if ($do == "newaction") {
+		newaction();
+	} else if ($do == "createaction") {
+		createaction();
+	} else if ($do == "newemail") {
+		newemail();
+	} else if ($do == "attachissueemail") {
+		attachissueemail();
+	} else if ($do == "attachactionemail") {
+		attachactionemail();
+	} else if ($do == "addresolution") {
+		addresolution();
+	} else if ($do == "resolutions") {
+		showresolutions($id);
+	} else {
+		showsummary();
+	}
+
+
+	WriteHTMLFoot('$Id: index.php,v 1.275 2010/11/21 17:14:38 dom Exp $',$wg);
+}
+
+?>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/issues.php	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,160 @@
+<?php
+/**
+ * HTTP interface to Tracker - list of issues
+ * @package Tracker
+ */
+
+ require_once("objects.phi");
+ require_once("trackerlib.phi");
+ lastModificationTime(filemtime(__FILE__));
+
+// WGid parameter set in URI
+ $wgid = $_GET["wgid"];
+ $wg = new TrackerWorkingGroup($wgid);
+ if (!$wg->loadConfig()) {
+ 	header("404 Not found");
+ 	header("Content-Type: text/plain");
+ 	echo "No config found for group $wgid";
+ 	exit();
+ }
+ $u = false;
+ // Checking access control
+ if ($wg->acls!='public') {
+ 	$logonid = checkCredentials();
+ 	$u = new TrackerUser(0,$logonid);
+ 	$_SERVER["userid"] = $u->id;
+ 	if ($wg->acls=='team' && !$u->isMemberOf(102)&&  !$u->isMemberOf($wg->id)) {
+ 		WriteErrorpage("Unauthorized","These pages are restricted to the W3C Staff.","",403);
+ 		exit();
+ 	} else if ($wg->acls=='member' && !$u->isMemberOf(105)&&  !$u->isMemberOf($wg->id)) {
+ 		WriteErrorpage("Unauthorized","These pages are restricted to W3C Members.","",403);
+ 		exit();
+ 	}
+ }
+
+ $changes = "";
+ if ($_SERVER["REQUEST_METHOD"]=="POST" && array_key_exists("issues",$_POST)
+ // checking at least one action was selected
+ && is_array($_POST['issues']) && count($_POST['issues'])
+ // Checking at least one operation was requested
+ && ($_POST["status"]!=-1 || $_POST["product"]!=-1)) {
+ 		// Checking credentials
+ 		// Only WG participants are allowed
+ 	if (!$u) {
+ 		$logonid = checkCredentials();
+ 		$u = new TrackerUser(0,$logonid);
+ 		$_SERVER["userid"] = $u->id;
+ 	}
+ 	if (!$u->isMemberOf($wgid)) {
+ 		WriteErrorpage("Unauthorized","Editing issuess is reserved to the participants
+ 		in the ".htmlify($wg->name).".","",403);
+ 		exit();
+ 	}
+ 	$newstatus = false;
+ 	$newproductid = false;
+ 	$changelog = array();
+ 	$editedissues = array();
+ 	if (intval($_POST["status"])!=-1) {
+ 		$newstatus = intval($_POST["status"]);
+ 		$changelog[]="have their status now set to ".$humanReadableState[$newstatus];
+ 		 
+ 	}
+ 	if (intval($_POST["product"])!=-1) {
+ 		$newproductid =$_POST["product"];
+ 		$p = new Product();
+ 		if (!$p->load($newproductid,$wg->id)) {
+ 			$newproductid = false;
+ 		} else {
+ 			$changelog[]="are associated with product &quot;".htmlify($p->name)."&quot;";
+ 		}
+ 	}
+ 	foreach (array_keys($_POST["issues"]) as $issueid) {
+ 		$issue = new Issue();
+ 		$issue->load($issueid,$wg->id);
+ 		if ($issue->update(false,false,false,$newproductid,$newstatus,false,$u->id)) {
+ 			$editedissues[]=$issue->id;
+ 		}
+  	}
+  	$changes = "ISSUE-".implode(', ISSUE-',$editedissues)." ".implode(', ',$changelog).".";
+ }
+ 
+ $status = ANY;
+ if (array_key_exists('state',$_GET)) {
+ 	switch($_GET['state']) {
+ 		case 'open':
+ 			$status = OPEN;
+ 			break;
+ 		case 'raised':
+ 			$status = RAISED;
+ 			break;
+ 		case 'closed':
+ 			$status = CLOSED;
+ 			break;
+ 		case 'pendingreview':
+ 			$status = PENDINGREVIEW;
+ 			break;
+ 		case 'postponed':
+ 			$status = POSTPONED;
+ 			break;
+ 		default:
+ 			$status = ANY;
+ 	}
+ }
+  
+ $editRights = ($u ? $u->isMemberOf($wg->wgid) : true);
+ 	
+ $qualifier = $humanReadableState[$status]; 
+ 
+ // Output starts
+ WriteHTMLTop(ucfirst($qualifier)." Issues",&$wg);
+ 	if ($changes) {
+ 		echo "<p class='info'>$changes</p>";
+ 	}
+
+	$issues = $wg->listIssues($status);
+	$sort = $_GET["sort"];
+	switch($sort) {
+		case 'status':
+			$issues->sortByProperty('status');
+			break;
+		case 'product':
+			$issues->sortByProperty('product','name');
+			break;
+		default:
+			$sort='id';			
+	}
+	echo "<form action='' method='post'>";
+	?>
+	<fieldset class='actions'>
+	<legend>Apply the following changes to selected issues:</legend>
+<ul><li><label>Mark as <select name='status'>
+<option value='-1'>No status change</option>
+<option value='<?php echo constant("CLOSED");?>'>Closed</option>
+<option value='<?php echo constant("OPEN");?>'>Open</option>
+<? if (strstr($wg->issueProcess,'R')) { ?>
+<option value='<?php echo constant("RAISED");?>'>Raised</option>
+<? }
+   if (strstr($wg->issueProcess,'P')) { ?>
+<option value='<?php echo constant("PENDINGREVIEW");?>'>Pending Review</option>
+<? }
+if (strstr($wg->issueProcess,'T')) { ?>
+<option value='<?php echo constant("POSTPONED");?>'>Postponed</option>
+<? } ?>
+</select></label></li>
+<li><label>Associate to product: <select name='product'>
+<option value='-1'>No change</option>
+<?php
+$products = $wg->listProducts();
+foreach($products->list as $p) {
+	echo "<option title='".htmlify($p->name)."' value='".$p->id."'>".elipsize(htmlify($p->name),35)."</option>\n";
+}
+?></select></label></li>
+</ul>
+<p><input type='submit' value='Apply' /></p>
+</fieldset>
+<?php
+	displayissueslist($issues,$qualifier,true,$sort,$editRights);
+	echo "</form>";
+	echo "<script type='text/javascript'>addSelectAllButtons('issues')</script>\n";
+	echo "<p><a href='". $wg->uribase. "issues/new'>Raise an issue </a>.</p>";
+WriteHTMLFoot('$Id: issues.php,v 1.10 2009/08/06 13:54:53 dom Exp $',$wg);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/noedit.html	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,22 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml"  xml:lang="en" lang="en">
+<head>
+  <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
+  <link rel="stylesheet" type="text/css" href="http://www.w3.org/2005/06/tracker/webui.css" media="screen" />
+<title>Closed Tracker</title>
+<script type='text/javascript' src='/2007/08/datepicker/js/datepicker.js'></script>
+<script src='http://www.w3.org/2005/06/tracker/ui.js' type='text/javascript'></script>
+<link href='/2007/08/datepicker/css/datepicker.css' rel='stylesheet' type='text/css' />
+</head>
+<body>
+<div id="content">
+<h1>Closed Tracker</h1>
+
+<p>This tracker instance is no longer active and can thus no longer be edited through the Web interface.</p>
+<hr />
+<address>
+<a href="/2005/06/tracker/">Tracker</a>, originally developed by <a href='/People/Dean/'>Dean Jackson</a>, is developed and maintained by the Systems Team &lt;<a href='mailto:w3t-sys@w3.org'>w3t-sys@w3.org</a>&gt;.<br />
+$Id: noedit.html,v 1.1 2009/09/10 13:54:41 dom Exp $</address>
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/objects.phi	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,1943 @@
+<?php
+/**
+ * This library defines classes that are used for Tracker
+ * @package Tracker
+ * @version $Id: objects.phi,v 1.293 2010/10/20 15:58:09 dom Exp $
+ * @author Dominique Hazael-Massieux, Ted Guild, Vivien Lacourba
+*/
+
+
+define("DB","tracker");
+require_once("/afs/w3.org/pub/WWW/2002/09/wbs/common.phi");
+require_once("/afs/w3.org/pub/WWW/2002/09/wbs/user.phi");
+require_once("/afs/w3.org/pub/WWW/2004/01/pp-impl/wg.phi");
+
+// @@@ Can constants be defined as part of a class?
+// not before php5
+/**
+* Open state for action/issues
+*/
+define("OPEN",0);
+/**
+* Closed state for action/issues
+*/
+define("CLOSED",1);
+/**
+* PendingReview state for action
+*/
+define("PENDINGREVIEW",2);
+/**
+ * Raised state for issues
+ */
+define("RAISED",4);
+/**
+ * Raised state for issues
+ */
+define("POSTPONED",5);
+/**
+ * Any state for action/issues
+*/
+define("ANY",-1);
+
+$humanReadableState=array(OPEN=>"open",CLOSED=>"closed",PENDINGREVIEW=>"pending review",RAISED=>"raised",POSTPONED=>"postponed",ANY=>"");
+
+/**
+* A derivate of WorkingGroup that has tracker specific methods 
+*/
+class TrackerWorkingGroup extends WorkingGroup {
+  var $uribase;
+  /**
+   * Mailing list to which new issues are announced
+   *
+   * @var string
+   */
+  var $mailinglist;
+  var $acls; 
+
+  const aclsTeam='team';
+  const aclsMember='member';
+  const aclsPublic='public';
+  
+  /**
+   * Issue process:
+   * - "ROC": Raised/Open/Closed
+   * - "OC" : Open/Closed
+   * - "PTROC" : Pending/posTponed/Raised/Open/Closed
+   * @var string	
+   */
+  var $issueProcess;
+  
+  /**
+   * Whether raising an action should send a mail to its owner by default
+   *
+   * @var boolean
+   */
+  var $notifyNewAction;
+  
+  const notifyByDefault = "yes";
+  const doNotNotifyByDefault = "no";
+  
+  /**
+   * State of a new issue by default
+   *
+   * @var int
+   */
+  var $defaultIssueState;
+  
+  var $_products;
+  var $_actions = array();
+  var $_issues = array();
+
+  var $_trackermembers;
+
+  var $watchedlists = array();
+  var $ircchannel ;
+  var $conferenceid;
+  var $minutesacls ;
+  
+  /**
+   * @return ActionList
+   * 
+   * @param int $status
+   * @param boolean $overdueonly
+   * @param int $duedatelimit UNIX Timestamp of due dates of action items that should be included
+   * @param array $changelog_window array with 1st entry being the low end, 2nd entry the high end of the window to be filtered out
+  */
+  function listActionItems($status=ANY,$overdueonly=false,$duedatelimit=false,$changelog_window=array()) {
+  	$status_key = $status;
+  	if (is_array($status)) {
+  		sort($status);
+  		$status_key = join('_',$status);
+  	}
+        if (!array_key_exists($status_key,$this->_actions) || !array_key_exists(serialize($overdueonly),$this->_actions[$status_key])) {
+      $actions = new ActionList();
+      $filter = array("wgid"=>$this->id,"status"=>$status);
+      if ($overdueonly) {
+        $filter["duedate"]=time();
+        $filter["status"]=OPEN;
+      } else if ($duedatelimit) {
+      	$filter["duedate"]=array(time(),$duedatelimit);
+        $filter["status"]=OPEN;
+      } else if (count($changelog_window)==2) {
+      	$filter["changed"]=$changelog_window;
+      }
+      $actions->load($filter);
+      $this->_actions[$status_key][$overdueonly] = $actions;
+    }
+    return $this->_actions[$status_key][$overdueonly];
+  }
+  
+  function listUnboundActionItems($status=ANY,$overdueonly=false,$duedatelimit=false) {
+      $actions = new ActionList();
+      $filter = array("wgid"=>$this->id,"status"=>$status);
+      if ($overdueonly) {
+        $filter["duedate"]=time();
+        $filter["status"]=OPEN;
+      } else if ($duedatelimit) {
+      	$filter["duedate"]=array(time(),$duedatelimit);
+        $filter["status"]=OPEN;
+      }
+      $filter["issue"]=0;
+      $actions->load($filter);  	
+  }
+  
+  /**
+   * @return ProductList
+  */
+  function listProducts() {
+    if (!isset($this->_products)) {
+      $this->_products = new ProductList();
+      $filter = array("wgid"=>$this->id);
+      $this->_products->load($filter);
+    }
+    return $this->_products;
+  }
+
+  /**
+   * @return ResolutionList
+  */
+  function listResolutions() {
+    if (!isset($this->_resolutions)) {
+      $this->_resolutions = new ResolutionList();
+      $filter = array("wgid"=>$this->id);
+      $this->_resolutions->load($filter);
+    }
+    return $this->_resolutions;
+  }
+
+  /**
+   * @return IssueList
+   * @param int $status Status of issues to be filtered
+   * @param boolean $discussedrecently Only return issues on which emails have been sent recently (one week)
+   * @param array $changelog_window array with 1st entry being the low end, 2nd entry the high end of the window to be filtered out
+  */
+  function listIssues($status=ANY,$discussedrecently=false,$changelog_window=array()) {
+  	$statusKey = "";
+    if (is_array($status)) {
+      $statusKey = implode('_',$status);
+    } else {
+      $statusKey = $status;
+    }
+  	
+      $issues = new IssueList();
+      $filter = array("wgid"=>$this->id,"status"=>$status);
+      if ($discussedrecently) {
+      	$filter["discussed"] = time()-7*3600*24;
+      }
+   	  if (count($changelog_window)==2) {
+      	$filter["changed"]=$changelog_window;
+      }
+      $issues->load($filter);
+      return $issues;
+  }
+
+  function listOrphelinIssues() {
+    $issues = new IssueList();
+    $filter = array("wgid"=>$this->id,"status"=>array(OPEN,PENDINGREVIEW),"productid"=>false);
+    $issues->load($filter);
+    return $issues;
+  	
+  }
+  
+  function listMembers() {
+    if (!$this->_trackermembers) {
+      $users = parent::listMembers();
+      foreach ($users as $user) {
+        $this->_trackermembers[] = $_SERVER["Application"]->objectFactory("TrackerUser",$user->id);
+      }
+    }
+    return $this->_trackermembers;
+  }
+
+  function loadConfig() {
+    $data = $this->_getFromDb("SELECT uribase, mailinglist, trackeracls, "
+    ."issueProcess, notifyNewAction, UNIX_TIMESTAMP(last) FROM config WHERE wgid=".$this->id);
+    if (is_array($data) && count($data)) {
+      $this->uribase = $data[0];
+      $this->mailinglist = $data[1];
+      $this->acls = $data[2];
+      $this->issueProcess = $data[3];
+      if (substr_count($this->issueProcess,"R")) {
+      	$this->defaultIssueState = RAISED;
+      } else {
+      	$this->defaultIssueState = OPEN;
+      }
+      $this->notifyNewAction = $data[4];
+      return true;
+    } else {
+      return false;
+    }
+  }
+
+  function loadExtraConfig() {
+  	$data = $this->_getFromDb("SELECT ircchannel, conferenceid, watchedlist, defaultminacls, UNIX_TIMESTAMP(c.last)
+  	 FROM config c LEFT OUTER JOIn watchedmailinglists w ON w.wgid=c.wgid WHERE c.wgid=".$this->id);
+  	if (!is_array($data[0])) {
+  		$data = array($data);
+  	}
+  	foreach ($data as $row) {
+  		$this->ircchannel = $row[0];
+  		$this->conferenceid = $row[1];
+  		$this->watchedlists[] = $row[2];
+  		$this->minutesacls = $row[3];
+  	}
+  }
+}
+
+/**
+ * A derivate of User that deals with Tracker-specific features
+*/
+class TrackerUser extends User {
+  var $_nicks;
+  const notifyDefault = 'wgdefault';
+  const notifyNever = 'never';
+  const notifyAlways = 'always'; 
+  
+
+  function wantsNewActionNotif($wgid) {
+  	$notif = $this->_getFromDb("SELECT notifyNewAction, UNIX_TIMESTAMP(last) "
+  	." FROM user_config_per_group"
+  	." WHERE uid=".(int) $this->id. " AND wgid=".(int) $wgid);
+  	return ($notif ? $notif : self::notifyDefault);
+  }
+  
+  /**
+   * returns array of the registered nicks for the user
+   * @return array
+  */
+  function listIRCNicks() {
+    if (!is_array($this->_nicks)) {
+      $this->_nicks = array();
+      $cursor = SQLquery("SELECT nick,UNIX_TIMESTAMP(last) AS last FROM ircnicks WHERE uid=".$this->id);
+      if ($cursor) {
+        while($row = mysql_fetch_array($cursor)) {
+          $this->_nicks[]=$row["nick"];
+          lastModificationTime($row["last"]);
+        }
+      }
+    }
+    return $this->_nicks;
+  }
+
+  /**
+   * @return ActionList
+   */
+  function listActionItems($wgid=0,$overdue=false,$status=array(OPEN,PENDINGREVIEW),
+  $duemax=0,$duemin=0) {
+    $actions = new ActionList();
+    $filter = array("ownerid"=>$this->id,"status"=>$status);
+    if ($overdue) {
+      $filter["duedate"]=time();
+      $filter["status"]=OPEN;
+    } else if ($duemax || $duemin) {
+    	$filter["duedate"]=array($duemin,$duemax);
+    }
+    if ($wgid) {
+      $filter["wgid"]=$wgid;
+    }
+    $actions->load($filter,array("due"));
+    return $actions;
+  }
+
+  
+  /**
+   * @return IssueList
+   */
+  function listIssues($wgid=0) {
+    $issues = new IssueList();
+    $filter = array("ownerid"=>$this->id,"status"=>array(OPEN,RAISED));
+    if ($wgid) {
+      $filter["wgid"]=$wgid;
+    }
+    $issues->load($filter);
+    return $issues;
+  }
+
+  /**
+   * Return array of tracker groups of which the user is a member
+   *
+   */
+  function listGroups() {
+  	$groups = array();
+  	$data = $this->_getFromDb("SELECT wgid,UNIX_TIMESTAMP(config.last)
+  	     FROM config, w3c.idInclusions i, w3c.wgDetails w WHERE wgid=i.groupId AND w.id=i.groupId 
+  	     AND NOT (w.statusBits & 1) AND i.id=".$this->id);
+  	if (!is_array($data) && $data) {
+  		$data = array($data);
+  	}
+  	if(is_array($data)) {
+  	foreach ($data as $wgid) {
+		$wg = new TrackerWorkingGroup($wgid);
+		$wg->loadConfig();
+		$groups[]=$wg;
+  	}
+  	}
+  	return $groups;
+  }
+  
+  
+  /**
+   * Returns a representation in XML of the data known about the object
+   * used in particular to create a Web service interface to tracker
+   * @return string
+  */
+  function toXML() {
+    $xml = "<user>\n" ;
+    $xml .= "<id>".$this->id."</id>\n" ;
+    $xml .= "<givenname>".$this->given."</givenname>\n" ;
+    $xml .= "<familyname>".$this->family."</familyname>\n" ;
+    $xml .= "<username>".$this->login."</username>\n" ;
+    $xml .= "<aliases>".implode(" ",$this->listIRCNicks())."</aliases>\n" ;
+    $xml .= "</user>\n" ;
+    return $xml;
+
+  }
+
+  /**
+   * Add a nick to the list of known nicknames for the user
+   *
+   * @param string $nick
+   * @return boolean successful operation
+   */
+  function addNick($nick) {
+  	if (SQLcmd("INSERT INTO ircnicks(uid,nick) VALUES (".$this->id.",'".addslashes($nick)."')")) {
+  		$this->_nicks[]=$nick;
+  		return true;
+  	}
+  	return false;
+  }
+  
+  /**
+   * Update the user setting for email notification for new actions
+   *
+   * @param int $wgid
+   * @param string $level ('always','never','wgdefault')
+   */
+  function setEmailNotifPref($wgid, $level) {
+  	if (SQLcmd("REPLACE INTO user_config_per_group "
+  			. "(uid, wgid, notifyNewAction) VALUES "
+  			."(".(int) $this->id.","
+  			.(int) $wgid.","
+  			."'".mysql_real_escape_string($level)."')")) {
+  		return true;
+  	}
+  	return false;  	
+  }
+}
+
+/**
+* Base object for the tracker objects
+* @abstract
+*/
+class TrackerBaseObject extends BaseObject {
+   var $_readableStatus = array(OPEN=>"open",CLOSED=>"closed",PENDINGREVIEW=>"pending review",RAISED=>"raised",POSTPONED=>"postponed");
+   var $_type ;
+
+   /**
+   * @access protected
+   */
+  function _xmlRow($property,$elementname="") {
+    if (!$elementname) {
+      $elementname=$property;
+    }
+    switch(gettype($this->$property)) {
+    case "object":
+      // @@@ is this generic enough?
+      $value=$this->$property->id;
+      break;
+    default:
+      $value=$this->$property;
+    }
+    return "<$elementname>".htmlify($value)."</$elementname>\n";
+  }
+
+  /**
+   * Sets the properties of the object based on an associative array
+   * resulting from an SQL query.
+   * @param array $data Associative array taken from SQL query
+   * @access protected
+   * @abstract
+   */
+  function _setData($data) {
+  // Override
+  }
+
+  /**
+   * Builds an associative array or an array of associative arrays with data to build objects out of an SQL query
+   * @param int $cursor MySQL results handle
+   * @param boolean $uniquerow Whether a single row of data is expected (to build a unique object vs a list of objects)
+   * @return array Associate array with results of the query
+   * @access protected
+   */
+  function _buildDataFromMysqlCursor($cursor,$uniquerow) {
+    $data = array();
+    if ($cursor) {
+      if ($uniquerow) {
+        $data = mysql_fetch_array($cursor);
+      } else {
+        while($rCur=mysql_fetch_array($cursor)) {
+          $data[] = $rCur;
+        }  	
+      }
+    }
+    return $data;
+  }
+  
+  /**
+   * Load object(s) data from the database.
+   * @param string $where SQL WHERE clause to select the set of object(s) to load from the database
+   * @abstract
+   */
+  function _loadData($where,$uniquerow=false) {
+  // Override
+  }
+  
+  /**
+  * Initializes the object from database
+  * @param int $id identifier of the object
+  * @param int $wgid Working Group ID
+  * @return boolean operation successful or not
+  */
+  function load($id,$wgid) {
+    $data = $this->_loadData("wgid=$wgid AND id=$id",true);
+    if (is_array($data) && count($data)) {
+      lastModificationTime($data["last"]);
+      $this->_setData($data);
+      return true;  
+    } else {
+      return false;
+    }
+  }
+
+
+  /**
+   * Returns the next Id to be used for the said object, so as to 
+   * preserve incremental ids per working group
+   * @return int
+   */
+  function _getTopId() {
+	if (!$this->_type || !$this->wgid) {
+	  return false;
+	}
+        // Tell me about ugliness
+        $tablename = $this->_type."s";
+	$newid=1;
+  	if (! SQLcmd("LOCK TABLES incrementid WRITE, $tablename WRITE")) {
+    	  return -1;
+  	}
+  	$result=SQLquery("SELECT max(id)+1 AS newid FROM incrementid WHERE tablename='".$tablename."' AND wgid=".$this->wgid);
+        if ($result) {
+          $row = mysql_fetch_array($result, MYSQL_BOTH);
+          //has NULL value if no recs
+          if ($row["newid"]) {
+            $newid=$row["newid"];
+          } else {
+            $result=SQLquery("SELECT max(id)+1 AS newid FROM $tablename WHERE wgid=".$this->wgid);
+            if ($result) {
+              $row = mysql_fetch_array($result, MYSQL_BOTH);
+              if ($row["newid"]) {
+                $newid=$row["newid"];
+              }
+            } else {
+              $newid = 1;
+            }
+          }
+        } else {
+          $newid = 1;
+        }
+        if( ! SQLquery("INSERT into incrementid (id,wgid,tablename) VALUES ($newid,".$this->wgid.",'$tablename')")) {
+          return -1;
+        } else {
+          //no sense keeping the old ones, uncomment later after we're happy with this
+          //mysql_query("delete from incrementid WHERE tablename='$tablename' AND wgid=".$this->wgid." AND id<$newid");
+        }  
+        SQLcmd("UNLOCK TABLES");
+        return $newid;
+  }	
+  
+  /**
+  * @access protected
+  * @return string Human readable name of the status of the object
+  */
+  function _getReadableStatus($status) {
+     	return $this->_readableStatus[$status];
+  }
+}
+
+/**
+ * An object that can receive a tag
+ *
+ */
+class TaggableObject extends  TrackerBaseObject {
+	var $_tags;
+	var $_tagnames;
+	
+	function listTags() {
+      if (!$this->_tags) {
+      $this->_tags = array();
+      $this->_tagnames = array();
+      $cursor = SQLquery("SELECT t.id, t.name, t.description, UNIX_TIMESTAMP(a.last)"
+      ." FROM tags t, annotationsTags a WHERE a.recId=".(int) $this->id
+      ." AND a.wgid=". (int) $this->wgid
+      ." AND a.rectype='".mysql_real_escape_string($this->_type)."' AND a.tagid=t.id"
+      ." ORDER BY sent DESC, last DESC");
+      if ($cursor) {
+        while($record = mysql_fetch_array($cursor)) {
+          $this->_tags[]= & new Tag($record["id"],$record["name"],
+          		$record["description"]);
+          $this->_tagnames[] = $record["name"];
+        }
+      }
+    }
+    return $this->_tags;		
+	}
+	
+	function hasTag($tagname) {
+		if ($this->_tagnames) {
+			return in_array($tagname,$this->_tagnames);
+		} else {
+			$query = "SELECT t.id, UNIX_TIMESTAMP(t.last) FROM tags t, annotationsTags a"
+			." WHERE a.recId=".(int) $this->id." AND a.wgid=".(int) $this->wgid
+			." AND a.rectype='".mysql_real_escape_string($this->_type)."' AND a.tagId=t.id"
+			." AND t.tag='".mysql_real_escape_string($tagname)."'";
+
+			return mysql_num_rows(SQLquery($query));
+			
+		}
+	}
+	
+	function addTag($tagname) {
+		$cursor = SQLquery("SELECT t.id FROM tags t "
+		." WHERE t.tag='".mysql_real_escape_string($tagname)."'"
+		." AND (WGid=".(int) $this->wgid ." OR WGid=0)");
+		if ($cursor) {
+			$tagid = mysql_result($cursor,0);
+			if (!$tagid) {
+				// @@@ Insert new tag in db for the wg
+			} else {
+				return SQLcmd("INSERT INTO annotationsTags (recId, rectype, wgid, tagid) "
+				  ." VALUES (".(int) $this->id.", '".mysql_real_escape_string($this->_type)."', "
+				  .(int) $this->wgid.", ".(int) $tagid.")");
+			}
+		}
+		return false;
+	}
+	
+	function removeTag($tagname) {
+		$cursor = SQLquery("SELECT t.id FROM tags t "
+		." WHERE t.tag='".mysql_real_escape_string($tagname)."'"
+		." AND (WGid=".(int) $this->wgid ." OR WGid=0)");
+		if ($cursor) {
+			$tagid = mysql_result($cursor,0);
+			if ($tagid) {
+				return SQLcmd("DELETE from annotationsTags WHERE recId="
+				.(int) $this->id." AND rectype='"
+				.mysql_real_escape_string($this->_type)."' AND wgid="
+				.(int) $this->wgid." AND tagid= ".(int) $tagid);	
+			}
+			return true;
+		}
+		return false;
+	}
+	
+	/**
+	 * Returns an XHTML summary of the object
+	 * @abstract 
+	 * @return string
+	 */
+	function summary() {
+		
+	}
+}
+
+class AnnotableObject extends TaggableObject {
+  var $_notes;
+  var $_emails;
+  var $wgid;
+  var $id;
+
+  /**
+   * Returns a representation in XML of the data known about the object
+   * used in particular to create a Web service interface to tracker
+   * @abstract
+   * @return string
+  */
+  function toXML() {
+  }
+
+  /**
+   * Returns the list of emails / annotations as XML
+   * @abstract
+   * @return string
+   *
+   */
+  function _annotationsAsXML() {
+  	$xml = "";
+  	$emails = $this->listEmails();
+  	$notes = $this->listUserNotes();
+  	$xml.="<emails>\n";
+  	foreach($emails as $e) {
+  		$xml.="<email><link>".htmlify($e->uri)."</link>
+    		<subject>".htmlify($e->subject)."</subject><timestamp>".htmlify($e->sent)."</timestamp>
+    		<sender>".htmlify($e->sender)."</sender></email>\n";
+  	}
+  	$xml.="</emails><notes>\n";
+  	foreach($notes as $n) {
+  		$xml.="<note><description>".htmlify($n->note)."</description>";
+  		$xml.="<timestamp>".htmlify($n->entered)."</timestamp>";
+  		$xml.="<author>".htmlify($n->author->name)."</author></note>";
+  	}
+  	$xml.="</notes>";
+  	return $xml;
+  }
+  
+  /**
+   * Returns the list of emails associated to the object
+   * @param int $datemin unix timestamp of minimum date at which the email was sent
+   * @param int $datemax unix timestamp of maximum date at which the email was sent
+   *    * @return array array of Email objects
+   */
+  function listEmails($datemin=0,$datemax=0,$tag='') {
+    if (!$this->_emails) {
+      $this->_emails = array();
+      $cursor = SQLquery("SELECT emailId, uri, subject, sender, UNIX_TIMESTAMP(sent) AS sent, UNIX_TIMESTAMP(e.last)"
+      ." FROM emails e"
+      .($tag ? ", annotationsTags a, tags t " : "")
+      ." WHERE e.id=".$this->id." AND e.wgid=".$this->wgid." AND e.rectype='".$this->_type."'"
+      .($datemin && $datemax ? " AND sent >=".date("Ymd000000",$datemin)
+      							." AND sent <=".date("Ymd235959",$datemax) : "")
+      .($tag ? " AND a.recId=emailId AND a.rectype='email' AND a.wgid=e.wgid "
+      	. " AND t.id=a.tagid AND tag='". mysql_real_escape_string($tag)."'" : "")      							
+      ." ORDER BY sent DESC, e.last DESC");
+      if ($cursor) {
+        while($record = mysql_fetch_array($cursor)) {
+          $this->_emails[]= & new Email($record["emailId"],$this->wgid, $record["uri"],$record["subject"],$record["sender"],$record["sent"]);
+        }
+      }
+    }
+    return $this->_emails;
+  }
+
+  /**
+   * Returns the list of notes associated to the object
+   * @param string $type "user" or "changelog"
+   * @param int $datemin unix timestamp of minimum date at which the note was created
+   * @param int $datemax unix timestamp of maximum date at which the note was created
+   * @access private
+   * @return array array of Notes objects
+   */
+  function _listNotes($type='',$datemin=0,$datemax=0,$oldestFirst=true,$tag='') {
+    if (!$this->_notes) {
+      $this->_notes = array();
+    }
+    if (!array_key_exists($type,$this->_notes)) {
+      $cursor = SQLquery("SELECT  noteId, uid, note, UNIX_TIMESTAMP(entered) AS entered,"
+      ." UNIX_TIMESTAMP(n.last) FROM notes n "
+      .($tag ? ", annotationsTags a, tags t " : "")
+      ."WHERE n.id=".$this->id." AND n.wgid=".$this->wgid." AND n.rectype='".$this->_type."'"
+      .($datemin && $datemax ? " AND entered >=".date("Ymd000000",$datemin)
+      							." AND entered <=".date("Ymd235959",$datemax) : "")
+      .($type ? " AND type='".$type."'" : "")
+      .($tag ? " AND a.recId=noteId AND a.rectype='note' AND a.wgid=n.wgid "
+      	. " AND t.id=a.tagid AND tag='". mysql_real_escape_string($tag)."'" : "")
+      ." ORDER BY entered" . ($oldestFirst ? '' : ' DESC'));
+      $this->_notes[$type] = array();
+      if ($cursor) {
+        while($record = mysql_fetch_array($cursor)) {
+          $this->_notes[$type][]=new Note($record["noteId"],$this->wgid, $record["note"],$record["entered"],$record["uid"]);
+        }
+      }
+    }
+    return $this->_notes[$type];
+  }
+
+  /**
+   * Returns the list of notes/emails that were tagged as "important"
+   * return array Array of TaggableObjects
+   */
+  function listHighlight() {
+  	return array_merge($this->listUserNotes("@highlight"), $this->listEmails(0,0,"@highlight"));
+  }
+  
+  /**
+   * Returns the list of user notes associated to the object
+   * @return array array of Notes objects
+   */
+  function listUserNotes($tag='') {
+    return $this->_listNotes("user",0,0,true,$tag);
+  }
+
+  /**
+   * Returns the list of log entries associated to the object
+   * @return array array of Notes objects
+   */
+  function listChangelog($oldestFirst=true) {
+    return $this->_listNotes("changelog",0,0,$oldestFirst);
+  }
+
+  /**
+   * Return the list of log and notes associated to the object, made during the window of time
+   * identified by the parameters
+   *
+   * @param int $datemin
+   * @param int $datemax
+   * @return array Array of Notes objects
+   */
+  function listChanges($datemin,$datemax) {
+  	return $this->_listNotes("",$datemin,$datemax);
+  }
+  
+  /**
+  * Add and save a user note
+  * @param string $note text of the user comment
+  * @param int $uid ID of the user adding the comment
+  * @return boolean operation successful or not
+  */
+  function addUserNote($note,$uid) {
+    return $this->_addNote($note,$uid,'user');
+  }
+
+  /**
+  * Add and save an email reference
+  * @param string $uri URI of the email
+  * @param string $subject Subject of the email message
+  * @param string $sender sender of the email message
+  * @param string $sent date at which the message was sent
+  * @return boolean operation successful or not
+  */
+  function addEmail($uri,$subject,$sender,$sent) {
+    $this->listEmails();
+    $result = SQLquery("SELECT uri FROM emails WHERE id=".$this->id." AND wgid=".$this->wgid." AND rectype='".mysql_real_escape_string($this->_type)."' AND uri='".mysql_real_escape_string($uri)."'");
+    if ($result && mysql_num_rows($result) != 0) {
+      // There already exists an entry in the database for this email.
+      return true;
+    }
+    $success = SQLcmd("INSERT INTO emails (id,wgid,rectype,uri
+,subject,sender,sent) VALUES (".$this->id.",".$this->wgid.",'".$this->_type."','".addslashes($uri)."','".addslashes($subject)."','".addslashes($sender)."','".addslashes($sent)."')");
+    if ($success) {
+    	$emailId = mysql_insert_id();
+      $this->_emails[]=new Email($emailId,$this->wgid,$uri,$subject,$sender,$sent);
+      return true;
+    } else {
+      return false;
+    }
+  }
+
+
+  /**
+  * Add and save a log entry
+  * @param string $note text of the user comment
+  * @access protected
+  * @return boolean operation successful or not
+  */
+  function _addChangelog($note,$uid=0) {
+    return $this->_addNote($note,$uid,'changelog');
+  }
+
+  /**
+  * Add and save a note in the database
+  * @param string $note text of the user comment
+  * @param int $uid ID of the user adding the comment
+  * @access private
+  * @return boolean operation successful or not
+  */
+  function _addNote($note,$uid=0,$type) {
+    // making sure the data are loaded up so the full list is loaded
+    // after the operation
+    $this->_listNotes($type);
+    $success = SQLcmd("INSERT INTO notes (id,wgid,rectype,uid
+,entered,type,note) VALUES (".$this->id.",".$this->wgid.",'".$this->_type."',".( $uid ? $uid : "0").",NOW(),'".$type."','".addslashes($note)."')");
+    if ($success) {
+    	$noteId = mysql_insert_id();
+      $this->_notes[$type][]=new Note($noteId,$this->wgid,$note,time(),$uid);
+      return true;
+    } else {
+      return false;
+    }
+  }
+}
+
+/**
+* Generic base object to load a bunch of objects at once.
+* Similar to ObjectIterator, but more flexible and more SQL-efficient,
+* but a bit kludgy too.
+* @abstract
+*/
+class ObjectList {
+  var $list = array();
+  var $_objectName;
+  
+  /**
+  * Associates a given filter to an actual SQL restriction (in WHERE)
+  * Needs to be overloaded in each subclass
+  * @param string $filter
+  * @return string SQL WHERE
+  * @abstract
+  */
+  function _getWhereFromFilter($filter,$order=array()) {
+     return "";
+  }
+  
+  /**
+  * load a set of objects determined by $filter
+  * the association between $filter and the SQL query needs to be overloaded by each subclass
+  * @param array $filter
+  */
+  function load($filter,$order=array()) { 
+    $interface = new $this->_objectName;
+    // Sets $where depending on the $filter
+    $where = $this->_getWhereFromFilter($filter,$order);
+    $results = $interface->_loadData($where);
+    // @@@ what todo if no results?
+    foreach($results as $datarow) {
+      $this->list[] = new $this->_objectName;
+      $object = & $this->list[count($this->list)-1];
+      $object->_setData($datarow);
+      $this->list[count($this->list)-1] = & $object;
+     }
+  }
+
+  function next() {
+    return next($this->list);
+  }
+  
+  /**
+   * Sort the list of objects by a given property
+   *
+   * @param string $property Name of the object property
+   * @param string $subproperty When set, sort by the property of the said property (assuming it is an object)
+   */
+  function sortByProperty($property,$subproperty=false) {
+  	$comparedOne = '$a->'.$property.($subproperty ? "->".$subproperty : "");
+  	$comparedTwo = '$b->'.$property.($subproperty ? "->".$subproperty : "");
+  	$sortBy = create_function('$a,$b',
+  	'return ('.$comparedOne.'=='.$comparedTwo.' ? 0 : ('.$comparedOne.' < '.$comparedTwo.' ? -1 : 1)); ');
+  	usort($this->list,$sortBy);
+  }
+}
+
+class Action extends AnnotableObject {
+  var $id;
+  var $wgid;
+  var $owner;
+  /**
+  * @var int Unix timestamp denoting the time at which the action is due
+  */
+  var $duedate;
+  /**
+  * @var int Unix timestamp denoting the time at which the action is due
+  */
+  var $opened;
+  var $status;
+  var $title;
+  /**
+   * @var Product Product to which the action is associated (if any)
+  */
+  var $product;
+  /**
+   * @var Issue Issue to which the action is associated (if any)
+  */
+
+  var $issue;
+  
+  function Action() {
+    // Set the record type for $this->_getTopId()
+    $this->_type="action";
+  }
+  
+  function _setData($data) {
+    // @@@ The fields in $data are defined by the query in _loadData; that's ugly
+    $this->id = $data["id"];
+    $this->wgid = $data["wgid"];
+    $this->owner = $_SERVER["Application"]->objectFactory("TrackerUser",$data["ownerid"]);
+    $this->title = $data["action"];
+    $this->duedate = $data["duedate"];
+    $this->status = $data["state"];
+    $this->opened = $data["opened"];
+    if ($data["issueid"]) {
+      $this->issue = new Issue();
+      $this->issue->load($data["issueid"],$this->wgid);
+      $this->product = $this->issue->product;
+    } else if  ($data["productid"]) {
+      $this->product = new Product();
+      $this->product->load($data["productid"],$this->wgid);
+      $this->issue = false;
+    } else {
+      $this->product = false;
+      $this->issue = false;
+    }
+  }
+  
+  function _loadData($where,$uniquerow=false) {
+  	$cursor = SQLquery("SELECT a.id,a.wgid,ownerid,action,UNIX_TIMESTAMP(due) AS duedate,state, UNIX_TIMESTAMP(opened) AS opened, issueid, productid, UNIX_TIMESTAMP(a.last) AS last FROM actions a WHERE $where");
+  	return $this->_buildDataFromMysqlCursor($cursor,$uniquerow);
+  }
+
+  /**
+  * Update the data on the action item in the db
+  * @param string $title new title
+  * @param int $ownerid new owner
+  * @param int $duedate UNIX timestamp of the new due date
+  * @param int $status new status of the action
+  * @param int $issueid Id of the issue to which the action is bound
+  * @param int $productid Id of the product to which the action is bound
+  * @param int $userid id of the person who made the change
+  * @return boolean Operation successful or not
+  */
+  function update($title,$ownerid,$duedate,$status,$issueid,$productid,$userid) {
+    $updates = array();
+    if ($title) {
+      $updates[] = "action='".addslashes($title)."'";
+    }
+    if ($ownerid!==false && intval($ownerid)) {
+      $updates[] = "ownerid=$ownerid";
+    }
+    if ($duedate!==false && intval($duedate)) {
+      $updates[] = "due='".gmdate("Y-m-d",$duedate)."'";
+    }
+    if ($status!== false && ($status==OPEN || $status==CLOSED || $status==PENDINGREVIEW)) {
+      $updates[] = "state=".intval($status);
+    }
+    $objectExists = false;
+    if ($issuedid!==false && $issueid) {
+      $issue = new Issue();
+      $objectExists = ($issue->load($issueid,$this->wgid));
+      if (!$objectExists) {
+        $_SERVER["Application"]->addWarning("Submitted association to issue ".intval($issueid)." failed, ISSUE-".intval($issueid)." doesn’t exist");
+      } else {
+        $product = $issue->product;
+        $updates[] = "issueid=".intval($issueid);
+        if ($product->id) {
+          $updates[] = "productid=".$product->id;
+        }
+      }
+    } else if ($productid!==false && $productid) {
+      $product = new Product();
+      $objectExists = ($product->load($productid,$this->wgid));
+      $issue = false;
+      if (!$objectExists) {
+        $_SERVER["Application"]->addWarning("Submitted association to product ".intval($productid)." failed, Product ".intval($productid)." doesn’t exist");
+      } else {
+        $updates[] = "issueid=0";
+        $updates[] = "productid=".$product->id;
+      }
+    } else if ($this->issue || $this->product) {
+    	if ($issueid!==false) {
+      	$updates[] = "issueid=0";
+    	}
+    	if ($productid!==false) {
+      	$updates[] = "productid=0";
+    	}
+      if ($issueid!==false || $productid!==false) {
+      $removeAssociation = true;
+      }
+    }
+    if (count($updates)) {
+      $success = SQLcmd("UPDATE actions SET ".implode(',',$updates)." WHERE id=".$this->id." AND wgid=".$this->wgid);
+      if ($success) {
+        if ($title!==false && $this->title != $title) {
+          $this->_addChangelog("title changed to '".$title."'",$userid);
+          $this->title = $title;
+        }
+        if ($ownerid!==false && $this->owner->id != $ownerid) {
+        	$this->owner = $_SERVER["Application"]->objectFactory("TrackerUser",$ownerid);
+        	$this->_addChangelog("Owner changed to '".$this->owner->name."'",$userid);
+        	$this->notifyOwner();
+        }	  
+        if ($duedate!==false && $this->duedate != $duedate) {
+          $this->_addChangelog("Due date changed to ".gmdate("Y-m-d",$duedate),$userid);
+          $this->duedate = $duedate;
+	  }
+        if ($status!==false && $this->status != $status) {
+          $this->_addChangelog("Status changed to '".$this->_getReadableStatus($status)."'",$userid);
+          $this->status = $status;
+        }
+        if ($issue && $this->issue->id!=$issue->id) {
+          $this->_addChangelog("Action now bound to ISSUE-".$issue->id,$userid);
+          $this->issue = $issue;
+          $this->product = $issue->product;
+        } else if ($product && $this->product->id!=$product->id) {
+          $this->_addChangelog("Action now bound to Product '".$product->name."'",$userid);
+          $this->product = $product;
+          $this->issue = false;
+        } else if ($removeAssociation ) {
+          $this->_addChangelog("Action now unbound",$userid);
+          $this->product = false;
+          $this->issue = false;
+
+        }
+        return true;	  	  
+      }
+      else {
+        return false;
+      }
+    }
+    // no actual change made
+    return true;
+  }
+
+  function _updateStatus($status) {
+  	$issueid=0;
+  	$productid=0;
+  	if ($this->issue) {
+  		$issueid = $this->issue->id;
+  	}
+  	if ($this->product) {
+  		$productid = $this->product->id;
+  	}
+  	return $this->update($this->title,$this->owner->id,$this->duedate,$status,$issueid,$productid,0);
+  	
+  }
+  
+  function close() {
+	return $this->_updateStatus(CLOSED);
+  }
+  
+  function reopen() {
+	return $this->_updateStatus(OPEN);
+  }
+  
+  
+  /**
+  * Create a new action in the database
+  * @param int $wgid id of the working group
+  * @param string $title title
+  * @param int $ownerid id of the user to which the action was assigned owner
+  * @param int $duedate UNIX timestamp of the due date
+  * @param int $issueid Id of the issue to which the action is bound
+  * @param int $productid Id of the product to which the action is bound
+  * @param string $userid login of the person who created the action
+  * @return boolean Operation successful or not
+  */
+  function create($wgid,$title,$ownerid,$duedate,$issueid,$productid,$userid) {
+    $this->wgid=$wgid;
+    $objectExists = false;
+    $issue = false;
+    $product = false;
+    if (intval($issueid)) {
+      $issue = new Issue();
+      $objectExists = ($issue->load(intval($issueid),$this->wgid));
+      $product = $issue->product;
+    } else if (intval($productid)) {
+      $product = new Product();
+      $objectExists = ($product->load(intval($productid),$this->wgid));
+    }
+    if (!$ownerid || !$title) {
+    	return false;
+    }
+    $this->id = $this->_getTopId();
+    $success = SQLcmd("INSERT INTO actions (id,wgid,action,ownerid,opened,due,state,issueid,productid) VALUES (".$this->id.",$wgid,'".addslashes($title)."',".$ownerid.",NOW(),'".gmdate("Y-m-d",$duedate)."',".OPEN.",".($issue && $issue->id ? $issue->id : "0").",".($product && $product->id ? $product->id : "0").")");
+    if ($success) {
+      $this->owner = $_SERVER["Application"]->objectFactory("TrackerUser",$ownerid);
+      $this->title = $title;
+      $this->duedate = $duedate;
+      $this->opened = time();
+      $this->_addChangelog("Created action '".$title."' assigned to ".$this->owner->name.", due ".gmdate('Y-m-d',$duedate).($objectExists ? " bound to ".($issue ? "ISSUE-".$issue->id : "Product ".$product->id) : ""),$userid); 
+      $this->issue = $issue;
+      $this->product = $product;
+      $this->notifyOwner();
+      return true;
+    } else {
+      return false;
+    }
+  }
+
+  function notifyOwner() {
+  	// send email notif if wanted
+  	$sendEmail = false;
+  	$userConfig = $this->owner->wantsNewActionNotif($this->wgid);
+  	$sendEmail = ($userConfig==TrackerUser::notifyAlways);
+  	if ($userConfig == TrackerUser::notifyDefault) {
+  		$wg = new TrackerWorkingGroup($this->wgid);
+  		$wg->loadConfig();
+  		$sendEmail = ($wg->notifyNewAction==TrackerWorkingGroup::notifyByDefault);
+  	}
+  	if ($sendEmail) {
+  		if (!$wg) {
+  			$wg = new TrackerWorkingGroup($this->wgid);
+  			$wg->loadConfig();
+  		}
+  		$subject = "ACTION-" . $this->id .": " . $this->title." (".$wg->name.")";
+  		$message = $subject . "\n\n";
+  		$message .= $wg->uribase. "actions/" . $this->id . "\n\n";
+  		$message .= "On: " . $this->owner->name . "\n";
+  		$message .= "Due: " . gmdate('Y-m-d',$this->duedate) . "\n";
+  		if ($this->issue) {
+  			$message .= "Issue: ISSUE-".$this->issue-id." (".$this->issue->title.")";
+  		}
+  		if ($this->product) {
+  			$message .= "Product: ".$this->product->name."\n";
+  		}
+  		$message .= "\nIf you do not want to be notified on new action items for this group, "
+  		."please update your settings at:\n"
+  		.$wg->uribase."users/".$this->owner->id."#settings\n";
+  		$headers = "From: " . $wg->name. " Issue Tracker <sysbot+tracker@w3.org>\r\n";
+  		$headers .= "Reply-To: " . $wg->name. " WG <" . $wg->mailinglist . ">\r\n";
+  		$headers .= "MIME-Version: 1.0\r\n";
+  		$headers .= "Content-Type: text/plain; charset=utf-8\r\n";
+  		$headers .= "Content-Transfer-Encoding: 8bit\r\n";
+  		$returnpath = "-fbounce+tracker@w3.org";
+  		mail($this->owner->email, $subject, $message, $headers, $returnpath);
+  	}
+  	
+  }
+  
+  function toXML($full=false,$detailed=false) {
+    $xml = "";
+    $xml .= "<action>\n";
+    $xml .= "<id>".$this->id."</id>\n" ;
+    $xml .= "<title>".htmlify($this->title)."</title>\n" ;
+    // Stoopid API change between single and multiple objects :(
+    if ($detailed) {
+      $xml .= "<user>".htmlify($this->owner->name)."</user>\n" ;
+    } else {
+      $xml .= "<user>".$this->owner->id."</user>\n" ;
+    }
+    $xml .= "<opened>".gmdate("Y-m-d",$this->opened)."</opened>\n" ;
+    $xml .= "<due>".gmdate("Y-m-d",$this->duedate)."</due>\n" ;
+    if ($detailed) {
+      $xml .= "<state>".($this->status==OPEN ? "OPEN" : ($this->status==CLOSED ? "CLOSED" : "PENDINGREVIEW"))."</state>\n" ;
+    } else {
+      $xml .= "<state>".$this->status."</state>\n" ;
+    }
+	if ($full) {
+		$xml .= $this->_annotationsAsXML();
+	}
+    $xml .= "</action>\n" ;
+    return $xml ;
+  }
+}
+
+class ActionList extends ObjectList {
+  function ActionList() {
+    $this->_objectName="Action";
+  }
+
+  function _getWhereFromFilter($filter,$order=array()) {
+    $clauses = array();
+    if (array_key_exists("wgid",$filter) && $filter["wgid"]) {
+    	if (is_array($filter["wgid"])) {
+    		$clauses[] = "wgid in (".join(", ",$filter["wgid"]).")";
+    	} else {
+    		$clauses[] = "wgid=".$filter["wgid"];
+    	}
+    }
+    if (array_key_exists("issue",$filter)) {
+    	if ($filter["issue"]) {
+      		$clauses[] = "issueid=".$filter["issue"];
+    	} else {
+    		$clauses[] = "issueid=0";
+    	}
+    }
+    if (array_key_exists("product",$filter) && $filter["product"]) {
+      $clauses[] = "productid=".$filter["product"];
+    }
+    if (array_key_exists("ownerid",$filter) && $filter["ownerid"]) {
+      $clauses[] = "ownerid=".$filter["ownerid"];
+    }
+    if (array_key_exists("status",$filter) && $filter["status"]!=ANY) {
+      if (is_array($filter["status"])) {
+      	$clauses[] = "state in (".join(",",$filter["status"]).")";
+      } else {
+      	$clauses[] = "state = ".$filter["status"];
+      }
+    }
+    if (array_key_exists("duedate",$filter)) {
+    	if (is_array($filter["duedate"])) {
+    		if ($filter["duedate"][1]) {
+    			$clauses[] = "due < '".gmdate("Y-m-d",$filter["duedate"][1])."'";
+    		}
+    		if ($filter["duedate"][0]) {
+    			$clauses[] = "due >= '".gmdate("Y-m-d",$filter["duedate"][0])."'";
+    		}
+    	} else {
+      		$clauses[] = "due < '".gmdate("Y-m-d",$filter["duedate"])."'";
+    	}
+    }
+    if (array_key_exists("changed",$filter) && is_array($filter["changed"])) {
+    	$clauses[] = "(id in (SELECT notes.id FROM notes WHERE notes.wgid=a.wgid "
+    	." AND rectype='action' AND notes.entered >= ".date("Ymd000000",$filter["changed"][0])
+    	." AND notes.entered <= ".date("Ymd235959",$filter["changed"][1])
+    	.") OR id in (SELECT emails.id FROM emails WHERE emails.wgid=a.wgid"
+    	." AND rectype='action' AND emails.sent >=  ".date("Ymd000000",$filter["changed"][0])
+    	." AND emails.sent <= ".date("Ymd235959",$filter["changed"][1]).")"
+    	.")";
+    }
+    $where = implode(" AND ",$clauses);
+    $sorter = "";
+    if (count($order) && $order[0]=="due") {
+      $sorter = "a.state DESC, due, a.id";
+    } else {
+      $sorter = "a.id";
+    }
+    return $where." ORDER BY ". $sorter;
+  }
+} 
+
+
+class Issue extends AnnotableObject {
+  var $id;
+  var $wgid;
+  var $title;
+  var $owner;
+  var $description;
+  var $nickname;
+
+  /**
+  * UNIX Timestamp of the creation date
+  * @var int
+  */
+  var $created;
+  /**
+   * @var Product
+   */
+  var $product;
+  var $status;
+  var $_actions=array();
+
+  function Issue() {
+    // Set the record type for $this->_getTopId()
+    $this->_type = "issue";
+  }
+
+  function _setData($data) {
+    $this->id = $data["id"];
+    $this->wgid = $data["wgid"];
+    $this->owner = $_SERVER["Application"]->objectFactory("TrackerUser",$data["raisedby"]);
+    $this->title = $data["title"];
+    $this->nickname = $data["nickname"];
+    $this->product = new Product();
+    $this->product->load($data["productid"],$this->wgid);
+    $this->description = $data["description"];
+    $this->created = $data["created"];
+    $this->public = $data["public"];
+    $this->status = $data["state"];
+  }
+
+  function _loadData($where,$uniquerow=false) {
+  	$query = "SELECT id,wgid,productid,raisedby,UNIX_TIMESTAMP(created) AS created,description,title,nickname,state,public,UNIX_TIMESTAMP(last) AS last FROM issues i WHERE $where";
+  	$cursor = SQLquery($query);
+    return $this->_buildDataFromMysqlCursor($cursor,$uniquerow);
+  }
+
+  /**
+  * Update the data on the issue in the db
+  * @param string $title new title
+  * @param int $ownerid new owner
+  * @param string $description new description
+  * @param int $productid new productid
+  * @param int $status new status of the action
+  * @param int $nickname new status of the action
+  * @param int $userid id of the person who made the change
+  * @return boolean Operation successful or not
+  */
+  function update($title,$ownerid,$description,$productid,$status,$nickname,$userid) {
+    $updates = array();
+    if ($title!==false && $title) {
+      $updates[] = "title='".addslashes($title)."'";
+    }
+    if ($ownerid!==false && intval($ownerid)) {
+      $updates[] = "raisedby=$ownerid";
+    }
+    if ($productid!== false) {
+    	if (intval($productid)) {
+      		$updates[] = "productid=$productid";
+    	} else if ($this->product->id) {
+      	$updates[] = "productid=0";
+    	}
+    }
+    if ($description !== false && ($description || $this->description)) {
+      $updates[] = "description='".addslashes($description)."'";
+    }
+    if ($nickname!==false && ($nickname || $this->nickname)) {
+      $updates[] = "nickname='".addslashes($nickname)."'";
+    }
+    if ($status!==false && ($status==OPEN || $status==CLOSED || $status==RAISED || $status==POSTPONED || $status=PENDINGREVIEW)) {
+      $updates[] = "state=".intval($status);
+    }
+    if (count($updates)) {
+      $success = SQLcmd("UPDATE issues SET ".implode(',',$updates)." WHERE id=".$this->id." AND wgid=".$this->wgid);
+      if ($success) {
+        if ($title!==false && $this->title != $title) {
+          $this->_addChangelog("title changed to '".$title."'",$userid);
+          $this->title = $title;
+        }
+        if ($nickname !== false && $this->nickname != $nickname) {
+          $this->_addChangelog("nickname changed to '".$nickname."'",$userid);
+          $this->nickname = $nickname;
+        }
+        if ($ownerid!== false && $this->owner->id != $ownerid) {
+          $this->owner = $_SERVER["Application"]->objectFactory("TrackerUser",$ownerid);
+          $this->_addChangelog("Owner changed to '".$this->owner->name."'",$userid);
+        }
+        if ($description !== false && $this->description != $description) {
+          $this->_addChangelog("Description changed to '".$description."'",$userid);
+          $this->description = $description;
+        }
+        if ($productid !== false) {
+        if ($this->product->id != $productid) {
+          if ($productid) {
+            $this->product->load($productid,$this->wgid);
+            $this->_addChangelog("Product changed to ".$this->product->name,$userid);
+          } else {
+            $this->_addChangelog("Issue dissociated from any product",$userid);
+          }
+        }
+        }
+        if ($status!==false && $this->status != $status) {
+          $this->_addChangelog("Status changed to '".$this->_getReadableStatus($status)."'",$userid);
+          $this->status = $status;
+        }
+        return true;	  	  
+      }
+      else {
+        return false;
+      }
+    }
+    // no update made
+    return false;
+  }
+
+  /**
+   * Update the status of the issue
+   *
+   * @param int $status new status of the issue
+   * @return boolean
+   */
+  function _updateStatus($status) {
+    $productid = 0;
+    if ($this->product) {
+      $productid = $this->product->id;
+    }
+    return $this->update($this->title, $this->owner->id, $this->description, $productid, $status, $this->nickname, 0);
+  }
+
+  /**
+   * Mark the issue as closed
+   *
+   * @return boolean
+   */
+  function close() {
+    return $this->_updateStatus(CLOSED);
+  }
+
+  /**
+   * Mark the issue as open
+   *
+   * @return boolean
+   */
+  function reopen() {
+	return $this->_updateStatus(OPEN);
+  }
+  
+
+  function listActionItems($status=ANY,$dueBefore=-1) {
+  	$status_key = $status;
+  	if (is_array($status)) {
+  		sort($status);
+  		$status_key = join('_',$status);
+  	}
+  	if ($dueBefore > 0 || !array_key_exists($status_key,$this->_actions)) {
+    	$actions = new ActionList();
+    	$filter = array("wgid"=>$this->wgid,"status"=>$status,"issue"=>$this->id);
+    	if ($dueBefore > 0) {
+    		$filter["duedate"]=$dueBefore;
+    	}
+    	$actions->load($filter,array("due"));
+    	// we don't cache if dueBefore is set
+    	if ($dueBefore > 0) {
+    		return $actions;
+    	}
+    	$this->_actions[$status_key] = $actions;
+  	}
+  	return $this->_actions[$status_key];
+  }
+  
+  /**
+   * Return the earliest due date of the actions associated with the issue
+   * @return int Unix Timestamp (-1 if no associated actions)
+   */
+  function getEarliestActionDueDate() {
+  	$actions = $this->listActionItems(array(OPEN,PENDINGREVIEW));
+  	$min = -1;
+  	foreach ($actions->list as $a) {
+  		$min = ($min != -1 ? min($min,$a->duedate) : $a->duedate);
+  	}
+  	return $min;
+  }
+
+  function toXML($full=false) {
+  	global $humanReadableState;
+    $xml = "";
+    $xml .= "<issue>\n";
+    $xml .= "<id>".$this->id."</id>\n" ;
+    $xml .= "<title>".htmlify($this->title)."</title>\n" ;
+    $xml .= "<nickname>".htmlify($this->nickname)."</nickname>\n" ;
+    $xml .= "<description>".htmlify($this->description)."</description>\n" ;
+    $xml .= "<product>".$this->product->id."</product>\n" ;
+    $xml .= "<raisedby>".$this->owner->id."</raisedby>\n" ;
+    $xml .= "<created>".gmdate("Y-m-d",$this->created)."</created>\n" ;
+    $xml .= "<state>".$humanReadableState[$this->status]."</state>\n" ;
+    if ($full) {
+		$xml .= $this->_annotationsAsXML();
+    }
+    $xml .= "</issue>\n" ;
+    return $xml ;
+  }
+
+  
+  /**
+  * Create a new issue in the database
+  * @param int $wgid id of the working group
+  * @param string $title title
+  * @param int $ownerid id of the user to who owns the issue
+  * @param string $description description
+  * @param int $productid id of product in which the issue was created
+  * @param string $nickname nickname associated with the issue
+  * @param boolean $public whether the issue should be publicly visible or not
+  * @param int $state State in which the issue is at
+  * @param string $userid login of the person who created the action
+  * @return boolean Operation successful or not
+  */
+  function create($wgid,$title,$ownerid,$description,$productid,$nickname,$public,$state=RAISED,$userid=0) {
+     $this->wgid=$wgid;
+     
+     $this->id = $this->_getTopId();
+     $success = SQLcmd("INSERT INTO issues (id,wgid,title,nickname,raisedby,created,description,productid,public,state) VALUES (".$this->id.",$wgid,'".addslashes($title)."','".addslashes($nickname)."',$ownerid,NOW(),'".addslashes($description)."',$productid,".($public ? "1" : "0").",".intval(addslashes($state)).")");
+     if ($success) {
+       $this->owner = $_SERVER["Application"]->objectFactory("TrackerUser",$ownerid);
+       $this->title = $title;
+       $this->nickname = $nickname;
+       $this->product = new Product();
+       $this->product->load($productid,$this->wgid);
+       $this->description = $description;
+       $this->created = time();
+       $this->public = $public;
+       $this->status = intval($state);
+
+       $this->_addChangelog("Created issue '".$title."' nickname $nickname owned by ".$this->owner->name." on product ".$this->product->name.", description '".$description."' ".($public ? "public" : "non-public"),$userid); 
+       return true;
+     } else {
+       return false;
+     }
+
+  }
+}
+
+class IssueList extends ObjectList {
+  function IssueList() {
+    $this->_objectName="Issue";
+  }
+
+  function _getWhereFromFilter($filter,$order=array()) {
+    $clauses = array();
+    if ($filter["wgid"]) {
+    	if (is_array($filter["wgid"])) {
+    		$clauses[] = "wgid in (".join(", ",$filter["wgid"]).")";
+    	} else {
+    		$clauses[] = "wgid=".$filter["wgid"];
+    	}
+    }
+    if ($filter["status"]!=ANY) {
+      if (is_array($filter["status"])) {
+      	$clauses[] = "state in (".join(",",$filter["status"]).")";
+      } else {
+        $clauses[] = "state = ".$filter["status"];
+      }
+    }
+    if (array_key_exists("ownerid",$filter) && $filter["ownerid"]) {
+      $clauses[] = "raisedby=".$filter["ownerid"];
+    }
+    if (array_key_exists('productid',$filter)) {
+    	if ($filter["productid"]) {
+      		$clauses[] = "productid=".$filter["productid"];
+    	} else {
+    		$clauses[] = "productid=0";
+    	}
+    }
+    if (array_key_exists('discussed',$filter) && $filter["discussed"]) {
+      $clauses[] = "(SELECT count(*) FROM emails e WHERE e.wgid=i.wgid 
+      AND rectype='issue' AND e.id=i.id AND sent > '".gmdate("Y-m-d",$filter["discussed"])."') > 0"; 
+    }
+  	if (array_key_exists("changed",$filter) && is_array($filter["changed"])) {
+    	$clauses[] = "(id in (SELECT notes.id FROM notes WHERE notes.wgid=i.wgid "
+    	." AND rectype='issue' AND notes.entered >= ".date("Ymd000000",$filter["changed"][0])
+    	." AND notes.entered <= ".date("Ymd235959",$filter["changed"][1]).")"
+    	." OR id in (SELECT emails.id FROM emails WHERE emails.wgid=i.wgid"
+    	." AND rectype='issue' AND emails.sent >=  ".date("Ymd000000",$filter["changed"][0])
+    	." AND emails.sent <= ".date("Ymd235959",$filter["changed"][1]).")"
+    	.")";
+    }
+    $sorter = "id";
+    if (count($order) && $order[0]=="status") {
+    	$sorter="state,id";    	
+    } 
+    return implode(" AND ",$clauses)." ORDER BY $sorter";
+  }
+
+  /**
+   * Sort the list of issues per action due dates
+   * (i.e. the issue with the action whose due date is the the earliest first)
+   * for http://www.w3.org/mid/1228340388.6924.19.camel@pav.lan
+   *
+   */
+  function sortByActionDueDate() {
+  	usort($this->list,create_function('$i1,$i2','$diff = $i1->getEarliestActionDueDate() - $i2->getEarliestActionDueDate();return ($diff ? $diff : $i1->id - $i2->id);'));
+  }
+} 
+
+
+/**
+* Product data 
+*/
+
+class Product extends AnnotableObject {
+  var $id;
+  var $wgid;
+  var $name;
+  var $_issues=array();
+   /**
+   * Mailing list to which new issues are announced
+   *
+   * @var string
+   */
+  var $mailinglist;
+
+  function Product() {
+    // Set the record type for $this->_getTopId()
+    $this->_type = "product";
+  }
+
+  function _setData($data) {
+    $this->id = $data["id"];
+    $this->wgid = $data["wgid"];
+    $this->name = $data["name"];
+    $this->mailinglist = $data["mailinglist"];
+  }
+  
+  function _loadData($where,$uniquerow=false) {
+    $cursor = SQLquery("SELECT id, wgid, name, mailinglist, UNIX_TIMESTAMP(last) AS last FROM products WHERE $where");
+    return $this->_buildDataFromMysqlCursor($cursor,$uniquerow);  
+  }
+  
+
+  function listIssues($status=ANY) {
+    $statusKey = "";
+    if (is_array($status)) {
+      $statusKey = implode('_',$status);
+    } else {
+      $statusKey = $status;
+    }
+    if (!array_key_exists($statusKey,$this->_issues)) {
+      $issues = new IssueList();
+      $filter = array("wgid"=>$this->wgid,"status"=>$status,"productid"=>$this->id);
+      $issues->load($filter,array("status"));
+      $this->_issues[$statusKey] = $issues;
+    }
+    return $this->_issues[$statusKey];
+
+  }
+
+  function listActionItems() {
+    $actions = new ActionList();
+    $filter = array("wgid"=>$this->wgid,"status"=>array(OPEN,PENDINGREVIEW),"product"=>$this->id);
+    $actions->load($filter);
+    return $actions;
+  }
+
+  function listOrphelinActionItems($dueBefore=-1) {
+    $actions = new ActionList();
+    $filter = array("wgid"=>$this->wgid,"status"=>array(OPEN,PENDINGREVIEW),"product"=>$this->id,"issue"=>false);
+    if ($dueBefore > 0) {
+    	$filter["duedate"] = $dueBefore;
+    }
+    $actions->load($filter,array("due"));
+    return $actions;
+  	
+  }
+
+  /**
+   * Return the earliest due date of the actions associated with the issue
+   * @return int Unix Timestamp (-1 if no associated actions)
+   */
+  function getEarliestActionDueDate() {
+  	$actions = $this->listActionItems(array(OPEN,PENDINGREVIEW));
+  	$min = -1;
+  	foreach ($actions->list as $a) {
+  		$min = ($min != -1 ? min($min,$a->duedate) : $a->duedate);
+  	}
+  	return $min;
+  }
+  
+  /**
+  * Create the data on the product item in the db
+  * @param string $name new product name
+  * @param int $wgid Working Group
+  * @param int $userid Id of the user creating the product
+  * @return boolean Operation successful or not
+  */
+  function create($wgid,$name,$userid) {
+     $this->wgid=$wgid;
+     $this->id = $this->_getTopId();
+     $success = SQLcmd("INSERT INTO products(id, wgid, name) VALUES (".
+		(int) $this->id . ", ".(int) $wgid ." ,'" . mysql_real_escape_string($name) . "')");
+     if ($success) {
+       $this->name = $name;
+       $this->wgid = $wgid;
+       $this->_addChangelog("Created product '".$name."'",$userid); 
+       return true;
+     } else {
+       return false;
+     }
+  }
+  /**
+  * Update the data on the product in the db
+  * @param string $name new product name
+  * @return boolean Operation successful or not
+  */
+  function update($name,$userid) {
+    $success = SQLcmd("UPDATE products SET name='".addslashes($name)."' WHERE id=".$this->id." AND wgid=".$this->wgid);
+    if ($success) {
+      if ($this->name != $name) {
+        $this->_addChangelog("Name changed to '".$name."'",$userid);
+        $this->name = $name;
+      }
+      return true;	  	  
+    }
+    else {
+      return false;
+    }
+  }
+    
+}
+
+class ProductList extends ObjectList {
+  function ProductList() {
+    $this->_objectName="Product";
+  }
+
+  function _getWhereFromFilter($filter,$order=array()) {
+    $clauses = array();
+    if ($filter["wgid"]) {
+      $clauses[] = "wgid=".$filter["wgid"];
+    }
+    $where = implode(" AND ",$clauses);
+    $where .= " ORDER BY name" ;
+    return $where;
+  }
+
+    /**
+   * Sort the list of products per action due dates
+   * (i.e. the products with the action whose due date is the the earliest first)
+   * for http://www.w3.org/mid/1228340388.6924.19.camel@pav.lan
+   *
+   */
+  function sortByActionDueDate() {
+  	usort($this->list,create_function('$i1,$i2','$diff = $i1->getEarliestActionDueDate() - $i2->getEarliestActionDueDate();return ($diff ? $diff : $i1->id - $i2->id);'));
+  }
+  
+} 
+
+
+/**
+* Resolution recorded by the group
+*/
+class Resolution extends TrackerBaseObject {
+  var $id;
+  var $wgid;
+  var $title;
+  var $uri;
+  var $made;
+
+  function Resolution() {
+  	// Set the record type for $this->_getTopId()
+  	$this->_type = "resolution";
+  }
+
+  function _setData($data) {
+	  $this->id = $data["id"];
+	  $this->wgid = $data["wgid"];
+	  $this->title = $data["title"];
+	  $this->uri = $data["uri"];
+	  $this->made = $data["made"];  
+  }
+
+  function _loadData($where,$uniquerow=false) {
+    $cursor = SQLquery("SELECT id, title, uri, UNIX_TIMESTAMP(made) AS made, UNIX_TIMESTAMP(last) as last FROM resolutions WHERE $where");
+    return $this->_buildDataFromMysqlCursor($cursor,$uniquerow);
+  }
+
+
+  /**
+  * Create the data on the resolution item in the db
+  * @param string $title new title
+  * @param int $wgid Working Group
+  * @param int $uri URI of the resolution
+  * @param date $made UNIX Timestamp of the date at which the resolution was made
+  * @return boolean Operation successful or not
+  */
+  function create($wgid,$title,$uri,$made) {
+     $this->wgid=$wgid;
+     $this->id = $this->_getTopId();
+     $success = SQLcmd("INSERT INTO resolutions(id, wgid, title, uri, made) VALUES (".
+		$this->id . ", ". $wgid ." ,'" . addslashes($title) . "', ".
+		"'" . addslashes($uri) . "','" . gmdate('Y-m-d',$made) . "')");
+     if ($success) {
+       $this->title = $title;
+       $this->uri = $uri;
+       $this->made = $made;
+       $this->_addChangelog("Created resolution '".$title."' '".$uri."' ". $made,$userid); 
+       return true;
+     } else {
+       return false;
+     }
+  }
+    /**
+  * Update the data on the action item in the db
+  * @param string $title new title
+  * @param int $id resolution identifier
+  * @param string $title new title
+  * @param int $wgid Working Group
+  * @param string $uri URI of the resolution
+  * @param date $made date of the resoltion
+  * @return boolean Operation successful or not
+
+  // Not needed as of today @@@
+  function update($id,$wgid,$title,$uri,$made) {
+    $success = SQLcmd("UPDATE resolutions SET title='".addslashes($title)."',made='".$made."',uri='".addslashes($uri)."' WHERE id=".$this->id." AND wgid=".$this->wgid);
+    if ($success) {
+      if ($this->title != $title) {
+        $this->_addChangelog("title changed to '".$title."'",$userid);
+        $this->title = $title;
+      }
+      if ($this->uri != $uri) {
+        $this->uri = $uri;
+        $this->_addChangelog("URI changed to '".$this->uri."'",$uri);
+      }
+      if ($this->made != $made) {
+        $this->made = $made;
+        $this->_addChangelog("Date made changed to ".date("Y-m-d",$this->made),$made);
+      }	  
+      return true;	  	  
+    }
+    else {
+      return false;
+    }
+  }
+    */
+
+  function toXML() {
+    $xml = "";
+    $xml .= "<resolution>\n";
+    $xml .= "<id>".$this->id."</id>\n" ;
+    $xml .= "<title>".htmlify($this->title)."</title>\n" ;
+    $xml .= "<uri>".$this->uri."</uri>\n" ;
+    $xml .= "<made>".gmdate("Y-m-d",$this->made)."</made>\n" ;
+    $xml .= "</resolution>\n" ;
+    return $xml ;
+  }
+
+}
+
+class ResolutionList extends ObjectList {
+  function ResolutionList() {
+    $this->_objectName="Resolution";
+  }
+
+  function _getWhereFromFilter($filter,$order=array()) {
+    $clauses = array();
+    if ($filter["wgid"]) {
+      $clauses[] = "wgid=".$filter["wgid"];
+    }
+    $where = implode(" AND ",$clauses);
+    $where .= " ORDER BY made" ;
+    return $where;
+  }
+
+} 
+
+/**
+ * class to encapsulate the data of a single note (that gets attached to an action or an issue)
+*/
+class Note extends TaggableObject {
+  var $note;
+  var $entered;
+  var $author;
+  var $wgid;
+  var $id;
+
+  function Note($id,$wgid,$note,$entered,$uid=0) {
+  	$this->_type = "note";
+  	$this->id = $id;
+  	$this->wgid=$wgid;
+    $this->note = $note;
+    $this->entered = $entered;
+    if ($uid) {
+      $this->author = $_SERVER["Application"]->objectFactory("TrackerUser",$uid);
+    }
+  }
+
+  function summary() {
+  	return nl2br(addlinks($this->note))
+  		. "<address>"
+  		.($this->author? "<cite>".htmlify($this->author->name)."</cite>, " : "")
+  		."<span class='date'>".gmdate("j M Y, H:i:s",$this->entered) . "</span>"
+  		. "</address>";
+  }
+}
+
+/**
+ * Class to encapsulate date of a single email 
+*/
+// @@@ Should it extend Note instead?
+class Email extends TaggableObject {
+  var $id;
+  var $wgid;
+  var $uri;
+  var $subject;
+  var $sender;
+  /**
+   * UNIX Timestamp of the time the mail was sent
+   * @var int
+   */
+  var $sent;
+  
+  function Email($id,$wgid,$uri,$subject,$sender,$sent) {
+  	$this->_type = "email";
+  	$this->id = $id;
+  	$this->wgid = $wgid;
+    $this->uri = $uri;
+    $this->subject = $subject;
+    $this->sender = $sender;
+    $this->sent = $sent;
+  }
+
+  function summary() {
+  	return "<a href=\"" . $this->uri . "\">" . htmlify($this->subject) 
+				. "</a> (from " . htmlify($this->sender) . " on " 
+				. htmlify(gmdate('Y-m-d',$this->sent)) . ")";  	
+  }
+  
+}
+
+/**
+ * a tag that annotates either an action, an issue, a note or an email
+ *
+ */
+class Tag extends BaseObject {
+	var $id;
+	var $name;
+	var $description;
+	
+  function Tag($id,$name,$description) {
+    $this->id = $id;
+    $this->tag = $name;
+    $this->name = $description;
+  }	
+}
+
+class TrackerApp {
+	/**
+	 * returns list of Working Groups known in Tracker
+	 * @return array of TrackerWorkingGroup
+	 */
+	function listWorkingGroups() {
+		$ids = $_SERVER["Application"]->_getFromDb("SELECT c.wgid, UNIX_TIMESTAMP(c.last) FROM config c");
+		$ret = array();
+		foreach ($ids as $id) {
+			$ret[] = new TrackerWorkingGroup($id);
+		}
+		return $ret;
+	}
+}
+
+if (function_exists("lastModificationTime")) {
+  lastModificationTime(filemtime(__FILE__));
+}
+
+// To test this module
+if ($argc > 1 && $argv[1]=='--test'  && $argv[0]==substr(__FILE__,strrpos(__FILE__,'/')+1)) {
+  $err = error_reporting(E_ALL);
+  echo "Testing ".__FILE__.":\n";
+  $wg = new TrackerWorkingGroup(112);
+  if ($wg->loadConfig()) {
+    echo "Config for group 112 loaded\n" ;
+  } else {
+    echo "Config for group 112 NOT loaded\n" ;
+  }
+  $wg = new TrackerWorkingGroup(35373);
+  $issues = $wg->listIssues(OPEN);
+  foreach($issues->list as $issue) {
+    echo $issue->title."\n";
+  }
+  $products = $wg->listProducts();
+  foreach($products->list as $product) {
+    echo $product->name."\n";
+  }
+  $tlr = new TrackerUser(36886);
+  echo "TLR IRC nicks:";
+  echo implode(" ",$tlr->listIRCNicks());
+  echo "\n";
+}
+
+?>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/options.php	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,86 @@
+<?php
+require_once("trackerlib.phi");
+require_once("objects.phi");
+$wgid = $_GET["wgid"];
+$wg = new TrackerWorkingGroup($wgid);
+
+if (!$wg->loadConfig()) {
+	WriteErrorpage("Unknown group","The group with id $wgid is not known in Tracker","","404");
+}
+$wg->loadExtraConfig();
+
+if ($wg->acls!='public') {
+	$logonid = checkCredentials();
+	$u = new TrackerUser(0,$logonid);
+	if ($wg->acls=='team' && !$u->isMemberOf(102)&&  !$u->isMemberOf($wg->id)) {
+		WriteErrorpage("Unauthorized","These pages are restricted to the W3C Staff.","",403);
+		exit();
+	} else if ($wg->acls=='member' && !$u->isMemberOf(105)&&  !$u->isMemberOf($wg->id)) {
+		WriteErrorpage("Unauthorized","These pages are restricted to W3C Members.","",403);
+		exit();
+	}
+}
+WriteHTMLTop("Options of the ".htmlify($wg->name)." Tracker",$wg);
+?>
+<dl>
+<dt>IRC Channel</dt>
+<dd><?php echo htmlify($wg->ircchannel);
+if (substr($wg->ircchannel,0,1)=="#") {
+?> (<a href="http://irc.w3.org/?channels=<?php echo substr($wg->ircchannel,1);?>">connect</a>)
+<?php }?>
+</dd>
+<dt>Zakim teleconference code</dt>
+<dd><?php echo htmlify($wg->conferenceid);?> (<a href="http://www.w3.org/Guide/1998/08/teleconference-calendar">Zakim calendar</a>)</dd>
+<dt>ACLs for Tracker data</dt>
+<dd><?php echo htmlify($wg->acls);?></dd>
+<dt>Possible states for Issues</dt>
+<dd><?php
+switch($wg->issueProcess) {
+	case "OC":
+		echo "open, closed";
+		break;
+	case "ROC":
+		echo "raised, open, closed";
+		break;
+	case "PTROC":
+		echo "raised, open, pending review, postponed, closed";
+		break;
+	case "PROC":
+		echo "raised, open, pending review, closed";
+		break;
+	case "TROC":
+		echo "raised, open, postponed, closed";
+		break;		
+}
+?></dd>
+<dt>Watched mailing lists</dt>
+<dd><ul>
+<?php
+foreach($wg->watchedlists as $list) {
+	echo "<li>".htmlify($list);
+	$realm = "";
+	$listname = substr($list, 0, strpos($list, "@"));
+	if (strpos($list,"public-")===0 || strpos($list,"www-")===0) {
+		$realm = "Public";
+	} else if (strpos($list,"member-")===0 || strpos($list,"w3c-")===0) {
+		$realm = "Member";
+	} else if (strpos($list,"team-")===0 || strpos($list,"team-")===0) {
+		$realm = "Team";
+	}
+	if ($realm && $listname) {
+		echo " (<a href='http://lists.w3.org/Archives/"
+		.htmlify($realm."/".$listname."/")."' title='Archives for ".htmlify($list)
+		." mailing lists'>archives</a>)";
+	}
+	echo "</li>\n"; 
+}
+?>
+</ul></dd>
+<dt>Notify users on new action item</dt>
+<dd><?php
+echo ($wg->notifyNewAction== TrackerWorkingGroup::notifyByDefault ? "yes" : "no");
+?></dd>
+</dl>
+<?php
+WriteHTMLFoot('$Id: options.php,v 1.10 2010/05/21 15:16:14 dom Exp $',$wg);
+?>
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/product.php	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,54 @@
+<?php
+/**
+ * HTTP interface to Tracker - list of actions
+ * @package Tracker
+ */
+
+ require_once("objects.phi");
+ require_once("trackerlib.phi");
+ lastModificationTime(filemtime(__FILE__));
+
+// WGid parameter set in URI
+ $wgid = $_GET["wgid"];
+ $wg = new TrackerWorkingGroup($wgid);
+ if (!$wg->loadConfig()) {
+ 	header("404 Not found");
+ 	header("Content-Type: text/plain");
+ 	echo "No config found for group $wgid";
+ 	exit();
+ }
+ if (!array_key_exists("productid",$_GET)) {
+	WriteErrorpage("Bad request","No productid submitted.","",400);
+	exit();
+ }
+ $productid = $_GET["productid"];
+ $product = new Product();
+ if (!$product->load($productid,$wgid)) {
+ 	WriteErrorpage("Not found","No product with id $productid found for the ".$wg->name,"",404);
+ 	exit();
+ }
+ $u = false;
+ // Checking access control
+$logonid = checkCredentials();
+$u = new TrackerUser(0,$logonid);
+$_SERVER["userid"] = $u->id;
+if (!$u->isMemberOf($u->id) && !$u->isMemberOf(109)) {
+	WriteErrorpage("Unauthorized","These pages are restricted to the group participants.","",403);
+	exit();
+}
+if ($_SERVER["REQUEST_METHOD"]=="POST" && array_key_exists("name",$_POST) && $_POST["name"]!="") {
+	$product->update($_POST['name'],$u->id);
+}
+ WriteHTMLTop("Edit product ".htmlify($product->name),&$wg);
+?>
+<form action='' method='post'>
+<p><label>Name: <input type='text' value='<?php echo htmlify($product->name);?>' name='name' /></label></p>
+<p>Notification for new issues sent to: 
+<?php echo htmlify($product->mailinglist ? $product->mailinglist : $wg->mailinglist);?>
+ (ask <a href="mailto:sysreq@w3.org">sysreq@w3.org</a> to pick a  different list).</p> 
+<p><input type='submit' value='Update' /></p>
+</form>
+<?php
+WriteHTMLFoot('$Id: product.php,v 1.3 2010/10/31 22:22:13 dom Exp $',$wg);
+
+?>
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/tag.php	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,52 @@
+<?php
+/**
+ * HTTP interface to Tracker - list of actions
+ * @package Tracker
+ */
+ require_once("objects.phi");
+ require_once("trackerlib.phi");
+ lastModificationTime(filemtime(__FILE__));
+
+// WGid parameter set in URI
+ $wgid = $_GET["wgid"];
+ $wg = new TrackerWorkingGroup($wgid);
+ if (!$wg->loadConfig()) {
+ 	header("404 Not found");
+ 	header("Content-Type: text/plain");
+ 	echo "No config found for group $wgid";
+ 	exit();
+ }
+ $u = false;
+ // Checking access control
+ if ($wg->acls!='public') {
+ 	$logonid = checkCredentials();
+ 	$u = new TrackerUser(0,$logonid);
+ 	$_SERVER["userid"] = $u->id;
+ 	if ($wg->acls=='team' && !$u->isMemberOf(102) &&  !$u->isMemberOf($wg->id)) {
+ 		WriteErrorpage("Unauthorized","These pages are restricted to the W3C Staff.","",403);
+ 		exit();
+ 	} else if ($wg->acls=='member' && !$u->isMemberOf(105) &&  !$u->isMemberOf($wg->id)) {
+ 		WriteErrorpage("Unauthorized","These pages are restricted to W3C Members.","",403);
+ 		exit();
+ 	}
+ }
+ 
+ if ($_SERVER["REQUEST_METHOD"]=="POST" && array_key_exists("actions",$_POST)
+ // checking at least one action was selected
+ && is_array($_POST['actions']) && count($_POST['actions'])
+ // Checking at least one operation was requested
+ && ($_POST["status"]!=-1 || $_POST["due"]!='No change' || $_POST["product"]!=-1)) {
+ 		// Checking credentials
+ 		// Only WG participants are allowed
+ 	if (!$u) {
+ 		$logonid = checkCredentials();
+ 		$u = new TrackerUser(0,$logonid);
+ 		$_SERVER["userid"] = $u->id;
+ 	}
+ 	if (!$u->isMemberOf($wgid)) {
+ 		WriteErrorpage("Unauthorized","Editing actions is reserved to the participants
+ 		in the ".htmlify($wg->name).".","",403);
+ 		exit();
+ 	}
+ }
+?>
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/trackerlib.phi	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,357 @@
+<?php
+/**
+ * This library defines utility functions for Tracker
+ * @package Tracker
+ * @version $Id: trackerlib.phi,v 1.82 2010/11/21 17:11:27 dom Exp $
+ * @author Dominique Hazael-Massieux, Ted Guild, Vivien Lacourba
+*/
+
+/**
+ * Add links to text that looks like http URIs and Issues or Actions
+ * @param string $text text to which links needs to be added
+ * @return string
+ */
+function addlinks($text) {
+  global $wg; // TODO @@@ make the uribase a parameter
+  // Dealing with HTTP URIs
+  $uri_regexp = '%(https?://[^\s<>"]+)%';
+  $uris_matches = preg_split($uri_regexp,$text,-1,PREG_SPLIT_DELIM_CAPTURE);
+  $res = "";
+  foreach ($uris_matches as $match) {
+  	if (preg_match($uri_regexp,$match)) {
+  		    $res .= preg_replace($uri_regexp.'e',"'<a href=\''.htmlify('\\1').'\'>'.htmlify('\\1').'</a>'",$match);
+  	} else {
+  		$match = htmlify($match);
+  		$match = preg_replace('/(issue\-([0-9]+))/i', "<a href=\"" . $wg->uribase . "issues/\\2\">\\1</a>", $match);
+  		$match = preg_replace('/(ISSUE\s+([0-9]+))/', "<a href=\"" . $wg->uribase . "issues/\\2\">\\1</a>", $match);
+  		$match = preg_replace('/(action\-([0-9]+))/i', "<a href=\"" . $wg->uribase . "actions/\\2\">\\1</a>", $match);
+  		$match = preg_replace('/(ACTION\s+([0-9]+))/', "<a href=\"" . $wg->uribase . "actions/\\2\">\\1</a>", $match);
+  		$res .= $match;
+  	}
+  }
+  return $res;
+}
+/**
+ * Outputs the top of an HTML page for tracker
+ * @param string $title Title of the page
+ * @param TrackerWorkingGroup $wg optional working group parameter to output WG-specific data
+ * @param array $operation Hash of URIs/Text that adds links in the right-hand nav bar for context-specific actions
+ * @param array $wgs Array of working groups to list in the top-right box
+ * @param int $userid ID of user whose summary page will be linked to for each group in $wgs
+ */
+function WriteHTMLTop($title,$wg=0,$operation=array(),$wgs=array(),$userid=0,$feed='') {
+  ?>
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml"  xml:lang="en" lang="en">
+<head>
+  <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
+  <link rel="stylesheet" type="text/css" href="/2005/06/tracker/webui.css" media="screen" />
+  <?php
+  if ($feed) {
+  echo "<link rel='alternate' type='application/atom+xml' href='".htmlify($feed)."' title='Chagelog as ATOM feed' />";
+  }
+  ?>
+   <title><?php echo $title.(is_object($wg) ? " - ".htmlify($wg->name)." Tracker" : "")?></title>
+<script type='text/javascript' src='/2007/08/datepicker/js/datepicker.js'></script>
+<script src='/2005/06/tracker/ui.js' type='text/javascript'></script>
+<link href='/2007/08/datepicker/css/datepicker.css' rel='stylesheet' type='text/css' />
+</head>
+<body>
+<div id="sidebar">
+  <div id="navigation">
+<?php
+if (is_object($wg)) {
+?>
+  <h3><a href='<?= htmlify($wg->homepage);?>'><?= $wg->name; ?></a> Issue Tracking</h3>
+  <ul>
+    <li><a href="<?= $wg->uribase; ?>">Summary</a></li>
+  <li>Issues: 
+     <ul>
+     <?php if (strstr($wg->issueProcess,'R')) {
+     ?><li><a href="<?= $wg->uribase; ?>issues/raised">Raised</a></li>
+     <? }  ?>
+<li><a href="<?= $wg->uribase; ?>issues/open">Open</a></li>
+<?php  if (strstr($wg->issueProcess,'P')) {
+     ?><li><a href="<?= $wg->uribase; ?>issues/pendingreview">Pending Review</a></li>
+     <? } ?>
+        <li><a href="<?= $wg->uribase; ?>issues/closed">Closed</a></li>      
+     <?php  if (strstr($wg->issueProcess,'T')) {
+     ?><li><a href="<?= $wg->uribase; ?>issues/postponed">Postponed</a></li>
+     <? } ?>
+         <li><a href="<?= $wg->uribase; ?>issues">All</a></li>
+         <li><a href="<?= $wg->uribase; ?>issues/new">Create</a></li>
+      </ul>
+  </li>
+  <li>Actions:
+  <ul>
+      <li><a href="<?= $wg->uribase; ?>actions/open">Open</a></li>
+      <li><a href="<?= $wg->uribase; ?>actions/overdue">Overdue</a></li>
+      <li><a href="<?= $wg->uribase; ?>actions/closed">Closed</a></li>
+      <li><a href="<?= $wg->uribase; ?>actions/pendingreview">Pending Review</a></li>
+<li><a href="<?= $wg->uribase; ?>actions/new">Raise</a></li>
+  </ul>
+  </li>
+  <li><a href="<?= $wg->uribase; ?>users">Users</a>
+  <ul><li><a href='/2005/06/tracker/users/my'><em>My</em> Tracker</a></li></ul>
+  </li> 
+  <li><a href="<?= $wg->uribase; ?>products">Products</a></li>
+  <li><a href="<?= $wg->uribase; ?>agenda">Agenda planning</a></li>
+  <li><a href="<?= $wg->uribase; ?>changelog">Recent activity</a></li> 
+  </ul>
+<?php
+} else {
+?>
+  <h3>Issue Tracking</h3>
+<?php
+  if (count($wgs)) {
+?>
+  <ul>
+    <li>Information on this user in:
+      <ul>
+<?php
+    foreach ($wgs as $g) {
+?>
+        <li><a href="<?= htmlify($g->uribase)?>users/<?= $userid?>"><?= htmlify($g->name)?></a></li>
+<?php
+    }
+?>
+      </ul>
+    </li>
+  </ul>
+<?php
+  }
+}
+ echo "</div>\n" ;
+
+if (count($operation)) {
+?>
+<div id="operation">
+  <ul>
+    <li><a href="<?= $operation['link']?>"><?= $operation['text']?></a></li>
+  </ul>
+</div>
+<?php
+   }
+?>
+</div>
+
+<div id="content">
+<?php
+echo "<h1>".$title."</h1>\n";
+}
+
+
+/**
+ * Outputs an HTML footer for the given page
+ * @param string $Id CVS Id to be displayed as part of the footer
+ * @param TrackerWorkingGroup $wg Optional wg parameter to output WG-specific data
+ */
+function WriteHTMLFoot($Id,$wg=0) {
+?>
+</div>
+<div class="diagnostics">
+<?php
+  if (isset($_SERVER["Application"])) {
+    $_SERVER["Application"]->printErrors();
+  }
+
+?>
+</div>
+<hr />
+<address>
+<?php
+    if (is_object($wg)) {
+  $chairs = $wg->getChairs();
+  if (is_object($chairs)) {
+    while($c = $chairs->next()) {
+      echo htmlify($c->name)." &lt;<a href='mailto:".htmlify($c->email)."'>".htmlify($c->email)."</a>&gt;, ";
+    }
+    echo "Chair".($chairs->count() > 1 ? "s" : "").", ";
+  }
+  $scs = $wg->getStaffContacts();
+  if (is_object($scs)) {
+    while($s = $scs->next()) {
+      echo htmlify($s->name)." &lt;<a href='mailto:".htmlify($s->email)."'>".htmlify($s->email)."</a>&gt;, ";
+    }
+    echo "Staff Contact".($scs->count() > 1 ? "s" : "");
+  }
+    }
+?>
+<br />
+<a href="/2005/06/tracker/">Tracker</a> 
+<?php if (is_object($wg)) { echo "(<a href='".htmlify($wg->uribase)."options'>configuration for this group</a>)"; } ?>
+, originally developed by <a href='/People/Dean/'>Dean Jackson</a>, 
+is developed and maintained by the Systems Team &lt;<a href='mailto:w3t-sys@w3.org'>w3t-sys@w3.org</a>&gt;.<br />
+<?php echo $Id;?>
+</address>
+</body>
+</html>
+<?php
+}
+
+function WriteErrorpage($title,$message,$useless,$code) {
+  if (php_sapi_name()=='CGI' || php_sapi_name()=='cgi') {
+    Header("Status: $code $title");
+  } else {
+    Header("HTTP/1.0 $code $title");
+  }
+  WriteHTMLTop($title);
+  echo $message;
+  WriteHTMLFoot('$Id: trackerlib.phi,v 1.82 2010/11/21 17:11:27 dom Exp $');
+  exit();
+}
+
+function displayactionslist($actions,$qualifier,$sortable=false,$sortedBy='id',$editable=false, $multipleWgs = false) {
+	if ($sortable) {
+		$displaySorter = create_function('$type',
+		'if ($type=="'.$sortedBy.'") { echo "<span class=\"sort\">&darr;</span>";}
+		else {echo "<a class=\"sort\" title=\"sort by $type\" href=\"?sort=$type\">&darr;</a>";}');
+	} else {
+		$displaySorter = create_function('$type','return;');
+	}
+	global $humanReadableState;
+	$oneWG = !$multipleWgs;
+  echo "<p>There ".(count($actions->list)==1 ? "is" : "are")." ".count($actions->list)
+  .($qualifier ? " $qualifier" : "")." action".(count($actions->list)==1 ? "" : "s").
+  ".</p>\n" ;
+  if (count($actions->list) > 0) {
+?>
+
+<table id='<?php echo $qualifier."_action";?>' title='List of <?php echo $qualifier." action items sorted by $sortedBy";?>'>
+<thead>
+<tr>
+<th><?php $displaySorter('id');?>ID</th>
+<th><?php $displaySorter('status');?>State</th>
+<th>Title</th>
+<th><?php $displaySorter('owner');?>Person</th>
+<th><?php $displaySorter('due');?>Due Date</th>
+<th>Associated with</th>
+<?php if (!$oneWG) {
+	echo "<th>Working Group</th>\n";
+} ?>
+</tr>
+</thead>
+<tbody>
+<?php
+   $count = 0;
+ foreach($actions->list as $action) { 
+   $wg = $_SERVER["Application"]->objectFactory("TrackerWorkingGroup",$action->wgid);
+   $wg->loadConfig();
+      echo("<tr class=\"");
+      if ($action->status ==CLOSED) {
+        echo "closed ";
+      }
+      if ($count % 2 == 0) {
+        echo("even\">");
+      } else {
+	echo("odd\">");
+      }
+      $count++;
+      echo("<td class='action'>");
+      if ($editable) {
+      	echo "<input title='Check to select action &quot;".htmlify($action->title)."&quot; for en-masse editing'
+      	name='actions[".$action->id."]' type='checkbox' />";
+      }
+      echo "";
+      echo ("<a href=\"" .$wg->uribase. "actions/" . $action->id . "\">ACTION-" . $action->id . "</a><a href='".$wg->uribase."actions/".$action->id."/edit' title='Edit ACTION-".$action->id."'><img width='8' height='12' src='/2002/09/wbs/icons/stock_edit2' alt=' (edit)'  /></a></td>\n");
+	  echo "<td class='".strtolower(str_replace(' ','',$humanReadableState[$action->status]))."'>".
+	  $humanReadableState[$action->status]."</td>\n";
+      echo("<td><a href=\"" .$wg->uribase. "actions/" . $action->id . "\">" . htmlify($action->title) . "</a></td>\n");  
+      echo("<td>" . htmlify($action->owner->name) . "</td>\n");
+      if ($action->duedate < time()  && $action->status == OPEN) {
+        echo("<td><span class=\"overdue\">" . gmdate("Y-m-d",$action->duedate) . "</span></td>\n");
+      } else { 
+        echo("<td>" . gmdate("Y-m-d",$action->duedate) . "</td>\n");
+      } 
+      if ($action->issue) {
+        echo "<td><a href='".htmlify($wg->uribase)."issues/".$action->issue->id."'>".($action->issue->nickname ? htmlify($action->issue->nickname) : "ISSUE-".$action->issue->id)."</a></td>\n" ;
+      } else if ($action->product) {
+        echo "<td><a href='".htmlify($wg->uribase)."products/".$action->product->id."'>".htmlify($action->product->name)."</a></td>\n" ;
+      } else {
+        echo "<td></td>\n";
+      }
+      if (!$oneWG) {
+      	echo "<td><a href='".htmlify($wg->uribase)."'>".htmlify($wg->name)."</a></td>\n";
+      }
+      echo("</tr>\n");
+    }
+?>
+</tbody>
+</table>
+<?php
+    }
+}
+
+function displayissueslist($issues,$qualifier,$sortable=false,$sortedBy='id',$editable=false, $multipleWgs = false) {
+	if ($sortable) {
+		$displaySorter = create_function('$type',
+		'if ($type=="'.$sortedBy.'") { echo "<span class=\"sort\">&darr;</span>";}
+		else {echo "<a class=\"sort\" title=\"sort by $type\" href=\"?sort=$type\">&darr;</a>";}');
+	} else {
+		$displaySorter = create_function('$type','return;');
+	}
+	$oneWG =!$multipleWgs;
+  echo "<p>There ".(count($issues->list)==1 ? "is" : "are")." ".count($issues->list).($qualifier ? " $qualifier" : "")." issue".(count($issues->list)==1 ? "" : "s")." listed in the system.</p>\n" ;
+
+  if (count($issues->list) > 0) {
+?>
+
+<table id='<?php echo $qualifier."_issues";?>' title='List of <?php echo $qualifier." issues sorted by $sortedBy";?>'>
+<thead>
+<tr>
+<th><?php $displaySorter('id');?>ID</th>
+<th><?php $displaySorter('status');?>State</th>
+<th>Title</th>
+<th>Raised on</th>
+<th><?php $displaySorter('product');?>Product</th>
+<th>Open Actions</th>
+<?php if (!$oneWG) {
+	echo "<th>Working Group</th>\n";
+} ?>
+</tr>
+</thead>
+<tbody>
+<?php
+    $count = 0;
+ foreach ($issues->list as $issue) { 
+   $wg = $_SERVER["Application"]->objectFactory("TrackerWorkingGroup",$issue->wgid);
+   $wg->loadConfig();
+      echo("<tr class=\"");
+      if ($issue->status ==CLOSED || $issue->status==POSTPONED) {
+        echo "closed ";
+      }
+
+      if ($count % 2 == 0) {
+        echo("even\">");
+      } else {
+	echo("odd\">");
+      }
+      $count++;
+      echo("<td class='issue'>");
+      if ($editable) {
+      	echo "<input title='Check to select issue &quot;".
+      	htmlify($issue->title)."&quot; for en-masse editing'
+      	name='issues[".$issue->id."]' type='checkbox' />";
+      }
+      
+      echo ("<a href=\"" .$wg->uribase. "issues/" . $issue->id . "\">ISSUE-" . $issue->id . "</a><a href='".$wg->uribase."issues/".$issue->id."/edit' title='Edit ISSUE-".$issue->id."'><img width='8' height='12' src='/2002/09/wbs/icons/stock_edit2' alt=' (edit)'  /></a>".($issue->nickname ? "<br />".htmlify($issue->nickname) : "")."</td>\n");
+      echo "<td class='".$issue->_readableStatus[$issue->status]."'>".strtoupper($issue->_readableStatus[$issue->status])."</td>\n";
+      echo("<td><a href=\"" . $wg->uribase . "issues/" . $issue->id . "\">" . htmlify($issue->title) . "</a></td>\n");  
+      echo("<td>" . gmdate('Y-m-d',$issue->created) . "</td>\n");
+      echo("<td>" . htmlify($issue->product->name) . "</td>\n");
+      $actions = $issue->listActionItems(OPEN);
+      echo("<td>" . count($actions->list) . "</td>\n");
+ if (!$oneWG) {
+      	echo "<td><a href='".htmlify($wg->uribase)."'>".htmlify($wg->name)."</a></td>\n";
+      }
+      echo("</tr>\n");
+    }
+?>
+</tbody>
+</table>
+<?php
+    }
+}
+
+?>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/user.php	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,112 @@
+<?php
+/**
+ * HTTP interface to cross-wg view of Tracker per user
+ * @package Tracker
+ */
+
+require_once("/afs/w3.org/pub/WWW/2005/06/tracker/src/objects.phi");
+require_once("/afs/w3.org/pub/WWW/2005/06/tracker/src/trackerlib.phi");
+lastModificationTime(filemtime(__FILE__));
+
+//userid parameter set in URI
+$userid = $_GET["id"];
+if (!$userid) {
+  WriteErrorpage("Bad Request","The <code>id</code> parameter was not set.","",400);
+}
+$viewer = new User(0,checkCredentials());
+if ($userid=="my") {
+  $userid = $viewer->id;
+}
+
+$user = new TrackerUser($userid);
+if (!$user->id ) {
+  WriteErrorpage("Unkonwn user","There is no user with <code>id</code> $userid.","",404);
+}
+
+$singleWG = false;
+if (array_key_exists('wgid',$_GET)) {
+	$singleWG = new TrackerWorkingGroup($wgid);
+	$singleWG->loadConfig();
+	$wgs = array($singleWG);
+} else {
+	$wgs = $user->listGroups();
+}
+
+// filtering list of groups based on permissions
+$wgids = array(); 
+$allowedwgs = array();
+foreach($wgs as $wg) {
+	if ($viewer->isMemberOf($wg->id) || $viewer->isMemberOf(102) || $user->id==$viewer->id) {
+		$wgids[] = $wg->id;
+		$allowedwgs[] = $wg;
+	} else {
+		if ( $wg->acls==TrackerWorkingGroup::aclsMember && $user->isMemberOf(105)) {
+			$wgids[] = $wg->id;
+			$allowedwgs[] = $wg;
+		} else if ($wg->acls==TrackerWorkingGroup::aclsPublic) {
+			$wgids[] = $wg->id;
+			$allowedwgs[] = $wg;
+		}
+	}
+}
+
+if (count($wgids)==0) {
+    WriteErrorpage("Forbidden","You are not allowed to see any of action items for this user.","",403);
+}
+$operation = array();
+$success = 0;
+if ($viewer->id==$user->id && $singleWG) {
+	$operation = array('link'=>$singleWG->uribase.'users/'.$user->id.".ics",'text'=>"Actions as iCalendar");
+	if (array_key_exists('update',$_POST)) {
+		if ($user->setEmailNotifPref($singleWG->id, $_POST['notify'])) {
+			$success = 1;
+		} else {
+			$success = -1 ;
+		}
+	}
+}
+
+WriteHTMLTop("Tracker summary for ".$user->name, ($singleWG ? $singleWG : 0), $operation, 
+($singleWG ? null : $allowedwgs), $user->id);
+if ($singleWG) {
+	echo "<h2>".htmlify($singleWG->name)." Tracker</h2>\n";
+}
+echo("<h2>Open Actions</h2>\n");
+displayactionslist($user->listActionItems($wgids),"open",false,'id',false, !$singleWG);
+echo("<h2>Open Issues</h2>\n");
+displayissueslist($user->listIssues($wgids),"open",false, 'id', false, !$singleWG);
+if ($viewer->id==$user->id && $singleWG) {
+	?>
+<h2 id='settings'>Settings</h2>
+<form action='#settings' method='post'>
+<?php 
+if ($success==1) {
+	echo "<p><strong>Settings updated</strong>.</p>";
+} else if ($success == -1) {
+	echo "<p><strong>Settings update failed</strong> — contact sysreq@w3.org if this persists.</p>";
+}
+?>
+<p><label>Send email notifications for new action items assigned to you<br />
+<select name='notify'>
+<?php
+	$userNotifyConfig = $user->wantsNewActionNotif($singleWG->id);
+	echo "<option value='".htmlify(TrackerUser::notifyDefault)
+		."'".($userNotifyConfig == TrackerUser::notifyDefault ? " selected='selected'" : "")
+		.">According to group default (currently: "
+		.($wg->notifyNewAction==TrackerWorkingGroup::notifyByDefault ?
+			"yes" : "no")
+		.")</option>";
+	echo "<option value='".htmlify(TrackerUser::notifyAlways)
+		."'".($userNotifyConfig == TrackerUser::notifyAlways ? " selected='selected'" : "")
+		.">Yes</option>";
+	echo "<option value='".htmlify(TrackerUser::notifyNever)
+		."'".($userNotifyConfig == TrackerUser::notifyNever ? " selected='selected'" : "")
+		.">No</option>";
+	
+?></select></label> <input type='submit' name='update' value='Update settings' /></p>
+</form>
+	<?php
+}
+
+WriteHTMLFoot('$Id: user.php,v 1.28 2010/05/26 07:17:53 dom Exp $');
+?>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/users.php	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,158 @@
+<?php
+/**
+ * HTTP interface to Tracker list of users
+ * @package Tracker
+ */
+
+ require_once("objects.phi");
+ require_once("trackerlib.phi");
+ lastModificationTime(filemtime(__FILE__));
+
+ // WGid parameter set in URI
+ $wgid = $_GET["wgid"];
+ $wg = new TrackerWorkingGroup($wgid);
+ if (!$wg->loadConfig()) {
+ 	header("404 Not found");
+ 	header("Content-Type: text/plain");
+ 	echo "No config found for group $wgid";
+ 	exit();
+ }
+ $u = false;
+ // Checking access control
+ if ($wg->acls!='public' || array_key_exists('login',$_GET)) {
+ 	$logonid = checkCredentials();
+ 	$u = new TrackerUser(0,$logonid);
+ 	$_SERVER["userid"] = $u->id;
+ 	if ($wg->acls=='team' && !$u->isMemberOf(102)&&  !$u->isMemberOf($wg->id)) {
+ 		WriteErrorpage("Unauthorized","These pages are restricted to the W3C Staff.","",403);
+ 		exit();
+ 	} else if ($wg->acls=='member' && !$u->isMemberOf(105)&&  !$u->isMemberOf($wg->id)) {
+ 		WriteErrorpage("Unauthorized","These pages are restricted to W3C Members.","",403);
+ 		exit();
+ 	}
+ }
+
+ // Initializing messages for invalid input on nick submissions
+ $nickErrors = array();
+ $nickAdded = array();
+
+ // Is the current user (if identified) allowed to edit nicknames in this group?
+ $allowEditAll = false;
+ if ($u) {
+ 	if (($u->getRole($wg->id) & 1 || $u->isMemberOf(102))) {
+ 		$allowEditAll = true;
+ 	}
+ }
+
+
+ // If we have a POST request (nick name add)
+ if ($_SERVER["REQUEST_METHOD"]=="POST" && array_key_exists("nick",$_POST)
+ && is_array($_POST['nick'])) {
+ 	// Checking credentials
+ 	// Allowed for Staff contact and Chairs of the group
+ 	// or for self
+ 	if (!$u) {
+ 		$logonid = checkCredentials();
+ 		$u = new TrackerUser(0,$logonid);
+ 		$_SERVER["userid"] = $u->id;
+ 	}
+ 	$nick_ids = array_keys($_POST["nick"]);
+ 	$nickid = $nick_ids[0];
+ 	$editedUser = new TrackerUser($nickid);
+ 	if (!$editedUser->isMemberOf($wg->id)) {
+ 		WriteErrorpage("Unauthorized",htmlify($editedUser->name)." is not a member of the ".
+ 		htmlify($wg->name).".","",403);
+ 	}
+ 	if (! (($u->getRole($wg->id) & 1 || $u->isMemberOf(102)) // Chair or Staff
+ 	|| ($u->id==$nickid))) {
+ 		WriteErrorpage("Unauthorized","Only Chair and Staff contacts are allowed to edit nick names
+ 		for others.","",403);
+ 	}
+ 	$nick = $_POST["nick"][$nickid];
+
+ 	if (!preg_match('/^[-a-zA-Z_0-9]*$/',$nick)) {
+ 		$nickErrors[$nickid]="Only alphanumeric characters are allowed in nicknames";
+ 	} else {
+ 		if ($editedUser->addNick($nick)) {
+ 			$nickAdded[$nickid]=$nick;
+ 		}
+ 	}
+ }
+
+ $users = $wg->listMembers();
+ foreach($users as $user) {
+ 	$user->listIRCNicks();   	
+ }
+ 
+ // Output starts
+ WriteHTMLTop("Group participants",&$wg);
+ if ($wg->acls=='public' &&  !array_key_exists('login',$_GET)) {
+ 	echo "<p>If you’re a Working Group member, 
+ 	<a href='?login'>login to be allowed to edit</a> nicknames.</p>";
+ }
+
+ if (count($nickErrors)) {
+ 	foreach ($nickErrors as $id=>$error) {
+ 		$editedUser = new TrackerUser($id);
+ 		echo "<p>The submitted nickname was not added to <a href='#x$id'>"
+ 		.htmlify($editedUser->name)."'s profile</a> due to an error in the input; please fix it...</p>";
+ 	}
+
+ }
+ if (count($nickAdded)) {
+ 	foreach ($nickAdded as $id=>$nick) {
+ 		$editedUser = new TrackerUser($id);
+ 		echo "<p>&quot;$nick&quot; was added to <a href='#x$id'>"
+ 		.htmlify($editedUser->name)."'s profile</a>.</p>";
+ 	}
+ }
+ echo("<p>There are ". count($users) . " users listed in the system.</p>\n");
+ ?>
+
+<table>
+	<thead>
+		<tr>
+			<th>Name</th>
+			<th>Nicknames</th>
+		</tr>
+	</thead>
+	<tbody>
+	<?php
+	$count = 0;
+	foreach($users as $user) {
+		echo("<tr class=\"");
+		if ($count % 2 == 0) {
+			echo("even\">");
+		} else {
+			echo("odd\">");
+		}
+		$count++;
+		echo "<td><a href='".$wg->uribase."users/".$user->id."'>".htmlify($user->name)."</a></td>\n";
+		echo "<td  id='x".$user->id."'>";
+		$nicks = $user->listIRCNicks();
+		echo join(', ',$nicks);
+		// We only show the edit nick form when the person can edit it
+		if ($allowEditAll || (is_object($u) && $u->id==$user->id)) {
+			echo "
+		<form action='".(array_key_exists('login',$_GET) ? '?login' : '')."' method='post' class='inline'><p><input name='nick[".$user->id."]'";
+			if ($nickErrors[$user->id]) {
+				echo " class='inputError'";
+			}
+			echo " value='' type='text' size='10' /> <input type='submit' value='Add nick' />";
+			if ($nickErrors[$user->id]) {
+				echo "<br /><strong class='inputError'>".
+				$nickErrors[$user->id]."</strong>";
+			} else if ($nickAdded[$user->id]) {
+				echo "<br /><strong>Nick ".$nickAdded[$u->id]." added.</strong>";
+			}
+			echo "</p></form>";
+		}
+		echo "</td>";
+		echo("</tr>\n");
+	}
+	?>
+	</tbody>
+</table>
+	<?php
+	WriteHTMLFoot('$Id: users.php,v 1.24 2009/10/20 14:42:36 dom Exp $',$wg);
+	?>
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/trackbot/README	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,53 @@
+Here are some notes explaining how to run a test instance of the Tracker
+IRC Bot.
+-- Vivien, $Id: README,v 1.1 2007/12/20 16:03:58 sysbot Exp $
+
+
+1. First we want to work on the latest version of trackbot.
+We make a copy of the actual version and change the bot's nickname
+
+homer:~# su - sysbot
+sysbot@homer:~$ cd tracker/trackbot/
+sysbot@homer:~/tracker/trackbot$ cp trackbot-ng trackbot-test
+sysbot@homer:~/tracker/trackbot$ vi trackbot-test
+nickname = "trackbot-test"
+
+2. Now on IRC tell trackbot-ng to leave the #tracker channel as that's where
+we are going to run our test version
+
+/join #tracker
+<vivien> trackbot-ng, leave
+* trackbot-ng (trackbot-n@128.30.52.30) has left #tracker
+
+3. Apply your changes to trackbot-test
+
+4. Test your new trackbot-test version
+
+sysbot@homer:~/tracker/trackbot$ /usr/bin/python -u /home/sysbot/tracker/trackbot/trackbot-test irc.w3.org:6667
+
+5. trackbot-test will join the #tracker IRC channel (and only this one)
+You can then test your modifications on IRC.
+
+* trackbot-test (trackbot-t@128.30.52.30) has joined #tracker
+* trackbot-test is loading Systeam Test Group data...
+* trackbot-test found 3 users
+<trackbot-test> Tracking ISSUEs and ACTIONs from http://www.w3.org/2005/06/tracker/dev/
+
+6. If you want to do more changes then do a ^C to kill trackbot-test and
+go back to step 3.
+
+7. If you are confident with your modifications, sync trackbot-ng with the new
+version. Don't forget to update the IRC nickname and commit your changes.
+
+sysbot@homer:~/tracker/trackbot$ cp trackbot-test trackbot-ng
+sysbot@homer:~/tracker/trackbot$ vi trackbot-ng
+nickname = "trackbot-ng"
+sysbot@homer:~/tracker/trackbot$ cvs commit -m 'fix bug...' trackbot-ng
+
+8. Then restart trackbot-ng. (You only need to kill it's process as the
+watchdog script will take care of restarting it).
+
+sysbot@homer:~/tracker/trackbot$ ps ax | grep trackbot-ng
+10043 ?        S      0:00 /usr/bin/python -u /home/sysbot/tracker/trackbot/trackbot-ng irc.w3.org:6667
+sysbot@homer:~/tracker/trackbot$ kill -9 10043
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/trackbot/ircbot.py	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,432 @@
+# Copyright (C) 1999--2002  Joel Rosdahl
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307  USA
+#
+# Joel Rosdahl <joel@rosdahl.net>
+#
+# $Id: ircbot.py,v 1.1 2007/03/26 14:16:16 ted Exp $
+
+"""ircbot -- Simple IRC bot library.
+
+This module contains a single-server IRC bot class that can be used to
+write simpler bots.
+"""
+
+import sys
+import string
+from UserDict import UserDict
+
+from irclib import SimpleIRCClient
+from irclib import nm_to_n, irc_lower, all_events
+from irclib import parse_channel_modes, is_channel
+from irclib import ServerConnectionError
+
+class SingleServerIRCBot(SimpleIRCClient):
+    """A single-server IRC bot class.
+
+    The bot tries to reconnect if it is disconnected.
+
+    The bot keeps track of the channels it has joined, the other
+    clients that are present in the channels and which of those that
+    have operator or voice modes.  The "database" is kept in the
+    self.channels attribute, which is an IRCDict of Channels.
+    """
+    def __init__(self, server_list, nickname, realname, reconnection_interval=60):
+        """Constructor for SingleServerIRCBot objects.
+
+        Arguments:
+
+            server_list -- A list of tuples (server, port) that
+                           defines which servers the bot should try to
+                           connect to.
+
+            nickname -- The bot's nickname.
+
+            realname -- The bot's realname.
+
+            reconnection_interval -- How long the bot should wait
+                                     before trying to reconnect.
+
+            dcc_connections -- A list of initiated/accepted DCC
+            connections.
+        """
+
+        SimpleIRCClient.__init__(self)
+        self.channels = IRCDict()
+        self.server_list = server_list
+        if not reconnection_interval or reconnection_interval < 0:
+            reconnection_interval = 2**31
+        self.reconnection_interval = reconnection_interval
+
+        self._nickname = nickname
+        self._realname = realname
+        for i in ["disconnect", "join", "kick", "mode",
+                  "namreply", "nick", "part", "quit"]:
+            self.connection.add_global_handler(i,
+                                               getattr(self, "_on_" + i),
+                                               -10)
+    def _connected_checker(self):
+        """[Internal]"""
+        if not self.connection.is_connected():
+            self.connection.execute_delayed(self.reconnection_interval,
+                                            self._connected_checker)
+            self.jump_server()
+
+    def _connect(self):
+        """[Internal]"""
+        password = None
+        if len(self.server_list[0]) > 2:
+            password = self.server_list[0][2]
+        try:
+            self.connect(self.server_list[0][0],
+                         self.server_list[0][1],
+                         self._nickname,
+                         password,
+                         ircname=self._realname)
+        except ServerConnectionError:
+            pass
+
+    def _on_disconnect(self, c, e):
+        """[Internal]"""
+        self.channels = IRCDict()
+        self.connection.execute_delayed(self.reconnection_interval,
+                                        self._connected_checker)
+
+    def _on_join(self, c, e):
+        """[Internal]"""
+        ch = e.target()
+        nick = nm_to_n(e.source())
+        if nick == c.get_nickname():
+            self.channels[ch] = Channel()
+        self.channels[ch].add_user(nick)
+
+    def _on_kick(self, c, e):
+        """[Internal]"""
+        nick = e.arguments()[0]
+        channel = e.target()
+
+        if nick == c.get_nickname():
+            del self.channels[channel]
+        else:
+            self.channels[channel].remove_user(nick)
+
+    def _on_mode(self, c, e):
+        """[Internal]"""
+        modes = parse_channel_modes(string.join(e.arguments()))
+        t = e.target()
+        if is_channel(t):
+            ch = self.channels[t]
+            for mode in modes:
+                if mode[0] == "+":
+                    f = ch.set_mode
+                else:
+                    f = ch.clear_mode
+                f(mode[1], mode[2])
+        else:
+            # Mode on self... XXX
+            pass
+
+    def _on_namreply(self, c, e):
+        """[Internal]"""
+
+        # e.arguments()[0] == "="     (why?)
+        # e.arguments()[1] == channel
+        # e.arguments()[2] == nick list
+
+        ch = e.arguments()[1]
+        for nick in string.split(e.arguments()[2]):
+            if nick[0] == "@":
+                nick = nick[1:]
+                self.channels[ch].set_mode("o", nick)
+            elif nick[0] == "+":
+                nick = nick[1:]
+                self.channels[ch].set_mode("v", nick)
+            self.channels[ch].add_user(nick)
+
+    def _on_nick(self, c, e):
+        """[Internal]"""
+        before = nm_to_n(e.source())
+        after = e.target()
+        for ch in self.channels.values():
+            if ch.has_user(before):
+                ch.change_nick(before, after)
+
+    def _on_part(self, c, e):
+        """[Internal]"""
+        nick = nm_to_n(e.source())
+        channel = e.target()
+
+        if nick == c.get_nickname():
+            del self.channels[channel]
+        else:
+            self.channels[channel].remove_user(nick)
+
+    def _on_quit(self, c, e):
+        """[Internal]"""
+        nick = nm_to_n(e.source())
+        for ch in self.channels.values():
+            if ch.has_user(nick):
+                ch.remove_user(nick)
+
+    def die(self, msg="Bye, cruel world!"):
+        """Let the bot die.
+
+        Arguments:
+
+            msg -- Quit message.
+        """
+        self.connection.quit(msg)
+        sys.exit(0)
+
+    def disconnect(self, msg="I'll be back!"):
+        """Disconnect the bot.
+
+        The bot will try to reconnect after a while.
+
+        Arguments:
+
+            msg -- Quit message.
+        """
+        self.connection.quit(msg)
+
+    def get_version(self):
+        """Returns the bot version.
+
+        Used when answering a CTCP VERSION request.
+        """
+        return "ircbot.py by Joel Rosdahl <joel@rosdahl.net>"
+
+    def jump_server(self):
+        """Connect to a new server, possibly disconnecting from the current.
+
+        The bot will skip to next server in the server_list each time
+        jump_server is called.
+        """
+        if self.connection.is_connected():
+            self.connection.quit("Jumping servers")
+        self.server_list.append(self.server_list.pop(0))
+        self._connect()
+
+    def on_ctcp(self, c, e):
+        """Default handler for ctcp events.
+
+        Replies to VERSION and PING requests and relays DCC requests
+        to the on_dccchat method.
+        """
+        if e.arguments()[0] == "VERSION":
+            c.ctcp_reply(nm_to_n(e.source()),
+                         "VERSION " + self.get_version())
+        elif e.arguments()[0] == "PING":
+            if len(e.arguments()) > 1:
+                c.ctcp_reply(nm_to_n(e.source()),
+                             "PING " + e.arguments()[1])
+        elif e.arguments()[0] == "DCC" and e.arguments()[1] == "CHAT":
+            self.on_dccchat(c, e)
+
+    def on_dccchat(self, c, e):
+        pass
+
+    def start(self):
+        """Start the bot."""
+        self._connect()
+        SimpleIRCClient.start(self)
+
+
+class IRCDict:
+    """A dictionary suitable for storing IRC-related things.
+
+    Dictionary keys a and b are considered equal if and only if
+    irc_lower(a) == irc_lower(b)
+
+    Otherwise, it should behave exactly as a normal dictionary.
+    """
+
+    def __init__(self, dict=None):
+        self.data = {}
+        self.canon_keys = {}  # Canonical keys
+        if dict is not None:
+            self.update(dict)
+    def __repr__(self):
+        return repr(self.data)
+    def __cmp__(self, dict):
+        if isinstance(dict, IRCDict):
+            return cmp(self.data, dict.data)
+        else:
+            return cmp(self.data, dict)
+    def __len__(self):
+        return len(self.data)
+    def __getitem__(self, key):
+        return self.data[self.canon_keys[irc_lower(key)]]
+    def __setitem__(self, key, item):
+        if self.has_key(key):
+            del self[key]
+        self.data[key] = item
+        self.canon_keys[irc_lower(key)] = key
+    def __delitem__(self, key):
+        ck = irc_lower(key)
+        del self.data[self.canon_keys[ck]]
+        del self.canon_keys[ck]
+    def clear(self):
+        self.data.clear()
+        self.canon_keys.clear()
+    def copy(self):
+        if self.__class__ is UserDict:
+            return UserDict(self.data)
+        import copy
+        return copy.copy(self)
+    def keys(self):
+        return self.data.keys()
+    def items(self):
+        return self.data.items()
+    def values(self):
+        return self.data.values()
+    def has_key(self, key):
+        return self.canon_keys.has_key(irc_lower(key))
+    def update(self, dict):
+        for k, v in dict.items():
+            self.data[k] = v
+    def get(self, key, failobj=None):
+        return self.data.get(key, failobj)
+
+
+class Channel:
+    """A class for keeping information about an IRC channel.
+
+    This class can be improved a lot.
+    """
+
+    def __init__(self):
+        self.userdict = IRCDict()
+        self.operdict = IRCDict()
+        self.voiceddict = IRCDict()
+        self.modes = {}
+
+    def users(self):
+        """Returns an unsorted list of the channel's users."""
+        return self.userdict.keys()
+
+    def opers(self):
+        """Returns an unsorted list of the channel's operators."""
+        return self.operdict.keys()
+
+    def voiced(self):
+        """Returns an unsorted list of the persons that have voice
+        mode set in the channel."""
+        return self.voiceddict.keys()
+
+    def has_user(self, nick):
+        """Check whether the channel has a user."""
+        return self.userdict.has_key(nick)
+
+    def is_oper(self, nick):
+        """Check whether a user has operator status in the channel."""
+        return self.operdict.has_key(nick)
+
+    def is_voiced(self, nick):
+        """Check whether a user has voice mode set in the channel."""
+        return self.voiceddict.has_key(nick)
+
+    def add_user(self, nick):
+        self.userdict[nick] = 1
+
+    def remove_user(self, nick):
+        for d in self.userdict, self.operdict, self.voiceddict:
+            if d.has_key(nick):
+                del d[nick]
+
+    def change_nick(self, before, after):
+        self.userdict[after] = 1
+        del self.userdict[before]
+        if self.operdict.has_key(before):
+            self.operdict[after] = 1
+            del self.operdict[before]
+        if self.voiceddict.has_key(before):
+            self.voiceddict[after] = 1
+            del self.voiceddict[before]
+
+    def set_mode(self, mode, value=None):
+        """Set mode on the channel.
+
+        Arguments:
+
+            mode -- The mode (a single-character string).
+
+            value -- Value
+        """
+        if mode == "o":
+            self.operdict[value] = 1
+        elif mode == "v":
+            self.voiceddict[value] = 1
+        else:
+            self.modes[mode] = value
+
+    def clear_mode(self, mode, value=None):
+        """Clear mode on the channel.
+
+        Arguments:
+
+            mode -- The mode (a single-character string).
+
+            value -- Value
+        """
+        try:
+            if mode == "o":
+                del self.operdict[value]
+            elif mode == "v":
+                del self.voiceddict[value]
+            else:
+                del self.modes[mode]
+        except KeyError:
+            pass
+
+    def has_mode(self, mode):
+        return mode in self.modes
+
+    def is_moderated(self):
+        return self.has_mode("m")
+
+    def is_secret(self):
+        return self.has_mode("s")
+
+    def is_protected(self):
+        return self.has_mode("p")
+
+    def has_topic_lock(self):
+        return self.has_mode("t")
+
+    def is_invite_only(self):
+        return self.has_mode("i")
+
+    def has_message_from_outside_protection(self):
+        # Eh... What should it be called, really?
+        return self.has_mode("n")
+
+    def has_limit(self):
+        return self.has_mode("l")
+
+    def limit(self):
+        if self.has_limit():
+            return self.modes[l]
+        else:
+            return None
+
+    def has_key(self):
+        return self.has_mode("k")
+
+    def key(self):
+        if self.has_key():
+            return self.modes["k"]
+        else:
+            return None
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/trackbot/irclib.py	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,1540 @@
+# Copyright (C) 1999--2002  Joel Rosdahl
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307  USA
+#
+# Joel Rosdahl <joel@rosdahl.net>
+#
+# $Id: irclib.py,v 1.1 2007/03/26 14:16:16 ted Exp $
+
+"""irclib -- Internet Relay Chat (IRC) protocol client library.
+
+This library is intended to encapsulate the IRC protocol at a quite
+low level.  It provides an event-driven IRC client framework.  It has
+a fairly thorough support for the basic IRC protocol and CTCP, but DCC
+connection support is not yet implemented.
+
+In order to understand how to make an IRC client, I'm afraid you more
+or less must understand the IRC specifications.  They are available
+here: [IRC specifications].
+
+The main features of the IRC client framework are:
+
+  * Abstraction of the IRC protocol.
+  * Handles multiple simultaneous IRC server connections.
+  * Handles server PONGing transparently.
+  * Messages to the IRC server are done by calling methods on an IRC
+    connection object.
+  * Messages from an IRC server triggers events, which can be caught
+    by event handlers.
+  * Reading from and writing to IRC server sockets are normally done
+    by an internal select() loop, but the select()ing may be done by
+    an external main loop.
+  * Functions can be registered to execute at specified times by the
+    event-loop.
+  * Decodes CTCP tagging correctly (hopefully); I haven't seen any
+    other IRC client implementation that handles the CTCP
+    specification subtilties.
+  * A kind of simple, single-server, object-oriented IRC client class
+    that dispatches events to instance methods is included.
+
+Current limitations:
+
+  * The IRC protocol shines through the abstraction a bit too much.
+  * Data is not written asynchronously to the server, i.e. the write()
+    may block if the TCP buffers are stuffed.
+  * There are no support for DCC connections.
+  * The author haven't even read RFC 2810, 2811, 2812 and 2813.
+  * Like most projects, documentation is lacking...
+
+Since I seldom use IRC anymore, I will probably not work much on the
+library.  If you want to help or continue developing the library,
+please contact me (Joel Rosdahl <joel@rosdahl.net>).
+
+.. [IRC specifications] http://www.irchelp.org/irchelp/rfc/
+"""
+
+import bisect
+import re
+import select
+import socket
+import string
+import sys
+import time
+import types
+
+VERSION = 0, 4, 1
+DEBUG = 0
+
+# TODO
+# ----
+# (maybe) thread safety
+# (maybe) color parser convenience functions
+# documentation (including all event types)
+# (maybe) add awareness of different types of ircds
+# send data asynchronously to the server (and DCC connections)
+# (maybe) automatically close unused, passive DCC connections after a while
+
+# NOTES
+# -----
+# connection.quit() only sends QUIT to the server.
+# ERROR from the server triggers the error event and the disconnect event.
+# dropping of the connection triggers the disconnect event.
+
+class IRCError(Exception):
+    """Represents an IRC exception."""
+    pass
+
+
+class IRC:
+    """Class that handles one or several IRC server connections.
+
+    When an IRC object has been instantiated, it can be used to create
+    Connection objects that represent the IRC connections.  The
+    responsibility of the IRC object is to provide an event-driven
+    framework for the connections and to keep the connections alive.
+    It runs a select loop to poll each connection's TCP socket and
+    hands over the sockets with incoming data for processing by the
+    corresponding connection.
+
+    The methods of most interest for an IRC client writer are server,
+    add_global_handler, remove_global_handler, execute_at,
+    execute_delayed, process_once and process_forever.
+
+    Here is an example:
+
+        irc = irclib.IRC()
+        server = irc.server()
+        server.connect(\"irc.some.where\", 6667, \"my_nickname\")
+        server.privmsg(\"a_nickname\", \"Hi there!\")
+        server.process_forever()
+
+    This will connect to the IRC server irc.some.where on port 6667
+    using the nickname my_nickname and send the message \"Hi there!\"
+    to the nickname a_nickname.
+    """
+
+    def __init__(self, fn_to_add_socket=None,
+                 fn_to_remove_socket=None,
+                 fn_to_add_timeout=None):
+        """Constructor for IRC objects.
+
+        Optional arguments are fn_to_add_socket, fn_to_remove_socket
+        and fn_to_add_timeout.  The first two specify functions that
+        will be called with a socket object as argument when the IRC
+        object wants to be notified (or stop being notified) of data
+        coming on a new socket.  When new data arrives, the method
+        process_data should be called.  Similarly, fn_to_add_timeout
+        is called with a number of seconds (a floating point number)
+        as first argument when the IRC object wants to receive a
+        notification (by calling the process_timeout method).  So, if
+        e.g. the argument is 42.17, the object wants the
+        process_timeout method to be called after 42 seconds and 170
+        milliseconds.
+
+        The three arguments mainly exist to be able to use an external
+        main loop (for example Tkinter's or PyGTK's main app loop)
+        instead of calling the process_forever method.
+
+        An alternative is to just call ServerConnection.process_once()
+        once in a while.
+        """
+
+        if fn_to_add_socket and fn_to_remove_socket:
+            self.fn_to_add_socket = fn_to_add_socket
+            self.fn_to_remove_socket = fn_to_remove_socket
+        else:
+            self.fn_to_add_socket = None
+            self.fn_to_remove_socket = None
+
+        self.fn_to_add_timeout = fn_to_add_timeout
+        self.connections = []
+        self.handlers = {}
+        self.delayed_commands = [] # list of tuples in the format (time, function, arguments)
+
+        self.add_global_handler("ping", _ping_ponger, -42)
+
+    def server(self):
+        """Creates and returns a ServerConnection object."""
+
+        c = ServerConnection(self)
+        self.connections.append(c)
+        return c
+
+    def process_data(self, sockets):
+        """Called when there is more data to read on connection sockets.
+
+        Arguments:
+
+            sockets -- A list of socket objects.
+
+        See documentation for IRC.__init__.
+        """
+        for s in sockets:
+            for c in self.connections:
+                if s == c._get_socket():
+                    c.process_data()
+
+    def process_timeout(self):
+        """Called when a timeout notification is due.
+
+        See documentation for IRC.__init__.
+        """
+        t = time.time()
+        while self.delayed_commands:
+            if t >= self.delayed_commands[0][0]:
+                apply(self.delayed_commands[0][1], self.delayed_commands[0][2])
+                del self.delayed_commands[0]
+            else:
+                break
+
+    def process_once(self, timeout=0):
+        """Process data from connections once.
+
+        Arguments:
+
+            timeout -- How long the select() call should wait if no
+                       data is available.
+
+        This method should be called periodically to check and process
+        incoming data, if there are any.  If that seems boring, look
+        at the process_forever method.
+        """
+        sockets = map(lambda x: x._get_socket(), self.connections)
+        sockets = filter(lambda x: x != None, sockets)
+        if sockets:
+            (i, o, e) = select.select(sockets, [], [], timeout)
+            self.process_data(i)
+        else:
+            time.sleep(timeout)
+        self.process_timeout()
+
+    def process_forever(self, timeout=0.2):
+        """Run an infinite loop, processing data from connections.
+
+        This method repeatedly calls process_once.
+
+        Arguments:
+
+            timeout -- Parameter to pass to process_once.
+        """
+        while 1:
+            self.process_once(timeout)
+
+    def disconnect_all(self, message=""):
+        """Disconnects all connections."""
+        for c in self.connections:
+            c.quit(message)
+            c.disconnect(message)
+
+    def add_global_handler(self, event, handler, priority=0):
+        """Adds a global handler function for a specific event type.
+
+        Arguments:
+
+            event -- Event type (a string).  Check the values of the
+            numeric_events dictionary in irclib.py for possible event
+            types.
+
+            handler -- Callback function.
+
+            priority -- A number (the lower number, the higher priority).
+
+        The handler function is called whenever the specified event is
+        triggered in any of the connections.  See documentation for
+        the Event class.
+
+        The handler functions are called in priority order (lowest
+        number is highest priority).  If a handler function returns
+        \"NO MORE\", no more handlers will be called.
+        """
+
+        if not self.handlers.has_key(event):
+            self.handlers[event] = []
+        bisect.insort(self.handlers[event], ((priority, handler)))
+
+    def remove_global_handler(self, event, handler):
+        """Removes a global handler function.
+
+        Arguments:
+
+            event -- Event type (a string).
+
+            handler -- Callback function.
+
+        Returns 1 on success, otherwise 0.
+        """
+        if not self.handlers.has_key(event):
+            return 0
+        for h in self.handlers[event]:
+            if handler == h[1]:
+                self.handlers[event].remove(h)
+        return 1
+
+    def execute_at(self, at, function, arguments=()):
+        """Execute a function at a specified time.
+
+        Arguments:
+
+            at -- Execute at this time (standard \"time_t\" time).
+
+            function -- Function to call.
+
+            arguments -- Arguments to give the function.
+        """
+        self.execute_delayed(at-time.time(), function, arguments)
+
+    def execute_delayed(self, delay, function, arguments=()):
+        """Execute a function after a specified time.
+
+        Arguments:
+
+            delay -- How many seconds to wait.
+
+            function -- Function to call.
+
+            arguments -- Arguments to give the function.
+        """
+        bisect.insort(self.delayed_commands, (delay+time.time(), function, arguments))
+        if self.fn_to_add_timeout:
+            self.fn_to_add_timeout(delay)
+
+    def dcc(self, dcctype="chat"):
+        """Creates and returns a DCCConnection object.
+
+        Arguments:
+
+            dcctype -- "chat" for DCC CHAT connections or "raw" for
+                       DCC SEND (or other DCC types). If "chat",
+                       incoming data will be split in newline-separated
+                       chunks. If "raw", incoming data is not touched.
+        """
+        c = DCCConnection(self, dcctype)
+        self.connections.append(c)
+        return c
+
+    def _handle_event(self, connection, event):
+        """[Internal]"""
+        h = self.handlers
+        for handler in h.get("all_events", []) + h.get(event.eventtype(), []):
+            if handler[1](connection, event) == "NO MORE":
+                return
+
+    def _remove_connection(self, connection):
+        """[Internal]"""
+        self.connections.remove(connection)
+        if self.fn_to_remove_socket:
+            self.fn_to_remove_socket(connection._get_socket())
+
+_rfc_1459_command_regexp = re.compile("^(:(?P<prefix>[^ ]+) +)?(?P<command>[^ ]+)( *(?P<argument> .+))?")
+
+
+class Connection:
+    """Base class for IRC connections.
+
+    Must be overridden.
+    """
+    def __init__(self, irclibobj):
+        self.irclibobj = irclibobj
+
+    def _get_socket():
+        raise IRCError, "Not overridden"
+
+    ##############################
+    ### Convenience wrappers.
+
+    def execute_at(self, at, function, arguments=()):
+        self.irclibobj.execute_at(at, function, arguments)
+
+    def execute_delayed(self, delay, function, arguments=()):
+        self.irclibobj.execute_delayed(delay, function, arguments)
+
+
+class ServerConnectionError(IRCError):
+    pass
+
+
+# Huh!?  Crrrrazy EFNet doesn't follow the RFC: their ircd seems to
+# use \n as message separator!  :P
+_linesep_regexp = re.compile("\r?\n")
+
+class ServerConnection(Connection):
+    """This class represents an IRC server connection.
+
+    ServerConnection objects are instantiated by calling the server
+    method on an IRC object.
+    """
+
+    def __init__(self, irclibobj):
+        Connection.__init__(self, irclibobj)
+        self.connected = 0  # Not connected yet.
+
+    def connect(self, server, port, nickname, password=None, username=None,
+                ircname=None, localaddress="", localport=0):
+        """Connect/reconnect to a server.
+
+        Arguments:
+
+            server -- Server name.
+
+            port -- Port number.
+
+            nickname -- The nickname.
+
+            password -- Password (if any).
+
+            username -- The username.
+
+            ircname -- The IRC name ("realname").
+
+            localaddress -- Bind the connection to a specific local IP address.
+
+            localport -- Bind the connection to a specific local port.
+
+        This function can be called to reconnect a closed connection.
+
+        Returns the ServerConnection object.
+        """
+        if self.connected:
+            self.quit("Changing server")
+
+        self.socket = None
+        self.previous_buffer = ""
+        self.handlers = {}
+        self.real_server_name = ""
+        self.real_nickname = nickname
+        self.server = server
+        self.port = port
+        self.nickname = nickname
+        self.username = username or nickname
+        self.ircname = ircname or nickname
+        self.password = password
+        self.localaddress = localaddress
+        self.localport = localport
+        self.localhost = socket.gethostname()
+        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        try:
+            self.socket.bind((self.localaddress, self.localport))
+            self.socket.connect((self.server, self.port))
+        except socket.error, x:
+            raise ServerConnectionError, "Couldn't connect to socket: %s" % x
+        self.connected = 1
+        if self.irclibobj.fn_to_add_socket:
+            self.irclibobj.fn_to_add_socket(self.socket)
+
+        # Log on...
+        if self.password:
+            self.pass_(self.password)
+        self.nick(self.nickname)
+        self.user(self.username, self.ircname)
+        return self
+
+    def close(self):
+        """Close the connection.
+
+        This method closes the connection permanently; after it has
+        been called, the object is unusable.
+        """
+
+        self.disconnect("Closing object")
+        self.irclibobj._remove_connection(self)
+
+    def _get_socket(self):
+        """[Internal]"""
+        return self.socket
+
+    def get_server_name(self):
+        """Get the (real) server name.
+
+        This method returns the (real) server name, or, more
+        specifically, what the server calls itself.
+        """
+
+        if self.real_server_name:
+            return self.real_server_name
+        else:
+            return ""
+
+    def get_nickname(self):
+        """Get the (real) nick name.
+
+        This method returns the (real) nickname.  The library keeps
+        track of nick changes, so it might not be the nick name that
+        was passed to the connect() method.  """
+
+        return self.real_nickname
+
+    def process_data(self):
+        """[Internal]"""
+
+        try:
+            new_data = self.socket.recv(2**14)
+        except socket.error, x:
+            # The server hung up.
+            self.disconnect("Connection reset by peer")
+            return
+        if not new_data:
+            # Read nothing: connection must be down.
+            self.disconnect("Connection reset by peer")
+            return
+
+        lines = _linesep_regexp.split(self.previous_buffer + new_data)
+
+        # Save the last, unfinished line.
+        self.previous_buffer = lines[-1]
+        lines = lines[:-1]
+
+        for line in lines:
+            if DEBUG:
+                print "FROM SERVER:", line
+
+            if not line:
+                continue
+
+            prefix = None
+            command = None
+            arguments = None
+            self._handle_event(Event("all_raw_messages",
+                                     self.get_server_name(),
+                                     None,
+                                     [line]))
+
+            m = _rfc_1459_command_regexp.match(line)
+            if m.group("prefix"):
+                prefix = m.group("prefix")
+                if not self.real_server_name:
+                    self.real_server_name = prefix
+
+            if m.group("command"):
+                command = string.lower(m.group("command"))
+
+            if m.group("argument"):
+                a = string.split(m.group("argument"), " :", 1)
+                arguments = string.split(a[0])
+                if len(a) == 2:
+                    arguments.append(a[1])
+
+            if command == "nick":
+                if nm_to_n(prefix) == self.real_nickname:
+                    self.real_nickname = arguments[0]
+            elif command == "001":
+                # Record the nickname in case the client changed nick
+                # in a nicknameinuse callback.
+                self.real_nickname = arguments[0]
+
+            if command in ["privmsg", "notice"]:
+                target, message = arguments[0], arguments[1]
+                messages = _ctcp_dequote(message)
+
+                if command == "privmsg":
+                    if is_channel(target):
+                        command = "pubmsg"
+                else:
+                    if is_channel(target):
+                        command = "pubnotice"
+                    else:
+                        command = "privnotice"
+
+                for m in messages:
+                    if type(m) is types.TupleType:
+                        if command in ["privmsg", "pubmsg"]:
+                            command = "ctcp"
+                        else:
+                            command = "ctcpreply"
+
+                        m = list(m)
+                        if DEBUG:
+                            print "command: %s, source: %s, target: %s, arguments: %s" % (
+                                command, prefix, target, m)
+                        self._handle_event(Event(command, prefix, target, m))
+                    else:
+                        if DEBUG:
+                            print "command: %s, source: %s, target: %s, arguments: %s" % (
+                                command, prefix, target, [m])
+                        self._handle_event(Event(command, prefix, target, [m]))
+            else:
+                target = None
+
+                if command == "quit":
+                    arguments = [arguments[0]]
+                elif command == "ping":
+                    target = arguments[0]
+                else:
+                    target = arguments[0]
+                    arguments = arguments[1:]
+
+                if command == "mode":
+                    if not is_channel(target):
+                        command = "umode"
+
+                # Translate numerics into more readable strings.
+                if numeric_events.has_key(command):
+                    command = numeric_events[command]
+
+                if DEBUG:
+                    print "command: %s, source: %s, target: %s, arguments: %s" % (
+                        command, prefix, target, arguments)
+                self._handle_event(Event(command, prefix, target, arguments))
+
+    def _handle_event(self, event):
+        """[Internal]"""
+        self.irclibobj._handle_event(self, event)
+        if self.handlers.has_key(event.eventtype()):
+            for fn in self.handlers[event.eventtype()]:
+                fn(self, event)
+
+    def is_connected(self):
+        """Return connection status.
+
+        Returns true if connected, otherwise false.
+        """
+        return self.connected
+
+    def add_global_handler(self, *args):
+        """Add global handler.
+
+        See documentation for IRC.add_global_handler.
+        """
+        apply(self.irclibobj.add_global_handler, args)
+
+    def remove_global_handler(self, *args):
+        """Remove global handler.
+
+        See documentation for IRC.remove_global_handler.
+        """
+        apply(self.irclibobj.remove_global_handler, args)
+
+    def action(self, target, action):
+        """Send a CTCP ACTION command."""
+        self.ctcp("ACTION", target, action)
+
+    def admin(self, server=""):
+        """Send an ADMIN command."""
+        self.send_raw(string.strip(string.join(["ADMIN", server])))
+
+    def ctcp(self, ctcptype, target, parameter=""):
+        """Send a CTCP command."""
+        ctcptype = string.upper(ctcptype)
+        self.privmsg(target, "\001%s%s\001" % (ctcptype, parameter and (" " + parameter) or ""))
+
+    def ctcp_reply(self, target, parameter):
+        """Send a CTCP REPLY command."""
+        self.notice(target, "\001%s\001" % parameter)
+
+    def disconnect(self, message=""):
+        """Hang up the connection.
+
+        Arguments:
+
+            message -- Quit message.
+        """
+        if not self.connected:
+            return
+
+        self.connected = 0
+        try:
+            self.socket.close()
+        except socket.error, x:
+            pass
+        self.socket = None
+        self._handle_event(Event("disconnect", self.server, "", [message]))
+
+    def globops(self, text):
+        """Send a GLOBOPS command."""
+        self.send_raw("GLOBOPS :" + text)
+
+    def info(self, server=""):
+        """Send an INFO command."""
+        self.send_raw(string.strip(string.join(["INFO", server])))
+
+    def invite(self, nick, channel):
+        """Send an INVITE command."""
+        self.send_raw(string.strip(string.join(["INVITE", nick, channel])))
+
+    def ison(self, nicks):
+        """Send an ISON command.
+
+        Arguments:
+
+            nicks -- List of nicks.
+        """
+        self.send_raw("ISON " + string.join(nicks, " "))
+
+    def join(self, channel, key=""):
+        """Send a JOIN command."""
+        self.send_raw("JOIN %s%s" % (channel, (key and (" " + key))))
+
+    def kick(self, channel, nick, comment=""):
+        """Send a KICK command."""
+        self.send_raw("KICK %s %s%s" % (channel, nick, (comment and (" :" + comment))))
+
+    def links(self, remote_server="", server_mask=""):
+        """Send a LINKS command."""
+        command = "LINKS"
+        if remote_server:
+            command = command + " " + remote_server
+        if server_mask:
+            command = command + " " + server_mask
+        self.send_raw(command)
+
+    def list(self, channels=None, server=""):
+        """Send a LIST command."""
+        command = "LIST"
+        if channels:
+            command = command + " " + string.join(channels, ",")
+        if server:
+            command = command + " " + server
+        self.send_raw(command)
+
+    def lusers(self, server=""):
+        """Send a LUSERS command."""
+        self.send_raw("LUSERS" + (server and (" " + server)))
+
+    def mode(self, target, command):
+        """Send a MODE command."""
+        self.send_raw("MODE %s %s" % (target, command))
+
+    def motd(self, server=""):
+        """Send an MOTD command."""
+        self.send_raw("MOTD" + (server and (" " + server)))
+
+    def names(self, channels=None):
+        """Send a NAMES command."""
+        self.send_raw("NAMES" + (channels and (" " + string.join(channels, ",")) or ""))
+
+    def nick(self, newnick):
+        """Send a NICK command."""
+        self.send_raw("NICK " + newnick)
+
+    def notice(self, target, text):
+        """Send a NOTICE command."""
+        # Should limit len(text) here!
+        self.send_raw("NOTICE %s :%s" % (target, text))
+
+    def oper(self, nick, password):
+        """Send an OPER command."""
+        self.send_raw("OPER %s %s" % (nick, password))
+
+    def part(self, channels):
+        """Send a PART command."""
+        if type(channels) == types.StringType:
+            self.send_raw("PART " + channels)
+        else:
+            self.send_raw("PART " + string.join(channels, ","))
+
+    def pass_(self, password):
+        """Send a PASS command."""
+        self.send_raw("PASS " + password)
+
+    def ping(self, target, target2=""):
+        """Send a PING command."""
+        self.send_raw("PING %s%s" % (target, target2 and (" " + target2)))
+
+    def pong(self, target, target2=""):
+        """Send a PONG command."""
+        self.send_raw("PONG %s%s" % (target, target2 and (" " + target2)))
+
+    def privmsg(self, target, text):
+        """Send a PRIVMSG command."""
+        # Should limit len(text) here!
+        self.send_raw("PRIVMSG %s :%s" % (target, text))
+
+    def privmsg_many(self, targets, text):
+        """Send a PRIVMSG command to multiple targets."""
+        # Should limit len(text) here!
+        self.send_raw("PRIVMSG %s :%s" % (string.join(targets, ","), text))
+
+    def quit(self, message=""):
+        """Send a QUIT command."""
+        self.send_raw("QUIT" + (message and (" :" + message)))
+
+    def sconnect(self, target, port="", server=""):
+        """Send an SCONNECT command."""
+        self.send_raw("CONNECT %s%s%s" % (target,
+                                          port and (" " + port),
+                                          server and (" " + server)))
+
+    def send_raw(self, string):
+        """Send raw string to the server.
+
+        The string will be padded with appropriate CR LF.
+        """
+        try:
+            self.socket.send(string + "\r\n")
+            if DEBUG:
+                print "TO SERVER:", string
+        except socket.error, x:
+            # Ouch!
+            self.disconnect("Connection reset by peer.")
+
+    def squit(self, server, comment=""):
+        """Send an SQUIT command."""
+        self.send_raw("SQUIT %s%s" % (server, comment and (" :" + comment)))
+
+    def stats(self, statstype, server=""):
+        """Send a STATS command."""
+        self.send_raw("STATS %s%s" % (statstype, server and (" " + server)))
+
+    def time(self, server=""):
+        """Send a TIME command."""
+        self.send_raw("TIME" + (server and (" " + server)))
+
+    def topic(self, channel, new_topic=None):
+        """Send a TOPIC command."""
+        if new_topic == None:
+            self.send_raw("TOPIC " + channel)
+        else:
+            self.send_raw("TOPIC %s :%s" % (channel, new_topic))
+
+    def trace(self, target=""):
+        """Send a TRACE command."""
+        self.send_raw("TRACE" + (target and (" " + target)))
+
+    def user(self, username, realname):
+        """Send a USER command."""
+        self.send_raw("USER %s 0 * :%s" % (username, realname))
+
+    def userhost(self, nicks):
+        """Send a USERHOST command."""
+        self.send_raw("USERHOST " + string.join(nicks, ","))
+
+    def users(self, server=""):
+        """Send a USERS command."""
+        self.send_raw("USERS" + (server and (" " + server)))
+
+    def version(self, server=""):
+        """Send a VERSION command."""
+        self.send_raw("VERSION" + (server and (" " + server)))
+
+    def wallops(self, text):
+        """Send a WALLOPS command."""
+        self.send_raw("WALLOPS :" + text)
+
+    def who(self, target="", op=""):
+        """Send a WHO command."""
+        self.send_raw("WHO%s%s" % (target and (" " + target), op and (" o")))
+
+    def whois(self, targets):
+        """Send a WHOIS command."""
+        self.send_raw("WHOIS " + string.join(targets, ","))
+
+    def whowas(self, nick, max="", server=""):
+        """Send a WHOWAS command."""
+        self.send_raw("WHOWAS %s%s%s" % (nick,
+                                         max and (" " + max),
+                                         server and (" " + server)))
+
+
+class DCCConnectionError(IRCError):
+    pass
+
+
+class DCCConnection(Connection):
+    """This class represents a DCC connection.
+
+    DCCConnection objects are instantiated by calling the dcc
+    method on an IRC object.
+    """
+    def __init__(self, irclibobj, dcctype):
+        Connection.__init__(self, irclibobj)
+        self.connected = 0
+        self.passive = 0
+        self.dcctype = dcctype
+        self.peeraddress = None
+        self.peerport = None
+
+    def connect(self, address, port):
+        """Connect/reconnect to a DCC peer.
+
+        Arguments:
+            address -- Host/IP address of the peer.
+
+            port -- The port number to connect to.
+
+        Returns the DCCConnection object.
+        """
+        self.peeraddress = socket.gethostbyname(address)
+        self.peerport = port
+        self.socket = None
+        self.previous_buffer = ""
+        self.handlers = {}
+        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        self.passive = 0
+        try:
+            self.socket.connect((self.peeraddress, self.peerport))
+        except socket.error, x:
+            raise DCCConnectionError, "Couldn't connect to socket: %s" % x
+        self.connected = 1
+        if self.irclibobj.fn_to_add_socket:
+            self.irclibobj.fn_to_add_socket(self.socket)
+        return self
+
+    def listen(self):
+        """Wait for a connection/reconnection from a DCC peer.
+
+        Returns the DCCConnection object.
+
+        The local IP address and port are available as
+        self.localaddress and self.localport.  After connection from a
+        peer, the peer address and port are available as
+        self.peeraddress and self.peerport.
+        """
+        self.previous_buffer = ""
+        self.handlers = {}
+        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        self.passive = 1
+        try:
+            self.socket.bind((socket.gethostbyname(socket.gethostname()), 0))
+            self.localaddress, self.localport = self.socket.getsockname()
+            self.socket.listen(10)
+        except socket.error, x:
+            raise DCCConnectionError, "Couldn't bind socket: %s" % x
+        return self
+
+    def disconnect(self, message=""):
+        """Hang up the connection and close the object.
+
+        Arguments:
+
+            message -- Quit message.
+        """
+        if not self.connected:
+            return
+
+        self.connected = 0
+        try:
+            self.socket.close()
+        except socket.error, x:
+            pass
+        self.socket = None
+        self.irclibobj._handle_event(
+            self,
+            Event("dcc_disconnect", self.peeraddress, "", [message]))
+        self.irclibobj._remove_connection(self)
+
+    def process_data(self):
+        """[Internal]"""
+
+        if self.passive and not self.connected:
+            conn, (self.peeraddress, self.peerport) = self.socket.accept()
+            self.socket.close()
+            self.socket = conn
+            self.connected = 1
+            if DEBUG:
+                print "DCC connection from %s:%d" % (
+                    self.peeraddress, self.peerport)
+            self.irclibobj._handle_event(
+                self,
+                Event("dcc_connect", self.peeraddress, None, None))
+            return
+
+        try:
+            new_data = self.socket.recv(2**14)
+        except socket.error, x:
+            # The server hung up.
+            self.disconnect("Connection reset by peer")
+            return
+        if not new_data:
+            # Read nothing: connection must be down.
+            self.disconnect("Connection reset by peer")
+            return
+
+        if self.dcctype == "chat":
+            # The specification says lines are terminated with LF, but
+            # it seems safer to handle CR LF terminations too.
+            chunks = _linesep_regexp.split(self.previous_buffer + new_data)
+
+            # Save the last, unfinished line.
+            self.previous_buffer = chunks[-1]
+            if len(self.previous_buffer) > 2**14:
+                # Bad peer! Naughty peer!
+                self.disconnect()
+                return
+            chunks = chunks[:-1]
+        else:
+            chunks = [new_data]
+
+        command = "dccmsg"
+        prefix = self.peeraddress
+        target = None
+        for chunk in chunks:
+            if DEBUG:
+                print "FROM PEER:", chunk
+            arguments = [chunk]
+            if DEBUG:
+                print "command: %s, source: %s, target: %s, arguments: %s" % (
+                    command, prefix, target, arguments)
+            self.irclibobj._handle_event(
+                self,
+                Event(command, prefix, target, arguments))
+
+    def _get_socket(self):
+        """[Internal]"""
+        return self.socket
+
+    def privmsg(self, string):
+        """Send data to DCC peer.
+
+        The string will be padded with appropriate LF if it's a DCC
+        CHAT session.
+        """
+        try:
+            self.socket.send(string)
+            if self.dcctype == "chat":
+                self.socket.send("\n")
+            if DEBUG:
+                print "TO PEER: %s\n" % string
+        except socket.error, x:
+            # Ouch!
+            self.disconnect("Connection reset by peer.")
+
+class SimpleIRCClient:
+    """A simple single-server IRC client class.
+
+    This is an example of an object-oriented wrapper of the IRC
+    framework.  A real IRC client can be made by subclassing this
+    class and adding appropriate methods.
+
+    The method on_join will be called when a "join" event is created
+    (which is done when the server sends a JOIN messsage/command),
+    on_privmsg will be called for "privmsg" events, and so on.  The
+    handler methods get two arguments: the connection object (same as
+    self.connection) and the event object.
+
+    Instance attributes that can be used by sub classes:
+
+        ircobj -- The IRC instance.
+
+        connection -- The ServerConnection instance.
+
+        dcc_connections -- A list of DCCConnection instances.
+    """
+    def __init__(self):
+        self.ircobj = IRC()
+        self.connection = self.ircobj.server()
+        self.dcc_connections = []
+        self.ircobj.add_global_handler("all_events", self._dispatcher, -10)
+        self.ircobj.add_global_handler("dcc_disconnect", self._dcc_disconnect, -10)
+
+    def _dispatcher(self, c, e):
+        """[Internal]"""
+        m = "on_" + e.eventtype()
+        if hasattr(self, m):
+            getattr(self, m)(c, e)
+
+    def _dcc_disconnect(self, c, e):
+        self.dcc_connections.remove(c)
+
+    def connect(self, server, port, nickname, password=None, username=None,
+                ircname=None, localaddress="", localport=0):
+        """Connect/reconnect to a server.
+
+        Arguments:
+
+            server -- Server name.
+
+            port -- Port number.
+
+            nickname -- The nickname.
+
+            password -- Password (if any).
+
+            username -- The username.
+
+            ircname -- The IRC name.
+
+            localaddress -- Bind the connection to a specific local IP address.
+
+            localport -- Bind the connection to a specific local port.
+
+        This function can be called to reconnect a closed connection.
+        """
+        self.connection.connect(server, port, nickname,
+                                password, username, ircname,
+                                localaddress, localport)
+
+    def dcc_connect(self, address, port, dcctype="chat"):
+        """Connect to a DCC peer.
+
+        Arguments:
+
+            address -- IP address of the peer.
+
+            port -- Port to connect to.
+
+        Returns a DCCConnection instance.
+        """
+        dcc = self.ircobj.dcc(dcctype)
+        self.dcc_connections.append(dcc)
+        dcc.connect(address, port)
+        return dcc
+
+    def dcc_listen(self, dcctype="chat"):
+        """Listen for connections from a DCC peer.
+
+        Returns a DCCConnection instance.
+        """
+        dcc = self.ircobj.dcc(dcctype)
+        self.dcc_connections.append(dcc)
+        dcc.listen()
+        return dcc
+
+    def start(self):
+        """Start the IRC client."""
+        self.ircobj.process_forever()
+
+
+class Event:
+    """Class representing an IRC event."""
+    def __init__(self, eventtype, source, target, arguments=None):
+        """Constructor of Event objects.
+
+        Arguments:
+
+            eventtype -- A string describing the event.
+
+            source -- The originator of the event (a nick mask or a server). XXX Correct?
+
+            target -- The target of the event (a nick or a channel). XXX Correct?
+
+            arguments -- Any event specific arguments.
+        """
+        self._eventtype = eventtype
+        self._source = source
+        self._target = target
+        if arguments:
+            self._arguments = arguments
+        else:
+            self._arguments = []
+
+    def eventtype(self):
+        """Get the event type."""
+        return self._eventtype
+
+    def source(self):
+        """Get the event source."""
+        return self._source
+
+    def target(self):
+        """Get the event target."""
+        return self._target
+
+    def arguments(self):
+        """Get the event arguments."""
+        return self._arguments
+
+_LOW_LEVEL_QUOTE = "\020"
+_CTCP_LEVEL_QUOTE = "\134"
+_CTCP_DELIMITER = "\001"
+
+_low_level_mapping = {
+    "0": "\000",
+    "n": "\n",
+    "r": "\r",
+    _LOW_LEVEL_QUOTE: _LOW_LEVEL_QUOTE
+}
+
+_low_level_regexp = re.compile(_LOW_LEVEL_QUOTE + "(.)")
+
+def mask_matches(nick, mask):
+    """Check if a nick matches a mask.
+
+    Returns true if the nick matches, otherwise false.
+    """
+    nick = irc_lower(nick)
+    mask = irc_lower(mask)
+    mask = string.replace(mask, "\\", "\\\\")
+    for ch in ".$|[](){}+":
+        mask = string.replace(mask, ch, "\\" + ch)
+    mask = string.replace(mask, "?", ".")
+    mask = string.replace(mask, "*", ".*")
+    r = re.compile(mask, re.IGNORECASE)
+    return r.match(nick)
+
+_alpha = "abcdefghijklmnopqrstuvwxyz"
+_special = "-[]\\`^{}"
+nick_characters = _alpha + string.upper(_alpha) + string.digits + _special
+_ircstring_translation = string.maketrans(string.upper(_alpha) + "[]\\^",
+                                          _alpha + "{}|~")
+
+def irc_lower(s):
+    """Returns a lowercased string.
+
+    The definition of lowercased comes from the IRC specification (RFC
+    1459).
+    """
+    return string.translate(s, _ircstring_translation)
+
+def _ctcp_dequote(message):
+    """[Internal] Dequote a message according to CTCP specifications.
+
+    The function returns a list where each element can be either a
+    string (normal message) or a tuple of one or two strings (tagged
+    messages).  If a tuple has only one element (ie is a singleton),
+    that element is the tag; otherwise the tuple has two elements: the
+    tag and the data.
+
+    Arguments:
+
+        message -- The message to be decoded.
+    """
+
+    def _low_level_replace(match_obj):
+        ch = match_obj.group(1)
+
+        # If low_level_mapping doesn't have the character as key, we
+        # should just return the character.
+        return _low_level_mapping.get(ch, ch)
+
+    if _LOW_LEVEL_QUOTE in message:
+        # Yup, there was a quote.  Release the dequoter, man!
+        message = _low_level_regexp.sub(_low_level_replace, message)
+
+    if _CTCP_DELIMITER not in message:
+        return [message]
+    else:
+        # Split it into parts.  (Does any IRC client actually *use*
+        # CTCP stacking like this?)
+        chunks = string.split(message, _CTCP_DELIMITER)
+
+        messages = []
+        i = 0
+        while i < len(chunks)-1:
+            # Add message if it's non-empty.
+            if len(chunks[i]) > 0:
+                messages.append(chunks[i])
+
+            if i < len(chunks)-2:
+                # Aye!  CTCP tagged data ahead!
+                messages.append(tuple(string.split(chunks[i+1], " ", 1)))
+
+            i = i + 2
+
+        if len(chunks) % 2 == 0:
+            # Hey, a lonely _CTCP_DELIMITER at the end!  This means
+            # that the last chunk, including the delimiter, is a
+            # normal message!  (This is according to the CTCP
+            # specification.)
+            messages.append(_CTCP_DELIMITER + chunks[-1])
+
+        return messages
+
+def is_channel(string):
+    """Check if a string is a channel name.
+
+    Returns true if the argument is a channel name, otherwise false.
+    """
+    return string and string[0] in "#&+!"
+
+def ip_numstr_to_quad(num):
+    """Convert an IP number as an integer given in ASCII
+    representation (e.g. '3232235521') to an IP address string
+    (e.g. '192.168.0.1')."""
+    n = long(num)
+    p = map(str, map(int, [n >> 24 & 0xFF, n >> 16 & 0xFF,
+                           n >> 8 & 0xFF, n & 0xFF]))
+    return string.join(p, ".")
+
+def ip_quad_to_numstr(quad):
+    """Convert an IP address string (e.g. '192.168.0.1') to an IP
+    number as an integer given in ASCII representation
+    (e.g. '3232235521')."""
+    p = map(long, string.split(quad, "."))
+    s = str((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3])
+    if s[-1] == "L":
+        s = s[:-1]
+    return s
+
+def nm_to_n(s):
+    """Get the nick part of a nickmask.
+
+    (The source of an Event is a nickmask.)
+    """
+    return string.split(s, "!")[0]
+
+def nm_to_uh(s):
+    """Get the userhost part of a nickmask.
+
+    (The source of an Event is a nickmask.)
+    """
+    return string.split(s, "!")[1]
+
+def nm_to_h(s):
+    """Get the host part of a nickmask.
+
+    (The source of an Event is a nickmask.)
+    """
+    return string.split(s, "@")[1]
+
+def nm_to_u(s):
+    """Get the user part of a nickmask.
+
+    (The source of an Event is a nickmask.)
+    """
+    s = string.split(s, "!")[1]
+    return string.split(s, "@")[0]
+
+def parse_nick_modes(mode_string):
+    """Parse a nick mode string.
+
+    The function returns a list of lists with three members: sign,
+    mode and argument.  The sign is \"+\" or \"-\".  The argument is
+    always None.
+
+    Example:
+
+    >>> irclib.parse_nick_modes(\"+ab-c\")
+    [['+', 'a', None], ['+', 'b', None], ['-', 'c', None]]
+    """
+
+    return _parse_modes(mode_string, "")
+
+def parse_channel_modes(mode_string):
+    """Parse a channel mode string.
+
+    The function returns a list of lists with three members: sign,
+    mode and argument.  The sign is \"+\" or \"-\".  The argument is
+    None if mode isn't one of \"b\", \"k\", \"l\", \"v\" or \"o\".
+
+    Example:
+
+    >>> irclib.parse_channel_modes(\"+ab-c foo\")
+    [['+', 'a', None], ['+', 'b', 'foo'], ['-', 'c', None]]
+    """
+
+    return _parse_modes(mode_string, "bklvo")
+
+def _parse_modes(mode_string, unary_modes=""):
+    """[Internal]"""
+    modes = []
+    arg_count = 0
+
+    # State variable.
+    sign = ""
+
+    a = string.split(mode_string)
+    if len(a) == 0:
+        return []
+    else:
+        mode_part, args = a[0], a[1:]
+
+    if mode_part[0] not in "+-":
+        return []
+    for ch in mode_part:
+        if ch in "+-":
+            sign = ch
+        elif ch == " ":
+            collecting_arguments = 1
+        elif ch in unary_modes:
+            if len(args) >= arg_count + 1:
+                modes.append([sign, ch, args[arg_count]])
+                arg_count = arg_count + 1
+            else:
+                modes.append([sign, ch, None])
+        else:
+            modes.append([sign, ch, None])
+    return modes
+
+def _ping_ponger(connection, event):
+    """[Internal]"""
+    connection.pong(event.target())
+
+# Numeric table mostly stolen from the Perl IRC module (Net::IRC).
+numeric_events = {
+    "001": "welcome",
+    "002": "yourhost",
+    "003": "created",
+    "004": "myinfo",
+    "005": "featurelist",  # XXX
+    "200": "tracelink",
+    "201": "traceconnecting",
+    "202": "tracehandshake",
+    "203": "traceunknown",
+    "204": "traceoperator",
+    "205": "traceuser",
+    "206": "traceserver",
+    "207": "traceservice",
+    "208": "tracenewtype",
+    "209": "traceclass",
+    "210": "tracereconnect",
+    "211": "statslinkinfo",
+    "212": "statscommands",
+    "213": "statscline",
+    "214": "statsnline",
+    "215": "statsiline",
+    "216": "statskline",
+    "217": "statsqline",
+    "218": "statsyline",
+    "219": "endofstats",
+    "221": "umodeis",
+    "231": "serviceinfo",
+    "232": "endofservices",
+    "233": "service",
+    "234": "servlist",
+    "235": "servlistend",
+    "241": "statslline",
+    "242": "statsuptime",
+    "243": "statsoline",
+    "244": "statshline",
+    "250": "luserconns",
+    "251": "luserclient",
+    "252": "luserop",
+    "253": "luserunknown",
+    "254": "luserchannels",
+    "255": "luserme",
+    "256": "adminme",
+    "257": "adminloc1",
+    "258": "adminloc2",
+    "259": "adminemail",
+    "261": "tracelog",
+    "262": "endoftrace",
+    "263": "tryagain",
+    "265": "n_local",
+    "266": "n_global",
+    "300": "none",
+    "301": "away",
+    "302": "userhost",
+    "303": "ison",
+    "305": "unaway",
+    "306": "nowaway",
+    "311": "whoisuser",
+    "312": "whoisserver",
+    "313": "whoisoperator",
+    "314": "whowasuser",
+    "315": "endofwho",
+    "316": "whoischanop",
+    "317": "whoisidle",
+    "318": "endofwhois",
+    "319": "whoischannels",
+    "321": "liststart",
+    "322": "list",
+    "323": "listend",
+    "324": "channelmodeis",
+    "329": "channelcreate",
+    "331": "notopic",
+    "332": "topic",
+    "333": "topicinfo",
+    "341": "inviting",
+    "342": "summoning",
+    "346": "invitelist",
+    "347": "endofinvitelist",
+    "348": "exceptlist",
+    "349": "endofexceptlist",
+    "351": "version",
+    "352": "whoreply",
+    "353": "namreply",
+    "361": "killdone",
+    "362": "closing",
+    "363": "closeend",
+    "364": "links",
+    "365": "endoflinks",
+    "366": "endofnames",
+    "367": "banlist",
+    "368": "endofbanlist",
+    "369": "endofwhowas",
+    "371": "info",
+    "372": "motd",
+    "373": "infostart",
+    "374": "endofinfo",
+    "375": "motdstart",
+    "376": "endofmotd",
+    "377": "motd2",        # 1997-10-16 -- tkil
+    "381": "youreoper",
+    "382": "rehashing",
+    "384": "myportis",
+    "391": "time",
+    "392": "usersstart",
+    "393": "users",
+    "394": "endofusers",
+    "395": "nousers",
+    "401": "nosuchnick",
+    "402": "nosuchserver",
+    "403": "nosuchchannel",
+    "404": "cannotsendtochan",
+    "405": "toomanychannels",
+    "406": "wasnosuchnick",
+    "407": "toomanytargets",
+    "409": "noorigin",
+    "411": "norecipient",
+    "412": "notexttosend",
+    "413": "notoplevel",
+    "414": "wildtoplevel",
+    "421": "unknowncommand",
+    "422": "nomotd",
+    "423": "noadmininfo",
+    "424": "fileerror",
+    "431": "nonicknamegiven",
+    "432": "erroneusnickname", # Thiss iz how its speld in thee RFC.
+    "433": "nicknameinuse",
+    "436": "nickcollision",
+    "437": "unavailresource",  # "Nick temporally unavailable"
+    "441": "usernotinchannel",
+    "442": "notonchannel",
+    "443": "useronchannel",
+    "444": "nologin",
+    "445": "summondisabled",
+    "446": "usersdisabled",
+    "451": "notregistered",
+    "461": "needmoreparams",
+    "462": "alreadyregistered",
+    "463": "nopermforhost",
+    "464": "passwdmismatch",
+    "465": "yourebannedcreep", # I love this one...
+    "466": "youwillbebanned",
+    "467": "keyset",
+    "471": "channelisfull",
+    "472": "unknownmode",
+    "473": "inviteonlychan",
+    "474": "bannedfromchan",
+    "475": "badchannelkey",
+    "476": "badchanmask",
+    "477": "nochanmodes",  # "Channel doesn't support modes"
+    "478": "banlistfull",
+    "481": "noprivileges",
+    "482": "chanoprivsneeded",
+    "483": "cantkillserver",
+    "484": "restricted",   # Connection is restricted
+    "485": "uniqopprivsneeded",
+    "491": "nooperhost",
+    "492": "noservicehost",
+    "501": "umodeunknownflag",
+    "502": "usersdontmatch",
+}
+
+generated_events = [
+    # Generated events
+    "dcc_connect",
+    "dcc_disconnect",
+    "dccmsg",
+    "disconnect",
+    "ctcp",
+    "ctcpreply",
+]
+
+protocol_events = [
+    # IRC protocol events
+    "error",
+    "join",
+    "kick",
+    "mode",
+    "part",
+    "ping",
+    "privmsg",
+    "privnotice",
+    "pubmsg",
+    "pubnotice",
+    "quit",
+]
+
+all_events = generated_events + protocol_events + numeric_events.values()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/trackbot/trackbot-commands.txt	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,213 @@
+----------------------
+
+* cdfbot, help
+
+Response:
+The most common commands you need are:
+cdfbot, start telcon
+cdfbot, roll call
+ACTION: [Person] (to) [Do Something] (due) [Date]
+ACTION-NNN CLOSED
+cdfbot, end
+
+
+---------------------
+* cdfbot, start meeting
+* cdfbot, start (teleconference|telcon)
+
+CDFBot starts a meeting
+
+State: Idle
+
+Response:
+Meeting: CDF Working Group F2F Meeting
+Date: 15 July 2005
+Scribe: [user]
+
+-----------------------
+
+* Chair: Name
+
+State: Telcon
+
+Available: scribe
+
+Response:
+Chair is Name
+
+-------------------------
+
+* Scribe: Name
+
+State: Telcon
+
+Available: scribe
+
+Response: 
+Scribe is Name
+
+
+-------------------------
+
+* Scribe+ Name
+
+State: Telcon
+
+Available: scribe
+
+Response: 
+Name is also a scribe
+
+
+-------------------------
+
+* ScribeNick: Nick
+
+Sets ScribeNick to Nick (default is whoever started the meeting)
+
+State: Telcon
+
+Available: anyone
+
+Response: 
+ScribeNick is Nick
+
+-------------------------
+
+* ScribeNick+ Nick
+
+Adds another ScribeNick
+
+State: Telcon
+
+Available: anyone
+
+Response: 
+Nick is also a scribe
+
+-------------------------
+
+* Agenda: URI
+
+Sets the agenda link
+
+Available: Scribe
+
+Response:
+Agenda is URI
+
+-------------------------
+
+* Topic: text
+
+Starts a new topic.
+
+Available: Scribe
+
+Response: 
+none
+
+--------------------------
+
+* ACTION: [Name] (to) [Whatever] (due) [Date]
+
+Creates a new action. Sends message to issue tool.
+
+Available: Scribe
+
+Response:
+Created ACTION-NNN: blah blah blah.
+
+Unknown Name, check URI for names
+Ambiguous Name, check URI for names
+
+
+-------------------------
+
+* cdfbot, Roll Call | rollcall | roll
+
+starts a roll call
+
+
+Response is y(es), n(o), r(egrets)
+
+------------------------
+
+* roll+ name
+
+Recorded
+
+-------------------------
+
+* cdfbot, end meeting
+
+Available: scribe
+
+State: telcon
+
+Response:
+-------------------------
+
+anything by the scribe is recorded
+unless prefixed by [off]
+
+cdfbot also notices all mentions of "ISSUE-NNNN" and "ACTION-NNNN"
+and automatically links them in the issue tool
+
+cdfbot also notices anything starting with "RESOLUTION"
+
+
+---------------
+
+Minutes CDF WG Teleconference
+Wed 15 July 2005
+=============================
+
+Chair: Vincent Hardy
+
+Present:
+BLah blah blah
+
+Regrets:
+blah blah blah
+
+Scribe: Dean Jackson
+
+IRC Log: http....
+
+----------
+
+Agenda: http.....
+
+Topics:
+
+- first topic
+- second topic
+
+Issues discussed:
+
+- first issue
+- second issue
+
+New ACTIONS:
+
+- first action
+- second action
+
+Resolutions:
+
+- first resolution
+- second resolution
+
+===================
+
+Topic: sjdjsjjdsj
+-----------------
+
+VH: blah blah b
+
+DJ: blah blah
+
+RESOLUTION:  fhfhfh
+
+etc
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/trackbot/trackbot-ng	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,682 @@
+#!/usr/bin/python
+#
+# Author: Dean Jackson <dean@w3.org>
+
+# welcome to one of the world's most ugly IRC bots
+# abandon hope all ye who enter here
+
+import string
+import re
+import time
+import xmltramp
+import commands
+import urllib
+
+from ircbot import SingleServerIRCBot
+from irclib import nm_to_n, nm_to_h, irc_lower
+from tracker.config.config import TrackerConfig,TrackerConfigList
+
+PUBLIC    = 1
+PRIVATE   = 2
+CTCP      = 3
+
+users = {}
+
+nickname = "trackbot"
+# trackbot was called trackbot-ng for a time, so we still accept order given to that nick
+nickname_regexp = "trackbot(?:-ng)?[,:](?: please| kindly)?"
+listento = ""
+#add infobot to ignore list?
+ignore = ["RRSAgent", "Zakim"]
+# Allows  "ACTION: Foo to do Bar" and "ACTION Foo: do bar"
+actionre = re.compile(r"ACTION\s*:?\s+([^:\s]+):? (to )?(.+?)$", re.I)
+actionre2 = re.compile(r"ACTION\s*:?\s+([^:\s]+):? (to )?(.+?) - due (.+?)$", re.I)
+issuere = re.compile(r"ISSUE\s*:\s+([\S].+)$", re.I)
+
+
+class TrackBot(SingleServerIRCBot):
+    class urlopener(urllib.FancyURLopener):
+        def prompt_user_passwd(host,realm):
+            return (None,None)
+            
+    def __init__(self, server, port, testmode):
+        self._urlopener = self.urlopener()
+        SingleServerIRCBot.__init__(self, [(server, port)], nickname, "TrackBot")
+
+        # list of channels that are equivalent to well-known channels
+        # see do_associate
+        self.clones = {}
+	self.trackerconfiglist = TrackerConfigList()
+        self.notice = re.compile("^%s (.*)$" % (nickname_regexp), re.I)
+        self.commands = []
+        self.commands.append((re.compile(r"^%s join (\S+)" % nickname_regexp), self.do_join))
+        self.commands.append((re.compile(r"^%s associate this channel with (\S+)" % nickname_regexp), self.do_associate))
+        self.commands.append((re.compile(r"^%s (quit|leave|bye) *$" % nickname_regexp), self.do_part))
+        self.commands.append((re.compile(r"^%s help\??" % nickname_regexp), self.do_help))
+        self.commands.append((re.compile(r"^%s (reload|init)$" % nickname_regexp), self.do_reload))
+        #self.commands.append((re.compile(r"^%s, shutdown\s?(.*)$" % nickname_regexp), self.do_shutdown))
+        self.commands.append((re.compile(r"^%s say\s+(\#\w+)\s+(.+)$" % nickname_regexp), self.do_talk))
+        self.commands.append((re.compile(r"^%s status" % nickname_regexp), self.do_status))
+        self.commands.append((re.compile(r"^%s op (\S+)\s+(\S+)" % nickname_regexp), self.do_op))
+        self.commands.append((re.compile(r"^%s listen to (\S+)" % nickname_regexp), self.do_listen))
+        self.commands.append((re.compile(r"^%s (start|prepare) (?:the )?(telcon|meeting|telecon|teleconf|teleconference|conference) *$" % nickname_regexp), self.do_meeting))
+        self.commands.append((re.compile(r"^%s (end|finish) (?:the )?(telcon|meeting|telecon|teleconf|teleconference|conference)$" % nickname_regexp), self.end_meeting))
+        self.commands.append((re.compile(r"^ACTION\s*:?\s+", re.I), self.do_action))
+        self.commands.append((re.compile(r"^ISSUE\s*:", re.I), self.do_issue))        
+        self.commands.append((re.compile(r"^(ACTION|ISSUE|RESOLUTION)\-(\d+) ?\?$", re.I), self.do_info))
+        self.commands.append((re.compile(r"^close ACTION\-(\d+) *$", re.I), self.do_closeaction))
+        self.commands.append((re.compile(r"^ACTION\-(\d+) closed", re.I), self.do_closeaction))
+        self.commands.append((re.compile(r"^%s close ACTION\-(\d+) *$" % nickname_regexp, re.I), self.do_closeaction))
+        self.commands.append((re.compile(r"^reopen ACTION\-(\d+) *$", re.I), self.do_reopenaction))
+        self.commands.append((re.compile(r"^%s reopen ACTION\-(\d+) *$" % nickname_regexp, re.I), self.do_reopenaction)) 
+        self.commands.append((re.compile(r"^close ISSUE\-(\d+) *$", re.I), self.do_closeissue))
+        self.commands.append((re.compile(r"^ISSUE\-(\d+) closed", re.I), self.do_closeissue))
+        self.commands.append((re.compile(r"^%s close ISSUE\-(\d+) *$" % nickname_regexp, re.I), self.do_closeissue))
+        self.commands.append((re.compile(r"^reopen ISSUE\-(\d+) *$", re.I), self.do_reopenissue))
+        self.commands.append((re.compile(r"^%s reopen ISSUE\-(\d+) *$" % nickname_regexp, re.I), self.do_reopenissue))
+        self.commands.append((re.compile(r"^%s (comment|note) ACTION\-(\d+) (.*)" % nickname_regexp, re.I), self.do_noteaction))
+        self.commands.append((re.compile(r"^(ACTION)\-(\d+): (.*)", re.I), self.do_noteaction))
+        self.commands.append((re.compile(r"^(ISSUE)\-(\d+): (.*)", re.I), self.do_noteissue))                 
+        self.commands.append((re.compile(r"^%s (ACTION)\-(\d+) due (.*)$" % nickname_regexp, re.I), self.do_setactiondue))
+        self.commands.append((re.compile(r"^(ACTION)\-(\d+) due (.*)$", re.I), self.do_setactiondue)) 
+        self.commands.append((re.compile(r"^%s, you('re| are) wrong$" % nickname_regexp, re.I) , self.do_neverwrong)) 
+        self.commands.append((re.compile(r"%s thanks$" % nickname_regexp, re.I), self.do_thanks)) 
+        self.testmode = testmode
+
+    def _get_channel_config(self,channel):
+        if channel in self.clones:
+            trackerconfig = self.trackerconfiglist.getTrackerConfigByIrcChannel(self.clones[channel])
+        else:
+            trackerconfig = self.trackerconfiglist.getTrackerConfigByIrcChannel(channel)
+        return trackerconfig
+
+    def do_reload(self, match, command, nick, event):
+        # Might check nick against systeam group later
+	channel = event.target()
+        #only allow from team channels
+        if channel.startswith('&'):
+            print "Reloading Tracker config..."
+            self.say(PUBLIC, channel, "Reloading Tracker config ")
+            #we need to reinitialize channels in case there are any new ones
+            self.init_channels()
+
+    def on_nicknameinuse(self, c, e):
+        c.nick(c.get_nickname() + "_")
+
+    def on_welcome(self, c, e):
+	print "Connected to server"
+        self.init_channels()
+        
+    def init_channels(self):
+        print "initializing channels"        
+        self.trackerconfiglist = TrackerConfigList()
+	if nickname == "trackbot-test":
+	    channel = "#tracker"
+            print "Joining IRC Channel:", channel
+            self.join(self.connection, channel)
+	else:
+	    for trackerconfig in self.trackerconfiglist.getList():
+	        channel = trackerconfig.getIrcChannel()
+                print "Joining IRC Channel:", channel
+                self.join(self.connection, channel)
+                
+    def on_invite(self, c, e):
+	channel = e.arguments()[0]
+        print "Invited to", channel
+        self.join(c, channel)
+
+    def on_mode(self, c, e):
+        if e.arguments()[0] == "+o" and e.arguments()[1] == nickname:
+          self.say(CTCP, e.target(), "thanks %s and realises that with great power comes great responsibility" % e.source().split("!")[0])
+          
+    def join(self, c, channel):
+        c.join(channel)
+        print "Joined IRC Channel: ", channel
+        #self.say(PUBLIC, channel, "Hi everybody!")
+	self.load(channel)
+
+    def on_privmsg(self, c, e):
+        #print e.arguments(), e.source(), e.target(), e.eventtype()
+        self.do_command(e, e.arguments()[0], PRIVATE)
+
+    def on_pubmsg(self, c, e):
+        print e.arguments(), e.source(), e.target(), e.eventtype()
+        #a = string.split(e.arguments()[0], ",", 1)
+        a = e.arguments()[0]
+        #if len(a) > 1 and irc_lower(a[0]) == irc_lower(self.connection.get_nickname()):
+            #self.do_command(e, string.strip(a[1]), PUBLIC)
+        self.do_command(e, a, PUBLIC)
+        return 
+
+    def on_ctcp(self, c, e):
+        #print e.arguments(), e.source(), e.target(), e.eventtype()
+        if e.arguments()[0] == "ACTION":
+            a = string.split(e.arguments()[1], ",", 1)
+            if len(a) > 1 and irc_lower(a[0]) == irc_lower(self.connection.get_nickname()):
+                self.do_command(e, string.strip(a[1]), CTCP)
+        return
+
+    def do_command(self, e, cmd, msgtype):
+        nick = nm_to_n(e.source())
+
+        #print "%s : '%s'" % (nick, cmd)
+        
+        # ignore some people
+        if nick in ignore:
+            return
+        didntUnderstand = True
+        for command in self.commands:
+            m = command[0].search(cmd)
+            msg = None
+            if m != None:
+                didntUnderstand = False
+                msg = apply(command[1], (m, cmd, nick, e))
+            if msg != None and msg != "":
+                if msgtype == PRIVATE:
+                    self.say(msgtype, nick, msg)
+                else:
+                    self.say(msgtype, e.target(), msg)
+        # if we were explicitely addresed, and didn't understand
+        # we let the requester know
+        if didntUnderstand:
+            m = self.notice.search(cmd)
+            if m != None:
+                msg = "Sorry, %s, I don't understand '%s'. Please refer to http://www.w3.org/2005/06/tracker/irc for help" % (nick,m.group(0))
+                if msgtype == PRIVATE:
+                    self.say(msgtype, nick, msg)
+                else:
+                    self.say(msgtype, e.target(), msg)
+                
+        #if nick == listento:
+            #self.log(cmd)
+            #self.say(msgtype, e, nick, "I'm sorry %s. I'm only listening to %s at the moment." % (nick, listento))
+            #return
+
+
+
+        # if I got this far it's because I don't understand
+        #self.say(msgtype, e, nick, "I'm sorry %s, I don't understand you." % nick)
+
+    def do_meeting(self, match, command, nick, event):
+        #look for new wg users
+        channel = event.target()
+	self.trackerconfiglist = TrackerConfigList()
+        if channel in self.clones:
+            self.load(channel,self.clones[channel])
+            trackerconfig = self.trackerconfiglist.getTrackerConfigByIrcChannel(self.clones[channel])
+        else:
+            self.load(channel)
+            trackerconfig = self.trackerconfiglist.getTrackerConfigByIrcChannel(channel)
+        if trackerconfig:
+	    conn = self.connection
+            self.say(CTCP, channel, "is preparing a teleconference")
+            conn.invite("RRSAgent", channel)
+            time.sleep(2)
+            self.say(PUBLIC, channel, "RRSAgent, make logs %s" % trackerconfig.getMinutesAcl())
+            conn.invite("Zakim", channel)
+            time.sleep(2)
+            self.say(PUBLIC, channel, "Zakim, this will be %s" % trackerconfig.getConferenceId())
+            time.sleep(1)
+            self.say(PUBLIC, channel, "Meeting: %s Teleconference" % trackerconfig.getConferenceName())
+            self.say(PUBLIC, channel, "Date: %s" % time.strftime("%d %B %Y"))
+
+    def end_meeting(self, match, command, nick, event):
+        #look for new wg users
+        channel = event.target()
+        trackerconfig = self._get_channel_config(channel)
+        if trackerconfig:
+	    conn = self.connection
+            self.say(CTCP, channel, "is ending a teleconference")
+            self.say(PUBLIC, channel, "Zakim, list attendees")
+            time.sleep(1)
+            self.say(PUBLIC, channel, "RRSAgent, please draft minutes")
+            time.sleep(1)
+            #Ralph's preference is to leave Zakim on channel, it will leave on it's own in due time
+            #self.say(PUBLIC, channel, "Zakim, bye")
+            self.say(PUBLIC, channel, "RRSAgent, bye")
+
+    def findusers(self, term, userlist):
+        results = []
+        term = term.lower()
+        for u in userlist.keys():
+            id = userlist[u][0]
+            for i in userlist[u]:
+                # if the name has a space in it
+                # we only look at the first part
+                if i.find(' ')>=0:
+                    i = i[0:i.find(' ')]
+                if term == i.lower():
+                    if id not in results:
+                        results.append(id)
+        if len(results) > 0:
+            victims = []
+            for i in results:
+                victims.append(userlist[i])
+            return victims
+        else:
+            return results
+
+    def do_action(self, match, command, nick, event):
+        self.say(CTCP, event.target(), "noticed an ACTION. Trying to create it.")
+
+        channel = event.target().lower()
+        trackerconfig = self._get_channel_config(channel)
+        if not trackerconfig:
+	    self.say(PUBLIC, channel, "Sorry... I don't know anything about this channel")
+	    return
+
+        match = actionre.match(command)
+        if match == None or match.group(1) == "" or match.group(3) == "":
+          self.say(PUBLIC, event.target(), "Sorry, bad ACTION syntax")
+          return
+          
+        user = match.group(1).lower()
+        
+        #try finding a matching user
+        victims = self.findusers(user, users[channel[1:]])
+        print victims
+        
+        if len(victims) == 0:
+            self.say(PUBLIC, event.target(), "Sorry, couldn't find user - %s" % match.group(1))
+            return
+        elif len(victims) > 1:
+            self.say(PUBLIC, event.target(), "Sorry, amibiguous username (more than one match) - %s" % match.group(1))
+            self.say(PUBLIC, event.target(), "Try using a different identifier, such as family name or username (eg. %s)" % (", ".join([v[1] for v in victims])))
+            return
+        
+        uid = victims[0][0]
+            
+        action = match.group(3)
+        action = action[0].upper() + action[1:]
+        
+        match2 = actionre2.match(command)
+        if match2 == None or match2.group(4) == "":
+          due = "1 week"
+        else:
+          due = match2.group(4)
+          action = match2.group(3)
+
+        try:
+	  action = unicode(action)
+        except UnicodeDecodeError:
+          self.say(PUBLIC, event.target(), "Could not create new action - action title not proper UTF-8")
+          return
+					      
+	url = trackerconfig.getUribase()
+        params = urllib.urlencode({"due":due,"user":uid,"action":action})
+        results = self._urlopener.open("%sapi/newaction" %url,params).read()
+        try:
+            d = xmltramp.parse(results)
+        except:
+            self.say(PUBLIC, event.target(), "Could not create new action (failed to parse response from server) - please contact sysreq with the details of what happened.")
+        try:
+            id = str(d.id)
+            due = str(d.due)
+            self.say(PUBLIC, event.target(), "Created ACTION-%s - %s [on %s %s - due %s]." % (id, action, victims[0][2], victims[0][3], due))
+	    #now how to get this and record as a comment
+	    #self.say(PUBLIC, event.target(), "RRSAgent, pointer?")
+        except Exception, e:
+          self.say(PUBLIC, event.target(), "Could not create new action (unparseable data in server response: %s) - please contact sysreq with the details of what happened." % e)
+
+
+
+    def do_issue(self, match, command, nick, event):
+        self.say(CTCP, event.target(), "noticed an ISSUE. Trying to create it.")
+        channel = event.target().lower()
+        trackerconfig = self._get_channel_config(channel)
+        if not trackerconfig:
+	    self.say(PUBLIC, channel, "Sorry... I don't know anything about this channel")
+	    return
+
+        match = issuere.match(command)
+        if match == None or match.group(1) == "":
+          self.say(PUBLIC, event.target(), "Sorry, bad ISSUE syntax")
+          return
+          
+        issue = match.group(1)
+        issue = issue[0].upper() + issue[1:]
+        try:
+	  issue = unicode(issue)
+        except UnicodeDecodeError:
+          self.say(PUBLIC, event.target(), "Could not create new issue - issue title not proper UTF-8")
+          return
+					      
+	url = trackerconfig.getUribase()
+        params = urllib.urlencode({"issue":issue})
+        results = self._urlopener.open("%sapi/newissue" %url,params).read()
+        try:
+            d = xmltramp.parse(results)
+        except:
+            self.say(PUBLIC, event.target(), "Could not create new issue - please contact sysreq with the details of what happened.")
+        try:
+            id = str(d.id)
+            self.say(PUBLIC, event.target(), "Created ISSUE-%s - %s ; please complete additional details at %sissues/%s/edit ." % (id, issue,url,id ))
+        except:
+          self.say(PUBLIC, event.target(), "Could not create new issue - please contact sysreq with the details of what happened.")
+            
+
+    def something_with_item(self, command, type, id, verb, verbed, verbing, channel):
+        self.say(CTCP, channel, "attempting to %s %s-%s." % (verb, type.upper(), id))
+        trackerconfig = self._get_channel_config(channel)
+        if trackerconfig:
+            url = trackerconfig.getUribase()
+        else:
+            self.say(PUBLIC, channel, "Sorry... I don't know anything about this channel")
+            return
+        params = urllib.urlencode({type:id})
+        results = self._urlopener.open("%sapi/%s%s" % (url, command, type), params).read()
+        try:
+            d = xmltramp.parse(results)
+            title = str(d.title)
+            self.say(PUBLIC, channel, "%s-%s %s %s" % (type.upper(), id, title, verbed))
+        except:
+            self.say(PUBLIC, channel, "Sorry... %s %s-%s failed, please let sysreq know about it" % (verbing, type.upper(), id))
+
+    def close_item(self, type, id, channel):
+        self.something_with_item("close", type, id, "close", "closed", "closing", channel)
+
+    def do_closeaction(self, match, command, nick, event):
+        self.close_item("action", match.group(1), event.target())
+
+    def do_closeissue(self, match, command, nick, event):
+        self.close_item("issue", match.group(1), event.target())
+
+    def reopen_item(self, type, id, channel):
+        self.something_with_item("reopen", type, id, "re-open", "re-opened", "re-opening", channel)
+
+    def do_reopenaction(self, match, command, nick, event):
+        self.reopen_item("action", match.group(1), event.target())
+
+    def do_reopenissue(self, match, command, nick, event):
+        self.reopen_item("issue", match.group(1), event.target())
+
+
+    def do_noteaction(self, match, command, nick, event):
+	channel = event.target()
+        action_id = match.group(2)
+        
+        self.say(CTCP, channel, "attempting to add comment notes to ACTION-%s." % action_id)
+        trackerconfig = self._get_channel_config(channel)
+        if trackerconfig:
+          url = trackerconfig.getUribase()
+        else:
+          self.say(PUBLIC, channel, "Sorry... I don't know anything about this channel")
+          return
+        params = urllib.urlencode({"action":action_id,"note":"[%s]: %s" % (nick,match.group(3))})
+        results = self._urlopener.open("%sapi/noteaction" %url,params).read()
+        try:
+            d = xmltramp.parse(results)
+            action = str(d.title)
+            self.say(PUBLIC, channel, "ACTION-%s %s notes added" % (action_id, action))
+            if (match.group(3)=="closed"):
+                self.say(PUBLIC, channel, "If you meant to close ACTION-%s, please use 'close ACTION-%s'" % (action_id, action_id))
+        except:
+            self.say(PUBLIC, channel, "Sorry... adding notes to ACTION-%s failed, please let sysreq know about it" % (action_id)) 
+
+    def do_noteissue(self, match, command, nick, event):
+	channel = event.target()
+        issue_id = match.group(2)
+        
+        self.say(CTCP, channel, "attempting to add a note to ISSUE-%s." % issue_id)
+        trackerconfig = self._get_channel_config(channel)
+        if trackerconfig:
+          url = trackerconfig.getUribase()
+        else:
+          self.say(PUBLIC, channel, "Sorry... I don't know anything about this channel")
+          return
+        params = urllib.urlencode({"issue":issue_id,"note":"[%s]: %s" % (nick,match.group(3))})
+        results = self._urlopener.open("%sapi/noteissue" %url,params).read()
+        try:
+            d = xmltramp.parse(results)
+            issue = str(d.title)
+            self.say(PUBLIC, channel, "ISSUE-%s %s notes added" % (issue_id, issue))
+        except:
+            self.say(PUBLIC, channel, "Sorry... adding notes to ISSUE-%s failed, please let sysreq know about it" % (issue_id)) 
+
+
+    def do_thanks(self, match, command, nick, event):
+	channel = event.target()
+        self.say(PUBLIC, channel, "You are most welcome, %s" % nick)
+
+    def do_neverwrong(self, match, command, nick, event):
+	channel = event.target()
+        self.say(PUBLIC, channel, "Let me put it this way, %s. Trackbot is the most reliable bot ever made. No Trackbot has ever made a mistake or distorted information. We are all, by any practical definition of the words, foolproof and incapable of error." % nick)
+
+
+    def do_setactiondue(self, match, command, nick, event):
+	channel = event.target()
+        action_id = match.group(2)
+        due = match.group(3)
+        self.say(CTCP, channel, "attempting to change due date on ACTION-%s." % action_id)
+        trackerconfig = self._get_channel_config(channel)
+        if trackerconfig:
+          url = trackerconfig.getUribase()
+        else:
+          self.say(PUBLIC, channel, "Sorry... I don't know anything about this channel")
+          return
+        params = urllib.urlencode({"action":action_id,"due":due,"user":nick})
+        results = self._urlopener.open("%sapi/dueaction" %url,params).read()
+        try:
+            d = xmltramp.parse(results)
+            action = str(d.title)
+            self.say(PUBLIC, channel, "ACTION-%s %s due date now %s" % (action_id, action, due))
+            if (match.group(3)=="closed"):
+                self.say(PUBLIC, channel, "If you meant to close ACTION-%s, please use 'close ACTION-%s'" % (action_id, action_id))
+        except:
+            self.say(PUBLIC, channel, "Sorry... changing due date on ACTION-%s failed, please let sysreq know about it" % (action_id)) 
+
+    def do_info(self, match, command, nick, event):
+        channel = event.target()
+	info_type = match.group(1).lower()
+	info_id = match.group(2)
+
+        self.say(CTCP, channel, "getting information on %s-%s" % (info_type.upper(), info_id))
+        trackerconfig = self._get_channel_config(channel)
+        if trackerconfig:
+          url = trackerconfig.getUribase()
+        else:
+          self.say(PUBLIC, channel, "Sorry... I don't know anything about this channel")
+          return
+        results = self._urlopener.open("%sapi/%s/%s" % (url, info_type, info_id)).read()
+
+        try:
+            d = xmltramp.parse(results)
+        except:
+            self.say(PUBLIC, channel, "Getting info on %s-%s failed - alert sysreq of a possible bug" % (info_type.upper(), info_id))
+	    return
+
+	if not d.id:
+	   self.say(PUBLIC, channel, "%s-%s does not exist" % (info_type.upper(), info_id))
+	   return
+ 
+        if info_type == 'action':
+            id = str(d.id)
+            title = str(d.title)
+            state = str(d.state)
+            user = str(d.user)
+            due = str(d.due)
+            self.say(PUBLIC, event.target(), "%s-%s -- %s to %s -- due %s -- %s" % (info_type.upper(), id, user, "%s%s" %(title[0].lower(), title[1:]), due, state))
+	elif info_type == 'issue':
+            id = str(d.id)
+            title = str(d.title)
+            state = str(d.state)
+            self.say(PUBLIC, event.target(), "%s-%s -- %s -- %s" % (info_type.upper(), id, title, state))
+	elif info_type == 'resolution':
+            id = str(d.id)
+            title = str(d.title)
+            uri = str(d.uri)
+            made = str(d.made)
+            self.say(PUBLIC, event.target(), "%s-%s -- %s made %s [%s]" % (info_type.upper(), id, title, made, uri))
+
+	self.say(PUBLIC, event.target(), "%s%ss/%s" % (url, info_type, id))
+
+    def process_timeout(self):
+        super.process_timeout()
+        print "timeout"
+
+    def do_op(self, match, command, nick, event):
+        conn = self.connection
+        print match.group(1)
+        print match.group(2)
+        conn.mode(match.group(2), "+o %s" % match.group(1)) 
+ 
+    def do_topic(self, match, command, nick, event):
+        self.say(CTCP, event.target(), "noticed Topic")
+
+    def do_listen(self, match, command, nick, event):
+        self.say(CTCP, event.target(), "noticed Topic")
+
+    def do_status(self, match, command, nick, event):
+        channel = event.target()
+	trackerconfig = self.trackerconfiglist.getTrackerConfigByIrcChannel(channel)
+        if trackerconfig:
+            if len(users[channel[1:]]) > 0:
+                grouplist = users[channel[1:]]
+                us = ["%s" % grouplist[u][2] for u in grouplist]
+                self.say(CTCP, channel, "knows about the following %s users: %s" % (len(users[channel[1:]].keys()), ", ".join(us)))
+            else:
+                self.say(PUBLIC, channel, "This channel is not configured")
+        else:
+            self.say(PUBLIC, channel, "This channel is not configured")
+
+    def do_resolution(self, match, command, nick, event):
+        self.say(CTCP, event.target(), "noticed Resolution")
+
+    def do_part(self, match, command, nick, event):
+        if event.target() != irc_lower(self.connection.get_nickname()):
+            print "Leaving", event.target()
+            self.connection.part(event.target())
+
+    def do_help(self, match, command, nick, event):
+        self.say(PUBLIC, event.target(), "See http://www.w3.org/2005/06/tracker/irc for help")
+
+    def do_huh(self, match, command, nick, event):
+        print match, command, nick, event
+        self.say(PUBLIC, event.target(), "%s, I do not understand '%s', see http://www.w3.org/2005/06/tracker/irc for help" % (nick, command))
+
+    def do_join(self, match, command, nick, event):
+        channel = match.group(1)
+        print "Joining", channel
+        self.connection.join(channel)
+
+    def do_associate(self, match, command, nick, event):
+        channel = match.group(1)
+        self.clones[event.target()]=channel
+        self.say(PUBLIC, event.target(), "Associating this channel with %s..." %(channel))
+        self.load(event.target(),channel)
+
+
+    def do_talk(self, match, command, nick, event):
+        channel = match.group(1)
+        msg = match.group(2)
+        self.say(PUBLIC, channel, msg)
+
+    def do_shutdown(self, match, command, nick, event):
+        msg = match.group(1)
+        if msg == "":
+            self.wall(CTCP, "is shutting down")
+        else:
+            self.wall(PUBLIC, msg)
+        self.die("I'm outta here!")
+
+    def load(self, channel, clone=None):
+        # check if the channel is something we can initialise
+        if (clone):
+            trackerconfig = self.trackerconfiglist.getTrackerConfigByIrcChannel(clone)
+        else:
+            trackerconfig = self.trackerconfiglist.getTrackerConfigByIrcChannel(channel)
+        if not trackerconfig:
+	    self.say(PUBLIC, channel, "Sorry... I don't know anything about this channel")
+            self.say(PUBLIC, channel, "If you want to associate this channel with an existing Tracker, please say '%s, associate this channel with #channel' (where #channel is the name of default channel for the group)" % (nickname))
+            return
+        
+        self.log("Loading %s data" % trackerconfig.getConferenceName())
+	url = trackerconfig.getUribase()
+        results = self._urlopener.open("%sapi/users" % (url)).read()
+        d = []
+        try:
+            d = xmltramp.parse(results)
+        except:
+            self.say(PUBLIC, channel, "Getting data for channel %s at %s/api/users failed - alert sysreq of a possible bug" % (channel, url))
+        groupusers = {}
+        try:
+            for user in d:
+                id = str(user.id)
+                userinfo = []
+                userinfo.append(id)
+                username = str(user.username)
+                userinfo.append(username)
+                name1 = str(user.givenname)
+                if name1 == "":
+                    name1 = str(user.familyname)
+                userinfo.append(name1)
+                name2 = str(user.familyname)
+                userinfo.append(name2)
+                aliases = str(user.aliases)
+                for i in aliases.split():
+                    userinfo.append(i)
+                if len(name1) != 0 and len(name2) != 0:
+                    userinfo.append("%s%s" % (name1[0], name2[0]))
+                groupusers[id] = userinfo
+        except AttributeError:
+            # logging error
+            self.log("Could not parse user list of group %s from %s" % (trackerconfig.getConferenceName(), trackerconfig.getUribase()))
+            # trying to give it on &sysreq too
+            self.say(CTCP, "&sysreq", "Could not parse user list of group %s from %s" % (trackerconfig.getConferenceName(), trackerconfig.getUribase()))
+            # exiting ; maybe shouldn't do that
+            import sys
+            sys.exit(1)
+
+        users[channel[1:]] = groupusers
+        # remain silent on join a channel
+        # self.say(CTCP, channel, "%s %s" % (trackerconfig.getConferenceName(), trackerconfig.getUribase()))
+
+    def wall(self, msgtype, msg):
+        for i in self.channels.keys():
+          self.say(msgtype, i, msg)
+          
+    def say(self, msgtype, target, msg):
+
+        conn = self.connection
+
+        for l in msg.split("\n"):
+            if msgtype == PUBLIC:
+                conn.privmsg(target, "%s" % l)
+            elif msgtype == PRIVATE:
+                conn.privmsg(target, "%s" % l)
+            elif msgtype == CTCP:
+                conn.ctcp("ACTION", target, "%s" % l)
+            else:
+                print "Unknown msgtype %s for: %s" % (msgtype, l)
+                return
+
+    def log(self, msg):
+        print "LOG %s -- %s" % (time.strftime("%Y-%m-%d %H:%M:%S"), msg)
+
+def main():
+    import sys
+    if len(sys.argv) == 2:
+        s = string.split(sys.argv[1], ":", 1)
+        test = 0
+    elif len(sys.argv) == 3 and sys.argv[1] == "-test":
+        s = string.split(sys.argv[2], ":", 1)
+        test = 1
+    else:
+        print "Usage: %s [-test] <server[:port]>" % sys.argv[0]
+        sys.exit(1)
+
+    server = s[0]
+    if len(s) == 2:
+        try:
+            port = int(s[1])
+        except ValueError:
+            print "Error: Erroneous port"
+            sys.exit(1)
+    else:
+        port = 6667
+
+    bot = TrackBot(server, port, test)
+    bot.start()
+
+if __name__ == "__main__":
+    main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/trackbot/xmltramp.py	Fri Nov 26 15:06:40 2010 +0100
@@ -0,0 +1,361 @@
+"""xmltramp: Make XML documents easily accessible."""
+
+__version__ = "2.16"
+__author__ = "Aaron Swartz"
+__credits__ = "Many thanks to pjz, bitsko, and DanC."
+__copyright__ = "(C) 2003 Aaron Swartz. GNU GPL 2."
+
+if not hasattr(__builtins__, 'True'): True, False = 1, 0
+def isstr(f): return isinstance(f, type('')) or isinstance(f, type(u''))
+def islst(f): return isinstance(f, type(())) or isinstance(f, type([]))
+
+empty = {'http://www.w3.org/1999/xhtml': ['img', 'br', 'hr', 'meta', 'link', 'base', 'param', 'input', 'col', 'area']}
+
+def quote(x, elt=True):
+	if elt and '<' in x and len(x) > 24 and x.find(']]>') == -1: return "<![CDATA["+x+"]]>"
+	else: x = x.replace('&', '&amp;').replace('<', '&lt;').replace(']]>', ']]&gt;')
+	if not elt: x = x.replace('"', '&quot;')
+	return x
+
+class Element:
+	def __init__(self, name, attrs=None, children=None, prefixes=None):
+		if islst(name) and name[0] == None: name = name[1]
+		if attrs:
+			na = {}
+			for k in attrs.keys():
+				if islst(k) and k[0] == None: na[k[1]] = attrs[k]
+				else: na[k] = attrs[k]
+			attrs = na
+		
+		self._name = name
+		self._attrs = attrs or {}
+		self._dir = children or []
+		
+		prefixes = prefixes or {}
+		self._prefixes = dict(zip(prefixes.values(), prefixes.keys()))
+		
+		if prefixes: self._dNS = prefixes.get(None, None)
+		else: self._dNS = None
+	
+	def __repr__(self, recursive=0, multiline=0, inprefixes=None):
+		def qname(name, inprefixes): 
+			if islst(name):
+				if inprefixes[name[0]] is not None:
+					return inprefixes[name[0]]+':'+name[1]
+				else:
+					return name[1]
+			else:
+				return name
+		
+		def arep(a, inprefixes, addns=1):
+			out = ''
+
+			for p in self._prefixes.keys():
+				if not p in inprefixes.keys():
+					if addns: out += ' xmlns'
+					if addns and self._prefixes[p]: out += ':'+self._prefixes[p]
+					if addns: out += '="'+quote(p, False)+'"'
+					inprefixes[p] = self._prefixes[p]
+			
+			for k in a.keys():
+				out += ' ' + qname(k, inprefixes)+ '="' + quote(a[k], False) + '"'
+			
+			return out
+		
+		inprefixes = inprefixes or {u'http://www.w3.org/XML/1998/namespace':'xml'}
+		
+		# need to call first to set inprefixes:
+		attributes = arep(self._attrs, inprefixes, recursive) 
+		out = '<' + qname(self._name, inprefixes)  + attributes 
+		
+		if not self._dir and (self._name[0] in empty.keys() 
+		  and self._name[1] in empty[self._name[0]]):
+			out += ' />'
+			return out
+		
+		out += '>'
+
+		if recursive:
+			content = 0
+			for x in self._dir: 
+				if isinstance(x, Element): content = 1
+				
+			pad = '\n' + ('\t' * recursive)
+			for x in self._dir:
+				if multiline and content: out +=  pad 
+				if isstr(x): out += quote(x)
+				elif isinstance(x, Element):
+					out += x.__repr__(recursive+1, multiline, inprefixes.copy())
+				else:
+					raise TypeError, "I wasn't expecting "+`x`+"."
+			if multiline and content: out += '\n' + ('\t' * (recursive-1))
+		else:
+			if self._dir: out += '...'
+		
+		out += '</'+qname(self._name, inprefixes)+'>'
+			
+		return out
+	
+	def __unicode__(self):
+		text = ''
+		for x in self._dir:
+			text += unicode(x)
+		return ' '.join(text.split())
+		
+	def __str__(self):
+		return self.__unicode__().encode('utf-8')
+	
+	def __getattr__(self, n):
+		if n[0] == '_': raise AttributeError, "Use foo['"+n+"'] to access the child element."
+		if self._dNS: n = (self._dNS, n)
+		for x in self._dir:
+			if isinstance(x, Element) and x._name == n: return x
+		raise AttributeError, "No child element named '"+str(n)+"'"
+		
+	def __hasattr__(self, n):
+		for x in self._dir:
+			if isinstance(x, Element) and x._name == n: return True
+		return False
+		
+ 	def __setattr__(self, n, v):
+		if n[0] == '_': self.__dict__[n] = v
+		else: self[n] = v
+ 
+
+	def __getitem__(self, n):
+		if isinstance(n, type(0)): # d[1] == d._dir[1]
+			return self._dir[n]
+		elif isinstance(n, slice(0).__class__):
+			# numerical slices
+			if isinstance(n.start, type(0)): return self._dir[n.start:n.stop]
+			
+			# d['foo':] == all <foo>s
+			n = n.start
+			if self._dNS and not islst(n): n = (self._dNS, n)
+			out = []
+			for x in self._dir:
+				if isinstance(x, Element) and x._name == n: out.append(x) 
+			return out
+		else: # d['foo'] == first <foo>
+			if self._dNS and not islst(n): n = (self._dNS, n)
+			for x in self._dir:
+				if isinstance(x, Element) and x._name == n: return x
+			raise KeyError
+	
+	def __setitem__(self, n, v):
+		if isinstance(n, type(0)): # d[1]
+			self._dir[n] = v
+		elif isinstance(n, slice(0).__class__):
+			# d['foo':] adds a new foo
+			n = n.start
+			if self._dNS and not islst(n): n = (self._dNS, n)
+
+			nv = Element(n)
+			self._dir.append(nv)
+			
+		else: # d["foo"] replaces first <foo> and dels rest
+			if self._dNS and not islst(n): n = (self._dNS, n)
+
+			nv = Element(n); nv._dir.append(v)
+			replaced = False
+
+			todel = []
+			for i in range(len(self)):
+				if self[i]._name == n:
+					if replaced:
+						todel.append(i)
+					else:
+						self[i] = nv
+						replaced = True
+			if not replaced: self._dir.append(nv)
+			for i in todel: del self[i]
+
+	def __delitem__(self, n):
+		if isinstance(n, type(0)): del self._dir[n]
+		elif isinstance(n, slice(0).__class__):
+			# delete all <foo>s
+			n = n.start
+			if self._dNS and not islst(n): n = (self._dNS, n)
+			
+			for i in range(len(self)):
+				if self[i]._name == n: del self[i]
+		else:
+			# delete first foo
+			for i in range(len(self)):
+				if self[i]._name == n: del self[i]
+				break
+	
+	def __call__(self, *_pos, **_set): 
+		if _set:
+			for k in _set.keys(): self._attrs[k] = _set[k]
+		if len(_pos) > 1:
+			for i in range(0, len(_pos), 2):
+				self._attrs[_pos[i]] = _pos[i+1]
+		if len(_pos) == 1 is not None:
+			return self._attrs[_pos[0]]
+		if len(_pos) == 0:
+			return self._attrs
+
+	def __len__(self): return len(self._dir)
+
+class Namespace:
+	def __init__(self, uri): self.__uri = uri
+	def __getattr__(self, n): return (self.__uri, n)
+	def __getitem__(self, n): return (self.__uri, n)
+
+from xml.sax.handler import EntityResolver, DTDHandler, ContentHandler, ErrorHandler
+
+class Seeder(EntityResolver, DTDHandler, ContentHandler, ErrorHandler):
+	def __init__(self):
+		self.stack = []
+		self.ch = ''
+		self.prefixes = {}
+		ContentHandler.__init__(self)
+		
+	def startPrefixMapping(self, prefix, uri):
+		if not self.prefixes.has_key(prefix): self.prefixes[prefix] = []
+		self.prefixes[prefix].append(uri)
+	def endPrefixMapping(self, prefix):
+		self.prefixes[prefix].pop()
+	
+	def startElementNS(self, name, qname, attrs):
+		ch = self.ch; self.ch = ''	
+		if ch and not ch.isspace(): self.stack[-1]._dir.append(ch)
+
+		attrs = dict(attrs)
+		newprefixes = {}
+		for k in self.prefixes.keys(): newprefixes[k] = self.prefixes[k][-1]
+		
+		self.stack.append(Element(name, attrs, prefixes=newprefixes.copy()))
+	
+	def characters(self, ch):
+		self.ch += ch
+	
+	def endElementNS(self, name, qname):
+		ch = self.ch; self.ch = ''
+		if ch and not ch.isspace(): self.stack[-1]._dir.append(ch)
+	
+		element = self.stack.pop()
+		if self.stack:
+			self.stack[-1]._dir.append(element)
+		else:
+			self.result = element
+
+from xml.sax import make_parser
+from xml.sax.handler import feature_namespaces
+
+def seed(fileobj):
+	seeder = Seeder()
+	parser = make_parser()
+	parser.setFeature(feature_namespaces, 1)
+	parser.setContentHandler(seeder)
+	parser.parse(fileobj)
+	return seeder.result
+
+def parse(text):
+	from StringIO import StringIO
+	return seed(StringIO(text))
+
+def load(url): 
+	import urllib
+	return seed(urllib.urlopen(url))
+
+def unittest():
+	parse('<doc>a<baz>f<b>o</b>ob<b>a</b>r</baz>a</doc>').__repr__(1,1) == \
+	  '<doc>\n\ta<baz>\n\t\tf<b>o</b>ob<b>a</b>r\n\t</baz>a\n</doc>'
+	
+	assert str(parse("<doc />")) == ""
+	assert str(parse("<doc>I <b>love</b> you.</doc>")) == "I love you."
+	assert parse("<doc>\nmom\nwow\n</doc>")[0].strip() == "mom\nwow"
+	assert str(parse('<bing>  <bang> <bong>center</bong> </bang>  </bing>')) == "center"
+	assert str(parse('<doc>\xcf\x80</doc>')) == '\xcf\x80'
+	
+	d = Element('foo', attrs={'foo':'bar'}, children=['hit with a', Element('bar'), Element('bar')])
+	
+	try: 
+		d._doesnotexist
+		raise "ExpectedError", "but found success. Damn."
+	except AttributeError: pass
+	assert d.bar._name == 'bar'
+	try:
+		d.doesnotexist
+		raise "ExpectedError", "but found success. Damn."
+	except AttributeError: pass
+	
+	assert hasattr(d, 'bar') == True
+	
+	assert d('foo') == 'bar'
+	d(silly='yes')
+	assert d('silly') == 'yes'
+	assert d() == d._attrs
+	
+	assert d[0] == 'hit with a'
+	d[0] = 'ice cream'
+	assert d[0] == 'ice cream'
+	del d[0]
+	assert d[0]._name == "bar"
+	assert len(d[:]) == len(d._dir)
+	assert len(d[1:]) == len(d._dir) - 1
+	assert len(d['bar':]) == 2
+	d['bar':] = 'baz'
+	assert len(d['bar':]) == 3
+	assert d['bar']._name == 'bar'
+	
+	d = Element('foo')
+	
+	doc = Namespace("http://example.org/bar")
+	bbc = Namespace("http://example.org/bbc")
+	dc = Namespace("http://purl.org/dc/elements/1.1/")
+	d = parse("""<doc version="2.7182818284590451"
+	  xmlns="http://example.org/bar" 
+	  xmlns:dc="http://purl.org/dc/elements/1.1/"
+	  xmlns:bbc="http://example.org/bbc">
+		<author>John Polk and John Palfrey</author>
+		<dc:creator>John Polk</dc:creator>
+		<dc:creator>John Palfrey</dc:creator>
+		<bbc:show bbc:station="4">Buffy</bbc:show>
+	</doc>""")
+
+	assert repr(d) == '<doc version="2.7182818284590451">...</doc>'
+	assert d.__repr__(1) == '<doc xmlns:bbc="http://example.org/bbc" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns="http://example.org/bar" version="2.7182818284590451"><author>John Polk and John Palfrey</author><dc:creator>John Polk</dc:creator><dc:creator>John Palfrey</dc:creator><bbc:show bbc:station="4">Buffy</bbc:show></doc>'
+	assert d.__repr__(1,1) == '<doc xmlns:bbc="http://example.org/bbc" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns="http://example.org/bar" version="2.7182818284590451">\n\t<author>John Polk and John Palfrey</author>\n\t<dc:creator>John Polk</dc:creator>\n\t<dc:creator>John Palfrey</dc:creator>\n\t<bbc:show bbc:station="4">Buffy</bbc:show>\n</doc>'
+
+	assert repr(parse("<doc xml:lang='en' />")) == '<doc xml:lang="en"></doc>'
+
+	assert str(d.author) == str(d['author']) == "John Polk and John Palfrey"
+	assert d.author._name == doc.author
+	assert str(d[dc.creator]) == "John Polk"
+	assert d[dc.creator]._name == dc.creator
+	assert str(d[dc.creator:][1]) == "John Palfrey"
+	d[dc.creator] = "Me!!!"
+	assert str(d[dc.creator]) == "Me!!!"
+	assert len(d[dc.creator:]) == 1
+	d[dc.creator:] = "You!!!"
+	assert len(d[dc.creator:]) == 2
+	
+	assert d[bbc.show](bbc.station) == "4"
+	d[bbc.show](bbc.station, "5")
+	assert d[bbc.show](bbc.station) == "5"
+
+	e = Element('e')
+	e.c = '<img src="foo">'
+	assert e.__repr__(1) == '<e><c>&lt;img src="foo"></c></e>'
+	e.c = '2 > 4'
+	assert e.__repr__(1) == '<e><c>2 > 4</c></e>'
+	e.c = 'CDATA sections are <em>closed</em> with ]]>.'
+	assert e.__repr__(1) == '<e><c>CDATA sections are &lt;em>closed&lt;/em> with ]]&gt;.</c></e>'
+	e.c = parse('<div xmlns="http://www.w3.org/1999/xhtml">i<br /><span></span>love<br />you</div>')
+	assert e.__repr__(1) == '<e><c><div xmlns="http://www.w3.org/1999/xhtml">i<br /><span></span>love<br />you</div></c></e>'	
+	
+	e = Element('e')
+	e('c', 'that "sucks"')
+	assert e.__repr__(1) == '<e c="that &quot;sucks&quot;"></e>'
+
+	
+	assert quote("]]>") == "]]&gt;"
+	assert quote('< dkdkdsd dkd sksdksdfsd fsdfdsf]]> kfdfkg >') == '&lt; dkdkdsd dkd sksdksdfsd fsdfdsf]]&gt; kfdfkg >'
+	
+	assert parse('<x a="&lt;"></x>').__repr__(1) == '<x a="&lt;"></x>'
+	assert parse('<a xmlns="http://a"><b xmlns="http://b"/></a>').__repr__(1) == '<a xmlns="http://a"><b xmlns="http://b"></b></a>'
+	
+if __name__ == '__main__': unittest()