Author Archive

Checking Lunches Against Conditions

Lunch Pairings Classes
Populating Lunch Pairings

My method for creating proposed lunches is to add three random contacts to make a lunch, then see if that lunch meets the conditions. If it does, I add it to the lunches collection class. If not, I try another combination. The portion of the FillMonth method that does this looks like:

If Not clsLunch.IsRepeat Then
    Me.Add clsLunch
    bAdded = True
    lCnt = lCnt + 1
End If

I’ve put all my previously mentioned conditions into one property called IsRepeat.

Public Property Get IsRepeat() As Boolean
   
    Dim clsLunch As CLunch
    Dim bReturn As Boolean
    Dim i As Long
   
    For i = gclsLunches.Count To 1 Step -1
        Set clsLunch = gclsLunches.Lunch(i)
       
        If clsLunch.LunchDate = Me.LunchDate Then
            If clsLunch.AttendeeMatch(Me, 1) Then
                bReturn = True
                Exit For
            End If
        End If
       
        If clsLunch.IsWithin(Me.LunchDate, 2) Then
            If clsLunch.AttendeeMatch(Me, 2) Then
                bReturn = True
                Exit For
            End If
        End If
       
        If clsLunch.IsWithin(Me.LunchDate, 10) Then
            If clsLunch.AttendeeMatch(Me, 3) Then
                bReturn = True
                Exit For
            End If
        End If
    Next i
   
    IsRepeat = bReturn
   
End Property

I loop through all the existing lunches, from both past months and those from the month I’m working on. In the first test, I check to see if the lunch is in the same month as the proposed lunch. Then I check if any of the attendees match via the AttendeeMatch property.

Public Property Get AttendeeMatch(clsLunch As CLunch, lMatchMax As Long) As Boolean
   
    Dim vaMe As Variant
    Dim vaLunch As Variant
    Dim i As Long, j As Long
    Dim lCnt As Long
    Dim clsMeAtt As CContact
    Dim clsLunchAtt As CContact
   
    vaMe = Split(Me.AttendeeList, "|")
    vaLunch = Split(clsLunch.AttendeeList, "|")

    For i = LBound(vaMe) To UBound(vaMe)
        For j = LBound(vaLunch) To UBound(vaLunch)
            If vaMe(i) = vaLunch(j) Then
                lCnt = lCnt + 1
            End If
        Next j
    Next i
   
    AttendeeMatch = lCnt >= lMatchMax
   
End Property

This property is bit weird. There are some unused variables in there, for one. In a later post, I’ll talk about the performance problems I had and how I tried to reduce execution time. The AttendeeList property returns a string of contact names, pipe delimited, and sorted by first names. Now that I look at it, I don’t think those names need to be sorted, but I use that property later where they do, so it’s there.

I’m checking my condition that a contact only have one lunch per month. I passed in a “1″ to lMatchMax so that if there was even one match, the lunch gets booted.

For my next condition, I test to make sure that no two contacts are in a lunch in the last two months. That starts by testing whether the lunch is within the last two months via the IsWithin property. Terrible name, I know.

Public Property Get IsWithin(dtLunch As Date, lMonths As Long) As Boolean
   
    IsWithin = dtLunch > DateSerial(Year(Me.LunchDate), Month(Me.LunchDate) - lMonths, 0)
   
End Property

If it passes this test, I call AttendeeMatch again, but this time with a lMatchMax of 2.

The final condition is that no three contacts are in the same group in the last ten months. It’s the same as the previous condition except that IsWithin gets passed a 10 and AttendeeMatch gets passed a 3.

Finally, I write the lunches by month to a range. Back in the main calling procedure,

    For lMonth = lFIRST To lLAST
        vaWrite = gclsLunches.LunchesByMonth(lMonth).RangeOutput
        wshLunch.Cells(wshLunch.Rows.Count, 1).End(xlUp).Offset(1, 0).Resize(UBound(vaWrite, 1), UBound(vaWrite, 2)).Value = vaWrite
    Next lMonth

The RangeOutput property returns a two-dimensional array

Public Property Get RangeOutput() As Variant
   
    Dim aReturn() As String
    Dim clsLunch As CLunch
    Dim lCnt As Long
    Dim i As Long
    Dim lAttCnt As Long
   
    ReDim aReturn(1 To Me.Count, 1 To 5)
   
    For Each clsLunch In Me
        lCnt = lCnt + 1
        lAttCnt = 0
        aReturn(lCnt, 1) = clsLunch.LunchMonth
        aReturn(lCnt, 2) = clsLunch.Attendees.Contact(clsLunch.Facilitator).FullName
        aReturn(lCnt, 5) = clsLunch.AttendeeList
        For i = 1 To clsLunch.Attendees.Count
            If i <> clsLunch.Facilitator Then
                lAttCnt = lAttCnt + 1
                aReturn(lCnt, 2 + lAttCnt) = clsLunch.Attendees(i).FullName
            End If
        Next i
    Next clsLunch
   
    RangeOutput = aReturn
   
End Property

The Facilitator is listed first, then the other two attendees. I also spit out the sorted AttendeeList so I can do a countif and make sure I didn’t screw anything up. And I screwed up plenty.

Next up, I’ll discuss some of the performance problems I had and how I solved them.

Populating Lunch Pairings

Yesterday I set up Contact objects and Lunch objects. Now I want to create new Lunch objects that meet the conditions that I defined. From my main LunchTrios procedure:

    For lMonth = lFIRST To lLAST
        gclsLunches.FillMonth lMonth
    Next lMonth

Ah, the brevity that class modules provide is a beautiful thing. I need FillMonth to:

  1. Get a list of active contacts
  2. Create a lunch with three of those contacts
  3. See if that lunch meets my conditions
  4. Keep the lunch if it passes, try another combination if it doesn’t
Public Sub FillMonth(lMonth As Long)
   
    Dim clsLunch As CLunch
    Dim dtLunch As Date
    Dim i As Long, j As Long, k As Long
    Dim bAdded As Boolean
    Dim lCnt As Long
    Dim clsActive As CContacts
   
    Set clsActive = gclsContacts.Active
    dtLunch = DateSerial(Year(Date), lMonth + 1, 0)
   
    For i = 1 To clsActive.Count
        bAdded = False
        For j = 1 To clsActive.Count
            If i <> j Then
                For k = 1 To clsActive.Count
                    If i <> k And j <> k Then
                        Set clsLunch = New CLunch
                        clsLunch.LunchDate = dtLunch
                        clsLunch.Attendees.Add clsActive(i)
                        clsLunch.Attendees.Add clsActive(j)
                        clsLunch.Attendees.Add clsActive(k)
                        clsLunch.Facilitator = CLng((Rnd * (clsLunch.Attendees.Count - 1)) + 1)
                       
                        If Not clsLunch.IsRepeat Then
                            Me.Add clsLunch
                            bAdded = True
                            lCnt = lCnt + 1
                        End If
                    End If
                    If bAdded Then Exit For
                Next k
            End If
            If bAdded Then Exit For
        Next j
        If lCnt >= clsActive.Count \ 3 Then Exit For
    Next i
   
End Sub

My first step is to get a list of active participants. I create a new instance of CContacts and store it in the variable called clsActive. Then I create a Property of the CContacts class to return an instance only containing active contacts. My main list of contacts is sorted by first name. I also have 29 active contacts, so two people every month won’t have a lunch. If I keep my contacts sorted, people like Wyatt James are always going to be left out because of his first name. I will have filled up my lunches by the time I get down to him. I need to test lunches in a somewhat random order so that the two people who get left out aren’t the same every month. To do this, I sort clsActive randomly. But I don’t really like sorting right after I populate a class. I have to loop through the contacts to populate the class, then again to sort it. It just seems wasteful. So I do it all in one fell swoop.

Public Property Get Active() As CContacts
   
    Dim clsReturn As CContacts
    Dim clsContact As CContact
    Dim lRand As Long
   
    Set clsReturn = New CContacts
   
    For Each clsContact In Me
        If clsContact.Active Then
           
            lRand = CLng((Rnd * clsReturn.Count) + 1)
           
            If clsReturn.Count = 0 Or lRand > clsReturn.Count Then
                clsReturn.Add clsContact
            Else
                clsReturn.Add clsContact, lRand
            End If
        End If
    Next clsContact
   
    Set Active = clsReturn
   
End Property

As I added qualified contacts to the collection class, I insert the new contact before a random existing contact. This has the effect of giving me a randomly sorted list of contacts in clsActive.

Now that I have my list of active contacts, I loop through them three times creating a proposed lunch. I check if that lunch meets my conditions with IsRepeat and if so, add it to my Lunches collection class. I have a lot of code in there to skip out of the For loop when it’s found a match because looping takes forever. I also skip out when I have enough lunches for the month.

Next up, I’ll go through the IsRepeat property that checks to see if a lunch meets the conditions.

Lunch Pairings

I have a list of 32 people, 29 of whom are “Active”. Every quarter, I need to generate a lunch schedule with certain conditions. A partial list of people looks like this:

The conditions are:

  • Three people to a lunch
  • The facilitator is chosen at random
  • Each person has one lunch per month at most
  • Two person combinations (different third person) can repeat after two months
  • Three person combinations can repeat after 10 months

It’s to the benefit of the attendees to not have lunch with the same person too often, so the conditions are set to minimize repeated pairings in certain time frames. If I have lunch with Joe this month, there’s no value in having lunch with him next month, even with a different third person, because not enough will have changed with regard to our lunch topic.

My first step is to identify the nouns in my scenario. The nouns are Contact and Lunch so I create a CContact class and a CLunch class and their respective collection classes CContacts and CLunches.

My main procedure is called LunchTrios and looks like this

Public Sub LunchTrios()
       
    Dim lMonth As Long
    Dim vaWrite As Variant
   
    Const lFIRST As Long = 7
    Const lLAST As Long = 9
   
    FillClasses
    Set gclsLunches = New CLunches
    If Not IsEmpty(wshLunch.Range("A4").Value) Then
        gclsLunches.FillFromRange wshLunch.Range("A4", wshLunch.Cells(wshLunch.Rows.Count, 1).End(xlUp)).Cells
    End If
   
    For lMonth = lFIRST To lLAST
        gclsLunches.FillMonth lMonth
    Next lMonth
       
    For lMonth = lFIRST To lLAST
        vaWrite = gclsLunches.LunchesByMonth(lMonth).RangeOutput
        wshLunch.Cells(wshLunch.Rows.Count, 1).End(xlUp).Offset(1, 0).Resize(UBound(vaWrite, 1), UBound(vaWrite, 2)).Value = vaWrite
    Next lMonth
   
End Sub

The FillClasses procedure reads the contact information from a range and creates a bunch of CContact objects. If wshLunch.Range(“A4″) isn’t empty, that means there are existing lunches and I need to read those in. Existing lunches look like this:

The code to populate existing lunches lives in the CLunches collection class.

Public Sub FillFromRange(rRng As Range)

    Dim rCell As Range
    Dim clsLunch As CLunch
    Dim clsContact As CContact
   
    For Each rCell In rRng.Columns(1).Cells
        Set clsLunch = New CLunch
        With clsLunch
            .LunchDate = rCell.Offset(0, 0).Value
            Set clsContact = gclsContacts.ContactByFullName(rCell.Offset(0, 1).Value)
            If Not clsContact Is Nothing Then
                .Attendees.Add clsContact
                .Facilitator = 1
                Set clsContact = Nothing
            End If

            Set clsContact = gclsContacts.ContactByFullName(rCell.Offset(0, 2).Value)
            If Not clsContact Is Nothing Then
                .Attendees.Add clsContact
                Set clsContact = Nothing
            End If

            Set clsContact = gclsContacts.ContactByFullName(rCell.Offset(0, 3).Value)
            If Not clsContact Is Nothing Then
                .Attendees.Add clsContact
                Set clsContact = Nothing
            End If
           
        End With
        Me.Add clsLunch
    Next rCell

End Sub

As people come and go from the group, sometimes an attendee from a past lunch won’t exist in the contact list anymore. I check to make sure that they exist before I add them to the Attendees property. I also set the Facilitator property to 1 as the facilitator is the first attendee listed. For this code to work, I’m going to need an Attendee property and a Facilitator property in my CLunch class.

'The class level variables
Private mlLunchID As Long
Private mdtLunchDate As Date
Private mclsAttendees As CContacts
Private mlFacilitator As Long

'the getters and setters
Public Property Let Facilitator(ByVal lFacilitator As Long): mlFacilitator = lFacilitator: End Property
Public Property Get Facilitator() As Long: Facilitator = mlFacilitator: End Property
Public Property Let LunchID(ByVal lLunchID As Long): mlLunchID = lLunchID: End Property
Public Property Get LunchID() As Long: LunchID = mlLunchID: End Property
Public Property Let LunchDate(ByVal dtLunchDate As Date): mdtLunchDate = dtLunchDate: End Property
Public Property Get LunchDate() As Date: LunchDate = mdtLunchDate: End Property

'set up and tear down of the Attendees property
Private Sub Class_Initialize()
    Set mclsAttendees = New CContacts
End Sub

Private Sub Class_Terminate()
    Set mclsAttendees = Nothing
End Sub

Public Property Get Attendees() As CContacts
    Set Attendees = mclsAttendees
End Property

I have a CContacts class that holds a bunch of CContact instances. One instance of CContacts is held in a global variable called gclsContacts and that one holds all of the contacts on the wshContacts sheet. Each of my CLunch instances also has a CContacts instance to hold the three attendees. I don’t need a separate Attendees class because it would look just like CContacts. The CContacts class module is a template that defines the characteristics of a Contacts object. The instances that are created from that template are separate animals. They are defined by the same properties, but the values of those properties are different for each instance. For example, every cow has a height and a gender, but not every cow has the same height and gender. Height and gender define a cow (a little simplistically), but each instance of a cow is defined by three things: that it was created from a cow template, its specific height, and its specific gender.

The instance of CContacts that I store in gclsContacts has a Count property just like the instance of CContacts that I store in gclsLunches.Lunch(1).Attendees. But the first instance has a Count value of 32 and the second instance has a Count value of 3.

So far I have gclsContacts that’s holding all my Contact objects and gclsLunches that’s holding all my existing Lunch objects. Each Lunch instance also has up to three Contact objects related to it via its Attendees property. Tomorrow, I’ll loop through the months I want to populate new Lunch objects that meet my conditions. No more farm animal analogies, I promise.

JoinRange Update

I use the heck out the JoinRange function I wrote a few years back. The vast majority of the time I’m using it for two purposes: creating a table in a Trac wiki page or creating an HTML table. I’ve been typing those same delimiters over and over and it has to stop.

I add an optional sMacro argument as the first argument. I can fill this argument with some predefined terms and it will create the necessary delimiters. And as long as I was in there, I change the range looping to array looping. Here’s what it looks like now.

'---------------------------------------------------------------------------------------
' Procedure : JoinRange
' Author    : dick
' Date      : 3/31/2012
' Purpose   : Concatenate cell values with delimiters and line ends
' Args      : sMacro - preset delimeters, overrides other arguments
'             sDelim - text inserted between cell values
'             sLinestart - text inserted before the first cell value
'             sLineEnd - text inserted after the last cell value
'             sBlank - text used instead of nothing for blank cells
'---------------------------------------------------------------------------------------
'
Public Function JoinRange(rInput As Range, _
    Optional sMacro As String = "", _
    Optional sDelim As String = "", _
    Optional sLineStart As String = "", _
    Optional sLineEnd As String = "", _
    Optional sBlank As String = "") As String
   
    Dim sReturn As String
    Dim vaValues As Variant
    Dim i As Long, j As Long
   
    Select Case UCase(sMacro)
        Case "HTMLTABLE", "HTML TABLE"
            sDelim = "</td><td>"
            sLineStart = "<tr><td>"
            sLineEnd = "</td></tr>"
        Case "TRACTABLE", "TRAC", "TRAC TABLE"
            sDelim = "||"
            sLineStart = "||"
            sLineEnd = "||"
    End Select
   
    vaValues = rInput.Value
    sReturn = sLineStart
   
    For i = LBound(vaValues, 1) To UBound(vaValues, 1)
        For j = LBound(vaValues, 2) To UBound(vaValues, 2)
            If Len(vaValues(i, j)) = 0 Then
               sReturn = sReturn & sBlank & sDelim
            Else
                sReturn = sReturn & vaValues(i, j) & sDelim
            End If
        Next j
    Next i
   
    sReturn = Left$(sReturn, Len(sReturn) - Len(sDelim))
   
    sReturn = sReturn & sLineEnd
   
    JoinRange = sReturn
   
End Function

Abigail Taylor Coral Springs
Bryan Burns Charlotte
Trinity Wallace Clarksville
Arianna Reynolds Elizabeth
Gabriella Roberts Providence
Katherine Foster Miami
Megan Hunt Toledo
Diego Black Garland

Oh, I’m going to save so much typing.

Convert PDF to Excel

Contextures had a nice post last week on converting a PDF to an Excel file using pdftoexcelconverter.net. Talk about a URL that says what it does. Jeff Weir commented that he’s used the OCR capabilities of OneNote to do the same thing. I thought a test was in order.

The original Excel file that was printed to PDF using CutePDF

I followed Debra’s instructions and got this result from pdftoexcelconverter.net

It converted the font to Times New Roman and ignored borders, bold, merged cells, underlines, and italics. For pure data though, a pretty good job.

Next, I dragged-and-dropped the PDF into OneNote and chose the “Print Out” option. I right-clicked on the image and chose “Copy Text…” and pasted in Excel.

Yikes. Font conversion is the least of my worries here. Finally, I dragged-and-dropped the PDF into Google Docs and chose the options to convert to Google Docs format and to OCR it. Google Docs converts PDFs to Documents (not spreadsheets) so I wasn’t very hopeful. I didn’t see a way to convert the Doc to a spreadsheet so I saved it as HTML, then opened in Excel.

The whole table is one cell. My conclusion is that OCRing tables is hard.

Monitor Worksheet Changes via RSS

I love RSS. If you have a website and don’t have a feed, I don’t follow it. I monitor stuff via Google Reader and it’s a great way to keep up on a lot of sites. So it should only follow that monitoring spreadsheet changes via RSS would be great too. Right? Well, not really, but that never stopped me before.

First some caveats. This is just an experiment and not meant for general use. There may actually be some bugs in it, if you can believe that. If you want to modify the code for your own use, be warned that if you monitor too many cells it might be slow. Or you might get so much information that it’s worthless. OK, now that that’s over.

An RSS feed is an XML file that sits on a web server. This blog has such an XML file. When I post this blog entry, WordPress will update the XML file with an entry for this post. Occasionally Google Reader will check the XML file and see if there’s anything new. If there is, it will display the new stuff for anyone who has subscribed to the feed.

It’s fairly trivial to create an XML file, even from Excel. The potentially difficult part is putting that file on a web server. Oh, except for one little thing. I have a webserver right on my computer and you might too. Mine is called “C:\Users\dick\Dropbox\Public\”. Did I trick you? Dropbox has a Public folder and you can get a “public link” from any file in that folder, including the XML file we’re about to create. What a simple way to publish something to the intertubes. Here’s how you get that public link.

Alright, enough screwing around. Let’s get to the code.

I start with a class module called CChange (and its parent CChanges). CChange has the following read/write properties:

Address - the cell address we're watching for changes and a way to uniquely identify the instance.
OldValue - the value in the cell before it changed.
NewValue - the value in the cell after it changed.
Modified - a time stamp when the changed occurred.

I’ve named a range on the sheet called RSSWatch. When the workbook opens, a CChange object is created for every cell in that range.

Sub Auto_Open()
   
    Set gclsChanges = New CChanges
   
    gclsChanges.Initialize
   
End Sub

Public Sub Initialize()
   
    Dim clsChange As CChange
    Dim rCell As Range
   
    For Each rCell In Sheet1.Range(gsNAMEDRNG).Cells
        Set clsChange = New CChange
        With clsChange
            .Address = rCell.Parent.Name & "!" & rCell.Address
            .OldValue = rCell.Value
            .NewValue = .OldValue
        End With
        Me.Add clsChange
    Next rCell
   
End Sub

These CChange objects are just sitting out there waiting to record any changes. They all have a Modified date of 12:00:00 AM (because I didn’t set anything) and in this state they won’t be written to the XML file. In the ThisWorkbook module, I use the Workbook_SheetChange event to monitor my range for changes. You might notice that I switch pretty liberally between sheet-specific references and general references. For instance, in the above Initialize method, I limit my range to Sheet1. When I went to code the event, I thought that someday I would want this to monitor different ranges on different sheets, so I used a Workbook level event. It’s totally inconsistent, but it will be helpful if you just ignore it.

Private Sub Workbook_SheetChange(ByVal Sh As Object, ByVal Target As Range)
   
    Dim clsChange As CChange
    Dim rCell As Range
    Dim rRng As Range
   
    On Error Resume Next
        Set rRng = Sh.Range(gsNAMEDRNG)
    On Error GoTo 0
   
    If Not rRng Is Nothing Then
        If Not Intersect(Target, rRng) Is Nothing Then
            For Each rCell In Target.Cells
                Set clsChange = gclsChanges.Change(Sh.Name & "!" & rCell.Address)
                If Not clsChange Is Nothing Then
                    clsChange.NewValue = rCell.Value
                    clsChange.Modified = Now
                End If
            Next rCell
        End If
    End If
   
End Sub

For every cell that has changed, I find it’s CChange brother and change the NewValue and Modified properties. I haven’t written the XML file yet. I still just have a bunch of CChange objects, except that at least one of them has a Modified property that will make it eligible to be included in the file. Changes get made and recorded and only the last value and time are saved. Then, when the workbook is saved, any eligible CChange objects are written to the file.

Private Sub Workbook_BeforeSave(ByVal SaveAsUI As Boolean, Cancel As Boolean)
   
    If Not gbDEBUG Then
        gclsChanges.WriteRSS
    End If
   
End Sub

And that’s pretty much all there is. Just kidding, there’s lots more. Before we look at more code, let’s take a look at what the file might look like after a few changes.

I’m sure you’re all experts on XML, so I’ll spare you the details, but the general hierarchy goes like this

rss
  channel
    title
    link
    description
    language
    lastBuildDate
    ttl
    item - one or more of these guys
      title
      link
      description
      pubDate
    /item
  /channel
/rss

Now that you know what the file looks like, let’s write one. I called the WriteRSS method from the Workbook_BeforeSave event.

Public Sub WriteRSS()
             
          Dim xmlDoc As MSXML2.DOMDocument
          Dim xmlChannel As MSXML2.IXMLDOMElement
          Dim xmlLastBuild As MSXML2.IXMLDOMElement
          Dim xmlItem As MSXML2.IXMLDOMElement
          Dim clsChange As CChange
          Dim dtMax As Date
         
10        If Me.HasChanges Then
20            If Not Me.FileExists Then Me.CreateFile
             
30            Set xmlDoc = New MSXML2.DOMDocument
40            xmlDoc.Load gsPATH & Me.Filename
50            Set xmlChannel = xmlDoc.SelectSingleNode(gsXRSS).SelectSingleNode(gsXCHANNEL)
60            Set xmlLastBuild = xmlChannel.SelectSingleNode(gsXBUILD)
70            Me.LastBuildDate = ConvertDate(xmlLastBuild.Text)
80            dtMax = Me.LastBuildDate
             
90            For Each clsChange In Me
100               If clsChange.ShouldWrite Then
110                   Set xmlItem = clsChange.xmlItem(xmlDoc)
120                   xmlChannel.appendChild xmlItem
                     
130                   If clsChange.Modified > dtMax Then dtMax = clsChange.Modified
140               End If
150           Next clsChange
             
160           Me.LastBuildDate = dtMax
170           xmlLastBuild.Text = Format(Me.LastBuildDate, gsFMTDATE)
180           FormatXMLDoc xmlDoc
190           xmlDoc.Save gsPATH & Me.Filename
200       End If
         
End Sub

Generally, I’m creating an XML file if it doesn’t exist, reading that file in, appending Items to it for any changes, and writing that file back out. The first thing I do is make sure there’s something to write. In line 10, I call the HasChanges property, which loops through all the CChange instances to see what’s eligible. If nothing has changed, there’s no need to create the XML file.

Line 20: If there isn’t already a file, I need to make one. I check to see if it’s out there.

Public Property Get FileExists() As Boolean
   
    FileExists = Len(Dir(gsPATH & Me.Filename)) > 0
   
End Property

Public Sub CreateFile()
   
    Dim xmlDoc As MSXML2.DOMDocument
    Dim xmlRss As MSXML2.IXMLDOMElement
    Dim xmlVer As MSXML2.IXMLDOMAttribute
    Dim xmlChannel As MSXML2.IXMLDOMElement
    Dim xmlNode As MSXML2.IXMLDOMElement

    Set xmlDoc = New MSXML2.DOMDocument
   
    Set xmlRss = xmlDoc.createElement(gsXRSS)
    Set xmlVer = xmlDoc.createAttribute(gsXVER)
    xmlVer.Value = gsRSSVERSION
    xmlRss.Attributes.setNamedItem xmlVer
   
    Set xmlChannel = xmlDoc.createElement(gsXCHANNEL)
   
    Set xmlNode = xmlDoc.createElement(gsXTITLE)
    xmlNode.Text = Me.Filename
    xmlChannel.appendChild xmlNode
   
    Set xmlNode = xmlDoc.createElement(gsXLINK)
    xmlNode.Text = gsLINK
    xmlChannel.appendChild xmlNode
   
    Set xmlNode = xmlDoc.createElement(gsXDESC)
    xmlNode.Text = "Changes made to " & ThisWorkbook.Name
    xmlChannel.appendChild xmlNode
   
    Set xmlNode = xmlDoc.createElement(gsXLANG)
    xmlNode.Text = gsLANG
    xmlChannel.appendChild xmlNode
   
    Set xmlNode = xmlDoc.createElement(gsXBUILD)
    xmlNode.Text = Format(Now - 1, gsFMTDATE)
    xmlChannel.appendChild xmlNode
   
    Set xmlNode = xmlDoc.createElement(gsXTTL)
    xmlNode.Text = glTTL
    xmlChannel.appendChild xmlNode
   
    xmlRss.appendChild xmlChannel
   
    xmlDoc.appendChild xmlRss
   
    xmlDoc.Save gsPATH & Me.Filename
   
End Sub

You’ll need to set a reference to Microsoft XML, v6.0 or similar. CreateFile sets up everything in the file that’s not an Item, like the title, link, description, language, etc. The basics of XML generation are 1) create a new node and 2) append it to its parent node.

Line 40: I read in the existing file or the one I just created. The Load method populates the XMLDOMDocument with all the hierarchies and data. Line 50 and 60 go find specific nodes in the file that I’m interested in. I want the Channel node because I’ll be appending Items to it. I want the lastBuildDate node so I can compare that to CChange Modified properties and only write new changes.

Line 70: The RSS example I got off the web showed dates formatted like Tue, 06 Mar 2012 21:28:01 CST. Neither Excel’s CDate or Datevalue functions can convert that to a date, so I have to strip off the day and the timezone, which I do in a separate function.

Line 80: Ultimately I want to make my lastBuildDate in the XML file equal to the latest date of all the CChange objects. I’m initializing the maximum variable here. If I don’t, every cell’s Modified would be greater than this date.

Line 100: For every CChange, I check to see if it needs to be written to the file.

Public Property Get ShouldWrite() As Boolean
   
    ShouldWrite = Me.Modified >= gclsChanges.LastBuildDate
   
End Property

If it’s been modified since the last time I wrote the file, it gets written this time. I really should also check to see if OldValue is different than NewValue. Right now if you change a cell and then change it back, it will still show up as a change. Sounds like a V2 enhancement.

The rest of the loop creates an Item, appends it to the Channel, and updates the maximum Modified date. The xmlItem property returns an object that can be appended.

Public Property Get xmlItem(xmlDoc As MSXML2.DOMDocument) As MSXML2.IXMLDOMElement
   
    Dim xmlReturn As MSXML2.IXMLDOMElement
    Dim xmlSubItem As MSXML2.IXMLDOMElement
   
    Set xmlReturn = xmlDoc.createElement(gsXITEM)
   
    Set xmlSubItem = xmlDoc.createElement(gsXTITLE)
    xmlSubItem.Text = Me.Address
    xmlReturn.appendChild xmlSubItem
   
    Set xmlSubItem = xmlDoc.createElement(gsXLINK)
    xmlSubItem.Text = gsLINK
    xmlReturn.appendChild xmlSubItem
   
    Set xmlSubItem = xmlDoc.createElement(gsXDESC)
    xmlSubItem.Text = Me.Description
    xmlReturn.appendChild xmlSubItem
   
    Set xmlSubItem = xmlDoc.createElement(gsXPUBDATE)
    xmlSubItem.Text = Format(Me.Modified, gsFMTDATE)
    xmlReturn.appendChild xmlSubItem
   
    Set xmlItem = xmlReturn
   
End Property

Not much to this – make a node and append it. The Description property is a read-only property that makes a nice English sentence describing what happened.

Line 160: I change the LastBuildDate to equal the max, then change that node in the XML file.

Line 180: This code I stole from VB Helper. It adds the line breaks and indentation that, while not necessary, is really helpful when debugging. No, I didn’t write this code perfectly the first time.

Finally I save the modified XML document. Dropbox publishes to the web and Google Reader reads it. Here’s what the last change looks like in the reader.

If I ever look at this again, here’s what I’d do in version 2

  • Put all the changes in one Item rather than one per cell
  • Check to see if a cell was changed and changed back and exclude it
  • Get the timezone from the Windows API rather than hardcoding it in a constant
  • Modify to use mulitple ranges on multiple sheets
  • Put the code in an add-in and look for workbooks to monitor
  • Change the Description to be easier to read

I like this Public Dropbox folder. I’m thinking of using it as a version control system to keep my add-ins up-to-date from multiple computers. It’s a shame that some companies block it. Oh well, your thoughts on the RSS code are welcome.

You can download RSSChanges.zip

Moving Scanned Files to Folders

Well, I finally done it. I’ve gone “paperless” in the AP department. Those quotes are because I haven’t gone totally paperless, I’m just done filing paid invoices. Instead I’m scanning them. I bought this happy little customer.

Canon 2454B002

And it’s proving to be quite a good scanner. The worst part about scanning paid invoices is renaming the file. There are some sophisticated accounting packages out there than handle this better, but the one that I have access to has it’s problems, not the least of which is that it’s a subscription. The scanner automatically names the file with some string of number representing the date and time. I want the file name to be VendorName_CheckNumber_Date.pdf, but typing that for every scan would be a pain. So I automated it. All scans are manually renamed as CheckNumber.pdf for paper check and eAmount.pdf for electronic transfers. Then I run the below code.

Public Sub RenameScans()
   
    Dim clsPaids As CPaids
    Dim clsPaid As CPaid
    Dim sFile As String
   
    Const sPATH As String = "\\Server\Company\Accounting\AP\Paid\"
    Const sFTYPE As String = "*.pdf"
   
    'Get a list of checks/paid invoices from accounting database
   Set clsPaids = New CPaids
    clsPaids.Fill
   
    'Look for pdf files in the scan location
   sFile = Dir(sPATH & sFTYPE)
   
    Do While Not Len(sFile) = 0
        'Find the check information for this scan
       Set clsPaid = Nothing
        Set clsPaid = clsPaids.PaidByRefNumber(sFile)
       
        If Not clsPaid Is Nothing Then
            'Record the old location and flag it to be moved
           clsPaid.ToMove = True
            clsPaid.OldLocation = sPATH & sFile
        End If
       
        'Get the next PDF file in the scan location
       sFile = Dir
    Loop
   
    'If I move the file in the middle of the Dir loop, the Dir gets all jacked up
   'so I flag it above, and actually move it when I've been through all the file
   For Each clsPaid In clsPaids
        If clsPaid.ToMove Then
            Name clsPaid.OldLocation As clsPaid.NewLocation(sPATH)
            clsPaid.ToMove = False
        End If
    Next clsPaid
   
End Sub

Here are the basics: Get a list of paid invoices from the accounting database. Try to match them up with scanned document. If there’s a match, flag it to be moved. Move and rename all the flagged documents to their proper folder. First, I fill a bunch of CPaid class instances with data from the accounting software. In CPaids:

Public Sub Fill()
   
    Dim adConn As ADODB.Connection
    Dim adRs As ADODB.Recordset
    Dim clsPaid As CPaid
    Dim sSql As String
   
    Const sCONN As String = "Some connection string"
   
    sSql = "SELECT TxnDate, RefNumber, PayeeEntityRef_FullName, Amount FROM billpaymentcheck WHERE TxnDate>=#" & Format$(Date - 90, "m/d/yyyy") & "#;"
   
    Set adConn = New ADODB.Connection
    adConn.Open sCONN
    Set adRs = adConn.Execute(sSql)
   
    If Not adRs.EOF And Not adRs.BOF Then
        adRs.MoveFirst
        Do While Not adRs.EOF
            Set clsPaid = New CPaid
            With clsPaid
                .TxnDate = adRs.Fields(0).Value
                .RefNumber = adRs.Fields(1).Value
                .Payee = adRs.Fields(2).Value
                .Amount = adRs.Fields(3).Value
            End With
            Me.Add clsPaid
            adRs.MoveNext
        Loop
    End If
   
    adRs.Close
    Set adRs = Nothing
    adConn.Close
    Set adConn = Nothing
   
End Sub

Nothing too fancy here. Get a recordset, loop through it, fill the class. I go back 90 days, which is overkill, but you never know. That hardcoded 90 should really be an argument. Once the classes are all filled, I loop through the PDF files in the designated folder. For every file I find, I try to match it up.

Public Property Get PaidByRefNumber(sRefNumber As String) As CPaid

    Dim clsReturn As CPaid
    Dim clsPaid As CPaid

    For Each clsPaid In Me
        If UCase(sRefNumber) Like UCase(clsPaid.ScanFile) Then
            Set clsReturn = clsPaid
            Exit For
        End If
    Next clsPaid

    Set PaidByRefNumber = clsReturn

End Property

Whenever I try to convince someone to try using class modules, I can never seem to make a compelling argument. It’s one of things you have to force yourself to try to see if you prefer it. But for me, this is a good example of why I like classes. This loops through all the child classes and returns a match if it finds one. The good part is the ScanFile property. I like to break down my logic into an absurd number of properties. You can look at this property’s name and usage and understand what it does, even if you don’t know what the heck ScanFile is. You don’t really need to know what it’s matching to understand that it is matching something. I could put all the logic for the match right in this property, but what the hell fun would that be. If you are interested in what is being matched, you simply drill down to ScanFile.

Public Property Get ScanFile() As String
   
    Dim sReturn As String
   
    If Me.IsCheck Then
        sReturn = Me.RefNumber & ".pdf"
    Else
        sReturn = Me.RefNumber & Me.Amount & "*.pdf"
    End If
   
    ScanFile = sReturn
   
End Propert

The logic to determine if this instance is a check or an electronic payment is really simple. You can see it in the next piece of code. And it might seem stupid to obfuscate that away from this procedure. Everything that’s involved in the IsCheck property could be put on the that one If line. Instead, I have a totally separate property and far more lines that necessary. But there are at least two advantages to doing it the way I did. The first is readability. A well named property like IsCheck tells me everything I need to know about why I have a conditional there in the first place: I do something different for checks. The second reason is maintainability. Right now, anything that starts with an “e” is an electronic payment and doesn’t have a proper check number. If that changes, say because I want to start prefixing them with “ach” instead of “e”, I can modify the code in one place. That should be, and is, the only place in the code where I make that determination so it’s the only place to make the modification.

If I was really a good programmer, I wouldn’t be naming properties names like IsCheck. I would name them IsType(ByVal eType as PaidType) and have a PaidType enum. That way if I want to differentiate between ACH and wire transfers (both currently “e” types) I could do it with less modification. There’s a balance though. If you go down that road too far, you end up writing code that writes code instead of doing actual productive work. Here’s the super-simple IsCheck property whose existence some people might consider superfluous.

Public Property Get IsCheck() As Boolean
   
    IsCheck = Me.RefNumber <> "e"
   
End Property

At this point, the only thing I need to do is determine where the file goes.

Public Property Get NewLocation(ByVal sPATH As String) As String
       
    Dim sReturn As String
    Dim lCnt As Long
   
    sReturn = Me.Folder(sPATH) & Me.Filename
   
    Do Until Len(Dir(sReturn)) = 0
        lCnt = lCnt + 1
        sReturn = Replace(sReturn, ".pdf", "_" & lCnt & ".pdf")
    Loop
   
    NewLocation = sReturn
   
End Property

This property builds the path and filename, then starts appending integers to the end if there are any conflicts.

Public Property Get Folder(ByVal sPATH As String) As String
   
    Dim sReturn As String
   
    sReturn = Me.CleanPayee
   
    If Len(Dir(sPATH & sReturn, vbDirectory)) = 0 Then
        sReturn = UCase(Left$(sReturn, 1))
    End If
   
    If Right$(sPATH, 1) <> Application.PathSeparator Then sPATH = sPATH & Application.PathSeparator
   
    Folder = sPATH & sReturn & Application.PathSeparator
   
End Property

I have more than 26 folders in which to store these scanned files: one for every letter of the alphabet and a few for vendors for whom I’ve determined it would be advantageous to have a separate folder. CleanPayee removes anything from the vendor name that’s not a letter or a number. If there’s a folder that matches that vendor’s cleaned name, it will store it there. If not, it stores it in the folder that matches the first letter of the vendor name.

Public Property Get Filename() As String
   
    Const sUS As String = "_"
   
    Filename = Me.CleanPayee & sUS & Me.RefNumber & sUS & Format(Me.TxnDate, "yyyymmdd") & ".pdf"
   
End Property

Lastly, I build the file name from properties of the class. And that’s it. On the fifty or so scanned files that I tested it on, it ran so fast that I assumed it didn’t work until I checked the folders. I really thought I was going to have performance problems with the Name statement for renaming and moving files, but it doesn’t seem to be the case. As Jon Peltier once said, don’t optimize until you know you have a problem and you know what’s causing the problem.

You can download RenameScans.zip

Custom Error Object

Ken Puls and I were discussing the merits of custom class modules recently. Shortly after that conversation, I started rewriting a small utility app with the intention of using the Rethrow method mention by Stephen Bullen. If I’m such a class module evangelist, why am I not using a custom error object? Good question.

I decided to rewrite the PED Error Handler using a class. Below is the central error handling function with more comments than are necessary, I think.

Public Function HandleError(ByVal sModule As String, ByVal sProc As String, _
    Optional ByVal sFile As String, _
    Optional ByVal bEntryPoint As Boolean = False) As Boolean

    Dim bReturn As Boolean
   
    'First call, the object will be nothing so it's created
   'and the number and description are saved
   If gclsError Is Nothing Then
        Set gclsError = New CError
        gclsError.Number = Err.Number
        gclsError.Message = Err.Description
    End If
   
    'Once the error number and description are captured,
   'suppress all other errors
   On Error Resume Next
   
    With gclsError
        'Additional properties set
       .Module = sModule
        .Procedure = sProc
        .File = sFile
        .EntryPoint = bEntryPoint
       
        'Method to write the error out to a file
       .WriteToLog
       
        If Not .UserCanceled Then
            'If it's at the entry point or in debug, display the error
           If .ShouldShowMessage Then
                Application.ScreenUpdating = True
                MsgBox .Message, vbCritical, gsAPPTITLE
                Set gclsError = Nothing
            Else
                'Rethrow the error in the calling procedure
               On Error GoTo 0
                Err.Raise .Number, .FullSource, .Message
            End If
           
            bReturn = .DebugMode
        Else
            'End silently and kill the object
           bReturn = False
            Set gclsError = Nothing
        End If
    End With
   
    HandleError = bReturn
   
End Function

This isn’t an exact replacement for the one in the book. It only uses the Rethrow method, so it won’t be a good solution if you need to clean up after an error. My goal was not to duplicate it exactly, but rather to kill some time during one of the less relevant MVP Summit sessions. Here are a couple of highlights:

I wrote a write-once property for the Message property. Later, I changed the main function to only write the Message property when a new CError object is created so it’s redundant.

Public Property Let Message(ByVal sMessage As String)
   
    If Len(Me.Message) = 0 Then msMessage = sMessage

End Property

Writing to the log file uses some other custom properties that are basically string builders.

Public Sub WriteToLog()
   
    Dim lFile As Long
   
    On Error Resume Next
   
    lFile = FreeFile
   
    Open Me.LogFile For Append As lFile
    Print #lFile, Format$(Now(), "dd mmm yy hh:mm:ss"); Me.LogEntry
    If Me.EntryPoint Then
        Print #lFile,
    End If
    Close lFile
   
End Sub

I modified the standard Let Number property to use a default “User Cancel” message.

Public Property Let Number(ByVal lNumber As Long)
   
    mlNumber = lNumber
    If lNumber = ErrorType.UserCancel Then
        Me.Message = msUSERCANCEL
    End If
   
End Property

One of the things I like about using class modules is turning Boolean logic into easy-to-understand English. I could have coded

If .DebugMode Or .EntryPoint Then

but I much prefer to see

If .ShouldShowMessage Then

and to put that Boolean logic in the property

Public Property Get ShouldShowMessage() As Boolean
   
    ShouldShowMessage = Me.DebugMode Or Me.EntryPoint
   
End Property

I get the benefit of using and reusing ShouldShowMessage wherever I want and if the logic changes, I change it only in one place. I only use it once and probably won’t use it anywhere else, but beyond that I just like that the intent is embedded in the code so the reader doesn’t have to try to figure it out unless they want to.

And here’s some fake code to see if it works.

Sub Main()
           
    Dim lResp As Long
   
    Const sSOURCE As String = "Main()"
   
    On Error GoTo ErrorHandler
   
    lResp = MsgBox("Cancel?", vbYesNo, gsAPPTITLE)
    If lResp = vbYes Then
        Err.Raise ErrorType.UserCancel, sSOURCE
    Else
        Sub_Procedure
    End If
   
    Exit Sub
   
ErrorHandler:
    If HandleError(msMODULE, sSOURCE, , True) Then
        Stop
        Resume
    End If
   
End Sub

Sub Sub_Procedure()
   
    Dim i As Long
   
    Const sSOURCE As String = "Sub_Procedure()"
   
    On Error GoTo ErrorHandler

    i = Sub_Function(1) 'no error here
   i = Sub_Function(0) 'this will create a divide by zero
   
    Exit Sub
   
ErrorHandler:
    If HandleError(msMODULE, sSOURCE) Then
        Stop
        Resume
    End If
   
End Sub


Function Sub_Function(lDenom As Long) As Long
   
    Dim i As Long
   
    Const sSOURCE As String = "Sub_Function()"
   
    On Error GoTo ErrorHandler

    i = 1 / lDenom 'When zero is passed in, an error is raised
   
    Exit Function
   
ErrorHandler:
    If HandleError(msMODULE, sSOURCE) Then
        Stop
        Resume
    End If
   
End Function

Thanks to Bob Phillips for telling me to use an Enum instead of a constant: ErrorType.UserCancel vs. glUSERCANCEL.

You can download ErrorClass.zip

Excel Power Analyst Bootcamp Omaha

Excel Power Analyst Bootcamp Omaha

Microsoft MVPs Dick Kusleika (Daily Dose of Excel) and Mike Alexander (DataPig) are joining together to bring you our acclaimed Power Analyst Boot Camp!

This two-day boot camp is designed for Excel Power Analysts who are looking to more effectively build and manage better data reporting mechanisms. During this workshop, you’ll be introduced to a wide array of tips and techniques that will muscle up your skills in Data Crunching, Reporting, and Automation.

Register early to get a $150 per seat discount. Only $700 for two days of awesome training.

Also, if you didn’t know, Omaha is my home town. That doesn’t just mean that I’ll be more rested during the training, it also means the class will fill up fast as I pressure my colleagues, friends, and family to attend. Don’t wait to sign up. Register here.

Semicolon in a Debug.Print Statement

I had no idea this existed.

Sub testsemicolon()
   
    Dim i As Long
   
    For i = 1 To 5
        Debug.Print i
    Next i
   
    For i = 1 To 5
        Debug.Print i;
    Next i
   
End Sub

It’s right there in the help for the Print # Statement, but I’ve never paid attention to it.