"Writing" a Journal
When I decided to start this journal I didn't expect to build the majority of the actual journal functionality myself, but that is exactly what I am doing. I built this website to share some of my technical experience and I think it's only fitting that my first entry describes the building of the journal itself.
Requirements
The first phase of development needed to address essential functionality that represents the minimum requirements to begin using the journal. The basic concept is to create a system that allows a user to quickly add interactive content to the website in the form of projects, articles and commentaries. Based on that idea I came up with the following list of requirements.
- Each journal entry must be associated to one or more Categories.
- Each journal entry must include a title, body, author and the date and time the entry was posted.
- Each author must include a name, email address and password, and optionally a website address.
- Each journal entry may contain zero or more comments.
- New comment entries require validation and anti-spam functionality.
- Journal entries may be added or modified using a web-based WYSIWYG editor.
- Ability to add comments may be enabled or disabled for each journal entry.
- Store all journal data in a MySql database, my host's preferred database solution.
Design
Interface
Journal Lists
Journal lists display a simple or summarized version of each entry filtered by predefined or user-defined categories. A summarized list includes title, a small portion of the body, the date the entry was created and the author. A simple list only includes the title and the date.
An ASP.NET Server Control was created to standardize the list display of journal entries. The definition for the JournalList class is shown in table 1. Currently, this is the only portion of the journal that is implemented as a stand-alone control, but eventually all journal functionality will be implemented in the same way.
Latsos.Web.UI.WebControls::JournalList
| Inheritance |
| System.Web.UI.Control |
| Properties |
| name |
type |
description |
| CategoryFilter |
string[] |
An array of category names used to filter the list. All rows are returned if the array is empty or null. |
| CategoryURL |
string |
The URL of the page to display when a category link is selected. The query string "?categoryId={id}" is automatically appended to the URL. |
| DetailURL |
string |
The URL of the page to display when the an individual entry is selected. The query string "?postId={id}" is automatically appended to the URL. |
| DisplayCount |
int |
Limits the number of entries returned by the list. The default is -1 (all entries). |
| ListTitle |
string |
The title of the list (H1). |
| ListType |
ListTypeEnum |
Determines whether a summary or basic list is created. The default is Summary.
public enum ListTypeEnum
{
Basic,
Summary
}
|
| Methods |
| name |
return type |
description |
| CreateChildControls() |
void |
Overridden. Adds controls to build the journal list. See implementation below. |
table 1.
Journal Entry
The actual journal entry will be displayed using a basic aspx page that includes a PlaceHolder control for the journal entry content and related comments, and an "add comment" area for new comments. Future enhancements will include a "Related Entries" list in the right-hand column.
New comments may be added if the "Allow Comments" checkbox is selected for the individual entry. A Captcha challenge-response mechanism will be used to thwart automated spam attacks. The Captcha control I decided to use was written by Kevin Gearing.
Journal Editor
This is one of the few components that I did not write myself. I decided to use FCKEditor, an open source javascript application. The flexibility provided by this editor is incredible. It includes an ASP.NET component that allows easy integration into existing applications. The editor can be seen in figure 1.

figure 1.
Data Elements
The following entity diagram (figure 2) describes the tables and relationships used by the journal. The diagram is self-explanatory, except for a few items.
The password stored in the user table is encrypted using the Advanced Encryption Standard (AES). The user table includes a column named eV, which is the encryption initialization vector. The vector is used in conjunction with the encryption key (stored in a secure location) to encrypt and decrypt the password value. Storing a unique vector with each password makes it more difficult to break the encryption algorithm. For more information regarding this technique see Implementing Encrypted SQL Server Database Columns with .NET.
The postDate column in the post_category table is a calculated field used during filtering procedures. The expression for the field is Parent(post__post_category).postDate.

figure 2.
Data Access
The journal data is stored in a MySql database and is accessed using a custom library class (Latsos.Data.MySqlClient::MyData). The class builds a DataSet dynamically based on a simple XML file (figure 3) that describes the underlying database. All of the tables have a primary key that consists of a single column named "id".
xml version="1.0" encoding="utf-8"
<dd>
<tables>
<table name="post" primaryKey="id" />
<table name="user" primaryKey="id" />
<table name="comment" primaryKey="id" />
<table name="category" primaryKey="id" />
<table name="post_category" primaryKey="id" />
<table name="relatedPost" primaryKey="id" />
</tables>
<relations>
<relation
parent="category"
child="post_category"
foreignKey="categoryId" />
<relation
parent="user"
child="post"
foreignKey="createdById" />
<relation
parent="user"
child="comment"
foreignKey="createdById" />
<relation
parent="post"
child="comment"
foreignKey="postId" />
<relation
parent="post"
child="post_category"
foreignKey="postId" />
<relation
parent="post"
child="relatedPost"
foreignKey="postAId" />
<relation
parent="post"
child="relatedPost"
foreignKey="postBId" />
</relations>
<expressionColumns>
<expressionColumn
tableName="post_category"
name="postDate"
typeName="System.DateTime"
expression="Parent(post__post_category).postDate"/>
</expressionColumns>
</dd>
figure 3.
Development
Interface
Journal Lists
The JournalList class produces two types of lists, Basic and Summary.
Basic List markup:
<div class="journal">
<h1>[list title]</h1>
<ul class="onsite-list">
<li><a href='journalEntry.aspx?postId=[post id]'></a> [entry date]</li>
...
</ul>
</div>
Summary List:
<div class="journal">
<h1>[list title]</h1>
<dl>
<dt><a href='JournalEntry?postId=[post id]'>[entry title]</a></dt>
<dd class="summary">[first paragraph of entry]</dd>
<dd class="footer>
<a href='JournalEntry?postId=[post id]'><img src="images/doc.gif"/></a>
[entry date]
[<a href='Journal.aspx?categoryId=[cat id]'>[cat name]</a>, ...]
| posted by [author name]
</dd>
...
</dl>
</div>
protected override void CreateChildControls()
{
base.CreateChildControls();
//create main DIV
Panel divJournal = new Panel();
divJournal.ID = this.ID;
divJournal.CssClass = "journal";
Controls.Add(divJournal);
//add list title
HtmlGenericControl h1 = new HtmlGenericControl("h1");
h1.InnerHtml = _ListTitle;
divJournal.Controls.Add(h1);
//get journal dataset
MyData myData = new MyData(Context.Server.MapPath(ConfigurationSettings.AppSettings["DDFile"]));
//initialize variables
DataTable dtCat = myData["category"];
DataTable dtPost = myData["post"];
DataRow[] postRows;
int iCount = _DisplayCount;
//create row array based on category filters
if (_CategoryFilter.Length != 0)
{
string filter = "[name] IN ('" + String.Join("', '", _CategoryFilter) + "')";
DataRow[] catRows = dtCat.Select(filter, "[name]");
if (catRows.Length == 0)
{
divJournal.Controls.Add(new LiteralControl("<p class='empty'>No Entries Found</p>"));
return;
}
string[] catIds = new string[catRows.Length];
for (int i = 0; i < catRows.Length; i++) catIds[i] = catRows[i]["id"].ToString();
DataRow[] postCategoryRows = myData["post_category"].Select("categoryId in (" + String.Join(", ", catIds) + ") AND Parent(post__post_category).isPublic = 1", "postDate DESC, postId");
if (postCategoryRows.Length > 0)
{
ArrayList alpcRows = new ArrayList();
alpcRows.Add(postCategoryRows[0]);
int pcPostId = Convert.ToInt32(postCategoryRows[0]["postId"]);
for (int i = 1; i < postCategoryRows.Length; i++)
{
if (pcPostId != Convert.ToInt32(postCategoryRows[0]["postId"]))
alpcRows.Add(postCategoryRows[i]);
}
if (iCount == -1 || iCount > alpcRows.Count)
iCount = alpcRows.Count;
postRows = new DataRow[iCount];
for (int i = 0; i < iCount; i++)
postRows[i] = (alpcRows[i] as DataRow).GetParentRow("post__post_category");
}
else
{
iCount = 0;
postRows = new DataRow[0];
}
}
//no category filters
else
{
postRows = dtPost.Select("isPublic = 1", "postDate DESC");
if (iCount == -1 || iCount > postRows.Length)
iCount = postRows.Length;
}
if (_ListType == ListTypeEnum.Summary)
CreateSummaryList(divJournal, postRows, iCount);
else
CreateBasicList(divJournal, postRows, iCount);
}
private void CreateSummaryList(Panel divJournal, DataRow[] postRows, int iCount)
{
//add list entries
HtmlGenericControl dl = new HtmlGenericControl("dl");
divJournal.Controls.Add(dl);
for (int i = 0; i < iCount; i++)
{
DataRow postRow = postRows[i];
HtmlGenericControl dt = new HtmlGenericControl("dt");
dt.InnerHtml = "<a href='" + _DetailURL + "?postId=" + postRow["id"].ToString() + "'>" + postRow["title"].ToString() + "</a>";
dl.Controls.Add(dt);
HtmlGenericControl dd = new HtmlGenericControl("dd");
dd.Attributes["class"] = "summary";
string bodyText = postRow["body"].ToString();
int iStart = bodyText.IndexOf("<p");
int iEnd = bodyText.IndexOf("</p>");
string bodyFragment = bodyText.Substring(iStart, iEnd - iStart + 4);
dd.Controls.Add(new LiteralControl(bodyFragment.Insert(iEnd, " <a href='" + _DetailURL + "?postId=" + postRow["id"].ToString() + "'>read more</a>")));
dl.Controls.Add(dd);
dd = new HtmlGenericControl("dd");
dd.Attributes["class"] = "footer";
DataRow[] postCategories = postRow.GetChildRows("post__post_category");
string[] cats = new string[postCategories.Length];
for (int c = 0; c < postCategories.Length; c++)
{
string catName = postCategories[c].GetParentRow("category__post_category")["name"].ToString();
cats[c] = "<a href='" + _CategoryURL + "?categoryId=" + postCategories[c]["categoryId"].ToString() + "'>" + catName + "</a>";
}
string authorName = postRow.GetParentRow("user__post")["name"].ToString();
string editIconHtml = String.Empty;
if (Convert.ToBoolean(Context.Session["isAdmin"]))
editIconHtml = "
";
dd.InnerHtml = editIconHtml + "
" +
Convert.ToDateTime(postRow["createdOn"]).ToString("MMMM d, yyyy") + " [ " + String.Join(", ", cats) + " ] | posted by " + authorName;
dl.Controls.Add(dd);
}
}
private void CreateBasicList(Panel divJournal, DataRow[] postRows, int iCount)
{
HtmlGenericControl ul = new HtmlGenericControl("ul");
ul.Attributes["class"] = "onsite-list";
divJournal.Controls.Add(ul);
for (int i = 0; i < iCount; i++)
{
DataRow postRow = postRows[i];
HtmlGenericControl li = new HtmlGenericControl("li");
li.InnerHtml = "<a href='" + _DetailURL + "?postId=" + postRow["id"].ToString() + "'>" + postRow["title"].ToString() + "</a> " + Convert.ToDateTime(postRow["createdOn"]).ToString("MMMM d, yyyy");
ul.Controls.Add(li);
}
}
Journal Entry
The journalEntry.aspx page's OnPreRender event is used to display the journal entry. The page contains a PlaceHolder control (phEntry), which is populated with the journal entry title, body and related comments.
protected override void OnPreRender(EventArgs e)
{
HtmlGenericControl h1 = new HtmlGenericControl("h1");
h1.InnerHtml = _PostRow["title"].ToString();
phEntry.Controls.Add(h1);
HtmlGenericControl p = new HtmlGenericControl("p");
p.InnerHtml = _PostRow["body"].ToString();
phEntry.Controls.Add(p);
DataRow[] commentRows = _PostRow.GetChildRows("post__comment");
if (commentRows.Length != 0)
{
comments.Controls.Add(new LiteralControl("<h4>" + commentRows.Length.ToString() + (commentRows.Length==1?" comment</h4>":" comments</h4>")));
HtmlGenericControl dl = new HtmlGenericControl("dl");
comments.Controls.Add(dl);
for (int i = 0; i < commentRows.Length; i++)
{
HtmlGenericControl dt = new HtmlGenericControl("dt");
dt.ID = "comment" + commentRows[i]["id"].ToString();
dl.Controls.Add(dt);
dt.Attributes["class"] = "alt" + Convert.ToString(i % 2);
string author =commentRows[i]["authorName"].ToString();
string webSite =commentRows[i]["authorWebsite"].ToString();
if (webSite != String.Empty)
author = "<a href='" + webSite + "'>" + author + ">";
DateTime dtime = Convert.ToDateTime(commentRows[i]["createdOn"]);
dt.Controls.Add(new LiteralControl(" + dtime.ToString("h:mm tt") + " on " + dtime.ToString("MMM dd, yyyy") + ", " + author + " wrote:"));
HtmlGenericControl dd = new HtmlGenericControl("dd");
dd.Attributes["class"] = "alt" + Convert.ToString(i % 2);
dl.Controls.Add(dd);
dd.InnerHtml = commentRows[i]["body"].ToString();
}
}
base.OnPreRender (e);
}
Journal Editor
The editor interface consists of three areas: Journal Entry, Categories and Options. The Journal Entry area contains a text input control for the title, and the editor control that captures the body of the entry. The title and body controls are initialized during the page Load event. The Categories area displays a list of checkboxes which allow the author to easily identify the appropriate categories for the current entry. The Options area includes a checkbox to indicate whether comments can be added to the entry, and a checkbox indicating that the entry is ready to publish.