Partager via


How to add Options to Live Writer PlugIns

This entry explains how to have options associated with your Windows Live Writer plugin, including design patterns, dealing with the UI, persistence issues, and setup/uninstall implications .  See here for an example of writing a plugin.

 

What are good options?

Some things that are nice to make as options are:

  • Enabling / Disabling plugin features.
  • Configurations. Eg, if your PlugIn pulls generic data from an RSS feed,  allow configuring which RSS feed it's using.
  • HTML configurations. If your plugin takes some raw data and then converts it to HTML, consider making an option string.

My Paste-From-Console plugin had options to enable and disable certain features (such as removing line breaks and colorizing), and other options to configure the behavior (such as HTML strings specifying how to colorize).

My first PlugIn (reading a data from an RSS feed and injecting it as HTML) should have options for the RSS feed name and HTML decoration, but I didn't because I didn't know how to do that yet.

You can get very far with options that are just string (including RegEx and HTML formatters), bool, and int data types.

 

Design patterns:

A nice design pattern is to encapsulate all the options in a single class, which I'll call 'Options'. This has a few advantages, which I'll briefly enumerate here and then drill into more below:

  1. Persistent: You need your Options to be persistent, since you don't want to set them every time you launch WLW. This then becomes an issue of just serializing / deserializing the Options class. WLW conveniently provides a storage API, IProperties, to aid with this.
  2. User Interface. A single Options class means a single object to pass to the UI. For example, you can just use the PropertyGrid control to generically show your Options settings in a dialog. Thus your UI is general and doesn't need any specific dependency on your Options class.
  3. Better engineering design: In general, having all your persistent state centralized in 1 area helps your control it. It also lets the rest of your app be side-effect free, which is a very nice property for unit testing.
  4. Cooperates better with unit testing. (Yes, you can unit test your plugin!). To properly Unit test, you need to decoupled UI state and options from the actual functions your testing. Your unit test can create an Options class and pass it in. In fact, you'll likely want many unit test exercising the different options configurations.
  5. Restoring Defaults: If you trash your options, it can be nice to be able to easily reset them. If options are scattered as fields throughout all your plugin, it can be tough to hunt them all down and reset them all.  I recommend having the Options class's default ctor set the option defaults.

 

 

Sample code for an Options class

For Paste-From-Console, the Options class looked like this. The custom attributes on the properties are to cooperate with the PropertyGrid control :

     // Options to configure PasteFromConsolePlugIn.
    // This can be passed to a PropertyGrid. 
    // - the key data is properties, not fields. 
    // - It has DescriptionAttributes on it.
    public class Options
    {
        public Options Clone()
        {
            return (Options) this.MemberwiseClone();
        }

        // This is the HTML fragment that the console output will be in.
        // Arg 0 is the console output after being formated by RemoveLineBreaks and the Colorizier.
        // Enclose in a table for formatting purposes.
        // Enclose in <pre> tags. This will ensure:
        // - the proper linespacing 
        // - that it uses a fixed-width font like the console
        // - the text can be copied back out without extra newlines.
        string m_HtmlFormat = "<table border=1><tr><td><pre>{0}</pre></td></tr></table>";


        [DescriptionAttribute("The HTML format string. This is applied to String.Format, and {0} is the content. It is recommended to at least enclose it in <pre> </pre> tags to get a console style.")]
        public string HtmlFormat
        {
            get { return m_HtmlFormat; }
            set { m_HtmlFormat = value; }
        }

        #region Line breaks
        // The console inserts line breaks when it wraps text. 
        // If RemoveLineBreaks, then we removes those line breaks at Width columns.
        // This isn't bullet proof, but a good heuristic. Allow disabling it in case the 
        // heuristic gets too bad.
        // This can break down when:
        // - the Width is wrong.
        // - a line ends right at the Width, but is not actually continued onto the next line.
        bool m_RemoveLineBreaks = true;

        [Description("If true, line breaks are removed at the column specified by the Width property. If false, this does not adjust line breaks.")]
        [Category("Line breaks")]
        public bool RemoveLineBreaks
        {
            get { return m_RemoveLineBreaks; }
            set { m_RemoveLineBreaks = value; }
        }

        // Width at which to remove line breaks. 80 is the default console width, so guess that.
        // This could really change with each different process instance.
        int m_Width = 80;

        [Description("If RemoveLineBreaks is true, then Width is the column to remove line breaks at. This should be the width (in characters) of the console window you're copying from.")]
        [Category("Line breaks")]
        public int Width
        {
            get { return m_Width; }
            set {
                if (value <= 2)
                {
                    throw new ArgumentOutOfRangeException("Width", "Width must be a positive number");
                }
                m_Width = value; 
            }
        }

        #endregion // Line breaks


        #region Colorizing

        // Allow colorizing prompt 
        // If this is true, then any matching of RegExPrompt will be formated using RegExFormat string.
        // This is a heuristic. Since we just get raw text, we don't know what's actually the 
        // prompt and what's the output. We just use regular expressions to guess what's a prompt.
        bool m_ColorizePrompt = true;

        [Description("If true, then colorize the prompt and input text. Use the RegExColorizePrompt regular expression to identify the prompt, and then use ColorizeHtmlFormat to format it as HTML.")]
        [Category("Colorize")]
        public bool ColorizePrompt
        {
            get { return m_ColorizePrompt; }
            set { m_ColorizePrompt = value; }
        }

        // The regular expression to recognize prompts.         
        // The default here captures 2 groups (the prompt and the input), which become the args
        // to ColorizeHtmlFormat.
        // This can have an arbitrary number of groups, it just needs to stay in sync with the 
        // formatting in ColorizeHtmlFormat
        // This is per-line (embedded in ^ ... $)
        string m_RegExColorizePrompt = @"([A-Za-z]:\\.*?>)(.*?)";

        [Description("If ColorizePrompt is true, this is a regular expression that identifies the input prompt. The capture here are then passed as args to the format string in ColorizeHtmlFormat.")]
        [Category("Colorize")]
        public string RegexColorizePrompt
        {
            get { return m_RegExColorizePrompt; }
            set { m_RegExColorizePrompt = value; }
        }

        // String applied to Format. Arg 0 is the prompt (C:\abc\>"). Arg 1 is the input to the prompt.        
        string m_ColorizeHtmlFormat = "<b><span style=\"color: rgb(0,0,160)\">{0}</span></b><i><span style=\"color: rgb(164,0,0)\">{1}</span></i>";

        [Description("If ColorizePrompt is true, this is the HTML format string to colorize the prompt. The args {0}, {1}... {n} are the html-escaped captures from the regular expression RegExColorizePrompt.")]
        [Category("Colorize")]
        public string ColorizeHtmlFormat
        {
            get { return m_ColorizeHtmlFormat; }
            set { m_ColorizeHtmlFormat = value; }
        }

        #endregion // Colorizing
    } // end class

 

The UI:

An easy way to handle the UI for the Options is to just use Winforms and a PropertyGrid to generically show all the properties in the Options class.  The PropertyGrid uses reflection to dynamically provide UI for an arbitrary object's properties (note, PropertyGrid only shows properties, not fields). So as you add / remove properties to your Options class during development, you don't need to even worry about your UI code. The property grid is one of the most powerful and useful controls in Winforms. See here for more on using the PropertyGrid control.

Here's a screenshot of the options in a dialog displayed with the properties grid for the Paste-From-Console example:

image

 

You can see that the items here map up to the properties on the Options class.

Notice there's a "Restore Defaults" button to restore options to their default value. This is useful if you trash your options and want to get back to a known state.

 

Persistence:

The WLW API provides the WindowsLive.Writer.API.Properties class to serialize and deserialize options. IProperties has methods like:

  • int GetInt(string name int defaultValue) - retrieve property 'name' from the backing store, and use defaultValue if it's not found.
  • void SetInt(string name, int value) -  persist property name to the backing store

It has similar GetT/SetT for other types.

Using IProperties is better than trying to save or load options yourself.

  • IProperties already has a nice name lookup scheme.
  • IProperties handles per-user data (as opposed to writing a file in the plugins directory)
  • Using IProperties may cooperate better with future WLW tools. For example, imagine a tool that copied all your WLW settings from 1 machine to another machine. The tool could copy IProperties data, but may miss your own custom data formats.
  • More verifiably safe plugin. If the plugin directly uses disk IO or registry access for save/load properties, it may appear to be more suspicious under a security audit, and that may make folks more  shy to use it.
  • Why reinvent the wheel?

 

Empirically, the backing store for IProperties is registry key under HKCU. The key's name includes the the guid you specified in the WriterPlugInAttribute, so each plugin gets a unique storage space. I observed it as HKEY_CURRENT_USER\Software\Microsoft\Windows Live\Writer\Preferences\PostEditor\ContentSources\{0602A85E-9B11-43f2-AEE7-3677B57BF9DE}, but that's not gauranteed to stay the same across future versions. Smart folks recommend not touching this with your uninstaller (See "Custom Data and IProperties").

The nice part is that if you just use IProperties, you don't need to worry about these details.

Here's the code I used to go between IProperties and an Options class. It just handles Bool, int, and string, but could be expanded to handle other data types.

This assumes that an Option class's default constructor assigns all properties to a default value.

 using WindowsLive.Writer.Api;
using System.Reflection;

namespace Sample
{
    static class Utility
    {
        // Convert from IProperties to Options.
        // Returns an object that starts with default values, and then updates values
        // based on what's in IProperties.
        // Only handles int, bool, string.
        public static Options ReadOptionsFromStorage(IProperties props)
        {            
            Options o = new Options();

            foreach (PropertyInfo p in typeof(Options).GetProperties())
            {
                if (p.PropertyType == typeof(int))
                {
                    int defaultValue = (int)p.GetValue(o, null);
                    int newValue = props.GetInt(p.Name, defaultValue);
                    p.SetValue(o, newValue, null);
                }
                if (p.PropertyType == typeof(bool))
                {
                    bool defaultValue = (bool)p.GetValue(o, null);
                    bool newValue = props.GetBoolean(p.Name, defaultValue);
                    p.SetValue(o, newValue, null);
                }
                if (p.PropertyType == typeof(string))
                {
                    string defaultValue = (string)p.GetValue(o, null);
                    string newValue = props.GetString(p.Name, defaultValue);
                    p.SetValue(o, newValue, null);
                }
            }

            return o;
        }

        // Convert save Options back to IProperties.
        // Only handles int, bool, string.
        public static void WriteOptionsToStorage(IProperties props, Options options)
        {
            foreach (PropertyInfo p in typeof(Options).GetProperties())
            {
                if (p.PropertyType == typeof(int))
                {
                    int val = (int)p.GetValue(options, null);
                    props.SetInt(p.Name, val);
                }
                if (p.PropertyType == typeof(bool))
                {
                    bool val = (bool)p.GetValue(options, null);
                    props.SetBoolean(p.Name, val);
                }
                if (p.PropertyType == typeof(string))
                {
                    string val = (string)p.GetValue(options, null);
                    props.SetString(p.Name, val);
                }
            }
        }
    } // end class Utility
}

 

Code to wire it together:

Here's code within the PlugIin class to write up things.

This declares the Options class.

 

         Options m_options = new Options();

This implements the ContentSource::EditOptions, the ContentSource callback that gets invoked when you modify the options from the WLW UI. OptionForm is just a winform dialog that has a giant PropertyGrid to display the Options, as seen above.

 
        // LiveWriter hook invoke when user hits "Options" button in the PlugIn menu.
        // Must have HasEditableOptions=true in WriterPluginAttribute.
        public override void EditOptions(IWin32Window dialogOwner)
        {
            OptionForm form = new OptionForm(m_options);
            DialogResult r = form.ShowDialog();
            if (r == DialogResult.OK)
            {
                m_options = form.Options;

                // Now that we've changed the options, persist them back to storage
                Utility.WriteOptionsToStorage(this.Options, m_options);
            }
        }

If the options are changed, then it saves the new values back to storage.

And here's the initialize code to originally read in the options.

         public override void Initialize(IProperties pluginOptions)
        {
            base.Initialize(pluginOptions);

            // Save off options.
            m_options = Utility.ReadOptionsFromStorage(pluginOptions);
        }