DirMerge

DirMerge is a Windows tool that can be used to copy one folder to another with a number of extra options that you don’t have in Windows explorer or in most other programs that can copy files.

Features include:

  • Copies directories with or without entire subdirectory structure
  • Optional source file deletion after copying (only if it was copied OR even if the file was skipped)
  • Overwrites file never, always OR only if a file is (that much) larger, smaller, older or newer than the existing file.
  • Or just ask the user what to do when a file already exists.

I’ve published this under the GPL license.

Download the executable: DirMerge.exe (for Windows 95/98/2000/XP/Vista)

And view the source code here:

unit DirMergeMainForm;

interface

uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls, Buttons, FileCtrl, ExtCtrls, ComCtrls, IniFiles, DateUtils;

type
  TfrmDirMerge = class(TForm)
    GroupBox1: TGroupBox;
    edSource: TEdit;
    edTarget: TEdit;
    btnSource: TSpeedButton;
    btnTarget: TSpeedButton;
    StaticText1: TStaticText;
    StaticText2: TStaticText;
    btnStart: TSpeedButton;
    rgOverwrite: TRadioGroup;
    cbCondition: TComboBox;
    edAtLeast: TEdit;
    cbSize: TComboBox;
    cbTime: TComboBox;
    StaticText3: TStaticText;
    edFilter: TEdit;
    cbSubdirs: TCheckBox;
    ProgressBar1: TProgressBar;
    RichEdit1: TRichEdit;
    rgDelete: TRadioGroup;
    procedure FormDestroy(Sender: TObject);
    procedure FormCreate(Sender: TObject);
    procedure btnStartClick(Sender: TObject);
    procedure cbConditionSelect(Sender: TObject);
    procedure btnTargetClick(Sender: TObject);
    procedure btnSourceClick(Sender: TObject);
  private
    function CopyFiles(const sourcePath, targetPath, filter: String;
                    IncludeSubDir:boolean; atLeast:Integer): boolean;
    function CopyWithProgress(sSource, sDest: string): Boolean;
    procedure ColorLastLine(AColor: TColor);
    procedure AddLineWithColor(aText:String; AColor: TColor);
    procedure AppendWithColor(aText:String; AColor: TColor);
  public
    { Public declarations }
  end;

var
  frmDirMerge: TfrmDirMerge;
  FCancelled: boolean;

implementation

{$R *.dfm}

procedure TfrmDirMerge.btnSourceClick(Sender: TObject);
var
  dir : string;
begin
  dir := edSource.Text;
  if SelectDirectory('Select source directory', '', dir) then
    edSource.Text := dir;
end;

function CopyFileWithProgressBar2(TotalFileSize,
  TotalBytesTransferred,
  StreamSize,
  StreamBytesTransferred: LARGE_INTEGER;
  dwStreamNumber,
  dwCallbackReason: DWORD;
  hSourceFile,
  hDestinationFile: THandle;
  lpData: Pointer): DWORD; stdcall;
begin
  // just set size at the beginning
  if dwCallbackReason = CALLBACK_STREAM_SWITCH then
    TProgressBar(lpData).Max := TotalFileSize.QuadPart;

  TProgressBar(lpData).Position := TotalBytesTransferred.QuadPart;
  Application.ProcessMessages;
  Result := PROGRESS_CONTINUE;
end;

procedure TfrmDirMerge.ColorLastLine(AColor: TColor);
begin
  with RichEdit1 do
  begin
    SelStart := SendMessage(Handle, EM_LINEINDEX, Lines.Count - 1, 0);
    SelLength := Length(Lines[Lines.Count - 1]);
    SelAttributes.Color := AColor;
    SelLength := 0;
  end;
end;

procedure TfrmDirMerge.AddLineWithColor(aText:String; AColor: TColor);
begin
  RichEdit1.Lines.Append(aText);
  ColorLastLine(AColor);
end;

procedure TfrmDirMerge.AppendWithColor(aText:String; AColor: TColor);
begin
  with RichEdit1 do
  begin
    Lines[Lines.Count-1] := Lines[Lines.Count-1]+aText;
    ColorLastLine(AColor);
  end;
end;


function TfrmDirMerge.CopyWithProgress(sSource, sDest: string): Boolean;
begin
  // set this FCancelled to true, if you want to cancel the copy operation
  FCancelled := False;
  Result     := CopyFileEx(PChar(sSource), PChar(sDest), @CopyFileWithProgressBar2,
    ProgressBar1, @FCancelled, 0);
end;

procedure TfrmDirMerge.FormCreate(Sender: TObject);
begin
  with TIniFile.Create(ExtractFilePath(Application.ExeName)+'DirMerge.ini') do
  begin
     edSource.Text := ReadString('LastSettings','SourceDir', '');
     edFilter.Text := ReadString('LastSettings','Filter', '*.*');
     cbSubdirs.Checked := ReadBool('LastSettings','Subdirs', False);
     edTarget.Text := ReadString('LastSettings','TargetDir','');
     rgDelete.ItemIndex := ReadInteger('LastSettings','DeleteOption', 0);
     rgOverwrite.ItemIndex := ReadInteger('LastSettings','OverwriteOption', 3);
     cbCondition.ItemIndex := ReadInteger('LastSettings','ConditionOption', 0);
     edAtLeast.Text:= ReadString('LastSettings','AtLeast', '0');
     cbSize.ItemIndex := ReadInteger('LastSettings','SizeOption', 0);
     cbTime.ItemIndex := ReadInteger('LastSettings','TimeOption', 0);
     Free;
  end;
end;

procedure TfrmDirMerge.FormDestroy(Sender: TObject);
begin
  with TIniFile.Create(ExtractFilePath(Application.ExeName)+'DirMerge.ini') do
  begin
     WriteString('LastSettings','SourceDir', edSource.Text);
     WriteString('LastSettings','Filter', edFilter.Text);
     WriteBool('LastSettings','Subdirs', cbSubdirs.Checked);
     WriteString('LastSettings','TargetDir', edTarget.Text);
     WriteInteger('LastSettings','DeleteOption', rgDelete.ItemIndex);
     WriteInteger('LastSettings','OverwriteOption', rgOverwrite.ItemIndex);
     WriteInteger('LastSettings','ConditionOption', cbCondition.ItemIndex);
     WriteString('LastSettings','AtLeast', edAtLeast.Text);
     WriteInteger('LastSettings','SizeOption', cbSize.ItemIndex);
     WriteInteger('LastSettings','TimeOption', cbTime.ItemIndex);
     Free;
  end;
end;

function TfrmDirMerge.CopyFiles(const sourcePath, targetPath, filter: String;
                    IncludeSubDir:boolean; atLeast:Integer): boolean;
var
  searchResult, targetResult : TSearchRec;
  overwrite, delete, yesToAll, noToAll: Boolean;
  choiceResult: integer;
  timeDif: double;
  sourceDate, targetDate: TDateTime;
  sn, tn: String;
begin
  yesToAll := false;
  noToAll := false;
  if not DirectoryExists(targetPath) then CreateDir(targetPath);
  if not DirectoryExists(targetPath) then begin
    AddLineWithColor('Could not create directory '+targetPath, clRed);
    Result := false;
    exit;
  end;
  if FindFirst(sourcePath+'\'+filter, faAnyFile-faDirectory, searchResult) = 0 then
  begin
    repeat
      sn := sourcePath+'\'+searchResult.Name;
      tn := targetPath+'\'+searchResult.Name;
      overwrite := true;
      delete := true;
      if FindFirst(tn, faAnyFile-faDirectory, targetResult) = 0
      then begin
        sourceDate :=FileDateToDateTime(searchResult.Time);
        targetDate :=FileDateToDateTime(targetResult.Time);
        //File exists -> overwrite?
        case rgOverwrite.ItemIndex of
          0: ; // overwrite = true
          1: begin
               case cbCondition.ItemIndex of
                 0: if searchResult.Size+atleast>targetResult.Size then
                      overwrite := false;
                 1: if searchResult.Size-atleast<targetResult.Size then
                      overwrite := false;
                 2,3: begin
                   case cbTime.ItemIndex of
                      0: timeDif := SecondSpan(sourceDate,targetDate);
                      1: timeDif := MinuteSpan(sourceDate,targetDate);
                      2: timeDif := HourSpan(sourceDate,targetDate);
                      3: timeDif := DaySpan(sourceDate,targetDate);
                      4: timeDif := MonthSpan(sourceDate,targetDate);
                      5: timeDif := WeekSpan(sourceDate,targetDate);
                      6: timeDif := YearSpan(sourceDate,targetDate);
                   end;
                   if ((cbTime.ItemIndex=2) and (timeDif<atLeast)) or
                      ((cbTime.ItemIndex=3) and (timeDif>atLeast)) then
                     overwrite := false;
                 end;
               end;
             end;
          2: overwrite := false;
          3: begin
               if not (noToAll or yesToAll) then begin
                 choiceResult := MessageDlgPos(
                                  'Do you want to overwrite '#13#13'   '+tn+#13+
                                  '   '+IntToStr(targetResult.Size)+' bytes'#13+
                                  '   Last modified '+DateToStr(targetDate)+#13+
                                  #13+' with '+#13#13'   '+sn+#13+
                                  '   '+IntToStr(searchResult.Size)+' bytes'#13+
                                  '   Last modified '+DateToStr(sourceDate),
                               mtConfirmation, mbYesAllNoAllCancel, 0, 20, 100);
                 if choiceResult = mrYesToAll then yesToAll := true
                 else if choiceResult = mrNoToAll then noToAll := true
                 else if choiceResult = mrCancel then begin
                   addLineWithColor('Canceled',clRed);
                   result := true;
                   exit;
                 end;
               end;
               if noToAll then choiceResult := mrNo;
               if yesToAll then choiceResult := mrYes;
               if choiceResult = mrNo then overwrite := false;
             end; //ask
        end;
        FindClose(targetResult);
        if overwrite then begin
          AddLineWithColor('Overwriting '+sn+'...', clBlue);
          if CopyWithProgress(sn, tn) then AppendWithColor('done',clBlue)
          else begin //if failed, we don't want to delete the source in any case
            AppendWithColor('failed',clRed);
            delete := false;
          end;
        end
        else begin
          AddLineWithColor('Skipped '+sn, clBlack);
        end;
      end
      else begin
        //File doesn't exists in target -> regular copy
        AddLineWithColor('Copying '+sn+'...',clGreen);
        if CopyWithProgress(sn, tn) then AppendWithColor('done',clGreen)
        else begin //if failed, we don't want to delete the source in any case
          AppendWithColor('failed',clRed);
          delete := false;
        end;
      end;
      //If selected, delete the source file
      if delete and
         (((rgDelete.ItemIndex=1) and overwrite) or (rgDelete.ItemIndex=2))
      then
        if DeleteFile(sn)
        then AppendWithColor('Deleted '+sn, clPurple)
        else AppendWithColor('Failed deleting '+sn, clRed);
    until FindNext(searchResult) <> 0;
    FindClose(searchResult);
  end
  else showMessage('No files found ('+sourcePath+'\'+filter+')');

  if not IncludeSubDir then begin
    Result := true;
    Exit;
  end;

  if FindFirst(SourcePath+'\'+filter, faDirectory, searchResult) = 0 then
  begin
    repeat
      if (SearchResult.Name <> '.') and (SearchResult.Name <> '..') then begin
        if not CopyFiles (SourcePath + '\' + SearchResult.Name,
                   TargetPath + '\' + SearchResult.Name, Filter, TRUE, atLeast)
        then begin
          Result := false;
          FindClose(searchResult);
          Exit;
        end;
      end;
    until FindNext(searchResult) <> 0;
     FindClose(searchResult);
  end;
  Result := True;
end;

procedure TfrmDirMerge.btnStartClick(Sender: TObject);
var
  sourcepath, targetpath: String;
  atLeast: Integer;
begin
  atLeast:=0;
  //Determine the at least value (only if option 1 is selected)
  if rgOverwrite.ItemIndex=1 then begin
    try
      atLeast := StrToInt(edAtLeast.Text);
    except on EConvertError do
      begin
        showMessage('Invalid value in the ''at least'' box');
        exit;
      end;
    end;
    case cbCondition.ItemIndex of
      0,1: case cbSize.ItemIndex of
             1: atLeast := atLeast*1024; //kB
             2: atLeast := atLeast*1024*1024; //MB
             3: atLeast := atLeast*1024*1024*1024 //GB
           end;
    end;
  end;
  sourcePath := edSource.Text;
  if Copy(sourcePath,Length(sourcePath),1)='\' then
    sourcePath := Copy(sourcePath,1,Length(sourcePath)-1);
  targetPath := edTarget.Text;
  if Copy(targetPath,Length(targetPath),1)='\' then
    targetPath := Copy(targetPath,1,Length(targetPath)-1);
  if UpperCase(sourcePath)=UpperCase(targetPath) then begin
    AddLineWithColor('Directories are the same', clRed);
    exit;
  end;
  if CopyFiles(sourcePath, targetPath, edFilter.Text, cbSubdirs.Checked, atLeast)
  then AddLineWithColor('Finished', clGreen)
  else AddLineWithColor('Finished - with errors', clRed);
end;

procedure TfrmDirMerge.btnTargetClick(Sender: TObject);
var
  dir : string;
begin
  dir := edTarget.Text;
  if SelectDirectory('Select target directory', '', dir) then
    edTarget.Text := dir;
end;

procedure TfrmDirMerge.cbConditionSelect(Sender: TObject);
begin
  if (cbCondition.ItemIndex=0) or (cbCondition.ItemIndex=1) then begin
    cbSize.Visible := true;
    cbTime.Visible := false;
  end
  else begin
    cbSize.Visible := false;
    cbTime.Visible := true;
  end;
end;

end.
object frmDirMerge: TfrmDirMerge
  Left = 0
  Top = 0
  Caption = 'DirMerge (Beta) by Bart Groot'
  ClientHeight = 494
  ClientWidth = 529
  Color = clBtnFace
  Font.Charset = DEFAULT_CHARSET
  Font.Color = clWindowText
  Font.Height = -11
  Font.Name = 'Tahoma'
  Font.Style = []
  OldCreateOrder = False
  OnCreate = FormCreate
  OnDestroy = FormDestroy
  PixelsPerInch = 96
  TextHeight = 13
  object GroupBox1: TGroupBox
    AlignWithMargins = True
    Left = 5
    Top = 5
    Width = 519
    Height = 289
    Margins.Left = 5
    Margins.Top = 5
    Margins.Right = 5
    Margins.Bottom = 5
    Align = alTop
    Constraints.MinHeight = 289
    Constraints.MinWidth = 519
    TabOrder = 0
    DesignSize = (
      519
      289)
    object btnSource: TSpeedButton
      Left = 239
      Top = 15
      Width = 23
      Height = 22
      Caption = '...'
      OnClick = btnSourceClick
    end
    object btnTarget: TSpeedButton
      Left = 240
      Top = 80
      Width = 23
      Height = 22
      Caption = '...'
      OnClick = btnTargetClick
    end
    object btnStart: TSpeedButton
      Left = 430
      Top = 229
      Width = 78
      Height = 42
      Caption = '&Start copying'
      OnClick = btnStartClick
    end
    object edSource: TEdit
      Left = 72
      Top = 16
      Width = 161
      Height = 21
      TabOrder = 0
    end
    object edTarget: TEdit
      Left = 73
      Top = 80
      Width = 161
      Height = 21
      TabOrder = 3
    end
    object StaticText1: TStaticText
      Left = 10
      Top = 20
      Width = 56
      Height = 17
      Caption = 'Source dir:'
      TabOrder = 10
    end
    object StaticText2: TStaticText
      Left = 11
      Top = 84
      Width = 55
      Height = 17
      Caption = 'Target dir:'
      TabOrder = 11
    end
    object rgOverwrite: TRadioGroup
      Left = 11
      Top = 118
      Width = 497
      Height = 105
      Caption = 
        'If a file already exists in the target dir with the same name as' +
        ' a source file, then'
      ItemIndex = 3
      Items.Strings = (
        'Always overwrite'
        
          'Only overwrite if source file is                         than ta' +
          'rget file by at least'
        'Never overwrite'
        'Ask me what to do')
      TabOrder = 5
    end
    object cbCondition: TComboBox
      Left = 184
      Top = 153
      Width = 63
      Height = 21
      Style = csDropDownList
      ItemHeight = 13
      ItemIndex = 0
      TabOrder = 6
      Text = 'larger'
      OnSelect = cbConditionSelect
      Items.Strings = (
        'larger'
        'smaller'
        'newer'
        'older')
    end
    object edAtLeast: TEdit
      Left = 384
      Top = 153
      Width = 42
      Height = 21
      TabOrder = 7
      Text = '0'
    end
    object cbTime: TComboBox
      Left = 432
      Top = 153
      Width = 63
      Height = 21
      Style = csDropDownList
      ItemHeight = 13
      ItemIndex = 0
      TabOrder = 9
      Text = 'seconds'
      Items.Strings = (
        'seconds'
        'minutes'
        'hours'
        'days'
        'weeks'
        'months'
        'years')
    end
    object cbSize: TComboBox
      Left = 432
      Top = 153
      Width = 63
      Height = 21
      Style = csDropDownList
      ItemHeight = 13
      ItemIndex = 0
      TabOrder = 8
      Text = 'bytes'
      Items.Strings = (
        'bytes'
        'KBytes'
        'MBytes'
        'GBytes')
    end
    object StaticText3: TStaticText
      Left = 17
      Top = 47
      Width = 49
      Height = 17
      Caption = 'File filter:'
      TabOrder = 12
    end
    object edFilter: TEdit
      Left = 72
      Top = 43
      Width = 64
      Height = 21
      Anchors = [akLeft, akTop, akRight]
      TabOrder = 1
      Text = '*.*'
    end
    object cbSubdirs: TCheckBox
      Left = 142
      Top = 45
      Width = 147
      Height = 17
      Caption = 'Include subdirectories'
      TabOrder = 2
    end
    object ProgressBar1: TProgressBar
      Left = 19
      Top = 248
      Width = 405
      Height = 23
      TabOrder = 13
    end
    object rgDelete: TRadioGroup
      Left = 280
      Top = 15
      Width = 228
      Height = 87
      Caption = 'When done with source file'
      ItemIndex = 0
      Items.Strings = (
        'Do nothing (regular copy)'
        'Delete it if it was copied'
        'Delete it')
      TabOrder = 4
    end
  end
  object RichEdit1: TRichEdit
    Left = 0
    Top = 299
    Width = 529
    Height = 195
    Align = alClient
    ReadOnly = True
    ScrollBars = ssBoth
    TabOrder = 1
  end
end