PowerShell to monitor for deleted, renamed or moved files (part 2)

Posted

This is part 2, of my attempt to "sync" photos I deleted on my desktop to my SD card (you can read part 1 first). In this post, I try to use PowerShell with .NET framework to (try) monitor for file system changes, and output to a batch file that "replicates" the ren (rename) and del (delete) to files and folders.

Script to Monitor Delete and Rename Events

PowerShell can invoke methods from .NET, so I thought of using the FileSystemWatcher APIs. Here is my first version to:

  • unregister any prior events registered,
  • create a FileSystemWatcher object,
  • register a function to handle files and folders deleted events,
  • register a function to handle files and folders renamed events.

You shouldn't have a file called run_changes.cmd already, unless you really want to append to it.

watch_deletes.ps1:

Unregister-Event FileDeleted -ErrorAction SilentlyContinue
Unregister-Event FileRenamed -ErrorAction SilentlyContinue

$folder = pwd
$filter = '*.*'
$global:output = "$folder\run_changes.cmd"
Write-Host "Monitoring $folder\$filter, output to $output"

$fsw = New-Object IO.FileSystemWatcher $folder, $filter -Property @{
 IncludeSubdirectories = $true;
 NotifyFilter = "FileName,DirectoryName"
}
Register-ObjectEvent $fsw Deleted -SourceIdentifier FileDeleted -Action {
 if ($Event.SourceEventArgs.FullPath -ine $output) {
  $name = $Event.SourceEventArgs.Name
  $change = $Event.SourceEventArgs.ChangeType
  $time = $Event.TimeGenerated
  Write-Host "$time  $change ""$name""" -fore red
  Out-File $output -Encoding ASCII -Append -InputObject "del ""$name"""
 }
}
Register-ObjectEvent $fsw Renamed -SourceIdentifier FileRenamed -Action {
 if ($Event.SourceEventArgs.OldFullPath -ine $output) {
  $oldname = $Event.SourceEventArgs.OldName
  $name = $Event.SourceEventArgs.Name
  $time = $Event.TimeGenerated
  Write-Host "$time  Renamed ""$oldname"" to ""$name""" -fore white
  Out-File $output -Encoding ASCII -Append -InputObject "ren ""$oldname"" ""$name"""
 }
}

Easy! This script is clean and works well. But moves are not tracked - specifically Windows treats the move as a delete followed by a create.

Script to Monitor "Move" Events

So this is my revised script to try and "associate" a delete event followed by a create event as as a "move". This is tricky because it could be a legitimate delete followed by an unrelated create!

I can't vouch for this script. It's very hack-y and relies on an arbitrary timeout of 500 ms for this "association" - I don't know if this is suitable or not!

You can see how my watch_changes.ps1 script:

  • sets-up some global variables, since events are triggered in a background thread,
  • sets-up a timer object to wait for a delete and create pair,
  • handles files and folders differently (i.e. del to delete a file vs rmdir to delete a folder),
  • handles events by appending the change detected to an output file.

You shouldn't have a file called run_changes.cmd already, unless you really want to append to it.

param (
 [switch]$stop = $false,
 $out = "run_changes.cmd"
)
Unregister-Event WatchTimerElapsed  -ErrorAction SilentlyContinue
Unregister-Event WatchFileDeleted   -ErrorAction SilentlyContinue
Unregister-Event WatchFileCreated   -ErrorAction SilentlyContinue
Unregister-Event WatchFileRenamed   -ErrorAction SilentlyContinue
Unregister-Event WatchFolderDeleted -ErrorAction SilentlyContinue
Unregister-Event WatchFolderCreated -ErrorAction SilentlyContinue
Unregister-Event WatchFolderRenamed -ErrorAction SilentlyContinue
if ($stop) { exit }

$folder = pwd
$filter = '*.*'
$global:output = "$folder\$out"
$global:filedeleted = ""
$global:folderdeleted = ""
Write-Host "Monitoring $folder\$filter, output to $output"

$timer = New-Object Timers.Timer
$timer.Interval = 500
Register-ObjectEvent $timer Elapsed -SourceIdentifier WatchTimerElapsed -Action {
 $timer.Stop()
 if ($filedeleted -ne "") {
  Out-File $output -Encoding ASCII -Append -InputObject "del ""$filedeleted"""
  $global:filedeleted = ""
 }
 if ($folderdeleted -ne "") {
  Out-File $output -Encoding ASCII -Append -InputObject "rmdir ""$folderdeleted"""
  $global:folderdeleted = ""
 }
}

$fsw = New-Object IO.FileSystemWatcher $folder, $filter -Property @{
 IncludeSubdirectories = $true;
 NotifyFilter = "FileName"
}
Register-ObjectEvent $fsw Deleted -SourceIdentifier WatchFileDeleted -Action {
 if ($Event.SourceEventArgs.FullPath -ine $output) {
  $name = $Event.SourceEventArgs.Name
  $change = $Event.SourceEventArgs.ChangeType
  $time = $Event.TimeGenerated
  Write-Host "$time  $change ""$name""" -fore red
  if ($filedeleted -ne "") {
   Out-File $output -Encoding ASCII -Append -InputObject "del ""$filedeleted"""
  }
  $global:filedeleted = $name
  $timer.Start()
 }
}
Register-ObjectEvent $fsw Created -SourceIdentifier WatchFileCreated -Action {
 if ($Event.SourceEventArgs.FullPath -ine $output) {
  $name = $Event.SourceEventArgs.Name
  $change = $Event.SourceEventArgs.ChangeType
  $time = $Event.TimeGenerated
  Write-Host "$time  $change ""$name""" -fore blue
  if ($filedeleted -ne "") {
   Out-File $output -Encoding ASCII -Append -InputObject "move ""$filedeleted"" ""$name"""
   $global:filedeleted = ""
  } else {
   Out-File $output -Encoding ASCII -Append -InputObject "#created ""$name"""
  }
 }
}
Register-ObjectEvent $fsw Renamed -SourceIdentifier WatchFileRenamed -Action {
 if ($Event.SourceEventArgs.OldFullPath -ine $output) {
  $oldname = $Event.SourceEventArgs.OldName
  $name = $Event.SourceEventArgs.Name
  $time = $Event.TimeGenerated
  Write-Host "$time  Renamed ""$oldname"" to ""$name""" -fore white
  Out-File $output -Encoding ASCII -Append -InputObject "ren ""$oldname"" ""$name"""
 }
}

$dsw = New-Object IO.FileSystemWatcher $folder, $filter -Property @{
 IncludeSubdirectories = $true;
 NotifyFilter = "DirectoryName"
}
Register-ObjectEvent $dsw Deleted -SourceIdentifier WatchFolderDeleted -Action {
 if ($Event.SourceEventArgs.FullPath -ine $output) {
  $name = $Event.SourceEventArgs.Name
  $change = $Event.SourceEventArgs.ChangeType
  $time = $Event.TimeGenerated
  Write-Host "$time  $change ""$name""" -fore red
  if ($folderdeleted -ne "") {
   Out-File $output -Encoding ASCII -Append -InputObject "rmdir ""$folderdeleted"""
  }
  $global:folderdeleted = $name
  $timer.Start()
 }
}
Register-ObjectEvent $dsw Created -SourceIdentifier WatchFolderCreated -Action {
 if ($Event.SourceEventArgs.FullPath -ine $output) {
  $name = $Event.SourceEventArgs.Name
  $change = $Event.SourceEventArgs.ChangeType
  $time = $Event.TimeGenerated
  Write-Host "$time  $change ""$name""" -fore blue
  if ($folderdeleted -ne "") {
   Out-File $output -Encoding ASCII -Append -InputObject "move ""$folderdeleted"" ""$name"""
   $global:folderdeleted = ""
  } else {
   Out-File $output -Encoding ASCII -Append -InputObject "mkdir ""$name"""
  }
 }
}
Register-ObjectEvent $dsw Renamed -SourceIdentifier WatchFolderRenamed -Action {
 if ($Event.SourceEventArgs.OldFullPath -ine $output) {
  $oldname = $Event.SourceEventArgs.OldName
  $name = $Event.SourceEventArgs.Name
  $time = $Event.TimeGenerated
  Write-Host "$time  Renamed ""$oldname"" to ""$name""" -fore white
  Out-File $output -Encoding ASCII -Append -InputObject "ren ""$oldname"" ""$name"""
 }
}
Running the PowerShell Script

Now, to run the script from the Command Prompt (cmd) in the folder where my photos reside before performing any renames or deletes:

cd my_photos
powershell -NoExit -ExecutionPolicy ByPass -File watch_changes.ps1

You need -NoExit because the script needs to keep running in the background. PowerShell won't close until you use exit, to stop the FileSystemWatcher.

I explained the reason for -ExecutionPolicy in part 1 - it's to allow unsigned scripts to be executed.

An alternative is to globally allow unsigned scripts:

  • From the Start Menu > right click Windows PowerShell ISE (x86) > Run as administrator.
  • In the top section, enter Set-ExecutionPolicy RemoteSigned.
  • Now hit F5 (or from the Debug menu > Run) then select Yes to confirm - and now scripts can run unsigned.

This is not safe, so to undo this, enter Set-ExecutionPolicy Restricted and hit Run again...

The script has one parameter, -stop to simply stop monitoring events and exit.

Running the PowerShell Script...

Anyway, run it, and you'll get an output like this:

Windows PowerShell
Copyright (C) 2009 Microsoft Corporation. All rights reserved.

Monitoring C:\Dev\*.*, output to C:\Dev\run_changes.cmd

WARNING: column "Command" does not fit into the display and was removed.

Id              Name            State      HasMoreData     Location
--              ----            -----      -----------     --------
1               WatchFileTimer  NotStarted False
2               WatchFileDel... NotStarted False
3               WatchFileCre... NotStarted False
4               WatchFileRen... NotStarted False

When files are deleted or moved, the console will show the events the script detected, e.g.

05/20/2018 12:30:45  Deleted "test\IMG1.jpg"
05/20/2018 12:30:48  Created "IMG1.jpg"
05/20/2018 12:31:24  Deleted "IMG1.jpg"
05/20/2018 12:33:56  Created "IMG2.jpg"
05/20/2018 12:39:44  Renamed "IMG2.jpg" to "IMG3.jpg"

Once you exit, the run_changes.cmd file will contain commands to try and repeat the changes made on the SD Card, e.g.

move "test\IMG1.jpg" "IMG1.jpg"
del "IMG1.jpg"
#created "IMG2.jpg"
ren "IMG2.jpg" "IMG3.jpg" 

Note that of course my script doesn't replicate a file created on my desktop folder to the SD card. I could replace the commented out note #created to instead copy the file from the source folder to the SD card, it's absolutely possible. I couldn't be bothered to, since I don't need my script to handle new files!

I move the cmd script to my SD card (the same folder where the files reside) and then run it...

Conclusion

PowerShell can easly call .NET framework APIs, but the API doesn't handle "moves". Therefore, I don't trust my script, instead preferring to stick to my much simpler script in part 1...