Click here to Skip to main content
Click here to Skip to main content
Go to top

A Reusable Framework for Connecting Both WebHelp and Compiled Help RoboHelp Projects to Relational Databases Through the Use of JavaScript Object LIterals

, 10 May 2005
Rate this:
Please Sign up or sign in to vote.
This article will describe how to hook up RoboHelp projects to a database to provide data to pages for both WebHelp and Compiled Help using a single architecture. The implementation will rely on the JavaScript interpreter built into almost all web browsers and that is available in Compiled Help too.

Introduction

This article will describe how to hook up RoboHelp projects to a database to provide data to pages for both WebHelp and Compiled Help using a single architecture. We will introduce the problem scenario, evaluate some alternatives for solving it, then discuss the implementation. In our implementation, we will rely on the JavaScript interpreter that is built into almost all web browsers and is available to pages included within Compiled Help output as well. We will rely on RoboHelp's Single Source Layout system and its support for Conditional Builds Tags to customize our output options.

For our server-side solution, we will also use JavaScript, but the solution framework presented here is not coupled to a server-side usage of JavaScript. However, this article will show how the JavaScript interpreter is, through use of object literal notation, well-suited to the task of marshaling data to and from the client. Ultimately, the solution presented is generic, reusable, and extensible. All project resources are available for download in the referenced .zip archive.

Notes about AJAX and MadCap Software

Before getting into this article, I want to note that what is described here is very similar to the popular technique dubbed AJAX, or Asynchronous JavaScript And XML. I didn't know of this term when I wrote this, but after looking for some information about JavaScript Object Literal notation to see whether someone had written a parser for different languages, I found out about JSON. Additionally, I learned that the future of RoboHelp X5 is unclear, but MadCap software wants to address this by supporting RoboHelp project file formats. Here are some links for more information about this information:

Table of Contents

I hope you find the tips, code, and resources in this article useful to your own RoboHelp projects.

The Problem of Providing Current Data in Help Projects for Both Web and Desktop

A problem that help authors often face is that they must deliver help content to users through two or more avenues:

  1. Web-delivered help pages (WebHelp in RoboHelp terminology).
  2. Standalone help systems available on the user's desktop (Compiled Help).

Content contained within these pages may be static and not prone to change, but it is likely that many areas should be dynamic. In the case of WebHelp, this content can be delivered to the client just-in-time when the request is sent to the server. But, for Compiled Help, this is not the case because the distribution should ideally be completely self-contained and never rely on the user having access to an internet connection.

Applying User-Centered Design Maxims

The above example applies even in the case of an internet-based system, because as authors and developers, our goal is to make information available to end-users in the manner that is most accessible and flexible for them.

Provided that we design our solution properly, there should never be a case in which something that can be done in theory cannot be done in practice flexibly for the user due to our own lack of technical foresight.

Case Study: International Staffing Company's Need for Dynamic Information

To provide an example of some dynamic information, consider these two examples for a fictitious company named International Staffing Company (ISC):

  1. ISC maintains lists of information like partner organizations, areas of operation, and job titles within the company.
  2. Contact information for people at ISC changes, sometimes due to duty rotation, turnover, or physical relocation.

ISC has an internationally accessible web site that provides customers with services for seeking and applying for jobs. They also are considering delivering standalone applications that users can download to their computers to make accessing services easier. So, they want to be able to deploy help systems both on the web and in a disconnected, desktop deployment. The following prototype shows how they want the data above to appear in the output.

Prototype of Desired Output

In this prototype, ISC has identified the dynamically generated areas with a green background. As a rapidly growing company, ISC has to update this information frequently, and does not want to have to recompiled their help projects all the time to provide accurate information to their customers.

Problem Statement

After examining both the requirements and the prototype output, we can define our problem statement in two parts:

  1. How do we provide a flexible mechanism for including dynamic data into our RoboHelp projects that will not require recompilation when the data changes?
  2. And, how can we do it such that the same base mechanism is used for both WebHelp and Compiled Help?

Derived Requirements Table

From this statement, we derive the following three requirements:

Number

Priority (3 highest)

Description

R1

3

Solution must provide a way to include dynamic data into both WebHelp and Compiled Help.

R2

2

Solution should use the same base mechanism for both WebHelp and Compiled Help to ease maintenance and development.

R3

1

Solution should be extensible to allow for future similar additions.

Continue reading to see how to chose and implement one solution that meets all the three requirements and is simple, reusable, and extensible.

Evaluation of Possible Solutions

We know the requirement now and some strongly coupled technical correlates:

  1. Provide dynamic data to both WebHelp and Compiled Help.
  2. Ensure that solution works in IE and Netscape, Mozilla, FireFox, etc.
  3. If possible, use the same mechanism for both WebHelp and Compiled Help.

Alternative Evaluation Matrix

With these in mind a number of ideas seem viable:

Number

Alternative

Implementation Plan

R1?

R2?

R3?

SA1

Use XML inside the browser as a “data island”

Chunks of XML can be used to provide the data segments and transformed at run time. Provide static copies for Compiled Help, and generate dynamically for WebHelp.

Yes

No

Yes

SA2

Use a simple database like MS Access

Embed the MDB file with the Compiled Help and call it from Web Help on the server.

Yes

No

Yes

SA3

Use JavaScript literal objects as a data storage mechanism

Embed static .js files as Baggage Files in RoboHelp for Compiled Help, generate the JavaScript dynamically from the server for WebHelp

Yes

Yes

Yes

Solution Outline

Solution SA3 looks like the winner. The reasons that SA1 and SA2 fail are:

  • SA1: While XML is a great candidate for storing data, we cannot rely on the user's browser agent being able to parse XML dynamically. So, we could do it server-side for Web Help and rely on Internet Explorer's XSL transformations for Compiled Help. This is possible, but also the reason why it fails R3.
  • SA2: Like SA1, this looks like it would work in both Compiled Help, using the ADODB library that many users have on their systems at render time. And, for WebHelp, we could use similar calls on the server side to render appropriate markup. But, this is also why SA2 fails to meet R3.

Even if we chose to go with SA1 or SA2, we still are left with how to get the dynamically generated content from the server side into the static content in the HTML. Inexorably, this leads to using small chunks of JavaScript in the client, if we want to maintain any type of division of labor in our project between help author and developer.

* Note: I have not tried either SA1 or SA2, but XML and MDB were two ideas that came to mind when thinking about possible solutions, but the definitive problems incurred in R2 made these solutions untenable. They may actually fail in R1 or R3 too, but I'll give them the benefit of the doubt in this analysis.

JavaScript Object Literals as the Solution

HTML is the lingua franca of the Internet, but a closely related native tongue is JavaScript. Almost all web browsers support some basic level of JavaScript, and Compiled Help does as well since it is simply a wrapper around the Internet Explorer browser control for the most part.

Client-side Implementation

JavaScript can be embedded into an HTML file and accessed programmatically when the page is loading to change how content is displayed. All we need to do as programmers is find a way to embed that JavaScript into the static HTML page's HEAD tag, and then make calls to some functions for rendering the data as formatted HTML.

Server-side Implementation

On the back end server, it does not matter much what language we use or what database we connect to, as long as we have a means of translating the data into strings of JavaScript object literals. But, as this example will show, using JavaScript on both the client and the server has some advantages when it comes to data marshaling and command dispatching.

Solution Framework Outline

Our solution framework will involve the following components:

  1. A way to provide static data for the client, in case a server is not available to generate the data dynamically.
  2. A way to connect to a data source on the server, if it is available, to get the most up-to-date data and override the static data.
  3. A way to structure the data such that the ultimate rendering functions in the client side content can access it the same way in both WebHelp and Compiled Help.

Closely related to all of these is that RoboHelp's Conditional Build Tags will be utilized throughout where applicable.

The rest of this article will explain exactly how this can be accomplished with minimal code writing and high reusability and extensibility.

Database Connectivity

In the ISC scenario, we will examine how to connect to a Microsoft Access Database database, create a record set object using ADODB, and then render the results as a JavaScript object literal that will be passed back to the client through the HTML script tag. In a similar real situation, I connected to the business layer of an existing system that uses SQL Server as its underlying data store.

In your case, you may be using PHP and MySQL, or PERL and Oracle, Java and Cloudscape, or something completely different. The only part that must be the same for you will be that the JavaScript object literal should look the same when returned to the client. I use Access here because the MDB file format can be downloaded and used by most users on their local machine with ease.

Database Schema

First, let's define the schema for the MDB file. Unfortunately, I did not do this first. I made it in the Access program through the interface, but I found a great little python utility to reverse engineer an MDB file to generate its underlying DDL.

DDL for the MDB File

Listing 1
CREATE TABLE "lookup" 
( 
    "lookup_id" Counter NOT NULL , 
    "category" LongInteger DEFAULT 0 , 
    "lookup_desc" char(50) , 
    PRIMARY KEY "lookup_id","category" 
); 
CREATE INDEX "lookup_IX0" ON "lookup" ("category" ASC ); 
CREATE INDEX "lookup_IX1" ON "lookup" ("lookup_id" ASC ); 
CREATE UNIQUE INDEX "lookup_PK" ON "lookup" ("lookup_id" ASC,"category" ASC ); 
  
CREATE TABLE "params" 
( 
    "ParamID" Counter NOT NULL , 
    "Name" char(50) , 
    "Value" char(50) , 
    "Label" char(100) , 
    PRIMARY KEY "ParamID","Name" 
); 
CREATE UNIQUE INDEX "params_PK" ON "params" ("ParamID" ASC,"Name" ASC );

JavaScript Object Literal Output Data Package

The goal at this point is to create a simple JavaScript object literal using the underlying database data. The format will look like this:

Listing 2
myData={'1':[{'lookup_id':'5','lookup_desc':'Director'}
,{'lookup_id':'2','lookup_desc':'Employee'}
,{'lookup_id':'1','lookup_desc':'Manager'}
,{'lookup_id':'4','lookup_desc':'President'}
,{'lookup_id':'3','lookup_desc':'Supervisor'}
...
] myContact=[{'Name':'ContactNumber','Value':'404-555-5566',
    'Label':'Contact Number'}
,
{'Name':'ContactPerson','Value':'Mr. Manager','Label':'Contact Person'}
,
{'Name':'ContactHours','Value':'Mon - Fri 8 AM to 5 PM',
     'Label':'Contact Hours'}
];

Those familiar with PERL will think of this as an associative array of associative arrays. In VBScript, it might be implemented with the Scripting.Dictionary object. Java and .NET programmers, and others still may think of it as a HashTable. In JavaScript, it's simply called an Object in literal notation. JavaScript provides the most terse syntax of any of these languages.

The eval Function

In PERL and JavaScript, simply written strings like this (In PERL, substitute => for Smile | :) can be instantly vivified into the current context of execution at run-time with zero parsing done by the programmer. This is done through calling the eval function. Basically, eval attempts to execute a string as code within the scope of the current block of code that the interpreter is executing. Many scripts abuse the eval function, like scripts that process image rollovers, but its main use in scripting should be to vivify data structures into the engine during execution or to allow for interactive debugging as can be done in the Mozilla extension Venkman.

More on JavaScript Literal Notation

For more about JavaScript's support for literal notation, see this reference under the ECMAScript section:

ASP Code to Connect to Database and Generate the JavaScript

Here is the JScript code for an ASP page that will connect to the MDB file, then translate the lookup values and contact information into JavaScript object literals. Note that, in this ASP script, we also rely on literal notation to implement a flexible command dispatcher within the server side code. We use it in the values passed across on the query string. In line 9, we use the eval function to vivify the data into the interpreter's execution context.

Similar command dispatch systems can be implemented in other languages, and almost identically in PERL. Even in compiled languages it is possible, but would require more complicated data parsing during run-time. The power of scripting in this scenario is that it can do the job of parsing for us, provided we take enough care to think through any pitfalls and make proper use of such language constructs as try / catch.

Listing 3
// Load the javascript in string form from the request

var cmdSetAsString = Request.Item("cmdSet"); 
var cmdSet = []; 
var jsrv = ""; 
var isValid = false; 
try { 
    eval("cmdSet = [" + cmdSetAsString + "]"); 
} 
catch(e) { } 
if (cmdSet.length > 0) 
    isValid = true; 
if (isValid != true) { 
    jsrv = "JS_Evaluator_Error = 'Could not instantiate object from string.\n'"; 
    Response.Write(jsrv); 
    Response.End(); 
} 
// Execute each command 
for (var i = 0; i < cmdSet.length; i++) { 
    try { 
        // Assign return value to the specified variable 
        jsrv += cmdSet[i].vnam + 
            "=" + this[cmdSet[i].func](cmdSet[i].arg) + ";\n"; 
        jsrv += cmdSet[i].vnam + "_error=false;\n"; 
    } 
    catch(e) { 
        jsrv += cmdSet[i].vnam + "_error=true;\n"; 
        Response.Write(e.description); 
    } 
} 
Response.Write(jsrv); 
// Page Level functions 
function GetConnection() { 
    var path, conn, connStr; 
    path = Server.MapPath("LookupDB.mdb"); 
    conn = Server.CreateObject("adodb.connection"); 
    conn.Provider = "Microsoft.Jet.OLEDB.4.0"; 
    conn.Open(path); 
    return conn; 
} 
function GetListValues(listIds) { 
    // Create a DB Connection 
    var conn = GetConnection(); 
    var rv, i; 
    rv = ""; 
    for (var i = 0; i < listIds.length; i++) 
        rv += "\n,'" + listIds[i] + "':" + PackageListAsJS(listIds[i], 
                 ['lookup_id', 'lookup_desc'], conn); 
    conn.Close(); 
    delete conn; 
    return "{" + rv.substring(2) + "}"; 
} 
function PackageListAsJS(listId, arFields, conn) { 
    var rsList, rv; 
    var rsList = GetLookups(listId, conn); 
    rv = PackageRecordSetAsJS(rsList, arFields); 
    rsList.Close(); 
    delete rsList; 
    return rv; 
} 
function PackageRecordSetAsJS(rs, arFields) { 
    var rv = ""; 
    var numFields = arFields.length; 
    while(!rs.EOF) { 
        var row = ""; 
        for (var i = 0; i < numFields; i++) { 
            var name, value; 
            if (row != "") row = row + ","; 
            name = arFields[i]; 
            value = String(rs(name).value); 
            value = value.replace("\n", "\\n"); 
            value = value.replace("\r", "\\r"); 
            value = value.replace("'", "\\'"); 
            row += "'" + name + "':'" + value + "'"; 
        } 
        rv += ",{" + row + "}"; 
        rs.MoveNext(); 
    } 
    // Complete the javascript object 
    return "[" + rv.substring(1) + "]"; 
} 
function GetLookups(id, conn) { 
    var sql = "SELECT * FROM lookup WHERE category = " + id + 
            " ORDER BY lookup_desc"; 
    return conn.Execute(sql); 
} 
function GetContactInfo() { 
    var conn = GetConnection(); 
    var sql = "SELECT * FROM params WHERE Name LIKE 'Contact%'"; 
    var rsContact = conn.Execute(sql); 
    var rv = PackageRecordSetAsJS(rsContact, ["Name", "Value", "Label"]); 
    rsContact.Close(); 
    conn.Close(); 
    delete rsContact; 
    delete conn; 
    return rv; 
}

Security Notes for Best Practices

Note that, I do not check the referring URI here, but I could have done so to ensure maximum security. This would allow you to prevent people from browsing to the URL and entering their own parameters. You may also want to check for possible SQL injection tactics. Even though we are only passing a SELECT statement to the underlying database, a cracker can try to terminate your statement and then execute malicious code. This is where the flexibility of scripting can become a nightmare and the rigors of compilation and strong-typing become beneficial. However, even in a compiled server-side solution like JSP or ASP.NET, you could write a relatively simple parser to translate { , : , and [ into a combination of HashTable and ArrayList style object instances and check each data type for validity, thus still providing a very flexible command dispatching system that is loosely coupled to your underlying command processing architecture. But, for the purpose of illustrating the basic concepts in this demonstration, we'll stick to using JavaScript on both Client and Server.

Dynamic Page for Inclusion in WebHelp and Compiled Help

Here is the code for an HTML file that will include both a static version of the data and a dynamic call to generatedata.asp. Note the following:

  1. The static data is a locally saved copy of the output from a call to generatedata.asp.
  2. The call to generatedata.asp is tagged with RoboHelp's x-condition style to ensure that it is only rendered when building WebHelp.
Listing 4
<html>
<head>
<title>International Staffing Company</title>
<script type="text/javascript" src='DynamicPage_data.js'></script>
<script type="text/javascript"
style="x-condition: ONLINE_ALL"
src="http://localhost/robohelp/generatedata.asp?cmdSet=
  {vnam:'myData',func:'GetListValues',arg:[1,2,3]},
  {vnam:'myContact',func:'GetContactInfo',arg:null}"></script>
<script type="text/javascript" src='applicationcontroller.js'></script>
<script type="text/javascript">
<!--
var MyApp = 
   new MyHelpApplicationController(
   MyHelpApplicationController.DEPLOYMENT_ENUM.PRODUCTION);
// -->
</script>

<style type="text/css">
<!--
BODY {
    font-family: arial;
    font-size: 80%;
}
H3 {
    color: navy;
}
LI {
    list-style: disc;
}

/* works in FireFox, but not IE:
LI:before {
    content: '> ';
    font-weight: bold;
    color: navy;
    font-size: 85%;
}
*/
// -->
</style>
<script type="text/javascript">
<!--
    function RenderList(id) {
        var rv = "<ul>\n";

        var obj = myData[id];

        for (var i in obj) {
            rv += "\t<li>" + obj[i].lookup_desc + "</li>\n";
        }

        return rv + "</ul>\n";
    }

    function RenderContactInfo() {
        var rv = "<div>\n";

        for (var i = 0; i < myContact.length; i++) {
            var rec = myContact[i];
            rv += "<b>" + rec.Label + "</b> : " + rec.Value + "<br />\n";
        }
        return rv + "</div>\n";
    }
// -->
</script>

<body>

<h2>Welcome to International Staffing Company Web Help</h2>

<p>This site provides information about navigating our 
 web site and using our online services.</p>

<h3>ICS fills positions for the following job types:</h3>
    <script type="text/javascript">
    <!--
    document.write(RenderList(1));
    // -->
    </script>
<h3>ICS has placed people in the following industries world wide:</h3>
    <script type="text/javascript">
    <!--
    document.write(RenderList(2));
    // -->
    </script>
<h3>ICS has worked with companies in the following countries:</h3>
    <script type="text/javascript">
    <!--
    document.write(RenderList(3));
    // -->
    </script>
<h3>Contact ICS</h3>
Feel free to contact us!<br /><br />
    <script type="text/javascript">
    <!--
    document.write(RenderContactInfo());
    // -->
    </script>
<br />
<a href="javascript:void(alert(MyApp.baseUrl))">Show Base Url</a>
</body>
</html>

Implementation Notes and Options

Note that, the static data is not excluded from the WebHelp to ensure that the page always properly renders with some data, even if the data is not current. When calls to generatedata.asp fail due to network problems, an inaccessible database, or otherwise, this will help prevent user frustration. In fact, for data that may not change all that frequently, a better solution may even be to simply cache output from calls to generatedata.asp to the server filesystem. This can allow for push-of-a-button updating of all data sections or for scheduled tasks to generate this output periodically without manual intervention. In this alternative, flexibility is maintained at all levels, but points-of-failure are minimized.

Employing a Facade Pattern in Client-side JavaScript for Resource Location Abstraction

Lastly, as food for thought and to stand on a soap box once more about JavaScript's flexibility, I present that idea of implementing an application controller object inside of the generated help system to provide a facade that hides the underlying path to disparately located resources.

JavaScript Object Constructors and Prototypes

One of the features often overlooked in JavaScript is its prototype-based object system. This is different from class-based languages, but provides similar functionality and more run-time flexibility. Instead of making use of this very flexible system, many scripts are written in a procedural style, with tons of variables declared in the top-level object.

A Better Way Through Constructors

There is a better way through the use of object constructors. This way, all variables and objects can be contained within a single object. Here is an example that can be used to abstract away the details of the physical location of resources. This leaves the application programmer free to focus simply on naming resources as appropriate and leaving the details of finding those resources up to the controller instance.

A Facade to Hide the Details of Disparate Deployments

I have used this in a real situation in which we must test our help system in several environments, including Local, Development, Staging, Production, and Microsoft Compiled Help. Complicating factors are:

  1. Each of these environments has slightly different locations for resources that cannot be made into fully relative links due to disparate locations and network configuration, including security restrictions.
  2. It is ill-advised to hard-code location paths into the content of the RoboHelp project.
  3. Similarly, it is ill-advised to make calls to a database to get these location paths because this is not applicable in Compiled Help.

  4. Finally, with multiple installations on different web servers that may need to access shared data outside of the help system, it is well-advised to find a way for these installations to point to shared resources.

With a mixture of RoboHelp's Conditional Build Tags and client-side JavaScript, the solution becomes workable through a simple implementation of the Facade design pattern.

Listing 5
// Application Controller employing a Facade design pattern
function MyHelpApplicationController(deploymentType) {
    this.baseUrl = "";
    this.init(deploymentType);
}
/* Constructor Level Enumeration for Deployment Configuration Options */
MyHelpApplicationController.DEPLOYMENT_ENUM = {
    LOCAL : 1,
    DEVELOPMENT : 2,
    STAGING : 3,
    PRODUCTION: 4,
    HOTSITE: 5,
    COMPILED_HELP: 6
}
MyHelpApplicationController.prototype.init = function(deploymentType) {
    switch(deploymentType) {
        case MyHelpApplicationController.DEPLOYMENT_ENUM.LOCAL:
            this.baseUrl = "c:\\projects\\helpsystem\\helpoutdirectory\\";
        break;
        case MyHelpApplicationController.DEPLOYMENT_ENUM.DEVELOPMENT:
            this.baseUrl = "http://machine-on-intranet/applicationdir/help/";
        break;
        case MyHelpApplicationController.DEPLOYMENT_ENUM.PRODUCTION:
            this.baseUrl = 
               "http://www.internationalstaffingcompanysiteurl.com/help/";
        break;
        case MyHelpApplicationController.DEPLOYMENT_ENUM.COMPILED_HELP:
            this.baseUrl = "./";
        break;
    }
}
MyHelpApplicationController.prototype.NavigateUrl = function(url) {
    location.href = this.baseUrl + url;
}
MyHelpApplicationController.prototype.GetScriptReference = function(url) {
    return "<" + "script type='text/javascript' src='" + this.baseUrl + url +
    "'>" + "</" + "script>";
}

Postscript: Notes About Scope and Ideas for Improvement

The solution described above assumes that help authoring is done through standard HTML authoring or importing of files from Word documents. This approach is common in many help projects and reuses existing content. A better, even more flexible approach in my opinion, but one that may or may not incur more up-front reworking of existing content, is to convert existing content into a standard XML schema.

XML Support in RoboHelp Provides Support for Authoring Semantically Rich Help Content

HTML is not a semantic language, especially when it comes to authoring help content! XML was designed to allow for tag sets to be developed to allow authors to convey their ideas semantically rather than graphically. Based upon the semantics provided by authors, application authors then write translation code to generate appropriate graphical representations. This allows for One to Many relationships between the original semantic intent and the eventual output format. Unfortunately, HTML sometimes feels more like One to None.

RoboHelp supports a sophisticated XML importation option that allows for content authors to design help systems with a semantically-rich structured content, rather than trying to fit Help and user-centric idioms into the ill-suited HTML schema. RoboHelp's support of XML through XML Handlers allows the application developer to code XSLT to translate the semantic Help schema into appropriate HTML or any other output.

In this scenario, the help author could write out semantically rich XML against which the application developer then applies an XSLT transformation to build the HTML content. By allowing the help author to create her own semantics and the application developer to process it to generate output, XML lets both author and developer be more productive and deliver a better user experience in the end.

See the Related Resources section for more information about XML support in RoboHelp X5.

Downloads and Related Resources

Downloads

See the downloads section on the right side of the page for a ZIP file containing the complete set of code listings and working examples for both static and dynamic pages. It also contains a complete RoboHelp X5 Project, with generated WebHelp and Compiled Help outputs. There is a Readme.txt file to explain how to use it in IIS.

Links

While writing this article, I came across some great links that were very useful.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here

Share

About the Author

UV2003
Web Developer
United States United States
No Biography provided

Comments and Discussions

 
GeneralInteresting article... PinmemberSimon Segal9-Jan-07 22:26 
Generalplease fix underline overflow Pinmemberpeterchen12-May-05 0:06 
GeneralRe: please fix underline overflow PinmemberUV200312-May-05 11:24 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Mobile
Web03 | 2.8.140916.1 | Last Updated 10 May 2005
Article Copyright 2005 by UV2003
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid