Discussion Forum update (Utilities and PostRepository)

This is just a followup with two classes from the discussion forum. I haven’t tested the PostRepository class well yet, but I’ll update it with fixes later. Util is the general utilities class that I’ve used in previous projects. It’s basically for rudimentary formatting, input validation etc…

6:40 AM… Time for bed!

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Security.Cryptography;
using System.Globalization;
using System.Web;

namespace Road.Helpers
{
	public class Util
	{
		/// <summary>
		/// Gets the checksum of a local file or some text.
		/// </summary>
		/// <param name="source">Path to a file or a string</param>
		/// <param name="mode">Checksum mode in sha1, sha256, sha512 or md5 (default)</param>
		/// <param name="isFile">True if file mode or false for text mode (defaults to false)</param>
		/// <returns>Completed checksum</returns>
		public static string GetChecksum(string source, string mode = "md5", bool isFile = false)
		{
			byte[] bytes = { };
			Stream fs;

			if (isFile)
				fs = new BufferedStream(File.OpenRead(source), 120000);
			else
				fs = new MemoryStream(Encoding.UTF8.GetBytes(source));

			switch (mode.ToLower())
			{
				case "sha1":
					using (SHA1CryptoServiceProvider sha1 =
						new SHA1CryptoServiceProvider())
						bytes = sha1.ComputeHash(fs);
					break;

				case "sha256":
					using (SHA256CryptoServiceProvider sha256 =
						new SHA256CryptoServiceProvider())
						bytes = sha256.ComputeHash(fs);
					break;

				case "sha512":
					using (SHA512CryptoServiceProvider sha512 =
						new SHA512CryptoServiceProvider())
						bytes = sha512.ComputeHash(fs);
					break;

				case "md5":
				default:
					using (MD5CryptoServiceProvider md5 =
						new MD5CryptoServiceProvider())
						bytes = md5.ComputeHash(fs);
					break;
			}

			// Cleanup
			fs.Close();
			fs = null;

			return BitConverter
					.ToString(bytes)
					.Replace("-", "")
					.ToLower();
		}

		/// <summary>
		/// Returns the page slug or converts a page title into a slug
		/// </summary>
		public static string GetSlug(string val, string d, int length = 45, bool lower = false)
		{
			val = Util.DefaultFlatString(val, d, length);

			// Duplicate spaces
			val = Regex.Replace(val, @"[\s-]+", " ").Trim();
			val = Util.NormalizeString(val); // Remove special chars
			val = Regex.Replace(val, @"\s", "-"); // Spaces to dashes


			// If we still couldn't get a proper string, generate one from default
			val = (String.IsNullOrEmpty(val) || val.Length < 3) ? d :
			val.Substring(0, val.Length <= length ? val.Length : length).Trim();

			
			return (lower) ? val.ToLower() : val;
		}

		private static string NormalizeString(string txt)
		{
			if (!String.IsNullOrEmpty(txt))
				txt = txt.Normalize(NormalizationForm.FormD);

			StringBuilder sb = new StringBuilder();

			sb.Append(
				txt.Normalize(NormalizationForm.FormD).Where(
					c => CharUnicodeInfo.GetUnicodeCategory(c)
					!= UnicodeCategory.NonSpacingMark).ToArray()
				);

			return sb.ToString().Normalize(NormalizationForm.FormD);
		}

		/// <summary>
		/// Gets an array of cleaned tags
		/// </summary>
		/// <param name="txt">A comma delimited string of tags</param>
		/// <returns>Array of cleaned tags</returns>
		public static string[] GetTags(string txt, bool lower = false)
		{
			string[] tags = txt.Split(',');
			ArrayList clean = new ArrayList();

			for (int i = 0; i < tags.Length; i++)
			{
				tags[i] = DefaultFlatString(tags[i], " ").Trim();

				if (!string.IsNullOrEmpty(tags[i]))
					tags[i] = NormalizeString((lower)? 
						tags[i].ToLower() : tags[i]);

				// Don't want to repeat
				if (!clean.Contains(tags[i])) 
						clean.Add(tags[i]);
			}

			return (string[])clean.ToArray(typeof(string));
		}

		/// <summary>
		/// Gets an array of cleaned keywords
		/// </summary>
		/// <param name="txt">A comma delimited string of keywords</param>
		/// <param name="limit">Limit s the number of tags returned</param>
		/// <param name="tolower">Optional parameter to convert the text to lowercase</param>
		/// <returns>Array of cleaned keywords</returns>
		public static List<string> GetKeywords(string txt, int limit, bool tolower = true)
		{
			string[] tags = txt.Split(',');
			List<string> clean = new List<string>();

			for (int i = 0; i < tags.Length; i++)
			{
				tags[i] = Util.DefaultFlatString(tags[i], "");

				if (!String.IsNullOrEmpty(tags[i]))
				{
					if (tolower)
						clean.Add(tags[i].ToLower());
					else
						clean.Add(tags[i]);
				}
			}

			return clean;
		}

		/// <summary>
		/// Shorten a give text block followed by an ellipse
		/// </summary>
		public static string TrimText(string strInput, int intNum)
		{
			strInput = strInput.Replace("\r", string.Empty)
				.Replace("\n", string.Empty);
			if ((strInput.Length > intNum) && (intNum > 0))
			{
				strInput = strInput.Substring(0, intNum) + "...";
			}
			return strInput;
		}

		/// <summary>
		/// Checks whether string has value or sets default it doesn't or is at 0
		/// </summary>
		public static int DefaultInt(string val, int d, int? min)
		{
			int tmp = 0;

			if (!Int32.TryParse(val, out tmp))
				tmp = d;

			if (min.HasValue)
				if (tmp <= min.Value) tmp = d;

			return tmp;
		}

		/// <summary>
		/// Checks whether nullable int has value or sets default it doesn't or is at 0
		/// </summary>
		public static int DefaultInt(int? val, int d, int? min)
		{
			val = val ?? d;
			if (min.HasValue)
				if (val.Value <= min.Value) val = d;

			return val.Value;
		}

		/// <summary>
		/// Checks whether nullable bool has value or sets default it doesn't
		/// </summary>
		public static bool DefaultBool(bool? val, bool d)
		{
			val = val ?? d;
			return val.Value;
		}

		/// <summary>
		/// Checks whether nullable bool has value or sets default it doesn't
		/// </summary>
		public static bool DefaultBool(string val, bool d)
		{
			bool tmp = d;

			if (Boolean.TryParse(val, out tmp))
				return tmp;

			return d;
		}

		/// <summary>
		/// Returns a flat (no line breaks) string or a default value if empty
		/// </summary>
		public static string DefaultFlatString(string val, string d, int l = 255)
		{
			return Util.DefaultString(val, d, l).Replace(Environment.NewLine, "");
		}

		/// <summary>
		/// Checks whether nullable string has value or sets default it doesn't or is at empty
		/// </summary>
		public static string DefaultString(string val, string d, int l = 255)
		{
			if (string.IsNullOrEmpty(val)) val = d;

			val.Replace("\r", Environment.NewLine)
				.Replace("\n", Environment.NewLine);

			if ((val.Length > 0) && (val.Length > l))
				val = val.Substring(0, l-1);

			return val;
		}

		/// <summary>
		/// Converts a string value to a DateTime object or returns the default value on failure
		/// </summary>
		public static DateTime DefaultDate(string val, DateTime d)
		{
			DateTime dt;
			if (DateTime.TryParse(val, out dt))
				return dt;

			return d;
		}

		/// <summary>
		/// Converts a nullable date value to a DateTime object or returns the default value on failure
		/// </summary>
		public static DateTime DefaultDate(DateTime? val, DateTime d)
		{
			DateTime dt;
			dt = (val.HasValue) ? val.Value : d;

			return d;
		}

		/// <summary>
		/// Gets the current user's IP address
		/// </summary>
		public static string GetUserIP()
		{
			// Connecting through a proxy?
			string ip = HttpContext.Current.Request.ServerVariables["HTTP_X_FORWARDED_FOR"];

			// None found
			if (string.IsNullOrEmpty(ip) || ip.ToLower() == "unknown")
				ip = HttpContext.Current.Request.ServerVariables["REMOTE_ADDR"];

			return ip;
		}

		public static string GetEmail(string v)
		{
			string email = @"^[\w!#$%&'*+\-/=?\^_`{|}~]+(\.[\w!#$%&'*+\-/=?\^_`{|}~]+)*" +
							@"@((([\-\w]+\.)+[a-zA-Z]{2,4})|(([0-9]{1,3}\.){3}[0-9]{1,3}))$";

			if (!string.IsNullOrEmpty(v))
				if (Regex.IsMatch(v, email))
					return v;

			// Didn't match the email format, so sent a cleaned string
			return Util.DefaultFlatString(v, "");
		}
	}
}

PostRepository. This one’s a bit long…

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using Road.Helpers;

namespace Road.Models
{
	public class PostRepository
	{
		// Common DataContext
		private readonly CMDataContext db;

		/// <summary>
		/// Constructor
		/// </summary>
		/// <param name="context">Global context</param>
		public PostRepository(CMDataContext context)
		{
			this.db = context;
		}

		#region Topic display methods


		/// <summary>
		/// Gets a topic by the given id and finds corresponding replies if any
		/// </summary>
		/// <param name="id">Topic id to search</param>
		/// <param name="index">Current page index</param>
		/// <param name="limit">Page size limit</param>
		/// <param name="unapproved">Include unapproved replies (optional)</param>
		/// <param name="status">Array of reply status types (optional)</param>
		/// <param name="newestFirst">Sort by newest replies first (optional)</param>
		public Topic TopicById(int id, int index, int limit,
			bool unapproved = false, ReplyStatus[] status = null, bool newestFirst = false)
		{
			var query = from p in db.Posts
						where p.PostId == id
						select p;

			Topic topic = TopicQuery(query).FirstOrDefault();

			// We have a topic and replies were also requested
			if (topic != null && limit > 0)
			{
				var rquery = from p in db.Posts
							 join pa in db.PostRelations on p.PostId equals pa.ParentId
							 where pa.ParentId == topic.Id
							 select p;

				topic.Replies =
					ReplyQuery(rquery, unapproved, status, newestFirst).ToPagedList(index, limit);
			}

			return topic;
		}

		/// <summary>
		/// Gets a list of topics (most basic request, usually for frontpage)
		/// </summary>
		/// <param name="limit">Page size limit</param>
		/// <param name="unapproved">Include unapproved topics (optional)</param>
		/// <param name="status">Array of status topic status types (optional)</param>
		/// <param name="newestFirst">Sort by newest topics first (optional)</param>
		/// <returns>List of topics</returns>
		public List<Topic> TopicList(int limit, bool unapproved = false,
			TopicStatus[] status = null, bool newestFirst = true)
		{
			var query = from p in db.Posts
						select p;

			return TopicQuery(query, unapproved, status, newestFirst).Take(limit).ToList();
		}

		/// <summary>
		/// Gets a paged list of topics
		/// </summary>
		/// <param name="index">Current page index</param>
		/// <param name="limit">Page size limit</param>
		/// <param name="unapproved">Include unapproved topics (optional)</param>
		/// <param name="status">Array of status topic status types (optional)</param>
		/// <param name="newestFirst">Sort by newest topics first (optional)</param>
		/// <returns>Paged list of topics</returns>
		public PagedList<Topic> TopicPageList(int index, int limit,
			bool unapproved = false, TopicStatus[] status = null, 
			bool newestFirst = true)
		{
			var query = from p in db.Posts
						select p;

			return TopicQuery(query, unapproved, status, newestFirst).ToPagedList(index, limit);
		}

		/// <summary>
		/// Gets a paged list of topics belonging to a tag(s)
		/// (This uses the lowercase TagName property)
		/// </summary>
		/// <param name="tag">Array of tags to search</param>
		/// <param name="index">Current page index</param>
		/// <param name="limit">Page size limit</param>
		/// <param name="unapproved">Include unapproved topics (optional)</param>
		/// <param name="status">Array of status topic status types (optional)</param>
		/// <param name="newestFirst">Sort by newest topics first (optional)</param>
		/// <returns>Paged list of topics</returns>
		public PagedList<Topic> TopicsByTag(string[] tag, int index, int limit,
			bool unapproved = false, TopicStatus[] status = null, bool newestFirst = true)
		{
			var query = from t in db.PostTags
						join pt in db.PostTagRelations on t.TagId equals pt.TagId
						join p in db.Posts on pt.PostId equals p.PostId
						where tag.Contains(t.TagName)
						select p;

			return TopicQuery(query, unapproved, status, newestFirst).ToPagedList(index, limit);
		}

		/// <summary>
		/// Gets an individual topic belonging to a list of tag(s)
		/// </summary>
		/// <param name="tagId">Array of tag Ids to search</param>
		/// <param name="index">Current page index</param>
		/// <param name="limit">Page size limit</param>
		/// <param name="unapproved">Include unapproved topics (optional)</param>
		/// <param name="status">Array of status topic status types (optional)</param>
		/// <param name="newestFirst">Sort by newest topics first (optional)</param>
		/// <returns>Paged list of topics</returns>
		public PagedList<Topic> TopicsByTagId(int[] tagId, int index, int limit,
			bool unapproved = false, TopicStatus[] status = null, bool newestFirst = true)
		{
			var query = from t in db.PostTags
						join pt in db.PostTagRelations on t.TagId equals pt.TagId
						join p in db.Posts on pt.PostId equals p.PostId
						where tagId.Contains(t.TagId)
						select p;

			return TopicQuery(query, unapproved, status, newestFirst).ToPagedList(index, limit);
		}

		/// <summary>
		/// Gets a paged list of topics by the search criteria
		/// </summary>
		/// <param name="search">Title and body search terms</param>
		/// <param name="index">Current page index</param>
		/// <param name="limit">Page size limit</param>
		/// <param name="unapproved">Include unapproved topics (optional)</param>
		/// <param name="status">Array of status topic status types (optional)</param>
		/// <param name="newestFirst">Sort by newest topics first (optional)</param>
		/// <returns>Paged list of topics</returns>
		public PagedList<Topic> TopicsBySearch(string search, int index, int limit,
			bool unapproved = false, TopicStatus[] status = null, bool newestFirst = true)
		{
			var query = from p in db.Posts
						where p.BodyText.Contains(search) || p.Title.Contains(search)
						select p;

			return TopicQuery(query, unapproved, status, newestFirst).ToPagedList(index, limit);
		}

		/// <summary>
		/// Gets a paged list of replies by the search criteria 
		/// (only searches the bodytext)
		/// </summary>
		/// <param name="search">Search terms</param>
		/// <param name="index">Current page index</param>
		/// <param name="limit">Page size limit</param>
		/// <param name="unapproved">Include unapproved topics (optional)</param>
		/// <param name="status">Array of topic status types (optional)</param>
		/// <param name="newestFirst">Sort by newest topics first (optional)</param>
		/// <returns>Paged list of topics</returns>
		public PagedList<Reply> RepliesBySearch(string search, int index, int limit,
			bool unapproved = false, ReplyStatus[] status = null, bool newestFirst = true)
		{
			var query = from p in db.Posts
						where p.BodyText.Contains(search)
						select p;

			return ReplyQuery(query, unapproved, status, newestFirst).ToPagedList(index, limit);
		}

		#endregion

		#region Save methods

		/// <summary>
		/// Saves or creates a new reply under the given topic
		/// </summary>
		/// <param name="topic">Topic the reply belongs to</param>
		/// <param name="reply">Reply to save</param>
		public Reply SaveReply(Topic topic, Reply reply)
		{
			Post p = null;
			DateTime dt = DateTime.UtcNow;

			if (reply.Id != 0)
			{
				p = (from post in db.Posts
					where post.PostId == reply.Id
					select post).FirstOrDefault();
			}
			else
			{
				p = new Post();
				p.CreatedDate = dt;
				p.ReplyCount = 0;
				p.ViewCount = 0;
				db.Posts.InsertOnSubmit(p);
			}

			p.Approved = reply.Approved;
			p.Status = (byte)reply.Status;
			p.Threshold = reply.Threshold;
			p.LastModified = dt;
			p.BodyHtml = reply.Body;
			p.BodyText = reply.Summary;

			// Save reply
			db.SubmitChanges();

			// If this is a new reply...
			if (p.PostId > 0 && reply.Id == 0)
			{
				// We now have an Id to set
				reply.Id = p.PostId;

				// Create Author, PostRelation and PostAuthor relationships
				Author a = new Author();
				a.MemberId = reply.CreatedBy.Id;
				a.AuthorIP = reply.CreatedBy.IP;
				a.AuthorName = reply.CreatedBy.Name;
				a.AuthorEmail = reply.CreatedBy.Email;
				a.AuthorWeb = reply.CreatedBy.Web;
				db.Authors.InsertOnSubmit(a);


				PostRelation pr = new PostRelation();
				pr.ParentId = topic.Id;
				pr.PostId = p.PostId;
				db.PostRelations.InsertOnSubmit(pr);
				db.SubmitChanges();

				if (a.AuthorId > 0)
				{
					PostAuthor pa = new PostAuthor();
					pa.AuthorId = reply.CreatedBy.Id;
					pa.PostId = reply.Id;
					db.PostAuthors.InsertOnSubmit(pa);
					db.SubmitChanges();
				}
			}

			return reply;
		}

		/// <summary>
		/// Saves or creates a new topic
		/// </summary>
		/// <param name="topic">Topic to save</param>
		/// <returns>Returns the saved topic</returns>
		public Topic SaveTopic(Topic topic)
		{
			Post p = null;
			DateTime dt = DateTime.UtcNow;

			if (topic.Id != 0)
			{
				p = (from post in db.Posts
					 where post.PostId == topic.Id
					 select post).FirstOrDefault();
			}
			else
			{
				p = new Post();
				p.CreatedDate = dt;
				db.Posts.InsertOnSubmit(p);
			}

			p.Title = topic.Name;
			p.Approved = topic.Approved;
			p.Status = (byte)topic.Status;
			p.Threshold = topic.Threshold;

			p.LastModified = dt;
			p.BodyHtml = topic.Body;
			p.BodyText = topic.Summary;

			p.ViewCount = topic.ViewCount;
			p.ReplyCount = topic.ReplyCount;

			// Save
			db.SubmitChanges();

			// If this is a new topic...
			if (p.PostId > 0 && topic.Id == 0)
			{
				// Set the Id, now that we have one
				topic.Id = p.PostId;

				// Create author and set relationship
				Author a = new Author();
				a.MemberId = topic.CreatedBy.MemberId;
				a.AuthorIP = topic.CreatedBy.IP;
				a.AuthorName = topic.CreatedBy.Name;
				a.AuthorEmail = topic.CreatedBy.Email;
				a.AuthorWeb = topic.CreatedBy.Web;
				db.Authors.InsertOnSubmit(a);

				PostRelation pr = new PostRelation();
				pr.ParentId = p.PostId; // Same since it's a topic
				pr.PostId = p.PostId;
				db.PostRelations.InsertOnSubmit(pr);

				db.SubmitChanges();

				if (a.AuthorId > 0)
				{
					PostAuthor pa = new PostAuthor();
					pa.AuthorId = a.AuthorId;
					pa.PostId = p.PostId;
					db.PostAuthors.InsertOnSubmit(pa);

					db.SubmitChanges();
				}
			}


			topic.Slug = Util.GetSlug(p.Title, "topic");


			ApplyTags(topic.Tags.ToList(), topic);

			return topic;
		}

		#endregion

		#region Tag methods

		/// <summary>
		/// Gets a list of tags by a search string 
		/// (usually for tag autocomplete)
		/// </summary>
		/// <param name="tag">Tag search string</param>
		/// <param name="limit">Page size limit</param>
		/// <returns></returns>
		public List<Tag> TagsByName(string tag, int limit)
		{
			var query = from t in db.PostTags
						orderby t.TagName ascending
						where t.TagName.StartsWith(tag)
						select new Tag
						{
							Id = t.TagId,
							Name = t.TagName,
							Slug = t.Slug,
							DisplayName = t.TagName
						};

			if (limit > 0)
				query = query.Take(limit);

			return query.ToList();
		}

		/// <summary>
		/// Associates a list of tags with the given topic
		/// </summary>
		/// <param name="tags">Tags to link to topic</param>
		/// <param name="topic">Target topic</param>
		private void ApplyTags(List<Tag> tags, Topic topic)
		{
			List<PostTagRelation> existing = (from pt in db.PostTagRelations
											  join t in db.PostTags on pt.TagId equals t.TagId
											  where pt.PostId == topic.Id
											  select pt).ToList();

			// Clean existing relationships
			db.PostTagRelations.DeleteAllOnSubmit(existing);
			db.SubmitChanges();

			// Setup the new relationships
			List<PostTagRelation> newrelation = new List<PostTagRelation>();

			// Store the new tags and get the complete list of tags
			tags = StoreTags(tags, topic.CreatedBy);

			foreach (Tag t in tags)
			{
				PostTagRelation tag = new PostTagRelation();
				tag.TagId = t.Id;
				tag.PostId = topic.Id;
				newrelation.Add(tag);
			}
			// Save the new tag relationships
			db.PostTagRelations.InsertAllOnSubmit(newrelation);
			db.SubmitChanges();
		}


		/// <summary>
		/// Finds existing tags and creates new tags with the associated creator if
		/// the tag doesn't exist
		/// </summary>
		/// <param name="tags">List of tags to create/find</param>
		/// <param name="creator">Tag creator</param>
		/// <returns>List of found and newly created tags</returns>
		private List<Tag> StoreTags(List<Tag> tags, Creator creator)
		{
			// Complete list of all tags
			List<Tag> complete = new List<Tag>();

			// Created date
			DateTime dt = DateTime.UtcNow;

			string[] search = tags.Select(tg => tg.Name).ToArray();
			string[] existing = (from t in db.PostTags
								where search.Contains(t.TagName)
								select t.TagName).ToArray();

			// Tags except those already in the database
			string[] newtags = search.Except(existing).ToArray();

			// We have new tags to save
			if (newtags.Length > 0)
			{
				List<PostTag> savetags = (from tg in tags
								   where newtags.Contains(tg.Name)
								   select new PostTag
								   {
									   DisplayName = tg.DisplayName,
									   TagName = tg.DisplayName.ToLower(),
									   Slug = Util.GetSlug(tg.DisplayName, tg.Name),
									   Status = (byte)TagStatus.Open,
									   LastModified = dt,
									   CreatedDate = dt,
									   BodyHtml = "",
									   BodyText = ""
								   }).ToList();

				if (savetags.Count() > 0)
				{
					db.PostTags.InsertAllOnSubmit(savetags);
					db.SubmitChanges();

					// Create author info for each new tag
					Author author = getAuthor(creator);
					List<TagAuthor> authors = (from tg in savetags
											   select new TagAuthor
											   {
												   AuthorId = author.AuthorId,
												   TagId = tg.TagId
											   }).ToList();
					db.TagAuthors.InsertAllOnSubmit(authors);
					db.SubmitChanges();
				}

				// Get all existing and newly inserted tags
				complete = (from tg in db.PostTags
							where search.Contains(tg.TagName)
							select new Tag
							{
								Id = tg.TagId,
								Name = tg.TagName,
								DisplayName = tg.DisplayName,
								Slug = tg.Slug
							}).ToList();
			}

			return complete;
		}

		#endregion


		#region Queries

		/// <summary>
		/// Creates a deferred execution IQueryable to search topics
		/// </summary>
		/// <param name="posts">Initial search query</param>
		/// <returns>Topic IQueryable</returns>
		private IQueryable<Topic> TopicQuery(IQueryable<Post> posts, 
			bool unapproved = false, TopicStatus[] status = null,
			bool newestFirst = true)
		{
			var query = from p in posts
						join au in db.PostAuthors on p.PostId equals au.PostId
						join a in db.Authors on au.AuthorId equals a.AuthorId
						join m in db.Members on au.AuthorId equals m.MemberId into author
						from auth in author.DefaultIfEmpty() // Empty if anonymous post

						let postauthor = getCreator(a, auth)
						let tags = GetTagsForTopic(p.PostId)

						select new { p, postauthor, tags };

			// Include unapproved topics?
			query = (unapproved) ?
				query.Where(r => r.p.Approved == false) :
				query.Where(r => r.p.Approved == true);

			// Any status other than "Open"?
			query = (status != null) ?
				query = query.Where(r => status.Contains((TopicStatus)r.p.Status)) :
				query = query.Where(r => r.p.Status == (byte)TopicStatus.Open);

			// Sort by new topics first?
			query = (newestFirst) ?
				query.OrderByDescending(r => r.p.CreatedDate) :
				query.OrderBy(r => r.p.CreatedDate);

			return from r in query
				   select new Topic
				   {
					   Id = r.p.PostId,
					   Name = r.p.Title,
					   CreatedBy = r.postauthor,
					   CreatedDate = r.p.CreatedDate,
					   LastModified = r.p.LastModified,
					   Summary = r.p.BodyText,
					   Slug = Util.GetSlug(r.p.Title, "topic", 50, true),
					   Tags = new LazyList<Tag>(r.tags),
					   ViewCount = r.p.ViewCount,
					   ReplyCount = r.p.ReplyCount,
					   Threshold = (float)r.p.Threshold,
					   Status = (TopicStatus)r.p.Status
				   };
		}

		/// <summary>
		/// Creates a deferred execution IQueryable to search replies
		/// </summary>
		/// <param name="posts">Initial posts query</param>
		/// <param name="status">Status restriction array</param>
		/// <param name="newestFirst">Sort by new replies first</param>
		/// <returns>Reply IQueryable</returns>
		private IQueryable<Reply> ReplyQuery(IQueryable<Post> posts,  bool unapproved, ReplyStatus[] status, bool newestFirst)
		{
			var query = from p in posts
						join au in db.PostAuthors on p.PostId equals au.PostId
						join a in db.Authors on au.AuthorId equals a.AuthorId
						join m in db.Members on au.AuthorId equals m.MemberId into author
						from auth in author.DefaultIfEmpty() // Empty if anonymous post

						let postauthor = getCreator(a, auth)
						select new { p, postauthor };

			// Include unapproved replies?
			query = (unapproved) ?
				query.Where(r => r.p.Approved == false) :
				query.Where(r => r.p.Approved == true);

			// Any status other than "Open"?
			query = (status != null) ?
				query = query.Where(r => status.Contains((ReplyStatus)r.p.Status)) :
				query = query.Where(r => r.p.Status == (byte)ReplyStatus.Open);

			// Sort by new replies first?
			query = (newestFirst) ?
				query.OrderByDescending(r => r.p.CreatedDate) :
				query.OrderBy(r => r.p.CreatedDate);

			return from r in query.AsQueryable()
				   select new Reply
				   {
					   Id = r.p.PostId,
					   CreatedBy = r.postauthor,
					   CreatedDate = r.p.CreatedDate,
					   LastModified = r.p.LastModified,
					   Body = r.p.BodyHtml,
					   Threshold = (float)r.p.Threshold
				   };
		}

		/// <summary>
		/// Helper finds the tags for a topic by id
		/// </summary>
		/// <param name="id">Topic id to search</param>
		/// <returns>IQueryable Tag</returns>
		private IQueryable<Tag> GetTagsForTopic(int id)
		{
			return from t in db.PostTags
				   join pt in db.PostTagRelations on t.TagId equals pt.TagId
				   where pt.PostId == id
				   select new Tag
				   {
					   Id = t.TagId,
					   Name = t.TagName,
					   Slug = t.Slug,
					   DisplayName = t.DisplayName,
					   CreatedDate = t.CreatedDate,
					   LastModified = t.LastModified
				   };
		}

		#endregion

		#region Author/Creator Helpers

		/// <summary>
		/// Helper function generates a save friendly Author from a given Creator
		/// </summary>
		/// <param name="c">Creator data</param>
		/// <returns>Author object</returns>
		private static Author getAuthor(Creator c)
		{
			if (c == null)
				return null;

			Author author = new Author();
			if (c.Id > 0)
			{
				author.AuthorId = c.Id;
			}
			else
			{
				author.AuthorEmail = c.Email;
				author.AuthorWeb = c.Web;
			}

			author.AuthorIP = c.IP;
			author.AuthorName = c.Name;

			return author;
		}

		/// <summary>
		/// Finds or creates a Creator object from given author information
		/// </summary>
		/// <param name="a">Saved author information</param>
		/// <param name="m">Optional membership information</param>
		/// <returns>Composite Creator object</returns>
		private static Creator getCreator(Author a, Member m)
		{
			Creator au = new Creator();

			au.IP = a.AuthorIP;
			if (m != null)
			{
				au = getCreator(m);
			}
			else
			{
				au.LastModified = DateTime.MinValue;
				au.Id = a.AuthorId;
				au.Name = a.AuthorName;
				au.DisplayName = a.AuthorName;
				au.Email = a.AuthorEmail;
				au.Web = a.AuthorWeb;
			}
			return au;
		}

		/// <summary>
		/// Helper function generates a Creator object from membership info
		/// </summary>
		/// <param name="m">Member object</param>
		/// <returns>Composite Creator object</returns>
		private static Creator getCreator(Member m)
		{
			return new Creator
			{
				Id = m.MemberId,
				Name = m.Username,
				DisplayName = m.DisplayName,
				Email = m.Email,
				Web = m.Web,
				Slug = Util.GetSlug(m.Username, m.MemberId.ToString(), 70),
				CreatedDate = m.CreatedDate,
				LastModified = m.LastActivity,
				Avatar = m.Avatar
			};
		}

		#endregion
	}
}

Simple CMS with Linq to SQL part II-a

This isn’t a new post per-se, but a few refinements to our existing project, including added comments in the new classes, before proceeding further. I pretty much rushed through to getting content adding, editing deleting functionality so let’s see how much we can smooth the edges before moving on…

First, I added a simple code file called Summaries.cs to the Models folder with the following…

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using SimpleCMS.Models;

namespace SimpleCMS.Models
{
	public class PageSummary : ContentPage { }

	public class CommentSummary : ContentComment { }
}

All this does is allow us to create an anonymous type wrapper for use in page and content summaries without getting all the associated fields in the object. It’s a performance thing.

Next, the modified PageView class in the Models folder.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using SimpleCMS.Helpers;

namespace SimpleCMS.Models
{
    public class PageView
    {
		/// <summary>
		/// Current viewing page
		/// </summary>
		public ContentPage Page { get; set; }

		/// <summary>
		/// Paged index list of pages
		/// </summary>
		public PagedList<ContentPage> Pages { get; set; }

		/// <summary>
		/// Paged index list of pages
		/// </summary>
		public PagedList<PageSummary> PageSummary { get; set; }


		/// <summary>
		/// Paged list of comments in the current page
		/// </summary>
		public PagedList<ContentComment> Comments { get; set; }

		/// <summary>
		/// Paged list of comments in the current page
		/// </summary>
		public PagedList<CommentSummary> CommentSummary { get; set; }

		/// <summary>
		/// This is for breadcrumb navigation use
		/// </summary>
		public Dictionary<int, string> Parents { get; set; }

		/// <summary>
		/// Constructor
		/// </summary>
		public PageView() { }

		/// <summary>
		/// Plain view of a content page
		/// </summary>
		/// <param name="_page">Current content page</param>
		public PageView(ContentPage _page)
		{
			Page = _page;
		}

		/// <summary>
		/// Plain view with comments
		/// </summary>
		/// <param name="_page">Current content page</param>
		/// <param name="_comments">List of commments in the current page</param>
		public PageView(ContentPage _page, PagedList<ContentComment> _comments)
		{
			Page = _page;
			Comments = _comments;
		}

		/// <summary>
		/// If no comments or sub pages are present, then this is an index view
		/// </summary>
		/// <param name="_pages">List of pages in current index</param>
		public PageView(PagedList<ContentPage> _pages)
		{
			Pages = _pages;
		}

		/// <summary>
		/// Full content page view with sub page summaries
		/// </summary>
		/// <param name="_page">Current content page</param>
		/// <param name="_pages">Paged list of sub pages</param>
		/// <param name="_comments">Paged list of comments</param>
		public PageView(ContentPage _page, PagedList<PageSummary> _pages, 
			PagedList<ContentComment> _comments)
		{
			Page = _page;
			PageSummary = _pages;
			Comments = _comments;
		}

		/// <summary>
		/// Full content page view with expanded pages
		/// </summary>
		/// <param name="_page">Current content page</param>
		/// <param name="_pages">Paged list of sub pages</param>
		/// <param name="_comments">Paged list of comments</param>
		public PageView(ContentPage _page, PagedList<ContentPage> _pages, 
			PagedList<ContentComment> _comments)
		{
			Page = _page;
			Pages = _pages;
			Comments = _comments;
		}

		/// <summary>
		/// Checks if this view has a page to display. If not, then 
		/// it's probably a main index.
		/// </summary>
		public bool HasPage {
			get { return (Page != null); }
		}
    }
}

Our new PagesController

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using SimpleCMS.Models;
using SimpleCMS.Helpers;
using MarkdownSharp;

namespace SimpleCMS.Controllers
{
	public class PagesController : Controller
	{
		/// <summary>
		/// Page index view. Lists the pages under the given parent Id
		/// </summary>
		/// <param name="id">Page Id</param>
		/// <param name="page">Current page index</param>
		/// <returns>Page view</returns>
		public ActionResult Index(int? id, int? page)
		{
			id = id ?? 0;
			page = page ?? 1;

			ContentPage content = new ContentPage();
			PagedList<ContentPage> pages;

			using (SimpleCMSDataContext db = new SimpleCMSDataContext())
			{
				// Get the published pages
				pages = (from p in db.ContentPages
						 where p.ParentId == id.Value
						 orderby p.PubDate descending
						 select p).ToPagedList(page.Value, 10);

				if (id > 0)
				{
					content = (from p in db.ContentPages
							   where p.PageId == id.Value
							   select p).Single();
				}


			}

			PageView index = new PageView(pages);

			if (content.PageId > 0)
				index.Page = content;

			ViewData.Model = index;
			return View();
		}

		/// <summary>
		/// Basically the Details view
		/// </summary>
		/// <param name="id">Page Id</param>
		/// <param name="page">Current page index (for comments)</param>
		/// <returns>Read view or the index if no Id was found</returns>
		public ActionResult Read(int? id, int? page)
		{
			// Page Id
			id = id ?? 0;
			page = page ?? 1;

			if (id.Value > 0)
			{
				ContentPage content;
				PagedList<ContentComment> comments;

				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					content = (from p in db.ContentPages
							   where p.PageId == id.Value
							   select p).Single();

					// Increment view count
					content.ViewCount = Util.DefaultInt(content.ViewCount, 0) + 1;

					comments = (from c in db.ContentComments
								where c.PageId == id.Value
								orderby c.CreatedDate ascending
								select c).ToPagedList(page.Value, 20);

					db.SubmitChanges();
				}

				ViewData.Model = new PageView(content, comments);
				return View();
			}
			return RedirectToAction("Index");
		}

		/// <summary>
		/// Creates a new page under the given parent Id
		/// </summary>
		/// <param name="id">Parent Id</param>
		/// <param name="collection">Form data input</param>
		/// <returns></returns>
		[HttpPost]
		public ActionResult Create(int? id, FormCollection collection)
		{
			// Parent Id
			id = id ?? 0;

			int newpageid = 0;

			ContentPage content;
			using (SimpleCMSDataContext db = new SimpleCMSDataContext())
			{
				// Gets the page data from form
				content = GetPageFromCollection(id.Value, collection, true);

				// Insert the new page
				db.ContentPages.InsertOnSubmit(content);

				if (id.Value > 0)
				{
					// Update parent page count if this page has a parent
					ContentPage parent = (from p in db.ContentPages
										  where p.PageId == id.Value
										  select p).Single();

					parent.PageCount = (from p in db.ContentPages
										where p.ParentId == id
										select p.PageId).Count() + 1;
				}

				// Save the page
				db.SubmitChanges();

				// Store the new Id for redirect
				newpageid = content.PageId;
			}

			// We have an Id
			if (newpageid > 0)
				return RedirectToAction("Read", new { id = newpageid });
			else
				return RedirectToAction("Index");
		}

		/// <summary>
		/// Edits a given page
		/// </summary>
		/// <param name="id">Page Id</param>
		/// <returns>Page edit view or the index if no Id is found</returns>
		public ActionResult Edit(int? id)
		{
			// Page Id
			id = id ?? 0;

			if (id.Value > 0)
			{
				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					ContentPage content = (from p in db.ContentPages
										   where p.PageId == id.Value
										   select p).Single();

					ViewData.Model = content;
					return View();
				}
			}
			return RedirectToAction("Index");
		}

		/// <summary>
		/// Edits a given page
		/// </summary>
		/// <param name="id">Page Id</param>
		/// <param name="collection">Form data input</param>
		/// <returns>Page read view</returns>
		[HttpPost]
		public ActionResult Edit(int? id, FormCollection collection)
		{
			// Page Id
			id = id ?? 0;

			if (id.Value > 0)
			{
				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					ContentPage content = (from p in db.ContentPages
										   where p.PageId == id.Value
										   select p).Single();

					ContentPage edited = GetPageFromCollection(id.Value, collection, false);

					// Basics
					content.Title = edited.Title;
					content.Description = edited.Description;
					content.AbstractText = edited.AbstractText;
					content.AbstractHtml = edited.AbstractHtml;
					content.BodyHtml = edited.BodyHtml;
					content.BodyText = edited.BodyText;

					//Edited date
					content.LastModified = edited.LastModified;

					// Moderation
					content.Approved = edited.Approved;
					content.AnonComments = edited.AnonComments;
					content.Moderated = edited.Moderated;

					db.SubmitChanges();
				}

				return RedirectToAction("Read", new { id = id });
			}
			return RedirectToAction("Index");
		}

		/// <summary>
		/// Deletes a given page
		/// </summary>
		/// <param name="id">Page Id</param>
		/// <returns>Delete page view or the index if no id is given</returns>
		public ActionResult Delete(int? id)
		{
			// Page Id
			id = id ?? 0;

			if (id.Value > 0)
			{
				ContentPage page;
				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					page = (from p in db.ContentPages
							where p.PageId == id.Value
							select p).Single();
				}
				ViewData.Model = page;
				return View();
			}
			return RedirectToAction("Index");
		}

		/// <summary>
		/// Deletes a given page
		/// </summary>
		/// <param name="id">Page Id</param>
		/// <param name="collection">Form data input</param>
		/// <returns>Redirects to the page index</returns>
		[HttpPost]
		public ActionResult Delete(int? id, FormCollection collection)
		{
			id = id ?? 0;
			if (id.Value > 0)
			{
				try
				{
					using (SimpleCMSDataContext db = new SimpleCMSDataContext())
					{
						ContentPage page = (from p in db.ContentPages
											where p.PageId == id
											select p).Single();

						db.ContentPages.DeleteOnSubmit(page);
						db.SubmitChanges();
					}
				}
				catch { }
			}
			return RedirectToAction("Index");
		}


		/// <summary>
		/// Creates a page comment under the given page Id
		/// </summary>
		/// <param name="id">Page Id</param>
		/// <param name="collection">Form input data</param>
		/// <returns>Read page view</returns>
		[HttpPost]
		public ActionResult CreateComment(int? id, FormCollection collection)
		{
			// Page Id
			id = id ?? 0;

			if (id.Value > 0)
			{
				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					// Get our page
					ContentPage content = (from p in db.ContentPages
										   where p.PageId == id
										   select p).Single();

					// If comments are enabled
					if (Util.DefaultBool(content.EnableComments, true))
					{
						// Create comment from the form data
						ContentComment comment =
							GetCommentFromCollection(id.Value, collection, true);

						// If the moderation is enabled, we need to 
						// disapprove this post first
						if(Util.DefaultBool(content.Moderated, false))
							comment.Approved = false;

						// Insert the new comment
						db.ContentComments.InsertOnSubmit(comment);

						// Update the comment count
						content.CommentCount = Util.DefaultInt(content.CommentCount, 0) + 1;

						db.SubmitChanges();
					}
				}
			}

			// There was no page or something went wrong
			if (id == 0)
				return RedirectToAction("Index");

			return RedirectToAction("Read", new { id = id.Value });
		}

		/// <summary>
		/// Edits a given comment
		/// </summary>
		/// <param name="id">Comment Id</param>
		/// <returns>Edit view</returns>
		public ActionResult EditComment(int? id)
		{
			// Comment Id
			id = id ?? 0;

			// Stores the page Id
			int pageid = 0;

			if (id.Value > 0)
			{
				ContentComment comment;
				ContentPage contentpage;
				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					// Get the comment to be edited
					comment = (from c in db.ContentComments
							   where c.CommentId == id.Value
							   select c).Single();

					// Get the contnt page for this comment 
					// (we need some elements of this to show the comment)
					contentpage = (from p in db.ContentPages
								   where p.PageId == comment.PageId
								   select p).Single();

					// Attach the page to the comment
					comment.ContentPage = contentpage;

					pageid = comment.PageId;

					ViewData.Model = comment;
					return View();
				}
			}

			// There was no page or something went wrong
			if (pageid == 0)
				return RedirectToAction("Index");

			return RedirectToAction("Read", new { id = pageid });
		}


		/// <summary>
		/// Edits a given comment
		/// </summary>
		/// <param name="id">Comment Id</param>
		/// <param name="collection">Form data input</param>
		/// <returns>Page read view</returns>
		[HttpPost]
		public ActionResult EditComment(int? id, FormCollection collection)
		{
			// Comment Id
			id = id ?? 0;

			// Stores the page Id
			int pageid = 0;

			if (id.Value > 0)
			{
				ContentComment comment;
				ContentPage contentpage;
				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					comment = (from c in db.ContentComments
							   where c.CommentId == id.Value
							   select c).Single();

					contentpage = (from p in db.ContentPages
								   where p.PageId == comment.PageId
								   select p).Single();

					ContentComment edited = GetCommentFromCollection(id.Value, collection, false);

					comment.Approved = edited.Approved;
					comment.Author = edited.Author;
					comment.AuthorEmail = edited.AuthorEmail;

					comment.BodyHtml = edited.BodyHtml;
					comment.BodyText = edited.BodyText;

					// Important to save changes here as the next step alters the model.
					db.SubmitChanges();

					comment.ContentPage = contentpage;

					pageid = comment.PageId;

					ViewData.Model = comment;
				}
			}

			// There was no page or something went wrong
			if (pageid == 0)
				return RedirectToAction("Index");

			return RedirectToAction("Read", new { id = pageid });
		}

		/// <summary>
		/// Deletes a given comment
		/// </summary>
		/// <param name="id">Comment Id</param>
		/// <returns>Delete comment view</returns>
		public ActionResult DeleteComment(int? id)
		{
			int pageid = 0;
			if (id.HasValue)
			{
				ContentComment comment;
				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					comment = (from c in db.ContentComments
							   where c.CommentId == id.Value
							   select c).Single();

					pageid = comment.PageId;
				}
				ViewData.Model = comment;
				return View();
			}
			return RedirectToAction("Read", new { id = pageid });
		}

		/// <summary>
		/// Deletes a given comment
		/// </summary>
		/// <param name="id">Comment Id</param>
		/// <param name="collection">Form input data</param>
		/// <returns>Back to read page index</returns>
		[HttpPost]
		public ActionResult DeleteComment(int id, FormCollection collection)
		{
			try
			{
				int pageid = 0;
				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					ContentComment comment = (from c in db.ContentComments
											  where c.CommentId == id
											  select c).Single();

					pageid = comment.PageId;
					db.ContentComments.DeleteOnSubmit(comment);
					db.SubmitChanges();
				}
				return RedirectToAction("Read", new { id = pageid });
			}
			catch { }
			return RedirectToAction("Index");
		}

		/// <summary>
		/// Gets a ContentPage object from the given form data
		/// </summary>
		/// <param name="id">Parent Id</param>
		/// <param name="collection">Form data input</param>
		/// <param name="newpage">True if creating a new page, false if editing</param>
		/// <returns>New ContentPage</returns>
		private ContentPage GetPageFromCollection(int id, FormCollection collection,
			bool newpage)
		{
			// Setup markdown
			Markdown m = new Markdown();

			// New page
			ContentPage content = new ContentPage();

			// Trigger date
			DateTime dt = DateTime.Now;

			// Basics
			content.ParentId = id;
			content.Title = Util.DefaultString(collection["title"], "No title");
			content.Description = Util.DefaultString(collection["description"], "");

			if (newpage)
			{
				content.Author = Util.DefaultString(collection["author"], "Anonymous"); // Just for now
				content.AuthorId = 0; // Just for now
			}

			// Page content
			content.AbstractText = Util.DefaultString(collection["abstracttext"], "");
			content.AbstractHtml = m.Transform(content.AbstractText);

			content.BodyText = Util.DefaultString(collection["bodytext"], "");
			content.BodyHtml = m.Transform(content.BodyText);


			// Eenable publishing
			content.Approved = Util.DefaultBool(collection["approved"], true);

			if (newpage)
			{
				// Nested
				content.ViewCount = 0;
				content.CommentCount = 0;
				content.PageCount = 0;

				// Times
				content.CreatedDate = dt;
			}

			// Pubdate
			content.PubDate = Util.DefaultDate(collection["pubdate"], dt);

			// Every time this function is called, we're doing something to the page.
			content.LastModified = dt;

			// Feedback
			content.EnableComments = Util.DefaultBool(collection["enablecomments"], true);
			content.AnonComments = Util.DefaultBool(collection["anoncomments"], true);
			content.Moderated = Util.DefaultBool(collection["moderated"], false);

			return content;
		}

		/// <summary>
		/// Gets a ContentComment object from the given form data
		/// </summary>
		/// <param name="id">Page Id</param>
		/// <param name="collection">Form data input</param>
		/// <param name="newpage">True if creating a new comment, false if editing</param>
		/// <returns>New ContentComment</returns>
		private ContentComment GetCommentFromCollection(int id, FormCollection collection,
			bool newcomment)
		{
			// Setup markdown
			Markdown m = new Markdown();

			// New comment
			ContentComment comment = new ContentComment();

			// Trigger date
			DateTime dt = DateTime.Now;


			// Author name
			comment.Author = Util.DefaultString(collection["author"], "Anonymous");

			if (newcomment)
			{
				// Basics
				comment.PageId = id;
				comment.AuthorId = 0; // Just for now
				comment.AuthorIP = Util.GetUserIP();
				comment.CreatedDate = dt;
			}

			comment.LastModified = dt;

			// We don't have an approval yet
			comment.Approved = true;
			comment.AuthorEmail = Util.DefaultString(collection["authoremail"], "");

			// Content
			comment.BodyText = Util.DefaultString(collection["bodytext"], "");
			comment.BodyHtml = m.Transform(comment.BodyText);

			return comment;
		}
	}
}

Onward to page 2…

Simple CMS with Linq to SQL part II

In our last installment, we looked at the basics of creating and deleting pages and comments. Now we’re going to dive into the views and editing portion including listing pages and comments in index (list) form and paging.

First, we can compile our project to make sure there no compile errors. If all went well, and if you used the mod portal modifications, you will be greeted with the following :

Welcome to your project

We’ll worry about the front page later. First let’s go into our PagesController and add a few views. I don’t want limit our flexibility with multiple full page views, so we’ll only have them for the Index, Read, Edit, Delete, EditComment and finally DeleteComment views. The rest will be strongly typed partial views (ViewUserControls).

Remember that the last time we didn’t include any programming to accomplish editing, but don’t worry. It’s fairly simple.

First, let’s create a control to display an index list of paged… er… content pages. You can create an empty control first called PageList, but we’ll need to modify the Inherits flag to match our PagedList class so we can access that model.

<%@ Control Language="C#" 
Inherits="System.Web.Mvc.ViewUserControl<PagedList<SimpleCMS.Models.ContentPage>>" %>
    <% foreach (var item in Model) { %>
    <div class="post">
           <h4><%: Html.ActionLink(item.Title, "Read", new { id = item.PageId }, 
                	new { title = Html.AltTitle(item.Title, item.Description) }) %></h4>

			<p class="data"><span><%: Html.FormalDate(item.PubDate) %> | 
			by <%: Html.AuthorLink(item.Author, item.AuthorId) %>.</span> 
			<span><%: Html.EditedDate(item.PubDate, item.LastModified) %>
			<%: Html.ActionLink("Edit", "Edit", new { id=item.PageId }) %> | 
			<%: Html.ActionLink("Delete", "Delete", new { id=item.PageId })%> | 
			<%: Html.CommentsLink(item.PageId, item.CommentCount) %> | 
			Viewed <%: item.ViewCount %> times</span></p>

                <% if (!String.IsNullOrEmpty(item.Abstract)) { %>
				<%= item.Abstract %>
				<%  } else { %>
				<%= item.BodyHtml %><% } %>			
    </div>
    <% } %>

Notice that there are multiple inheritences/implementations here. Our PagedList implements ContentPage, since that’s what we’re displaying in this control. Also note that we’re making use of a couple of helper functions, EditedDate and CommentsLink in the MvcHelpers class we created the last time.

Now we can view our pages, let’s also make another control that allows us to create them… Note, this has to be a strongly typed control called CreatePage that uses the ContentPage model.

<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<SimpleCMS.Models.ContentPage>" %>
<div class="postform">
	<%
		using (Html.BeginForm("Create", "Pages", new { id = ViewData.Model.PageId }, FormMethod.Post))
		{%>
	<%: Html.ValidationSummary(true)%>
	<fieldset>
		<legend>Create new page</legend>
		<p>
			<label for="Title">Title</label>
			<%: Html.TextBoxFor(model => model.Title)%><%: Html.ValidationMessageFor(model => model.Title, "*")%></p>
		<p>
			<label for="Description">Description</label>
			<%: Html.TextBoxFor(model => model.Description)%>
		</p>
		<p>
			<label for="Abstract">Abstract</label>
			<%: Html.TextAreaFor(model => model.Abstract, new { rows = 5, cols = 60 })%>
		</p>
		<p>
			<label for="Author">Author</label>
			<%: Html.TextBoxFor(model => model.Author)%>
		</p>
		<p>
			<label for="BodyText">Content</label>
			<%: Html.TextAreaFor(model => model.BodyText, new { rows = 5, cols = 60 })%>
			<%: Html.ValidationMessageFor(model => model.BodyText)%>
		</p>
		<p>
			<label for="PubDate">Publish Date</label>
			<%: Html.TextBoxFor(model => model.PubDate)%>
		</p>
		<ul class="feedback options">
			<li><label><input type="checkbox" value="true" name="EnableComments" 
			<%= Html.CheckBoxChecked(ViewData.Model.EnableComments, true) %>/> Enable Comments</label></li>
			<li><label><input type="checkbox" value="true" name="AnonComments" 
			<%= Html.CheckBoxChecked(ViewData.Model.AnonComments, true) %>/> Anonymous Comments</label></li>
			<li><label><input type="checkbox" value="true" name="ModeratedComments" 
			<%= Html.CheckBoxChecked(ViewData.Model.Moderated, false) %>/> Moderated</label></li>
			<li><label><input type="checkbox" value="true" name="Approved" 
			<%= Html.CheckBoxChecked(ViewData.Model.Approved, true) %>/> Approved</label></li>
		</ul>
		<p>
			<input type="submit" value="Create new page" />
		</p>
	</fieldset>
</div>
<% } %>

Now we’re finally ready to create the Index View. Remember, this is going to be a full page strongly typed view, not just an .ascx. We just have to make sure that the strongly typed model is going to be PageView. You can leave View Content option as Empty since we’re going to be writing in our own functionality.

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" 
Inherits="System.Web.Mvc.ViewPage<SimpleCMS.Models.PageView>" %>

<asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
Index
</asp:Content>

<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">
    <h2>Index</h2>
	<% 		
		if (Model.Pages.Count > 0)
		{
			// We pages in this PageView, so we can start listing them.
			Html.RenderPartial("PageList", ViewData.Model.Pages);
		}
		if (Model.HasPage)
		{
			// We're adding a new page to an existing one.
			Html.RenderPartial("CreatePage", ViewData.Model.Page);
		}
		else
		{
			// We're creating a new content page from scratch
			Html.RenderPartial("CreatePage", new SimpleCMS.Models.ContentPage());
		}
		%>
</asp:Content>

See how uncluttered our page views are when we delegate all the “stuff” to user controls. Also note that we’re not fiddling with ViewData[“somestuff”] and such.

Go ahead and try it out.. You should be able to navigate to the /Pages/ section of your project after a compilation and create new pages.

Onward to the next step…

Simple CMS with Linq to SQL

A very basic content management system with multiple nested pages, comments and maybe a basic user management interface. Everything will be in ASP.Net MVC 2 in C#.

I’ve been meaning to do this for quite some time, but work and life kept getting in the way. I thought if I’m going to update for real, I’d at least post something useful.

I’ll be making use of my previous Linq to SQL Membership and Role providers for this to save time and effort. There are some minor alterations in the code, but those should be easy to make.

It would be helpful to have some prior knowledge of MVC basics, but not an absolute prerequisite. Things will go a bit smoother if you’re already familiar with it and Visual Web Developer Express, which my IDE of choice for this one. I’m also going to be using Sql Server Express 2008 and I won’t be adding an MDF to the App_Data folder as most quick examples would show, but create a database in SQL Server using the Management Studio instead. I think it’s far simpler to deploy to a hosted service with this starting point.

Because I’m going to be posting a lot of code in this, there is the potential for mistakes in my train of thought or during formatting. Please point them out if you happen to come across them or if you have improvements. But please keep in mind that this is a SimpleCMS, so we’re not going to get all that fancy ;)

Let’s start with a ContentPage table in your database :

Don't forget to set PageId as the primary key and set properties to auto increment.

Note that only three fields are required and one is by default because it’s the primary key. This is because I thought of having the most flexible arrangement program-wise without being concerned too much about required fields in the table. And because I wanted the CMS to double as a forum at some point… More on that later.

Now we need a ContentComments table :

Very important not to confuse AuthorId with AuthorIP. Seems silly to point that out, but it's happened to me ;)

Now we need to add a parent-child relationship by adding a foreign key to the ContentComments table. In this case, the PageId of the ContentComments table is going to be the foreign key to the ContentPages table’s PageId primary.

When adding relationships, it's better to keep the Primary and Foreign key field names the same.

Now back in your solution explorer, right click on the Models folder and Add > New Item. We’re going to add the Linq to SQL data classes here and call it SimpleCMS (you can call it any name of your choice, but just remember to keep it matching the project name for simplicity.

You’ll see the classes designer open with two sections exposed. You need to drag and drop the ContentPages and ContentComments tables into the class designer view.

Just remember to compile your project after adding these tables (or making any changes to the dbml) or else the next steps won't work.

Now that the database is set, we’re on our way to the fun part; the classes. But first, I’m going to break one of my own rules here and develop the template. This is because the default MVC theme is a bit too bare and, I believe, not very flexible even if you decide to hack it to pieces.

We’ll be using a version of the mod portal variation of one of my old template tutorials.

Onward to the next step…

LINQ to SQL Role Provider

This is following my previous post on the Membership Provider. I decided I might as well add the RoleProvider to go along with it.

/**
 * THIS SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 */

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Security;
using System.Configuration;
using System.Configuration.Provider;
using Osiris.Models;

namespace Osiris.Providers
{
	public sealed class OsirisRoleProvider : RoleProvider
	{

		/*************************************************************************
		 * General settings
		 *************************************************************************/

		private string _applicationName;
		public override string ApplicationName
		{
			get { return _applicationName; }
			set { _applicationName = value; }
		}


		/*************************************************************************
		 * Retrieval methods
		 *************************************************************************/

		/// <summary>
		/// Gets all available user roles
		/// </summary>
		/// <returns>Array of all available roles</returns>
		public override string[] GetAllRoles()
		{
			string[] roles = null;
			using (OsirisDataContext db = new OsirisDataContext())
			{
				roles = (from groups in db.Groups
						 select groups.Title).ToArray();
			}
			return roles;
		}

		/// <summary>
		/// Gets the assigned roles for a particular user.
		/// </summary>
		/// <param name="username">Matching username</param>
		/// <returns>Array of assigned roles</returns>
		public override string[] GetRolesForUser(string username)
		{
			string[] roles = null;
			using (OsirisDataContext db = new OsirisDataContext())
			{
				roles = (from mg in db.MembersInGroups
								  where mg.Member.Username == username
								  select mg.Group.Title).ToArray();
			}
			return roles;
		}

		/// <summary>
		/// Gets all the users in a particular role
		/// </summary>
		public override string[] GetUsersInRole(string roleName)
		{
			// Without paging, this function seems pointless to me,
			// so I didn't implement it. Should be simple enough using the previous code though.
			throw new NotImplementedException();
		}


		/*************************************************************************
		 * Create and Delete methods
		 *************************************************************************/

		/// <summary>
		/// Creates a new role
		/// </summary>
		public override void CreateRole(string roleName)
		{
			// No need to add if it already exists
			if (!RoleExists(roleName))
			{
				Group g = new Group();
				g.Title = roleName;
				g.Signup = false;

				using (OsirisDataContext db = new OsirisDataContext())
				{
					db.Groups.InsertOnSubmit(g);
					db.SubmitChanges();
				}
			}
		}

		/// <summary>
		/// Deletes a given role
		/// </summary>
		/// <param name="roleName">Role name to delete</param>
		/// <param name="throwOnPopulatedRole">Specifies whether the function should throw 
		/// if there are assigned users to this role</param>
		/// <returns>True if successful. Defaults to false</returns>
		public override bool DeleteRole(string roleName, bool throwOnPopulatedRole)
		{
			// Return status. Defaults to false.
			bool ret = false;

			// You can only delete an existing role
			if (RoleExists(roleName))
			{
				try
				{
					using (OsirisDataContext db = new OsirisDataContext())
					{
						if (throwOnPopulatedRole)
						{
							int[] users = (from mg in db.MembersInGroups
										   where mg.Group.Title == roleName
										   select mg.Member.MemberId).ToArray();

							if (users.Count() > 0)
								throw new ProviderException("Cannot delete roles with users assigned to them");
						}

						Group g = (from groups in db.Groups
								   where groups.Title == roleName
								   select groups).FirstOrDefault();

						db.Groups.DeleteOnSubmit(g);
						db.SubmitChanges();

						ret = true;
					}
				}
				catch { }
			}
			
			return ret;
		}


		/*************************************************************************
		 * Assign/Remove methods
		 *************************************************************************/
		
		/// <summary>
		/// Adds a collection of users to a collection of corresponding roles
		/// </summary>
		public override void AddUsersToRoles(string[] usernames, string[] roleNames)
		{
			// Get the actual available roles
			string[] allRoles = GetAllRoles();

			// See if any of the given roles match the available roles
			IEnumerable<string> roles = allRoles.Intersect(roleNames);

			// There were some roles left after removing non-existent ones
			if (roles.Count() > 0)
			{
				// Cleanup duplicates first
				RemoveUsersFromRoles(usernames, roleNames);

				using (OsirisDataContext db = new OsirisDataContext())
				{
					// Get the user IDs
					List<int> mlist = (from members in db.Members
									   where usernames.Contains(members.Username)
									   select members.MemberId).ToList();

					// Get the group IDs
					List<int> glist = (from groups in db.Groups
									   where roleNames.Contains(groups.Title)
									   select groups.GroupId).ToList();

					// Fresh list of user-role assignments
					List<MembersInGroup> mglist = new List<MembersInGroup>();
					foreach (int m in mlist)
					{
						foreach (int g in glist)
						{
							MembersInGroup mg = new MembersInGroup();
							mg.MemberId = m;
							mg.GroupId = g;
							mglist.Add(mg);
						}
					}

					db.MembersInGroups.InsertAllOnSubmit(mglist);
					db.SubmitChanges();
				}
			}
		}

		/// <summary>
		/// Remove a collection of users from a collection of corresponding roles
		/// </summary>
		public override void RemoveUsersFromRoles(string[] usernames, string[] roleNames)
		{
			// Get the actual available roles
			string[] allRoles = GetAllRoles();

			// See if any of the given roles match the available roles
			IEnumerable<string> roles = allRoles.Intersect(roleNames);

			// There were some roles left after removing non-existent ones
			if (roles.Count() > 0)
			{
				using (OsirisDataContext db = new OsirisDataContext())
				{
					List<MembersInGroup> mg = (from members in db.MembersInGroups
											   where usernames.Contains(members.Member.Username) &&
											   roleNames.Contains(members.Group.Title)
											   select members).ToList();

					db.MembersInGroups.DeleteAllOnSubmit(mg);
					db.SubmitChanges();
				}
			}
		}


		/*************************************************************************
		 * Searching methods
		 *************************************************************************/

		/// <summary>
		/// Checks if a given username is in a particular role
		/// </summary>
		public override bool IsUserInRole(string username, string roleName)
		{
			// Return status defaults to false
			bool ret = false;

			if (RoleExists(roleName))
			{
				using (OsirisDataContext db = new OsirisDataContext())
				{
					int c = (from m in db.MembersInGroups
							 where m.Member.Username == username &&
							 m.Group.Title == roleName
							 select m).Count();

					if (c > 0)
						ret = true;
				}
			}

			return ret;
		}

		/// <summary>
		/// Finds a set of users in a given role
		/// </summary>
		public override string[] FindUsersInRole(string roleName, string usernameToMatch)
		{
			// Here's another function that doesn't make sense without paging
			throw new NotImplementedException();
		}

		/// <summary>
		/// Checks if a given role already exists in the database
		/// </summary>
		/// <param name="roleName">Role name to search</param>
		/// <returns>True if the role exists. Defaults to false.</returns>
		public override bool RoleExists(string roleName)
		{
			bool ret = false;

			// If the specified role doesn't exist
			if (!GetAllRoles().Contains(roleName))
				ret = true;
			
			return ret;
		}

		/*************************************************************************
		 * Initialization
		 *************************************************************************/

		/// <summary>
		/// Initialize the RoleProvider
		/// </summary>
		public override void Initialize(string name, System.Collections.Specialized.NameValueCollection config)
		{
			if (config == null)
				throw new ArgumentNullException("config");

			if (name == null || name.Length == 0)
				name = "OsirisRoleProvider";

			if (String.IsNullOrEmpty(config["description"]))
			{
				config.Remove("description");
				config.Add("description", "Osiris Role Provider");
			}

			// Initialize base class
			base.Initialize(name, config);

			_applicationName = GetConfigValue(config["applicationName"],
				System.Web.Hosting.HostingEnvironment.ApplicationVirtualPath);

		}


		/*************************************************************************
		 * Private helper methods
		 *************************************************************************/

		private string GetConfigValue(string configValue, string defaultValue)
		{
			if (String.IsNullOrEmpty(configValue))
				return defaultValue;

			return configValue;
		}
	}
}