Click here to Skip to main content
15,867,453 members
Articles / Programming Languages / C#

Power of Dynamic: Reading XML and CSV files made easy using DynamicObject in C#

Rate me:
Please Sign up or sign in to vote.
5.00/5 (12 votes)
10 Aug 2012CPOL8 min read 61.5K   2.2K   28   23
DynamicObject wrapper for XML and CSV reading

  • Download DynamicIOStream-noexe.zip - 30.4 KB
  • Download DynamicIOStream.zip - 77 KB 
  • 1. INTRODUCTION   

    In my past assignments, i came across parsing XML and CSV files. I always ended up using several XML parsing APIs. I started working in C# from 2.0. I remember i was using System.Xml.XMlTextReader, System.Xml.XmlReader, System.Xml.Linq.XElement,
    System.Xml.XDocument. There are several pre-defined classes in C# for parsing and creating XML file.

    In case of CSV, its a kind of text file parsing but every developer had their own approach of reading a CSV file no matter its good or bad approach. 

    I wish if some XML parsing APIs gives me result value by just passing structure of xml nodes. For example, if below if my XML file:
    <Customer>
       <Name>Mahesh Kumar</Name>
    </Customer> 
    If i specify "Customer.Name", it should results in "Mahesh Kumar". i realised by dream partially using XPath. But, still i am not compromised.
    There should be some dynamic ways of reading or creating XML and CSV file. 
    This article explains in details about how to read XML and CSV files dynamically just by passing structure of XML nodes using System.Dynamic.DynamicObject in C#. 

    2. PREFACE 

    when i read an article about C# 4.0 Dynamic Programming. I saw an example in MSDN blog that interprets text file and fetch the line thats starts with user input string using DynamicObject.

    This gave me an idea that, i can use "DynamicObject" for reading XML and CSV files. i wrote a small prototype, it really worked well. 

    After few months, i just went through an article that also had similar approach of my thinking. So, i changed my code by adding more functionalities
    of reading or modifying XML/CSV files. This is how, this article evolved. 

    3. CODE DOWNLOAD  

    4. DYNAMIC OBJECT in C# 

    "dynamic" is a data type that binds to actual type run time. dynamic variable can hold any data type. There is no intellisense support for dynamic.
    Its developer's intelligence to handle it to avoid runtime exception. Here is a small example of dynamic: 
    dynamic myDynamic = "Something";
    Console.WriteLine(myDynamic.GetType().FullName);// results System.String  

    System.Dynamic.DynamicObject expose members such as properties and methods at run time, instead of in at compile time.   

    This enables you to create objects to work with structures that do not match a static type or format.
    The DynamicObject class enables you to override operations like getting or setting a member, calling a method, 
    or performing any binary, unary, or type conversion operation. 

    4.1 REFLECTION Vs DYNAMIC 

    Although both Reflection and Dynamic serves for same purpose but, Dynamic is a cleanest form of reflection.
    Consider the below example using reflection and dynamic: 
    #region Reflection Vs Dynamic
    // --- Using Reflection ---
    object usingReflection = Activator.CreateInstance(Type.GetType("System.Text.StringBuilder"));
    MethodInfo ObjectMethodInfo = usingReflection.GetType().GetMethod("Append", new[] { typeof(string) });
    ObjectMethodInfo.Invoke(usingReflection, new object[] { "Hello" });
    Console.WriteLine(
        usingReflection.GetType().GetMethod("ToString", new Type[0]).Invoke(usingReflection, null)
    );
    // --- Using Dynamic ---
    dynamic usingDynamic = Activator.CreateInstance(Type.GetType("System.Text.StringBuilder"));
    usingDynamic.Append("Hello");
    Console.WriteLine(usingDynamic.ToString());
    #endregion
    Dynamic variable will be type casting to actual object at run time and call properties or methods. 

    5. PARSING XML DOCUMENT USING System.Dynamic.DynamicObject 

    At end of this article, i want to make my readers to get to know how to parse XML file like below:
    Consider this is your XML document:
    <?xml version="1.0" encoding="utf-8" ?>
    <Root>
      <Customers>
        <Customer CustomerID="GREAL">
          <CompanyName>Great Lakes Food Market</CompanyName>
          <ContactName>Howard Snyder</ContactName>
          <ContactTitle>Marketing Manager</ContactTitle>
          <Phone>(503) 555-7555</Phone>
          <FullAddress>
            <Address>2732 Baker Blvd.</Address>
            <City>Eugene</City>
            <Region>OR</Region>
            <PostalCode>97403</PostalCode>
            <Country>USA</Country>
          </FullAddress>
        </Customer>
     </Customers>
    </Root> 
    If i want to read the value of "CompanyName" of a Customer, i should be doing like below: 
    dynamic customerObj = new DynamicObject(strXmlContent); // Lets say, strXmlContent variable holds value of above XML content
    string compName = customerObj.Root.Customers.Customer.CompanyName; 
    In the above example, you are not using any API's rather you are just specifying the structure of XML node which you want to read the value of it. 

    5.1 DYNAMIC XML STREAM 

    i simply use constructor to feed List<System.Xml.Linq.XElement>. This class has several static overloaded "Load()" method that creates object of "DynamicXmlStream" and returns it.

    static DynamicXmlStream Parse(string, LoadOptions): allows user to pass xml string directly and create the dynamic object

    static DynamicXmlStream Create(string): allows user to create XML document by specifying root element name as parameter. 

    This class is extended from System.Dynamic.DynamicObject and IEnumerable<DynamicXmlStream>.

    There are several overrided methods from "DynamicObject" to accomplish dynamic parsing/creation of XML file. 

    5.1.1 TryGetMember() 

    This method gets information about what property it was called for through the binder parameter. 
    As you can see, the binder.Name contains the actual name of the property. The TryGetMember method returns true if the operation is successful. 

    But the actual result of the operation must be assigned to the out parameter result.  

    public override bool TryGetMember(GetMemberBinder binder, out object result)
    {
        result = null;
    	if (binder.Name == DynamicXmlStream.Value)
    	{
    		var items = _XmlElementCollection[0].Descendants(XName.Get("Value"));
    		if (items == null || items.Count() == 0)
    		{
    			result = _XmlElementCollection[0].Value;
    		}
    		else
    		{
    			result = new DynamicXmlStream(items);
    		}
    	}
    	else if (binder.Name == DynamicXmlStream.Count)
    	{
    		result = _XmlElementCollection.Count;
    	}
    	else
    	{
    		XAttribute xAttribute = _XmlElementCollection[0].Attribute(XName.Get(binder.Name));
    		if (null != xAttribute)
    		{
    			result = xAttribute;
    		}
    		else
    		{
    			IEnumerable<XElement> xElementItems = _XmlElementCollection[0].DescendantsAndSelf(XName.Get(binder.Name));
    			if (xElementItems == null || xElementItems.Count() == 0)
    			{
    				return false;
    			}
    			result = new DynamicXmlStream(xElementItems);
    		}
    	}
        return true;
    }

    5.1.2 TrySetMember() 

    This method gets the element node as mentioned in binder.Name and sets the value of the xml node.
    The value is mentioned in the second parameter as object type. 
    public override bool TrySetMember(SetMemberBinder binder, object value)
    {
    	if (binder.Name == DynamicXmlStream.Value)
    	{
    		_XmlElementCollection[0].Value = value.ToString();
    	}
    	else
    	{
    		XElement setNode = _XmlElementCollection[0].Element(binder.Name);
    		if (setNode != null)
    		{
    			setNode.SetValue(value);
    		}
    		else
    		{
    			if (value.GetType() == typeof(DynamicXmlStream))
    			{
    				_XmlElementCollection[0].Add(new XElement(binder.Name));
    			}
    			else
    			{
    				_XmlElementCollection[0].Add(new XElement(binder.Name, value));
    			}
    		}
    	}
    	return true;
    } 

    5.1.3 TryGetIndex() 

    This methods serves for two purpose:
    #1: if there are multiple nodes of same name, "int" index act as a array to fetch the specified index value.
    #2: if index type is string, it will search for attribute on the current node of name mentioned in index. 
    public override bool TryGetIndex(GetIndexBinder binder, object[] indexes, out object result)
    {
    	result = null;
    	if (null != indexes[0])
    	{
    		if (typeof(int) == indexes[0].GetType())
    		{
    			int index = (int)indexes[0];
    			result = new DynamicXmlStream(_XmlElementCollection[index]);
    			return true;
    		}
    		else if (typeof(string) == indexes[0].GetType())
    		{
    			string attributeName = (string)indexes[0];
    			result = _XmlElementCollection[0].Attribute(XName.Get(attributeName)).Value;
    			return true;
    		}
    	}
    	return false;
    } 

    5.1.4 TrySetIndex() 

    This method serves to set value to the attribute as specified in third parameter as object type. 
    public override bool TrySetIndex(SetIndexBinder binder, object[] indexes, object value)
    {
    	if (null != indexes[0])
    	{
    		if (typeof(string) == indexes[0].GetType())
    		{
    			_XmlElementCollection[0].SetAttributeValue((string)indexes[0], value);
    			return true;
    		}
    	}
    	return false;
    } 

    5.1.5 TryConvert() 

    This methods used to convert dynamic object to XElement or List<XElement> or string.
    The user can simply cast the dynamic object to any of the above 3 types as normal syntax like:
    XElement xElem = (XElement)dynamicXmlObject; 
    public override bool TryConvert(ConvertBinder binder, out object result)
    {
    	if (binder.Type == typeof(XElement))
    	{
    		result = _XmlElementCollection[0];
    	}
    	else if (binder.Type == typeof(List<XElement>) || (binder.Type.IsArray && binder.Type.GetElementType() == typeof(XElement)))
    	{
    		result = _XmlElementCollection;
    	}
    	else if (binder.Type == typeof(String))
    	{
    		result = _XmlElementCollection[0].Value;
    	}
    	else
    	{
    		result = false;
    		return false;
    	}
    	return true;
    } 

    5.1.6 TryInvokeMember() 

    This method help the DynamicXmlStream class to act similar like System.Xml.Linq.XElement.
    Any method of XElement can be invoked from this DynamicXmlStream without casting. 
    public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result)
    {
    	Type xmlType = typeof(XElement);
    	try
    	{
    		result = xmlType.InvokeMember(
    			binder.Name, 
    			BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Instance,
    			null, _XmlElementCollection[0], args
    		);
    		return true;
    	}
    	catch
    	{
    		result = null;
    		return false;
    	}
    } 

    5.1.7 Other Public Methods 

    AsDynamicEnumerable(): used to cast the sequence of XElement objects to dynamic that makes easy for user to enumerate using foreach.
    GetEnumerator(): used to enumerate DynamicXmlStream using foreach. 

    5.1.8 XElementExtension class 

    This static class has extension method thats allows to convert System.Xml.Linq.XElement object to DynamicXmlStream.
    So, user can either cast DynamicXmlStream to XElement and viceversa. 
    public static class XElementExtension
    {
    	public static DynamicXmlStream ToDynamicXmlStream(this XElement xElement)
    	{
    		DynamicXmlStream dynamicXmlStream = new DynamicXmlStream(xElement);
    		return dynamicXmlStream;
    	}
    } 

    5.1.9 XmlNamespaceRemover class 

    This class helps us to remove the namespaces in XML file. For example if below is my XML file:

    <x:Book> <x:Name>Steve Jobs</x:Name> </x:Book> 

    In this case, developer cannot retrive name of the book by: "dynXmlObj.x:Book.x:Name" (which is wrong syntax). So, this XmlNamespaceRemover will remove all namespaces. Now, developer can fetch name of the book by: "dynXmlObj.Book.Name" (which is correct syntax) 

    public class XmlNamespaceRemover
        {
            public XmlNamespaceRemover() { }
     
            public static XElement RemoveAllNamespaces(XDocument xDocumentSource)
            {
    			Stream docStream = new MemoryStream();
                xDocumentSource.Save(docStream);
                docStream.Position = 0;
    
                Stream outputStream = new MemoryStream();
                outputStream.Position = 0;
    
                XPathDocument xPathDocument = new XPathDocument(docStream);
                XPathNavigator xPathNavigator = xPathDocument.CreateNavigator();
     
                XslTransform myXslTransform;
                myXslTransform = new XslTransform();
                
                XmlReader xsltReader = 
    				XmlReader.Create(Assembly.GetExecutingAssembly().GetManifestResourceStream("LearningMahesh.DynamicIOStream.Xml.StripNamespace.xslt"));
                myXslTransform.Load(xsltReader);
     
                XsltArgumentList xsltArgList = new XsltArgumentList();
                myXslTransform.Transform(xPathNavigator, xsltArgList, outputStream);
     
                outputStream.Position = 0;
    			XDocument finalDocument = XDocument.Load(outputStream);
    
    			XElement root = finalDocument.Root;
                return root;
            }  

    Thanks to one of reader of this article(woutercx), who suggested this change.  

    6. PARSING CSV DOCUMENT USING System.Dynamic.DynamicObject 

    Consider, this is your CSV (Comma Seperated Value) file: 
    CustomerID,EmployeeID,Address
    GREAL,6,USA
    HUNGC,3,UK 
    i wish to read Address of Customer GREAL as "order.Address", which depends on the row that dynamic object pointed currently.
    if it pointed on first row, value is USA. if its second row, value is UK. 

    6.1 DYNAMIC CSV STREAM 

    DynamicCsvStream class acts as a falicitator to create DynamicCsvRow object. DynamicCsvStream extended from IEnumerable<DynamicCsvRow>.

    In DynamicCsvStream, i simply use constructor to feed header columns and List<DynamicCsvRow> that holds the value of each row in the CSV file. 

    static DynamicCsvStream Load(StreamReader): get the StreamReader object and reads teh header column and fill List<DynamicCsvRow> with each row value.
    static DynamicCsvStream Parse(string): calls Load(StreamReader) method
    GetEnumerator(): used to enumerate DynamicCsvStream using foreach statement.
    IEnumerable<dynamic> AsDynamicEnumerable(): used to cast the sequence of DynamicCsvRow objects to dynamic that makes easy for user to enumerate using foreach. 

    6.2 DYNAMIC CSV ROW 

    DynamicCsvRow class extended from System.Dynamic.DynamicObject and IEnumerable<string>. It holds the row value and allows it to read dynamically.
    Each row is actually stored in List<string>. Each element in the List is a comma seperated value. 

    6.2.1 TryGetMember() 

    This method allows to get the column value of a column name specified in binder.Name.
    User can just mention column name to read the value. 
    public override bool TryGetMember(GetMemberBinder binder, out object result)
    {
    	int indexOfColumn = _HeaderColumns.IndexOf(binder.Name);
    	result = null;
    	if (-1 != indexOfColumn)
    	{
    		result = (indexOfColumn < _RowColumns.Count) 
    			? _RowColumns[indexOfColumn]
    			: null;
    	}
    	return (indexOfColumn >= 0);
    } 

    6.2.2 TrySetMember() 

    This method allows to set or alter the value of the column that specified with column name as value mentioned as object type. 
    public override bool TrySetMember(SetMemberBinder binder, object value)
    {
    	int indexOfColumn = _HeaderColumns.IndexOf(binder.Name);
    	if (-1 != indexOfColumn)
    	{
    		_RowColumns[indexOfColumn] = value.ToString();
    	}
    	return (indexOfColumn >= 0);
    } 

    6.2.3 TryGetIndex() 

    If the index specified in "int" type, it will fetch the value of index in comma seperated list of values.
    If the index specified in "string type, it will look for the header name and return correspoding value. This will be useful is Column header has any white spaces.
    Eg: csvObj["Customer Name] (we cannot specify like "csvObj.Customer Name"). 
    public override bool TryGetIndex(GetIndexBinder binder, object[] indexes, out object result)
    {
    	result = null;
    	if (null != indexes[0])
    	{
    		if (typeof(int) == indexes[0].GetType())
    		{
    			int index = (int)indexes[0];
    			result = (index <= _RowColumns.Count)
    				? _RowColumns[index]
    				: null;
    			return true;
    		}
    		else if (typeof(string) == indexes[0].GetType())
    		{
    			string attribute = (string)indexes[0];
    			int index = _HeaderColumns.IndexOf(attribute);
    			result = (-1 != index && index < _RowColumns.Count)
    				? _RowColumns[index]
    				: null;
    			return true;
    		}
    	}		
    	return false;
    } 

    6.2.4 TrySetIndex() 

    It will act similar like TryGetIndex but it will set the value as mentioned in object type.
    This method will accept both int and string type index. 
    public override bool TrySetIndex(SetIndexBinder binder, object[] indexes, object value)
    {
    	if (null != indexes[0])
    	{
    		if (typeof(int) == indexes[0].GetType())
    		{
    			int indexToChange = (int)indexes[0];
    			if (indexToChange <= _RowColumns.Count)
    			{
    				_RowColumns[indexToChange] = value.ToString();
    			}
    			return true;
    		}
    		else if (typeof(string) == indexes[0].GetType())
    		{
    			string attribute = (string)indexes[0];
    			int indexToChange = _HeaderColumns.IndexOf(attribute);
    			if (indexToChange <= _RowColumns.Count)
    			{
    				_RowColumns[indexToChange] = value.ToString();
    			}
    			return true;
    		}
    	}
    	return false;
    } 

    6.2.5 TryConvert() 

    This method allows to directly convert DynamicCsvRow to List<string> as comma sperated values or just string value with raw output of a row. 
    public override bool TryConvert(ConvertBinder binder, out object result)
    {
    	result = null;
    	if (binder.Type == typeof(List<string>))
    	{
    		result = _RowColumns;
    	}
    	else if (binder.Type == typeof(string))
    	{
    		result = string.Join(",", _RowColumns);
    	}
    	else
    	{
    		return false;
    	}
    	return true;
    } 

    6.2.6 TryInvokeMember()  

    This method help the DynamicXmlStream class to act similar like List<string> object.
    All lamda expressions that supported with List<string> can also be invoked from this DynamicCsvStream without casting. 
    public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result)
    {
    	Type stringListType = typeof(List<string>);
    	try
    	{
    		result = stringListType.InvokeMember(
    			binder.Name,
    			BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Instance,
    			null, _RowColumns, args
    		);
    		return true;
    	}
    	catch
    	{
    		result = null;
    		return false;
    	}
    } 

    7. CLIENT PROGRAM 

    Using DynamicXmlStream: Here is the simple client for reading XML file. 
    dynamic customerOrderXmlReader = DynamicXmlStream.Load(new FileStream("../../Input/CustomerOrder.xml", FileMode.Open));
    Console.WriteLine("First Customer's Company Name = " + customerOrderXmlReader.Root.Customers.Customer[0].CompanyName.Value);
    Console.WriteLine("Orders Shipped to CA: ");
    foreach (dynamic order in 
    	(customerOrderXmlReader.Root.Orders.Order as DynamicXmlStream).AsDynamicEnumerable()
    		.Where(ord => ord.ShipInfo.ShipRegion.Value == "CA"))
    {
    	Console.WriteLine(order.ToString());
    } 
    // --- Read XML file with namespace like <x:book> ---
    dynamic booksReviewXmlReader = DynamicXmlStream.Load(new FileStream("../../Input/BooksReview.xml", FileMode.Open));
    Console.WriteLine("\n\nXML Namespace remover for Book review:");
    Console.WriteLine("Title Name" + booksReviewXmlReader.html.head.title.Value);
    Console.WriteLine("Book Price" + booksReviewXmlReader.html.body.bookreview.table.tr[1].td[1].price.Value); 
    Using DynamicCsvStream: Here is the simple client for reading CSV file. 
    dynamic customerOrderCsvReader = DynamicCsvStream.Load(new StreamReader("../../Input/CustomerOrder.csv"));
    Console.WriteLine("All Processed Orders: ");
    foreach (dynamic order in customerOrderCsvReader)
    {
    	Console.WriteLine("Customer ID: {0}, Ship Date: {1}, Ship Address: {2}", 
    		order.CustomerID,		// Calls TryGetMember
    		order[4],				// Calls TryGetIndex (Int)
    		order["ShipAddress"]	// Calls TryGetIndex (String)
    	);
    } 

    8. CONCLUSION

    The ultimate goal is to make client program as KISS (Keep It Short and Simple). There are multiple ways to read XML and CSV file by using several 
    predefined class and APIs provided by .NET C# but, using DynamicXmlStream and DynamicCsvStream, best APIs are encapsulated and exposed to users by asking
    them to just specify structure of XML node or column name of CSV field. This will make client program simple and clean. 

    9. REFERENCES 

    License

    This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


    Written By
    Software Developer (Senior)
    India India
    Started my software development career in December 2007. Having 4+ years of experience in C#. More interested to learn deeper and share on Design Pattern, Parallel and Dynamic programming.
    Started my career as PERL developer for few months and then moved to VC++ and then to C#. When i moved from Procedure oriented programming to Object oriented programming, i really enjoyed and appreciated the OOPS concepts.
    Explored most of the language features of C# 3.5 and 4.0. In my current assignments, working on Biz Talk and WCF.

    Comments and Discussions

     
    QuestionRuntimeBinderException Pin
    mendy aaron29-Jan-19 9:40
    mendy aaron29-Jan-19 9:40 
    Questionperiod '.' on local name Pin
    lightkun2214-Oct-15 0:11
    lightkun2214-Oct-15 0:11 
    Questioncode is not working Pin
    Afazal MD 310420918-Aug-15 23:38
    professionalAfazal MD 310420918-Aug-15 23:38 
    QuestionExtremely nice library - but found one issue: When xml tag a C# keyword such as 'event' Pin
    devvvy8-Apr-15 8:51
    devvvy8-Apr-15 8:51 
    QuestionSlow using many nested tags Pin
    Member 1115434413-Dec-14 2:40
    Member 1115434413-Dec-14 2:40 
    QuestionThank you oh so very much Mahesh! Pin
    Swab.Jat23-Jan-14 22:10
    Swab.Jat23-Jan-14 22:10 
    GeneralMy vote of 5 Pin
    Swab.Jat23-Jan-14 22:10
    Swab.Jat23-Jan-14 22:10 
    GeneralMy vote of 5 Pin
    devvvy16-Dec-13 17:07
    devvvy16-Dec-13 17:07 
    QuestionJust what I need = 5 stars Pin
    Member 208301826-Sep-13 11:42
    Member 208301826-Sep-13 11:42 
    Questionlinq lambda search in dynamic class Pin
    luhh23-May-13 8:36
    luhh23-May-13 8:36 
    QuestionEnumerating using AsDynamicEnumerable Pin
    StephenPAdams22-Oct-12 19:39
    StephenPAdams22-Oct-12 19:39 
    AnswerRe: Enumerating using AsDynamicEnumerable Pin
    Mahesh Kumar Velayutham23-Oct-12 2:22
    Mahesh Kumar Velayutham23-Oct-12 2:22 
    QuestionWhat happens when the XML has namespaces? Pin
    woutercx9-Aug-12 9:02
    woutercx9-Aug-12 9:02 
    AnswerRe: What happens when the XML has namespaces? Pin
    Mahesh Kumar Velayutham9-Aug-12 15:12
    Mahesh Kumar Velayutham9-Aug-12 15:12 
    AnswerRe: What happens when the XML has namespaces? Pin
    woutercx10-Aug-12 1:01
    woutercx10-Aug-12 1:01 
    SuggestionDownloads not working Pin
    woutercx9-Aug-12 4:35
    woutercx9-Aug-12 4:35 
    GeneralRe: Downloads not working Pin
    Mahesh Kumar Velayutham9-Aug-12 5:49
    Mahesh Kumar Velayutham9-Aug-12 5:49 
    AnswerDownloads working now Pin
    woutercx9-Aug-12 8:56
    woutercx9-Aug-12 8:56 
    QuestionGood article! Pin
    Volynsky Alex8-Aug-12 22:26
    professionalVolynsky Alex8-Aug-12 22:26 
    QuestionVery helpful thank you Pin
    woutercx8-Aug-12 11:48
    woutercx8-Aug-12 11:48 
    I already did something with dynamic for XML parsing for writing simple tools. This is just the thing I need! Thank you, I'm going to use this! One remark I have, how about getting an attribute of an XML element? For example, the CustomerID from <Customer CustomerID="GREAL"> ?
    AnswerRe: Very helpful thank you Pin
    Mahesh Kumar Velayutham8-Aug-12 18:27
    Mahesh Kumar Velayutham8-Aug-12 18:27 
    GeneralRe: Very helpful thank you Pin
    woutercx8-Aug-12 19:31
    woutercx8-Aug-12 19:31 
    AnswerRe: Very helpful thank you Pin
    woutercx10-Aug-12 1:09
    woutercx10-Aug-12 1:09 

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

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