Archive for the ‘User Defined Functions’ Category.

Preventing Event Conflicts

I have two custom add-ins loaded that use application-level events. I only want those events to run when a workbook associated with my application is active. Otherwise, the events in my purchase order application will try and do stuff to my invoices and vice versa. Not good.

For every application, the first thing I do is test to make sure I'm dealing with an appropriate object; be it a sheet, workbook, or whatever. I use two utilities to do the testing. The first utility verifies an open workbook and the second verifies a closed one.

As described in Custom Document Properties, I use custom document properties in my templates to identify the workbook as being part of the application. The utilities check the property and return True if it's there.

Function IsOpenInvoice(ByRef Wb As Workbook, _
    Optional ByVal sProperty As String = gsCDPINVAPP) As Boolean
   
    Dim bTemp As Boolean
   
    On Error Resume Next
        bTemp = Wb.CustomDocumentProperties(sProperty).Value
    On Error GoTo 0
   
    IsOpenInvoice = bTemp
   
End Function
 
Function IsInvoice(ByVal sName As String, _
    Optional ByVal sProperty As String = gsCDPINVAPP) As Boolean
       
    With Application.FileSearch
        .NewSearch
        .FileType = msoFileTypeAllFiles
        .Filename = Dir(sName)
        If Not sName = Dir(sName) Then
            .LookIn = Replace(sName, Dir(sName), "")
        End If
        .PropertyTests.Add sProperty, msoConditionIsYes
       
        .Execute
       
        IsInvoice = .FoundFiles.Count> 0
    End With
   
End Function

In IsOpenInvoice, the property value from the supplied workbook is set to a Boolean variable. If the property value is False or if the property doesn't exist, the variable is False. Of course the property value will never be false. I created the properties in the template and set the value to True. It will either exist or not.

For closed workbooks, IsInvoice uses FileSearch to find a file with the proper name and with the correct property. The argument sName is the full path and name of the file. If the file exists in the directory and has the property, True is returned. This is typically used before a file is opened, such as after GetOpenFilename is used but before the file is actually opened.

Both functions have an optional second argument, sProperty, that defaults to the property name that identifies the application in general. I also use these utilities to identify particular templates within my app. I may need to know, for instance, if the open workbook is an invoice, a sales order, or a report in particular, rather than just part of my app in general. I have constants set up for each template type that I want to test.

Here's an example of an application level event in a custom class module that uses IsOpenInvoice:

Private Sub mApp_SheetChange(ByVal Sh As Object, ByVal Target As Range)
   
    If IsOpenInvoice(Sh.Parent, gsINVOICE) Then
        'Do stuff
    End If
   
End Sub

Having a number and a formula “co-exist” in a cell

On an ongoing project, the client uses the TM1 OLAP system. One of the interesting things I noticed was this:

A user can "slice" data from the database into Excel. The result is a new worksheet where the appropriate cell contains a formula such as =DBRW(...), which essentially looks up the TM1 database for the current value that corresponds to the specified parameters.

So far so good. But, here's the twist. One can enter a new value into the cell containing the formula. TM1 will update the OLAP database with this new value and restore the formula.

As soon as I saw what was happening I guessed how it was done. Here's the implementation of a proof-of-concept. Of course, as a proof-of-concept there are a lot of safeties, performance issues, and other niceties that are missing.

An obvious requirement is that that one must have a secondary data storage since it is impossible for a value and a formula to actually co-exist in a cell. So, that requires a backend database to store the actual value and I decided to use MS Access to create one.

The database had a single table with 3 columns: Col1, Col2, DataVal. For those who want to know how this maps to an OLAP system, think of Col1 and Col2 as dimensions in a TM1 OLAP system and DataVal as the value at the intersection of specific elements in those dimensions.

That led to the infrastructure to access data in the database. In a standard module:

Option Explicit
Dim Cn As ADODB.Connection
    Dim aRSTable1 As ADODB.Recordset
Function initializeADO(DataSrcName As String) As ADODB.Connection
        Dim Cn As ADODB.Connection
        Set Cn = New ADODB.Connection
        With Cn
            .Provider = "Microsoft.Jet.OLEDB.4.0"
            .ConnectionString = DataSrcName
            .Open
            End With
        Set initializeADO = Cn
    End Function
Sub openADO()
    Set Cn = initializeADO("C:\Documents and Settings\Owner\My Documents\testADO\db1.mdb")
    End Sub
Public Function DBVal(Table, Col1, Col2)
    If aRSTable1 Is Nothing Then Set aRSTable1 = New ADODB.Recordset
    If Cn Is Nothing Then openADO
    On Error Resume Next: aRSTable1.Close: On Error GoTo 0
    aRSTable1.Open "SELECT DataVal FROM " & Table _
        & " WHERE Col1='" & Col1 & "' AND Col2='" & Col2 & "'", Cn
    DBVal = aRSTable1.Fields("DataVal").Value
    End Function

OK, nothing unusual about the above. It's standard stuff to write a User Defined Function that retrieves data from an external database. Again, remember this is proof-of-concept code and leaves out a lot of safeties and performance effectiveness issues.

This is used in a worksheet cell as =DBVal(A3,B3,C3) where A3 contains the Access table being queried, and B3 and C3 the values for the 2 columns Col1 and Col2 respectively. In TM1 parlance, this would correspond to the cube name, and the two elements of the 2 dimensions in the cube.

Next, the infrastructure to allow a new value to update the database.

First, a worksheet event procedure that keeps track of the existing formula. Note that I would never deploy an event procedure in a worksheet code module, but it works well to test concepts.

Option Explicit
Dim CellFormula As String

Private Sub Worksheet_SelectionChange(ByVal Target As Range)
    If Target.Cells.Count <> 1 Then Exit Sub
    If Not Target.HasFormula Then CellFormula = "": Exit Sub
    CellFormula = Target.Formula
    End Sub

Next, an event procedure that responds to a new value entered by the user.

Private Sub Worksheet_Change(ByVal Target As Range)

    If Target.Cells.Count <> 1 Then Exit Sub
    If Target.HasFormula Then Exit Sub
    If CellFormula = "" Then Exit Sub
   
   
    Dim NewVal
    NewVal = Target.Value
    updateDB CellFormula, NewVal
   
    On Error Resume Next
    Application.EnableEvents = False
    Target.Formula = CellFormula
    Application.EnableEvents = True
    On Error GoTo 0
   
    End Sub

The SelectionChange event procedure above saves the cell formula, if it has one, at the time the user selects the cell. Then, if the user enter a value, the Change event procedure uses the new value to update the database through UpdateDB and then restores the formula saved by the SelectionChange procedure.

The corresponding code for the updateDB subroutine in the standard code module:

Sub updateDB(CellFormula As String, NewVal)
    If Cn Is Nothing Then openADO
    Dim Params
    Params = Split(CellFormula, "(")
    Params = Split(Left(Params(1), Len(Params(1)) - 1), ",")
    Cn.Execute "UPDATE " & Range(Params(0)).Value & " SET DataVal=" & NewVal _
        & " WHERE Col1='" & Range(Params(1)).Value & "' AND Col2='" & Range(Params(2)).Value & "'"
    End Sub

And, for completeness, code, in the standard module, to close the database connection:

Sub closeADO()
    On Error Resume Next
    aRSTable1.Close
    Set aRSTable1 = Nothing
    Cn.Close
    Set Cn = Nothing
    End Sub

To use the above, in the Access database, a table named Table1. I had 4 records in it corresponding to values of "a" and "b" for Col1 and Col2. The associated DataVal values were 1, 2, 3, and 4, respectively.

In the Excel worksheet, Cell A3 had the value "Table1" and in B3 and C3 one could enter the values corresponding to Col1 and Col2 (i.e., either "a" or "b"). The =DBVal() formula then retrieved the corresponding value from the database.

Now, enter a new value in the cell that contains the =DBVal() formula. The code will update the database with the new value and restore the =DBVal() formula. This will show the new value in the cell.

CEILING Part 3

From KeepITCool

Function kicCeiling(ByVal vNum As Variant, ByVal vSig As Variant) As Variant
    Dim vRes
    Dim r&, c&
    Dim n&, nrL&, nrU&, ncL&, ncU&
    Dim s&, srL&, srU&, scL&, scU&
   
    On Error Resume Next
   
    If TypeName(vNum) = "Range" Then vNum = vNum.Value
    nrU = -1: ncU = -1
    nrL = LBound(vNum, 1): nrU = UBound(vNum, 1)
    ncL = LBound(vNum, 2): ncU = UBound(vNum, 2)
    If ncU> -1 Then n = 2 Else If nrU> -1 Then n = 1 Else n = 0
   
    If TypeName(vSig) = "Range" Then vSig = vSig.Value
    srU = -1: scU = -1
    srL = LBound(vSig, 1): srU = UBound(vSig, 1)
    scL = LBound(vSig, 2): scU = UBound(vSig, 2)
    If scU> -1 Then s = 2 Else If srU> -1 Then s = 1
       
    Select Case n & s
        Case "00"
            vRes = GetCeiling(vNum, vSig)
        Case "20"
            ReDim vRes(nrL To nrU, ncL To ncU)
            For r = nrL To nrU: For c = ncL To ncU: vRes(r, c) = GetCeiling(vNum(r, c), vSig): Next: Next
        Case "02"
            ReDim vRes(srL To srU, scL To scU)
            For r = srL To srU: For c = scL To scU: vRes(r, c) = GetCeiling(vNum, vSig(r, c)): Next: Next
        Case "22"
            Debug.Assert nrL = srL And ncL = scL
            If nrU = srU And ncU = scU Then
                ReDim vRes(nrL To nrU, ncL To ncU)
                For r = nrL To nrU: For c = ncL To ncU: vRes(r, c) = GetCeiling(vNum(r, c), vSig(r, c)): Next: Next
            ElseIf nrU = 1 Then
                ReDim vRes(srL To srU, ncL To ncU)
                For r = srL To srU: For c = ncL To ncU: vRes(r, c) = GetCeiling(vNum(1, c), vSig(r, 1)): Next: Next
            ElseIf srU = 1 Then
                ReDim vRes(nrL To nrU, scL To scU)
                For r = nrL To nrU: For c = scL To scU: vRes(r, c) = GetCeiling(vNum(r, 1), vSig(1, c)): Next: Next
            Else
                nrU = Application.Max(nrU, srU)
                ncU = Application.Max(ncU, scU)
                ReDim vRes(nrL To nrU, ncL To ncU)
                For r = nrL To nrU
                    If r <= UBound(vNum, 1) And r <= UBound(vSig, 1) Then
                        For c = ncL To ncU
                            If c <= UBound(vNum, 2) And c <= UBound(vSig, 2) Then
                                vRes(r, c) = GetCeiling(vNum(r, c), vSig(r, c))
                            Else
                                vRes(r, c) = CVErr(xlErrNA)
                            End If
                        Next
                    Else
                        For c = ncL To ncU
                            vRes(r, c) = CVErr(xlErrNA)
                        Next
                    End If
                Next
            End If
        Case "10"
            ReDim vRes(nrL To nrU)
            For r = nrL To nrU: vRes(r) = GetCeiling(vNum(r), vSig): Next
        Case "01"
            ReDim vRes(srL To srU)
            For r = srL To srU: vRes(r) = GetCeiling(vNum, vSig(r)): Next
        Case "11"
            Debug.Assert nrL = srL And nrU = srU
            ReDim vRes(nrL To nrU)
            For r = nrL To nrU: vRes(r) = GetCeiling(vNum(r), vSig(r)): Next
        Case "21"
            Debug.Assert ncU = 1
            ReDim vRes(nrL To nrU, srL To srU)
            For r = nrL To nrU: For c = srL To srU: vRes(r, c) = GetCeiling(vNum(r, 1), vSig(c)): Next: Next
        Case "12"
            Debug.Assert scU = 1
            ReDim vRes(srL To srU, nrL To nrU)
            For r = srL To srU: For c = nrL To nrU: vRes(r, c) = GetCeiling(vNum(c), vSig(r, 1)): Next: Next
    End Select
   
    kicCeiling = vRes
   
End Function
     
Private Function GetCeiling(ByVal number As Variant, ByVal significance As Variant) As Variant
    Dim dNum#, dSig#, dTmp#
    Dim vRes
   
    On Error GoTo errH
   
    Select Case VarType(number) '+vbArray will error out.
        Case vbError: vRes = number: GoTo endH
        Case vbBoolean: dNum = Abs(number)
        Case Else: dNum = number
    End Select
    Select Case VarType(significance)
        Case vbError: vRes = significance: GoTo endH
        Case vbBoolean: dSig = Abs(significance)
        Case Else: dSig = significance
    End Select
   
    If dNum = 0 Or dSig = 0 Then
        vRes = 0#
    ElseIf Sgn(dNum) <> Sgn(dSig) Then
        vRes = CVErr(xlErrNum)
    Else
        dTmp = dNum / dSig
        vRes = (Int(dTmp) + Abs(dTmp <> Int(dTmp))) * dSig
    End If
endH:
    GetCeiling = vRes
    Exit Function
errH:
    vRes = CVErr(xlErrValue)
    GoTo endH:
End Function

I get NA for the array formula

=SUM(kicceiling(A17:B18,E17:E18))

but I haven't had time to sort out why.

Rewriting CEILING for Arrays

With regard to Rewriting the CEILING function, fzz comments:

Not only does it fail to handle arrays, it also mishandles string arguments that could be converted into numbers.

Good one. I hate it when I miss the easier ones.

And Stephen Bullen comments:

Now extend it to handle array formulae!

Ouch! That complicates things. Below is my attempt at that. There's probably a better way to combine arrays, but I couldn't figure it out.

Public Function xCEILING(number As Variant, significance As Variant) As Variant
   
    Dim vaNumber() As Variant
    Dim vaSignif() As Variant
    Dim vaTempNum() As Variant
    Dim vaTempSig() As Variant
    Dim lTemp As Long
    Dim i As Long
    Dim bDelayedCalc As Boolean
   
    Dim vaReturn() As Variant
   
    'In case of error, make sure return array has at least one element
    ReDim vaReturn(1 To 1)
   
    On Error GoTo Err_Proc
           
    'Convert arguments into 2D arrays
    vaTempNum = ConvertToArray(number)
    vaTempSig = ConvertToArray(significance)
   
    'Send two 2D arrays and get back two 1D arrays
    CombineArrays vaTempNum, vaTempSig, vaNumber, vaSignif
   
    ReDim vaReturn(1 To UBound(vaNumber))
   
    For i = LBound(vaNumber) To UBound(vaNumber)
       
        bDelayedCalc = True
       
        Select Case True
            Case TypeName(vaNumber(i)) = "Error"
                vaReturn(i) = vaNumber(i)
                bDelayedCalc = False
            Case TypeName(vaSignif(i)) = "Error"
                vaReturn(i) = vaSignif(i)
                bDelayedCalc = False
            Case IsDate(vaNumber(i))
                vaNumber(i) = CDbl(vaNumber(i))
            Case IsDate(vaSignif(i))
                vaSignif(i) = CDbl(vaSignif(i))
            Case Not IsNumeric(vaNumber(i)) Or Not IsNumeric(vaSignif(i))
                vaReturn(i) = CVErr(xlErrValue)
                bDelayedCalc = False
            Case TypeName(vaNumber(i)) = "Boolean"
                vaNumber(i) = Abs(CDbl(vaNumber(i)))
            Case TypeName(vaSignif(i)) = "Boolean"
                vaSignif(i) = Abs(CDbl(vaSignif(i)))
            Case (vaNumber(i) <0) <> (vaSignif(i) <0)
                vaReturn(i) = CVErr(xlErrNum)
                bDelayedCalc = False
            Case vaSignif(i) = 0
                vaReturn(i) = 0
                bDelayedCalc = False
        End Select
       
        If bDelayedCalc Then
            lTemp = Int(vaNumber(i) / vaSignif(i))
   
            If lTemp = (vaNumber(i) / vaSignif(i)) Then 'already at the correct precision
                vaReturn(i) = vaNumber(i)
            Else
                vaReturn(i) = (lTemp + 1) * vaSignif(i)
            End If
        End If
    Next i
   
Exit_Proc:
    On Error Resume Next
    xCEILING = vaReturn
    Exit Function
   
Err_Proc:
    Select Case Err.number
        Case xlErrNA
            vaReturn(1) = CVErr(xlErrNA)
        Case Else
            vaReturn(1) = CVErr(xlErrValue)
    End Select
    Resume Exit_Proc
   
End Function
   
Private Function ConvertToArray(vArg As Variant) As Variant
   
    Dim vaReturn As Variant
    Dim lTestArrDim As Long
    Dim i As Long
   
    Select Case TypeName(vArg)
        Case "Range"
            If vArg.Cells.Count = 1 Then
                ReDim vaReturn(1 To 1, 1 To 1)
                vaReturn(1, 1) = vArg.Value
            Else
                vaReturn = vArg.Value
            End If
        Case "Boolean"
            ReDim vaReturn(1 To 1, 1 To 1)
            vaReturn(1, 1) = Abs(CDbl(vArg)) 'convert Excel Boolean to VBA Boolean
        Case "Variant()"
            'If the array only has one dimension, convert it to two
            lTestArrDim = 0
            On Error Resume Next
                lTestArrDim = UBound(vArg, 2)
            On Error GoTo 0
            If lTestArrDim = 0 Then
                ReDim vaReturn(1 To 1, 1 To UBound(vArg))
                For i = LBound(vArg) To UBound(vArg)
                    vaReturn(1, i) = vArg(i)
                Next i
            Else
                vaReturn = vArg
            End If
        Case Else
            vaReturn(1, 1) = IIf(IsNumeric(vArg), CDbl(vArg), vArg)
    End Select
   
    ConvertToArray = vaReturn
   
End Function
   
Private Sub CombineArrays(ByVal Arr1 As Variant, ByVal Arr2 As Variant, _
    ByRef aReturn1 As Variant, ByRef aReturn2 As Variant)
           
    Dim vaOne() As Variant
    Dim vaTwo() As Variant
    Dim lMaxRows As Long, lMaxCols As Long
    Dim lMaxElems As Long
    Dim lRow As Long, lCol As Long
    Dim lElemCnt As Long
           
    If lMaxRows <UBound(Arr1, 1) Then lMaxRows = UBound(Arr1, 1)
    If lMaxRows <UBound(Arr2, 1) Then lMaxRows = UBound(Arr2, 1)
    If lMaxCols <UBound(Arr1, 2) Then lMaxCols = UBound(Arr1, 2)
    If lMaxCols <UBound(Arr2, 2) Then lMaxCols = UBound(Arr2, 2)
   
    'If elements> 1 do not match, pass NA back to calling procedure
    If UBound(Arr1, 1)> 1 And UBound(Arr2, 1)> 1 And _
        UBound(Arr1, 1) <> UBound(Arr2, 1) Then
       
        Err.Raise xlErrNA
    End If
   
    If UBound(Arr1, 2)> 1 And UBound(Arr2, 2)> 1 And _
        UBound(Arr1, 2) <> UBound(Arr2, 2) Then
       
        Err.Raise xlErrNA
    End If
   
    lMaxElems = lMaxRows * lMaxCols
   
    ReDim vaOne(1 To lMaxElems)
    ReDim vaTwo(1 To lMaxElems)
   
    For lRow = 1 To lMaxRows
        For lCol = 1 To lMaxCols
            lElemCnt = lElemCnt + 1
            'Match up columns and rows.  When an array doesn't have enough
            'columns or rows, use column 1 or row 1.
            If lRow <= UBound(Arr1, 1) Then
                If lCol <= UBound(Arr1, 2) Then
                    vaOne(lElemCnt) = Arr1(lRow, lCol)
                Else
                    vaOne(lElemCnt) = Arr1(lRow, 1)
                End If
            Else
                If lCol <= UBound(Arr1, 2) Then
                    vaOne(lElemCnt) = Arr1(1, lCol)
                Else
                    vaOne(lElemCnt) = Arr1(1, 1)
                End If
            End If
            If lRow <= UBound(Arr2, 1) Then
                If lCol <= UBound(Arr2, 2) Then
                    vaTwo(lElemCnt) = Arr2(lRow, lCol)
                Else
                    vaTwo(lElemCnt) = Arr2(lRow, 1)
                End If
            Else
                If lCol <= UBound(Arr2, 2) Then
                    vaTwo(lElemCnt) = Arr2(1, lCol)
                Else
                    vaTwo(lElemCnt) = Arr2(1, 1)
                End If
            End If
           
        Next lCol
    Next lRow
   
    aReturn1 = vaOne
    aReturn2 = vaTwo
       
End Sub

Update: Rob Bruce correctly points out that I wasn't bubbling error cells up through properly. I was also returning an one-element array with an error for any error that occurred, rather than returning the complete array with the error in the proper element. As I was fixing that, I also discovered that dates weren't working. The above code is the latest iteration and the old code is gone forever. What else did I miss?

Rewriting the CEILING Function

Someone on the newsgroups wanted to rewrite the CEILING function in order to learn how to write user defined functions. Rewriting built-in functions is a good way to learn, I think. It seems like I pick up something new every time I go through the exercise,which is not that often. Ben McBen supplied the logic in answer to the post and I added the error checking. If you've ever written a worksheet function, you know it's 90% error checking and 10% logic.

Public Function xCEILING(number As Variant, significance As Variant) As Variant
   
    Dim dNumber As Double
    Dim dSignif As Double
    Dim lTemp As Long
   
    Dim vReturn As Variant
   
    Const lERR_NUM As Long = 9997
    Const lERR_TYPE As Long = 9998
    Const lERR_OTHER As Long = 9999
   
    On Error GoTo Err_Proc
   
    'Verify that arguments are numbers
    Select Case TypeName(number)
        Case "Range"
            Select Case TypeName(number.Value)
                Case "String"
                    Err.Raise lERR_TYPE
                Case "Boolean"
                    dNumber = Abs(CDbl(number.Value))
                Case Else
                    dNumber = CDbl(number.Value)
            End Select
        Case "String"
            Err.Raise lERR_TYPE
        Case "Boolean"
            dNumber = Abs(CDbl(number))
        Case Else
            dNumber = CDbl(number)
    End Select
   
    Select Case TypeName(significance)
        Case "Range"
            Select Case TypeName(significance.Value)
                Case "String"
                    Err.Raise lERR_TYPE
                Case "Boolean"
                    dSignif = Abs(CDbl(significance.Value))
                Case Else
                    dSignif = CDbl(significance.Value)
            End Select
        Case "String"
            Err.Raise lERR_TYPE
        Case "Boolean"
            dSignif = Abs(CDbl(significance))
        Case Else
            dSignif = CDbl(significance)
    End Select
   
    'Verify that signs are the same
    If (dNumber <0) <> (dSignif <0) Then
        Err.Raise lERR_NUM
    End If
       
    'if significance is zero, always return zero
    If dSignif = 0 Then
        vReturn = 0
    Else
        lTemp = Int(dNumber / dSignif)
       
        If lTemp = (dNumber / dSignif) Then 'already at the correct precision
            vReturn = dNumber
        Else
            vReturn = (lTemp + 1) * dSignif
        End If
    End If
   
Exit_Proc:
    On Error Resume Next
    xCEILING = vReturn
    Exit Function
   
Err_Proc:
    Select Case Err.number
        Case lERR_TYPE
            vReturn = CVErr(xlErrValue)
        Case lERR_NUM
            vReturn = CVErr(xlErrNum)
        Case Else
            vReturn = CVErr(xlErrValue)
    End Select
    Resume Exit_Proc
   
End Function

I always make my arguments and return values Variants. If it's to be used in a worksheet, the user should be able to enter a range reference or a value. VBA will convert range references to their values because the Value property is the default property for the Range object. But handling the conversion explicitly inside the function gives me more flexibility in returning specific errors. For return values, I need to be able to return one or more error values, so it has to be a Variant.

Note that I have to explicitly handle Boolean data types. VBA and Excel treat True in different ways. Namely, VBA assigns True the value of -1 and Excel assigns it the value of 1. Even if the True comes from Excel (like in a worksheet function argument), VBA still treats it as -1, so I use the Abs function to make it 1.

What did I learn? I learned that I didn't really understand integer division like I thought I did. I originally used the \ operator because I thought it was the same as Int(x/y), but not so. I guess I've only every used it with Integer operands. When I passed it a Double divisor, it was converted to an Integer. Since the Integer it was converted to was zero, I got an error. So my lesson was "don't use integer division unless you're dividing integers".

Converting Mapped Drives

Emily once commented with a nifty procedure for enumerating mapped drives. It uses the Windows Script Host Object Model. I created a function to convert mapped drives to UNC paths for an application I'm working on.

Public Function ConvertMapped(ByVal sPath As String) As String
   
    Dim wsNet As WshNetwork
    Dim wsDrives As WshCollection
    Dim i As Long
    Dim sReturn As String
   
    Set wsNet = New WshNetwork
    Set wsDrives = wsNet.EnumNetworkDrives
   
    sReturn = sPath
   
    For i = 0 To wsDrives.Count - 1
        If Left$(sPath, 2) = wsDrives.Item(i) Then
            sReturn = Replace(sPath, wsDrives.Item(i), wsDrives.Item(i + 1), 1, 1)
            Exit For
        End If
    Next i
   
    ConvertMapped = sReturn
   
End Function

But I still have a problem. I have a list of documents and a list of links. The user can add a link by navigating to the document (using GetOpenFileName). If the document already exists in the Document table, the link is created. If not, a record is added to the Document table, then the link is created. I need to ensure there are no duplicates in the Document table.

I thought that converting paths to UNC paths would do it, but not so. The user can navigate to a document through multiple network shares. For instance, he could go to \\Actserver\AIMUSA\Mydoc.pdf or he could go to \\Actserver\AllCompanies\AIMUSA\Mydoc.pdf and he would be pointing to the same document. The paths don't match, though, so this document could be added twice.

There's only currently a few of these overlapping shares, so I could catch them "manually" in the code. That doesn't seem like a good long term solution, though, as certainly another share will crop up that breaks it. Ideas?