背景
作为开发人员,我觉得应用程序有个 /Help/ 文件夹专门放帮助就行了;但是客户却要求在操作界面也放给出相应的帮助,原因是他不愿意多点击几下鼠标.好吧,You're the boss,容我想个能偷懒的方案来吧.
实现
首先我不打算在不同的地方重复文档的具体内容(一是我懒,二是如果有变化了要改的地方很多,而且有忽略了某处造成信息不同步的可能),那么最好的办法就是文档内容还是在原来的 /Help/ 文件夹里,你愿意到这来看还能看到;操作界面调用这里的内容(估且称在某处出现的帮助文字叫帮助块(HelpPiece)吧)。这样的话就必须给这些文档定义一个统一的格式,因为只有这样才能方便地解析出里面的帮助块,以便调用。根据原有文档的格式,我稍作修改,做出了以下约定:帮助块是紧随在命名的<h2>或<h3>或<h4>后面<div>。前面的<hx id="xxx">的id是此帮助块的id, 其内容是帮助块的title, <div>里的内容是此帮助块的具体内容。<hx>的id应该是唯一的,而且是在所有帮助文档的范围内唯一。
有了这些信息,就可以打造我们的HelpPiece和HelpParser了:
HelpPiece.cs
using System;
using System.Collections.Generic;
using System.Text;
namespace LiveHelp
{
public class HelpPiece
{
string id;
string title;
string content;
string pageUrl;
public string Title
{
get { return title; }
set { title = value; }
}
public string Id
{
get { return id; }
set { id = value; }
}
public string PageUrl
{
get { return pageUrl; }
set { pageUrl = value; }
}
public string Content
{
get { return content; }
set { content = value; }
}
}
}
using System;
using System.Collections.Generic;
using System.Text;
namespace LiveHelp
{
public class HelpPiece
{
string id;
string title;
string content;
string pageUrl;
public string Title
{
get { return title; }
set { title = value; }
}
public string Id
{
get { return id; }
set { id = value; }
}
public string PageUrl
{
get { return pageUrl; }
set { pageUrl = value; }
}
public string Content
{
get { return content; }
set { content = value; }
}
}
}
HelpParser.cs
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
using System.IO;
using System.Web;
namespace LiveHelp
{
public class HelpParser
{
string path;
string filter;
ParseMode parseMode;
string urlFormat;
string rootPath;
public HelpParser(string dirPath, string filter, bool parseSubFolders, string urlFormat, string rootPath)
{
this.path = dirPath;
this.filter = filter;
this.parseMode = parseSubFolders ? ParseMode.DirectoryAndDescendants : ParseMode.Directory;
this.urlFormat = urlFormat;
if (rootPath.EndsWith("\\"))
rootPath = rootPath.Substring(0, rootPath.Length - 1);
this.rootPath = rootPath;
}
public HelpParser(string filePath, string urlFormat, string rootPath)
{
this.path = filePath;
this.parseMode = ParseMode.File;
this.urlFormat = urlFormat;
if (rootPath.EndsWith("\\"))
rootPath = rootPath.Substring(0, rootPath.Length - 1);
this.rootPath = rootPath;
}
public Dictionary<string, HelpPiece> Parse()
{
switch (parseMode)
{
case ParseMode.File:
return ParseFile();
case ParseMode.Directory:
return ParseDirectory();
default:
return ParseDirectory(true);
}
}
private Dictionary<string, HelpPiece> ParseDirectory(bool parseSubFolders)
{
return ParseDirectory(this.path, parseSubFolders);
}
private Dictionary<string, HelpPiece> ParseDirectory(string path, bool parseSubFolders)
{
Dictionary<string, HelpPiece> result = new Dictionary<string, HelpPiece>();
DirectoryInfo dir = new DirectoryInfo(path);
foreach (FileInfo file in dir.GetFiles(this.filter))
{
MergeDictionary<string, HelpPiece>(result, ParseFile(file.FullName));
}
if (parseSubFolders)
foreach (DirectoryInfo subdir in dir.GetDirectories())
MergeDictionary<string, HelpPiece>(result, ParseDirectory(subdir.FullName, parseSubFolders));
return result;
}
private Dictionary<string, HelpPiece> ParseDirectory()
{
return ParseDirectory(false);
}
private Dictionary<string, HelpPiece> ParseFile()
{
return ParseFile(this.path);
}
#region too slow to use XmlDocument + XPath
//private Dictionary<string, HelpPiece> ParseFile(string path)
//{
// XmlDocument xml = new XmlDocument();
// xml.Load(path);
// XmlNamespaceManager nm = new XmlNamespaceManager(xml.NameTable);
// nm.AddNamespace("html", "http://www.w3.org/1999/xhtml");
// XmlNodeList headers = xml.DocumentElement.SelectNodes("//html:h2[@id] | //html:h3[@id] | //html:h4[@id]", nm);
// Dictionary<string, HelpPiece> result = new Dictionary<string, HelpPiece>();
// foreach (XmlNode header in headers)
// {
// if (header.NextSibling.Name == "div")
// {
// HelpPiece piece = new HelpPiece();
// piece.Id = header.Attributes["id"].Value;
// piece.Title = header.InnerText.Trim();
// piece.PageUrl = GetUrl(path);
// piece.Content = header.NextSibling.OuterXml;
// result.Add(piece.Id, piece);
// }
// }
// return result;
//}
#endregion
private Dictionary<string, HelpPiece> ParseFile(string path)
{
Regex regHeader = new Regex(
#region regex
@"<(?'Tag'h[234])
\s[^>]*
id=""(?'Id'[^""]*)""
[^>]*>
\s*(?'Title'[^<]*)\s*
</\k'Tag'>"
#endregion
, RegexOptions.IgnorePatternWhitespace);
Regex regDiv = new Regex(
#region regex
@"^\s*<div[^>]*> #最外层的<div>
(
(
(?'Open'<div[^>]*>) #碰到了<div>,在黑板上写一个Open
.*? #匹配<div>后面的内容
)*
(
(?'-Open'</div>) #碰到了</div>,擦掉一个Open
.*? #匹配</div>后面不是括号的内容
)*
.*? #最外层的<div></div>间的其它内容
)*
(?(Open)(?!)) #在遇到最外层的</div>前面,判断黑板上还有没有没擦掉的Open;如果还有,则匹配失败
</div> #最外层的</div>"
#endregion
, RegexOptions.Singleline | RegexOptions.IgnorePatternWhitespace);
string fileContent;
using (StreamReader reader = new StreamReader(path))
{
fileContent = reader.ReadToEnd();
}
Dictionary<string, HelpPiece> result = new Dictionary<string, HelpPiece>();
if (regHeader.IsMatch(fileContent))
{
string fileUrl = GetUrl(path);
foreach (Match matchHeader in regHeader.Matches(fileContent))
{
string rest = fileContent.Substring(matchHeader.Index + matchHeader.Length);
Match matchContent = regDiv.Match(rest);
if (!matchContent.Success)
continue;
HelpPiece piece = new HelpPiece();
piece.Id = matchHeader.Groups["Id"].Value;
piece.Title = matchHeader.Groups["Title"].Value;
piece.Content = matchContent.Value;
piece.PageUrl = fileUrl;
result.Add(piece.Id, piece);
}
}
return result;
}
private string GetUrl(string fullPath)
{
if (fullPath.StartsWith(this.rootPath, StringComparison.InvariantCultureIgnoreCase))
{
string localPath = fullPath.Substring(this.rootPath.Length);
return string.Format(urlFormat, localPath.Replace("\\", "/"));
}
return null;
}
public static void MergeDictionary<TKey, TVal>(Dictionary<TKey, TVal> result, Dictionary<TKey, TVal> dictionary)
{
foreach (TKey key in dictionary.Keys)
result.Add(key, dictionary[key]);
}
}
public enum ParseMode
{
File,
Directory,
DirectoryAndDescendants,
}
}
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
using System.IO;
using System.Web;
namespace LiveHelp
{
public class HelpParser
{
string path;
string filter;
ParseMode parseMode;
string urlFormat;
string rootPath;
public HelpParser(string dirPath, string filter, bool parseSubFolders, string urlFormat, string rootPath)
{
this.path = dirPath;
this.filter = filter;
this.parseMode = parseSubFolders ? ParseMode.DirectoryAndDescendants : ParseMode.Directory;
this.urlFormat = urlFormat;
if (rootPath.EndsWith("\\"))
rootPath = rootPath.Substring(0, rootPath.Length - 1);
this.rootPath = rootPath;
}
public HelpParser(string filePath, string urlFormat, string rootPath)
{
this.path = filePath;
this.parseMode = ParseMode.File;
this.urlFormat = urlFormat;
if (rootPath.EndsWith("\\"))
rootPath = rootPath.Substring(0, rootPath.Length - 1);
this.rootPath = rootPath;
}
public Dictionary<string, HelpPiece> Parse()
{
switch (parseMode)
{
case ParseMode.File:
return ParseFile();
case ParseMode.Directory:
return ParseDirectory();
default:
return ParseDirectory(true);
}
}
private Dictionary<string, HelpPiece> ParseDirectory(bool parseSubFolders)
{
return ParseDirectory(this.path, parseSubFolders);
}
private Dictionary<string, HelpPiece> ParseDirectory(string path, bool parseSubFolders)
{
Dictionary<string, HelpPiece> result = new Dictionary<string, HelpPiece>();
DirectoryInfo dir = new DirectoryInfo(path);
foreach (FileInfo file in dir.GetFiles(this.filter))
{
MergeDictionary<string, HelpPiece>(result, ParseFile(file.FullName));
}
if (parseSubFolders)
foreach (DirectoryInfo subdir in dir.GetDirectories())
MergeDictionary<string, HelpPiece>(result, ParseDirectory(subdir.FullName, parseSubFolders));
return result;
}
private Dictionary<string, HelpPiece> ParseDirectory()
{
return ParseDirectory(false);
}
private Dictionary<string, HelpPiece> ParseFile()
{
return ParseFile(this.path);
}
#region too slow to use XmlDocument + XPath
//private Dictionary<string, HelpPiece> ParseFile(string path)
//{
// XmlDocument xml = new XmlDocument();
// xml.Load(path);
// XmlNamespaceManager nm = new XmlNamespaceManager(xml.NameTable);
// nm.AddNamespace("html", "http://www.w3.org/1999/xhtml");
// XmlNodeList headers = xml.DocumentElement.SelectNodes("//html:h2[@id] | //html:h3[@id] | //html:h4[@id]", nm);
// Dictionary<string, HelpPiece> result = new Dictionary<string, HelpPiece>();
// foreach (XmlNode header in headers)
// {
// if (header.NextSibling.Name == "div")
// {
// HelpPiece piece = new HelpPiece();
// piece.Id = header.Attributes["id"].Value;
// piece.Title = header.InnerText.Trim();
// piece.PageUrl = GetUrl(path);
// piece.Content = header.NextSibling.OuterXml;
// result.Add(piece.Id, piece);
// }
// }
// return result;
//}
#endregion
private Dictionary<string, HelpPiece> ParseFile(string path)
{
Regex regHeader = new Regex(
#region regex
@"<(?'Tag'h[234])
\s[^>]*
id=""(?'Id'[^""]*)""
[^>]*>
\s*(?'Title'[^<]*)\s*
</\k'Tag'>"
#endregion
, RegexOptions.IgnorePatternWhitespace);
Regex regDiv = new Regex(
#region regex
@"^\s*<div[^>]*> #最外层的<div>
(
(
(?'Open'<div[^>]*>) #碰到了<div>,在黑板上写一个Open
.*? #匹配<div>后面的内容
)*
(
(?'-Open'</div>) #碰到了</div>,擦掉一个Open
.*? #匹配</div>后面不是括号的内容
)*
.*? #最外层的<div></div>间的其它内容
)*
(?(Open)(?!)) #在遇到最外层的</div>前面,判断黑板上还有没有没擦掉的Open;如果还有,则匹配失败
</div> #最外层的</div>"
#endregion
, RegexOptions.Singleline | RegexOptions.IgnorePatternWhitespace);
string fileContent;
using (StreamReader reader = new StreamReader(path))
{
fileContent = reader.ReadToEnd();
}
Dictionary<string, HelpPiece> result = new Dictionary<string, HelpPiece>();
if (regHeader.IsMatch(fileContent))
{
string fileUrl = GetUrl(path);
foreach (Match matchHeader in regHeader.Matches(fileContent))
{
string rest = fileContent.Substring(matchHeader.Index + matchHeader.Length);
Match matchContent = regDiv.Match(rest);
if (!matchContent.Success)
continue;
HelpPiece piece = new HelpPiece();
piece.Id = matchHeader.Groups["Id"].Value;
piece.Title = matchHeader.Groups["Title"].Value;
piece.Content = matchContent.Value;
piece.PageUrl = fileUrl;
result.Add(piece.Id, piece);
}
}
return result;
}
private string GetUrl(string fullPath)
{
if (fullPath.StartsWith(this.rootPath, StringComparison.InvariantCultureIgnoreCase))
{
string localPath = fullPath.Substring(this.rootPath.Length);
return string.Format(urlFormat, localPath.Replace("\\", "/"));
}
return null;
}
public static void MergeDictionary<TKey, TVal>(Dictionary<TKey, TVal> result, Dictionary<TKey, TVal> dictionary)
{
foreach (TKey key in dictionary.Keys)
result.Add(key, dictionary[key]);
}
}
public enum ParseMode
{
File,
Directory,
DirectoryAndDescendants,
}
}
除了上面提到的东西以外,我还在HelpParser里添加了解析一个文件,一个文件夹,一个文件夹和它所有子文件夹的三种解析方式,并记录了解析的文件的Url。
测试完这两个类能完成它们应该完成的工作之后,下面就轮到LiveHelper这个控件了。我们第一次Render这个控件时,要解析所有指定的帮助文档,并把结果放到Cache中;以后就直接从Cache中取。Control的UI我选择了使用ibox,一个类似于lightbox(用于在当前页面弹出图片)但还能处理Html的javascript程序。
LiveHelper.cs
using System;
using System.Collections.Generic;
using System.Text;
using System.Web.UI;
using System.ComponentModel;
using System.Web.UI.HtmlControls;
namespace LiveHelp
{
public class LiveHelper : Control
{
string helpId;
string helpHtmlPath = "~/help/";
ParseMode parseMode = ParseMode.DirectoryAndDescendants;
string helpUrlFormat = "{0}";
string fileFilter = "*.htm";
string text="Help";
[Category("HelpFiles"), DefaultValue("*.htm"), Description("When parsing files in directory, use this to filter files to parse.")]
public string FileFilter
{
get { return fileFilter; }
set { fileFilter = value; }
}
[Category("HelpFiles"), DefaultValue("~/help/"), Description("The file or directory path where the help files are.")]
public string HelpHtmlPath
{
get { return helpHtmlPath; }
set { helpHtmlPath = value; }
}
[Category("HelpFiles"), DefaultValue(ParseMode.DirectoryAndDescendants), Description("Parse a file, Parse a directory, or Parse a directory and all its sub directories.")]
public ParseMode ParseMode
{
get { return parseMode; }
set { parseMode = value; }
}
[Category("Data"), Description("This specifies which help will be shown.")]
public string HelpId
{
get { return helpId; }
set { helpId = value; }
}
[Category("Data"), DefaultValue("Help")]
public string Text
{
get { return text; }
set { text = value; }
}
public HelpPiece Help
{
get
{
if (string.IsNullOrEmpty(helpId) || string.IsNullOrEmpty(helpHtmlPath))
throw new ArgumentNullException("HelpId, HelpHtmlPath", "要使用LiveHelper,必须先设置这两个参数.");
Dictionary<string, HelpPiece> cached = Page.Cache[GetCacheKey()] as Dictionary<string, HelpPiece>;
if (cached == null)
{
cached = GetParsedResult();
Page.Cache[GetCacheKey()] = cached;
}
return cached[helpId];
}
}
Dictionary<string, HelpPiece> GetParsedResult()
{
string helpPath = Page.Server.MapPath(helpHtmlPath);
HelpParser parser;
switch (parseMode)
{
case ParseMode.File:
parser = new HelpParser(helpPath, helpUrlFormat, Page.Request.PhysicalApplicationPath);
break;
case ParseMode.Directory:
parser = new HelpParser(helpPath, fileFilter, false, helpUrlFormat, Page.Request.PhysicalApplicationPath);
break;
default:
parser = new HelpParser(helpPath, fileFilter, true, helpUrlFormat, Page.Request.PhysicalApplicationPath);
break;
}
return parser.Parse();
}
string GetCacheKey()
{
return parseMode.ToString() + ":" + helpHtmlPath +":" + fileFilter +":" +helpUrlFormat;
}
protected override void OnPreRender(EventArgs e)
{
base.OnPreRender(e);
ClientScriptManager sm = Page.ClientScript;
sm.RegisterClientScriptResource(this.GetType(), "LiveHelp.ibox.js");
HtmlLink link = new HtmlLink();
link.Attributes.Add("type", "text/css");
link.Attributes.Add("rel", "stylesheet");
string defaultStyleSheet = sm.GetWebResourceUrl(this.GetType(), "LiveHelp.ibox.css");
link.Attributes.Add("href", defaultStyleSheet);
Page.Header.Controls.Add(link);
}
protected override void Render(HtmlTextWriter writer)
{
writer.Write(string.Format(@"<a href=""#{0}"" rel=""ibox"" title=""{1}"">{2}</a>", ContentClientId(), Help.Title, text));
writer.Write(string.Format(@"<div id=""{0}"" style=""display:none"">{1}<p style=""text-align:right;""><a href=""{2}"">More</a></p></div>", ContentClientId(), Help.Content, Help.PageUrl));
}
string ContentClientId()
{
return "help_" + HelpId;
}
}
}
using System;
using System.Collections.Generic;
using System.Text;
using System.Web.UI;
using System.ComponentModel;
using System.Web.UI.HtmlControls;
namespace LiveHelp
{
public class LiveHelper : Control
{
string helpId;
string helpHtmlPath = "~/help/";
ParseMode parseMode = ParseMode.DirectoryAndDescendants;
string helpUrlFormat = "{0}";
string fileFilter = "*.htm";
string text="Help";
[Category("HelpFiles"), DefaultValue("*.htm"), Description("When parsing files in directory, use this to filter files to parse.")]
public string FileFilter
{
get { return fileFilter; }
set { fileFilter = value; }
}
[Category("HelpFiles"), DefaultValue("~/help/"), Description("The file or directory path where the help files are.")]
public string HelpHtmlPath
{
get { return helpHtmlPath; }
set { helpHtmlPath = value; }
}
[Category("HelpFiles"), DefaultValue(ParseMode.DirectoryAndDescendants), Description("Parse a file, Parse a directory, or Parse a directory and all its sub directories.")]
public ParseMode ParseMode
{
get { return parseMode; }
set { parseMode = value; }
}
[Category("Data"), Description("This specifies which help will be shown.")]
public string HelpId
{
get { return helpId; }
set { helpId = value; }
}
[Category("Data"), DefaultValue("Help")]
public string Text
{
get { return text; }
set { text = value; }
}
public HelpPiece Help
{
get
{
if (string.IsNullOrEmpty(helpId) || string.IsNullOrEmpty(helpHtmlPath))
throw new ArgumentNullException("HelpId, HelpHtmlPath", "要使用LiveHelper,必须先设置这两个参数.");
Dictionary<string, HelpPiece> cached = Page.Cache[GetCacheKey()] as Dictionary<string, HelpPiece>;
if (cached == null)
{
cached = GetParsedResult();
Page.Cache[GetCacheKey()] = cached;
}
return cached[helpId];
}
}
Dictionary<string, HelpPiece> GetParsedResult()
{
string helpPath = Page.Server.MapPath(helpHtmlPath);
HelpParser parser;
switch (parseMode)
{
case ParseMode.File:
parser = new HelpParser(helpPath, helpUrlFormat, Page.Request.PhysicalApplicationPath);
break;
case ParseMode.Directory:
parser = new HelpParser(helpPath, fileFilter, false, helpUrlFormat, Page.Request.PhysicalApplicationPath);
break;
default:
parser = new HelpParser(helpPath, fileFilter, true, helpUrlFormat, Page.Request.PhysicalApplicationPath);
break;
}
return parser.Parse();
}
string GetCacheKey()
{
return parseMode.ToString() + ":" + helpHtmlPath +":" + fileFilter +":" +helpUrlFormat;
}
protected override void OnPreRender(EventArgs e)
{
base.OnPreRender(e);
ClientScriptManager sm = Page.ClientScript;
sm.RegisterClientScriptResource(this.GetType(), "LiveHelp.ibox.js");
HtmlLink link = new HtmlLink();
link.Attributes.Add("type", "text/css");
link.Attributes.Add("rel", "stylesheet");
string defaultStyleSheet = sm.GetWebResourceUrl(this.GetType(), "LiveHelp.ibox.css");
link.Attributes.Add("href", defaultStyleSheet);
Page.Header.Controls.Add(link);
}
protected override void Render(HtmlTextWriter writer)
{
writer.Write(string.Format(@"<a href=""#{0}"" rel=""ibox"" title=""{1}"">{2}</a>", ContentClientId(), Help.Title, text));
writer.Write(string.Format(@"<div id=""{0}"" style=""display:none"">{1}<p style=""text-align:right;""><a href=""{2}"">More</a></p></div>", ContentClientId(), Help.Content, Help.PageUrl));
}
string ContentClientId()
{
return "help_" + HelpId;
}
}
}
用法
OK,现在我们能这样使用这个控件了:<%@ Register Assembly="LiveHelp" Namespace="LiveHelp" TagPrefix="live" %>
<live:LiveHelper FileFilter="*.htm" HelpHtmlPath="~/Help" HelpId="testHelp"
ParseMode="DirectoryAndDescendants" Text="Help" runat="server">
</live:LiveHelper>
<!--或者使用默认参数值-->
<live:LiveHelper HelpId="testHelp" runat="server"></live:LiveHelper>
<live:LiveHelper FileFilter="*.htm" HelpHtmlPath="~/Help" HelpId="testHelp"
ParseMode="DirectoryAndDescendants" Text="Help" runat="server">
</live:LiveHelper>
<!--或者使用默认参数值-->
<live:LiveHelper HelpId="testHelp" runat="server"></live:LiveHelper>
Html帮助文件的格式:
...
<h2 id="testHelp">帮助一</h2>
<div>
<p>some contents here.</p>
</div>
...
<h2 id="testHelp">帮助一</h2>
<div>
<p>some contents here.</p>
</div>
...