static void

Asp.Net Localize Utility

Convert pages to use explicit localizations (as opposed to the implicit localizations with Visual Studio). Creates either App_GlobalResources, App_LocalResources or SQL scripts (for sql localization provider).

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Web;
using System.Xml;
using System.Xml.XPath;
 
namespace AddAspResources
{
    /// <summary>
    /// Converts a page to use explicit asp resources (similar to Visual Studio Tools>Localize)
    /// </summary>
    /// <remarks>
    /// Important: text must be localizable first (eg asp:Label, not in html p tags).
    /// It can be run repeatedly (it recognises already localized text) but it doesn't load the old localizations.
    /// <para>Re-running</para>
    /// <para>For resx (global and local) the existing files are read so there are non duplicate keys.</para>
    /// <para>For sql providers, this is not done, so duplicate keys are possible (especially in global mode)</para>
    /// </remarks>
    /// <example>
    /// <code>
    /// //Called from a non-web project
    /// Localize c = new Localize();
    /// c.BackUpSavedFiles = false; //by default it saves a backup of each file
    /// c.ResourceStore = Localize.ResourceStores.App_GlobalResources; //can be global or local, or to sql scripts
    /// c.ConvertDirectory(@"C:\Users\x\Desktop\Documents\Visual Studio 2005\WebSites\LocalizeMe"); //convert a directory
    /// </code>
    /// <code>
    /// //called from a web project- complete website - put a button on any page and insert this...
    /// Localize c = new Localize();
    /// c.ConvertDirectory(); //convert everything
    /// </code>
    /// <code>
    /// //called from a web project- single page - put a button on any page and insert this...
    /// Localize c = new Localize();
    /// //by default this is local resx
    /// c.ConvertFile(Server.MapPath(Request.AppRelativeCurrentExecutionFilePath)); //just this file
    /// </code>
    /// <code>
    /// //called from a web project- sitemap only - put a button on any page and insert this...
    /// Localize c = new Localize();
    /// c.ResourceStore = Localize.ResourceStores.App_GlobalResources;
    /// c.ConvertSiteMap();
    /// c.SaveResources(); //you only have to do this for global resources- if local, it's already done
    /// </code>
    /// </example>
    public class Localize
    {
        #region ResourceStores enum
 
        /// <summary>
        /// Where resources are stored- global or local resx files, or in a sql script for a database
        /// </summary>
        public enum ResourceStores
        {
            /// <summary>
            /// Creates resx files in App_LocalResources for each directory. Sitemaps are always global resources. DEFAULT.
            /// </summary>
            App_LocalResources,
            /// <summary>
            /// Creates resx files in App_GlobalResources in the root (or at least initial directory)
            /// </summary>
            App_GlobalResources,
            /// <summary>
            /// WestWind sql provider. Creates a localize.sql file in each folder. Not tested.
            /// </summary>
            SqlLocalizationWW,
            /// <summary>
            /// Other sql provider. Creates a localize.sql file in each folder.
            /// </summary>
            SqlLocalizationX
        }
 
        #endregion
 
        /// <summary>
        /// String holding the html
        /// </summary>
        protected string _fileContents;
        protected const string _backupExtension = ".backup"; //this extension is used to rename the original source
        protected bool _backUpSavedFiles; //whether to back up files
        protected bool _changed = false; //if the file has changed
        protected Dictionary<string, string> _dict; //needs to be static as MatchEvaluators are static
        protected string _filePath; //assigned in Load and ConvertSiteMap to the full absolute file path.
        protected ResourceStores _store; //whether global or local resx, or sql
        protected string _webRoot; //the absolute path of the web root.
 
        /// <summary>
        /// Gets or sets the resource store.
        /// </summary>
        /// <value>The resource store.</value>
        public ResourceStores ResourceStore
        {
            get { return _store; }
            set { _store = value; }
        }
 
        /// <summary>
        /// Gets or sets a value indicating whether to back up saved files.
        /// Any file that is changed is first saved with a .backup extension.
        /// Default is true.
        /// </summary>
        /// <value><c>true</c> if back up saved files; otherwise, <c>false</c>.</value>
        public bool BackUpSavedFiles
        {
            get { return _backUpSavedFiles; }
            set { _backUpSavedFiles = value; }
        }
 
        #region public ctor and run methods
 
        /// <summary>
        /// Default constructor.
        /// </summary>
        public Localize()
        {
            BackUpSavedFiles = true; //explicitly set this false if you don't want backups
            ResourceStore = ResourceStores.App_LocalResources;
        }
 
        /// <summary>
        /// Converts the directory. Must be called within a web application (automatically uses root).
        /// Calls <see cref="ConvertSiteMap(string)"/> automatically- no need to call again
        /// </summary>
        public void ConvertDirectory()
        {
            if (HttpContext.Current == null)
                throw new ApplicationException("This overload can only be used within a web application");
            string path = HttpContext.Current.Server.MapPath("~/");
            WebAbsoluteRoot = path;
            ConvertDirectory(path);
        }
 
        /// <summary>
        /// Converts the directory and all subdirectories recursively.
        /// If you specified a different <see cref="WebAbsoluteRoot"/> the resources are not saved- call <see cref="SaveResources()"/> manually
        /// </summary>
        /// <param name="path">The path.</param>
        public void ConvertDirectory(string path)
        {
            if (!Directory.Exists(path))
                throw new ArgumentException("Directory does not exist");
 
            CheckPrepareToConvert(path);
 
            //change the pattern to .cs for code behind & normal classes
            foreach (string f in Directory.GetFiles(path, "*.as*x")) //aspx and ascx.
            {
                if (f.Contains("DontTryThisOneItsGotJavaApplets")) //add others here!
                    continue;
                ConvertFile(f);
            }
            foreach (string f in Directory.GetFiles(path, "*.master")) //master page(s)
            {
                ConvertFile(f);
            }
            foreach (string f in Directory.GetFiles(path, "*.sitemap")) //master page(s)
            {
                ConvertSiteMap(f);
            }
            //recurse
            foreach (string sub in Directory.GetDirectories(path))
            {
                ConvertDirectory(sub);
            }
            //if we're global and we're in the root, save the resources.
            if (ResourceStore == ResourceStores.App_GlobalResources && WebAbsoluteRoot.Equals(path, StringComparison.OrdinalIgnoreCase))
                SaveResources(WebAbsoluteRoot, null, "AppResources");
        }
 
        private void CheckPrepareToConvert(string path)
        {
            //need an absolute root
            if (string.IsNullOrEmpty(WebAbsoluteRoot))
            {
                WebAbsoluteRoot = path;
                if (ResourceStore == ResourceStores.App_GlobalResources)
                {
                    CreateNewDictionary();
                    LoadResources(WebAbsoluteRoot, null, null);
                }
            }
 
            //rename any previous localize sql files
            if (ResourceStore == ResourceStores.SqlLocalizationX || ResourceStore == ResourceStores.SqlLocalizationWW)
            {
                string sqlFile = Path.Combine(path, "localize.sql");
                if (File.Exists(sqlFile)) File.Move(sqlFile, Path.Combine(path, "localize(old).sql"));
            }
        }
 
        /// <summary>
        /// Converts the file.
        /// Essentially just <see cref="Load"/>, <see cref="Process"/>, <see cref="Save"/>, <see cref="SaveResources()"/>
        /// </summary>
        /// <param name="fullPath">The full path.
        /// For the current page pass in HttpContext.Current.Server.MapPath( HttpContext.Current.Request.AppRelativeCurrentExecutionFilePath)</param>
        public void ConvertFile(string fullPath)
        {
            Load(fullPath);
            Process();
            if (ResourceDictionary.Count > 0)
            {
                Save(fullPath);
                if (ResourceStore != ResourceStores.App_GlobalResources)
                    SaveResources();
            }
        }
 
        /// <summary>
        /// Saves the resources for a particular file. Writes <see cref="ResourceDictionary"/> to the resource file.
        /// Don't use for global resources (unless it's the sitemap)
        /// - use <see cref="SaveResources(string,string,string)"/> passing in null for page and class.
        /// </summary>
        public void SaveResources()
        {
            string pageName = Path.GetFileName(_filePath);
            string path = Path.GetDirectoryName(_filePath);
            if (pageName.EndsWith("x.cs")) pageName = pageName.Replace(".cs", ""); //if you're doing codebehind
            if(pageName.EndsWith(".sitemap")) pageName = null; //sitemaps
            SaveResources(path, pageName, null);
        }
 
        /// <summary>
        /// Saves the resources. Use this for global resources (pass in className, and null for pageName)
        /// </summary>
        /// <param name="dir">The directory</param>
        /// <param name="pageName">Name of the page. Pass null for global resources.</param>
        /// <param name="className">Name of the class. Pass null for local resources.</param>
        public void SaveResources(string dir, string pageName, string className)
        {
            //need an absolute root
            if (string.IsNullOrEmpty(WebAbsoluteRoot)) WebAbsoluteRoot = dir;
            //used for the sqlLocalization scripts
            StringBuilder sb = new StringBuilder();
            string sqlFile = "localize" + className + ".sql";
            string s;
            string resxFile;
            XmlDocument doc = new XmlDocument();
 
            switch (ResourceStore)
            {
                case ResourceStores.App_LocalResources:
                    resxFile =
                        Path.Combine(dir, "App_LocalResources" + Path.DirectorySeparatorChar + pageName + ".resx");
                    if (pageName == null)
                        resxFile = Path.Combine(WebAbsoluteRoot,
                                                "App_GlobalResources" + Path.DirectorySeparatorChar + "AppResources.resx");
                    SaveResourceToResx(resxFile, doc);
                    break;
                case ResourceStores.App_GlobalResources:
                    resxFile = Path.Combine(WebAbsoluteRoot,
                                            "App_GlobalResources" + Path.DirectorySeparatorChar + "AppResources.resx");
                    SaveResourceToResx(resxFile, doc);
                    break;
                case ResourceStores.SqlLocalizationWW:
                    foreach (KeyValuePair<string, string> kvp in ResourceDictionary)
                    {
                        sb.AppendLine(SaveResourceToSqlFileWW(pageName, kvp.Key, kvp.Value));
                    }
                    s = sb.ToString();
                    if (s != String.Empty) //only write if results
                        File.AppendAllText(Path.Combine(dir, sqlFile), s);
                    break;
                case ResourceStores.SqlLocalizationX:
                    foreach (KeyValuePair<string, string> kvp in ResourceDictionary)
                    {
                        sb.AppendLine(SaveResourceToSqlFile(className, pageName, kvp.Key, kvp.Value));
                    }
                    s = sb.ToString();
                    if (s != String.Empty) //only write if results
                        File.AppendAllText(Path.Combine(dir, sqlFile), s);
                    break;
                default:
                    break;
            }
            ResourceDictionary.Clear(); //in case it gets called twice
        }
 
        /// <summary>
        /// Loads the resources. Only applicable to resx files. Use this overload for local resx
        /// </summary>
        /// <param name="fullPathToFile">The full path to file.</param>
        public void LoadResources(string fullPathToFile)
        {
            string pageName = Path.GetFileName(fullPathToFile);
            string path = Path.GetDirectoryName(fullPathToFile);
            if (pageName.EndsWith("x.cs")) pageName = pageName.Replace(".cs", ""); //if you're doing codebehind
            LoadResources(path, pageName, null);
        }
 
        /// <summary>
        /// Loads the resources. Only applicable to resx files. Use this overload for global resx with page and class= null
        /// </summary>
        /// <param name="dir">The dir.</param>
        /// <param name="pageName">Name of the page.</param>
        /// <param name="className">Name of the class.</param>
        private void LoadResources(string dir, string pageName, string className)
        {
            if (ResourceDictionary == null) CreateNewDictionary();
            string resxFile;
            switch (ResourceStore)
            {
                case ResourceStores.App_LocalResources:
                    resxFile =
                        Path.Combine(dir, "App_LocalResources" + Path.DirectorySeparatorChar + pageName + ".resx");
                    if (pageName == null)
                        resxFile =
                            Path.Combine(WebAbsoluteRoot,
                                        "App_GlobalResources" + Path.DirectorySeparatorChar + className + ".resx");
                    LoadResx(resxFile);
                    break;
                case ResourceStores.App_GlobalResources:
                    resxFile =
                        Path.Combine(WebAbsoluteRoot,
                                    "App_GlobalResources" + Path.DirectorySeparatorChar + "AppResources.resx");
                    LoadResx(resxFile);
                    break;
            }
        }
 
        private void LoadResx(string resxFile)
        {
            if (File.Exists(resxFile))
            {
                XPathDocument docNav = new XPathDocument(resxFile);
                XPathNavigator nav = docNav.CreateNavigator();
                XPathNodeIterator iter = nav.Select("/root/data");
                while (iter.MoveNext())
                {
                    XPathNavigator dataNav = iter.Current;
                    string key = dataNav.GetAttribute("name", "");
                    XPathNavigator valueNav = dataNav.SelectSingleNode("value");
                    string value = valueNav.Value;
                    ResourceDictionary.Add(key, value);
                }
            }
        }
 
        /// <summary>
        /// The minimum xml to create a Resx. The actual files include an xsd but it doesn't seem to be needed.
        /// </summary>
        /// <returns></returns>
        private static string baseResxXml()
        {
            return
                @"<root><resheader name=""resmimetype"">
                    <value>text/microsoft-resx</value>
                    </resheader>
                    <resheader name=""version"">
                    <value>2.0</value>
                    </resheader>
                    <resheader name=""reader"">
                    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
                    </resheader>
                    <resheader name=""writer"">
                    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
                    </resheader>
                </root>";
        }
 
        #region Load Public Methods
 
        /// <summary>
        /// Load an individual file.
        /// </summary>
        /// <param name="path"></param>
        public void Load(string path)
        {
            if (String.IsNullOrEmpty(path))
                throw new ArgumentNullException(path);
 
            if (!File.Exists(path))
                throw new ArgumentException(path);
 
            _fileContents = File.ReadAllText(path);
            _filePath = path;
        }
 
        #endregion
 
        #region Save Public Methods
 
        /// <summary>
        /// Save output to a file path (NB: only if changed). Automatically saves original file with .orig extension.
        /// </summary>
        /// <param name="file"></param>
        public void Save(string file)
        {
            if (_fileContents == null)
                throw new ApplicationException("Have not loaded yet");
 
            //don't change it if nothing different
            if (_changed || string.IsNullOrEmpty(_fileContents))
            {
                string origfile = Path.ChangeExtension(file, _backupExtension);
 
                //only create the origin file once. if this is run twice, leave it
                if (BackUpSavedFiles && File.Exists(file) && !File.Exists(origfile))
                    File.Move(file, origfile);
 
                using (StreamWriter writer = new StreamWriter(file))
                {
                    writer.Write(_fileContents);
                }
            }
        }
 
        #endregion
 
        #endregion
 
        /// <summary>
        /// Runs the conversion.
        /// </summary>
        public virtual void Process()
        {
            if (string.IsNullOrEmpty(_fileContents))
                throw new ApplicationException("Must Load first");
 
            string orig = _fileContents;
            if (ResourceStore != ResourceStores.App_GlobalResources || ResourceDictionary == null)
                //if global, the dictionary is held throughout
            {
                CreateNewDictionary();
                LoadResources(WebAbsoluteRoot, null, null);
            }
            _changed = false;
 
            FindTags();
 
            FindCodeLocalizations();
            VerifyKeys(); //this is primarily for debugging/ reference
 
            if (orig != _fileContents)
                _changed = true;
        }
 
        /// <summary>
        /// Creates the new dictionary.
        /// </summary>
        private void CreateNewDictionary()
        {
            ResourceDictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        }
 
        /// <summary>
        /// Finds a common business class that takes a localizable string. Typically this is in code behind, but may be in a code block in the page.
        /// Searching for .Text=" is best done manually unfortunately (too much scope for error)
        /// </summary>
        private void FindCodeLocalizations()
        {
            //eg new Exception("text") - probably should be a regex to catch the first quote
            string findstr = "BusinessValidationError(";
            if (_fileContents.Contains(findstr))
            {
                int i = 0;
                while ((i = _fileContents.IndexOf(findstr, i)) != -1)
                {
                    i = i + findstr.Length;
                    //already converted (trimmed because could be split with spaces or line feeds)
                    if (_fileContents.Substring(i).TrimStart().StartsWith("GetLocalResourceObject"))
                        continue;
                    if (_fileContents.Substring(i).TrimStart().StartsWith("GetGlobalResourceObject"))
                        continue;
                    _fileContents = ChangeText(_fileContents, i, false);
                }
            }
        }
 
        /// <summary>
        /// Loads each of the GetLocalResourceObject keys it finds. Useful for debugging.
        /// </summary>
        private void VerifyKeys()
        {
            if (HttpContext.Current == null)
                return; //this only works within the web app (HttpContext exists)
            string findstr = "GetLocalResourceObject(";
            if (_fileContents.Contains(findstr))
            {
                int i = 0;
                while ((i = _fileContents.IndexOf(findstr, i)) != -1)
                {
                    i = i + findstr.Length;
                    CheckKey(_fileContents, i);
                }
            }
            findstr = "GetGlobalResourceObject(";
            if (_fileContents.Contains(findstr))
            {
                int i = 0;
                while ((i = _fileContents.IndexOf(findstr, i)) != -1)
                {
                    i = i + findstr.Length;
                    CheckKey(_fileContents, i);
                }
            }
        }
 
        /// <summary>
        /// Write the key and value out- for debugging
        /// </summary>
        /// <param name="s">The string.</param>
        /// <param name="i">The start position.</param>
        private void CheckKey(string s, int i)
        {
            int startQuote = s.IndexOf("\"", i); //find the first quote
            if (startQuote == -1)
                return;
            startQuote++; //we want after the quote
            int endQuote = s.IndexOf("\"", startQuote); //find the end quote
            if (endQuote == -1)
                return;
            int length = endQuote - startQuote;
            string key = s.Substring(startQuote, length);
            string pageName = _filePath.Replace(WebAbsoluteRoot, "~/"); //dirty way to make relative
            if (pageName.EndsWith("x.cs")) pageName = pageName.Replace(".cs", ""); //if you're doing codebehind
            //forces a get, so it should come back okay
            string value = HttpContext.GetLocalResourceObject("~/" + pageName, key) as string;
            Debug.WriteLine(pageName + " : " + key + " = " + value);
        }
 
        /// <summary>
        /// Finds the tags using a regular expression (each is processed with <see cref="CheckTag"/>)
        /// </summary>
        private void FindTags()
        {
            //this regular expression should get attributes as well (not http-equiv as an attribute though)
            //regex from http://haacked.com/archive/2005/04/22/Matching_HTML_With_Regex.aspx
            //fixed with allowing for server blocks (assumes fairly well formed)
            Regex regex =
                new Regex(
                    @"</?(?<tag>[\w:]+)((?<attpair>(\s+(?<attribute>[A-Za-z0-9-]+)(?<equals>\s*=\s*(?<value>(?:(?<quote2value>"".*?"")|<%.*?%>|(?<quote1value>'.*?')|(?<nakedvalue>[^'"">\s]+))))?)|(\s*<%.*?%>))+\s*|\s*|\s*<%.*?%>\s*)/?>",
                    RegexOptions.Singleline);
 
            // multiline affects ^$ only- we want \s to include \n
 
            _fileContents = regex.Replace(_fileContents, new MatchEvaluator(CheckTag));
        }
 
        /// <summary>
        /// The MatchEvaluator which takes each tag and loops through the attributes
        /// </summary>
        /// <param name="m">The match</param>
        /// <returns></returns>
        private string CheckTag(Match m)
        {
            string s = m.Value;
            string tag = m.Groups["tag"].Value;
            Debug.WriteLine("Tag " + tag);
 
            //asp filter - only asp tags can be localized
            //remove this to quickly localize non-asp then change them manually
            if (!tag.Contains("asp:"))
                return s; //only asp tags can be localized
 
            string Id = FindID(m);
 
            //second pass, after having got ID
            for (int k = 0; k < m.Groups["value"].Captures.Count; k++)
            {
                Capture c = m.Groups["value"].Captures[k];
                string atName = m.Groups["attribute"].Captures[k].Value.Trim();
                string value = c.Value.Trim();
                string unquoted = value.Trim(new char[] {'"', '\''});
                Debug.WriteLine(atName + " = " + value);
                int i = c.Index - m.Index;
                if (!IsLocalizable(atName))
                    continue;
                if (unquoted.Length < 2) //single character or empty
                    continue;
                if (value.Contains("<%"))
                    //contains some server tags (<%# Bind or <% resources etc)- either done or too complex to touch
                    continue;
                //key is Id.Attribute (eg TextBox1.Text). If two fields have the same text, they both get the same key.
                string key = Id + "." + atName;
                if (ResourceStore == ResourceStores.App_GlobalResources)
                    //if global, id.attribute doesn't make sense- use the text
                    key = CreateKey(unquoted);
                else if (Id.Equals(string.Empty)) //TemplateFields etc don't have IDs so shorten and uppercase the value
                    key = CreateKey(unquoted);
                if (key == String.Empty) continue;
 
                if (value.Equals(unquoted))
                    //the programmer didn't quote, naughty but compilable. Not compilable with resources tag though...
                {
                    int startQuote = s.IndexOf(unquoted, i); //the position of the first bit of unquoted
                    s = s.Insert(startQuote + unquoted.Length, "\"");
                    s = s.Insert(startQuote, "\"");
                }
                s = ChangeAttributeValue(s, atName, unquoted, key, true, i);
            }
 
            return s;
        }
 
        /// <summary>
        /// Finds the ID for this tag.
        /// </summary>
        /// <param name="m">The match</param>
        /// <returns></returns>
        private static string FindID(Match m)
        {
            //find ID
            string Id = String.Empty;
            for (int k = 0; k < m.Groups["attribute"].Captures.Count; k++)
            {
                Capture c = m.Groups["attribute"].Captures[k];
                string v = c.Value.Trim(); //should have been lowercased
                if (v.Equals("ID", StringComparison.OrdinalIgnoreCase))
                {
                    Capture c2 = m.Groups["value"].Captures[k];
                    string value = c2.Value.Trim();
                    Id = value.Trim(new char[] {'"', '\''});
                    break;
                }
            }
            return Id;
        }
 
        private string ChangeAttributeValue(string s, string atName, string text, string key, bool isAsp, int position)
        {
            if (IgnoreResourceValues(text))
                return s;
            int startQuote = s.IndexOf(text, position); //the position of the first bit of unquoted
            if (startQuote == -1) //things have got very confused
            {
                //if the text is "asp" you'll get the tag- so we find the attribute name then text and hope we've got it
                startQuote = s.IndexOf(atName); //find the attribute
                startQuote = s.IndexOf(text, startQuote); //then find the text
            }
            int length = text.Length;
 
            key = SaveResourceKeyValue(text, key);
            s = InsertResourceKey(s, isAsp, startQuote, length, text, key);
 
            return s;
        }
 
        /// <summary>
        /// Determines whether the specified attribute is localizable.
        /// </summary>
        /// <param name="attName">Name of the attribute</param>
        /// <returns>
        ///    <c>true</c> if the specified attribute name is localizable; otherwise, <c>false</c>.
        /// </returns>
        private static bool IsLocalizable(string attName)
        {
            bool localizable = false;
            if (attName.Equals("value", StringComparison.OrdinalIgnoreCase))
                //normal html buttons , only if asp filter in CheckTags is removed
                localizable = true;
            else if (attName.Equals("Text", StringComparison.OrdinalIgnoreCase))
                localizable = true;
            else if (attName.Equals("HeaderText", StringComparison.OrdinalIgnoreCase))
                localizable = true;
            else if (attName.Equals("EmptyDataText", StringComparison.OrdinalIgnoreCase))
                localizable = true;
            else if (attName.Equals("ErrorMessage", StringComparison.OrdinalIgnoreCase))
                localizable = true;
            else if (attName.Equals("ToolTip", StringComparison.OrdinalIgnoreCase))
                localizable = true;
            else if (attName.Equals("SelectText", StringComparison.OrdinalIgnoreCase))
                localizable = true;
            //other things that may be done- CancelText etc in GridView
            //wizard buttons ... StepNextButtonText="<%$ Resources: NEXT %>" StartNextButtonText="<%$ Resources: NEXT %>" StepPreviousButtonText="<%$ Resources: PREVIOUS %>" FinishPreviousButtonText="<%$ Resources: PREVIOUS %>" FinishCompleteButtonText="<%$ Resources: FINISH %>" CancelButtonText="<%$ Resources: CANCEL %>"
            return localizable;
        }
 
        private string ChangeText(string s, int i, bool isAsp)
        {
            int startQuote = s.IndexOf("\"", i); //find the first quote
            if (startQuote == -1)
                startQuote = s.IndexOf("'", i); //single quotes
            if (startQuote == -1)
                return s;
            startQuote++; //we want after the quote
            int endQuote = s.IndexOf("\"", startQuote); //find the end quote
            if (endQuote == -1)
                endQuote = s.IndexOf("'", startQuote); //single quotes
            if (endQuote == -1)
                return s;
            int length = endQuote - startQuote;
            string text = s.Substring(startQuote, length);
            if (IgnoreResourceValues(text))
                return s;
 
            //
            string key = CreateKey(text);
            if (key == "")
                return s; //only punctuation in the text - don't localize
 
            key = SaveResourceKeyValue(text, key);
            s = InsertResourceKey(s, isAsp, startQuote, length, text, key);
            return s;
        }
 
        /// <summary>
        /// Generates a resource key from the text (uppercased alphnumeric).
        /// </summary>
        /// <param name="text">The text.</param>
        /// <returns></returns>
        private static string CreateKey(string text)
        {
            string key = Regex.Replace(text, "[^A-Za-z0-9]", "");
            key = key.ToUpper(); //because the database has a case insensitive unique constraint
            if (key.Length > 20) key = key.Substring(0, 20); //for very long text
            return key;
        }
 
        private string InsertResourceKey(string s, bool isAsp, int startQuote, int length, string text, string key)
        {
            string prefix;
            string suffix;
            if (isAsp)
            {
                //no quotes
                prefix = "<%$ Resources: ";
                suffix = " %>";
                //preserve surrounding quotes
            }
            else
            {
                //key must be quoted
                prefix = "GetLocalResourceObject(\"";
                if (ResourceStore == ResourceStores.App_GlobalResources)
                    prefix = "GetGlobalResourceObject(\"";
                suffix = "\") as string";
                //kill surrounding quotes
                startQuote = startQuote - 1;
                length = length + 2;
            }
 
            string resrc = prefix +
                           (ResourceStore == ResourceStores.App_GlobalResources ? "AppResources," : "")
                           + key + suffix;
 
            s = s.Remove(startQuote, length);
            s = s.Insert(startQuote, resrc);
 
            Debug.WriteLine(
                s.Substring((startQuote > 20 ? startQuote - 20 : 0),
                            (s.Length - startQuote > 80 ? 80 : s.Length - startQuote)));
            Debug.WriteLine(s + " [" + text + "]");
            return s;
        }
 
        /// <summary>
        /// Ignore certain resource values because they are not really localizable. Single letters (A, B...) or VCR buttons
        /// </summary>
        /// <param name="text">The text.</param>
        /// <returns>
        ///      <c>true</c> if should be ignored; otherwise, <c>false</c>.
        ///  </returns>
        private static bool IgnoreResourceValues(string text)
        {
            text = text.Trim();
            if (text.Length < 2)
                return true; //a single letter or number- don't localize
                //"VCR" buttons- ignore these
            else if (text.Equals("&lt;&lt;", StringComparison.OrdinalIgnoreCase))
                return true; //<<
            else if (text.Equals("&lt;", StringComparison.OrdinalIgnoreCase))
                return true; //<
            else if (text.Equals("&gt;&gt;", StringComparison.OrdinalIgnoreCase))
                return true; //>>
            else if (text.Equals("&gt;", StringComparison.OrdinalIgnoreCase))
                return true; //>
            return false;
        }
 
        /// <summary>
        /// Saves the resource key and value.
        /// <para>If the value already exists elsewhere on the page, re-use that key.</para>
        /// <para>If the key is already used, we modify it to create a unqiue key.</para>
        /// </summary>
        /// <param name="text">The text.</param>
        /// <param name="key">The key.</param>
        /// <returns>tThe key (may be different - see summary)</returns>
        private string SaveResourceKeyValue(string text, string key)
        {
            if (ResourceDictionary.ContainsValue(text))
            {
                //find key
                foreach (KeyValuePair<string, string> kvp in ResourceDictionary)
                {
                    if (kvp.Value.Equals(text)) key = kvp.Key;
                }
            }
            else
            {
                while (ResourceDictionary.ContainsKey(key))
                {
                    key += "1"; //unless the key was because the text is the same- we check next
                }
                ResourceDictionary.Add(key, text);
            }
            return key;
        }
 
        #region Helpers
/* NOT USED
        /// <summary>
        /// Utility to manually change a tag to another one, given just the start of the tag.
        /// </summary>
        /// <param name="s">the full string</param>
        /// <param name="find">the substring to find (the start of the tag)</param>
        /// <param name="replace">the substring to replace</param>
        /// <returns></returns>
        private string ReplaceTagStarting(string s, string find, string replace)
        {
            int start = s.IndexOf(find, StringComparison.CurrentCultureIgnoreCase);
            if (start == -1)
                return s;
 
            int end = FindEndOfTag(s, start);
            Debug.WriteLine("Replace [" + start + " to " + end + "] =" +
                            s.Substring(start + 1, end - start) +
                            " \nreplaced by " + replace);
            s = s.Substring(0, start) + replace + s.Substring(end + 1);
            return s;
        }
 
        private int FindEndOfTag(string htmlFragment, int start)
        {
            //assuming we're at the start of the tag just find the next >
            int end = htmlFragment.IndexOf('>', start);
 
            //if there are nested asp tags, ignore 'em
            if (end != -1 && htmlFragment.Substring(end - 1, 2) == "%>")
                //add one to skip to next endbrace
                end = FindEndOfTag(htmlFragment, end + 1);
 
            if (end != -1 && htmlFragment.Substring(end - 1, 2) == "<>")
                //add one to skip to next endbrace
                end = FindEndOfTag(htmlFragment, end + 1);
 
            return end;
        }
        */
        #endregion
 
        #region Properties
 
        /// <summary>
        /// Gets or sets the resource dictionary. It is refreshed with each file (except when using App_GlobalResources).
        /// It is recreated in <see cref="Process"/> and written to the store in <see cref="SaveResources()"/>
        /// </summary>
        /// <value>The resource dictionary.</value>
        public Dictionary<string, string> ResourceDictionary
        {
            get { return _dict; }
            set { _dict = value; }
        }
 
        /// <summary>
        /// Gets or sets the web absolute root. If not explicitly set, we assume it is the first directory it finds.
        /// </summary>
        /// <value>The web absolute root.</value>
        public string WebAbsoluteRoot
        {
            get { return _webRoot; }
            set { _webRoot = value; }
        }
 
        #endregion
 
        #region SiteMap fixing
 
        /// <summary>
        /// Converts the site map. Only use this overload in a webapplication. If you use <see cref="ConvertDirectory()"/> this is done for you.
        /// </summary>
        public void ConvertSiteMap()
        {
            if (HttpContext.Current == null)
                throw new ApplicationException("This overload can only be used within a web application");
            string path = HttpContext.Current.Server.MapPath("~/");
            ConvertSiteMap(path);
        }
 
        /// <summary>
        /// Converts the site map. If you use <see cref="ConvertDirectory()"/> this is done for you.
        /// <para>If global DOES NOT SAVE RESOURCE- use <see cref="SaveResources()"/></para>
        /// <code>SaveResources(path, "", "AppResources");</code>
        /// </summary>
        /// <param name="path">The path.</param>
        public void ConvertSiteMap(string path)
        {
            XmlDocument doc = new XmlDocument();
            //if they just passed in the directory, assume they mean web.sitemap
            if (!path.EndsWith(".sitemap", StringComparison.OrdinalIgnoreCase))
                path = Path.Combine(path, "Web.sitemap");
            if (!File.Exists(path))
                return;
            _filePath = path;
            doc.Load(path);
            XmlNamespaceManager nsmgr = new XmlNamespaceManager(doc.NameTable);
            nsmgr.AddNamespace("ns", "http://schemas.microsoft.com/AspNet/SiteMap-File-1.0");
            if (ResourceDictionary == null || ResourceStore != ResourceStores.App_GlobalResources)
                //if global, the dictionary is held throughout
                CreateNewDictionary();
 
            bool changed = false;
            foreach (XmlElement siteMapNode in doc.SelectNodes("//ns:siteMapNode", nsmgr))
            {
                //we presume this is unique
                string key = siteMapNode.GetAttribute("url");
                if (MakeSitemapResource("title", siteMapNode, key)) changed = true;
                if (MakeSitemapResource("description", siteMapNode, key + ".description")) changed = true;
            }
            if (changed)
            {
                if (ResourceStore != ResourceStores.App_GlobalResources)
                    SaveResources(Path.GetDirectoryName(path), null, "sitemap");
                doc.DocumentElement.SetAttribute("enableLocalization", "true");
                doc.Save(path);
            }
        }
 
        private bool MakeSitemapResource(string attribute, XmlElement siteMapNode, string key)
        {
            string value = siteMapNode.GetAttribute(attribute);
            if (value == "")
                return false;
            if (value.ToLower().Contains("$resources:")) //string.contains doesn't have a stringcomparison overload :(
                return false; //already done
            //we could check if value is already there and reuse the key...
            if (key == "")
                key = value;
            //get rid of confusing characters
            key = key.Replace(" ", "")
                .Replace(".aspx", "")
                .Replace(".", "")
                .Replace("#", "")
                .Replace("=", "")
                .Replace("&", "")
                .Replace("/", "")
                .Replace("?", "")
                .Replace("'", "")
                .Replace("_", "")
                .Replace("-", "");
            if (key.Equals("default", StringComparison.OrdinalIgnoreCase))
                key = "_default"; //The resource name 'default' is not a valid identifier
            //check dictionary
            while (ResourceDictionary.ContainsKey(key))
            {
                key += "1"; //simplest way of doing it, sorry
            }
            ResourceDictionary.Add(key, value);
            //always global
            siteMapNode.SetAttribute(attribute, "$resources:AppResources," + key);
            return true;
        }
 
        #endregion
 
        #region Save resources to resx or sql files
 
        private void SaveResourceToResx(string resxFile, XmlDocument doc)
        {
            if (!File.Exists(resxFile))
            {
                if (!Directory.Exists(Path.GetDirectoryName(resxFile)))
                    Directory.CreateDirectory(Path.GetDirectoryName(resxFile));
                File.WriteAllText(resxFile, baseResxXml());
            }
            doc.Load(resxFile);
            foreach (KeyValuePair<string, string> kvp in ResourceDictionary)
            {
                if (doc.SelectSingleNode("/root/data[@name='" + kvp.Key + "']") != null)
                    continue; //already exists
                XmlElement data = doc.CreateElement("data");
                data.SetAttribute("name", kvp.Key);
                XmlElement value = doc.CreateElement("value");
                value.InnerText = kvp.Value;
                data.AppendChild(value);
                doc.DocumentElement.AppendChild(data);
            }
            doc.Save(resxFile);
        }
 
        private static string SaveResourceToSqlFile(string globalResourceClass, string localPageName, string key,
                                                    string value)
        {
            const string defaultCulture = "'en'"; //with single quotes
            //very bad, but..
            value = value.Replace("'", "''");
            //basic sanitize. If you have sql injection code in your text, don't blame me
            if (string.IsNullOrEmpty(globalResourceClass))
                globalResourceClass = "null";
            else
                globalResourceClass = "'" + globalResourceClass + "'";
            if (string.IsNullOrEmpty(localPageName))
                localPageName = "null";
            else
                localPageName = "'" + localPageName + "'";
            string s = "INSERT INTO [LOCALIZATION] " +
                       "([VIRTUAL_PATH],[CLASS_NAME],[CULTURE_NAME],[RESOURCE_NAME],[RESOURCE_VALUE])";
            s += " VALUES (" + localPageName + ", " + globalResourceClass + "," + defaultCulture + ",'" + key + "','" +
                value + "');";
            return s;
        }
 
        private static string SaveResourceToSqlFileWW(string resourceSet, string key, string value)
        {
            //very bad, but..
            value = value.Replace("'", "''");
            //basic sanitize. If you have sql injection code in your text, don't blame me
            if (string.IsNullOrEmpty(resourceSet))
                resourceSet = "null";
            else
                resourceSet = "'" + resourceSet + "'";
            //below is the schema for the Rick Strahl localization table
            string s = "INSERT INTO [LOCALIZATION] " +
                       "([RESOURCESET],[RESOURCEID],[VALUE])";
            s += " VALUES (" + resourceSet + ", '" + key + "','" + value + "');";
            return s;
        }
 
        #endregion
    }
}