Develop an example RSS Zimlet

From Zimbra :: Wiki

Jump to: navigation, search

Contents

Introduction

Here we are going to develop an example RSS Zimlet. This is not a hello-world Zimlet and the idea is to demonstrate how easy it is to write a relatively feature-rich Zimlet and also to show various aspects and apis of Zimlet development.

Prerequisites:

1. Have basic Javascript knowledge and know what a 'callback' and a 'closure' is.

2. Have the Zimbra development environment setup via Zimbra Desktop (or by building source-code).

3. Have Proxy-Zimlet that opens-up all domains deployed.

RSS Example Zimlet (com_zimbra_rssexample) Requirements

This Zimlet's objective is to get an RSS feed from news.yahoo.com and display the result in a dialog box. It will also have an option to show the result in the mini-calendar(aka mini-cal) area. The user can right-click on the panel item and change the preferences to show the information in either the dialog-box or the mini-cal area.

If mini-cal area is chosen, single-clicking (or double-clicking) on the panel-item should show the rss-feed in the mini-cal area. When displayed, single-clicking on the panel-item hides and shows the mini-calendar again.

Zimlet files

Now we have the requirements, lets start developing it. Remember we will be developing the RSS Zimlet in _dev folder so that we don't have to worry about deploying-restart-undeploying cycle. Now, Lets look at the files we might need.

Since we are writing a JavaScript-based Zimlet, we need the following files:

0. Actual Zimlet (for reference):

File:Com zimbra rssexample.zip

1. A Zimlet Description file (e.g. com_zimbra_rssexample.xml) - This is where we describe Zimlet information, things like: Zimlet name, Zimlet version, user properties.

Note: If the Zimlet is written mainly using xml-api, this will be the primary file you will work with.

2. Zimlet JavaScript file (e.g. rssexample.js) - This is the main file that does all the hard work.

3. A Zimlet configuration file (config_template.xml) - Contains configuration information like "alloweddomains". This is used to indicate that a specific domain is OK to access.

Note: This is not required if your Zimlet doesn't talk to any services but an Extension-Zimlet like: email-reminder, colored-emails etc.

4. A panel-icon image(e.g. rssexample.png) - Usually a 16x16 panel icon for the Zimlet.

Note: This is not required if your Zimlet doesn't provide any preferences and/or not user-triggered(via Singleclick or double-click) Zimlet.

Zimlet Description file (com_zimbra_rssexample.xml)

This is where we describe Zimlet information, things like: Zimlet name, Zimlet version, user properties. And below is how the completed file looks like.

<zimlet name="com_zimbra_rssexample" version="0.1" description="displays rss feeds from yahoo!">
  <include>rssexample.js</include>
  <includeCSS>rssexample.css</includeCSS>
  <handlerObject>com_zimbra_rssexample</handlerObject>

  <zimletPanelItem label="RSS Example" icon="rssexample-panelIcon">
    <toolTipText>displays rss feeds from yahoo</toolTipText>
		<contextMenu>
			<menuItem label="Zimlet Preferences" id="smseg_preferences" />
		</contextMenu>
  </zimletPanelItem>
	<userProperties>
		<property type="string" name="rsseg_showFeedsInMiniCal" value="false"/>
	</userProperties>
</zimlet>


Lets dig deeper and see what they all mean.

To start with, we need to describe the Zimlet's name, version and description.

<zimlet name="com_zimbra_rssexample" version="0.1" description="displays rss feeds from yahoo!">


Since we are developing JavaScript(rssexample.js), we need to tell Zimbra to upload/include the JavaScript file.

<include>rssexample.js</include>


One of the requirements is to show the RSS feed information in a dialog box. To make it look good lets use a CSS file where we can describe all the style information.

<includeCSS>rssexample.css</includeCSS>


Create a handler object to suggest that we are using JavaScript-api and to call the Zimlet during login or init time.

<handlerObject>com_zimbra_rssexample</handlerObject>

This Zimlet needs a panel-item so that we can click to show information in dialog-box or show/hide it in mini-calendar area. zimletPanelItem says what the label("RSS Example") of the overview-panel should be and what icon should be used"rssexample-panelIcon".

It has <toolTipText> to show information of what the Zimlet does when a user mouse-overs the panel-item.
It also has <contextMenu> to describe what should happen when user right-clicks on panel item. In our case we need this to allow user to choose show-in-dialogbox vs show-in-minical preference. so lets describe a menuItem whose name/label is "Zimlet Preferences".


  <zimletPanelItem label="RSS Example" icon="rssexample-panelIcon">
    <toolTipText>displays rss feeds from yahoo</toolTipText>
		<contextMenu>
			<menuItem label="Zimlet Preferences" id="smseg_preferences" />
		</contextMenu>
  </zimletPanelItem>


One last thing thats required for our Zimlet is a way to store user's preference b/w show-in-dialogbox vs show-in-minical. Zimlet will store and retrieve this information to obey the choice for subsequent sessions or sign-ins. Lets give a unique name to this property "rsseg_showFeedsInMiniCal" and set its type to string. Since this is a boolean, we can also use boolean instead.

	<userProperties>
		<property type="string" name="rsseg_showFeedsInMiniCal" value="false"/>
	</userProperties>



Zimlet configuration file (config_template.xml)

Contains configuration information like "alloweddomains". This is used to indicate that a specific domain is OK to access. We need this because our Zimlet talks to external sites directly via javascript. Note: You can skip this if your Zimlet talks to external sites via jsp.

<zimletConfig name="com_zimbra_rssexample" version="0.1">
    <global>
        <property name="allowedDomains">rss.news.yahoo.com</property>
    </global>
</zimletConfig>

The CSS file (rssexample.css)

This is the file where you would write all the necessary style-sheet details required by the Zimlet's panel-item, dialogbox, menu etc.

When we were describing the zimlet's panelItem in ZimletDescription.xml file (<zimletPanelItem label="RSS Example" icon="rssexample-panelIcon">), we had mentioned icon="rssexample-panelIcon". This is actually the CSS classname. Zimbra prepends "Img" to the classname to make it "Imgrssexample-panelIcon" and then uses the image "rssexample.gif" described within the resulting css class and plugs it as Zimlet panel icon.

.Imgrssexample-panelIcon {
  background: url("rssexample.gif") no-repeat 0 0;
  width: 16px;
  height: 16px;
  overflow: hidden;
}


Once we get RSS results back and show them in dialog, we need to show the feed url(in our case news url) and feed details(news details). The below styles are to simply show the url in yellowish-background and details in whitish-background.

.rsseg_sectionDiv{
background:#FFFFFF;
border-bottom:1px solid #A7A194;border-right:1px solid #A7A194;
}
.rsseg_HdrDiv{
background:#FFFF99;
border-bottom:1px solid #A7A194;border-right:1px solid #A7A194;
font-size:200%;
}


Main Javascript file (rssexample.js)

- This is the main file that does all the hard work.

We will start-off with writing a constructor. And make the object a "Zimlet" by subclassing it to ZmZimletBase()

function com_zimbra_rssexample() {
}

com_zimbra_rssexample.prototype = new ZmZimletBase();
com_zimbra_rssexample.prototype.constructor = com_zimbra_rssexample;

User Interaction handlers

Lets implement handlers for user-interaction. i.e what should happen when a user clicks or double-clicks on the Zimlets panel-item. Since some people tends to double-click on the Zimlet while only single-click is required, lets make override double-click and make it work just like single-click.


com_zimbra_rssexample.prototype.doubleClicked =
function() {	
	this.singleClicked();
};

Lets start implementing single-click function. Before that, lets remember what all it must do.

1. When a user clicks, it must decide if we should show the RSS feed results in dialog-box or in mini-calendar area(based on user's preference). This also means, we must persist user's choice in the server.

2. Based on the choice, create a post-callback that should be called after we get the feed.

User's preference

1. When a user clicks, it must decide if we should show the RSS feed results in dialog-box or in mini-calendar area(based on user's preference). This also means, we must persist user's choice in the server. In this case, we persist the user's choice in a variable whose name is "rsseg_showFeedsInMiniCal" and its value will be either "true" or "false"

com_zimbra_rssexample.prototype.singleClicked =
function() {	
	this.rsseg_showFeedsInMiniCal = this.getUserProperty("rsseg_showFeedsInMiniCal") == "true";
	//if the option was recently changed using pref, use that value
	if(document.getElementById("rsseg_showFeedsInMiniCal") != undefined) {
		this.rsseg_showFeedsInMiniCal = document.getElementById("rsseg_showFeedsInMiniCal").checked;
	}
.......
.......
};

PostCallback

2. Based on the choice, create a post-callback that should be called after we get the feed. Post-callback is basically an object(AjxCallback) that contains the actual function(this._showRSSInMiniCal or this._displayRSSResultsDialog) that needs to be called after we make an ajax-call and after a successful response.

e.g: Lets assume that user wanted to see RSS feed in minical-area. Then, we will create an AjxCallback object and set the required function.

var  postCallback = new AjxCallback(this, this._showRSSInMiniCal);

Then, we will pass this object to a function(this._invoke)which makes an ajax-call to get the RSS feed. And once we get the the RSS-feed back(to _reponseHandler), _reponseHandler automatically calls postCallback.run function (without knowing exactly which function it is calling). And since postCallback object itself knows this information, it calls this._showRSSInMiniCal.

The flow is:

  1. singleClicked creates postcallback and calls this._invoke(postCallback).
  2. this._invoke(postCallback) makes ajax call and calls _reponseHandler
  3. reponseHandler runs postCallback.run (and internally postCallback.run calls this._showRSSInMiniCal)


com_zimbra_rssexample.prototype.singleClicked =
function() {	
        .....
        ....
        ......
	var postCallback = null;
	if(this.rsseg_showFeedsInMiniCal) {//show in minical..
		if(this._visible) {//if rss feed is visible.. clear timeout, and then swap it
			if(this._timerID){
				clearTimeout(this._timerID);
			}
			this._showRSSInMiniCal();
			return;
		}
		 postCallback = new AjxCallback(this, this._showRSSInMiniCal);
	} else {//show in dialog..
		postCallback = new AjxCallback(this, this._displayRSSResultsDialog);
	}
	this._invoke(postCallback);
};

AJAX Call

Now that we know the flow from the above section, lets go into the details of ajax-call made by _invoke function and then how it handles response.

AJAX Request

Since we will be using pure-javascript, we will have to use Proxy url(simply append ZmZimletBase.PROXY) and also encode it. var feedUrl = ZmZimletBase.PROXY + AjxStringUtil.urlComponentEncode(com_zimbra_rssexample._feed);

Next is to make an AJAX Call. AjxRpc.invoke(null, feedUrl, null, new AjxCallback(this, this._reponseHandler, postCallback), true);

The API for AjxRpc.invoke looks like: AjxRpc.invoke(requestStr, serverUrl, requestHeaders, callback, useGet, timeout) Here Since we are doing a HTTP GET, and dont have to set any request headers, requestStr = null serverUrl = feedUrl requestHeaders = null callback = new AjxCallback(this, this._reponseHandler, postCallback) Here we are saying, once we get the response back, call this._reponseHandler and also pass postCallback object to that function. useGet = true

com_zimbra_rssexample.prototype._invoke =
function(postCallback) {
	var feedUrl = ZmZimletBase.PROXY + AjxStringUtil.urlComponentEncode(com_zimbra_rssexample._feed);
	AjxRpc.invoke(null, feedUrl, null, new AjxCallback(this, this._reponseHandler, postCallback), true);
};

AJAX Response (on Failure)

Now, lets look at the response handler. If there is an issue with the response, we will show the exception in a dialog box.

com_zimbra_rssexample.prototype._reponseHandler =
function(postCallback, reponse) {
	var items = "";
	try {
	       items = reponse.xml.getElementsByTagName("item");
	} catch(e) {//there was some expn getting feed
		this._showErrorMsg(e);
		return;
	}
        .....
        ......

};

com_zimbra_rssexample.prototype._showErrorMsg =
function(msg) {
	var msgDialog = appCtxt.getMsgDialog();//get a message dialog
	msgDialog.reset(); // clean up earlier message
	msgDialog.setMessage(msg, DwtMessageDialog.CRITICAL_STYLE); //set new message
	msgDialog.popup();//display the dialog
};

AJAX Response (on Success)

If the response was OK, i.e. if the response actually has xml component(since we are getting RSS feed), then we basically parse the the feed for things like title, description and link for all the feed items. PS: When we fetch a feed, it usually has anywhere b/w 10-30 feed items.

com_zimbra_rssexample.prototype._reponseHandler =
function(postCallback, reponse) {
         .....
         ......
         ...... 
	this.titleDescArray = new Array();
	this._currentFeedIndex = 0;
	var counter = 0;
	for (var i = 0; i < items.length; i++) {
		try {
			var title = desc = "";
			var titleObj = items[i].getElementsByTagName("title")[0].firstChild;
			var descObj = items[i].getElementsByTagName("description")[0].firstChild;
			var linkObj = items[i].getElementsByTagName("link")[0].firstChild;
			if (titleObj.textContent) {
				this.titleDescArray[counter] = {title: titleObj.textContent, desc:descObj.textContent, link:linkObj.textContent};
			} else if (titleObj.text) {
				this.titleDescArray[counter] =  {title: titleObj.text, desc:descObj.text, link:linkObj.text}; 
			}
			counter++;
		}catch(e) {//print some exception
			this._showErrorMsg(e);
			return;
		}
	}

   if(postCallback)
	   postCallback.run(this);

};

Once we parse the information, we store them in a array (this.titleDescArray)

this.titleDescArray[counter] =  {title: titleObj.text, desc:descObj.text, link:linkObj.text}; 

And finally we run the post-callback. That is we would either call this._displayRSSResultsDialog or this._showRSSInMiniCal depending on what or how the postCallback object was constructed (in singleClicked function).

  if(postCallback)
	   postCallback.run(this);

Display RSS Feeds

Story thus far: We made ajax call and got the response but have two ways to show this information. Lets first explore with showing the information in a dialog(this._displayRSSResultsDialog). this._displayRSSResultsDialog basically creates a dialog box, sets the information and displays the dialog box.

Display RSS Feeds in a dialog box

To create a dialogbox, we will first create its view(a DwtComposite object; this._parentView) and set its size, style and innerHTML. PS: The innerHTML is the html that contains feed information.

	this._parentView = new DwtComposite(this.getShell());
	this._parentView.setSize("550", "300");
	this._parentView.getHtmlElement().style.overflow = "auto";
	this._parentView.getHtmlElement().innerHTML = this._constructDialogView();

Then pass this view along with title and button information.

        this._createDialog({title:"RSS Feeds", view:this._parentView, standardButtons : [DwtDialog.OK_BUTTON]});

PS: We also have to make sure that we don't recreate the dialog every time the user clicks, instead should reuse the existing dialog and just change or update its view.

	if (this.rssExampleDlg) {//if the dialog object is already created, reuse but change its contents
		this._parentView.getHtmlElement().innerHTML = this._constructDialogView();
		this.rssExampleDlg.popup();
		return;
	}
com_zimbra_rssexample.prototype._displayRSSResultsDialog =
function() {
	if (this.rssExampleDlg) {//if the dialog object is already created, reuse but change its contents
		this._parentView.getHtmlElement().innerHTML = this._constructDialogView();
		this.rssExampleDlg.popup();
		return;
	}
	this._parentView = new DwtComposite(this.getShell());
	this._parentView.setSize("550", "300");
	this._parentView.getHtmlElement().style.overflow = "auto";
	this._parentView.getHtmlElement().innerHTML = this._constructDialogView();

	this.rssExampleDlg = this._createDialog({title:"RSS Feeds", view:this._parentView, standardButtons : [DwtDialog.OK_BUTTON]});
	this.rssExampleDlg.popup();
};


One last thing we have to talk about here is constructing the innerHTML. In displayRSSResultsDialog, we set the innerHTML of this._parentView to the return value of this._constructDialogView() function. i.e. constructDialogView is the one that takes care of going through the feeds and creates a pleasing html. This is also the function that uses CSS classes ("rsseg_sectionDiv", "rsseg_HdrDiv") we had set earlier in rssexample.css file.


com_zimbra_rssexample.prototype._displayRSSResultsDialog =
function() {
     ....
     ....
     ....
      this._parentView.getHtmlElement().innerHTML = this._constructDialogView();
     ....
     ....
    this.rssExampleDlg.popup();
}

com_zimbra_rssexample.prototype._constructDialogView =
function() {
	var html = new Array();
	var i = 0;
	for(var j=0;j<this.titleDescArray.length; j++) {
		var val = this.titleDescArray[j];
		html[i++] = "<div class='rsseg_HdrDiv'>";
		html[i++] = "<TABLE  cellpadding=5>";
		html[i++] = "<TR>";
		html[i++] = "<TD>";
		html[i++] =  val.title;
		html[i++] = "</TD>";
		html[i++] = "</TR>";
		html[i++] = "</TABLE>";
		html[i++] = "</div>";
		html[i++] = "<div class='rsseg_sectionDiv'>";
		html[i++] = "<TABLE  cellpadding=5>";
		html[i++] = "<TR>";
		html[i++] = "<TD>";
		html[i++] =  val.desc;
		html[i++] = "</TD>";
		html[i++] = "</TR>";
		html[i++] = "<TR>";
		html[i++] = "<TD>";
		html[i++] =  "<a href='"+val.link+"' target='_blank'>"+val.link+"</a>";
		html[i++] = "</TD>";
		html[i++] = "</TR>";
		html[i++] = "</TABLE>";
		html[i++] = "</div>";		
	}
	return html.join("");
};

Display RSS Feeds in mini-calendar area

Now lets see how to construct mini-calendar view. This is a little bit more advanced and I am going to go into little bit more details here. Before that lets see the big-picture of how it works. If the user has chosen to see the feed-information in the minical-area, 1. Singleclick should swap minicalendar with Zimlet's view. 2.While the feed is being displayed or rotated, single-clicking the Zimlet should swap-back. i.e hide the Zimlet and display the minicalendar again. 3. Once displayed, since we cant show all 10 or 30 feed-items in that small area, we will have to show 1 at a time and rotate it every 5 or 10 seconds.


1. Singleclick should swap minicalendar with Zimlet's view. First we create a div html-element thats of the same size as the minicalendar. PS: We need to do this only once during the session.

           this._newDiv = document.createElement("div");
           this._newDiv.id = "rssexample_DIV";;
           this._newDiv.style.zIndex = 900;
	   this._newDiv.style.width = 163;
           this._newDiv.style.backgroundColor = "white";

Next we need to figure out whats the static-id of the minicalendar-area. Zimbra UI provides static-ids for various sections of the skin. For example: The mini-calendar area has "skin_container_tree_footer", the whole-overview panel has id "skin_td_tree" etc Then simply stick our div (this._newDiv) as its child.

document.getElementById("skin_container_tree_footer").appendChild(this._newDiv);

While we are displaying the Zimlet, we will also have to hide mini-calendar itself. But for that we will have to get hold of the html-element.

	if(!this._miniCal) {
		var calController = AjxDispatcher.run("GetCalController");
		this._miniCal = calController ? calController.getMiniCalendar().getHtmlElement() : null;
	}
        ....
        ....
        ....

        // temporarily hide the mini calendar
        this._miniCal.style.visibility = "hidden";



2.While the feed is being displayed or rotated, single-clicking the Zimlet should swap-back. i.e hide the Zimlet and display the minicalendar again.



com_zimbra_rssexample.prototype._showRSSInMiniCal = function() {
    this._visible = !this._visible;
        ....
        ....
    if (this._visible) {//currently mini calendar is visible
         ....
        ....
        // temporarily hide the mini calendar
        this._miniCal.style.visibility = "hidden";
	this._showMinicalView();//call it for the first time
        ....
        ....
    } else { // currently Zimlet is visible...
        this._miniCal.style.visibility = "visible";
    }
        ....
        ....
};


Complete Code:
com_zimbra_rssexample.prototype._showRSSInMiniCal = function() {
    this._visible = !this._visible;

	if(!this._miniCal) {
		var calController = AjxDispatcher.run("GetCalController");
		this._miniCal = calController ? calController.getMiniCalendar().getHtmlElement() : null;
	}
    if (this._visible) {
        if (!this._newDiv) {
            this._newDiv = document.createElement("div");

            this._newDiv.id = "rssexample_DIV";;
            this._newDiv.style.zIndex = 900;
			this._newDiv.style.width = 163;
            this._newDiv.style.backgroundColor = "white";
            document.getElementById("skin_container_tree_footer").appendChild(this._newDiv);
        }
        // temporarily hide the mini calendar
        this._miniCal.style.visibility = "hidden";
		this._showMinicalView();//call it for the first time
		this._timerID = setInterval(AjxCallback.simpleClosure(this._showMinicalView, this), 2000);
    } else {
        this._miniCal.style.visibility = "visible";
    }
};


3. Now that we know how to display or hide minical-element with Zimlet-element, only thing remaining is to show actual feed contents. Once displayed, since we cant show all 10 or 30 feed-items in that small area, we will have to show 1 at a time and rotate it every 5 or 10 seconds.

The following code simply says, call the _showMinicalView function every 2 seconds. PS: setInterval makes a global call, so we will have to create a closure object ( AjxCallback.simpleClosure) around this(showMinicalView) function and then pass the closure object.

this._timerID = setInterval(AjxCallback.simpleClosure(this._showMinicalView, this), 2000);

showMinicalView keeps track of this._currentFeedIndex and increments it after every call every 2 seconds. And it uses this counter to get different feed-items( this.titleDescArray[this._currentFeedIndex]) so that ultimately user sees a different feed every two seconds.

com_zimbra_rssexample.prototype._showMinicalView =
function() {
	if(this._currentFeedIndex ==this.titleDescArray.length) {
		this._currentFeedIndex =0;//reset
	}
	var html = new Array();
	var i = 0;
	var val = this.titleDescArray[this._currentFeedIndex];
	html[i++] = "<div>";
	html[i++] = "<TABLE  cellpadding=5>";
	html[i++] = "<TR>";
	html[i++] = "<TD>";
	html[i++] =  val.title;
	html[i++] = "</TD>";
	html[i++] = "</TR>";
	html[i++] = "<TR>";
	html[i++] = "<TD>";
	html[i++] =  "<a href='"+val.link+"' target='_blank'>"+val.link+"</a>";
	html[i++] = "</TD>";
	html[i++] = "</TR>";
	html[i++] = "</TABLE>";
	html[i++] = "</div>";	
	this._newDiv.innerHTML = html.join("");
	this._currentFeedIndex++;
};

Preferences Dialog

Finally, we will have to create yet another dialog with a checkbox that allows user to choose between 'show feed in dialog' or 'show feed in mini-calendar' Something like: '[X] Show RSS Feed in minical(and not in dialog)'

Create a Dialog box

This is very similar to whats described in 'Display RSS Feeds in a dialog box' section.

com_zimbra_rssexample.prototype._displayPrefDialog =
function() {
	//if zimlet dialog already exists(reuse)...
	if (this.pbDialog) {
		this.pbDialog.popup();
		return;
	}
	this.pView = new DwtComposite(this.getShell());
	this.pView.getHtmlElement().innerHTML = this._createPreferenceView();


	//show the checkbox checked if needed
	if (this.getUserProperty("rsseg_showFeedsInMiniCal") == "true") {
		document.getElementById("rsseg_showFeedsInMiniCal").checked = true;
	}

	this.pbDialog = this._createDialog({title:"Zimlet Preferences", view:this.pView, standardButtons:[DwtDialog.OK_BUTTON]});
	this.pbDialog.setButtonListener(DwtDialog.OK_BUTTON, new AjxListener(this, this._okBtnListner));
	this.pbDialog.popup();
};

Create the view to show a checkbox.
com_zimbra_rssexample.prototype._createPreferenceView =
function() {
	var html = new Array();
	var i = 0;
	html[i++] = "<DIV>";
	html[i++] = "<input id='rsseg_showFeedsInMiniCal'  type='checkbox'/>Show RSS Feed in minical(and not in dialog)";
	html[i++] = "</DIV>";
	return html.join("");
};

Save User's choice

When 'OK' button is clicked, we will save the user's choice if its different from his previous choice.

    this.setUserProperty("rsseg_showFeedsInMiniCal", document.getElementById("rsseg_showFeedsInMiniCal").checked, true);
com_zimbra_rssexample.prototype._okBtnListner =
function() {
	if (document.getElementById("rsseg_showFeedsInMiniCal").checked && !this.rsseg_showFeedsInMiniCal
		|| !document.getElementById("rsseg_showFeedsInMiniCal").checked && this.rsseg_showFeedsInMiniCal) {

		this.setUserProperty("rsseg_showFeedsInMiniCal", document.getElementById("rsseg_showFeedsInMiniCal").checked, true);
	} 
	this.pbDialog.popdown();
};
Verified Against: ZCS 5.0 and later Date Created: 3/30/2009
Article ID: http://wiki.zimbra.com/index.php?title=Develop_an_example_RSS_Zimlet Date Modified: 03/1/2011
Personal tools