Downloads-Auto-Archiver.ps1
|
<#PSScriptInfo
.VERSION 1.0.0 .GUID 139b1b17-5955-4a1c-acde-1c39074edce9 .AUTHOR aalex954 .COMPANYNAME aalex954 .COPYRIGHT (c) 2026 aalex954. MIT License. .TAGS Downloads Archive Cleanup NAS Automation TaskScheduler Windows .LICENSEURI https://github.com/aalex954/downloads-auto-archiver/blob/main/LICENSE .PROJECTURI https://github.com/aalex954/downloads-auto-archiver .RELEASENOTES Initial stable release. See https://github.com/aalex954/downloads-auto-archiver/releases #> <# .SYNOPSIS Safely moves old or untouched items from your Downloads folder to a NAS or archive location. .DESCRIPTION Downloads Auto-Archiver efficiently moves old or untouched items from your Windows Downloads folder to a NAS or mapped drive on a recurring schedule. Built for Task Scheduler with DryRun mode for safe auditing before any files are moved. #> [CmdletBinding(PositionalBinding = $false)] param( [string]$SourceDir = "$env:USERPROFILE\Downloads", [string]$DestinationRoot, [bool]$DryRun = $false, [switch]$VerboseLog = $false, [string]$LocalLogDir = "$env:USERPROFILE\\DownloadsAutoArchiver\\logs", [string]$RemoteLogDir = $null, [string]$ConfigFile = $null, [bool]$RequireConfirmation = $true, # <--- NEW: require interactive confirmation before any deletions/moves that remove source files # File rules [Nullable[TimeSpan]]$FileUntouchedOlderThan = [TimeSpan]::FromDays(14), # LastAccessTime [Nullable[TimeSpan]]$FileOlderThan = [TimeSpan]::FromDays(30), # Age based on property below [ValidateSet('AND','OR')][string]$FileTimeCombine = 'AND', [ValidateSet('CreationTime','LastWriteTime')][string]$FileAgeProperty = 'CreationTime', # Folder rules [Nullable[TimeSpan]]$FolderUntouchedOlderThan = [TimeSpan]::FromDays(30), [Nullable[TimeSpan]]$FolderOlderThan = [TimeSpan]::FromDays(45), [ValidateSet('AND','OR')][string]$FolderTimeCombine = 'AND', [ValidateSet('CreationTime','LastWriteTime')][string]$FolderAgeProperty = 'CreationTime', [switch]$DeepFolderActivityScan = $true, # if set, compute latest activity from descendants (slower) # Archive detection [string[]]$ArchiveExtensions = @('*.zip','*.7z','*.rar','*.tar','*.tar.gz','*.tgz','*.tar.bz2','*.tbz2','*.tar.xz','*.txz','*.iso'), [int]$ArchiveExtractedGraceMinutes = 30, # wait this long after archive write time before moving # Patterns [string[]]$IncludePatterns = @('*'), [string[]]$ExcludePatterns = @('*.crdownload','*.opdownload','*.download','*.aria2','*.part','*.filepart','*.tmp','*.temp','*.!ut','*.!qB','_UNPACK_*','_FAILED_*'), [switch]$IgnoreHidden = $true, # Safety/Performance [ValidateSet('Skip','Overwrite','RenameWithTimestamp')][string]$OnNameConflict = 'RenameWithTimestamp', [int]$MaxOperationsPerRun = 500, [int]$MinFreeSpaceMB = 512, # required free space on destination drive [switch]$UseRobocopy = $true, [int]$RobocopyLargeFileMB = 256, # use robocopy for files >= this size # Housekeeping [switch]$DeleteEmptyFolders = $true ) # -------------------------- Config file loading -------------------------- if ($ConfigFile) { try { $configExt = [System.IO.Path]::GetExtension($ConfigFile).ToLowerInvariant() if ($configExt -eq '.json') { $configData = Get-Content -Raw -LiteralPath $ConfigFile | ConvertFrom-Json } elseif ($configExt -eq '.psd1') { $configData = Import-PowerShellDataFile -Path $ConfigFile } else { throw "Unsupported config file format: $ConfigFile" } # Only override parameters NOT set via command line $bound = $PSBoundParameters.Keys foreach ($key in $configData.PSObject.Properties.Name) { if ($bound -contains $key) { continue } Set-Variable -Name $key -Value $configData.$key -Scope Script } Write-Host "Loaded configuration from $ConfigFile" } catch { throw "Failed to load config file: $ConfigFile :: $($_.Exception.Message)" } } if ([string]::IsNullOrWhiteSpace($DestinationRoot)) { throw "DestinationRoot parameter is required. Please specify a destination path." } $dirsToEnsure = @() if ($LocalLogDir) { $dirsToEnsure += $LocalLogDir } if ($RemoteLogDir) { $dirsToEnsure += $RemoteLogDir } if ($DestinationRoot) { $dirsToEnsure += $DestinationRoot } foreach ($d in $dirsToEnsure | Select-Object -Unique) { if ([string]::IsNullOrWhiteSpace($d)) { continue } try { if (-not (Test-Path -LiteralPath $d)) { New-Item -Path $d -ItemType Directory -Force | Out-Null if ($VerboseLog) { Write-Host "Created directory: $d" } } } catch { Write-Host "WARN: Could not create directory '$d': $($_.Exception.Message)" -ForegroundColor Yellow } } # -------------------------- Helpers -------------------------- function New-DirectoryIfMissing { param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { if ($DryRun) { Write-Verbose "[DRYRUN] Would create directory: $Path" } else { New-Item -ItemType Directory -Path $Path -Force | Out-Null } } } function Write-LogEntry { param( [string]$Level = 'INFO', [string]$Message, [hashtable]$Data ) $timestamp = (Get-Date).ToString('s') $obj = [ordered]@{ ts = $timestamp level = $Level message = $Message } if ($Data) { $Data.GetEnumerator() | ForEach-Object { $obj[$_.Key] = $_.Value } } $json = ($obj | ConvertTo-Json -Compress) foreach ($logDir in @($LocalLogDir, $RemoteLogDir)) { if ([string]::IsNullOrWhiteSpace($logDir)) { continue } try { New-DirectoryIfMissing -Path $logDir $json | Add-Content -LiteralPath (Join-Path $logDir 'DownloadsAutoArchiver.log.jsonl') -Encoding UTF8 } catch { Write-Verbose "Failed to write JSON log to $logDir : $($_.Exception.Message)" } } # Also CSV (simple) $csvLine = '"{0}","{1}","{2}"' -f $timestamp, $Level, ($Message -replace '"','''') foreach ($logDir in @($LocalLogDir, $RemoteLogDir)) { if ([string]::IsNullOrWhiteSpace($logDir)) { continue } try { New-DirectoryIfMissing -Path $logDir $csvLine | Add-Content -LiteralPath (Join-Path $logDir 'DownloadsAutoArchiver.log.csv') -Encoding UTF8 } catch {} } if ($VerboseLog) { Write-Host "[$timestamp][$Level] $Message" } } function Test-Patterns { param( [System.IO.FileSystemInfo]$Item, [string[]]$Includes, [string[]]$Excludes ) $name = $Item.Name $included = $false foreach ($pat in $Includes) { if ($name -like $pat) { $included = $true; break } } if (-not $included) { return $false } foreach ($pat in $Excludes) { if ($name -like $pat) { return $false } } return $true } function Test-FileInUse { param([string]$Path) if (-not (Test-Path $Path)) { return $false } try { $fs = [System.IO.File]::Open($Path, 'Open', 'Read', 'None') $fs.Close() return $false } catch { return $true } } function Get-LatestFolderActivity { param([System.IO.DirectoryInfo]$Dir) if (-not $DeepFolderActivityScan) { # Use folder's own timestamps return [ordered]@{ LastAccessTime = $Dir.LastAccessTime LastWriteTime = $Dir.LastWriteTime } } $latestAccess = $Dir.LastAccessTime $latestWrite = $Dir.LastWriteTime try { Get-ChildItem -LiteralPath $Dir.FullName -Recurse -Force -ErrorAction Stop | ForEach-Object { if ($_.PSIsContainer) { if ($_.LastAccessTime -gt $latestAccess) { $latestAccess = $_.LastAccessTime } if ($_.LastWriteTime -gt $latestWrite) { $latestWrite = $_.LastWriteTime } } else { if ($_.LastAccessTime -gt $latestAccess) { $latestAccess = $_.LastAccessTime } if ($_.LastWriteTime -gt $latestWrite) { $latestWrite = $_.LastWriteTime } } } } catch {} return [ordered]@{ LastAccessTime = $latestAccess; LastWriteTime = $latestWrite } } function Test-TimeRules { param( [Parameter(Mandatory)][object]$Item, [Nullable[TimeSpan]]$UntouchedOlderThan, [Nullable[TimeSpan]]$OlderThan, [ValidateSet('AND','OR')][string]$Combine, [ValidateSet('CreationTime','LastWriteTime')][string]$AgeProperty ) $now = Get-Date # Safely read time properties from either FileSystemInfo or a synthetic PSCustomObject $itemLastAccess = $null $itemLastWrite = $null $itemCreationTime = $null if ($Item -ne $null) { if ($Item -is [System.IO.FileSystemInfo]) { $itemLastAccess = $Item.LastAccessTime $itemLastWrite = $Item.LastWriteTime $itemCreationTime = $Item.CreationTime } else { # Attempt dynamic property access for PSCustomObject or hashtable if ($Item.PSObject.Properties.Match('LastAccessTime')) { $itemLastAccess = $Item.LastAccessTime } if ($Item.PSObject.Properties.Match('LastWriteTime')) { $itemLastWrite = $Item.LastWriteTime } if ($Item.PSObject.Properties.Match('CreationTime')) { $itemCreationTime = $Item.CreationTime } } } # Ensure fallback values are valid datetimes if (-not ($itemLastAccess -is [datetime])) { $itemLastAccess = [datetime]'1900-01-01' } if (-not ($itemLastWrite -is [datetime])) { $itemLastWrite = [datetime]'1900-01-01' } if (-not ($itemCreationTime -is [datetime])) { $itemCreationTime = [datetime]'1900-01-01' } $untouchedOk = $false if ($UntouchedOlderThan) { # If LastAccessTime looks uninitialized, fallback to LastWriteTime $accessTime = if ($itemLastAccess -gt [datetime]'1900-01-01') { $itemLastAccess } else { $itemLastWrite } $untouchedOk = ($now - $accessTime) -ge $UntouchedOlderThan } $ageOk = $false if ($OlderThan) { $agePropVal = if ($AgeProperty -eq 'CreationTime') { $itemCreationTime } else { $itemLastWrite } $ageOk = ($now - $agePropVal) -ge $OlderThan } if ($UntouchedOlderThan -and $OlderThan) { if ($Combine -eq 'AND') { return ($untouchedOk -and $ageOk) } else { return ($untouchedOk -or $ageOk) } } elseif ($UntouchedOlderThan) { return $untouchedOk } elseif ($OlderThan) { return $ageOk } else { return $false # no thresholds configured } } function Resolve-DestinationPath { param([System.IO.FileSystemInfo]$Item) $year = Get-Date -Format 'yyyy' $month = Get-Date -Format 'MM' $dstDir = Join-Path $DestinationRoot $year $month # year/month bucketing (cross-platform) New-DirectoryIfMissing -Path $dstDir return Join-Path $dstDir $Item.Name } function Resolve-NameConflict { param([string]$TargetPath) if (-not (Test-Path -LiteralPath $TargetPath)) { return $TargetPath } switch ($OnNameConflict) { 'Skip' { return $null } 'Overwrite' { return $TargetPath } default { $dir = Split-Path $TargetPath -Parent $base = [System.IO.Path]::GetFileNameWithoutExtension($TargetPath) $ext = [System.IO.Path]::GetExtension($TargetPath) $ts = (Get-Date).ToString('yyyyMMdd_HHmmss') return (Join-Path $dir ("{0}__{1}{2}" -f $base,$ts,$ext)) } } } function Move-ItemSafe { param( [Parameter(Mandatory)] [System.IO.FileSystemInfo] $Item, [Parameter(Mandatory)] [string] $TargetPath ) if ($DryRun) { Write-LogEntry -Message "[DRYRUN] Would move: $($Item.FullName) -> $TargetPath" -Data @{path=$Item.FullName; dest=$TargetPath} return $true } # Ensure destination directory exists New-DirectoryIfMissing -Path (Split-Path $TargetPath -Parent) try { if (-not $Item.PSIsContainer) { $sizeMB = [math]::Round(($Item.Length/1MB),2) if ($UseRobocopy -and $sizeMB -ge $RobocopyLargeFileMB) { # Use ROBOCOPY for resilience on large files (across volumes/NAS) $srcDir = $Item.DirectoryName $fileName = $Item.Name $dstDir = Split-Path $TargetPath -Parent $cmd = @('robocopy', $srcDir, $dstDir, $fileName, '/MOV', '/NFL','/NDL','/NJH','/NJS','/NP','/R:2','/W:2') $p = Start-Process -FilePath $cmd[0] -ArgumentList ($cmd[1..($cmd.Count-1)]) -PassThru -Wait -NoNewWindow if ($p.ExitCode -lt 8) { return $true } else { throw "Robocopy failed with code $($p.ExitCode)" } } else { Move-Item -LiteralPath $Item.FullName -Destination $TargetPath -Force -ErrorAction Stop return $true } } else { # Directory move Move-Item -LiteralPath $Item.FullName -Destination $TargetPath -Force -ErrorAction Stop return $true } } catch { Write-LogEntry -Level 'ERROR' -Message "Move failed: $($Item.FullName) -> $TargetPath :: $($_.Exception.Message)" -Data @{path=$Item.FullName; dest=$TargetPath; err=$_.Exception.Message} return $false } } function Get-DriveFreeSpaceMB { param([string]$Path) try { $root = [System.IO.Path]::GetPathRoot((Resolve-Path $Path)) $drive = Get-PSDrive -Name ($root.TrimEnd(':','\\')) -ErrorAction Stop return [math]::Floor($drive.Free/1MB) } catch { return $null } } # -------------------------- Pre-flight checks -------------------------- New-DirectoryIfMissing -Path $LocalLogDir if ($RemoteLogDir) { New-DirectoryIfMissing -Path $RemoteLogDir } if (-not (Test-Path -LiteralPath $SourceDir)) { throw "Source directory not found: $SourceDir" } if (-not (Test-Path -LiteralPath $DestinationRoot)) { Write-LogEntry -Level 'ERROR' -Message "Destination root not found: $DestinationRoot" -Data @{dest=$DestinationRoot} throw "Destination root not found: $DestinationRoot" } $freeMB = Get-DriveFreeSpaceMB -Path $DestinationRoot if ($freeMB -ne $null -and $freeMB -lt $MinFreeSpaceMB) { Write-LogEntry -Level 'ERROR' -Message "Insufficient free space on destination ($freeMB MB < $MinFreeSpaceMB MB). Aborting." -Data @{freeMB=$freeMB} throw "Insufficient free space on destination." } Write-LogEntry -Message "Starting scan of '$SourceDir' -> '$DestinationRoot' (DryRun=$DryRun)" # -------------------------- Discover top-level items -------------------------- $topFiles = Get-ChildItem -LiteralPath $SourceDir -File -Force $topDirs = Get-ChildItem -LiteralPath $SourceDir -Directory -Force # Build quick lookup of top-level dirs by stem $dirStem = @{} foreach ($d in $topDirs) { $dirStem[$d.Name.ToLowerInvariant()] = $true } # -------------------------- Selection logic -------------------------- $toMove = New-Object System.Collections.Generic.List[object] # Files foreach ($f in $topFiles) { if ($IgnoreHidden -and ($f.Attributes -band [IO.FileAttributes]::Hidden)) { continue } if (-not (Test-Patterns -Item $f -Includes $IncludePatterns -Excludes $ExcludePatterns)) { continue } # Skip if file is in use (best-effort) if (Test-FileInUse -Path $f.FullName) { continue } $selected = $false $reason = $null # Time-based rules if (Test-TimeRules -Item $f -UntouchedOlderThan $FileUntouchedOlderThan -OlderThan $FileOlderThan -Combine $FileTimeCombine -AgeProperty $FileAgeProperty) { $selected = $true $reason = "file-time" } # Archive sibling rule (only if not already selected) if (-not $selected) { $isArchive = $false foreach ($pat in $ArchiveExtensions) { if ($f.Name -like $pat) { $isArchive = $true; break } } if ($isArchive) { $stem = [System.IO.Path]::GetFileNameWithoutExtension($f.Name).ToLowerInvariant() if ($dirStem.ContainsKey($stem)) { # Ensure archive isn't too fresh $ageMin = [int]((Get-Date) - $f.LastWriteTime).TotalMinutes if ($ageMin -ge $ArchiveExtractedGraceMinutes) { $selected = $true $reason = "archive-extracted" } } } } if ($selected) { $toMove.Add([pscustomobject]@{ Item=$f; Reason=$reason }) } } # Folders (top-level only) foreach ($d in $topDirs) { if ($IgnoreHidden -and ($d.Attributes -band [IO.FileAttributes]::Hidden)) { continue } if (-not (Test-Patterns -Item $d -Includes $IncludePatterns -Excludes $ExcludePatterns)) { continue } $act = Get-LatestFolderActivity -Dir $d # Construct a synthetic object for time testing using folder timestamps $folderTimeProxy = New-Object psobject -Property @{ LastAccessTime = $act.LastAccessTime LastWriteTime = $act.LastWriteTime CreationTime = $d.CreationTime } if (Test-TimeRules -Item $folderTimeProxy -UntouchedOlderThan $FolderUntouchedOlderThan -OlderThan $FolderOlderThan -Combine $FolderTimeCombine -AgeProperty $FolderAgeProperty) { $toMove.Add([pscustomobject]@{ Item=$d; Reason='folder-time' }) } } # -------------------------- Confirmation before destructive actions -------------------------- if ($RequireConfirmation -and -not $DryRun) { $moveCount = $toMove.Count if ($moveCount -gt 0) { # Summarize planned operations $summaryLines = @() $summaryLines += "Planned operations: $moveCount item(s) will be moved (this will remove source items)." # show a small sample $sample = $toMove | Select-Object -First 10 | ForEach-Object { "{0} -> {1}" -f $_.Item.FullName, (Resolve-DestinationPath -Item $_.Item) } if ($toMove.Count -gt 10) { $sample += "... (and more)" } $summaryLines += "" $summaryLines += "Sample planned moves (first 10):" $summaryLines += $sample # Log summary Write-LogEntry -Level 'WARN' -Message ($summaryLines -join "`n") # Interactive prompt (fail-safe for non-interactive hosts) $choice = $null try { $choices = @([System.Management.Automation.Host.ChoiceDescription]::new("&Yes","Proceed with moves and deletions"), [System.Management.Automation.Host.ChoiceDescription]::new("&No","Abort; do not perform moves")) $caption = "Downloads Auto-Archiver: confirm destructive actions" $message = "This run will move $moveCount item(s) and remove the source files. Proceed?" $choiceIdx = $Host.UI.PromptForChoice($caption, $message, $choices, 1) $choice = $choiceIdx } catch { # Host not interactive - abort to be safe Write-LogEntry -Level 'ERROR' -Message "Non-interactive host and RequireConfirmation enabled: aborting to avoid destructive actions." throw "RequireConfirmation is enabled but no interactive prompt is available. Re-run with -RequireConfirmation:$false to skip confirmation in non-interactive contexts." } if ($choice -ne 0) { Write-LogEntry -Level 'WARN' -Message "User aborted run via confirmation prompt. No files were moved or deleted." exit 0 } else { Write-LogEntry -Level 'INFO' -Message "User confirmed destructive actions; proceeding with moves." } } else { # Nothing to move; safe to continue (no destructive actions) Write-LogEntry -Message "No items selected for move; nothing to confirm." } } # -------------------------- Execute moves -------------------------- $ops = 0 foreach ($entry in $toMove) { if ($ops -ge $MaxOperationsPerRun) { Write-LogEntry -Level 'WARN' -Message "Hit MaxOperationsPerRun=$MaxOperationsPerRun; stopping for safety."; break } $item = $entry.Item $reason = $entry.Reason $target = Resolve-DestinationPath -Item $item $target = Resolve-NameConflict -TargetPath $target if (-not $target) { Write-LogEntry -Level 'WARN' -Message "Skipping due to name conflict policy: $($item.FullName)"; continue } $ok = Move-ItemSafe -Item $item -TargetPath $target if ($ok) { $ops++ Write-LogEntry -Message "Moved [$reason]: $($item.FullName) -> $target" -Data @{path=$item.FullName; dest=$target; reason=$reason} } } # -------------------------- Cleanup: delete empty top-level folders only (safer) -------------------------- # Policy: do NOT touch nested folders. Only remove empty immediate children of $SourceDir # Respect DryRun and RequireConfirmation (interactive). Skip hidden/excluded items. if ($DeleteEmptyFolders) { $deleted = 0 # Grab immediate child directories only (top-level) try { $topLevelDirs = Get-ChildItem -LiteralPath $SourceDir -Directory -Force -ErrorAction Stop } catch { Write-LogEntry -Level 'WARN' -Message "Failed to enumerate top-level directories under $($SourceDir): $($_.Exception.Message)" $topLevelDirs = @() } # If destructive actions require confirmation and we are about to perform real deletions, # prompt the user (skip prompt for DryRun). If an earlier confirmation already occurred this run, # set $script:ConfirmedDestructive to avoid double prompts. $needsConfirmation = ($RequireConfirmation -and -not $DryRun) if ($needsConfirmation -and -not (Get-Variable -Name ConfirmedDestructive -Scope Script -ErrorAction SilentlyContinue)) { # Only prompt when there are top-level empties that would be deleted (compute quickly) $wouldDeleteCount = 0 foreach ($d in $topLevelDirs) { try { $hasChildren = (Get-ChildItem -LiteralPath $d.FullName -Force -ErrorAction Stop | Measure-Object).Count -gt 0 if (-not $hasChildren) { if ($IgnoreHidden -and ($d.Attributes -band [IO.FileAttributes]::Hidden)) { continue } if (-not (Test-Patterns -Item $d -Includes $IncludePatterns -Excludes $ExcludePatterns)) { continue } $wouldDeleteCount++ } } catch { continue } } if ($wouldDeleteCount -gt 0) { try { $choices = @( [System.Management.Automation.Host.ChoiceDescription]::new("&Yes","Proceed with removing empty top-level directories"), [System.Management.Automation.Host.ChoiceDescription]::new("&No","Abort; do not remove directories") ) $caption = "Downloads Auto-Archiver: confirm empty-directory removals" $message = "This run would remove $wouldDeleteCount empty top-level directory(ies) under $SourceDir. Proceed?" $choiceIdx = $Host.UI.PromptForChoice($caption, $message, $choices, 1) if ($choiceIdx -ne 0) { Write-LogEntry -Level 'WARN' -Message "User aborted empty-directory cleanup. No directories were removed." $topLevelDirs = @() # skip deletion loop } else { # mark that destructive actions were confirmed this run Set-Variable -Name ConfirmedDestructive -Scope Script -Value $true -Force Write-LogEntry -Level 'INFO' -Message "User confirmed empty-directory cleanup; proceeding." } } catch { Write-LogEntry -Level 'ERROR' -Message "RequireConfirmation enabled but host is non-interactive; skipping empty-directory cleanup to avoid destructive actions." $topLevelDirs = @() # skip deletion loop } } } foreach ($dir in $topLevelDirs) { try { $hasChildren = (Get-ChildItem -LiteralPath $dir.FullName -Force -ErrorAction Stop | Measure-Object).Count -gt 0 if ($hasChildren) { continue } # Respect hidden flag and include/exclude patterns if ($IgnoreHidden -and ($dir.Attributes -band [IO.FileAttributes]::Hidden)) { continue } if (-not (Test-Patterns -Item $dir -Includes $IncludePatterns -Excludes $ExcludePatterns)) { continue } if ($DryRun) { Write-LogEntry -Message "[DRYRUN] Would remove empty top-level directory: $($dir.FullName)" } else { Remove-Item -LiteralPath $dir.FullName -Force -ErrorAction Stop $deleted++ Write-LogEntry -Message "Removed empty top-level directory: $($dir.FullName)" } } catch { Write-LogEntry -Level 'WARN' -Message "Failed to evaluate/delete top-level dir $($dir.FullName): $($_.Exception.Message)" } } if ($deleted -gt 0) { Write-LogEntry -Message "Deleted empty top-level folders: $deleted" } } Write-LogEntry -Message "Completed. Operations performed: $ops (DryRun=$DryRun)" |