commit def171a0d92134ee393077d81e285f83b3bd1779 Author: weishaupt <1+weishaupt@noreply.localhost> Date: Thu Oct 22 23:04:57 2026 +0000 Add Satsuma scripts diff --git a/Convert.ps1 b/Convert.ps1 new file mode 100644 index 0000000..64582a0 --- /dev/null +++ b/Convert.ps1 @@ -0,0 +1,356 @@ +param( + [string]$Target, + [string]$Source +) + +# Satsuma converter. Called by Run.vbs from the right-click menu. +# Target is the format wanted (jpg, png, mp4, pdf ...). Source is the full path of the file. + +$ErrorActionPreference = 'Continue' +$root = $PSScriptRoot +$logFile = Join-Path $root 'log.txt' + +function Write-Log([string]$text) { + try { + if ((Test-Path -LiteralPath $logFile) -and ((Get-Item -LiteralPath $logFile).Length -gt 1048576)) { + Remove-Item -LiteralPath $logFile -Force + } + Add-Content -LiteralPath $logFile -Value ('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $text) + } catch { } +} + +function Limit-Text([string]$s, [int]$max) { + if ($null -eq $s) { return '' } + $s = ($s -replace '\s+', ' ').Trim() + if ($s.Length -gt $max) { $s = $s.Substring(0, $max - 3) + '...' } + return $s +} + +# ---------- tray notifications ---------- +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +$tray = New-Object System.Windows.Forms.NotifyIcon +$icoPath = Join-Path $root 'satsuma.ico' +if (Test-Path -LiteralPath $icoPath) { + $tray.Icon = New-Object System.Drawing.Icon($icoPath) +} else { + $tray.Icon = [System.Drawing.SystemIcons]::Information +} +$tray.Visible = $true + +function Send-Note([string]$title, [string]$text, [string]$kind) { + try { + $tray.BalloonTipTitle = (Limit-Text $title 60) + $tray.BalloonTipText = (Limit-Text $text 250) + $tray.BalloonTipIcon = $kind + $tray.ShowBalloonTip(5000) + } catch { } +} + +# ---------- helpers ---------- +function Test-Nonempty([string]$p) { + return ((Test-Path -LiteralPath $p) -and ((Get-Item -LiteralPath $p).Length -gt 0)) +} + +function Remove-IfExists([string]$p) { + if (Test-Path -LiteralPath $p) { + Remove-Item -LiteralPath $p -Force -Recurse -ErrorAction SilentlyContinue + } +} + +function Get-OutPath([string]$src, [string]$ext) { + $dir = [System.IO.Path]::GetDirectoryName($src) + $base = [System.IO.Path]::GetFileNameWithoutExtension($src) + $candidate = Join-Path $dir ($base + '.' + $ext) + $n = 1 + while (Test-Path -LiteralPath $candidate) { + $candidate = Join-Path $dir ('{0} ({1}).{2}' -f $base, $n, $ext) + $n++ + } + return $candidate +} + +function Invoke-Tool([string]$exe, [string[]]$arguments) { + # Turn every output line (including error lines) into plain text so messages stay readable. + $text = & $exe @arguments 2>&1 | ForEach-Object { $_.ToString() } | Out-String + $code = $LASTEXITCODE + return @{ Code = $code; Output = $text } +} + +function Get-Ffmpeg { + $p = Join-Path $root 'bin\ffmpeg.exe' + if (Test-Path -LiteralPath $p) { return $p } + $c = Get-Command ffmpeg.exe -ErrorAction SilentlyContinue + if ($c) { return $c.Source } + throw 'ffmpeg was not found. Run Install.bat again.' +} + +function Get-Magick { + $dir = Join-Path $root 'bin\magick' + if (Test-Path -LiteralPath $dir) { + $f = Get-ChildItem -LiteralPath $dir -Filter 'magick.exe' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($f) { return $f.FullName } + } + foreach ($base in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) { + if ($base -and (Test-Path -LiteralPath $base)) { + $d = Get-ChildItem -LiteralPath $base -Filter 'ImageMagick*' -Directory -ErrorAction SilentlyContinue | Sort-Object Name -Descending | Select-Object -First 1 + if ($d) { + $m = Join-Path $d.FullName 'magick.exe' + if (Test-Path -LiteralPath $m) { return $m } + } + } + } + $c = Get-Command magick.exe -ErrorAction SilentlyContinue + if ($c) { return $c.Source } + throw 'ImageMagick was not found. Run Install.bat again.' +} + +function Find-Soffice { + $candidates = @( + (Join-Path $env:ProgramFiles 'LibreOffice\program\soffice.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'LibreOffice\program\soffice.exe') + ) + foreach ($c in $candidates) { + if ($c -and (Test-Path -LiteralPath $c)) { return $c } + } + return $null +} + +function Free-Com($obj) { + if ($null -ne $obj) { + try { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($obj) } catch { } + } +} + +# ---------- video ---------- +function Convert-Video([string]$src, [string]$out, [string]$t) { + $ff = Get-Ffmpeg + $pre = @('-hide_banner', '-loglevel', 'error', '-nostdin', '-y', '-i', $src, '-map', '0:v:0', '-map', '0:a?') + # Several encoders refuse odd sizes (321x241 for example), so round every size down to even. + $evenSize = @('-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2') + $x264 = @('-c:v', 'libx264', '-preset', 'fast', '-crf', '20', '-pix_fmt', 'yuv420p') + $aac = @('-c:a', 'aac', '-b:a', '192k') + + switch ($t) { + 'mp4' { $enc = $x264 + $aac + @('-movflags', '+faststart') } + 'mov' { $enc = $x264 + $aac } + 'mkv' { $enc = $x264 + $aac } + 'flv' { $enc = $x264 + $aac + @('-ar', '44100') } + 'webm' { $enc = @('-c:v', 'libvpx-vp9', '-crf', '30', '-b:v', '0', '-row-mt', '1', '-cpu-used', '4', '-pix_fmt', 'yuv420p', '-c:a', 'libopus', '-b:a', '128k') } + 'avi' { $enc = @('-c:v', 'mpeg4', '-q:v', '3', '-pix_fmt', 'yuv420p', '-c:a', 'libmp3lame', '-q:a', '3', '-ac', '2') } + 'wmv' { $enc = @('-c:v', 'wmv2', '-b:v', '5M', '-pix_fmt', 'yuv420p', '-c:a', 'wmav2', '-b:a', '192k', '-ac', '2') } + 'mpg' { $enc = @('-c:v', 'mpeg2video', '-q:v', '3', '-pix_fmt', 'yuv420p', '-c:a', 'mp2', '-b:a', '192k', '-ar', '48000', '-ac', '2') } + default { throw ('Unsupported video format: ' + $t) } + } + + # MKV can hold nearly anything, so try a fast lossless copy first. + if ($t -eq 'mkv') { + $r = Invoke-Tool $ff ($pre + @('-c', 'copy', $out)) + if (($r.Code -eq 0) -and (Test-Nonempty $out)) { return } + Remove-IfExists $out + } + + $r = Invoke-Tool $ff ($pre + $evenSize + $enc + @($out)) + if (($r.Code -ne 0) -or -not (Test-Nonempty $out)) { + Remove-IfExists $out + throw ('ffmpeg failed: ' + (Limit-Text $r.Output 200)) + } +} + +# ---------- audio (also pulls the sound out of a video) ---------- +function Convert-Audio([string]$src, [string]$out, [string]$t) { + $ff = Get-Ffmpeg + $pre = @('-hide_banner', '-loglevel', 'error', '-nostdin', '-y', '-i', $src, '-vn', '-map', '0:a:0') + + switch ($t) { + 'mp3' { $enc = @('-c:a', 'libmp3lame', '-q:a', '2') } + 'wav' { $enc = @('-c:a', 'pcm_s16le') } + 'flac' { $enc = @('-c:a', 'flac') } + 'ogg' { $enc = @('-c:a', 'libvorbis', '-q:a', '5') } + 'm4a' { $enc = @('-c:a', 'aac', '-b:a', '192k') } + 'opus' { $enc = @('-c:a', 'libopus', '-b:a', '128k') } + default { throw ('Unsupported audio format: ' + $t) } + } + + $r = Invoke-Tool $ff ($pre + $enc + @($out)) + if (($r.Code -ne 0) -or -not (Test-Nonempty $out)) { + Remove-IfExists $out + throw ('ffmpeg failed: ' + (Limit-Text $r.Output 200)) + } +} + +# ---------- images ---------- +function Convert-Image([string]$src, [string]$out, [string]$srcExt, [string]$t) { + $mg = Get-Magick + $stamp = [guid]::NewGuid().ToString('N') + # Work on safe temp names so odd characters in file names cannot confuse ImageMagick. + $tmpIn = Join-Path $env:TEMP ('satsuma_{0}_in.{1}' -f $stamp, $srcExt) + $tmpOut = Join-Path $env:TEMP ('satsuma_{0}_out.{1}' -f $stamp, $t) + try { + Copy-Item -LiteralPath $src -Destination $tmpIn -Force + $mArgs = @(($tmpIn + '[0]'), '-auto-orient') + switch ($t) { + 'jpg' { $mArgs += @('-background', 'white', '-alpha', 'remove', '-alpha', 'off', '-quality', '92') } + 'bmp' { $mArgs += @('-background', 'white', '-alpha', 'remove', '-alpha', 'off') } + 'pdf' { $mArgs += @('-background', 'white', '-alpha', 'remove', '-alpha', 'off', '-quality', '92') } + 'webp' { $mArgs += @('-quality', '90') } + 'tiff' { $mArgs += @('-compress', 'lzw') } + 'png' { } + 'gif' { } + default { throw ('Unsupported image format: ' + $t) } + } + $mArgs += $tmpOut + $r = Invoke-Tool $mg $mArgs + if (($r.Code -ne 0) -or -not (Test-Nonempty $tmpOut)) { + throw ('ImageMagick failed: ' + (Limit-Text $r.Output 200)) + } + Move-Item -LiteralPath $tmpOut -Destination $out -Force + } finally { + Remove-IfExists $tmpIn + Remove-IfExists $tmpOut + } +} + +# ---------- documents to PDF ---------- +function Convert-WithWord([string]$src, [string]$out) { + $app = $null + $doc = $null + try { + $app = New-Object -ComObject Word.Application + $app.Visible = $false + $app.DisplayAlerts = 0 + # The odd password stops Word from showing a password box nobody can see. + $doc = $app.Documents.Open($src, $false, $true, $false, 'satsuma-no-password') + $doc.ExportAsFixedFormat($out, 17) + } finally { + if ($null -ne $doc) { try { $doc.Close(0) } catch { } } + if ($null -ne $app) { try { $app.Quit(0) } catch { } } + Free-Com $doc + Free-Com $app + } +} + +function Convert-WithExcel([string]$src, [string]$out) { + $app = $null + $book = $null + try { + $app = New-Object -ComObject Excel.Application + $app.Visible = $false + $app.DisplayAlerts = $false + $missing = [System.Reflection.Missing]::Value + $book = $app.Workbooks.Open($src, 0, $true, $missing, 'satsuma-no-password') + $book.ExportAsFixedFormat(0, $out) + } finally { + if ($null -ne $book) { try { $book.Close($false) } catch { } } + if ($null -ne $app) { try { $app.Quit() } catch { } } + Free-Com $book + Free-Com $app + } +} + +function Convert-WithPowerPoint([string]$src, [string]$out) { + $app = $null + $pres = $null + try { + $app = New-Object -ComObject PowerPoint.Application + $pres = $app.Presentations.Open($src, -1, 0, 0) + $pres.SaveAs($out, 32) + } finally { + if ($null -ne $pres) { try { $pres.Close() } catch { } } + if ($null -ne $app) { try { $app.Quit() } catch { } } + Free-Com $pres + Free-Com $app + } +} + +function Convert-Document([string]$src, [string]$out, [string]$ext) { + $wordExts = @('doc', 'docx', 'rtf', 'txt', 'odt') + $excelExts = @('xls', 'xlsx', 'ods') + $slideExts = @('ppt', 'pptx', 'odp') + $problems = @() + + try { + if ($wordExts -contains $ext) { Convert-WithWord $src $out } + elseif ($excelExts -contains $ext) { Convert-WithExcel $src $out } + elseif ($slideExts -contains $ext) { Convert-WithPowerPoint $src $out } + else { throw ('Unsupported document type: ' + $ext) } + } catch { + $problems += $_.Exception.Message + } + if (Test-Nonempty $out) { return } + Remove-IfExists $out + + $soffice = Find-Soffice + if ($soffice) { + $tmpDir = Join-Path $env:TEMP ('satsuma_' + [guid]::NewGuid().ToString('N')) + [void](New-Item -ItemType Directory -Force -Path $tmpDir) + try { + $r = Invoke-Tool $soffice @('--headless', '--convert-to', 'pdf', '--outdir', $tmpDir, $src) + $made = Join-Path $tmpDir ([System.IO.Path]::GetFileNameWithoutExtension($src) + '.pdf') + if (Test-Nonempty $made) { + Move-Item -LiteralPath $made -Destination $out -Force + return + } + $problems += ('LibreOffice: ' + $r.Output) + } finally { + Remove-IfExists $tmpDir + } + } else { + $problems += 'LibreOffice not installed' + } + + throw ('Could not make a PDF. This needs Microsoft Office or LibreOffice installed. ' + (Limit-Text ($problems -join ' | ') 160)) +} + +# ---------- main ---------- +$videoTargets = @('mp4', 'mkv', 'webm', 'avi', 'mov', 'wmv', 'flv', 'mpg') +$audioTargets = @('mp3', 'wav', 'flac', 'ogg', 'm4a', 'opus') +$imageTargets = @('jpg', 'png', 'webp', 'bmp', 'tiff', 'gif') +$docExts = @('doc', 'docx', 'rtf', 'txt', 'odt', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'odp') + +$mutex = New-Object System.Threading.Mutex($false, 'Local\SatsumaQueue') +$haveLock = $false +$exitCode = 0 +$name = '' + +try { + if ([string]::IsNullOrWhiteSpace($Target) -or [string]::IsNullOrWhiteSpace($Source)) { + throw 'Missing format or file name.' + } + $Target = $Target.ToLower() + $name = [System.IO.Path]::GetFileName($Source) + $srcExt = [System.IO.Path]::GetExtension($Source).TrimStart('.').ToLower() + + # One conversion at a time, so picking many files does not overload the machine. + try { $haveLock = $mutex.WaitOne() } catch [System.Threading.AbandonedMutexException] { $haveLock = $true } + + if (-not (Test-Path -LiteralPath $Source)) { throw ('File not found: ' + $Source) } + + Send-Note 'Satsuma' ('Converting {0} to {1}' -f $name, $Target.ToUpper()) 'Info' + $out = Get-OutPath $Source $Target + + if ($Target -eq 'pdf') { + if ($docExts -contains $srcExt) { Convert-Document $Source $out $srcExt } + else { Convert-Image $Source $out $srcExt 'pdf' } + } + elseif ($videoTargets -contains $Target) { Convert-Video $Source $out $Target } + elseif ($audioTargets -contains $Target) { Convert-Audio $Source $out $Target } + elseif ($imageTargets -contains $Target) { Convert-Image $Source $out $srcExt $Target } + else { throw ('Unknown format: ' + $Target) } + + $outName = [System.IO.Path]::GetFileName($out) + Write-Log ('OK {0} -> {1}' -f $Source, $out) + Send-Note 'Satsuma: done' $outName 'Info' +} +catch { + $msg = $_.Exception.Message + Write-Log ('FAILED {0} -> {1}: {2}' -f $Source, $Target, $msg) + Send-Note 'Satsuma could not convert' ($name + ': ' + $msg) 'Error' + $exitCode = 1 +} +finally { + if ($haveLock) { try { $mutex.ReleaseMutex() } catch { } } + Start-Sleep -Seconds 5 + try { $tray.Visible = $false; $tray.Dispose() } catch { } +} +exit $exitCode diff --git a/Install.bat b/Install.bat new file mode 100644 index 0000000..29faf25 --- /dev/null +++ b/Install.bat @@ -0,0 +1,5 @@ +@echo off +title Satsuma setup +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Install.ps1" +echo. +pause diff --git a/Install.ps1 b/Install.ps1 new file mode 100644 index 0000000..5d43ca4 --- /dev/null +++ b/Install.ps1 @@ -0,0 +1,247 @@ +# Satsuma installer. Run it with Install.bat. +# Adds a "Convert to (Satsuma)" entry to the right-click menu. Nothing needs admin rights. + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$root = Join-Path $env:LOCALAPPDATA 'Satsuma' +$bin = Join-Path $root 'bin' +$tmp = Join-Path $env:TEMP ('satsuma_setup_' + [guid]::NewGuid().ToString('N')) + +function Say([string]$text) { Write-Host $text } +function Good([string]$text) { Write-Host $text -ForegroundColor Green } +function Warn([string]$text) { Write-Host $text -ForegroundColor Yellow } + +function Set-Reg([string]$subKey, [string]$name, [string]$value) { + $k = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($subKey) + $k.SetValue($name, $value) + $k.Close() +} + +function Remove-Reg([string]$subKey) { + try { [Microsoft.Win32.Registry]::CurrentUser.DeleteSubKeyTree($subKey, $false) } catch { } +} + +function Find-Magick { + $dir = Join-Path $bin 'magick' + if (Test-Path -LiteralPath $dir) { + $f = Get-ChildItem -LiteralPath $dir -Filter 'magick.exe' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($f) { return $f.FullName } + } + foreach ($base in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) { + if ($base -and (Test-Path -LiteralPath $base)) { + $d = Get-ChildItem -LiteralPath $base -Filter 'ImageMagick*' -Directory -ErrorAction SilentlyContinue | Sort-Object Name -Descending | Select-Object -First 1 + if ($d) { + $m = Join-Path $d.FullName 'magick.exe' + if (Test-Path -LiteralPath $m) { return $m } + } + } + } + return $null +} + +Write-Host '' +Write-Host ' Satsuma setup' -ForegroundColor Yellow +Write-Host ' Right-click file converter for Windows' +Write-Host '' + +try { + [void](New-Item -ItemType Directory -Force -Path $root) + [void](New-Item -ItemType Directory -Force -Path $bin) + [void](New-Item -ItemType Directory -Force -Path $tmp) + + # ---- 1. copy the program files ---- + Say 'Step 1 of 4: copying Satsuma files...' + foreach ($f in @('Convert.ps1', 'Run.vbs', 'satsuma.ico')) { + $from = Join-Path $here $f + if (-not (Test-Path -LiteralPath $from)) { throw ('A file is missing next to the installer: ' + $f + '. Unzip the whole folder first.') } + $to = Join-Path $root $f + Copy-Item -LiteralPath $from -Destination $to -Force + try { Unblock-File -LiteralPath $to } catch { } + } + Good ' done' + + # ---- 2. ffmpeg (video, audio, webp) ---- + $ffmpegExe = Join-Path $bin 'ffmpeg.exe' + if (Test-Path -LiteralPath $ffmpegExe) { + Say 'Step 2 of 4: ffmpeg is already here.' + } else { + Say 'Step 2 of 4: downloading ffmpeg (about 90 MB, this can take a minute)...' + $zip = Join-Path $tmp 'ffmpeg.zip' + $urls = @( + 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip', + 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip' + ) + $got = $false + foreach ($u in $urls) { + try { + Invoke-WebRequest -Uri $u -OutFile $zip -UseBasicParsing + $got = $true + break + } catch { + Warn (' that source failed, trying the next one: ' + $_.Exception.Message) + } + } + if (-not $got) { throw 'Could not download ffmpeg. Check the internet connection and run Install.bat again.' } + $x = Join-Path $tmp 'ffmpeg_x' + Expand-Archive -LiteralPath $zip -DestinationPath $x -Force + $found = Get-ChildItem -LiteralPath $x -Filter 'ffmpeg.exe' -Recurse | Select-Object -First 1 + if (-not $found) { throw 'ffmpeg.exe was not inside the download.' } + Copy-Item -LiteralPath $found.FullName -Destination $ffmpegExe -Force + Good ' done' + } + try { + $v = (& $ffmpegExe -version 2>&1 | Select-Object -First 1) | Out-String + Say (' ' + $v.Trim()) + } catch { + Warn ' ffmpeg was installed but did not start. Video and audio may not work.' + } + + # ---- 3. ImageMagick (HEIC, WebP, PNG, JPG) ---- + $magickPath = Find-Magick + if ($magickPath) { + Say 'Step 3 of 4: ImageMagick is already here.' + } else { + Say 'Step 3 of 4: downloading ImageMagick (for HEIC and other pictures)...' + $base = 'https://imagemagick.org/archive/binaries/' + $name = $null + try { + $html = (Invoke-WebRequest -Uri $base -UseBasicParsing).Content + $best = $null + $bestKey = $null + foreach ($m in [regex]::Matches($html, 'ImageMagick-(\d+)\.(\d+)\.(\d+)-(\d+)-portable-Q16-HDRI-x64\.7z')) { + $key = [version]('{0}.{1}.{2}.{3}' -f $m.Groups[1].Value, $m.Groups[2].Value, $m.Groups[3].Value, $m.Groups[4].Value) + if (($null -eq $bestKey) -or ($key -gt $bestKey)) { $bestKey = $key; $best = $m.Value } + } + $name = $best + } catch { } + if (-not $name) { $name = 'ImageMagick-7.1.2-25-portable-Q16-HDRI-x64.7z' } + + $ok = $false + try { + $arc = Join-Path $tmp 'magick.7z' + $z = Join-Path $tmp '7zr.exe' + Invoke-WebRequest -Uri ($base + $name) -OutFile $arc -UseBasicParsing + Invoke-WebRequest -Uri 'https://www.7-zip.org/a/7zr.exe' -OutFile $z -UseBasicParsing + $dest = Join-Path $bin 'magick' + [void](New-Item -ItemType Directory -Force -Path $dest) + & $z x $arc ('-o' + $dest) -y | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'The 7-Zip extractor reported an error.' } + $ok = $true + } catch { + Warn (' portable download did not work: ' + $_.Exception.Message) + } + + if (-not $ok) { + Say ' trying winget instead (Windows may ask for permission)...' + try { + $wg = Get-Command winget.exe -ErrorAction SilentlyContinue + if ($wg) { + & $wg.Source install --id ImageMagick.ImageMagick -e --silent --accept-package-agreements --accept-source-agreements + } + } catch { } + } + + $magickPath = Find-Magick + if ($magickPath) { Good ' done' } + else { Warn ' ImageMagick could not be installed. Pictures will not convert, but video, audio and documents still can.' } + } + + if ($magickPath) { + try { + $fmt = & $magickPath -list format 2>&1 | Out-String + if ($fmt -match '(?m)^\s*HEIC\b') { Good ' HEIC pictures are supported.' } + else { Warn ' This ImageMagick build does not list HEIC support, so iPhone pictures may fail.' } + } catch { + Warn ' Could not check HEIC support.' + } + } + + # ---- 4. the right-click menu ---- + Say 'Step 4 of 4: adding the right-click menu...' + $iconPath = Join-Path $root 'satsuma.ico' + $runVbs = Join-Path $root 'Run.vbs' + + $video = @( + @('mp4', 'MP4'), @('mkv', 'MKV'), @('webm', 'WebM'), @('avi', 'AVI'), + @('mov', 'MOV'), @('wmv', 'WMV'), @('flv', 'FLV'), @('mpg', 'MPG') + ) + $audio = @( + @('mp3', 'MP3'), @('wav', 'WAV'), @('flac', 'FLAC'), + @('ogg', 'OGG'), @('m4a', 'M4A'), @('opus', 'Opus') + ) + $image = @( + @('jpg', 'JPG'), @('png', 'PNG'), @('webp', 'WebP'), + @('bmp', 'BMP'), @('tiff', 'TIFF'), @('pdf', 'PDF') + ) + $same = @{ 'm4v' = 'mp4'; 'mpeg' = 'mpg'; 'jpeg' = 'jpg'; 'tif' = 'tiff'; 'heif' = 'heic' } + + $groups = @( + @{ Exts = @('mp4', 'm4v', 'mkv', 'webm', 'avi', 'mov', 'wmv', 'flv', 'mpg', 'mpeg', '3gp', 'ogv'); Items = ($video + @(, @('mp3', 'MP3 (sound only)'))) }, + @{ Exts = @('mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'opus', 'wma'); Items = $audio }, + @{ Exts = @('heic', 'heif', 'webp', 'png', 'jpg', 'jpeg', 'bmp', 'tif', 'tiff'); Items = $image }, + @{ Exts = @('gif'); Items = ($image + @(, @('mp4', 'MP4 video'))) }, + @{ Exts = @('doc', 'docx', 'rtf', 'txt', 'odt', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'odp'); Items = @(, @('pdf', 'PDF')) } + ) + + $count = 0 + foreach ($g in $groups) { + foreach ($ext in $g.Exts) { + $menuKey = 'Software\Classes\SystemFileAssociations\.' + $ext + '\shell\Satsuma' + Remove-Reg $menuKey + $mine = $ext + if ($same.ContainsKey($ext)) { $mine = $same[$ext] } + + Set-Reg $menuKey 'MUIVerb' 'Convert to (Satsuma)' + Set-Reg $menuKey 'SubCommands' '' + Set-Reg $menuKey 'Icon' $iconPath + + $n = 0 + foreach ($item in $g.Items) { + $t = $item[0] + $label = $item[1] + if ($t -eq $mine) { continue } + $n++ + $verbKey = '{0}\shell\{1:D2}_{2}' -f $menuKey, $n, $t + Set-Reg $verbKey '' $label + Set-Reg ($verbKey + '\command') '' ('wscript.exe "{0}" {1} "%1"' -f $runVbs, $t) + } + $count++ + } + } + Good (' done (' + $count + ' file types)') + + # ---- Windows 11 note ---- + $build = 0 + try { $build = [int](Get-CimInstance Win32_OperatingSystem).BuildNumber } catch { } + if ($build -ge 22000) { + $clsid = 'Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32' + $existing = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($clsid) + if ($null -eq $existing) { + Write-Host '' + Warn 'Windows 11 hides this menu under "Show more options" in the right-click menu.' + $a = Read-Host 'Turn on the classic full right-click menu so Satsuma shows right away? (Y/N)' + if ($a -match '^[Yy]') { + Set-Reg $clsid '' '' + Set-Content -LiteralPath (Join-Path $root 'classic-menu.txt') -Value 'set by Satsuma' + Good ' classic menu turned on. Sign out and back in (or restart) to see it.' + } + } else { + $existing.Close() + } + } + + Write-Host '' + Good 'All set.' + Say 'Right-click a picture, video, sound or document file and look for "Convert to (Satsuma)".' + Say 'The converted file lands next to the original. The original is never touched.' +} +catch { + Write-Host '' + Write-Host ('Setup stopped: ' + $_.Exception.Message) -ForegroundColor Red +} +finally { + if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue } +} diff --git a/Run.vbs b/Run.vbs new file mode 100644 index 0000000..cf0078e --- /dev/null +++ b/Run.vbs @@ -0,0 +1,15 @@ +' Satsuma launcher. Starts the converter with no visible window. +Option Explicit + +Dim sh, fso, here, target, src, cmd +Set sh = CreateObject("WScript.Shell") +Set fso = CreateObject("Scripting.FileSystemObject") + +If WScript.Arguments.Count < 2 Then WScript.Quit 1 + +here = fso.GetParentFolderName(WScript.ScriptFullName) +target = WScript.Arguments(0) +src = WScript.Arguments(1) + +cmd = "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File """ & here & "\Convert.ps1"" -Target """ & target & """ -Source """ & src & """" +sh.Run cmd, 0, False diff --git a/Uninstall.ps1 b/Uninstall.ps1 new file mode 100644 index 0000000..6d14dc2 --- /dev/null +++ b/Uninstall.ps1 @@ -0,0 +1,43 @@ +# Satsuma uninstaller. Run it with Uninstall.bat. +$ErrorActionPreference = 'Continue' + +$root = Join-Path $env:LOCALAPPDATA 'Satsuma' +$classes = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Software\Classes\SystemFileAssociations', $true) +$removed = 0 + +if ($null -ne $classes) { + foreach ($name in $classes.GetSubKeyNames()) { + $shell = $classes.OpenSubKey($name + '\shell', $true) + if ($null -ne $shell) { + if ($shell.GetSubKeyNames() -contains 'Satsuma') { + try { $shell.DeleteSubKeyTree('Satsuma', $false); $removed++ } catch { } + } + $shell.Close() + } + } + $classes.Close() +} +Write-Host ('Removed the right-click menu from ' + $removed + ' file types.') + +$marker = Join-Path $root 'classic-menu.txt' +if (Test-Path -LiteralPath $marker) { + $a = Read-Host 'Satsuma turned on the classic Windows 11 right-click menu. Put the new Windows 11 menu back? (Y/N)' + if ($a -match '^[Yy]') { + try { + [Microsoft.Win32.Registry]::CurrentUser.DeleteSubKeyTree('Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}', $false) + Write-Host 'Done. Sign out and back in (or restart) to see the change.' + } catch { } + } +} + +if (Test-Path -LiteralPath $root) { + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $root) { + Write-Host 'Some files in the Satsuma folder could not be deleted (they may be in use). Delete this folder by hand:' + Write-Host $root + } else { + Write-Host 'Deleted the Satsuma program folder, including the downloaded tools.' + } +} +Write-Host '' +Write-Host 'Satsuma is removed.'