Thursday, 18 September 2014

VBA to check 'User is in group'

It's a common question: is the user in this group? Or rather: is the user allowed to do this?

Now VBA and Excel are *not* secure platforms, so you really shouldn't use this for sensitive information and operations - get this set up properly at the database, or in the target application - but you may well be using NT groups, quite legitimately, to direct your users into different tasks and data sets, rather than using Excel as the security layer.

I'm pretty sure I've posted this on StackOverflow, some or other year ago; but this is as good a place for it as any.

I've attached a User Long Name function, because you'll almost always find yourself using this in applications that are aware of the user's network ID.

As always, watch out for Blogspot rendering an unwanted line break.

Public Function UserIsInGroup(GroupName As String, Optional Username As String, Optional Domain As String) As Boolean
' Returns TRUE if the user is in the named NT Group.

' If user name is omitted, current logged-in user's login name is assumed.
' If domain is omitted, current logged-in user's domain is assumed.
' User name can be submitted in the form 'NETWORK/UserName' - this will run slightly faster
' Does not raise errors for unknown user: they are not in the group and the function returns false.

'
' Sample Usage: UserIsInGroup( "Domain Users" )


Dim strUsername As String
Dim objGroup    As Object
Dim objUser     As Object
Dim objNetwork  As Object


UserIsInGroup = False

If Username = "" Then
    Set objNetwork = CreateObject("WScript.Network")
    strUsername = objNetwork.UserDomain & "/" & objNetwork.Username
Else
    strUsername = Username
End If


strUsername = Replace(strUsername, "\", "/")

If InStr(strUsername, "/") Then
    ' No action: Domain has already been supplied in the user name
Else

    If Domain = "" Then
        Set objNetwork = CreateObject("WScript.Network")
        Domain = objNetwork.UserDomain
    End If
    
    strUsername = Domain & "/" & strUsername
    
End If

Set objUser = GetObject("WinNT://" & strUsername & ",user")

If objUser Is Nothing Then

    ' Insert error-handler here if you want to report an unknown user name
    
Else

    For Each objGroup In objUser.Groups
    
        If GroupName = "" Then
            Debug.Print objGroup.Name
        End If
        
        If GroupName = objGroup.Name Then
            UserIsInGroup = True
            Exit For
        End If
    
    Next objGroup


End If


Set objNetwork = Nothing
Set objGroup = Nothing
Set objUser = Nothing


End Function


Public Function UserLongName(ByVal strUserID As String) As String
Application.Volatile False
On Error GoTo ErrSub


Dim strDomain As String


If strUserID = "" Then
    Exit Function
End If



If InStr(2, strUserID, "(", vbBinaryCompare) > 0 Then
    Exit Function
End If

strUserID = Replace(strUserID, "\", "/")

If InStr(strUserID, "/") Then
    UserLongName = GetObject("WinNT://" & strUserID).FullName
Else
    strDomain = CreateObject("wscript.Network").UserDomain
    UserLongName = GetObject("WinNT://" & strDomain & "/" & strUserID).FullName
End If
              
ExitSub:
    Exit Function
ErrSub:
    Resume ExitSub
                
End Function


Monday, 1 September 2014

An automated 'Copy down' for the formulas in the top row of a range

Here's a common case: you have a regular data import into a sheet, and the columns to the right of the 'landing pad' contain formulae performing calculations on that data.

However, this calculation only needs to be performed once, it's the same for every row, and you don't want it dragging down the performance of the whole workbook when you recalculate it for unrelated data updates...

...So you take the formulae in the first row, copy them down, calculate the lot, and replace all those copied-down formulae with the results as static values.

Not included here: the number of rows varies, so you need to vary the depth of the adjacent calculation range.




Attribute VB_Name = "basCopyCalc"
Option Explicit

' Nigel Heffernan Jan 2009 
' Proof-of-concept for an enhanced 'CopyDown' macro




Public Sub CopyCalc(TargetRange As Excel.Range, _ 
                    Optional NoRecopy As Boolean = False, _ 
                    Optional SkipHeader As Boolean = False, _ 
                    Optional SuppressErrors As Boolean = False)

' Copy the formulae in the first row into all rows of the range
' Calculate the entire range
' Overwrite all formulae in the range (except for the first row) with the calculated values

'    NoRecopy:       The overwrite with static values is skipped if the optional NoRecopy parameter is set TRUE
'    SkipHeader:     Common 'use-case' of a range being a table with a header row, and the first row of formulae being row 2
'    SuppressErrors: You are strongly advised to set this TRUE whenever VBA user-defined functions are present in the formulae

' Note that noncontiguous ranges will be processed one area at a time, with each subrange copying down its own first row

If SuppressErrors Then
    On Error GoTo ErrSub
Else
    On Error Resume Next
End If

Dim lngXLCalculation As Excel.XlCalculation
Dim boolScreenUpdate As Boolean
Dim boolEnableEvents As Boolean

Dim rng As Excel.Range
Dim lngRow As Long

If TargetRange Is Nothing Then
    Exit Sub
End If

If SkipHeader = True Then
    lngRow = 2
Else
    lngRow = 1
End If

boolScreenUpdate = Application.ScreenUpdating
boolEnableEvents = Application.EnableEvents

If Application.Calculation <> xlCalculationManual Then
    Application.Calculation = xlCalculationManual
End If

If Application.EnableEvents = True Then
    Application.EnableEvents = False
End If




For Each rng In TargetRange.Areas

    With rng
    
        If .Rows.Count > 1024 Then
            If Application.ScreenUpdating = True Then
                Application.ScreenUpdating = False
            End If
        End If
        
        If .Rows.Count > lngRow Then
        
            ' Copy down formulae
            .Formula = .Rows(lngRow).Formula
            
            .Calculate
            
            ' Overwrite with static values
            If Not NoRecopy Then
                .Worksheet.Range(.Cells(lngRow + 1, 1), .Cells(.Rows.Count, .Columns.Count)).Value2 = .Worksheet.Range(.Cells(lngRow + 1, 1), .Cells(.Rows.Count, .Columns.Count)).Value2
            End If

        End If
        
    End With
    
Next rng

ExitSub:

   
    On Error Resume Next    ' this code must run, no matter what happens
    
    ' Restore prior application settings
    If Application.ScreenUpdating <> boolScreenUpdate Then
        boolScreenUpdate = Application.ScreenUpdating
    End If
    If Application.EnableEvents <> boolEnableEvents Then
        Application.EnableEvents = boolEnableEvents
    End If
    
    Exit Sub
    
ErrSub:

    Dim strMsg As String
    
     strMsg = ""
     strMsg = strMsg & "The CopyCalc operation on range '" & rng.Worksheet.Name & "'!" & rng.Address & " failed: "
     strMsg = strMsg & vbCrLf & vbCrLf
     strMsg = strMsg & "Excel error " & Err.Number & ": " & Error.Description
     strMsg = strMsg & vbCrLf & vbCrLf
     strMsg = strMsg & "There may be an error in the formulas you are attempting to copy. Try a manual copy and see if you can fix the formulas. If this is a system problem, or a macro error, contact support."

    If Err.HelpContext <> 0 Then
        MsgBox strMsg, vbCritical + vbMsgBoxHelpButton, ThisWorkbook.Name & "CopyCalc Error:", Err.HelpFile, Err.HelpContext
    Else
        MsgBox strMsg, vbCritical, ThisWorkbook.Name & "CopyCalc Error:"
    
    End If
    Resume ExitSub
    
Exit Sub
    ' DEBUGGING ONLY: you can only reach this statement by a manual 'Set Next Statement'
    ' use it to identify the bad line if you've placed a breakpoint in the error-handler
    Resume
    
End Sub


Sunday, 31 August 2014

Reading a closed Excel workbook using ADODB

Here's a rough code sample (check for line breaks!) for reading closed Excel files:
Public Function GetDataFromClosedWorkbook(ByVal SourceFile As String, _
                                          ByVal SourceRange As String, _
                                 Optional ByRef FieldNames As String = "", _
                                 Optional ByVal SkipHeaders As Boolean = False, _
                                 Optional ByVal LocalCopyLifetime As Double = 1#, _
                                 Optional ByVal ForceRecopy As Boolean = False, _
                                 Optional ByVal Asynchronous As Boolean = False) As Variant

Application.Volatile False
On Error GoTo ErrSub

' Read a Range in a closed workbook (which remains closed throughout the 
'  operation - we do not open the file in Excel.exe)
' Returns a TRANSPOSED 2-dimensional variant array, in which the first column will be the headers

' If your range is a worksheet, append "$" to the worksheet name
' If your range is a defined set of cells on a worksheet, use this format: 
'    Sheet_Name$B1:G1024  (spaces are OK in the worksheet name)
' If you're using workbook-level named range, just supply the name
' If you're querying a csv file, don't bother with a sheet or range name. The filename is the 'table' 

' SkipHeaders = TRUE means that the top row of your data range will NOT be 
' treated as part of the data to be returned
' Set SkipHeaders=True if you pass the parameter SourceRange as 
' a SQL query instead of a range or table name


' FieldNames will be populated by a comma-delimited string containing 
' the field names if SkipHeaders is True



' Note that we do not attempt to examine files on network folders: we always copy to a temporary folder

'      - However, we'll only overwrite a pre-existing local copy if the pre-existing
'        file is older than LocalCopyLifetime days
'      - While the copy-to-local-folder operation in running in asynchronous mode,
'        the function will return #WAITING FOR FILE TRANSFER




Dim objFSO      As Object   '  late-binding: imperfect, but it means we can drag-and-drop this sheet without creating references
Dim objConnect  As Object   '  ADODB.Connection
Dim rst         As Object   '  ADODB.Recordset
Dim strConnect  As String

Dim i           As Long
Dim j           As Long
Dim arrData     As Variant
Dim TempFile    As String
Dim strTest     As String
Dim SQL         As String
Dim iColCount   As Long
Dim strPathFull As String


Dim strHeaders As String

If SourceFile = "" Then
    Exit Function
End If


' ****  Parse out web folder paths ' **** **** **** **** **** **** **** **** **** **** **** ****

If Left(SourceFile, 5) = "http:" Then

    SourceFile = Right(SourceFile, Len(SourceFile) - 5)
    SourceFile = Replace(SourceFile, "%20", " ")
    SourceFile = Replace(SourceFile, "%160", " ")
    SourceFile = Replace(SourceFile, "/", "\")

End If


strPathFull = SourceFile


If Len(Dir(SourceFile)) = 0 Then
    ReDim arrTemp(1 To 1, 1 To 1)
    arrTemp(1, 1) = "#ERROR Source file not found"
    GetDataFromClosedWorkbook = arrTemp
    Erase arrTemp
    Exit Function
End If


' **** Copy remote files to the local drive: **** **** **** **** **** **** **** **** **** **** **** 

If objFSO Is Nothing Then
    Set objFSO = CreateObject("Scripting.FileSystemObject") ' New Scripting.FileSystemObject
End If

If objFSO Is Nothing Then
    Shell "Regsvr32.exe /s scrrun.dll", vbHide
    Application.Wait (Now() + 5 / 3600 / 24)
    Set objFSO = CreateObject("Scripting.FileSystemObject")
End If

If objFSO Is Nothing Then
    Exit Function
End If


TempFile = objFSO.GetSpecialFolder(2).Path & "\" & Filename(SourceFile)
    
    
If ForceRecopy Then

    If Len(VBA.FileSystem.Dir(TempFile)) > 0 Then
        VBA.FileSystem.Kill TempFile
    End If
    
End If



If Not (Left(SourceFile, 3) = "C:\" Or Left(SourceFile, 3) = "D:\") Then
  
    If Len(VBA.FileSystem.Dir(TempFile)) > 0 Then
    
        On Error Resume Next
        If VBA.FileSystem.FileDateTime(TempFile) < VBA.FileSystem.FileDateTime(SourceFile) Then
            VBA.FileSystem.Kill TempFile
        ElseIf objFSO.GetFile(TempFile).dateLastAccessed < (Now - LocalCopyLifetime) Then
            VBA.FileSystem.Kill TempFile
        End If
        
    End If
    
    If Len(VBA.FileSystem.Dir(TempFile)) = 0 Then
    
        If Asynchronous Then
            Shell "cmd /c COPY " & Chr(34) & SourceFile & _ 
                   Chr(34) & " " & Chr(34) & TempFile & Chr(34), vbHide
            ReDim arrTemp(1 To 1, 1 To 1)
            arrTemp(1, 1) = "#WAITING FOR FILE TRANSFER. Please try again in a minute."
            GetDataFromClosedWorkbook = arrTemp
            Erase arrTemp
            Exit Function
        Else
     
            VBA.FileSystem.FileCopy SourceFile, TempFile
            
        End If
        
    Else
        SourceFile = TempFile
    End If

End If


' ****  Decide whether we need to read a header row separately from the main body of the data: ' **** **** 

If InStr(1, SourceRange, "SELECT", vbTextCompare) > 0 And _ 
   InStr(7, SourceRange, "FROM", vbTextCompare) > 1 _ 
Then
    strHeaders = "HDR=Yes"
    'SkipHeaders = True
ElseIf SkipHeaders = True Then
    strHeaders = "HDR=Yes"
Else
    strHeaders = "HDR=No"
End If





' **** Connect to the file: ' **** **** **** **** **** **** **** **** **** **** **** ****' **** **** 

        Application.StatusBar = "Connecting to " & SourceFile & "..."
    
    
        If Right(SourceFile, 4) = ".xls" Then    '
        
            'strConnect = "DRIVER={Microsoft Excel Driver (*.xls, *.xlsx, *.xlsm, *.xlsb)};" & _ 
                            "ReadOnly=1;DBQ=" & Chr(34) & SourceFile & Chr(34) & ";" & _ 
                            ";Extended Properties=" & _ 
                            Chr(34) & "HDR=No;IMEX=1;MaxScanRows=0" & Chr(34) & ";"
            
            strConnect = "Provider=Microsoft.Jet.OLEDB.4.0;_ 
                            Data Source=" & Chr(34) & SourceFile & Chr(34) & ";_ 
                            Extended Properties=" & Chr(34) & "Excel 8.0;" & _ 
                            strHeaders & ";IMEX=1;MaxScanRows=0" & Chr(34) & ";"
          
           ' strConnect = "Provider=Microsoft.ACE.OLEDB.12.0;_ 
                            Data Source=" & Chr(34) & SourceFile & Chr(34) & ";_ 
                            Extended Properties=" & Chr(34) & "Excel 8.0;" & _ 
                            strHeaders & ";IMEX=1;MaxScanRows=0" & Chr(34) & ";"
          
        ElseIf Right(SourceFile, 5) = ".xlsx" Then
        
            strConnect = "Provider=Microsoft.ACE.OLEDB.12.0;_ 
                            Data Source=" & Chr(34) & SourceFile & Chr(34) & ";_ 
                            Extended Properties=" & Chr(34) & "Excel 12.0 Xml;" & _ 
                            strHeaders & ";IMEX=1;MaxScanRows=0" & Chr(34) & ";"
        
        ElseIf Right(SourceFile, 5) = ".xlsm" Then
            
            'strConnect = "Driver={Microsoft Excel Driver (*.xls, *.xlsx, *.xlsm, *.xlsb)};"_ 
                             & "ReadOnly=1;_ 
                            DBQ=" & SourceFile & ";" & Chr(34) & SourceFile & Chr(34) & ";" & ";_ 
                            Extended Properties=" & Chr(34) & "Excel 12.0;" & _ 
                            strHeaders & ";IMEX=1;MaxScanRows=0" & Chr(34) & ";"
     
            strConnect = "Provider=Microsoft.ACE.OLEDB.12.0; _ 
                            Data Source=" & Chr(34) & SourceFile & Chr(34) & ";_ 
                            Extended Properties=" & Chr(34) & "Excel 12.0 Macro;" & _ 
                            strHeaders & ";IMEX=1;MaxScanRows=0" & Chr(34) & ";"
        
        ElseIf Right(SourceFile, 5) = ".xlsb" Then
          
            'strConnect = "Driver={Microsoft Excel Driver (*.xls, *.xlsx, *.xlsm, *.xlsb)};" &  _ 
            '"ReadOnly=1;DBQ=" & SourceFile & ";" & Chr(34) & SourceFile & Chr(34) & ";" &  _ 
            ' ";Extended Properties=" & Chr(34) & "Excel 12.0;" & strHeaders & "; _ 
            ' IMEX=1;MaxScanRows=0" & Chr(34) & ";"

            ' This ACE driver is unstable on xlsb files... 
            ' But it's more likely to return a result, if you don't mind crashes:
            strConnect = "Provider=Microsoft.ACE.OLEDB.12.0; _ 
                          Data Source=" & Chr(34) & SourceFile & Chr(34) &  _ 
                          ";Extended Properties=" & Chr(34) & "Excel 12.0;" & strHeaders _  & "; _ 
                            IMEX=1;MaxScanRows=0" & Chr(34) & ";"
        
        ElseIf Right(SourceFile, 4) = ".csv" Or Right(SourceFile, 4) = ".txt" Then
        
            ' JET OLEDB text driver connection string:
            '   Provider=Microsoft.Jet.OLEDB.4.0;Data Source=c:\txtFilesFolder\;
            '     Extended Properties="text;HDR=Yes;FMT=Delimited;MaxScanRows=;IMEX=1;"; 

            ' ODBC text driver connection string:
            '   Driver={Microsoft Text Driver 
            '    (*.txt; *.csv)};Dbq=c:\txtFilesFolder\;Extensions=asc,csv,tab,txt;


            strConnect = "Provider=Microsoft.Jet.OLEDB.4.0;Data Source=" & _ 
                          Chr(34) & Folder(SourceFile) & Chr(34) & ";"
            strConnect = strConnect & "Extended Properties=" & Chr(34) &  _ 
                          "text;HDR=Yes;IMEX=1;MaxScanRows=0" & Chr(34) & ";"
            SourceRange = Filename(SourceFile)
            
         
        ElseIf Right(SourceFile, 4) = ".tab" Or Right(SourceFile, 4) = ".dat" Then
        
            ' JET OLEDB text driver connection string:
            '   Provider=Microsoft.Jet.OLEDB.4.0;Data Source=c:\txtFilesFolder\;Extended  
            '    Properties="text;HDR=Yes;FMT=Delimited;MaxScanRows=;IMEX=1;";

            ' ODBC text driver connection string:
            '   Driver={Microsoft Text Driver 
            '     (*.txt; *.csv)};Dbq=c:\txtFilesFolder\;Extensions=asc,csv,tab,txt;



            strConnect = "Provider=Microsoft.Jet.OLEDB.4.0;Data Source=" &  _ 
                         Chr(34) & Folder(SourceFile) & Chr(34) & ";"
            strConnect = strConnect & "Extended Properties=" & Chr(34) & _ 
                         "text;HDR=Yes;IMEX=1;MaxScanRows=0;DELIMITER=TAB" & Chr(34) & ";"
            SourceRange = Filename(SourceFile)
           
        
        Else
        
            ReDim arrTemp(1 To 1, 1 To 1)
            arrTemp(1, 1) = "#ERROR - file format not known"
            GetDataFromClosedWorkbook = arrTemp
            Erase arrTemp
            
        End If
        
        On Error GoTo ErrSub
        
        Set objConnect = CreateObject("ADODB.Connection")   ' New ADODB.Connection
        With objConnect
        
            .ConnectionTimeout = 60
            .CommandTimeout = 90
            .Mode = 1 ' adModeRead = 1
            .ConnectionString = strConnect
    
            .Open
    
        End With

    
' ****  Retrieve the data: ' **** **** **** **** **** **** **** **** **** **** **** **** **** 
    
    Set rst = CreateObject("ADODB.Recordset") '  New ADODB.Recordset
    
    With rst

        .CursorLocation = 3
        
        If Right(ThisWorkbook.Name, 4) = ".xls" Then
            .MaxRecords = 65535
        Else
            .MaxRecords = 1048575
        End If
        
        If InStr(1, SourceRange, "SELECT", vbTextCompare) > 0  _ 
        And InStr(7, SourceRange, "FROM", vbTextCompare) > 1 Then
            SQL = SourceRange
        Else
           SQL = "SELECT * FROM [" & SourceRange & "] "
        End If
        
        Application.StatusBar = "Querying " & SourceFile & "..."
        
        '.Open SQL, objConnect, adOpenStatic, adLockReadOnly, adCmdText + adAsyncFetch
        .Open SQL, objConnect, 3, 1, 1 + 32
 
        i = 0
        Do While .State > 1
            i = (i + 1) Mod 3
            Application.StatusBar = "Retrieving data from " & SourceFile & String(i, ".")
            Application.Wait Now + (0.25 / 24 / 3600)
        Loop
        
    
    End With
        
    
' ****  Handle the returned data ' **** **** **** **** **** **** **** **** **** **** **** ****  

On Error Resume Next
' resume next is required, as the errors we anticipate cannot be trapped:
' they can only be detected after the fact
    
    
    For i = 0 To rst.Fields.Count - 1
        FieldNames = FieldNames & rst.Fields(i).Name & ","
    Next i
    FieldNames = Left(FieldNames, Len(FieldNames) - 1)
    
    If rst.EOF And rst.BOF Then
        'return a single empty rpw, so that the caller doesn't error out
        ReDim arrTemp(1 To rst.Fields.Count, 1 To 1)   'remember, its a transposed array
        arrTemp(1, 1) = "#NO MATCHING DATA IN '" & SourceFile & "' USING '" & SQL & "'"
        GetDataFromClosedWorkbook = arrTemp
    Else
        Err.Clear
        rst.MoveFirst
        GetDataFromClosedWorkbook = rst.GetRows   
        ' note that this often fails on the first try.
        
        If IsEmpty(GetDataFromClosedWorkbook) Then
            rst.MoveFirst
            GetDataFromClosedWorkbook = rst.GetRows
        End If
        
        If IsEmpty(GetDataFromClosedWorkbook) Then  
        ' ...And on the second try. GetRows is fast when it works, but cannot be relied on
            
            rst.MoveFirst
            ReDim arrTemp(0 To rst.Fields.Count - 1, 0 To rst.RecordCount)
            i = 0
            j = 0
            Do Until rst.EOF
            
                Err.Clear
                If i > UBound(arrTemp, 2) Then
                    ReDim Preserve arrTemp(0 To rst.Fields.Count - 1, 0 To i)
                End If
                
                For j = 0 To rst.Fields.Count - 1
                    arrTemp(j, i) = rst.Fields(j).Value
                    If Err.Number = &HBCD Then
                        Exit For
                    End If
                Next j
                
                i = i + 1
            
           
                If Err.Number <> &HBCD Then
                    rst.MoveNext
                End If
                
                If Err.Number <> &H80004005 And Err.Number <> 0 Then
                    Exit Do
                End If
                
            Loop
            
            GetDataFromClosedWorkbook = arrTemp
            Erase arrTemp
            
        End If ' IsEmpty(GetDataFromClosedWorkbook)
        
        
    End If '  rst.EOF And rst.BOF Then
    
    
   
    
    
    
ExitSub:
On Error Resume Next

    rst.Close
    objConnect.Close ' close the database connection
    
    Set rst = Nothing
    Set objConnect = Nothing

    

    Exit Function
    
ErrSub:

    ReDim arrTemp(1 To 1, 1 To 1)
    If InStr(Err.Description, "not a valid name") Then
        arrTemp(1, 1) = "#ERROR: cannot retrieve data from '" & SourceRange & "'t"
        MsgBox "Cannot open the file: " & vbCrLf & vbCrLf & strPathFull & vbCrLf & vbCrLf & _ 
        "This error message probably means that the source file is locked because another  _ 
        user has this file open. Please wait a few minutes, and try again." & vbCrLf &  _ 
        vbCrLf & "If this error persists, please contact the tech team.", vbCritical,  _ 
        APP_NAME & ": file access error:"
    
    ElseIf InStr(Err.Description, "cannot open the file") Then
        arrTemp(1, 1) = "#ERROR: Cannot open the file '" & SourceRange & "'t"
        MsgBox "Cannot open the file: " & vbCrLf & vbCrLf & strPathFull & vbCrLf  _ 
        & vbCrLf & "This error message probably means that the source file is  _ 
        locked because another user has this file open. Please wait a few minutes,  _ 
        and try again." & vbCrLf & vbCrLf & "If this error persists, please contact  _ 
        the tech team.", vbCritical, APP_NAME & ": file access error:"
    
    ElseIf InStr(Err.Description, "not find the object") Then
        arrTemp(1, 1) = "#ERROR: Invalid object name in '" & SourceRange & "'" _         
        MsgBox Err.Description & vbCrLf & vbCrLf & "This error message probably  _ 
        means that the worksheet or range has been renamed, or does not exist in  _ 
        the file. Please check your file: if you can't see an obvious error, ask  _ 
        for help from the tech team.", vbCritical, APP_NAME & ": file data error:"
    
    ElseIf InStr(Err.Description, "Permission Denied") Then
        arrTemp(1, 1) = "#ERROR Access to file"
        MsgBox "Cannot open the file: " & vbCrLf & vbCrLf & strPathFull & vbCrLf & vbCrLf &  _ 
        "Another user probably has this file open. Please wait a few minutes, and try again."  _ 
        & vbCrLf & vbCrLf & "If this error persists, please contact tech team.",  _ 
        vbCritical, APP_NAME & ": file access error:"
    
    Else
        arrTemp(1, 1) = "#ERROR " & Err.Number & ": " & Err.Description
    End If
    
    GetDataFromClosedWorkbook = arrTemp
    Erase arrTemp
   
    Resume ExitSub
    Resume
    
End Function



Sunday, 23 June 2013

Code for a version-independent date picker

By now, you're either working entirely in 64-bit windows and the 64-bit versions of VBA in Office 2010 and 2013, or sort-of-stuck with running office in 32-bit mode because all the 32-bit OCX and COM objects are broken. A good (or bad) example of this is the Date-Picker control, and I ended up coding a native VBA version that runs on either OS. This code won't do it all for you: you've got to build the form and create the controls - I don't do downloads on this server - but it's a good overview of the coding you've got to do to make a date-picker work.


Option Explicit

' Version-independent date-picker form, entirely
' built in MSForms controls and native VBA.

' Note that the date-selector functions for month
' and year respect end-of-month: jumping back one
' month from March 31st goes to February 28th and
' jumping forward one month from Feb 28th goes to
' March 31st - not March 28th, the result you get
' using the native Excel and VBA date arithmetic.



'   ********************************************


'   Author: Nigel Heffernan
'   June 2013  http://excellerando.blogspot.com

'   **** THIS CODE IS IN THE PUBLIC DOMAIN ****
'
'    You are advised to segregate this code from
'   any proprietary or commercially-confidential
'   source code, and to label it clearly. If you
'   fail do do so, there is a risk that you will
'   impair your right to assert ownership of any
'   intellectual property embedded in your work;
'   or impair your employers or clients' ability
'   to do so if the intellectual property rights
'   in your work have been assigned to them.
'
'   You are free to use this code as-is, but all
'   use is entirely at your own risk: the author
'   accepts no liability arising from the use of
'   this source code or any work derived from it
'   and no warranty is offered or implied.
'
'   * YOU ARE EXPECTED TO DO YOUR OWN TESTING  *
'
'    You are asked, as a matter of professional
'    courtesy, to acknowledge the author of any
'    source code that you incorporate into your
'    work, with a link to author's website or a
'    link to the relevant open-source community
'    site if that was where you found the code.
'
'   You are strongly advised to include both the
'   copyright and liability disclaimers, and to
'   consult your company's legal advisors with a
'   view to providing equivalent and appropriate
'   notices and disclaimers.

'   ********************************************



'SAMPLE USAGE: FUNCTION TO OPEN THE FORM AND RETURN THE SELECTED DATE

'Option Explicit
'Option Private Module  ' Don't expose this for use in formulas

'Public Function DatePicker(Optional StartDate As Date = 0, _
'                  Optional LinkedCell As Excel.Range, _
'                  Optional Caption As String = "Select date") As Date
'
'' Open a date picker form and return the date selected by the user.
'
'' This function respects end-of-month: jumping forward a month from
'' February 28th lands on March 31st, not March 28th.
'
'' Clicking Cancel, or the form's window close button, will discard
'' the user's selection and return the initial date.

'If StartDate = 0 Then
'    StartDate = VBA.Date
'End If
'
'With frmDatePicker
'
'    .Caption = Caption
'
'    If LinkedCell Is Nothing Then
'        ' no action
'    ElseIf Not IsDate(LinkedCell.Cells(1, 1).Value) Then
'        ' no action
'    Else
'        .txtSelectedDate.ControlSource = Chr(39) & LinkedCell.Worksheet.Name & Chr(39) & "!" & LinkedCell.Cells(1, 1).Address
'        If IsDate(LinkedCell.Value) Or IsNumeric(LinkedCell.Value2) Then
'            StartDate = CVDate(LinkedCell.Value)
'        Else
'            StartDate = VBA.Date
'        End If
'    End If
'
'    .InitialDate = StartDate
'
'    .StartUpPosition = 0 'manual
'    .Left = Application.Left + (0.5 * Application.Width) - (0.5 * .Width)
'    .Top = Application.Top + (0.5 * Application.Height) - (0.5 * .Height)
'
'    .Show
'
'End With
'
'' This 'With' block exit and re-entry avoids OLE disconnection errors if the form window is closed
'
'With frmDatePicker
'
'    If .Cancel Then
'
'        DatePicker = StartDate
'        If Not LinkedCell Is Nothing Then
'            LinkedCell.Value2 = StartDate
'        End If
'
'    Else
'
'        DatePicker = .SelectedDate
'        If Not LinkedCell Is Nothing Then
'            LinkedCell.Value2 = .SelectedDate
'        End If
'
'    End If
'
'End With
'
'
'Unload frmDatePicker
'
'End Function

'  *************************************************************************************


#If VBA7 And Win64 Then    ' 64 bit Excel
    Private Declare PtrSafe Sub Sleep Lib "kernel32" (ByVal dwMilliseconds As LongLong)
#Else    ' 32 bit Excel
    Private Declare Sub Sleep Lib "kernel32" (ByVal dwMilliseconds As Long)
#End If


Private Const BTN_MEDIUM As Long = &HE0E0E0
Private Const BTN_DARK_1 As Long = &HD0D0D0
Private Const BTN_LIGHT  As Long = &HF0F0F0

Private Const FONT_DARK  As Long = &H800000
Private Const FONT_LIGHT As Long = &HFFFFA0
Private Const FONT_GREY  As Long = &H808080

Private Const BTN_DELAY  As Long = 150

Private Const YEAR_START As Long = -3

Private m_dtSelected As Date    ' The selected date, as displayed
Private m_dtInitial  As Date    ' an initial date set externally by a VBA caller
Private m_BaseDate   As Date    ' A nominal date corresponding to day button zero
Private m_Month      As Long    ' The current month (as 1-12)
Private m_Year       As Long    ' The current year

Public Cancel As Boolean        ' Cancel remains true until the user performs some
                                ' action that selects a date, or clicks 'OK'


Public Property Get InitialDate() As Date

    InitialDate = m_dtInitial
    
End Property


Public Property Let InitialDate(DateInitial As Date)
' InitialDate is the date returned to callers when Cancel=True

'   Cancel is set TRUE on initialisation, or on setting this property
'   All user actions that select a date set Cancel=False
'   The user action 'Cancel' sets Cancel=True

    m_dtInitial = DateInitial
    SelectedDate = m_dtInitial
    Cancel = True
    
End Property


Public Property Get SelectedDate() As Date
' Return the date currently selected

    SelectedDate = m_dtSelected
    
End Property



Public Property Let SelectedDate(DateSelected As Date)

Dim lngLabel As Long
Dim strLabel As String

On Error Resume Next
Application.EnableEvents = False

    m_dtSelected = DateSelected
    
    If m_Month <> Month(DateSelected) Or m_Year <> Year(DateSelected) Then
        m_Month = Month(DateSelected)
        m_Year = Year(DateSelected)
        'ResetFormats
        DisplayMonth m_Year, m_Month
    End If
    
    'lng Label is the ordinal (1 to 42) of the 'day' button for the selected date
    lngLabel = DateSelected - m_BaseDate
    strLabel = "day" & Right("00" & lngLabel, 2)
    
    DayButton_Click Me.Controls(strLabel)
    
    Me.txtSelectedDate = Format(DateSelected, "dd-mmm-yyyy")
    Me.cboMonth.Text = Format(DateSelected, "mmmm")
    Me.cboYear.Text = Format(DateSelected, "yyyy")
    
    Me.Cancel = False
    
Application.EnableEvents = True
    
End Property

Private Sub DisplayMonth(iYear As Long, lngMonth As Long)


Dim lngStartDate As Long  ' First day of this month
Dim lngEndDate   As Long  ' First day of this month
Dim strCtrlName  As String
Dim lngLabel     As Long
Dim lngWeekDay   As Long
Dim lngDate      As Long  ' local variable, incremented in a loop


lngStartDate = DateSerial(iYear, lngMonth, 1)
lngEndDate = DateSerial(iYear, lngMonth + 1, 1) - 1  ' DateSerial(2014, 13, 1) ' actually works in VBA

lngWeekDay = Weekday(lngStartDate)
m_BaseDate = lngStartDate - lngWeekDay

For lngLabel = 1 To 42

    lngDate = m_BaseDate + lngLabel
    
    strCtrlName = "day" & Right("00" & lngLabel, 2)
    
    With Me.Controls(strCtrlName)
    
        .Caption = Day(lngDate)
        
        If lngDate = m_dtSelected Then
            FormatSelected Me.Controls(strCtrlName)
        Else
            FormatDeselected Me.Controls(strCtrlName), lngMonth
        End If
        
    End With
    
Next lngLabel

End Sub


Private Sub FormatSelected(ctrl As MSForms.Control)
' format the day selector control  for a selection 'click'


Dim lngLabel As Integer

With ctrl
        
    .SpecialEffect = fmSpecialEffectSunken
    
    lngLabel = CInt(Right(.Name, 2))
    
    If lngLabel Mod 7 > 1 Then
        .BackColor = BTN_LIGHT - &H606060
    Else
        .BackColor = BTN_MEDIUM - &H606060
    End If
    
    .ForeColor = &HFFFFB0   ' FONT_LIGHT
    .Font.Bold = True
    
End With

End Sub

Private Sub FormatDeselected(ctrl As MSForms.Control, Optional lngMonth As Long = 0)
' format the day selector control  for a selection 'click'


Dim lngLabel As Integer
Dim lngDate  As Long

If lngMonth = 0 Then
    lngMonth = Month(m_dtSelected)
End If

With ctrl
        
    .SpecialEffect = fmSpecialEffectEtched
    
    lngLabel = CInt(Right(.Name, 2))
    lngDate = m_BaseDate + lngLabel
    
    If lngLabel Mod 7 > 1 Then
        .BackColor = BTN_LIGHT
    Else
        .BackColor = BTN_MEDIUM
    End If
    
    .Font.Bold = False
    
    If lngLabel Mod 7 > 1 Then
        .BackColor = BTN_LIGHT
    Else
        .BackColor = BTN_MEDIUM
    End If
    
    If Month(lngDate) = lngMonth Then
        '.Enabled = True
        .ForeColor = FONT_DARK
    Else
        '.Enabled = False
        .ForeColor = FONT_GREY
        .BackColor = BTN_MEDIUM
    End If
    
    
End With

End Sub

Private Sub DayButton_Click(ctrlClicked As MSForms.Control, Optional SetDate As Boolean = True)

Dim lngLabel As Integer
Dim ctrl As MSForms.Control
   
    
    For Each ctrl In Me.Controls
    
        ' Enforce 'toggle' behaviour: deselect any other date button that's selected
        
        If Left(ctrl.Name, 3) = "day" And ctrl.Name <> ctrlClicked.Name Then
        
            If ctrl.SpecialEffect = fmSpecialEffectSunken Then
            
                FormatDeselected ctrl
                
            End If
            
        End If
        
    Next
 
    With ctrlClicked
    
        lngLabel = CInt(Right(.Name, 2))
        
        If .SpecialEffect = fmSpecialEffectEtched Then
        
            FormatSelected ctrlClicked
            
            If SetDate Then
                SelectedDate = m_BaseDate + lngLabel
            End If
            
        End If
        
    End With


End Sub


Private Sub cboMonth_Change()

Dim lngDate As Date
Dim lngMonth As Long
Dim lngShift As Long

If Application.EnableEvents = False Then Exit Sub

lngDate = SelectedDate
lngMonth = cboMonth.ListIndex + 1
lngShift = lngMonth - Month(lngDate)

ShiftMonth lngShift

End Sub


Public Sub ShiftMonth(lngShift As Long)

Dim lngDate As Date
lngDate = SelectedDate

    ' Respect end-of-month logic: adding 1 month to Feb 28th does NOT equal March 28
    If DateSerial(Year(lngDate), Month(lngDate) + 1, 1) - 1 = lngDate Then ' start from EOM
        SelectedDate = DateSerial(Year(lngDate), Month(lngDate) + lngShift + 1, 1) - 1
    ElseIf DateSerial(Year(lngDate), Month(lngDate), 1) = lngDate Then    ' start from BOM
        SelectedDate = DateSerial(Year(lngDate), Month(lngDate) + lngShift, 1)
    Else
        SelectedDate = DateAdd("m", lngShift, lngDate)
    End If


End Sub


Private Sub cboYear_Change()
If Application.EnableEvents = False Then Exit Sub

Dim lngDate As Date
Dim lngYear As Long
Dim lngShift As Long


If Len(cboYear.Text) < 2 Then Exit Sub

lngDate = SelectedDate
lngYear = cboYear.Text

If lngYear > -1 And lngYear < 100 Then
    lngYear = Year(SelectedDate) - (Year(SelectedDate) Mod 100) + lngYear
End If

lngShift = lngYear - Year(lngDate)

ShiftYear lngShift

End Sub


Public Sub ShiftYear(lngShift As Long)

Dim lngDate As Date
lngDate = SelectedDate

    ' Respect end-of-month logic: adding 1 month to Feb 28th does NOT equal March 28
    If DateSerial(Year(lngDate), Month(lngDate) + 1, 1) - 1 = lngDate Then ' start from EOM
        SelectedDate = DateSerial(Year(lngDate) + lngShift, Month(lngDate) + 1, 1) - 1
    ElseIf DateSerial(Year(lngDate), Month(lngDate), 1) = lngDate Then    ' start from BOM
        SelectedDate = DateSerial(Year(lngDate) + lngShift, Month(lngDate), 1)
    Else
        SelectedDate = DateAdd("yyyy", lngShift, lngDate)
    End If


End Sub


' ***  Note the use of label controls instead of MSForms butttons
' The MS Forms 'button' controls don't support the fine detail we
' need in this kind of densely-packed and densely-functional form

Private Sub cmdDateDec_Click()
' Microbutton decrementing a textbox: dynamic formatting required
With cmdDateDec
    .Top = .Top - 0.75
    .SpecialEffect = fmSpecialEffectSunken
    Me.Repaint
    Sleep BTN_DELAY
    Me.SelectedDate = Me.SelectedDate - 1
    .Top = .Top + 0.75
    .SpecialEffect = fmSpecialEffectFlat
End With

End Sub

Private Sub cmdDateInc_Click()
' Microbutton incrementing a textbox: dynamic formatting required
With cmdDateInc
    .Top = .Top - 0.75
    .SpecialEffect = fmSpecialEffectSunken
    Me.Repaint
    Sleep BTN_DELAY
    Me.SelectedDate = Me.SelectedDate + 1
    .SpecialEffect = fmSpecialEffectFlat
    .Top = .Top + 0.75
End With

End Sub

Private Sub cmdMonthDec_Click()
' Microbutton decrementing a textbox: dynamic formatting required
With cmdMonthDec
    .Top = .Top - 0.75
    .SpecialEffect = fmSpecialEffectSunken
    Me.Repaint
    Sleep BTN_DELAY
    ShiftMonth -1
    .Top = .Top + 0.75
    .SpecialEffect = fmSpecialEffectFlat
End With

End Sub

Private Sub cmdMonthInc_Click()
' Microbutton incrementing a textbox: dynamic formatting required
With cmdMonthInc
    .Top = .Top - 0.75
    .SpecialEffect = fmSpecialEffectSunken
    Me.Repaint
    Sleep BTN_DELAY
    ShiftMonth 1
    .SpecialEffect = fmSpecialEffectFlat
    .Top = .Top + 0.75
End With

End Sub

Private Sub cmdYearDec_Click()
' Microbutton decrementing a textbox: dynamic formatting required
With cmdYearDec
    .Top = .Top - 0.75
    .SpecialEffect = fmSpecialEffectSunken
    Me.Repaint
    Sleep BTN_DELAY
    ShiftYear -1
    .Top = .Top + 0.75
    .SpecialEffect = fmSpecialEffectFlat
End With

End Sub

Private Sub cmdYearInc_Click()
' Microbutton incrementing a textbox: dynamic formatting required
With cmdYearInc
    .Top = .Top - 0.75
    .SpecialEffect = fmSpecialEffectSunken
    Me.Repaint
    Sleep BTN_DELAY
    ShiftYear 1
    .SpecialEffect = fmSpecialEffectFlat
    .Top = .Top + 0.75
End With

End Sub

Private Sub cmdCancel_Click()

cmdCancel.SpecialEffect = fmSpecialEffectSunken

Me.Repaint
Sleep BTN_DELAY
Me.SelectedDate = m_dtInitial
Me.Cancel = True
cmdCancel.SpecialEffect = fmSpecialEffectEtched

Me.Hide
 
End Sub


Private Sub cmdOK_Click()

cmdOK.SpecialEffect = fmSpecialEffectSunken
Me.Repaint
Sleep BTN_DELAY
cmdOK.SpecialEffect = fmSpecialEffectEtched

Me.Hide

End Sub


Private Sub txtSelectedDate_Change()

Dim lngYear As Long
Dim strDate As String
Dim arrDate As Variant
Dim lngDate As Variant
Dim varTemp As Variant

If Application.EnableEvents = False Then Exit Sub

txtSelectedDate.Text = Replace(txtSelectedDate.Text, "/", "-")
txtSelectedDate.Text = Replace(txtSelectedDate.Text, " ", "-")
txtSelectedDate.Text = Replace(txtSelectedDate.Text, ",", "-")
txtSelectedDate.Text = Replace(txtSelectedDate.Text, ".", "-")
txtSelectedDate.Text = Replace(txtSelectedDate.Text, "--", "-")

arrDate = Split(txtSelectedDate.Text, "-")

If UBound(arrDate) < 2 Then Exit Sub
If UBound(arrDate) > 2 Then ReDim Preserve arrDate(0 To 2)

If Len(CStr(arrDate(0))) > 2 Then

    ' Swap mmm-dd-yyyy to dd-mmm-yyyy
    If IsNumeric(arrDate(1)) And Not IsNumeric(arrDate(0)) Then
        varTemp = arrDate(0)
        arrDate(0) = arrDate(1)
        arrDate(1) = varTemp
    
    ' Swap 05-26-2011 to 26-05-2011
    If IsNumeric(arrDate(1)) And IsNumeric(arrDate(0)) Then
        If arrDate(1) > 12 And arrDate(0) < 12 Then
            varTemp = arrDate(0)
            arrDate(0) = arrDate(1)
            arrDate(1) = varTemp
        End If
    End If
    
    ' Swap yyyy-mmm-dd to dd-mmm-yyyy
    ElseIf Len(CStr(arrDate(0))) = 4 And Len(arrDate(2)) < 3 Then
        varTemp = arrDate(0)
        arrDate(0) = arrDate(2)
        arrDate(2) = varTemp
    End If

    
End If

If Not IsNumeric(arrDate(0)) Then Exit Sub
If arrDate(0) < 1 Then Exit Sub

If Not IsNumeric(arrDate(2)) Then
    Exit Sub
ElseIf Len(arrDate(2)) < 2 Then
    Exit Sub                        'do nothing, the user is still typing the year
ElseIf Left(arrDate(2), 2) = CStr((Year(Date) \ 100)) And Len(arrDate(2)) < 4 Then
    Exit Sub   'do nothing, the user is still typing the year
End If

strDate = "00" & Right(arrDate(0), 2) & "-" & arrDate(1) & "-" & arrDate(2)

If IsDate(strDate) Then
    Me.SelectedDate = CVDate(strDate)
End If


End Sub

Private Sub txtSelectedDate_DblClick(ByVal Cancel As MSForms.ReturnBoolean)

With txtSelectedDate

    If IsDate(.Value) Then
    
        Select Case .SelStart
        Case 1, 2
        
            Me.SelectedDate = Me.SelectedDate + 1
            
        Case 3, 4, 5, 6
        
            ' Respect end-of-month logic: adding 1 month to Feb 28th does NOT equal March 28
            If Month(SelectedDate + 1) <> Month(SelectedDate) Then
                SelectedDate = DateSerial(Year(SelectedDate), Month(SelectedDate) + 2, 1) - 1
            Else
                SelectedDate = DateAdd("m", 1, SelectedDate)
            End If
            
        Case Is > 7
        
            ' However, we do not apply EOM logic for leap years: it surprises the users
             SelectedDate = DateAdd("yyyy", 1, SelectedDate)
        
        End Select
        
    End If

End With


End Sub

Private Sub txtSelectedDate_Exit(ByVal Cancel As MSForms.ReturnBoolean)

   'Supports direct user edits in the control
    If IsDate(txtSelectedDate.Value) Then
    
        If Me.SelectedDate <> CVDate(txtSelectedDate.Value) Then
            Me.SelectedDate = CVDate(txtSelectedDate.Value)
        End If
        
    End If

End Sub


Private Sub UserForm_Initialize()

Dim lngLabel As Long
Dim strLabel As String
Dim lngDate     As Long
    
    Me.Caption = "Select Date"
    
    
    SelectedDate = Date
    
    ' Populate day name labels, lblDay1 to lblDay7
    ' Doing this in code picks up the locale's day
    ' abbreviations - test this on a 'French' PC
    
    lngDate = SelectedDate
    Do Until Weekday(lngDate) = 7
        lngDate = lngDate + 1
    Loop
    
    For lngLabel = 1 To 7
       
        strLabel = "lblDay" & lngLabel
        Me.Controls(strLabel).Caption = Format(lngDate + lngLabel, "ddd")
        
    Next lngLabel
    
    lngDate = SelectedDate
    With cboMonth
        .Clear
        For lngLabel = 1 To 12
            .AddItem Format(DateSerial(Year(lngDate), lngLabel, 1), "mmmm")
        Next lngLabel
        .ListIndex = Month(lngDate) - 1
    End With
    
    lngDate = SelectedDate
    With cboYear
        .Clear
        For lngLabel = YEAR_START To 10
            .AddItem Year(lngDate) + lngLabel
        Next lngLabel
        .ListIndex = -YEAR_START
    End With
    
    Me.InitialDate = lngDate    ' This also sets Me.Cancel = True
                                ' Cancel remains true until the user selects a date
    
End Sub


Private Sub day01_Click(): DayButton_Click day01: End Sub
Private Sub day02_Click(): DayButton_Click day02: End Sub
Private Sub day03_Click(): DayButton_Click day03: End Sub
Private Sub day04_Click(): DayButton_Click day04: End Sub
Private Sub day05_Click(): DayButton_Click day05: End Sub
Private Sub day06_Click(): DayButton_Click day06: End Sub
Private Sub day07_Click(): DayButton_Click day07: End Sub
Private Sub day08_Click(): DayButton_Click day08: End Sub
Private Sub day09_Click(): DayButton_Click day09: End Sub
Private Sub day10_Click(): DayButton_Click day10: End Sub
Private Sub day11_Click(): DayButton_Click day11: End Sub
Private Sub day12_Click(): DayButton_Click day12: End Sub
Private Sub day13_Click(): DayButton_Click day13: End Sub
Private Sub day14_Click(): DayButton_Click day14: End Sub
Private Sub day15_Click(): DayButton_Click day15: End Sub
Private Sub day16_Click(): DayButton_Click day16: End Sub
Private Sub day17_Click(): DayButton_Click day17: End Sub
Private Sub day18_Click(): DayButton_Click day18: End Sub
Private Sub day19_Click(): DayButton_Click day19: End Sub
Private Sub day20_Click(): DayButton_Click day20: End Sub
Private Sub day21_Click(): DayButton_Click day21: End Sub
Private Sub day22_Click(): DayButton_Click day22: End Sub
Private Sub day23_Click(): DayButton_Click day23: End Sub
Private Sub day24_Click(): DayButton_Click day24: End Sub
Private Sub day25_Click(): DayButton_Click day25: End Sub
Private Sub day26_Click(): DayButton_Click day26: End Sub
Private Sub day27_Click(): DayButton_Click day27: End Sub
Private Sub day28_Click(): DayButton_Click day28: End Sub
Private Sub day29_Click(): DayButton_Click day29: End Sub
Private Sub day30_Click(): DayButton_Click day30: End Sub
Private Sub day31_Click(): DayButton_Click day31: End Sub
Private Sub day32_Click(): DayButton_Click day32: End Sub
Private Sub day33_Click(): DayButton_Click day33: End Sub
Private Sub day34_Click(): DayButton_Click day34: End Sub
Private Sub day35_Click(): DayButton_Click day35: End Sub
Private Sub day36_Click(): DayButton_Click day36: End Sub
Private Sub day37_Click(): DayButton_Click day37: End Sub
Private Sub day38_Click(): DayButton_Click day38: End Sub
Private Sub day39_Click(): DayButton_Click day39: End Sub
Private Sub day40_Click(): DayButton_Click day40: End Sub
Private Sub day41_Click(): DayButton_Click day41: End Sub
Private Sub day42_Click(): DayButton_Click day42: End Sub




Sunday, 19 August 2012

Join and Split functions for 2-Dimensional arrays

Here's something I fished out of the attic and posted into StackOverflow...

The code's trivial, in the sense that anyone can do a bit of string-concatenation and a Redim() statement and you've probably done some kind of 'Join' and 'Split' already. But there are a couple of points about efficient string-handling in the comments; or rather, overcoming the inefficiencies of a language which has no string-builder class.

Someday, you are going to find out that concatenating strings slows down *severely* for long strings, and you'll need to know how to work around that.

So, without further ado:

Join2d: A 2-Dimensional Join function in VBA with optimised string-handling

Coding notes:
  1. This 'Join' function does not suffer from the 255-char limitation that affects most (if not all) of the native Concatenate functions in Excel, and the Range.Value code sample above will pass in the data, in full, from cells containing longer strings.
  2. This is heavily optimised: we use string-concatenation as little as possible, as the native VBA string-concatenations are slow and get progressively slower as a longer string is concatenated.
If you want to look more deeply into optimising string-handling in VBA and the VB family of languages, advanced techniques are listed in parts I, II and II of this web article: http://www.aivosto.com/vbtips/stringopt3.html

The biggest performance gain available in native VBA is to avoid allocation and concatenation ( here's why: http://www.aivosto.com/vbtips/stringopt2.html#huge ) - so I use join, split, and replace instead of myString = MyString & MoreString

Bigger gains are available if you use the Kernel string functions directly: after that, you're Googling for LightningStrings and taking the big step into pointer arithmentic... Which I consider a step too far: if you need that kind of performance, you need another platform.



Public Function Join2d(ByRef InputArray As Variant, _ 
                       Optional RowDelimiter As String = vbCr, _ 
                       Optional FieldDelimiter = vbTab,_ 
                       Optional SkipBlankRows As Boolean = False _ 
                       ) As String

' Join up a 2-dimensional array into a string. Works like the standard
'  VBA.Strings.Join, for a 2-dimensional array.
' Note that the default delimiters are those inserted into the string
'  returned by ADODB.Recordset.GetString

On Error Resume Next

' Coding note: we're not doing any string-handling in VBA.Strings - 
' allocating, deallocating and (especially!) concatenating are SLOW.
' We're using the VBA Join & Split functions ONLY. The VBA Join,
' Split, & Replace functions are linked directly to fast (by VBA
' standards) functions in the native Windows code. Feel free to 
' optimise further by declaring and using the Kernel string functions
' if you want to.

' ** THIS CODE IS IN THE PUBLIC DOMAIN **
'   Nigel Heffernan   Excellerando.Blogspot.com

Dim i As Long
Dim j As Long

Dim i_lBound As Long
Dim i_uBound As Long
Dim j_lBound As Long
Dim j_uBound As Long

Dim arrTemp1() As String
Dim arrTemp2() As String

Dim strBlankRow As String

i_lBound = LBound(InputArray, 1)
i_uBound = UBound(InputArray, 1)

j_lBound = LBound(InputArray, 2)
j_uBound = UBound(InputArray, 2)

ReDim arrTemp1(i_lBound To i_uBound)
ReDim arrTemp2(j_lBound To j_uBound)

For i = i_lBound To i_uBound

    For j = j_lBound To j_uBound
        arrTemp2(j) = InputArray(i, j)
    Next j

    arrTemp1(i) = Join(arrTemp2, FieldDelimiter)

Next i

If SkipBlankRows Then

    If Len(FieldDelimiter) = 1 Then
        strBlankRow = String(j_uBound - j_lBound, FieldDelimiter)
    Else
        For j = j_lBound To j_uBound
            strBlankRow = strBlankRow & FieldDelimiter
        Next j
    End If

    Join2d = Replace(Join(arrTemp1, RowDelimiter), strBlankRow, RowDelimiter, "")
    i = Len(strBlankRow & RowDelimiter)

    If Left(Join2d, i) = strBlankRow & RowDelimiter Then
        Mid$(Join2d, 1, i) = ""
    End If

Else

    Join2d = Join(arrTemp1, RowDelimiter)    

End If

Erase arrTemp1

End Function
For completeness, here's the corresponding 2-D Split function:

Split2d: A 2-Dimensional Split function in VBA with optimised string-handling



Public Function Split2d(ByRef strInput As String, _ 
                        Optional RowDelimiter As String = vbCr, _ 
                        Optional FieldDelimiter = vbTab, _ 
                        Optional CoerceLowerBound As Long = 0 _ 
                        ) As Variant

' Split up a string into a 2-dimensional array. 

' Works like VBA.Strings.Split, for a 2-dimensional array.
' Check your lower bounds on return: never assume that any array in
' VBA is zero-based, even if you've set Option Base 0
' If in doubt, coerce the lower bounds to 0 or 1 by setting 
' CoerceLowerBound
' Note that the default delimiters are those inserted into the
'  string returned by ADODB.Recordset.GetString

On Error Resume Next

' Coding note: we're not doing any string-handling in VBA.Strings -
' allocating, deallocating and (especially!) concatenating are SLOW.
' We're using the VBA Join & Split functions ONLY. The VBA Join,
' Split, & Replace functions are linked directly to fast (by VBA
' standards) functions in the native Windows code. Feel free to 
' optimise further by declaring and using the Kernel string functions
' if you want to.

' ** THIS CODE IS IN THE PUBLIC DOMAIN **
'    Nigel Heffernan   Excellerando.Blogspot.com

Dim i   As Long
Dim j   As Long

Dim i_n As Long
Dim j_n As Long

Dim i_lBound As Long
Dim i_uBound As Long
Dim j_lBound As Long
Dim j_uBound As Long

Dim arrTemp1 As Variant
Dim arrTemp2 As Variant

arrTemp1 = Split(strInput, RowDelimiter)

i_lBound = LBound(arrTemp1)
i_uBound = UBound(arrTemp1)

If VBA.LenB(arrTemp1(i_uBound)) <= 0 Then  
    ' clip out empty last row: a common artifact in data 
     'loaded from files with a terminating row delimiter
    i_uBound = i_uBound - 1
End If

i = i_lBound
arrTemp2 = Split(arrTemp1(i), FieldDelimiter)

j_lBound = LBound(arrTemp2)
j_uBound = UBound(arrTemp2)

If VBA.LenB(arrTemp2(j_uBound)) <= 0 Then 
 ' ! potential error: first row with an empty last field...
    j_uBound = j_uBound - 1
End If

i_n = CoerceLowerBound - i_lBound
j_n = CoerceLowerBound - j_lBound

ReDim arrData(i_lBound + i_n To i_uBound + i_n, j_lBound + j_n To j_uBound + j_n)

' As we've got the first row already... populate it
' here, and start the main loop from lbound+1

For j = j_lBound To j_uBound
    arrData(i_lBound + i_n, j + j_n) = arrTemp2(j)
Next j

For i = i_lBound + 1 To i_uBound Step 1

    arrTemp2 = Split(arrTemp1(i), FieldDelimiter)

    For j = j_lBound To j_uBound Step 1

        arrData(i + i_n, j + j_n) = arrTemp2(j)

    Next j

    Erase arrTemp2

Next i

Erase arrTemp1

Application.StatusBar = False

Split2d = arrData

End Function
Share and enjoy... And watch out for unwanted line breaks in the code, inserted by your browser (or by Blogger's helpful formatting functions)

Wednesday, 1 August 2012

Adding a month to the end of the month: another Excel annoyance:

Ever see a column of month-end payment days do this in a spreadsheet?
 31/08/2005  30/11/2005  28/02/2006  28/05/2006  28/08/2006  28/11/2006  28/02/2007  28/05/2007  28/08/2007  28/11/2007  28/02/2008

Sigh. All VBA developers eventually face the weary task of correcting the VBA.DateTime function library because of this loathsome miscoding by Microsoft:



' Special handling required for adding months at EOM:
' VBA.DateAdd("m", 1, "28 Feb 2006") = 28/03/2006 (!)
' Business logic is ALWAYS that adding a month to EOM
' gives the end of the following month - 31 Mar 2006.

Here's my solution: I guess you've all got one of your own by now.

The usual health warning applies to ATTRIBUTE statements: they are not recognised by the VBA editor, you have to drag-and-drop the entire module out of the VB IDE, insert the statements manually in notepad, and drag the object back. In case you didn't know, the VB_ProcData attribute places AddDate in the 'Date & Time' category of the Spreadsheet Function Wizard, instead of letting it languish in obscurity under 'User-Defined'.


Public Function AddDate( _
        ByVal DateString As String, _
        Optional ByVal ReferenceDate As Date _
        Optional Subtract As Boolean = False _
        ) As Date
'ATTRIBUTE AddDate.VB_Description="Add a datestring of the form '1m', '10d' or '5y' to the reference date. \r\nBy default the reference date is the current date. \r\nInteger dates only: time expressed as fractional days is discarded. \r\nAll addition and subtraction uses Actual/Actual: no other date convention is implemented.
'ATTRIBUTE AddDate.VB_ProcData.VB_Invoke_Func = " \n2"

'Nigel Heffernan 2001

'THIS CODE IS IN THE PUBLIC DOMAIN

'Add a datestring of the form '1m', '10d' or '5y' to the reference date.
'By default the reference date is the current date.
'Integer dates only: time expressed as fractional days is discarded.
'All addition and subtraction uses Actual/Actual: no other date convention is implemented.

Const VB_HELPFILE As String = "C:\PROGRA~1\COMMON~1\MICROS~1\VBA\VBA6\1033\VbLR6.chm"
' I'm too lazy to do the proper registry lookup for this help file.

On Error GoTo ErrSub

Dim sNum As String
Dim iLen As Integer
Dim i As Long
Dim strLabel As String

If ReferenceDate = 0 Then
    ReferenceDate = Date
End If

DateString = Trim(UCase(DateString))
DateString = Left(DateString, 16)

If DateString = "SPOT" Then

    DateString = "2" 'Spot price - 'zero-day' plus settlement lag
    strLabel = "d"

ElseIf DateString = "OVERNIGHT" Then

    DateString = "1"
    strLabel = "d"

ElseIf DateString = "O/N" Then

    DateString = "1"
    strLabel = "d"

ElseIf DateString = "DAILY" Then

    DateString = "1"
    strLabel = "d"

ElseIf DateString = "WEEKLY" Then

    DateString = "7"
    strLabel = "d"

ElseIf DateString = "ANNUAL" Then

    DateString = "1"
    strLabel = "yyyy"  ' Year

ElseIf DateString = "YEARLY" Then

    DateString = "1"
    strLabel = "yyyy"  ' Year

ElseIf DateString = "MONTHLY" Then

    DateString = "1"
    strLabel = "m"

ElseIf DateString = "QUARTERLY" Then

    DateString = "3"
    strLabel = "m"

ElseIf DateString = "SEMI-ANNUAL" Then

    DateString = "6"
    strLabel = "m"

ElseIf DateString = "SEMIANNUAL" Then

    DateString = "6"
    strLabel = "m"

ElseIf InStr(DateString, "MONTH") Then

    iLen = InStr(DateString, "M")
    strLabel = "m"      ' Month"

ElseIf InStr(DateString, "YEAR") Then

    iLen = InStr(DateString, "Y")
    strLabel = "yyyy"  ' Year"

ElseIf InStr(DateString, "DAY") Then

    iLen = InStr(DateString, "D")
    strLabel = "d"      ' Day"

ElseIf InStr(DateString, "M") Then

    iLen = InStr(DateString, "M")
    strLabel = "m"      ' Month"

ElseIf InStr(DateString, "Y") Then

    iLen = InStr(DateString, "Y")
    strLabel = "yyyy"  ' Year"

ElseIf InStr(DateString, "D") Then

    iLen = InStr(DateString, "D")
    strLabel = "d"      ' Day"

ElseIf InStr(DateString, "Q") Then

    iLen = InStr(DateString, "Q")
    strLabel = "q"      ' Quarter"

ElseIf InStr(DateString, "W") Then

    iLen = InStr(DateString, "W")
    strLabel = "ww"     ' Week"

ElseIf IsNumeric(DateString) Then

    iLen = Len(DateString)
    strLabel = "d"      ' Day"

Else

    GoTo ErrSub

End If

sNum = Trim(Left(DateString, iLen - 1))

If Not IsNumeric(sNum) Then

    'Trim down until we reach a number

    Do Until IsNumeric(sNum) Or Len(sNum) < 1

        sNum = Left(sNum, Len(sNum) - 1)
        sNum = Trim(sNum)

        'Do not read "5-Year" as "Minus five years"

        If Right(sNum, 1) = "-" Then

            sNum = Left(sNum, Len(sNum) - 1)
            sNum = Trim(sNum)

        End If

    Loop

End If

If Len(sNum) < 1 Then
    GoTo ErrSub

End If

If Not IsNumeric(sNum) Then

    GoTo ErrSub

End If

i = CLng(sNum)

If Subtract Then

    i = -1 * i

End If



' Special handling required for adding months at EOM:
' VBA.DateAdd("m", 1, "28 Feb 2006") = 28/03/2006 (!)
' Business logic is ALWAYS that adding a month to EOM
' gives the end of the following month - 31 Mar 2006.

If strLabel = "m" Then

    If Month(ReferenceDate) <> Month(ReferenceDate) + 1 Then 'EOM detected

        ReferenceDate = ReferenceDate + 1
        AddDate = DateAdd(strLabel, i, ReferenceDate)
        AddDate = AddDate - 1

    Else

        AddDate = DateAdd(strLabel, i, ReferenceDate)

    End If

Else

    AddDate = DateAdd(strLabel, i, ReferenceDate)

End If

ExitSub:

    Exit Function

ErrSub:

    If Len(Dir(VB_HELPFILE)) > 0 Then

        Err.Raise 13, "AddDate Function", _
         "'" & DateString & "'" & " was not recognised as date interval." & vbCrLf _
         & vbCrLf _
         & "Try typing '10d', '3m' or '5y', or the date " & vbCrLf _
         & "interval as a number of calendar days.", _
         VB_HELPFILE, 1000013

    Else

        Err.Raise 13, "AddDate Function", _
         "'" & DateString & "'" & " was not recognised as date interval." & vbCrLf _
         &  _
        vbCrLf _
         & "Try typing '10d', '3m' or '5y', or the date " &  _
        vbCrLf _
         & "interval as a number of calendar days."

    End If

     

End Function

Saturday, 21 July 2012

A generic VBA Array To Range function

Here's a common task: writing an array to a range.

Here, we're writing an array to the sheet in a single 'hit' to the sheet. This is much faster than writing the data into the sheet one cell at a time in lops for the rows and columns.

However, there's some housekeeping to do, as you must specify the size of the target range correctly.

This 'housekeeping' looks like a lot of work and it's probably rather slow: but this is 'last mile' code to write to the sheet, and everything is faster than writing to the worksheet. Or at least, so much faster that it's effectively instantaneous, compared with a read or write to the worksheet, even in VBA, and you should do everything you possibly can in code before you hit the sheet.

A major component of this is error-trapping that I used to see turning up everywhere. I hate repetitive coding: I've coded it all here, and - hopefully - you'll never have to write it again.

As always, watch out for 'helpful' reformatting by your browser (or by Blogger) that inserts line breaks.


Option Explicit
                    
Public Sub ArrayToRange(rngTarget As Excel.Range, InputArray As Variant)
' Write an array to an Excel range in a single 'hit' to the sheet

' InputArray expects a 2-Dimensional structure of the form Variant(Rows, Columns)
' Vector arrays will be written as an array of 1 to n rows in a single column

' The target range is resized automatically to the dimensions of the array, with
' the top left cell used as the start point.

' This subroutine saves repetitive coding for a common VBA and Excel task.

' If you think you won't need the code that works around common errors (long
' strings and objects in the array, etc) then feel free to comment it out.

On Error Resume Next

'
' Author: Nigel Heffernan  Http://Excellerando.blogspot.com
'
'
' This code is in the public domain: take care to mark it clearly, and segregate
' it from proprietary code if you intend to assert intellectual property rights
' or impose commercial confidentiality restrictions on your proprietary code

Dim rngOutput As Excel.Range

Dim iRowCount   As Long
Dim iColCount   As Long
Dim iRow        As Long
Dim iCol        As Long
Dim arrTemp     As Variant
Dim iDimensions As Integer

Dim iRowOffset  As Long
Dim iColOffset  As Long
Dim iStart      As Long


Application.EnableEvents = False
If rngTarget.Cells.Count > 1 Then
    rngTarget.ClearContents
End If
Application.EnableEvents = True


If IsEmpty(InputArray) Then
    Exit Sub
End If

If TypeName(InputArray) = "Range" Then
    InputArray = InputArray.Value
End If


' Is it actually an array? IsArray is sadly broken so...
If InStr(TypeName(InputArray), "(") < 1 Then
    rngTarget.Cells(1, 1).Value2 = InputArray
    Exit Sub
End If


iDimensions = ArrayDimensions(InputArray)


If iDimensions < 1 Then

    rngTarget.Value = CStr(InputArray)


ElseIf iDimensions = 1 Then


    ReDim arrTemp(LBound(InputArray) To UBound(InputArray), 1 To 1)
    
    For iRow = LBound(InputArray) To UBound(InputArray)
        arrTemp(iRow, 1) = InputArray(iRow)
    Next iRow
    
    ArrayToRange rngTarget, arrTemp
    Erase arrTemp
    
    

ElseIf iDimensions >= 2 Then
    
    
    iRowCount = UBound(InputArray, 1) - LBound(InputArray, 1)
    iColCount = UBound(InputArray, 2) - LBound(InputArray, 2)
    
    iStart = LBound(InputArray, 1)
    
    If iRowCount > (65534 - rngTarget.Row) Then
        iRowCount = 65534 - rngTarget.Row
        InputArray = ArrayTranspose(InputArray)
        ReDim Preserve InputArray(LBound(InputArray, 1) To UBound(InputArray, 1), iStart To iRowCount)
        InputArray = ArrayTranspose(InputArray)
    End If
    
    iStart = LBound(InputArray, 2)
    
    If iColCount > (rngTarget.Worksheet.Columns.Count - rngTarget.Column) Then
        iColCount = rngTarget.Worksheet.Columns.Count - rngTarget.Column - 1
        ReDim Preserve InputArray(LBound(InputArray, 1) To UBound(InputArray, 1), iStart To iColCount)
    End If
    
  
    With rngTarget.Worksheet
    
        Set rngOutput = .Range(rngTarget.Cells(1, 1), rngTarget.Cells(iRowCount + 1, iColCount + 1))
    
        Err.Clear
        Application.EnableEvents = False
        rngOutput.Value2 = InputArray
        Application.EnableEvents = True
    
        If Err.Number <> 0 Then
            For iRow = LBound(InputArray, 1) To UBound(InputArray, 1)
                For iCol = LBound(InputArray, 2) To UBound(InputArray, 2)
                    If IsNumeric(InputArray(iRow, iCol)) Then
                        ' no action
                    Else
                        InputArray(iRow, iCol) = "" & InputArray(iRow, iCol)
                        InputArray(iRow, iCol) = Trim(InputArray(iRow, iCol))
                    End If
                Next iCol
            Next iRow
            Err.Clear
            rngOutput.Formula = InputArray
        End If 'err<>0
    
        If Err <> 0 Then
        
            For iRow = LBound(InputArray, 1) To UBound(InputArray, 1)
            
                For iCol = LBound(InputArray, 2) To UBound(InputArray, 2)
                
                    If IsNumeric(InputArray(iRow, iCol)) Then
                        ' no action
                    Else
                        ' Have we picked up values that can be read as a formula?
                        If Left(InputArray(iRow, iCol), 1) = "=" Then
                            InputArray(iRow, iCol) = "'" & InputArray(iRow, iCol)
                        End If
                        If Left(InputArray(iRow, iCol), 1) = "+" Then
                            InputArray(iRow, iCol) = "'" & InputArray(iRow, iCol)
                        End If
                        If Left(InputArray(iRow, iCol), 1) = "*" Then
                            InputArray(iRow, iCol) = "'" & InputArray(iRow, iCol)
                        End If
                    End If
                    
                Next iCol
                
            Next iRow
            Err.Clear
            rngOutput.Value2 = InputArray
            
        End If 'err<>0
    
        If Err <> 0 Then
        
            For iRow = LBound(InputArray, 1) To UBound(InputArray, 1)
                For iCol = LBound(InputArray, 2) To UBound(InputArray, 2)
    
                    
                    If IsError(InputArray(iRow, iCol)) Then
                        InputArray(iRow, iCol) = "#ERROR"
                    ElseIf IsObject(InputArray(iRow, iCol)) Then
                        InputArray(iRow, iCol) = "[OBJECT] " & TypeName(InputArray(iRow, iCol))
                    ElseIf IsArray(InputArray(iRow, iCol)) Then
                        InputArray(iRow, iCol) = Split(InputArray(iRow, iCol), ",")
                    ElseIf IsNumeric(InputArray(iRow, iCol)) Then
                        ' no action
                    Else
                        InputArray(iRow, iCol) = "" & InputArray(iRow, iCol)
                        If Len(InputArray(iRow, iCol)) > 255 Then
                            ' Block-write operations fail on strings exceeding 255 chars. You
                            ' have to go back and check, and write it out one cell at a time.
                            InputArray(iRow, iCol) = Left(Trim(InputArray(iRow, iCol)), 255)
                        End If
                    End If
                Next iCol
            Next iRow
            Err.Clear
            rngOutput.Text = InputArray
            
        End If 'err<>0
    
        If Err <> 0 Then
        
            Application.ScreenUpdating = False
            Application.Calculation = xlCalculationManual
            iRowOffset = LBound(InputArray, 1) - 1
            iColOffset = LBound(InputArray, 2) - 1
            For iRow = 1 To iRowCount
                If iRow Mod 100 = 0 Then
                    Application.StatusBar = "Filling range... " & CInt(100# * iRow / iRowCount) & "%"
                End If
                For iCol = 1 To iColCount
                    rngOutput.Cells(iRow, iCol) = InputArray(iRow + iRowOffset, iCol + iColOffset)
                Next iCol
            Next iRow
            Application.StatusBar = False
            Application.ScreenUpdating = True
    
        End If 'err<>0
    
    
        Set rngTarget = rngOutput   ' resizes the range This is useful, *most* of the time
    
    End With  '  rngTarget.Worksheet

End If  ' iDimensions

End Sub


Private Function ArrayTranspose(InputArray As Variant) As Variant

Dim arrOutput As Variant

Dim i As Long
Dim j As Long
Dim iMin As Long
Dim iMax As Long
Dim jMin As Long
Dim jMax As Long

iMin = LBound(InputArray, 1)
iMax = UBound(InputArray, 1)
jMin = LBound(InputArray, 2)
jMax = UBound(InputArray, 2)

ReDim arrOutput(jMin To jMax, iMin To iMax)

For i = iMin To iMax
    For j = jMin To jMax
        arrOutput(j, i) = InputArray(i, j)
    Next j
Next i

ArrayTranspose = arrOutput

End Function


Public Function ArrayDimensions(arr As Variant) As Integer
 ' Return values:
 
 ' -1 if arr is not an array
 '  0 for an array variant that has not been dimensioned
 '  1 to 255 for the array's dimensions.
 
 ' Special case: arr isn't a variant, it's a Range object
 ' Return the dimensions of the range's .Value() property
 
 ' VBA will pass the reference to a Range *object* (not the
 ' object's default property (the .Value variant) into your
 ' function, even though the parameter was declared as type
 ' variant. The  'least astonishment'  approach to handling
 ' that is to defer to the infallibility of Microsoft's API
 ' decisions and return the dimensions of the range's value
 
 ' We ignore the possibility of a range with multiple areas
 
 
Dim i As Integer
Dim j As Long

If TypeName(arr) = "Range" Then

    If arr Is Nothing Then
        ArrayDimensions = 0
    ElseIf arr.Areas(1).Cells.Count = 1 Then
        ArrayDimensions = 1
    Else
        ArrayDimensions = 2
    End If
    
ElseIf InStr(TypeName(arr), "(") < 1 Then

    ArrayDimensions = -1

ElseIf IsEmpty(arr) Then

    ArrayDimensions = 0
     
Else

    On Error Resume Next
    Err.Clear
    For i = 1 To 255
    
        j = 0
        j = UBound(arr, i)
    
        If Err.Number <> 0 Then
            ArrayDimensions = i - 1
            Exit For
        End If
        
    Next i
    
    If i > 255 Then ' not a VBA-compatible array
        ArrayDimensions = -1
    End If

End If

End Function




Please keep the acknowledgements in your source code: as you progress in your career as a developer, you will come to appreciate your own contributions being acknowledged.