ATTENTION: You are viewing a page formatted for mobile devices; to view the full web page, click HERE.

DonationCoder.com Software > Post New Requests Here

IDEA: Automagic Digital Photo Manager

(1/4) > >>

oblivion:
My father-in-law wants to be able to clear the photos off the SD card from his camera onto his PC so he can (a) free up space on the card, and (b) review and delete any photos he doesn't want to keep.

He has next to no idea of file management. The computer is for eBay and a few games.

So what I thought was some sort of semi-automated process. Identify a base target folder (possibly stored for later use in a config file), make a subfolder of the current year (if it doesn't already exist) and a subfolder of that for the current month.

Identify the source drive; assume \DCIM as the base folder for the photos.

Then copy all the JPGs from \dcim\ and any subfolders it might have into the newly-created yyyy/mmmm target folder, but ignore source target structure. (His camera makes subfolders for month or day but trying to create and then navigate the destination if it's too granular seems overkill, and dating the destination for now won't prevent later organisation based on the file data or EXIF stuff, I figure.)

Then fire up Explorer, or Photos, or Windows Photo Viewer, pointed at the destination.

Then reviewing the photos should at least mean accidental deletions wind up in the recycle bin...

This sounds straightforward but I keep getting tied in knots trying to work out how to solve it myself -- I guess I'm getting old :(

skwire:
To review:


* Source is an SD card full of photos in the standard DCIM folder.  The folder structure of this folder is of no concern, i.e., we're not looking to recreate it in the destination.
* Choose a destination folder on the computer.  In this folder, create a new folder with the year and a subfolder of the month.
* Move all photos in the DCIM folder, and its subfolders, into this destination folder.
* Start Windows Explorer at the current month subfolder of the destination.
Does the SD card show up as a drive letter?

oblivion:
The review: yes to all.

Does the SD card show up as a drive letter?
-skwire (October 21, 2018, 03:43 PM)
--- End quote ---
Yes, again.

I can probably ensure that the drive letter always comes up the same, but I guess you could also look for available drives with \dcim folders in the root and present them (or, hopefully always!) it as a confirmation for the source.

4wd:
Something simple in PowerShell while skwire whips up a masterpiece :P : DCIMover.ps1

Updated version:

* Lists all removable drives that have a DCIM folder,
* Saves the destination folder when you click Start,
* Defaults to Copy,
* Creates destination folders as per Year\Month, eg. 2017\03_Mar,
* Will not overwrite any existing file,
* Works on JPEG, DNG, AVI, MOV, MP4 files, (uses Date Created for video formats),
* Can easily add more image/video formats, (just edit the .ini file),
* Will open your default file manager at the last folder created, (works for DOpus, File Explorer), for both images and videos (if it's the same folder then normally the already open file manager window is brought to the front),
* Multi-threaded so the GUI doesn't become unresponsive,
* Progressbar updates as it goes and a window shows the current operation and any errors,
* Can cancel the operation by just closing the GUI, (might implement a Stop button later),
* Sets the creation date of the destination file to the Date Taken of the image, (or Date Created if it's video),
* You can set the initial folder to select a destination folder in the .ini file,
* Interface controls are disabled while it's running, (so you don't go swapping between Copy/Move),
* If any errors occurred the log file will be opened at the end of the operation.
There's a small video, (1:16), in the archive that shows it working, (original files shown on the left, folder tree being created with files on the right).

It requires Powershell v3+, (TBH I'm not sure what minimum version is required for some cmdlets - I can only test against v5).

Installation:

Extract the archive and copy the contents somewhere.
If you need to move the shortcut somewhere else, open the properties of the shortcut and edit the Target field to include the path to the script.

Edit the DCIMover.ini file for some basic preferences.

DCIMover.ini

--- Code: Text ---[General]dest=K:\a_folder\test placeinitfolder=c:\users\fred\pictures\photosimages=.dng,.jpgvideos=.3gp,.avi,.mov,.mp4

* dest - The last destination folder used, saved whenever the Start button is pressed.
* initfolder - The "top level" folder used for the folder browser when selecting a destination, if it doesn't exist the folder browser will default to My Computer.
* images - The extensions of image files, comma separated.  These are files that contain Date Taken metadata so can include any other file type that has that data, (eg. maybe some video formats)
* videos - The extensions of video files, comma separated.  The Date Created metadata will be used for these file types.
Running:


* Run it from the shortcut, a Powershell console will open for a second then close, the script GUI will then open.
* Select your source drive from the drop-down list, (any removable drive with a folder named DCIM in the root will be in the list).
* Select your destination folder, the files will be copied here in the appropriate year\month sub-folders.
* Select Copy or Move, (I suggest Copy until you're are sure it's working correctly).
* Hit Start.
IDEA: Automagic Digital Photo Manager


--- Code: PowerShell ---$Global:SyncHash = [hashtable]::Synchronized(@{})$newRunspace =[runspacefactory]::CreateRunspace()$newRunspace.ApartmentState = "STA"$newRunspace.ThreadOptions = "ReuseThread"$newRunspace.Open()$newRunspace.SessionStateProxy.SetVariable("SyncHash",$SyncHash) # Load WPF assembly if necessary[void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework') $psCmd = [PowerShell]::Create().AddScript({  [xml]$xaml = @"<Window x:Class="DCIMover.MainWindow"        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"        Title="DCIMover" Height="350" Width="522" ResizeMode="CanMinimize">    <Grid>        <TextBox x:Name="TextBox1" HorizontalAlignment="Left" Height="23" Margin="102,72,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="325" ToolTip="Destination folder for photos/videos"/>        <Label x:Name="Label1" Content="Destination" HorizontalAlignment="Left" Margin="25,66,0,0" VerticalAlignment="Top" Width="72"/>        <Button x:Name="Button1" Content="..." HorizontalAlignment="Left" Margin="460,72,0,0" VerticalAlignment="Top" Width="32" ToolTip="Select destination folder"/>        <Label x:Name="Label2" Content="Source" HorizontalAlignment="Left" Margin="25,21,0,0" VerticalAlignment="Top"/>        <ComboBox x:Name="ComboBox1" HorizontalAlignment="Left" Margin="102,25,0,0" VerticalAlignment="Top" Width="62" ToolTip="Select source card from list"/>        <ProgressBar x:Name="ProgressBar1" HorizontalAlignment="Left" Height="26" Margin="25,118,0,0" VerticalAlignment="Top" Width="467" IsTabStop="False"/>        <TextBox x:Name="TextBox2" HorizontalAlignment="Left" Height="136" Margin="25,164,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="467" ToolTip="Status messages" AllowDrop="False" Focusable="False" IsTabStop="False" VerticalScrollBarVisibility="Auto" IsUndoEnabled="False" IsReadOnly="True" HorizontalScrollBarVisibility="Auto" ScrollViewer.CanContentScroll="True"/>        <Button x:Name="Button2" Content="Start" HorizontalAlignment="Left" Margin="417,25,0,0" VerticalAlignment="Top" Width="75"/>        <RadioButton x:Name="RadioButton1" Content="Copy" HorizontalAlignment="Left" Margin="331,19,0,0" VerticalAlignment="Top" IsChecked="True" ToolTip="Copy files from card"/>        <RadioButton x:Name="RadioButton2" Content="Move" HorizontalAlignment="Left" Margin="331,39,0,0" VerticalAlignment="Top" ToolTip="Move files from card"/>     </Grid></Window>"@   # Remove XML attributes that break a couple things.  #   Without this, you must manually remove the attributes  #   after pasting from Visual Studio. If more attributes  #   need to be removed automatically, add them below.  $AttributesToRemove = @(    'x:Class',    'mc:Ignorable'  )   Foreach ($Attrib in $AttributesToRemove) {    if ($xaml.Window.GetAttribute($Attrib)) {      $xaml.Window.RemoveAttribute($Attrib)    }  }      $reader = (New-Object System.Xml.XmlNodeReader $xaml)      $SyncHash.Window = [Windows.Markup.XamlReader]::Load( $reader )   [xml]$XAML = $xaml  $xaml.SelectNodes("//*[@*[contains(translate(name(.),'n','N'),'Name')]]") | %{  #Find all of the form types and add them as members to the synchash    $SyncHash.Add($_.Name, $SyncHash.Window.FindName($_.Name) )  }   $Script:JobCleanup = [hashtable]::Synchronized(@{})  $Script:Jobs = [system.collections.arraylist]::Synchronized((New-Object System.Collections.ArrayList))   #region Background runspace to clean up jobs  $jobCleanup.Flag = $True  $newRunspace = [runspacefactory]::CreateRunspace()  $newRunspace.ApartmentState = "STA"  $newRunspace.ThreadOptions = "ReuseThread"            $newRunspace.Open()          $newRunspace.SessionStateProxy.SetVariable("jobCleanup", $jobCleanup)       $newRunspace.SessionStateProxy.SetVariable("jobs", $jobs)  $jobCleanup.PowerShell = [PowerShell]::Create().AddScript({  #Routine to handle completed runspaces    Do {          Foreach($runspace in $jobs) {                    If ($runspace.Runspace.isCompleted) {          [void]$runspace.powershell.EndInvoke($runspace.Runspace)          $runspace.powershell.dispose()          $runspace.Runspace = $null          $runspace.powershell = $null                       }       }  #Clean out unused runspace jobs      $temphash = $jobs.clone()      $temphash | Where {$_.runspace -eq $Null} | ForEach {$jobs.remove($_)}              Start-Sleep -Seconds 1         } while ($jobCleanup.Flag)  })  $jobCleanup.PowerShell.Runspace = $newRunspace  $jobCleanup.Thread = $jobCleanup.PowerShell.BeginInvoke()    #endregion Background runspace to clean up jobs    $SyncHash.Button1.Add_Click({    $objShell = new-object -com shell.application    # Special Folders: https://docs.microsoft.com/en-us/windows/desktop/api/shldisp/ne-shldisp-shellspecialfolderconstants    # eg. Pictures = 0x27    $objFolder = $objShell.BrowseForFolder(0, "Select destination folder", 0x00000074, $SyncHash.InitFolder)    if ($objfolder.self.path -ne $null) {      $SyncHash.TextBox1.Text = $objfolder.self.path    }  })   $SyncHash.Button2.Add_Click({# Start-Job -Name Sleeping -ScriptBlock {start-sleep 5}# while ((Get-Job Sleeping).State -eq 'Running'){# region Boe's Additions    $newRunspace =[runspacefactory]::CreateRunspace()    $newRunspace.ApartmentState = "STA"    $newRunspace.ThreadOptions = "ReuseThread"              $newRunspace.Open()    $newRunspace.SessionStateProxy.SetVariable("SyncHash", $SyncHash)     $PowerShell = [PowerShell]::Create().AddScript({      Function Update-Window {        Param (          $Control,          $Property,          $Value,          [switch]$AppendContent        )# This is kind of a hack, there may be a better way to do this        If ($Property -eq "Close") {          $SyncHash.Window.Dispatcher.invoke([action]{$SyncHash.Window.Close()}, "Normal")          Return        }# This updates the control based on the parameters passed to the function        $SyncHash.$Control.Dispatcher.Invoke([action]{# This bit is only really meaningful for the TextBox control, which might be useful for logging progress steps          If ($PSBoundParameters['AppendContent']) {            $SyncHash.$Control.AppendText($Value)          } Else {            $SyncHash.$Control.$Property = $Value          }        }, "Normal")      }       Function Get-MetaData {        Param (          [PARAMETER(Mandatory=$true)]          [string]$path = '',#          [PARAMETER( Mandatory = $true, HelpMessage = 'extension eg. .mp3, .txt or .mov')]          [boolean]$images,#          [string]$type = '', # 'mp3'          [switch]$recurse        )         if ($images) {          $types = $SyncHash.imageTypes        } else {          $types = $SyncHash.videoTypes        }         $path += 'DCIM'        if ($recurse) {          $LPath = Get-ChildItem -Path $path -Directory -Recurse        } else {          $LPath = $path        } #        $DirectoryCount = 1        $RetrievedMetadata = $true        $OutputList = New-Object 'System.Collections.generic.List[psobject]'         Foreach ($pa in $LPath) {          $shell = New-Object -ComObject shell.application           if ($recurse) {            $objshell = $shell.NameSpace($pa.FullName)          } else {            $objshell = $shell.NameSpace($pa)          }          # Build data list          $count = 0           # Filter on filetype          for ($i = 0; $i -lt $types.Count; $i++) {            $filter = $null            Update-Window -Control ProgressBar1 -Property Value -Value 0            Start-Sleep -Milliseconds 500            $filter = $objshell.Items() | where {$_.Name -match $types[$i]}            Write-Message  "Collecting Metadata for '$($types[$i])' files ..."<#$filter.Count > 0 if more than one matching file$filter.Count = 0 if no matching files$filter.Count = $null if one matching file#>            Foreach ($file in $filter) {              if ($RetrievedMetadata) {# Build metanumbers                $Metanumbers = New-Object -TypeName 'System.Collections.Generic.List[int]'                for ($a = 0; $a -le 400; $a++) {                  if ($objshell.GetDetailsOf($file, $a)) {                    $Metanumbers.Add([int]$a)                  }                    }                $RetrievedMetadata = $false              }               $count++              $CurrentDirectory = Get-ChildItem -Path $file.path               if ($filter.Count -gt 0) {                try {                  Update-Window -Control ProgressBar1 -Property Value -Value ([math]::Round((($count / $filter.count) * 100)))                } catch {}              } else {                try {                  Update-Window -Control ProgressBar1 -Property Value -Value 100                } catch {}              }                # Build Hashtable for each file              $hash = $null              $Hash = @{}              foreach ($nr in $Metanumbers) {                $PropertyName = $($objshell.GetDetailsOf($objshell.Items, $nr))                $PropertyValue = $($objshell.GetDetailsOf($File, $nr))                $Hash[$PropertyName] = $PropertyValue              }                          $Hash.Remove("")              $FileMetaData = New-Object -TypeName PSobject -Property $hash              $OutputList.Add($FileMetaData)            }          }        }        Process-OutputList $OutputList $images      }       Function Process-OutputList {        Param (          [object]$files,          [string]$images        )         $dest = $SyncHash.Window.Dispatcher.invoke([System.Func[String]] {$SyncHash.TextBox1.Text})         if ($files.Count -eq $null) {          Write-Message  "Copy/Move one file ..."          switch ($images) {            $true { $takenDate = [datetime]((Select-Object -InputObject $files[$j] -ExpandProperty 'Date Taken') -replace '[^\d:/ -_.,]', '') }            $false { $takenDate = [datetime](Select-Object -InputObject $files[$j] -ExpandProperty 'Date Created') }          }          if ($takenDate -ne $null) {            $outPath = ($dest + '\' + $takenDate.Year + '\' + ($takenDate.Month).ToString('00') + '_' + ((Get-Culture).DateTimeFormat.GetAbbreviatedMonthName($takenDate.Month)) + '\' + $files.Name) -replace '\\', '\'            Move-File $files.'Path' $outPath $takenDate            Update-Window -Control ProgressBar1 -Property Value -Value 100          } else {            Write-Message "[Warn]: $($files.Name): Date Taken not found, Copy/Move not performed" $true          }        } else {          if ($files.Count -gt 0) {            Write-Message  "Copy/Move $($files.Count) files ..."            for ($j = 0; $j -lt $files.Count; $j++) {              $outPath = $null              $takenDate = $null                switch ($images) {                  $true {                      if (((Select-Object -InputObject $files[$j] -ExpandProperty 'Date Taken')).Length -gt 1 ) {                        $takenDate = [datetime]((Select-Object -InputObject $files[$j] -ExpandProperty 'Date Taken') -replace '[^\d:/ -_.,]', '')                      } else {                        Write-Message "[Warn]: $($files[$j].'Name'): Date Taken not found, Copy/Move not performed" $true                      }                    }                  $false {                      $takenDate = [datetime](Select-Object -InputObject $files[$j] -ExpandProperty 'Date Created')                    }                }              if ($takenDate -ne $null) {                $outPath = ($dest + '\' + $takenDate.Year + '\' + ($takenDate.Month).ToString('00') + '_' + ((Get-Culture).DateTimeFormat.GetAbbreviatedMonthName($takenDate.Month)) + '\' + $files[$j].'Name') -replace '\\', '\'                Move-File $files[$j].'Path' $outPath $takenDate              }              Update-Window -Control ProgressBar1 -Property Value -Value ([math]::Round((($j + 1) / $files.Count) * 100))            }          } else {            Write-Message  "No matching files found ..."          }        }        Invoke-Item (Split-Path -Path $outPath)      }       Function Move-File {        param (          [string]$source,          [string]$dest,          [datetime]$date        )        if (!(Test-Path -Path (Split-Path -Path $dest))) {          New-Item (Split-Path -Path $dest) -ItemType Directory          if (!$?) {            Write-Message "[Error] Failed to create folder: $(Split-Path -Path $dest)" $true          }        }         if (!(Test-Path $dest)) {          if ($SyncHash.Window.Dispatcher.invoke([System.Func[string]] {$SyncHash.RadioButton1.IsChecked}) -eq 'True') {            Copy-Item $source $dest            if (!$?) {              Write-Message "[Error] $(Split-Path $source -Leaf): Copy failed" $true            }          } else {            Move-Item $source $dest            if (!$?) {              Write-Message "[Error] $(Split-Path $source -Leaf): Move failed" $true            }          }          Set-ItemProperty -Path $dest -Name CreationTime -Value $date        } else {          Write-Message "[Warn] $(Split-Path $source -Leaf): Exists in destination" $true        }      }       Function Write-Message {        Param (          [string]$message,          [boolean]$err = $false        )        Update-Window -Control TextBox2 -Property Text -Value "$($message)`r`n" -Append        if ($err) {          $SyncHash.hadError = $true          $message | Out-File -FilePath $SyncHash.LogFile -Append        }      } # Button2 Main      $srceFol = $SyncHash.Window.Dispatcher.invoke([System.Func[String]] {$SyncHash.ComboBox1.SelectedItem})      $destFol = $SyncHash.Window.Dispatcher.invoke([System.Func[String]] {$SyncHash.TextBox1.Text})      $SyncHash.hadError = $false       if (($destFol -ne $null) -and ($destFol -ne '')) {        if (($srceFol -ne $null) -and ($srceFol -ne '')) {          $ini = "[General]`r`ndest=$($destFol)`r`ninitfolder=$($SyncHash.InitFolder)`r`nimages=$($SyncHash.h.Get_Item('images'))`r`nvideos=$($SyncHash.h.Get_Item('videos'))"          Out-File -FilePath $SyncHash.iniFile -InputObject $ini          $SyncHash.LogFile = ("$($env:TEMP)\$(Get-Date -Format 'yyyyMMdd_HHmmss').txt").Replace('\\', '\')          Update-Window -Control Button2 -Property IsEnabled -Value $false          Update-Window -Control RadioButton1 -Property IsEnabled -Value $false          Update-Window -Control RadioButton2 -Property IsEnabled -Value $false          Update-Window -Control TextBox1 -Property IsEnabled -Value $false          Update-Window -Control ComboBox1 -Property IsEnabled -Value $false          Update-Window -Control Button1 -Property IsEnabled -Value $false          $SyncHash.hadError = $false          Update-Window -Control TextBox2 -Property Text -Value ''          Write-Message "---- START ----`r`nLogfile: $($SyncHash.LogFile)`r`nProcessing image files ..."          Get-MetaData -Path $srceFol $true -Recurse          Write-Message "Finished image files`r`nProcessing video files ..."          Get-MetaData -Path $srceFol $false -Recurse          Write-Message  "Finished video files`r`n---- END ----"          Update-Window -Control RadioButton1 -Property IsEnabled -Value $true          Update-Window -Control RadioButton2 -Property IsEnabled -Value $true          Update-Window -Control TextBox1 -Property IsEnabled -Value $true          Update-Window -Control ComboBox1 -Property IsEnabled -Value $true          Update-Window -Control Button1 -Property IsEnabled -Value $true          Update-Window -Control Button2 -Property IsEnabled -Value $true          if ($SyncHash.hadError) {            "--- SAVE THIS FILE ---`r`n" + (Get-Content $SyncHash.LogFile -Raw) | Set-Content $SyncHash.LogFile            Write-Message  "*** There were errors, refer to log file ***"            Invoke-Item $SyncHash.LogFile          }        }      }# End Button2 Main     })    $PowerShell.Runspace = $newRunspace    [void]$Jobs.Add((    [pscustomobject]@{      PowerShell = $PowerShell      Runspace = $PowerShell.BeginInvoke()    }    ))  })     #region Window Close     $SyncHash.Window.Add_Closed({      Write-Verbose 'Halt runspace cleanup job processing'      $jobCleanup.Flag = $False       #Stop all runspaces      $jobCleanup.PowerShell.Dispose()          })    #endregion Window Close     #endregion Boe's Additions  # GUI Main + Functions  Function Add-Drives {    $usb = ([System.IO.DriveInfo]::GetDrives() | Where DriveType -match 'Removable')    for ($i = 0; $i -lt $usb.Count; $i++) {      $dcimFol = ($usb[$i].Name) + 'DCIM'      if (Test-Path -Path $dcimFol) {        [void] $SyncHash.ComboBox1.Items.Add($usb[$i])      }    }  }   Function Read-Ini {    if (Test-Path -Path $SyncHash.iniFile) {      Get-Content $SyncHash.iniFile | foreach-object -begin {[email protected]{}} -process { $k = [regex]::split($_,'='); `        if (($k[0].CompareTo("") -ne 0) -and ($k[0].StartsWith("[") -ne $True)) { $SyncHash.h.Add($k[0], $k[1]) } }      $SyncHash.TextBox1.Text = $SyncHash.h.Get_Item('dest')      $SyncHash.InitFolder = $SyncHash.h.Get_Item('initfolder')      $SyncHash.imageTypes = ($SyncHash.h.Get_Item('images')).Split(',')      $SyncHash.videoTypes = ($SyncHash.h.Get_Item('videos')).Split(',')    }  }   if ($PSVersionTable.PSVersion.Major -lt 3) {    [System.Windows.MessageBox]::Show("This script requires Powershell v3+`r`n`r`nClick OK to exit")    Exit  }  $SyncHash.iniFile = '.\DCIMover.ini'  Read-Ini  Add-Drives# End GUI Main + Functions    $SyncHash.Window.ShowDialog() | Out-Null  $SyncHash.Error = $Error})$psCmd.Runspace = $newRunspace$data = $psCmd.BeginInvoke()
Other things I might look at doing:

* If Date Taken doesn't exist, default to Date Created, (usually happens with photos that are manipulated in the phone/camera, eg. panoramas).
* For video files look for Media Created first before using Date Created, (one problem is Media Created uses UTC as defined by the camera setting, which can be different from Date Created).
* Find some way of outputting Powershell errors, (the red ones), to a log file.

skwire:
Something simple in PowerShell while skwire whips up a masterpiece-4wd (October 23, 2018, 05:36 PM)
--- End quote ---

Hahaha.   :D  I've been traveling for work a lot lately, so I won't be able to get to this for at least a few days.  Thank you for coming up with your rendition of the original ask!

Navigation

[0] Message Index

[#] Next page

Go to full version