diff --git a/README.md b/README.md index 8961503..7d4c71d 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,14 @@ ArnePowershellAutomation/ │ ├── Config.ps1 # Configuration and state I/O │ ├── Elevation.ps1 # UAC elevation helper │ ├── NetworkUtils.ps1 # DNS suffix detection - │ └── ToastHelper.ps1 # Windows 11 toast notifications + │ ├── ToastHelper.ps1 # Windows 11 toast notifications + │ ├── PromptHelper.ps1 # Multi-choice dialog boxes (up to 4 buttons) + │ └── SandwichAutoOrder.ps1 # Browser keyboard automation for sandwich auto-order ├── features/ # Automation feature modules │ ├── DynamicLock.ps1 # Toggle Dynamic Lock on network presence │ ├── DefaultBrowser.ps1 # Monitor default browser setting - │ └── SandwichReminder.ps1 # Time-based reminder notifications + │ ├── SandwichReminder.ps1 # Time-based reminder with snooze options + │ └── SandwichReminder-AutoOrder.ps1 # Personal browser automation for sandwich ordering └── data/ ├── config.json # Feature toggles & settings (auto-seeded) ├── state/ @@ -123,14 +126,27 @@ Enables/disables Windows Dynamic Lock based on network connectivity. ### DefaultBrowser Monitors if your default browser matches expected ProgId (app identifier). - **Settings**: Target ProgId (e.g., "FirefoxURL", "OperaGXStable") -- **Logic**: On each run, reads current ProgId; if mismatch, shows toast with link to settings; max once per day +- **Logic**: On each run, reads current ProgId; if mismatch, shows a Yes/No dialog; "Yes" opens Windows Settings → Default apps for manual selection; max once per day - **State Tracked**: Last notification date to avoid spam +- **Note**: Windows 11 protects the default browser registry key with a hash; the browser cannot be changed programmatically, hence the guided Settings approach ### SandwichReminder -Shows a toast reminder at a specific time on a specific network. -- **Settings**: Reminder time (HH:MM), network domain suffix, reminder URL, notification window (±5 minutes) -- **Logic**: If on configured network AND current time is within ±5 min of reminder time AND not shown today, display toast with link -- **State Tracked**: Last notification date +Shows a reminder dialog at a specific time on a specific network with snooze options. +- **Settings**: Reminder time (HH:MM), network domain suffix, reminder URL, notification window in minutes +- **Logic**: If on configured network AND current time is within window of reminder time AND not shown today, display a 4-choice dialog: Order now / Snooze 15 min / Snooze 1 hour / No +- **State Tracked**: Last shown date, snooze-until timestamp +- **Auto-order delegation**: If `SandwichReminder-AutoOrder` feature is enabled, selecting "Order now" triggers the automated browser flow instead of just opening the URL + +### SandwichReminder-AutoOrder +Personal browser automation feature that drives the sandwich ordering website via keyboard navigation. +- **Settings**: Base URL, item ID, browser window title hint, tab counts for each navigation step, delays, calibration mode +- **Logic**: Opens the item modal URL, brings the browser window to the foreground (with retry), then sends keyboard inputs to: toggle option checkboxes → click add-to-cart → refresh page → tab to mini-cart → open cart → tab to confirm order button → open delivery popup +- **Key settings**: + - `tabsToOption1` / `tabsBetweenOptions` / `tabsToAddButton`: navigation within item modal + - `refreshBeforeCart`: send F5 after add-to-cart to reset focus to a known page position + - `tabsToCartButton` / `tabsToConfirmButton`: navigation after refresh + - `calibrationOnly` + `calibrationTabs`: safe mode to manually count tab stops without clicking +- **Note**: Uses WScript.Shell SendKeys (best-effort); relies on stable tab order in the target website ## Usage Guide @@ -168,54 +184,6 @@ When you select a feature for configuration, you'll be prompted for each setting - **Type: time** → Enter HH:MM format - Press Enter to accept the displayed default -## Testing Plan - -### T1 — Basic Logging & Config Load -- [ ] Run `.\configure.ps1` → Main menu displays -- [ ] Verify `internal/data/config.json` was created (if first run) -- [ ] Verify `internal/data/logs/automation-.log` has initialization entries - -### T2 — Feature Discovery & Configuration Menu -- [ ] Select menu option 4 (Configure) -- [ ] Verify all three features appear (DynamicLock, DefaultBrowser, SandwichReminder) -- [ ] Toggle DynamicLock (1) → Verify enabled flag flips in config.json -- [ ] Select C1 (configure DynamicLock) → Modify network suffix, save, verify in config.json - -### T3 — Registration & Task Scheduler -- [ ] Select menu option 2 (Register) -- [ ] If prompted for UAC, approve elevation -- [ ] Verify success message and `last-register-result.json` shows success=true -- [ ] Open Task Scheduler → Navigate to Library root → Verify "PSAutomation-Runner" task exists -- [ ] Right-click task → Properties → Verify "Run as current user" (not elevated), AtLogOn trigger, 2-min repetition - -### T4 — Feature Execution (DynamicLock) -- [ ] Register the runner (option 2) -- [ ] Enable DynamicLock in configuration menu (option 4) -- [ ] Configure DynamicLock network to current network suffix (use `Get-DnsClient` or `ipconfig /all` to find) -- [ ] Wait for runner to execute (at next 2-min interval or logon event) -- [ ] Check `internal/data/logs/automation-.log` for "DynamicLock" entries -- [ ] Verify state.json has updated `isConnected` flag - -### T5 — Feature Execution (DefaultBrowser) -- [ ] Enable DefaultBrowser in configuration menu -- [ ] Verify toast notification appears (if configured ProgId differs from current default) -- [ ] Click "Yes" button on toast → Should open Settings → Default apps -- [ ] Check logs for "DefaultBrowser" entries and state.json for `lastShownDate` - -### T6 — Feature Execution (SandwichReminder) -- [ ] Enable SandwichReminder in configuration menu -- [ ] Configure reminderTime to a time 1–2 minutes in the future (e.g., if current time is 14:35, set to 14:37) -- [ ] Configure to current network suffix -- [ ] Wait for runner to execute -- [ ] Verify toast appears with configured URL -- [ ] Check logs for "SandwichReminder" entries and state.json for `lastShownDate` - -### T7 — Error Isolation -- [ ] Intentionally break one feature (e.g., edit `internal/features/DynamicLock.ps1`, add invalid syntax) -- [ ] Wait for runner to execute -- [ ] Verify broken feature logged an error in automation-.log -- [ ] Verify other features still executed successfully (logged their own entries) - ## Known Limitations - **Default Browser Registry Protection**: On Windows 11, Microsoft protects the default browser registry key with a hash. The DefaultBrowser feature reads the ProgId but cannot *set* the default browser programmatically. The toast provides a link to Settings for manual override. diff --git a/internal/features/SandwichReminder-AutoOrder.ps1 b/internal/features/SandwichReminder-AutoOrder.ps1 new file mode 100644 index 0000000..6001ef0 --- /dev/null +++ b/internal/features/SandwichReminder-AutoOrder.ps1 @@ -0,0 +1,149 @@ +$FeatureMeta = @{ + Name = 'SandwichReminder-AutoOrder' + Description = 'Personal automation settings for sandwich auto-order browser flow' + Settings = @( + @{ + Key = 'baseUrl' + Label = 'Base URL' + Type = 'string' + Default = 'https://ylos-kitchen.unipage.eu' + Description = 'Base URL of the ordering website' + }, + @{ + Key = 'itemId' + Label = 'Item ID' + Type = 'string' + Default = 'dmwmo3' + Description = 'Item ID for direct item modal URL (default = Smoske)' + }, + @{ + Key = 'windowTitleHint' + Label = 'Browser Window Title Hint' + Type = 'string' + Default = "YLO'S Kitchen" + Description = 'Used to bring browser window to foreground before sending keys' + }, + @{ + Key = 'defaultItemRemark' + Label = 'Default Item Remark' + Type = 'string' + Default = 'zonder tomaat aub' + Description = 'For next step (checkout automation)' + }, + @{ + Key = 'defaultOrderRemark' + Label = 'Default Order Remark' + Type = 'string' + Default = 'levering in Sioen Bistro' + Description = 'For next step (checkout automation)' + }, + @{ + Key = 'initialDelayMs' + Label = 'Initial Delay (ms)' + Type = 'int' + Default = 2500 + Description = 'Wait time after opening browser before key automation starts' + }, + @{ + Key = 'activationTimeoutMs' + Label = 'Activation Timeout (ms)' + Type = 'int' + Default = 10000 + Description = 'How long to retry foregrounding the browser window' + }, + @{ + Key = 'stepDelayMs' + Label = 'Key Step Delay (ms)' + Type = 'int' + Default = 120 + Description = 'Delay between keyboard steps' + }, + @{ + Key = 'optionToggleKey' + Label = 'Option Toggle Key' + Type = 'string' + Default = 'SPACE' + Description = 'Key used to toggle option checkbox: SPACE or ENTER' + }, + @{ + Key = 'calibrationOnly' + Label = 'Calibration Only' + Type = 'bool' + Default = $false + Description = 'If enabled, only tabs through the modal and stops (no clicks/add)' + }, + @{ + Key = 'calibrationTabs' + Label = 'Calibration Tabs' + Type = 'int' + Default = 0 + Description = 'Number of tabs to send in calibration mode before stopping' + }, + @{ + Key = 'tabsToOption1' + Label = 'Tabs To Option 1' + Type = 'int' + Default = 7 + Description = 'Tab count from modal start to first option checkbox' + }, + @{ + Key = 'tabsBetweenOptions' + Label = 'Tabs Between Options' + Type = 'int' + Default = 2 + Description = 'Tab count from first checkbox to second checkbox' + }, + @{ + Key = 'tabsToAddButton' + Label = 'Tabs To Add Button' + Type = 'int' + Default = 6 + Description = 'Tab count from second checkbox to add-to-cart button' + }, + @{ + Key = 'openCartPopupAfterAdd' + Label = 'Open Cart Popup After Add' + Type = 'bool' + Default = $true + Description = 'Open mini-cart after adding item' + }, + @{ + Key = 'refreshBeforeCart' + Label = 'Refresh Before Cart' + Type = 'bool' + Default = $true + Description = 'Refresh the page after add-to-cart to reset focus to page start before tabbing to mini-cart' + }, + @{ + Key = 'postRefreshDelayMs' + Label = 'Post-Refresh Delay (ms)' + Type = 'int' + Default = 2500 + Description = 'Wait time after F5 refresh before tabbing to mini-cart' + }, + @{ + Key = 'tabsToCartButton' + Label = 'Tabs To Cart Button' + Type = 'int' + Default = 5 + Description = 'Tab count from page start (after refresh) to mini-cart button' + }, + @{ + Key = 'tabsToConfirmButton' + Label = 'Tabs To Confirm Button' + Type = 'int' + Default = 3 + Description = 'Tab count from mini-cart button to confirm order button (opens delivery popup)' + } + ) +} + +function Invoke-Feature { + param( + [hashtable]$Config, + [hashtable]$State + ) + + if (-not $State) { $State = @{} } + return $State +} diff --git a/internal/features/SandwichReminder.ps1 b/internal/features/SandwichReminder.ps1 index f5396c9..55e323d 100644 --- a/internal/features/SandwichReminder.ps1 +++ b/internal/features/SandwichReminder.ps1 @@ -118,8 +118,21 @@ function Invoke-Feature { if ($decision -eq 'order') { try { - Start-Process $Config['url'] - Write-Log -Level Info -Message "Opened sandwich order URL: $($Config['url'])" -Feature 'SandwichReminder' + $rootConfig = Get-Config + $autoOrderConfig = $null + + if ($rootConfig.features.ContainsKey('SandwichReminder-AutoOrder')) { + $autoOrderConfig = $rootConfig.features['SandwichReminder-AutoOrder'] + } + + if ($autoOrderConfig -and $autoOrderConfig['enabled']) { + Write-Log -Level Info -Message 'SandwichReminder-AutoOrder is enabled; running auto-order flow.' -Feature 'SandwichReminder' + [void](Invoke-SandwichAutoOrderFlow -AutoOrderConfig $autoOrderConfig -FallbackUrl $Config['url']) + } + else { + Start-Process $Config['url'] + Write-Log -Level Info -Message "Opened sandwich order URL: $($Config['url'])" -Feature 'SandwichReminder' + } } catch { Write-Log -Level Error -Message "Failed to open sandwich order URL '$($Config['url'])': $_" -Feature 'SandwichReminder' diff --git a/internal/lib/SandwichAutoOrder.ps1 b/internal/lib/SandwichAutoOrder.ps1 new file mode 100644 index 0000000..e269233 --- /dev/null +++ b/internal/lib/SandwichAutoOrder.ps1 @@ -0,0 +1,184 @@ +# SandwichAutoOrder.ps1 — best-effort browser keyboard automation for personal sandwich flow. + +function Invoke-AppActivateBestEffort { + param( + [Parameter(Mandatory)]$Shell, + [Parameter(Mandatory)][string[]]$TitleCandidates, + [int]$TimeoutMs = 10000, + [int]$RetryDelayMs = 300 + ) + + $deadline = (Get-Date).AddMilliseconds($TimeoutMs) + $candidates = @($TitleCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + + do { + foreach ($candidate in $candidates) { + if ($Shell.AppActivate($candidate)) { + return $true + } + } + + Start-Sleep -Milliseconds $RetryDelayMs + } while ((Get-Date) -lt $deadline) + + return $false +} + +function Invoke-SandwichAutoOrderFlow { + [CmdletBinding()] + param( + [Parameter(Mandatory)][hashtable]$AutoOrderConfig, + [Parameter(Mandatory)][string]$FallbackUrl, + [string]$FeatureName = 'SandwichReminder-AutoOrder' + ) + + $baseUrl = [string]$AutoOrderConfig['baseUrl'] + if ([string]::IsNullOrWhiteSpace($baseUrl)) { $baseUrl = 'https://ylos-kitchen.unipage.eu' } + + $itemId = [string]$AutoOrderConfig['itemId'] + if ([string]::IsNullOrWhiteSpace($itemId)) { $itemId = 'dmwmo3' } + + $windowTitleHint = [string]$AutoOrderConfig['windowTitleHint'] + if ([string]::IsNullOrWhiteSpace($windowTitleHint)) { $windowTitleHint = "YLO'S Kitchen" } + + $initialDelayMs = [int]$AutoOrderConfig['initialDelayMs'] + if ($initialDelayMs -le 0) { $initialDelayMs = 2500 } + + $stepDelayMs = [int]$AutoOrderConfig['stepDelayMs'] + if ($stepDelayMs -le 0) { $stepDelayMs = 120 } + + $toggleKeySetting = [string]$AutoOrderConfig['optionToggleKey'] + if ([string]::IsNullOrWhiteSpace($toggleKeySetting)) { $toggleKeySetting = 'SPACE' } + $toggleKey = if ($toggleKeySetting.ToUpperInvariant() -eq 'ENTER') { '{ENTER}' } else { ' ' } + + $calibrationOnly = [bool]$AutoOrderConfig['calibrationOnly'] + $calibrationTabs = [int]$AutoOrderConfig['calibrationTabs'] + if ($calibrationTabs -lt 0) { $calibrationTabs = 0 } + + $tabsToOption1 = [int]$AutoOrderConfig['tabsToOption1'] + if ($tabsToOption1 -lt 0) { $tabsToOption1 = 3 } + + $tabsBetweenOptions = [int]$AutoOrderConfig['tabsBetweenOptions'] + if ($tabsBetweenOptions -lt 0) { $tabsBetweenOptions = 2 } + + $tabsToAddButton = [int]$AutoOrderConfig['tabsToAddButton'] + if ($tabsToAddButton -lt 0) { $tabsToAddButton = 4 } + + $openCartPopup = [bool]$AutoOrderConfig['openCartPopupAfterAdd'] + $refreshBeforeCart = [bool]$AutoOrderConfig['refreshBeforeCart'] + $postRefreshDelayMs = [int]$AutoOrderConfig['postRefreshDelayMs'] + if ($postRefreshDelayMs -le 0) { $postRefreshDelayMs = 2500 } + $tabsToCartButton = [int]$AutoOrderConfig['tabsToCartButton'] + if ($tabsToCartButton -le 0) { $tabsToCartButton = 5 } + $tabsToConfirmButton = [int]$AutoOrderConfig['tabsToConfirmButton'] + if ($tabsToConfirmButton -le 0) { $tabsToConfirmButton = 3 } + + $activationTimeoutMs = [int]$AutoOrderConfig['activationTimeoutMs'] + if ($activationTimeoutMs -le 0) { $activationTimeoutMs = 10000 } + + $itemUrl = ($baseUrl.TrimEnd('/') + '/item/' + $itemId) + + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + + Start-Process $itemUrl + Write-Log -Level Info -Message "Opened item modal URL for auto-order: $itemUrl" -Feature $FeatureName + Start-Sleep -Milliseconds $initialDelayMs + + $shell = New-Object -ComObject WScript.Shell + $hostHint = $null + try { $hostHint = ([uri]$baseUrl).Host } catch {} + + $activated = Invoke-AppActivateBestEffort -Shell $shell -TitleCandidates @( + 'Bestel online bij YLO''S Kitchen Pittem', + 'Bestel online bij YLO', + 'Bestel online bij', + $windowTitleHint, + ($windowTitleHint -replace "'", ''), + $hostHint, + 'ylos-kitchen', + 'unipage' + ) -TimeoutMs $activationTimeoutMs -RetryDelayMs 300 + + if (-not $activated) { + Write-Log -Level Warn -Message "Could not foreground browser window (title hint '$windowTitleHint'). Skipping key automation to avoid acting on the wrong window." -Feature $FeatureName + return $false + } + + if ($calibrationOnly) { + for ($i = 0; $i -lt $calibrationTabs; $i++) { + $shell.SendKeys('{TAB}') + Start-Sleep -Milliseconds $stepDelayMs + } + + Write-Log -Level Info -Message ("Calibration mode completed: sent {0} TAB keys and stopped." -f $calibrationTabs) -Feature $FeatureName + return $true + } + + # Modal flow keyboard navigation (best-effort; tab indexes are configurable). + for ($i = 0; $i -lt $tabsToOption1; $i++) { + $shell.SendKeys('{TAB}') + Start-Sleep -Milliseconds $stepDelayMs + } + $shell.SendKeys($toggleKey) + Start-Sleep -Milliseconds $stepDelayMs + + for ($i = 0; $i -lt $tabsBetweenOptions; $i++) { + $shell.SendKeys('{TAB}') + Start-Sleep -Milliseconds $stepDelayMs + } + $shell.SendKeys($toggleKey) + Start-Sleep -Milliseconds $stepDelayMs + + for ($i = 0; $i -lt $tabsToAddButton; $i++) { + $shell.SendKeys('{TAB}') + Start-Sleep -Milliseconds $stepDelayMs + } + $shell.SendKeys('{ENTER}') + Start-Sleep -Milliseconds 350 + + if ($openCartPopup) { + if ($refreshBeforeCart) { + $shell.SendKeys('{F5}') + Write-Log -Level Info -Message 'Refreshing page to reset focus before tabbing to mini-cart.' -Feature $FeatureName + Start-Sleep -Milliseconds $postRefreshDelayMs + + $activated = Invoke-AppActivateBestEffort -Shell $shell -TitleCandidates @( + 'Bestel online bij YLO''S Kitchen Pittem', + 'Bestel online bij YLO', + 'Bestel online bij', + $windowTitleHint, + ($windowTitleHint -replace "'", ''), + $hostHint, + 'ylos-kitchen', + 'unipage' + ) -TimeoutMs $activationTimeoutMs -RetryDelayMs 300 + + if (-not $activated) { + Write-Log -Level Warn -Message 'Could not re-foreground browser after refresh. Skipping cart popup.' -Feature $FeatureName + return $false + } + } + + for ($i = 0; $i -lt $tabsToCartButton; $i++) { + $shell.SendKeys('{TAB}') + Start-Sleep -Milliseconds $stepDelayMs + } + $shell.SendKeys('{ENTER}') + Start-Sleep -Milliseconds $stepDelayMs + + for ($i = 0; $i -lt $tabsToConfirmButton; $i++) { + $shell.SendKeys('{TAB}') + Start-Sleep -Milliseconds $stepDelayMs + } + $shell.SendKeys('{ENTER}') + } + + Write-Log -Level Info -Message 'Auto-order flow executed up to add-to-cart and cart popup open (best-effort).' -Feature $FeatureName + return $true + } + catch { + Write-Log -Level Error -Message "Auto-order flow failed: $_" -Feature $FeatureName + return $false + } +} diff --git a/internal/runner.ps1 b/internal/runner.ps1 index efabf1b..366bcba 100644 --- a/internal/runner.ps1 +++ b/internal/runner.ps1 @@ -11,6 +11,7 @@ $script:InternalRoot = $PSScriptRoot # runner.ps1 lives in internal\ . (Join-Path $InternalRoot 'lib\NetworkUtils.ps1') . (Join-Path $InternalRoot 'lib\ToastHelper.ps1') . (Join-Path $InternalRoot 'lib\PromptHelper.ps1') +. (Join-Path $InternalRoot 'lib\SandwichAutoOrder.ps1') . (Join-Path $InternalRoot 'lib\Config.ps1') Write-Log -Level Info -Message '────── Runner started ──────' -Feature 'Runner'