Friday, August 03, 2007

A SiteMapProvider for Static Web Sites

The new navigation features of ASP.Net 2.0 are pretty cool. If you haven't seen them yet, check out ScottGu's blog for more information.

I've seen a few blog posts on SiteMapProvider implementations for dynamic web sites, but not a whole lot on providers for static web sites. Sure, you could use the default implementation and manually update the web.sitemap xml file, but what about large sites? In my opinion, its not worth the effort.

Here are my requirements for a static SiteMapProvider:

  • Must automagically update whenever pages are added/removed.
  • Must be able to only include specified file types.
  • Must be able to exclude specified directories under the application's virtual directory.

So I went searching and didn't find anything. The closest I found was a macro by K. Scott Allen that generates a web.sitemap file from a web project. A noble effort, but I needed a bit more. So I set off to implement my own provider. Using the SqlSiteMapProvider example as a reference, I had created my own StaticFileSiteMapProvider by lunch time.

The implementation is rather straighforword. It starts at the application path (~/) and recurses each of its sub directories. There is a FileExtensions property that defines the types of files to include (e.g. aspx, html) and there is also a DirExclusions property that defines the directory name patterns to exclude (e.g. bin, App_*). The DefaultDocuments property defines the default document names for a directory (e.g index, default).

Why do I need a DefaultDocuments property? I can answer that with another question. What happens when you've got a directory in your app that doesn't have an index page? Well, the provider will generate a link to that folder, but clicking on it will result in a Directory Listing Denied error (at least I hope you would have your site set up that way). In
BuildSiteMap(SiteMapNode parentNode, string directory), if the current node's directory doesn't have a default document page, then the node's url isn't set, ensuring that its not hyperlinked.

On to the code. I've included the main parts of the class below. For a full listing, use the link at the end of this post.



public override SiteMapNode BuildSiteMap()
{
lock (this)
{
if (isBuilt)
{
return root;
}

string physicalAppPath = HttpContext.Current.Server.MapPath("~/");

BuildSiteMap(null, physicalAppPath);

isBuilt = true;

return root;
}
}

/// <summary>
/// Recursive method to build the site map.
/// </summary>
/// <param name="parentNode">The parent node.</param>
/// <param name="directory">The directory.</param>
private void BuildSiteMap(SiteMapNode parentNode, string directory)
{
// create the current node
string url = GetUrlFromPhysicalPath(directory);
string title = parentNode == null ? "Home" : Path.GetFileName(directory);
SiteMapNode node = new SiteMapNode(this, url, url, title);

// set the root
if (parentNode == null)
{
root = node;
}

// add a node foreach file
string[] files = GetFiles(directory);

foreach (string file in files)
{
url = GetUrlFromPhysicalPath(file);
SiteMapNode fileNode = new SiteMapNode(this, url, url, Path.GetFileNameWithoutExtension(file));
AddNode(fileNode, node);
}

// unset the url if there isn't an index file in the directory
if (!Array.Exists(files, delegate(string match)
{
foreach (string index in DefaultDocuments.Split(','))
{
if (String.Compare(Path.GetFileNameWithoutExtension(match), index.Trim(),
StringComparison.OrdinalIgnoreCase) == 0)
{
return true;
}
}

return false;
}))
{
// Note: setting node.Url to null doesn't change the value, so I'm setting it to String.Empty,
// which is its default value
node.Url = String.Empty;
}

// recurse sub directories
string[] directories = GetDirectories(directory);

foreach (string dir in directories)
{
BuildSiteMap(node, dir);
}

// only add the current node if it has children
// Note: node.HasChildren throws an InvalidOperationException, so I'm checking the
// file and directory arrays instead
if (files.Length > 0 directories.Length > 0)
{
AddNode(node, parentNode);
}
}

web.config settings:


<siteMap defaultProvider="StaticFileSiteMapProvider">
<providers>
<add name="StaticFileSiteMapProvider" type="Providers.StaticFileSiteMapProvider"
fileExtensions="asp, htm"
defaultDocuments="index"
dirExclusions="bin, obj, Properties, App_*, DMS, old" />
</providers>
</siteMap>


Full code listing

kick it on DotNetKicks.com

Monday, May 21, 2007

QueryString Property C# Code Snippet

Working with ASP.Net I often use GET parameters in my web forms. I typically create a property for each parameter a page uses. For instance, if I have a GET parameter representing the date, I'll create the following property:




protected DateTime Date
{
get
{
DateTime date;
string s = Request["date"];

if (s == null)
{
date = DateTime.MinValue;
}

try
{
date = Convert.ToDateTime(s);
}
catch
{
date = DateTime.MinValue;
}

return date;
}
}



Since I tend to create properties like this quite often, I finally decided to look into Visual Studio 2005's Code Snippets. My goal was to create a snippet with replacements for the property name, type and GET parameter. Thanks to this documentation, it was much easier than I had expected. Here is the snippet that will generate most of the above property:




<CodeSnippets xmlns="http://schemas.microsoft.com/VisualStudio/CodeSnippet">
<CodeSnippet Format="1.0.0">
<Header>
<Title>QueryString Property</Title>
<Description>Code snippet for creating QueryString Properties.</Description>
<Author>John Rummell</Author>
<Shortcut>qsp</Shortcut>
</Header>
<Snippet>
<Declarations>
<Literal>
<ID>type</ID>
<ToolTip>Replace with the Property type.</ToolTip>
<Default>type</Default>
</Literal>
<Literal>
<ID>name</ID>
<ToolTip>Replace with the Property name.</ToolTip>
<Default>propertyName</Default>
</Literal>
<Literal>
<ID>parameter</ID>
<ToolTip>Replace with the GET parameter name.</ToolTip>
<Default>parameter</Default>
</Literal>
</Declarations>
<Code Language="CSharp">
<![CDATA[ protected $type$ $name$
{
get
{
string s = Request["$parameter$"];
$type$ $parameter$;

if (s == null)
{
//TODO: set $parameter$ to default value ...
}

try
{
//TODO: cast s to $type$ ...
}
catch
{
//TODO: set $parameter$ to default value ...
}

return $parameter$;
}
} ]]>
</Code>
</Snippet>
</CodeSnippet>
</CodeSnippets>



To install this snippet, just copy and paste the above code into a new xml file and save it with the .snippet extension in [...]\My Documents\Visual Studio 2005\Code Snippets\Visual C#\My Code Snippets\.

To use the snippet type in the shortcut, 'qsp' and hit tab. You will see there are three replacements: type, propertyName, and parameter. Type is the return type of the property's get accessor, propertyName is the name of the property's name, and parameter is the name of the GET parameter. Use the tab key to cycle through and type in the appropriate values, and then hit enter to finish.



Here's the code generated by the snippet:



protected DateTime Date
{
get
{
string s = Request["date"];
DateTime date;

if (s == null)
{
//TODO: set date to default value ...
}

try
{
//TODO: cast s to DateTime ...
}
catch
{
//TODO: set date to default value ...
}

return date;
}
}



Then all you need to do is fill in the missing parts marked with TODO comments.

Update: I've submitted this to gotcodesnippets.net. You can now download the snippet in Visual Studio Community Content Installer format from http://www.gotcodesnippets.net/1114.snippet.

kick it on DotNetKicks.com

Converting DateTime to String and back.

I spent about 20 frustrating minutes the other day wondering why a sql query wasn't selecting the record I wanted. Everything looked right until I stepped through my code the 3rd time. Then I discovered my problem.

In a web form, I allow a user to select a row in a GridView that fires an event to populate a DetailsView using an ObjectDataSource. The select method of the ObjectDataSource takes two parameters, a DateTime and a string. Since the date is coming from the GridView, I was just using Convert.ToDateTime([date cell].ToString()).

I discovered that the DateTime displayed in the GridView was '5/21/2007 8:51:42 AM' while the DateTime in the database was '2007-05-21 08:51:42.153'. They're close, but not exactly the same. It's that missing fraction of a second that made my where clause incorrect.

So then I thought, "How can I successfully convert a DateTime to a string and back?" After a short pause it hit me, "Ticks". No, not the kind of ticks I'm afraid of getting when backpacking in woods, but DateTime.Ticks. I used a HiddenField to store the string representation of the selected DateTime in ticks, and then added an overloaded select method that takes a ticks (long) parameter. In the new method I simply construct a DateTime from the ticks and call the original select method.

So in summary, to convert from DateTime to string and back, use ticks.

kick it on DotNetKicks.com

Monday, March 05, 2007

DotNetKicks integration into the "New" Blogger.com

A while back I came across a post on gaech's blog on how to integrate that cool looking DotNetKicks image into your Blogger template. I was thrilled and immediately used it on my own blog.

But then I switched over to the "New (We're out of beta!)" Blogger with the fancy AJAXy template editor. I soon discovered that the template scripting was completely different, causing my DotNetKicks "kick it" link to generate an error. Thankfully, the new template tags are well documented and I was able to come up the following:



<a expr:href='"http://www.dotnetkicks.com/kick/?url=" + data:post.url + "&title=" + data:post.title'>
<img expr:src='"http://www.dotnetkicks.com/Services/Images/KickItImageGenerator.ashx?url=" + data:post.url'
style="border:none;" border="0"
alt="kick it on DotNetKicks.com" />
</a>

kick it on DotNetKicks.com

Monday, February 19, 2007

Using ASP.Net to open a new browser window - Part II, the web control

This a follow up to my previous post, Using ASP.Net to open a new browser window - Part I. There I used a static helper class to register a javascript function to open a new browser window. I've scratched the static class and replaced it with a BrowserWindow class and a PopUpWindow WebControl. BrowserWindow simply encapsulates all of the parameters passed to RegisterOpenWindowScript(), and PopUpWindow registers the javascript function and the call to the function.

Here's an example:




<cc1:PopUpWindow ID="PopUpWindow1" runat="server"
OpenOnLoad="false" />
<asp:Button ID="Button1" runat="server"
Text="Open Window" OnClick="Button1_Click" />






protected void Page_Load(object sender, EventArgs e)
{
BrowserWindow window = new BrowserWindow(
"http://john.rummellcc.com/blog", 800, 600);
window.Resizable = true;
window.Scrollbars = true;

PopUpWindow1.BrowserWindow = window;
}

protected void Button1_Click(object sender, EventArgs e)
{
PopUpWindow1.OpenWindow();
}



PopUpWindow exposes a few of BrowserWindow's properties: Width, Height, and Url. For more options, create a BrowserWindow object and set PopUpWindow's BroswerWindow property with it, as shown in Page_Load. You can use the OpenWindow() method of PopUpWindow to open the window as shown in Button1_Click, or you can set OpenOnLoad to true to have it open when the page loads.

Download source: BrowserWindow.cs, PopUpWindow.cs

kick it on DotNetKicks.com