[UPDATED] Word After Save Event

When I wrote my first Word AfterSave Event entry, it was designed for Word 2007, and was – as it turns out – not a catch all. So I have updated it here (thanks for the catch go to Pat Lemm).

When the document was closed, you never got access to the Saved filename. So, I have updated the code here and it now works in all conditions and has been tested in Word 2013.

Here is how it works:

  1. Upon initialization you pass it your Word object.
  2. It attaches to the Before Save Event.
  3. When any save event occurs, it kicks off a thread that loops until the Background Save is complete.
  4. Once the background save is done, it checks to see if the document Saved == true:
  • If Saved == true: then a regular save did occur.
  • If Saved == false: then it had to be an AutoSave

In each case it will fire a unique event:

  • AfterSaveUiEvent
  • AfterSaveEvent
  • AfterAutoSaveEvent

Additionally, if the document being saved is also being closed, we catch the filename on the WindowDeactivate event on the way out. This can now be accessed by the caller (as you can see in the example below), to get the full filename of the closed document.

Here is the code to the class:

public class WordSaveHandler
{
    public delegate void AfterSaveDelegate(Word.Document doc, bool isClosed);
    // public events
    public event AfterSaveDelegate AfterUiSaveEvent;
    public event AfterSaveDelegate AfterAutoSaveEvent;
    public event AfterSaveDelegate AfterSaveEvent;

    // module level
    private bool preserveBackgroundSave;
    private Word.Application oWord;
    string closedFilename = string.Empty;

    /// <summary>
    /// CONSTRUCTOR  takes the Word application object to link to.
    /// </summary>
    /// <param name="oApp"></param>
    public WordSaveHandler(Word.Application oApp)
    {
        oWord = oApp;
        // hook to before save
        oWord.DocumentBeforeSave += oWord_DocumentBeforeSave;
        oWord.WindowDeactivate += oWord_WindowDeactivate;
    }
    
    /// <summary>
    /// Public property to get the name of the file
    /// that was closed and saved
    /// </summary>
    public string ClosedFilename
    {
        get
        {
            return closedFilename;
        }
    }

    /// <summary>
    /// WORD EVENT  fires before a save event.
    /// </summary>
    /// <param name="Doc"></param>
    /// <param name="SaveAsUI"></param>
    /// <param name="Cancel"></param>
    void oWord_DocumentBeforeSave(Word.Document Doc, ref bool SaveAsUI, ref bool Cancel)
    {
        // This could mean one of four things:
        // 1) we have the user clicking the save button
        // 2) Another add-in or process firing a resular Document.Save()
        // 3) A Save As from the user so the dialog came up
        // 4) Or an Auto-Save event
        // so, we will start off by first:
        // 1) Grabbing the current background save flag. We want to force
        //    the save into the background so that Word will behave
        //    asyncronously. Typically, this feature is on by default,
        //    but we do not want to make any assumptions or this code
        //    will fail.
        // 2) Next, we fire off a thread that will keep checking the
        //    BackgroundSaveStatus of Word. And when that flag is OFF
        //    no know we are AFTER the save event
        preserveBackgroundSave = oWord.Options.BackgroundSave;
        oWord.Options.BackgroundSave = true;
        // kick off a thread and pass in the document object
        bool UiSave = SaveAsUI; // have to do this because the bool from Word
        // is passed to us as ByRef
        new Thread(() =>
        {
            Handle_WaitForAfterSave(Doc, UiSave);
        }).Start();
    }

    /// <summary>
    /// This method is the thread call that waits for the same to compelte.
    /// The way we detect the After Save event is to essentially enter into
    /// a loop where we keep checking the background save status. If the
    /// status changes we know the save is compelte and we finish up by
    /// determineing which type of save it was:
    /// 1) UI
    /// 2) Regular
    /// 3) AutoSave
    /// </summary>
    /// <param name="Doc"></param>
    /// <param name="UiSave"></param>
    private void Handle_WaitForAfterSave(Word.Document Doc, bool UiSave)
    {
        try
        {
            // we have a UI save, so we need to get stuck
            // here until the user gets rid of the SaveAs dialog
            if (UiSave)
            {
                while (isBusy())
                    Thread.Sleep(1);
            }

            // check to see if still saving in the background
            // we will hang here until this changes.
            while (oWord.BackgroundSavingStatus > 0)
                Thread.Sleep(1);
        }
        catch (ThreadAbortException)
        {
            // we will get a thread abort exception when Word
            // is in the process of closing, so we will
            // check to see if we were in a UI situation
            // or not
            if (UiSave)
            {
                AfterUiSaveEvent(null, true);
            }
            else
            {
                AfterSaveEvent(null, true);
            }
        }
        catch
        {
            oWord.Options.BackgroundSave = preserveBackgroundSave;
            return; // swallow the exception
        }

        try
        {
            // if it is a UI save, the Save As dialog was shown
            // so we fire the after ui save event
            if (UiSave)
            {
                // we need to check to see if the document is
                // saved, because of the user clicked cancel
                // we do not want to fire this event
                try
                {
                    if (Doc.Saved == true)
                    {
                        AfterUiSaveEvent(Doc, false);
                    }
                }
                catch
                {
                    // DOC is null or invalid. This occurs because the doc
                    // was closed. So we return doc closed and null as the
                    // document
                    AfterUiSaveEvent(null, true);
                }
            }
            else
            {
                // if the document is still dirty
                // then we know an AutoSave happened
                try
                {
                    if (Doc.Saved == false)
                        AfterAutoSaveEvent(Doc, false); // fire autosave event
                    else
                        AfterSaveEvent(Doc, false); // fire regular save event
                }
                catch
                {
                    // DOC is closed
                    AfterSaveEvent(null, true);
                }
            }
        }
        catch { }
        finally
        {
            // reset and exit thread
            oWord.Options.BackgroundSave = preserveBackgroundSave;
        }
    }

    /// <summary>
    /// WORD EVENT – Window Deactivate
    /// Fires just before we close the document and it
    /// is the last moment to get the filename
    /// </summary>
    /// <param name="Doc"></param>
    /// <param name="Wn"></param>
    void oWord_WindowDeactivate(Word.Document Doc, Word.Window Wn)
    {
        closedFilename = Doc.FullName;
    }

    /// <summary>
    /// Determines if Word is busy  essentially that the File Save
    /// dialog is currently open
    /// </summary>
    /// <param name="oApp"></param>
    /// <returns></returns>
    private bool isBusy()
    {
        try
        {
            // if we try to access the application property while
            // Word has a dialog open, we will fail
            object o = oWord.ActiveDocument.Application;
            return false; // not busy
        }
        catch
        {
            // so, Word is busy and we return true
            return true;
        }
    }
}

 

And here is how you set it up and attach to it’s events:

public partial class ThisAddIn
{
    WordSaveHandler wsh = null;
    private void ThisAddIn_Startup(object sender,
                                    System.EventArgs e)
    {
        // attach the save handler
        wsh = new WordSaveHandler(Application);
        wsh.AfterAutoSaveEvent += new WordSaveHandler.AfterSaveDelegate(wsh_AfterAutoSaveEvent);
        wsh.AfterSaveEvent += new WordSaveHandler.AfterSaveDelegate(wsh_AfterSaveEvent);
        wsh.AfterUiSaveEvent += new WordSaveHandler.AfterSaveDelegate(wsh_AfterUiSaveEvent);
    }

    void wsh_AfterUiSaveEvent(Word.Document doc, bool isClosed)
    {
        if (!isClosed)
            MessageBox.Show("After SaveAs Event");
        else
            MessageBox.Show("After Close and SaveAs Event. The filname was: " + wsh.ClosedFilename);
    }

    void wsh_AfterSaveEvent(Word.Document doc, bool isClosed)
    {
        if (!isClosed)
            MessageBox.Show("After Save Event");
        else
            MessageBox.Show("After Close and Save Event. The filname was: " + wsh.ClosedFilename);
    }

    void wsh_AfterAutoSaveEvent(Word.Document doc, bool isClosed)
    {
        MessageBox.Show("After AutoSave Event");
    }

How to determine if an Excel Workbook is Embedded… and more…

When working with a Visual Studio Tools for Office (VSTO) project, you may want to have your add-in behave differently when the Active Workbook is inside an embedding. More specifically, the user has inserted an Excel sheet into a Word Document, for example, and has just double-clicked on it and you find that you need to detect this scenario. The trick is to know when you are in an embedded scenario and as it turns out, this is not easy to do. If you are an OLE guru, you might check the Container property on the Workbook Object:

However, this property always throws an Exception for Office Document types. It specifically calls out Internet Explorer, for a reason. This property does not work with OLE Embeddings in other Office documents. You can check the path length and as I have seen in many solutions hardcoding for “Workbook in…” but this fails for multi-language solutions.

What I found you have to do is access a series of OLE properties through C# to get the information you need. And as an added bonus, not only can you determine if Excel is embedded, but you can also get the Name of the class object it is embedded inside of and the Moniker / Filename of the file it is embedded in. I created the following Extension methods for Excel:

/// <summary>
/// EXTENSION METHOD CLASS FOR EXCEL
/// </summary>
public static class ExcelExtensionMethods
{
    [DllImport("ole32.dll")]
    static extern int CreateBindCtx(uint reserved, out IBindCtx ppbc);

    /// <summary>
    /// WORKBOOK EXTENSION METHOD
    /// Checks to see if the Workbook is embeeded inside of 
    /// another ActiveX Document type, sy=uch as Word or Excel.
    /// </summary>
    /// <param name="PobjWb"></param>
    /// <returns></returns>
    public static bool IsEmbedded(this Excel.Workbook PobjWb)
    {
        if (PobjWb.Path == null || PobjWb.Path.Length == 0)
        {
            try
            {
                // requires using Microsoft.VisualStudio.OLE.Interop;
                // and you have to manually add this to reference from here:
                // C:\Program Files (x86)\Microsoft Visual Studio 10.0\Common7\IDE\PrivateAssemblies\Microsoft.VisualStudio.OLE.Interop.dll
                IOleObject LobjOleObject = ((object)PobjWb) as IOleObject;
                IOleClientSite LobjPpClientSite;
                // get the client site
                LobjOleObject.GetClientSite(out LobjPpClientSite);
                // if there is one - we are embedded
                if (LobjPpClientSite != null)
                {
                    return true;
                }
                else
                {
                    // not embedded
                    return false;
                }
            }
            catch (Exception ex)
            {
                // exception
                Debug.Print(ex.ToString());
                return false;
            }
            finally { }
        }
        else
        {
            // not embedded
            return false;
        }
    }

    /// <summary>
    /// WORKBOOK EXTENSION METHOD
    /// This method return the name of the class that we
    /// are embedded inside of.
    /// If we are not embedded it return null.
    /// If there is any exception it return null.
    /// If the container cannot be accessed it returns UNKNOWN.
    /// </summary>
    /// <param name="PobjWb"></param>
    /// <returns></returns>
    public static string EmbedClassName(this Excel.Workbook PobjWb)
    {
        try
        {
            IOleObject LobjOleObject = ((object)PobjWb) as IOleObject;
            IOleClientSite LobjPpClientSite;
            // get the client site
            LobjOleObject.GetClientSite(out LobjPpClientSite);
            if (LobjPpClientSite != null)
            {
                IOleContainer LobjPpContainer;
                LobjPpClientSite.GetContainer(out LobjPpContainer);
                if (LobjPpContainer != null)
                {
                    return LobjPpContainer.GetType().Name;
                }
                else
                {
                    // something wrong - container is not valid
                    return "UNKNOWN";
                }
            }
            else
            {
                // not embedded
                return null;
            }
        }
        catch (Exception ex)
        {
            Debug.Print(ex.ToString());
            return null; // failed
        }
    }

    /// <summary>
    /// WORKBOOK EXTENSION METHOD
    /// Get the full path to the file that the workbook is embedded 
    /// inside of. 
    /// If we are not embeeded then this will return null.
    /// If we are embedded but there are issues with the container
    /// or an exception occurs, it will return null.
    /// Otherwise we get the full path and filename.
    /// </summary>
    /// <param name="PobjWb"></param>
    /// <returns></returns>
    public static string EmbedMoniker(this Excel.Workbook PobjWb)
    {
        try
        {
            IOleObject LobjOleObject = ((object)PobjWb) as IOleObject;
            IOleClientSite LobjPpClientSite;
            // get the client site
            LobjOleObject.GetClientSite(out LobjPpClientSite);
            if (LobjPpClientSite != null)
            {
                IOleContainer LobjPpContainer;
                LobjPpClientSite.GetContainer(out LobjPpContainer);
                if (LobjPpContainer != null)
                {
                    // get the moniker
                    IMoniker LobjMoniker;
                    LobjPpClientSite.GetMoniker((uint)OLEGETMONIKER.OLEGETMONIKER_FORCEASSIGN,
                                                (uint)OLEWHICHMK.OLEWHICHMK_OBJFULL,
                                                out LobjMoniker);
                    if (LobjMoniker != null)
                    {
                        // now pull the moniker display name
                        // this will be in the form of PATH!Context
                        string LstrDisplayName;
                        IBindCtx LobjCtx = null;
                        CreateBindCtx(0, out LobjCtx); // required (imported function)
                        LobjMoniker.GetDisplayName(LobjCtx, null, out LstrDisplayName);
                        // remove context is exists
                        if (LstrDisplayName.Contains("!"))
                        {
                            string[] LobjMonikerArray = LstrDisplayName.Split('!');
                            // return the first part - which should be the path
                            return LobjMonikerArray[0];
                        }
                        else
                        {
                            // return full display name
                            return LstrDisplayName;
                        }
                    }
                    else
                    {
                        // no moniker value
                        return null;
                    }
                }
                else
                {
                    // something wrong - container is not valid
                    return null;
                }
            }
            else
            {
                // not embedded
                return null;
            }
        }
        catch (Exception ex)
        {
            Debug.Print(ex.ToString());
            return null; // failed
        }
    }
}

Pay close attention to the comments, because you will not find the OLE reference you need in the References window. You will have to browse to the path given and manually select it.

Now, to use it, I hook to the WorkbookActivate event, like this:

/// <summary>
/// STARTUP
/// </summary>
/// <param name="PobjSender"></param>
/// <param name="pObjEventArgs"></param>
private void ThisAddIn_Startup(object PobjSender, System.EventArgs pObjEventArgs)
{
    Application.WorkbookActivate += new Excel.AppEvents_WorkbookActivateEventHandler(Application_WorkbookActivate);
}

/// <summary>
/// Workbook Activate event fires when any workbook is activated.
/// </summary>
/// <param name="PobjWb"></param>
void Application_WorkbookActivate(Excel.Workbook PobjWb)
{
    // call the extension method created below
    if (PobjWb.IsEmbedded())
    {
        string LstrClass = PobjWb.EmbedClassName();
        string LstrPath = PobjWb.EmbedMoniker();
        MessageBox.Show("This workbook is embedded in a [" + LstrClass + "] " +
                        "with a path of: \n\n\t" + LstrPath);
    }
}

What you can see from the code above is that on WorkbookActivate, I check to see if the workbook is embedded and if it is, then I get the Class Name by calling my other Extension Method – EmbedClassName() – and then I get the path to the file it is embedded inside of (for example, the Word document path that contains the embedded Excel sheet) by calling yet another Extension Method – EmbedMoniker().

Key Binding in Word C# Projects

I normally advise against mixing VBA and C# in the same project. The reason is that debugging can become difficult and complicated. However, there are a few cases where the object model design built on top of legacy VBA constructs leaves you no other option but to mix a little VBA into your project.

Key Binding is one such example. Short of building your own Key Binder using Windows API calls, which has it’s own drawbacks upon performance, the next best thing is to build a callback model where the keystroke is captured via a VBA macro and the macro then makes a call back into your C# project.

In this example, we will be building upon my previous post on exposing methods using the RequestComAddInAutomationService callback. First, you need to create a Word Template (dotm) file, add a VBA project to it (press ALT+F11 to open the VB Editor), and then insert a new Module (from the Insert menu, click Module.) Here is the code you will need to add to the template:

Function GetAddin() As Object
On Error Resume Next

    Dim addIn As COMAddIn
Dim automationObject As Object
Set addIn = Application.COMAddIns(“dttWordKeyBindingPOC”)
Set automationObject = addIn.Object
Set GetAddin = automationObject
End Function

Public Sub KeyCode1()
On Error Resume Next
GetAddin.CallKey 1
End Sub

Public Sub KeyCode2()
On Error Resume Next
GetAddin.CallKey 2
End Sub

Public Sub KeyCode3()
On Error Resume Next
GetAddin.CallKey 3
End Sub

Public Sub KeyCode4()
On Error Resume Next
GetAddin.CallKey 4
End Sub

Public Sub KeyCode5()
On Error Resume Next
GetAddin.CallKey 5
End Sub

In the above code, we have a function called GetAddin that returns a reference to the C# DLL add-in we will build below. We then have 5 methods that simply return a number to the add-in when they are executed. Nothing else is needed at this point, simply save this to the install folder, or the DEBUG folder of your add-in as “KeyCodes.dotm.”

NOTE: We are not registering the key codes in the Word Template. Instead, we will be registering them in the add-in.

Now we need to create the Word add-in. Here we will generate a standard add-in, but as discussed above, expose it so that the VBA code above can find and connect to it.

First, we create an interface:

/// <summary>
/// INTERFACE - for the Key Code callback
/// </summary>
[ComVisible(true)]
public interface IKeys
{
    void CallKey(int i);
}

 

Next, we expose our class, attach the interface, and then register the keys (comments inline):

/// <summary>
/// ADDIN - use IKeys Intrface and make
/// COM Visible
/// </summary>
[ComVisible(true)]
[ClassInterface(ClassInterfaceType.None)]
public partial class ThisAddIn : IKeys
{
    Word.AddIn addinKeys;

    /// <summary>
    /// Method is required to allow the VBA code
    /// to hook to our class here and get the exposed
    /// interface method CallKey()
    /// </summary>
    /// <returns></returns>
    protected override object RequestComAddInAutomationService()
    {
        return this;
    }

    /// <summary>
    /// EXPOSED METHOD - VBA will call this when a
    /// macro from an assigned key is called
    /// </summary>
    /// <param name="i"></param>
    public void CallKey(int i)
    {
        switch (i)
        {
            case 1:
                MessageBox.Show("You pressed CTRL+ALT+D");
                break;
            case 2:
                MessageBox.Show("You pressed CTRL+ALT+Y");
                break;
        }
    }

    /// <summary>
    /// STARTUP - get the path to our VBA add-in, verify that
    /// it is valid and then hook up the keys
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void ThisAddIn_Startup(object sender, System.EventArgs e)
    {
        // load the KeysAddin
        //get the full location of the assembly
        string fullPath = AppDomain.CurrentDomain.BaseDirectory + "addins\\KeyCodes.dotm";
        // NOTE: There is no need to load the add-in
        // having the fuull path and verifying it is
        // all you need...
        if (!new FileInfo(fullPath).Exists)
            return; // we failed - do not hook keys
        // laod the template
        addinKeys = Application.AddIns.Add(fullPath);
        addinKeys.Installed = true;

        // wire up our keys
        object cContext = Application.CustomizationContext;
        Application.CustomizationContext = Application.Templates[fullPath];
        Application.KeyBindings.Add(Word.WdKeyCategory.wdKeyCategoryCommand, "KeyCode1",
                Application.BuildKeyCode(Word.WdKey.wdKeyControl, Word.WdKey.wdKeyAlt, Word.WdKey.wdKeyD));
        Application.KeyBindings.Add(Word.WdKeyCategory.wdKeyCategoryCommand, "KeyCode2",
                Application.BuildKeyCode(Word.WdKey.wdKeyControl, Word.WdKey.wdKeyAlt, Word.WdKey.wdKeyY));
        Application.CustomizationContext = cContext;
        // what happens here is the ADDIN is loaded first to 
        // expose the KeyCode functions in the addin
        // there are (5) in the adding KeyCode#
        // we can add more if needed, but this give an example
        // of the fact that those macros in the add-in are generic
        // and in no way are tied to the respective key code that
        // is assigned below.
        // NOTE: on unload of this we MUST unload the add-in

        // make sure the customization - keys - does not dirty the file
        // otherwise on close the user will get prompted to save this 
        // template - this line avoid that...
        Application.Documents[addinKeys.Name].Saved = true; 
    }

    /// <summary>
    /// SHUTDOWN - clean up
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void ThisAddIn_Shutdown(object sender, System.EventArgs e)
    {
        // remove the add-in
        addinKeys.Installed = false;
        addinKeys.Delete();
    }

 

And that is all there is to it. In the above example, CTRL+ALT+D and CTRL+ALT+Y are now bound. When you launch Word, the C# add-in will load the Word template, from the same folder a the DLL and then register each KeyCode. In the template we created above you can register up to 5 codes, but you can easily modify that to add more.

How to Expose Methods in your VSTO Add-in

I have had this question a number of times and surprised myself when I did not see it in my blog. So here goes.

There are times when (like my Master Add-in Entry), you need to expose a set of methods or properties in your add-in so that other add-ins or applications are able to access them. Here is how it is done:

1) You create an interface and a class that uses that interface, like this:

[ComVisibleAttribute(true)]
public interface IExposedMethods
{
    void LoadDocument(string path);
}

[ComVisibleAttribute(true)]
[ClassInterface(ClassInterfaceType.None)]
public class ExposedClass : IExposedMethods
{
    public void LoadDocument(string path)
    {
        Globals.ThisAddIn.Application.Workbooks.Open(path);
    }
}

 

2) Next, you need to add the following code to your ThisAddin.cs:

private ExposedClass _exposedInstance;
protected override object RequestComAddInAutomationService()
{
    if (_exposedInstance == null)
        _exposedInstance = new ExposedClass();

    return _exposedInstance;
}

3) And finally, to call it and access this from another application instance, you do this:

Excel.Application xlApp = new Excel.Application();
xlApp.Visible = true;
object addinName = "MyAddinName";
Office.COMAddIn addIn = xlApp.COMAddIns.Item(ref addinName);
addIn.Object.LoadDocument(@"c:\path\test.xlsm");

It is actually quite easy to implement. And the beauty here is that the ExposedClass you created can have all sorts of methods and properties that are exposed. And because the _exposedInstance is global to ThisAddIn (via Globals.ThisAddin._exposedInstance), you can access it anywhere. So it is a perfect way for one add-in to contact another or for a parent Windows Form application to reach into the Add-in and set properties and call methods and have it open documents and such for you.

VSTO and COM/OLE…

If you have been doing much work in VSTO and especially around Excel and embeddings, you may have been bit by this bug. Does this error look familiar:

clip_image002

“The program used to create this object is Excel. That program is either not installed on your computer or it is not responding. To edit this object, install Excel or ensure that any dialog boxes in Excel are closed.”

This error can be caused by the following:

  • A .NET3.5 Add-in or a .NET 4.0 Add-in
  • You have attached to the WorkbookOpen and/or WorkbookActivate events.
  • You are trying to edit/open/double-click on an embedded Excel instance inside a Word or PowerPoint document.

There have been several reports of this problem on the Microsoft MSDN site:

There are also a number of KB articles that document the problem and attribute it to specific programs:

The problem is specifically documented here:

Simple Solution

The basic simple answer is to place a Marshal.ComReleaseObject(Wb) at the end (or better, in the Finally block) of your event handlers. This will properly allow Word and excel to handle the OLE communication by not having VSTO hang on to an instance handle of the workbook, therefore causing the error.

And this is not carte blanche to start placing ComReleaseObject() all over your code. I have found VERY VERY few limited cases where using ComReleaseObject() in an add-in necessary. And this is one of them. Hot smile

Orphan Issue

It is not a panacea, either. Confused smile While it resolves the issues of OLE initialization and allows you to edit your Excel embedding in Word (as one example), it does not prevent one other scenario that I like to call “Orphaned Excel.” In this scenario, you edit your embedded Excel instance in another Window (usually via a right-click / Ole Object / Open). If you leave Excel open, return to Word and close the document, Excel should close. But in the VSTO COM/OLE scenario it may not – it will remain open with the embedded workbook still editable. However, it is orphaned an no longer associated with its container. Any edits will be lost.

That is where a solution I created for Excel/Word OLE interaction comes in here:

This add-in is very well commented and explains the following:

  • When a workbook is opened, it looks to see if it is embedded.
  • If it is, it connects to the running instance of Word, and gets a reference to the parent document.
  • A timer in the add-in will then continually check the status of the document
  • If the parent document is still opened, nothing happens. However, if Word is existed or the parent document is closed, the child embedding is forced closed.

IMPORTANT NOTE

However, and this is important note. For everything you do, you are in the sandbox with other kids. Sad smile Your add-in is loading in the same AppDomain as everybody else. If there is another VSTO 3.5/4.0 Add-in loaded in Excel and that add-in is not doing any of the above – well, your still going to have problems.

That is what makes this issue so vexing. Steaming mad You can play by all the rules, but you cannot prevent other kids from throwing sand. This is why I see some customers going to the extreme to manage out (disable) all other COM add-ins when they load their solution. But this does not work for HKEY_LOCAL_MACHINE Loaded add-ins.

There are few options and most involve a lot of code. I say, TEST. If your are in an Enterprise environment, test all your VSTO add-ins together, find code owners and get everyone on the same page. If it is a vendor add-in causing the problem, point them here. Smile

Creating a Loader Add-in (Master Add-in)

I have now helped about half a dozen customers over the last 3 years to perform this very same task. In each case the scenario is the same:

  • They have documents that are very specific to their system
  • They want to create an add-in to assist with the management of these documents.
  • They only want the add-in to load when one of their document is opened.
  • BONUS: They also want to remove other add-ins
  • BONUS: They would like a pristine instance of Word or Excel.

There are several methods to accomplish each of these tasks. But the one I find is easiest to maintain is what I call the “Master Add-in” approach.

In the “Master Add-in” you essentially attach one event: DocumentOpen or WorkbookOpen. You place code in the event to detect whether it is a document you care about. This can be done in several ways:

  • You can look at the path where the file came from.
  • You can look at the name of the file, if there is a specific naming convention you follow.
  • Or, you can tag he document with Document Properties or Custom Xml Parts.

Once you have identified that it is a document you care about you follow this basic process:

  • Get the path to the file being open and store it.
  • Close the workbook or document being passed into the Open event.
  • Create a new Instance of Word or Excel.
  • Iterate through the COM Add-ins collection and the Add-ins collection and disable everything you do not want running. This includes disabling the Master Add-in as you do not want it running for the next parts.
  • Locate your COM Add-in in the collection and set Connect = true.
  • Open the document or workbook from the application object.

You will now have two instances of Excel or Word open. One that the user was originally working with which has the Master Add-in loaded and then the new one that is customized with your specific add-in and only your add-in.

This is useful for when you have multiple versions of your system and you can update the Master to recognize which version to launch.

Getting this to work just right is sometimes a challenge and you have to be careful not to disable all the add-ins for all instances. I have a few customer examples that I have built and will work on cleaning them up. I will combine the best parts and will write a new entry in the future which will walk you through creating one.

Turning off Document Recovery

I have had this request a couple of times. At times you may find a need to turn off the Document Recovery for a specific document. You may not want it to appear in the Document Recovery Pane if for some reason Word fails while a user is editing a specific type of document.

To turn off Document Recovery, you essentially need to find your document in the Registry after it is opened and remove it. The following Code Sample does this with a Extension method:

 
/// <summary>
/// EXTENSION METHOD CLASS
/// To make it easier to turn off resiliency on a document by document basis
/// </summary>
public static class WordDocumentExtensionMethods
{
    /// <summary>
    /// WORD DOCUMENT EXTENSION METHOD
    /// Turns off resiliency for the current docuent by deleting the
    /// registry key for it.
    /// </summary>
    /// <param name="doc"></param>
    public static void DisableResiliency(this Word.Document doc)
    {
        DeleteResiliencyKey(doc.FullName);
    }

    /// <summary>
    /// Private method that actually reads the registry, locates a specific
    /// resiliency key and then deletes it
    /// </summary>
    /// <param name="path"></param>
    private static void DeleteResiliencyKey(string path)
    {
        // This is the base path for the resiliency key in Word 2010
        string basePath = @"Software\Microsoft\Office\14.0\Word\Resiliency\DocumentRecovery";
        RegistryKey key = Registry.CurrentUser.OpenSubKey(basePath, true);
        if (key == null)
            return; // no key is present at this time
        // now loop through all the subkeys
        foreach (string k in key.GetSubKeyNames())
        {
            // now look in each sub-key and get the value by the same name
            RegistryKey subKey = Registry.CurrentUser.OpenSubKey(basePath + "\\" + k);
            // the values are binary, so we need to convert to a string
            byte[] o = (byte[])subKey.GetValue(k);
            System.Text.Encoding encoding = new System.Text.UnicodeEncoding();
            string keyVal = encoding.GetString(o);
            // now we only need to see if the path is in the string and if it
            // is, we have found the resiliency key that Word created for this
            // file automatically. Delete it...
            if (keyVal.Contains(path))
                key.DeleteSubKey(k);
        }
    }
}
To turn off the Document Recovery for a specific document, you can attach to the DocumenOpen event and then determine if the document is one that you want to turn off Recovery for. You can then execute the following line of code:
 
/// <summary>
/// DOCUMENT OPEN EVENT
/// </summary>
/// <param name="Doc"></param>
void Application_DocumentOpen(Word.Document Doc)
{
    // ... determine if the document is a member of the solution
    // and then just turn it off...
    Doc.DisableResiliency(); // call extension method
}

Outlook Contact Category Correction Tool

When it rains it pours Outlook issues. A few weeks ago I was working with a customer that was having an issue where a small handful of users were loosing the custom categories they applied to their categories. The problem – based on my experiences – seems to be caused by having iCloud sync for contacts turned on in the iPad, but also pulling the contacts from your Exchange account. At some point I think a restore happens or some automatic sync correction ends up wiping the categories away also updating all the contacts in Exchange, therefore Outlook…

In the end, it is ALWAYS good to have a backup of you contacts. and as it turns out this customer did – albeit they were 6 months old. The problem was that the “decategoried” contacts they had in Outlook were fairly updated with notes, changed e-mail and phone numbers, versus the properly categorized backup’s that were 6 months old. Enter the tool…

 

image

While on-site I developed the tool to resolve this problem for their users. It might be useful for others, so that is why I posted it on CodePlex:

https://olcontactcorrecttool.codeplex.com/

But, I think it is also a good example/sample of how to access Outlook from an external application, iterate through a folder and compare items in Outlook. So the source code is available too.

Add-in to Check for Double Resource Booking

I was working with a customer today that had an issue where one some occasions they were double-booking, triple booking or even booking up to 30 conference rooms for the same meeting. They were looking for a way to prevent this from happening because once booked and auto-accepted by the room, they were unable to “cancel” without an administrator. So, I wrote them some code that essentially does a check to make sure if they have more than one resource/room scheduled for a meeting it will prompt them to ask them if they are sure they want to do this. The trick – in this sample – is to put all the resources you care about into a text file called “MeetingRooms.txt” in the installation folder. It will read it into memory and then check to make sure no more than one is scheduled or it will stop the user from making the mistake.

You will need to start off creating a Visual Studio 2010 / Outlook 2010 / .NET 4.0 Add-in…Here is the code:

public partial class ThisAddIn
{
private void ThisAddIn_Startup(object sender, System.EventArgs e)
{
// attach to the Item Send Event
Application.ItemSend += new Outlook.ApplicationEvents_11_ItemSendEventHandler(Application_ItemSend);
}

void Application_ItemSend(object Item, ref bool Cancel)
{
// try to cast to a Meeting Item
Outlook.MeetingItem meet = Item as Outlook.MeetingItem;
if (meet != null) // is it a meeting item - not null
{
// load the list of conference rooms from the install folder
List<string> rooms = new List<string>();
string path = System.Reflection.Assembly.GetExecutingAssembly().Location + "\\MeeingRooms.txt";
StreamReader sr = new StreamReader(path);
while (!sr.EndOfStream)
{
// save as lowercase -- we will compare this way
rooms.Add(sr.ReadLine().ToLower());
}

int cnt = 0;
// no loop through all the recipients
foreach (Outlook.Recipient r in meet.Recipients)
{
// if the recipient is in the List of rooms we count it
if (rooms.Contains(r.Name.ToLower()))
cnt++;

// now if we get more than one -- we have a problem
if (cnt > 1)
{
// notify the user they are about to send a meeting request
// that will book more than one room
DialogResult result = MessageBox.Show("You have more than one conference room on " +
"this meeting request. \n\n" +
"Are you sure you want to continue?", "Resource Conflict",
MessageBoxButtons.YesNoCancel, MessageBoxIcon.Hand);
// if the user hits cancel or no, we will
// cancel the send
if (result != DialogResult.Yes)
Cancel = true;
else
// otherwise, allow it
Cancel = false;

// exit for loop
break;
}
else
{
Cancel = false;
}
}
}
else
{
Cancel = false;
}
}

You need the following using statements:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Xml.Linq;
using Outlook = Microsoft.Office.Interop.Outlook;
using Office = Microsoft.Office.Core;
using System.Windows.Forms;
using System.IO;

Iterating through Names list Slow

When you try to iterate through many of the collections provided by Excel through VSTO, you may experience a significant performance hit, depending on your task.

In one recent case, a customer was iterating through the Names collection of an Excel Workbook. The workbook in question had 10,000 Named Range entries. They were looping through the collection and accessing the Name and RefersTo properties of each Name in the list. Because each call (property request or method) goes back through the VSTO and COM Wrapper layer, you may encounter a tremendous amount overhead.

In this particular case, they needed to iterate through the Names collection each time they performed a right-click and the result was a 14 second hang. Now imagine a scenario when you need to perform this every time the selection changes.

So, the best option I found, to improve performance is to PInvoke and go around VSTO and access the object directly and build a Dictionary of these two properties (Name/RefersTo). In the sample provided below, I was able trim 14 seconds to 0.6 seconds for 10,000 Named Ranges.

Here is the code which does this by implementing a Workbook Extension Method:

public static Dictionary<string,string> GetNamesAsDictionary(this Excel.Workbook wb)
{
try
{
// our return collection
Dictionary<string, string> myNames = new Dictionary<string, string>();
// use PInvoke to get the Names collection from the Workbook
object oNames = wb.GetType().InvokeMember("Names", BindingFlags.GetProperty, null, wb, null);
// get the total count of names
int iCnt = int.Parse(oNames.GetType().InvokeMember("Count", BindingFlags.GetProperty, null, oNames, null).ToString());
// loop through all the names by index...
for (int i = 1; i <= iCnt; i++)
{
object[] oParams = { i }; // parameter call for PInvoke
// get the specific name object at index (i)
object oName = oNames.GetType().InvokeMember("Item", BindingFlags.InvokeMethod, null, oNames, oParams);
// grab the name and range address...
string rangeName = oName.GetType().InvokeMember("Name", BindingFlags.GetProperty, null, oName, null).ToString();
string rangeAddress = oName.GetType().InvokeMember("RefersTo", BindingFlags.GetProperty, null, oName, null).ToString();
// add to our collection
myNames.Add(rangeName, rangeAddress);
}

// done - return
return myNames;
}
catch
{
// something bad happened - return null
return null;
}
}

To use this, you simple access it off the Workbook object, like this:

Dictionary<string, string> myNames = xlApp.ActiveWorkbook.GetNamesAsDictionary();