Archive for the ‘CodeCritic’ Category.

Double Clicking Through a List

I have a cell with data validation. It's set as an in-cell dropdown list and contains two items: Yes and No. I want to make it so that I can double click on that cell to toggle between yes and no. But wait, that's not good enough. What about longer lists? Yes, I want something that will iterate through all the items on a data validation list by double clicking. Dare I dream.

Here's my first go at it. I figure it's going to need some work, like what happens when the user double clicks on something that's not a range, but it's a start. I've basically handled two types of lists: the kind where you hard code values separated by commas (international issue here?), and the the range reference. Oh, and I need to test named ranges, but I think they'll work.

Private Sub Worksheet_BeforeDoubleClick(ByVal Target As Range, Cancel As Boolean)
   
    Dim dv As Validation
    Dim sDv1 As String
    Dim vaList As Variant
    Dim i As Long
    Dim vOldValue As Variant
   
    On Error Resume Next
        Set dv = Target.Validation
        sDv1 = dv.Formula1
    On Error GoTo 0
   
    If Len(sDv1)> 0 Then 'only if the cell has dv
        If dv.Type = xlValidateList Then
            Cancel = True 'don't do the default action
            vOldValue = Target.Value
            vaList = GetValidList(dv.Formula1) 'return single dim array
            For i = LBound(vaList) To UBound(vaList)
                If vaList(i) = Target.Value Then
                    If i = UBound(vaList) Then
                        Target.Value = vaList(LBound(vaList))
                    Else
                        Target.Value = vaList(i + 1)
                    End If
                    Exit For
                End If
            Next i
            If Target.Value = vOldValue Then 'if cell was blank
                Target.Value = vaList(LBound(vaList)) 'go to first item
            End If
        End If
    End If
   
End Sub
 
Private Function GetValidList(sForm As String) As Variant
   
    Dim vArr As Variant
    Dim vaReturn As Variant
    Dim i As Long
    Dim bIsRange As Boolean
   
    On Error Resume Next
        vArr = Evaluate(sForm) 'for range reference
    On Error GoTo 0
   
    If IsError(vArr) Then 'for csv list
        vArr = Split(sForm, ",")
        bIsRange = False
    Else
        bIsRange = True
    End If
 
    If bIsRange Then 'conver to single dim array
        ReDim vaReturn(0 To UBound(vArr, 1) - 1)
        For i = LBound(vArr, 1) To UBound(vArr, 1)
            vaReturn(i - 1) = vArr(i, 1)
        Next i
    Else
        vaReturn = vArr
    End If
   
    GetValidList = vaReturn
   
End Function

This code is in the sheet's class module (Sheet1 in my case). Test it out if you like. Let me know if you see any errors or better ways.

P.S. Why is Target.Validation always something (that is, Not Nothing) even if the cell doesn't have validation?

Sent Items Cleanup

A couple weeks ago, I had more than 1,200 emails in my Inbox. Now I have none. I've been using, and tweaking, my Outlook Tags macro to clean and maintain my mail. In addition to handling new mail in a timely manner, I processed a couple hundred old emails every day. I really gained an appreciation for writing good subjects. There's one person in my office who writes terrible subjects. I had to open almost every email she sent me to see what was in it before I could classify it. Moving those emails everyday took about 10 minutes.

I moved the last of them on Saturday and then set my sights on the Sent Items folder. Saturday morning I had 1,409 items in that folder. Cleaning the inbox felt like a real accomplish. Seeing the number of items in Sent Items made me feel like Nando Parrado when he reached the first summit only to find Andean peaks as far as the eye could see. I took care of a good chunk of it with this macro:

Sub CleanSent()
   
    Dim fld As MAPIFolder
    Dim oSearch As Search
    Dim i As Long
    Dim sTopic As String
   
    Set fld = Application.GetNamespace("MAPI").GetDefaultFolder(olFolderSentMail)
   
    For i = fld.Items.Count To 1 Step -1
        Debug.Print fld.Items(i).Subject
        gbASComplete = False
        sTopic = Replace(fld.Items(i).ConversationTopic, "'", "''")
        Set oSearch = Application.AdvancedSearch("Inbox", _
            """urn:schemas:httpmail:thread-topic"" = '" & sTopic & "'", True)
        Do
            DoEvents
        Loop Until gbASComplete
        If oSearch.Results.Count>= 1 Then
            fld.Items(i).Move oSearch.Results.Item(1).Parent
            Debug.Print "Moved"
        Else
            Debug.Print "Not Moved"
        End If
    Next i
       
End Sub

For which I needed a global variable (gbASComplete) and this event code:

Private Sub Application_AdvancedSearchComplete(ByVal SearchObject As Search)
    gbASComplete = True
End Sub

The AdvancedSearch works asynchronously, meaning that the rest of the macro continues to run before the search is complete. Thus the need for the event. It prevents the macro execution from continuing until the search is complete.

To get the Filter argument of the AdvancedSearch method, I went to the Outlook UI, specifically View > Arrange By > Current View > Customize Current View. I then clicked on the Filter button and set up a filter the way I wanted. Finally, I switched to the SQL tab to get the syntax.

If the search returned any items, I moved the sent item to the same folder as the first item in the search. It took almost two hours for this macro to execute and it knocked the item count down to 666. Next I sorted on subject and deleted all of the email I didn't want to save, like those five emails from a Thursday last summer when I needed a sub for my golf league. That got me down to 257, which I disposed of in about 20 minutes using the Outlook Tags userform.

Now I have a clean inbox, a clean Sent Items folder, and a plan for keeping them that way. What I also have is some broken code. To keep the Sent Items folder clean, I checked the last five emails in Sent Items for related messages. Now that the count is zero, the code fails. I changed that code to look like this:

Private Sub MoveSentMail(sTopic As String, fldTag As MAPIFolder)
   
    Dim mi As Object
    Dim i As Long
    Dim fldr As MAPIFolder
    Dim lInclude As Long
   
    Set fldr = Application.GetNamespace("MAPI").GetDefaultFolder(olFolderSentMail)
    'lInclude = Application.Min(fldr.Items.Count, 5)
       
    If fldr.Items.Count <5 Then
        lInclude = fldr.Items.Count - 1
    Else
        lInclude = 5
    End If
   
    If lInclude> 0 Then
        For i = fldr.Items.Count To fldr.Items.Count - lInclude Step -1
            Set mi = fldr.Items(i)
            If mi.ConversationTopic = sTopic Then
                mi.Move fldTag
            End If
        Next i
    End If
   
End Sub

I left that commented line in there for your amusement. Guess which application I'm used to programming in.

I didn't get a chance to test this in Outlook 2007 like I'd hoped, but I plan to do that soon. Then I suppose I'll have to port this over to a COM Add-in. I don't really know how to write distributable code for Outlook. There doesn't appear to be non-COM Add-ins, at least that I can see. If anyone has experience with that, leave a comment.

More Outlook Tags

I've been using my Outlook Tags system this week and it's been very nice. I've made some changes over the week and I thought both people who were interested in this might be interested in the changes as well.

I changed the Tags textbox into a combobox as, I believe, Ken Puls suggested. I don't have to type out the whole folder name because it autofills and it helps to prevent accidental misspellings. The downside is that I need a list of folders before I show the form, which is why I didn't implement it to begin with. In its original form, I only recursed through the folder tree when I clicked the Save button. If I canceled, those cycles weren't wasted. But it was worth a try, and as it turns out, it takes less than a second to load all the folder names into a collection and sort the collection.

Sub FillFolders(fldStart As MAPIFolder)
   
    Dim fld As MAPIFolder
   
    Set gcolFolders = New Collection
   
    If fldStart Is Nothing Then
        Set fldStart = Application.GetNamespace("MAPI").GetDefaultFolder(olFolderInbox)
    End If
   
    RecurseFolders fldStart
   
    SortFolders gcolFolders
   
End Sub
 
Sub RecurseFolders(fldStart As MAPIFolder)
   
    Dim fld As MAPIFolder
   
    For Each fld In fldStart.Folders
        On Error Resume Next
            gcolFolders.Add fld, fld.FolderPath
        On Error GoTo 0
        If fld.Folders.Count> 0 Then
            RecurseFolders fld
        End If
    Next fld
   
End Sub
 
Sub SortFolders(col As Collection)
   
    Dim i As Long
    Dim j As Long
    Dim fTemp As MAPIFolder
   
    For i = 1 To col.Count - 1
        For j = i + 1 To col.Count
            If col(i)> col(j) Then
                'store the lesser item
                Set fTemp = col(j)
                'remove the lesser item
                col.Remove j
                're-add the lesser item before the
                'greater Item
                col.Add fTemp, fTemp.FolderPath, i
            End If
        Next j
    Next i
   
End Sub

Another problem I encountered was that while my Inbox was being managed, my SentItems was as out of control as ever. I needed something to handle my replies and forwards as well as new mail. For replies and forwards, my first thought was to move the email first, then reply. For this, I needed to change the focus of Outlook to the folder to which I moved the email. I'm sure that's possible, but I couldn't find a good way to do that. Changing the CurrentFolder property didn't work or didn't work the way I wanted, I don't remember. I decided a better approach was to move related emails in the SentItems folder at the same time I move the Inbox item. To wit:

Private Sub MoveSentMail(sTopic As String, fldTag As MAPIFolder)
   
    Dim mi As Object
    Dim i As Long
    Dim fldr As MAPIFolder
   
    Set fldr = Application.GetNamespace("MAPI").GetDefaultFolder(olFolderSentMail)
   
    For i = fldr.Items.Count To fldr.Items.Count - 5 Step -1
        Set mi = fldr.Items(i)
        If mi.ConversationTopic = sTopic Then
            mi.Move fldTag
        End If
    Next i
   
End Sub

Note that I only look through the last five sent emails. It turns out that looping through MailItems in a MAPIFolder is time consuming. Even limiting it to the last 20 items was a noticeable delay. Five works for me because nothing sits in my inbox for very long. It may not be a generally appealing feature though.

If I Tools > Find > Related Messages in the UI, and switch to the Advanced tab, I can see that Outlook uses the Conversation field to determine the match. There is a ConversationIndex and a ConversationTopic property in the object model. ConversationIndex sounds better, but I didn't have much luck using it. ConversationTopic, on the other hand, seems to work nicely finding the related message.

My workflow is something like this: I receive an email, read it, respond to it, then move it to a folder. Moving items from SentItems like I'm doing works well, but I'm not sure it will work well for people who go through their email differently. I can't include more items in SentItems because it simply takes too long.

For new email I send, I created a new sub that does pretty much the same thing as the old sub.

Public Sub TagSentMail()
   
    Dim mi As MailItem
    Dim ufTag As UTags
    Dim sTag As String
    Dim lFlagColor As Long
    Dim fldTag As MAPIFolder
   
    On Error Resume Next
        Set mi = Application.ActiveInspector.CurrentItem
    On Error GoTo 0
   
    If Not mi Is Nothing Then
           
        InitGlobals
       
        Set ufTag = New UTags
       
        ufTag.Subject = mi.Subject
        ufTag.Show
       
        'get info back from userform
        If Not ufTag.UserCancel Then
            If ufTag.Flag Then lFlagColor = ufTag.FlagColor
                   
            mi.FlagStatus = olFlagMarked
            mi.FlagIcon = lFlagColor
                               
            'move item to folder
            If ufTag.xFolder Is Nothing Then
                sTag = ufTag.xTag
                Set fldTag = gfldStart.Folders.Add(StrConv(sTag, vbProperCase))
            Else
                Set fldTag = ufTag.xFolder
            End If
           
            Set mi.SaveSentMessageFolder = fldTag
             
        End If
   
    End If
End Sub

Retrieving mi is different because I'm calling this from the Inspector window that contains the new email, rather than the Inspector window that contains a selected message in a folder. Also, I'm not moving this email, I'm setting its SaveSentMessageFolder property to save it to the correct folder.

If you compare this to my sub that moves a message, you'll notice some other differences too. I don't have to recurse through the folders any more because I do that before I show the form (in order to fill the combobox). Instead of getting a string tag back from the userform, I can get one of two things. I can get a MAPIFolder object which means the user selected something from the list or I can get a string tag which means the user typed a new value into the combobox. From the code behind the Save button:

If Me.cbxTag.MatchFound Then
    Set mobjxFolder = gcolFolders(Me.cbxTag.ListIndex + 1)
Else
    msXTag = Me.cbxTag.Text
End If

Then if the property that holds the MAPIFolder is Nothing, I create a folder using the string.

Download OutlookTags2.zip, which consists of a .bas file and a .frm file.

Outlook Tags

I've recently started using del.icio.us to manage my bookmarks. I don't have any interest in social bookmarking, but I like that I can get to my bookmarks from any computer and I love the tagging system. I set my home page to my frequent category and I can easily access other categories from there. There's a shortcut key to add a site and assign a tag. If the tag doesn't exist, it's created for you. If you delete all the bookmarks in a tag, it's removed.

I currently have 434 items in my inbox, down from over 1,200 a couple of weeks ago. I've been whittling away at them, getting them into folders. The end-game is keep my inbox cleaner - consisting of only those email that need my attention. The process to move items to folders is painful: Right click, Move to folder, click on folder, OK. And that's only if the folder exists.

I need a delicious-like way to move email so I wrote the below code.

The userform takes the currently selected email item (it doesn't work with other items - yet). It shows the subject so I can make sure I had the correct email selected. If the email is unread, it defaults to flagging the email as red. I'll be using flagged email to determine what needs my attention rather than unread mail. The cursor starts in the Tag textbox so that a tag can be entered quickly.

If I type in an existing folder, the email is moved there. If I type in a new folder, the folder is created under Inbox and the email is moved there.

Limitations:

  • I can't search for a specific subfolder. That is, if I have two subfolders (Products: Datasheets and Marketing: Datasheets), by typing in 'Datasheets', it will use the first one it finds.
  • All created folders end up under Inbox and I have to manually move them if I want.
  • Misspellings create new folders.
  • Empty folders are not automatically deleted.

Most of these limitations are by design - I wanted to keep it simple and flexible.

This is definitely a first draft, so there's probably tons of bugs in it. Let me know what you think of the concept, the implementation, and particularly the recursive function. It's been a while since I've done recursion and I'd like to know if there's a better way. And if you're itching to comment 'You can already do that in Outlook', then please be specific, because I prefer built-in solutions over my crappy VBA any day. If you haven't figured it out yet, this post has nothing to do with Excel - all the code goes into Outlook.

You can download OutlookTags.zip and view the code below:

'Module: UTags
Option Explicit
 
Private msSubject  As String
Private mbUnread As Boolean
Private mbFlag As Boolean
Private mlFlagColor As Long
Private mbUserCancel As Boolean
Private msXTag As String
 
Public Property Get Subject() As String
    Subject = msSubject
End Property
 
Public Property Let Subject(sSubject As String)
    msSubject = sSubject
End Property
 
Public Property Get Unread() As Boolean
    Unread = mbUnread
End Property
 
Public Property Let Unread(ByVal bUnread As Boolean)
    mbUnread = bUnread
End Property
 
Public Property Get Flag() As Boolean
    Flag = mbFlag
End Property
 
Public Property Let Flag(ByVal bFlag As Boolean)
    mbFlag = bFlag
End Property
 
Public Property Get FlagColor() As Long
    FlagColor = mlFlagColor
End Property
 
Private Sub cmdCancel_Click()
   
    mbUserCancel = True
    Me.Hide
   
End Sub
 
Private Sub cmdMove_Click()
   
    mbUserCancel = False
    mbFlag = Me.chkFlag.Value
    mlFlagColor = Me.cbxFlag.ListIndex
    msXTag = Me.tbxTag.Text
    Me.Hide
   
End Sub
 
Private Sub UserForm_Activate()
   
    Dim vaColors As Variant
   
    vaColors = Array("None", "Purple", "Orange", "Green", "Yellow", "Blue", "Red")
    Me.cbxFlag.List = vaColors
   
    Me.tbxSubject.Text = msSubject
    If mbUnread Then
        Me.chkFlag.Value = True
        Me.cbxFlag.Value = "Red"
    End If
   
End Sub
 
Public Property Get UserCancel() As Boolean
 
    UserCancel = mbUserCancel
 
End Property
 
Public Property Get xTag() As String
 
    xTag = msXTag
 
End Property
 
'Module: MEntryPoints
Option Explicit
 
Sub ShowTagForm()
   
    Dim ufTag As UTags
    Dim mi As MailItem
    Dim sTag As String
    Dim lFlagColor As Long
    Dim fldTag As MAPIFolder
   
    On Error Resume Next
        Set mi = Application.ActiveExplorer.Selection.Item(1)
    On Error GoTo 0
   
    If Not mi Is Nothing Then
       
        Set ufTag = New UTags
       
        'pass info to userform
        ufTag.Subject = mi.Subject
        ufTag.Unread = mi.Unread
       
        'show userform
        ufTag.Show
       
        'get info back from userform
        If Not ufTag.UserCancel Then
            sTag = ufTag.xTag
            If ufTag.Flag Then lFlagColor = ufTag.FlagColor
                   
            mi.FlagStatus = olFlagMarked
            mi.FlagIcon = lFlagColor
                               
            'move item to folder
            With Application.GetNamespace("MAPI")
                Set fldTag = GetOrCreateFldr(.GetDefaultFolder(olFolderInbox), sTag)
                mi.Move fldTag
            End With
             
        End If
       
    Else
        MsgBox "No Email Selected"
    End If
   
    Set ufTag = Nothing
   
End Sub
 
Private Function GetOrCreateFldr(fMain As MAPIFolder, sTag As String) As MAPIFolder
   
    Dim fSub As MAPIFolder
    Dim fEnd As MAPIFolder
   
    Set fEnd = CheckSubs(fMain, sTag)
   
    If Not fEnd Is Nothing Then
        Set GetOrCreateFldr = fEnd
    Else
        Set GetOrCreateFldr = fMain.Folders.Add(StrConv(sTag, vbProperCase))
    End If
   
End Function
 
Private Function CheckSubs(fSub As MAPIFolder, sTag As String) As MAPIFolder
   
    Dim fldr As MAPIFolder
    Dim fEnd As MAPIFolder
   
    For Each fldr In fSub.Folders
        If UCase(fldr.Name) = UCase(sTag) Then
            Set CheckSubs = fldr
            Exit Function
        Else
            If fldr.Folders.Count> 0 Then
                Set fEnd = CheckSubs(fldr, sTag)
                If Not fEnd Is Nothing Then
                    Set CheckSubs = fEnd
                    Exit Function
                End If
            End If
        End If
    Next fldr
   
End Function

Creating Wildcards

I need to save the names of one or more files in the custom document properties of a workbook. I don't want to create a separate property for every filename. I thought about saving a comma-delimited string, then parsing it out. It would look like:

file1name,file2name

Another idea I had was just save one string that could stand for both:

file?name

I need to convert a number of strings into one string with the appropriate wildcards. I came up with the code below, but it has some shortcomings. It replaces differing characters in the same position with a question mark, and converts strings of three question marks or more into an asterisk. That means for wildcards("consistent","inconsistent"), it returns *s* when I would prefer it return *consistent.

Any suggestions on making it better?

Function Wildcards(ParamArray vaText() As Variant) As String
   
    Dim i As Long, j As Long
    Dim sShort As String, sLong As String
    Dim sTemp As String
   
    Const sQUES As String = "?"
    Const sASTR As String = "*"
       
    'If only one string, then return it
    If LBound(vaText) = UBound(vaText) Then
        Wildcards = vaText(LBound(vaText))
    Else
        sShort = vaText(LBound(vaText))
       
        'Store the longest and shortest strings
        For i = LBound(vaText) To UBound(vaText)
            If Len(vaText(i)) <Len(sShort) Then
                sShort = vaText(i)
            End If
            If Len(vaText(i))> Len(sLong) Then
                sLong = vaText(i)
            End If
        Next i
       
        sTemp = sShort
       
        'replace differing chars with ?
        For i = LBound(vaText) To UBound(vaText)
            If vaText(i) <> sShort Then
                For j = 1 To Len(sShort)
                    If Mid(vaText(i), j, 1) <> Mid(sTemp, j, 1) Then
                        sTemp = Left(sTemp, j - 1) & sQUES & Mid(sTemp, j + 1, Len(sTemp))
                    End If
                Next j
            End If
        Next i
       
        'pad ?s to the end of the longest string
        sTemp = sTemp & String(Len(sLong) - Len(sShort), sQUES)
       
        'replace three or more ?s with a *
        If Len(sLong)>= 3 Then
            For i = Len(sLong) To 3 Step -1
                sTemp = Replace(sTemp, String(i, sQUES), sASTR)
            Next i
        End If
               
        Wildcards = sTemp
    End If
   
End Function

Keyless Entry

JWalk wrote about a Keyless Entry Hack. "That shouldn't be too hard to duplicate", I thought. I get 3,133, but I have four duplicate numbers. Here's part of my immediate window.

immediate window output

How do I get rid of those duplicates?

Sub MakeKeyCombo()
   
    Dim i As Long, j As Long, k As Long, l As Long, m As Long, n As Long
    Dim sResult As String, sFinal As String
    Dim sCurrent As String
   
    For i = 9 To 1 Step -2
        For j = 9 To 1 Step -2
            For k = 9 To 1 Step -2
                For l = 9 To 1 Step -2
                    For m = 9 To 1 Step -2
                        sCurrent = i & j & k & l & m
                        If InStr(1, sResult, sCurrent) = 0 Then
                            For n = 4 To 0 Step -1
                                If Right$(sResult, n) = Left$(sCurrent, n) Then
                                    sResult = sResult & Right$(sCurrent, 5 - n)
                                    Exit For
                                End If
                            Next n
                        End If
                    Next m
                Next l
            Next k
        Next j
    Next i
   
    Debug.Print sResult
    Debug.Print Len(sResult)
   
    TestResult sResult
   
End Sub
 
Sub TestResult(sResult As String)
 
    Dim i As Long, j As Long, k As Long, l As Long, m As Long
    Dim sCurr As String
   
    For i = 1 To 9 Step 2
        For j = 1 To 9 Step 2
            For k = 1 To 9 Step 2
                For l = 1 To 9 Step 2
                    For m = 1 To 9 Step 2
                        sCurr = i & j & k & l & m
                        If InStr(1, sResult, sCurr) = 0 Then
                            Debug.Print sCurr & " : Missing"
                        ElseIf Len(sResult) - Len(Replace(sResult, sCurr, ""))> 5 Then
                            Debug.Print sCurr & " : Duplicate"
                        End If
                    Next m
                Next l
            Next k
        Next j
    Next i
   
End Sub