From 0c8932ffd46e0c284ac81d97bbe96438c34d0add Mon Sep 17 00:00:00 2001 From: Andrew Ensley Date: Mon, 29 Jun 2026 19:33:59 -0500 Subject: [PATCH 01/10] feat: :sparkles: add windows support Fixes #19 --- .github/CONTRIBUTING.md | 21 +- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 29 +- README.md | 85 ++-- package.json | 6 +- src/backdrop.psd1 | 16 + src/backdrop.psm1 | 780 ++++++++++++++++++++++++++++++++++ src/install.ps1 | 60 +++ test/backdrop.tests.ps1 | 499 ++++++++++++++++++++++ 9 files changed, 1468 insertions(+), 30 deletions(-) create mode 100644 src/backdrop.psd1 create mode 100644 src/backdrop.psm1 create mode 100644 src/install.ps1 create mode 100644 test/backdrop.tests.ps1 diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a464579..faeac03 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,5 +1,16 @@ # Contributing +## Implementations + +There are two parallel implementations that must be kept in feature parity: + +| File | Platform | Language | +| ------------------- | -------- | ---------- | +| `src/backdrop.sh` | Linux | Bash | +| `src/backdrop.psm1` | Windows | PowerShell | + +Changes to commands, sources, config keys, or behaviour should be applied to both files. + ## Setup After cloning, install the Node dev dependencies: @@ -23,6 +34,8 @@ sudo apt install shellcheck shfmt brew install shellcheck shfmt ``` +> **Note:** shfmt and ShellCheck only apply to `backdrop.sh`. The PowerShell module (`backdrop.psm1`) is not checked by the pre-commit hook. + ## Commits Commits must follow the [Conventional Commits](https://www.conventionalcommits.org/) spec. The pre-commit hook will guide you through it. You can also run the prompt manually: @@ -41,9 +54,15 @@ npm test Tests live in `test/backdrop.bats` and cover the pure and file-I/O functions in `src/backdrop.sh`; things like config read/write, source validation, image dimension detection, wallpaper option selection, metadata read/write, version comparison, and source rotation. Source resolver functions are tested using a stub `curl` script injected via `PATH`. The `_rotation_index` helper takes a unix timestamp as a parameter rather than calling `date`, so rotation tests run without needing to stub `date`; `get_active_source` integration tests stub `date` via `PATH` in the same way as `curl`. +The PowerShell module (`src/backdrop.psm1`) is tested with [Pester 5](https://pester.dev/). Tests live in `test/backdrop.tests.ps1` and run via `Invoke-Pester test/backdrop.tests.ps1`. The `Get-UnixTimestamp` helper is extracted so rotation tests can mock it without stubbing `[DateTimeOffset]::UtcNow` directly. + ## Image metadata -Each `resolve_()` function emits `META_TITLE:`, `META_DESC:`, and `META_URL:` prefixed lines before the image URLs it returns. `apply_wallpaper` strips these out to get the candidate URL list, then stores the values in the `META_TITLE`/`META_DESC`/`META_URL` globals. After a successful download, `_write_meta` saves those values to a `-.meta` file alongside the `.jpg`. The `status` command reads this file via `_meta_get` to display image title, description, and URL. Old `.meta` files are pruned on the same 14-day schedule as wallpaper images. +**Bash (`backdrop.sh`):** each `resolve_()` function emits `META_TITLE:`, `META_DESC:`, and `META_URL:` prefixed lines before the image URLs it returns. `apply_wallpaper` strips these out to get the candidate URL list, then stores the values in the `META_TITLE`/`META_DESC`/`META_URL` globals. + +**PowerShell (`backdrop.psm1`):** each `Resolve-` function returns a hashtable with `Title`, `Desc`, `Url`, and `ImageUrls` keys directly, avoiding the stdout multiplexing used by the bash version. + +In both implementations, after a successful download the metadata is saved to a `-.meta` file (key = value format) alongside the `.jpg`. The `status` command reads this file to display image title, description, and URL. Old `.meta` files are pruned on the same 14-day schedule as wallpaper images. ## Linting diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8fcc71..34e0f79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: matrix: node-version: [22] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: token: ${{ secrets.GHA_SEMANTIC_RELEASE_TOKEN }} - name: Use Node.js ${{ matrix.node-version }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ace44b7..afd693f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: name: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: @@ -34,3 +34,30 @@ jobs: - name: Test run: npm test + + test-ps: + name: test (powershell) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install PSScriptAnalyzer and Pester + shell: pwsh + run: | + Install-Module -Name PSScriptAnalyzer -Scope CurrentUser -Force -ErrorAction Stop + Install-Module -Name Pester -Scope CurrentUser -Force -MinimumVersion 5.0 -ErrorAction Stop + + - name: Lint + shell: pwsh + run: | + $results = Invoke-ScriptAnalyzer -Path src/backdrop.psm1, src/install.ps1 -Severity @('Error', 'Warning') + if ($results) { + $results | Format-Table RuleName, Severity, Line, Message -AutoSize + exit 1 + } + + - name: Test + shell: pwsh + run: | + $result = Invoke-Pester test/backdrop.tests.ps1 -Output Detailed -PassThru + if ($result.FailedCount -gt 0) { exit 1 } diff --git a/README.md b/README.md index b540fbe..8e9b322 100644 --- a/README.md +++ b/README.md @@ -14,23 +14,40 @@ Set a new desktop wallpaper every day from various sources. backdrop fetches a daily image from one of several curated sources and sets it as your desktop wallpaper. It automatically picks the best display mode based on the image dimensions and your screen aspect ratio. -Run it manually or let a systemd timer handle it on a schedule. +Run it manually or let a background timer handle it on a schedule. ## Quick Install +### Linux + ```bash curl -fsSL https://ensl.ee/backdrop | bash ``` +### Windows + +```powershell +iex (iwr 'https://ensl.ee/backdrop-w' -UseBasicParsing).Content +``` + ## Requirements -- A supported Linux desktop environment (see below) +### Linux + +- A supported desktop environment (see [Desktop Environments](#desktop-environments) below) - `curl`, `python3` (standard on most distros) - systemd (for the daily timer) +### Windows + +- Windows 10 or later +- PowerShell 5.1 or later (built-in on Windows 10+) + ## Desktop Environments -backdrop supports the following Desktop Environments. +**Windows** uses `SystemParametersInfo` (user32.dll) to set the wallpaper, with fill mode stored in the registry under `HKCU:\Control Panel\Desktop`. No additional tools required. + +**Linux** supports the following desktop environments: | Desktop | Method | Notes | | --------------------- | ----------------------------- | ------------------------------------------------------------------------------- | @@ -46,7 +63,9 @@ backdrop supports the following Desktop Environments. ## Installation -Use the [quick install script](#quick-install) or clone the repo and run the installer locally: +Use the [quick install script](#quick-install) or clone the repo and run the installer locally. + +### Linux ```bash git clone https://github.com/aensley/backdrop.git \ @@ -59,15 +78,24 @@ The installer: 2. Copies `backdrop` to `/usr/local/bin/` 3. Runs `backdrop enable`, which downloads and installs the systemd unit files and starts the daily timer -### Additional users +**Additional users:** with `backdrop` already installed system-wide, additional users can enable it for their own login with `backdrop enable`. This downloads the matching systemd unit files from GitHub and installs them into `~/.config/systemd/user/`. -With `backdrop` already installed, additional users can enable it for their login with: +### Windows -```bash -backdrop enable +Clone the repo and run the installer from PowerShell: + +```powershell +git clone https://github.com/aensley/backdrop.git +cd backdrop/src +.\install.ps1 ``` -This will download the matching systemd unit files from the matching GitHub release and install them into their own `~/.config/systemd/user/` directory. +The installer: + +1. Copies `backdrop.psm1` and `backdrop.psd1` to the per-user PowerShell modules directory +2. Runs `backdrop enable`, which registers the scheduled task and applies the wallpaper immediately + +The module is auto-imported in every new PowerShell session; no `$PROFILE` changes needed. ## Usage @@ -75,19 +103,19 @@ This will download the matching systemd unit files from the matching GitHub rele backdrop ``` -| Command | Description | -| ------------------------------- | ---------------------------------------------------------------------------- | -| `status` | Show version, active source, last image, and image metadata (default) | -| `update [--force]` | Refresh wallpaper from the active source | -| `set [--force]` | Switch active source(s) and refresh now; use `all` for all sources | -| `set-time ` | Set the daily run time (24-hour); restarts timer if active | -| `set-rotate-interval ` | Set rotation interval in minutes; 0 to disable | -| `random [--force]` | Refresh from a randomly chosen source (does not change active) | -| `enable` | Enable the systemd --user timer; downloads unit files from GitHub if missing | -| `disable` | Disable the systemd --user timer | -| `upgrade` | Check for and install the latest version from GitHub | -| `uninstall` | Remove backdrop from this system | -| `help` | Show help | +| Command | Description | +| ------------------------------- | ---------------------------------------------------------------------------------- | +| `status` | Show version, active source, last image, and image metadata (default) | +| `update [--force]` | Refresh wallpaper from the active source | +| `set [--force]` | Switch active source(s) and refresh now; use `all` for all sources | +| `set-time ` | Set the daily run time (24-hour); restarts timer if active | +| `set-rotate-interval ` | Set rotation interval in minutes; 0 to disable | +| `random [--force]` | Refresh from a randomly chosen source (does not change active) | +| `enable` | Enable the background timer (Linux: systemd --user timer; Windows: Task Scheduler) | +| `disable` | Disable the background timer | +| `upgrade` | Check for and install the latest version from GitHub | +| `uninstall` | Remove backdrop from this system | +| `help` | Show help | ## Sources @@ -147,7 +175,12 @@ When rotation is active, the systemd timer fires at the rotation interval instea ## Configuration -The config file lives at `~/.config/backdrop/config` and is created on first run. You can edit it directly or use the `set` / `set-time` commands. +The config file is created on first run. You can edit it directly or use the `set` / `set-time` commands. + +| Platform | Config file | Cached images | +| -------- | --------------------------- | -------------------------- | +| Linux | `~/.config/backdrop/config` | `~/.local/share/backdrop/` | +| Windows | `%APPDATA%\backdrop\config` | `%LOCALAPPDATA%\backdrop\` | | Key | Default | Description | | --------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------- | @@ -160,12 +193,14 @@ The config file lives at `~/.config/backdrop/config` and is created on first run ## Uninstallation -```bash +``` backdrop uninstall ``` To also remove your config and cached wallpapers: -```bash +``` backdrop uninstall --purge ``` + +On Linux this removes the script from `/usr/local/bin` and disables the systemd timer. On Windows it unregisters the scheduled task and removes the PowerShell module. diff --git a/package.json b/package.json index 9666a4a..6dca877 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ [ "@semantic-release/exec", { - "prepareCmd": "sed -i 's/^VERSION=.*/VERSION=\"${nextRelease.version}\"/' src/backdrop.sh" + "prepareCmd": "sed -i 's/^VERSION=.*/VERSION=\"${nextRelease.version}\"/' src/backdrop.sh && sed -i 's/^\\$script:Version[[:space:]]*=.*/\\$script:Version = '\"'\"'${nextRelease.version}'\"'\"'/' src/backdrop.psm1 && sed -i 's/ModuleVersion[[:space:]]*=.*/ModuleVersion = '\"'\"'${nextRelease.version}'\"'\"'/' src/backdrop.psd1" } ], [ @@ -70,7 +70,9 @@ { "assets": [ "package.json", - "src/backdrop.sh" + "src/backdrop.sh", + "src/backdrop.psm1", + "src/backdrop.psd1" ] } ], diff --git a/src/backdrop.psd1 b/src/backdrop.psd1 new file mode 100644 index 0000000..19d26de --- /dev/null +++ b/src/backdrop.psd1 @@ -0,0 +1,16 @@ +@{ + ModuleVersion = '1.6.1' + GUID = '3D6C8A5B-1F2E-4A0D-9B7C-E6F5D4C3B2A1' + Author = 'Andrew Ensley' + Description = 'Set a new desktop wallpaper every day from various sources (apod, bing, earth, eo, iotd, natgeo, wmc)' + PowerShellVersion = '5.1' + RootModule = 'backdrop.psm1' + FunctionsToExport = @('backdrop') + PrivateData = @{ + PSData = @{ + Tags = @('wallpaper', 'desktop', 'background', 'Windows') + ProjectUri = 'https://github.com/aensley/backdrop' + LicenseUri = 'https://github.com/aensley/backdrop/blob/main/LICENSE' + } + } +} diff --git a/src/backdrop.psm1 b/src/backdrop.psm1 new file mode 100644 index 0000000..db655be --- /dev/null +++ b/src/backdrop.psm1 @@ -0,0 +1,780 @@ +# backdrop.psm1 - set a new desktop wallpaper every day from various sources +# Sources: apod bing earth eo iotd natgeo wmc + +#region Module state + +$script:Version = '1.6.1' +$script:StateDir = Join-Path $env:LOCALAPPDATA 'backdrop' +$script:ConfigDir = Join-Path $env:APPDATA 'backdrop' +$script:ConfigFile = Join-Path $script:ConfigDir 'config' +$script:ValidSources = @('apod', 'bing', 'earth', 'iotd', 'natgeo', 'eo', 'wmc') +$script:TaskName = 'backdrop' + +# Built-in defaults (overridden by config file) +$script:Source = 'iotd' +$script:RotateInterval = 0 +$script:ScreenAspectRatio = 1.7778 +$script:ZoomMinCoverage = 0.55 +$script:UserAgent = "backdrop/1.6 (personal daily wallpaper script)" +$script:TimerTime = '08:00' + +$null = New-Item -ItemType Directory -Force -Path $script:StateDir, $script:ConfigDir + +#endregion + +#region Helpers + +function Invoke-BackdropRequest { + param([string]$Uri, [int]$TimeoutSec = 30) + $ProgressPreference = 'SilentlyContinue' + (Invoke-WebRequest -Uri $Uri -UseBasicParsing -UserAgent $script:UserAgent ` + -TimeoutSec $TimeoutSec -ErrorAction Stop).Content +} + +function Save-BackdropFile { + param([string]$Uri, [string]$OutFile, [int]$TimeoutSec = 120) + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $Uri -UseBasicParsing -UserAgent $script:UserAgent ` + -OutFile $OutFile -TimeoutSec $TimeoutSec -ErrorAction Stop | Out-Null +} + +function Strip-Html { + param([string]$s) + if (-not $s) { return '' } + $s = $s -replace '<[^>]+>', '' + try { + Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue + $s = [System.Web.HttpUtility]::HtmlDecode($s) + } catch { } + ($s -replace '\s+', ' ').Trim() +} + +#endregion + +#region Config + +function Get-BackdropConfigValue { + param([string]$Key) + if (-not (Test-Path $script:ConfigFile)) { return $null } + $content = Get-Content $script:ConfigFile -Raw -ErrorAction SilentlyContinue + if (-not $content) { return $null } + if ($content -match "(?m)^\s*$([regex]::Escape($Key))\s*=\s*(.+?)\s*$") { + $v = $Matches[1] + if ($v -match '^"(.+)"$' -or $v -match "^'(.+)'$") { return $Matches[1] } + return $v + } + return $null +} + +function Set-BackdropConfigValue { + param([string]$Key, [string]$Value) + Initialize-BackdropConfig + $content = Get-Content $script:ConfigFile -Raw -ErrorAction SilentlyContinue + if (-not $content) { $content = '' } + if ($content -match "(?m)^\s*$([regex]::Escape($Key))\s*=") { + $content = $content -replace "(?m)^(\s*$([regex]::Escape($Key))\s*=\s*).*", "`${1}$Value" + } else { + $content = $content.TrimEnd() + "`n$Key = $Value`n" + } + Set-Content -Path $script:ConfigFile -Value $content -NoNewline +} + +function Initialize-BackdropConfig { + if (Test-Path $script:ConfigFile) { return } + Set-Content -Path $script:ConfigFile -Value @" +# backdrop configuration (key = value; lines starting with # are ignored) + +# Active wallpaper source(s): iotd | apod | bing | wmc | eo | earth | natgeo +# Single source: source = iotd +# Multiple sources: source = iotd apod bing +# All sources: source = all +# Also settable with: backdrop set [source...] +source = $($script:Source) + +# How often to rotate between sources, in minutes (0 = disabled). +# Only applies when multiple sources are configured. +# Also settable with: backdrop set-rotate-interval +rotate_interval = $($script:RotateInterval) + +# Screen aspect ratio used only if auto-detection fails. +# 16:9 = 1.7778 16:10 = 1.6 21:9 = 2.3333 4:3 = 1.3333 +screen_aspect_ratio = $($script:ScreenAspectRatio) + +# Crop tolerance for choosing zoom vs scaled. +zoom_min_coverage = $($script:ZoomMinCoverage) + +# HTTP User-Agent string sent with all requests. +# user_agent = backdrop/1.6 (personal daily wallpaper script) + +# Time of day to run the daily wallpaper update (HH:MM, 24-hour format). +# Also settable with: backdrop set-time HH:MM +timer_time = $($script:TimerTime) +"@ +} + +function Import-BackdropConfig { + Initialize-BackdropConfig + $v = Get-BackdropConfigValue 'screen_aspect_ratio' + if ($v) { $script:ScreenAspectRatio = [double]$v } + $v = Get-BackdropConfigValue 'zoom_min_coverage' + if ($v) { $script:ZoomMinCoverage = [double]$v } + $v = Get-BackdropConfigValue 'user_agent' + if ($v) { $script:UserAgent = $v } + $v = Get-BackdropConfigValue 'timer_time' + if ($v) { $script:TimerTime = $v } + $v = Get-BackdropConfigValue 'rotate_interval' + if ($v -match '^\d+$') { $script:RotateInterval = [int]$v } +} + +#endregion + +#region Source resolvers +# Each returns a hashtable: Title, Desc, Url (source page), ImageUrls (array, best first). +# Returns $null when the source has no image today (e.g. APOD video day). +# Throws on network failure. + +function Resolve-Iotd { + $feed = Invoke-BackdropRequest 'https://www.nasa.gov/feeds/iotd-feed/' + [xml]$xml = $feed + $item = $xml.rss.channel.item | Select-Object -First 1 + $imageUrl = $item.enclosure.url + if (-not $imageUrl) { return $null } + @{ + Title = Strip-Html $item.title + Desc = Strip-Html $item.description + Url = if ($item.link) { $item.link } else { 'https://www.nasa.gov/image-of-the-day/' } + ImageUrls = @($imageUrl) + } +} + +function Resolve-Apod { + $page = Invoke-BackdropRequest 'https://apod.nasa.gov/apod/astropix.html' + $rel = $null + if ($page -match '(?i)href="(image/[^"]+\.(jpg|jpeg|png|gif))"') { $rel = $Matches[1] } + if (-not $rel) { return $null } + $title = '' + $m = [regex]::Match($page, '(?i)
\s*([^<]+)\s*\s*Explanation:\s*(.*?)(?=

|)') + if ($m.Success) { + $desc = ($m.Groups[1].Value -replace '<[^>]+>', '') -replace '\s+', ' ' + $desc = $desc.Trim() + if ($desc.Length -gt 400) { $desc = $desc.Substring(0, 400) } + } + @{ + Title = $title + Desc = $desc + Url = 'https://apod.nasa.gov/apod/astropix.html' + ImageUrls = @("https://apod.nasa.gov/apod/$rel") + } +} + +function Resolve-Bing { + $json = Invoke-BackdropRequest 'https://www.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=en-US' + $img = ($json | ConvertFrom-Json).images[0] + $urls = @() + if ($img.urlbase) { $urls += "https://www.bing.com$($img.urlbase)_UHD.jpg" } + if ($img.url) { $urls += "https://www.bing.com$($img.url)" } + if (-not $urls) { return $null } + @{ + Title = $img.title + Desc = $img.copyright + Url = 'https://www.bing.com/' + ImageUrls = $urls + } +} + +function Resolve-Wmc { + $date = Get-Date -Format 'yyyy-MM-dd' + $resp = Invoke-BackdropRequest "https://commons.wikimedia.org/w/api.php?action=expandtemplates&format=json&prop=wikitext&text=%7B%7BPotd/$date%7D%7D" + $file = ($resp | ConvertFrom-Json).expandtemplates.wikitext + if (-not $file) { return $null } + $title = ($file -replace '^File:', '') -replace '\.[^.]+$', '' -replace '_', ' ' + $desc = '' + try { + $dresp = Invoke-BackdropRequest "https://commons.wikimedia.org/w/api.php?action=expandtemplates&format=json&prop=wikitext&text=%7B%7BPotd/$($date)%20(en)%7D%7D" + $rawDesc = ($dresp | ConvertFrom-Json).expandtemplates.wikitext + $rawDesc = $rawDesc -replace '\[\[(?:[^|\]]*\|)?([^\]]*)\]\]', '$1' -replace '\{\{[^}]*\}\}', '' + $desc = ($rawDesc -replace '\s+', ' ').Trim() + if ($desc.Length -gt 300) { $desc = $desc.Substring(0, 300) } + } catch { } + $enc = [Uri]::EscapeDataString($file) + $iresp = Invoke-BackdropRequest "https://commons.wikimedia.org/w/api.php?action=query&format=json&prop=imageinfo&iiprop=url&iiurlwidth=3840&titles=File:$enc" + $info = (($iresp | ConvertFrom-Json).query.pages.PSObject.Properties | Select-Object -First 1).Value.imageinfo[0] + $urls = @() + if ($info.thumburl) { $urls += $info.thumburl } + if ($info.url) { $urls += $info.url } + if (-not $urls) { return $null } + @{ + Title = Strip-Html $title + Desc = Strip-Html $desc + Url = "https://commons.wikimedia.org/wiki/File:$([Uri]::EscapeDataString(($file -replace '^File:', '')))" + ImageUrls = $urls + } +} + +function Resolve-Eo { + $feed = Invoke-BackdropRequest 'https://earthobservatory.nasa.gov/feeds/image-of-the-day.rss' + $m = [regex]::Match($feed, '(?is)(.*?)') + if (-not $m.Success) { return $null } + $item = $m.Groups[1].Value + $imgUrl = $null + if ($item -match '(?i)(https://assets\.science\.nasa\.gov/dynamicimage/[^"?]+\.(jpg|jpeg|png))') { + $imgUrl = $Matches[1] + } + if (-not $imgUrl) { return $null } + $title = '' + if ($item -match '(?i)]*>') { $title = $Matches[1] } + elseif ($item -match '(?i)]*>([^<]+)') { $title = $Matches[1] } + $link = '' + if ($item -match '(?i)([^<]+)') { $link = $Matches[1] } + elseif ($item -match '(?i)(https://earthobservatory\.nasa\.gov/images/\d+[^"< ]*)') { $link = $Matches[1] } + @{ + Title = Strip-Html $title + Desc = '' + Url = if ($link) { $link.Trim() } else { 'https://earthobservatory.nasa.gov/' } + ImageUrls = @("${imgUrl}?w=3840", $imgUrl) + } +} + +function Resolve-Natgeo { + $page = Invoke-BackdropRequest 'https://www.nationalgeographic.com/photo-of-the-day/' + $url = $null + if ($page -match '(?i)property="og:image"\s+content="(https://i\.natgeofe\.com/[^"]+)"') { $url = $Matches[1] } + if (-not $url) { return $null } + $ogTitle = '' + $ogDesc = '' + $ogUrl = '' + if ($page -match '(?i)property="og:title"\s+content="([^"]+)"') { $ogTitle = $Matches[1] -replace ' \|.*', '' } + if ($page -match '(?i)property="og:description"\s+content="([^"]+)"') { $ogDesc = $Matches[1] } + if ($page -match '(?i)property="og:url"\s+content="([^"]+)"') { $ogUrl = $Matches[1] } + @{ + Title = Strip-Html $ogTitle + Desc = Strip-Html $ogDesc + Url = if ($ogUrl) { $ogUrl } else { 'https://www.nationalgeographic.com/photo-of-the-day/' } + ImageUrls = @("${url}?w=5120", $url) + } +} + +function Resolve-Earth { + $page = Invoke-BackdropRequest 'https://www.earth.com/gallery/images-of-the-day/' + $articleUrl = $null + if ($page -match '(?i)href="(https://www\.earth\.com/image/[^"]+)"') { $articleUrl = $Matches[1] } + if (-not $articleUrl) { return $null } + $article = Invoke-BackdropRequest $articleUrl + $url = $null + if ($article -match '(?i)(https://cff2\.earth\.com/uploads/[^"]+\.(jpg|jpeg|png))') { $url = $Matches[1] } + if (-not $url) { return $null } + $ogTitle = '' + $ogDesc = '' + $ogUrl = '' + if ($article -match '(?i)property="og:title"\s+content="([^"]+)"') { $ogTitle = $Matches[1] } + if ($article -match '(?i)property="og:description"\s+content="([^"]+)"') { $ogDesc = $Matches[1] } + if ($article -match '(?i)property="og:url"\s+content="([^"]+)"') { $ogUrl = $Matches[1] } + @{ + Title = Strip-Html $ogTitle + Desc = Strip-Html $ogDesc + Url = if ($ogUrl) { $ogUrl } else { $articleUrl } + ImageUrls = @($url) + } +} + +#endregion + +#region Geometry + +function Get-ImageDimensions { + param([string]$Path) + try { + Add-Type -AssemblyName System.Drawing -ErrorAction Stop + $img = [System.Drawing.Image]::FromFile($Path) + $dims = @{ Width = $img.Width; Height = $img.Height } + $img.Dispose() + return $dims + } catch { + return $null + } +} + +function Get-ScreenAspectRatio { + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop + $s = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + if ($s.Height -gt 0) { return [double]$s.Width / $s.Height } + } catch { } + return [double]$script:ScreenAspectRatio +} + +function Get-PictureOption { + param([string]$Path) + $dims = Get-ImageDimensions $Path + if (-not $dims -or $dims.Width -le 0 -or $dims.Height -le 0) { return 'zoom' } + $iar = [double]$dims.Width / $dims.Height + $sar = Get-ScreenAspectRatio + $cov = if ($iar -lt $sar) { $iar / $sar } else { $sar / $iar } + if ($cov -ge $script:ZoomMinCoverage) { 'zoom' } else { 'scaled' } +} + +#endregion + +#region Wallpaper + +if (-not ([System.Management.Automation.PSTypeName]'BackdropWallpaper').Type) { + Add-Type @' +using System.Runtime.InteropServices; +public class BackdropWallpaper { + [DllImport("user32.dll", CharSet = CharSet.Auto)] + public static extern int SystemParametersInfo(int uAction, int uParam, string lpvParam, int fuWinIni); +} +'@ +} + +function Set-Wallpaper { + param([string]$Path, [string]$Option) + # WallpaperStyle: 10=Fill (zoom/crop), 6=Fit (scaled/letterbox) + $style = if ($Option -eq 'zoom') { '10' } else { '6' } + Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name WallpaperStyle -Value $style + Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name TileWallpaper -Value '0' + [BackdropWallpaper]::SystemParametersInfo(20, 0, $Path, 3) | Out-Null +} + +#endregion + +#region Scheduling + +function Invoke-TimerConfig { + $action = New-ScheduledTaskAction ` + -Execute 'powershell.exe' ` + -Argument "-NonInteractive -WindowStyle Hidden -Command `"Import-Module backdrop; backdrop update`"" + + if ($script:RotateInterval -gt 0) { + $timeTrigger = New-ScheduledTaskTrigger -Daily -At '00:00' + $timeTrigger.Repetition.Interval = "PT$($script:RotateInterval)M" + $timeTrigger.Repetition.Duration = 'P1D' + } else { + $timeTrigger = New-ScheduledTaskTrigger -Daily -At $script:TimerTime + } + + $logon = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME + $logon.Delay = 'PT2M' + + $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited + $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 10) ` + -StartWhenAvailable -MultipleInstances IgnoreNew + + $existing = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + if ($existing) { + Set-ScheduledTask -TaskName $script:TaskName -Action $action ` + -Trigger @($timeTrigger, $logon) -Principal $principal -Settings $settings | Out-Null + } else { + Register-ScheduledTask -TaskName $script:TaskName -Action $action ` + -Trigger @($timeTrigger, $logon) -Principal $principal -Settings $settings | Out-Null + } +} + +#endregion + +#region Source and meta helpers + +function Get-BackdropSources { + $s = Get-BackdropConfigValue 'source' + if ($s) { + if ($s -eq 'all') { return $script:ValidSources } + return @($s -split '\s+') + } + return @($script:Source) +} + +function Get-UnixTimestamp { [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } + +function Get-ActiveSource { + $srcs = @(Get-BackdropSources) + if ($srcs.Count -le 1 -or $script:RotateInterval -le 0) { + return if ($srcs.Count -gt 0) { $srcs[0] } else { $script:Source } + } + $idx = [int]((Get-UnixTimestamp) / 60 / $script:RotateInterval) % $srcs.Count + return $srcs[$idx] +} + +function Test-ValidSource { + param([string]$s) + $script:ValidSources -contains $s +} + +function Write-MetaFile { + param([string]$Dest, [hashtable]$Meta) + $metaPath = [System.IO.Path]::ChangeExtension($Dest, 'meta') + $lines = @() + if ($Meta.Title) { $lines += "title = $($Meta.Title)" } + if ($Meta.Desc) { + $d = $Meta.Desc + if ($d.Length -gt 200) { $d = $d.Substring(0, 200) } + $lines += "desc = $d" + } + if ($Meta.Url) { $lines += "url = $($Meta.Url)" } + Set-Content -Path $metaPath -Value $lines +} + +function Get-MetaValue { + param([string]$MetaFile, [string]$Key) + if (-not (Test-Path $MetaFile)) { return $null } + $content = Get-Content $MetaFile -Raw -ErrorAction SilentlyContinue + if ($content -match "(?m)^\s*$([regex]::Escape($Key))\s*=\s*(.+?)\s*$") { return $Matches[1] } + return $null +} + +#endregion + +#region Core + +function Invoke-ApplyWallpaper { + param([string]$Src, [bool]$Force = $false) + if (-not (Test-ValidSource $Src)) { + throw "backdrop: unknown source '$Src' (valid: $($script:ValidSources -join ', '))" + } + + $dest = Join-Path $script:StateDir "$Src-$(Get-Date -Format 'yyyy-MM-dd').jpg" + + if ((Test-Path $dest) -and -not $Force) { + $opt = Get-PictureOption $dest + Set-Wallpaper $dest $opt + Set-Content -Path (Join-Path $script:StateDir 'current') -Value $dest + $dims = Get-ImageDimensions $dest + $dimsStr = if ($dims) { "$($dims.Width)x$($dims.Height)" } else { 'unknown' } + Write-Host "backdrop: set from $Src [$dimsStr, $opt] -> $dest (cached)" + return + } + + $result = switch ($Src) { + 'iotd' { Resolve-Iotd } + 'apod' { Resolve-Apod } + 'bing' { Resolve-Bing } + 'wmc' { Resolve-Wmc } + 'eo' { Resolve-Eo } + 'natgeo' { Resolve-Natgeo } + 'earth' { Resolve-Earth } + } + + if ($null -eq $result) { + Write-Host "backdrop: $Src has no image today (e.g. APOD video day); wallpaper unchanged." + return + } + + $ok = $false + foreach ($url in $result.ImageUrls) { + try { Save-BackdropFile $url $dest; $ok = $true; break } catch { } + } + if (-not $ok) { throw "backdrop: could not download any image for $Src" } + + $opt = Get-PictureOption $dest + Set-Wallpaper $dest $opt + Set-Content -Path (Join-Path $script:StateDir 'current') -Value $dest + Write-MetaFile $dest $result + + Get-ChildItem $script:StateDir -File | + Where-Object { $_.Extension -in @('.jpg', '.meta') -and $_.LastWriteTime -lt (Get-Date).AddDays(-14) } | + Remove-Item -Force -ErrorAction SilentlyContinue + + $dims = Get-ImageDimensions $dest + $dimsStr = if ($dims) { "$($dims.Width)x$($dims.Height)" } else { 'unknown' } + Write-Host "backdrop: set from $Src [$dimsStr, $opt] -> $dest" +} + +#endregion + +#region Commands + +function Invoke-BackdropUpdate { + param([switch]$Force) + Invoke-ApplyWallpaper (Get-ActiveSource) $Force.IsPresent +} + +function Invoke-BackdropRandom { + param([switch]$Force) + Invoke-ApplyWallpaper ($script:ValidSources | Get-Random) $Force.IsPresent +} + +function Invoke-BackdropEnable { + Invoke-TimerConfig + $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + if ($task -and $task.State -eq 'Disabled') { + Enable-ScheduledTask -TaskName $script:TaskName | Out-Null + } + if ($script:RotateInterval -gt 0) { + Write-Host "backdrop: timer enabled (rotating every $($script:RotateInterval) min)." + } else { + Write-Host "backdrop: daily timer enabled (runs at $($script:TimerTime))." + } + Invoke-BackdropUpdate +} + +function Invoke-BackdropDisable { + Disable-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue | Out-Null + Write-Host "backdrop: daily timer disabled." +} + +function Invoke-BackdropSetTime { + param([string]$Time) + if ($Time -notmatch '^([01]\d|2[0-3]):[0-5]\d$') { + throw "backdrop: set-time: expected HH:MM (24-hour), e.g. 08:00" + } + $script:TimerTime = $Time + Set-BackdropConfigValue 'timer_time' $Time + $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + if ($task) { + Invoke-TimerConfig + Write-Host "backdrop: timer time set to $Time and task updated." + } else { + Write-Host "backdrop: timer time set to $Time (run 'backdrop enable' to start the timer)." + } +} + +function Invoke-BackdropSetRotateInterval { + param([string]$Minutes) + if ($Minutes -notmatch '^\d+$') { + throw "backdrop: set-rotate-interval: expected number of minutes (0 to disable), e.g. 60" + } + $script:RotateInterval = [int]$Minutes + Set-BackdropConfigValue 'rotate_interval' $Minutes + $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + if ($task) { + Invoke-TimerConfig + if ([int]$Minutes -gt 0) { + Write-Host "backdrop: rotate interval set to $Minutes min and task updated." + } else { + Write-Host "backdrop: rotation disabled, task reset to daily at $($script:TimerTime)." + } + } else { + if ([int]$Minutes -gt 0) { + Write-Host "backdrop: rotate interval set to $Minutes min (run 'backdrop enable' to start the timer)." + } else { + Write-Host "backdrop: rotation disabled (run 'backdrop enable' to start the daily timer)." + } + } +} + +function Invoke-BackdropSet { + param([string[]]$Sources, [bool]$Force = $false) + if (-not $Sources -or $Sources.Count -eq 0) { + throw "backdrop: set: choose one or more sources ($($script:ValidSources -join ', ')) or 'all'" + } + if ($Sources.Count -eq 1 -and $Sources[0] -eq 'all') { $Sources = $script:ValidSources } + foreach ($s in $Sources) { + if (-not (Test-ValidSource $s)) { + throw "backdrop: set: unknown source '$s' (valid: $($script:ValidSources -join ', '))" + } + } + Set-BackdropConfigValue 'source' ($Sources -join ' ') + $timerChanged = $false + if ($Sources.Count -gt 1) { + if ($script:RotateInterval -le 0) { + $script:RotateInterval = 30 + Set-BackdropConfigValue 'rotate_interval' '30' + $timerChanged = $true + } + Write-Host "backdrop: active sources: $($Sources -join ' ') (rotating every $($script:RotateInterval) min)" + } else { + if ($script:RotateInterval -gt 0) { + $script:RotateInterval = 0 + Set-BackdropConfigValue 'rotate_interval' '0' + $timerChanged = $true + } + Write-Host "backdrop: active source is now '$($Sources[0])'" + } + if ($timerChanged) { + $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + if ($task) { Invoke-TimerConfig } + } + Invoke-ApplyWallpaper (Get-ActiveSource) $Force +} + +function Invoke-BackdropStatus { + Write-Host "backdrop v$($script:Version)" + Write-Host '' + + $activeSrcs = @(Get-BackdropSources) + $activeSrc = Get-ActiveSource + $latest = $null + + $currentFile = Join-Path $script:StateDir 'current' + if (Test-Path $currentFile) { + $p = (Get-Content $currentFile -Raw).Trim() + if (Test-Path $p) { $latest = $p } + } + if (-not $latest) { + $latest = Get-ChildItem $script:StateDir -Filter "$activeSrc-*.jpg" -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName + } + + $displayedSrc = $activeSrc + if ($latest) { + $candidate = [System.IO.Path]::GetFileNameWithoutExtension($latest) -replace '-\d{4}-\d{2}-\d{2}$', '' + if (Test-ValidSource $candidate) { $displayedSrc = $candidate } + } + + if ($activeSrcs.Count -gt 1) { + $labeled = ($activeSrcs | ForEach-Object { if ($_ -eq $displayedSrc) { "[$_]" } else { $_ } }) -join ' ' + Write-Host "Active sources: $labeled" + } else { + Write-Host "Active source: $activeSrc" + } + + $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + if ($task -and $task.State -ne 'Disabled') { + if ($script:RotateInterval -gt 0) { + Write-Host "Timer: enabled (rotating every $($script:RotateInterval) min)" + } else { + Write-Host "Timer: enabled (runs at $($script:TimerTime))" + } + } else { + Write-Host "Timer: disabled" + } + + Write-Host '' + if ($latest) { + Write-Host "Current image: $latest" + $metaFile = [System.IO.Path]::ChangeExtension($latest, 'meta') + $mv = Get-MetaValue $metaFile 'title' + if ($mv) { + if ($mv.Length -gt 77) { $mv = $mv.Substring(0, 77) + '...' } + Write-Host "Title: $mv" + } + $mv = Get-MetaValue $metaFile 'desc' + if ($mv) { + if ($mv.Length -gt 77) { $mv = $mv.Substring(0, 77) + '...' } + Write-Host "Description: $mv" + } + $mv = Get-MetaValue $metaFile 'url' + if ($mv) { Write-Host "URL: $mv" } + } + + $styleNum = (Get-ItemProperty 'HKCU:\Control Panel\Desktop' -ErrorAction SilentlyContinue).WallpaperStyle + $styleStr = switch ($styleNum) { '10' { 'zoom' } '6' { 'scaled' } default { "style=$styleNum" } } + Write-Host '' + Write-Host "Display method: windows, $styleStr" + Write-Host "Aspect ratio: $(Get-ScreenAspectRatio), $($script:ZoomMinCoverage) min coverage" + Write-Host "Config file: $($script:ConfigFile)" + Write-Host '' + Write-Host "Use 'backdrop help' for usage information." + Write-Host '' +} + +function Invoke-BackdropUpgrade { + Write-Host "backdrop: checking for updates (current: v$($script:Version))..." + $apiResp = Invoke-BackdropRequest 'https://api.github.com/repos/aensley/backdrop/releases/latest' -TimeoutSec 15 + $release = $apiResp | ConvertFrom-Json + $latestTag = $release.tag_name + $latestVer = $latestTag.TrimStart('v') + if ([version]$latestVer -le [version]$script:Version) { + Write-Host "backdrop: already up to date (v$($script:Version))." + return + } + Write-Host "backdrop: upgrading v$($script:Version) -> v$latestVer..." + $rawUrl = "https://raw.githubusercontent.com/aensley/backdrop/$latestTag/src/backdrop.psm1" + $modPath = (Get-Module backdrop -ErrorAction SilentlyContinue).Path + if (-not $modPath) { throw 'backdrop: upgrade: could not locate installed module path' } + $tmp = [System.IO.Path]::GetTempFileName() + try { + Save-BackdropFile $rawUrl $tmp -TimeoutSec 60 + Copy-Item $tmp $modPath -Force + Write-Host "backdrop: upgraded to v$latestVer. Restart PowerShell to use the new version." + } finally { + Remove-Item $tmp -Force -ErrorAction SilentlyContinue + } +} + +function Invoke-BackdropUninstall { + param([switch]$Purge) + Disable-ScheduledTask -TaskName $script:TaskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + Unregister-ScheduledTask -TaskName $script:TaskName -Confirm:$false -ErrorAction SilentlyContinue + $modDir = Split-Path -Parent (Get-Module backdrop -ErrorAction SilentlyContinue).Path + if ($modDir -and (Test-Path $modDir)) { Remove-Item $modDir -Recurse -Force } + if ($Purge) { + Remove-Item $script:ConfigDir -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item $script:StateDir -Recurse -Force -ErrorAction SilentlyContinue + Write-Host 'backdrop: uninstalled. Config and cached wallpapers removed.' + } else { + Write-Host 'backdrop: uninstalled.' + Write-Host "Note: config and cached wallpapers were not removed. Run 'backdrop uninstall --purge' to delete them." + } +} + +function Invoke-BackdropHelp { + Write-Host @" +backdrop v$($script:Version) + +Usage: backdrop + +Commands: + status Show the active source and last image (default command) + update [--force] Refresh wallpaper from the active source + set [--force] Switch active source(s) and refresh now; use 'all' for all sources + set-time Set the daily run time (24-hour); updates task if active + set-rotate-interval Set rotation interval in minutes; 0 to disable rotation + random [--force] Refresh from a randomly chosen source (does not change active source) + enable Enable the scheduled task + disable Disable the scheduled task + upgrade Check for and install the latest version from GitHub + uninstall [--purge] Remove backdrop and (with --purge) delete config and cached wallpapers + help Show this help + +Sources: + bing Bing image of the day + earth Earth.com Image of the Day + apod NASA Astronomy Picture of the Day + eo NASA Earth Observatory Image of the Day + iotd NASA Image of the Day (default) + natgeo National Geographic Photo of the Day + wmc Wikimedia Commons Picture of the Day +"@ +} + +#endregion + +#region Dispatch + +function backdrop { + <# + .SYNOPSIS + Set a new desktop wallpaper every day from various sources. + .DESCRIPTION + Downloads the image of the day from a configurable source and sets it as the desktop wallpaper. + Run 'backdrop help' for available commands and sources. + #> + param( + [Parameter(Position = 0)] + [string]$Command = 'status', + [Parameter(ValueFromRemainingArguments)] + [string[]]$Rest + ) + + if ($Command -ne 'uninstall') { Import-BackdropConfig } + + $force = $Rest -contains '--force' + $args = @($Rest | Where-Object { $_ -ne '--force' }) + + try { + switch ($Command) { + { $_ -in 'update', 'refresh' } { Invoke-BackdropUpdate -Force:$force } + { $_ -in 'set', 'use' } { Invoke-BackdropSet -Sources $args -Force $force } + 'status' { Invoke-BackdropStatus } + 'random' { Invoke-BackdropRandom -Force:$force } + 'enable' { Invoke-BackdropEnable } + 'disable' { Invoke-BackdropDisable } + 'set-time' { Invoke-BackdropSetTime ($args | Select-Object -First 1) } + 'set-rotate-interval' { Invoke-BackdropSetRotateInterval ($args | Select-Object -First 1) } + 'upgrade' { Invoke-BackdropUpgrade } + 'uninstall' { Invoke-BackdropUninstall -Purge:($Rest -contains '--purge') } + { $_ -in '-h', '--help', 'help' } { Invoke-BackdropHelp } + default { throw "backdrop: unknown command '$Command' (try: backdrop help)" } + } + } catch { + Write-Host $_.Exception.Message -ForegroundColor Red + exit 1 + } +} + +Export-ModuleMember -Function 'backdrop' + +#endregion diff --git a/src/install.ps1 b/src/install.ps1 new file mode 100644 index 0000000..fb98902 --- /dev/null +++ b/src/install.ps1 @@ -0,0 +1,60 @@ +#Requires -Version 5.1 +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$REPO = 'aensley/backdrop' + +# Resolve install source: local repo checkout or remote download. +$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { $null } +$repoRaw = $null + +if (-not $scriptDir) { + Write-Host 'Fetching latest release info from GitHub...' + $apiResp = (Invoke-WebRequest -Uri "https://api.github.com/repos/$REPO/releases/latest" ` + -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop).Content + $latestTag = ($apiResp | ConvertFrom-Json).tag_name + $repoRaw = "https://raw.githubusercontent.com/$REPO/$latestTag/src" + Write-Host "Installing backdrop $latestTag..." +} else { + Write-Host 'Installing backdrop...' +} + +# Determine the per-user PowerShell modules directory for the running PS version. +$modulesRoot = if ($PSVersionTable.PSVersion.Major -ge 6) { + Join-Path $env:USERPROFILE 'Documents\PowerShell\Modules' +} else { + Join-Path $env:USERPROFILE 'Documents\WindowsPowerShell\Modules' +} +$destDir = Join-Path $modulesRoot 'backdrop' +$null = New-Item -ItemType Directory -Force -Path $destDir + +$tmp = $null +if ($repoRaw) { + # Remote install: download both files to a temp directory first. + $tmp = Join-Path $env:TEMP "backdrop-install-$(Get-Random)" + $null = New-Item -ItemType Directory -Force -Path $tmp + foreach ($file in 'backdrop.psm1', 'backdrop.psd1') { + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri "$repoRaw/$file" -UseBasicParsing -OutFile (Join-Path $tmp $file) ` + -TimeoutSec 30 -ErrorAction Stop + } + $srcDir = $tmp +} else { + $srcDir = $scriptDir +} + +try { + Copy-Item (Join-Path $srcDir 'backdrop.psm1') $destDir -Force + Copy-Item (Join-Path $srcDir 'backdrop.psd1') $destDir -Force +} finally { + if ($tmp -and (Test-Path $tmp)) { Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue } +} + +Write-Host "backdrop: installed to $destDir" + +# Load the module and enable the scheduled task. +Import-Module (Join-Path $destDir 'backdrop.psm1') -Force +backdrop enable + +Write-Host 'Done.' diff --git a/test/backdrop.tests.ps1 b/test/backdrop.tests.ps1 new file mode 100644 index 0000000..c6eddbe --- /dev/null +++ b/test/backdrop.tests.ps1 @@ -0,0 +1,499 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } + +BeforeAll { + # Ensure APPDATA/LOCALAPPDATA are set. They don't exist on Linux but the module reads them at load time. + if (-not $env:APPDATA) { $env:APPDATA = [System.IO.Path]::GetTempPath() } + if (-not $env:LOCALAPPDATA) { $env:LOCALAPPDATA = [System.IO.Path]::GetTempPath() } + Import-Module "$PSScriptRoot/../src/backdrop.psm1" -Force +} + +# All tests run inside the module scope so private functions are directly callable. +InModuleScope backdrop { + + BeforeEach { + # Redirect config/state to an isolated per-test directory. + $script:ConfigDir = Join-Path $TestDrive 'config' + $script:ConfigFile = Join-Path $TestDrive 'config' 'config' + $script:StateDir = Join-Path $TestDrive 'state' + $script:Source = 'iotd' + $script:RotateInterval = 0 + $script:ScreenAspectRatio = 1.7778 + $script:ZoomMinCoverage = 0.55 + $script:TimerTime = '08:00' + $script:UserAgent = 'backdrop/1.6 (personal daily wallpaper script)' + New-Item -ItemType Directory -Force -Path $script:ConfigDir, $script:StateDir | Out-Null + } + + # ------------------------------------------------------------------------- + # Test-ValidSource + # ------------------------------------------------------------------------- + + Describe 'Test-ValidSource' { + It 'accepts built-in source: <_>' -ForEach @('iotd', 'apod', 'bing', 'wmc', 'eo', 'earth', 'natgeo') { + Test-ValidSource $_ | Should -BeTrue + } + It 'rejects an unknown source' { + Test-ValidSource 'unknown' | Should -BeFalse + } + It 'rejects an empty string' { + Test-ValidSource '' | Should -BeFalse + } + } + + # ------------------------------------------------------------------------- + # Get-BackdropConfigValue + # ------------------------------------------------------------------------- + + Describe 'Get-BackdropConfigValue' { + It 'returns null when config does not exist' { + Get-BackdropConfigValue 'source' | Should -BeNullOrEmpty + } + It 'reads a simple key = value' { + Set-Content $script:ConfigFile 'source = bing' + Get-BackdropConfigValue 'source' | Should -Be 'bing' + } + It 'strips surrounding double quotes' { + Set-Content $script:ConfigFile 'user_agent = "my agent"' + Get-BackdropConfigValue 'user_agent' | Should -Be 'my agent' + } + It 'strips surrounding single quotes' { + Set-Content $script:ConfigFile "user_agent = 'my agent'" + Get-BackdropConfigValue 'user_agent' | Should -Be 'my agent' + } + It 'ignores comment lines' { + Set-Content $script:ConfigFile "# source = apod`nsource = wmc" + Get-BackdropConfigValue 'source' | Should -Be 'wmc' + } + } + + # ------------------------------------------------------------------------- + # Initialize-BackdropConfig / Set-BackdropConfigValue + # ------------------------------------------------------------------------- + + Describe 'Initialize-BackdropConfig / Set-BackdropConfigValue' { + It 'creates the config file with built-in defaults' { + Initialize-BackdropConfig + $script:ConfigFile | Should -Exist + Get-BackdropConfigValue 'source' | Should -Be 'iotd' + Get-BackdropConfigValue 'screen_aspect_ratio' | Should -Be '1.7778' + } + It 'writes a new key' { + Initialize-BackdropConfig + Set-BackdropConfigValue 'source' 'apod' + Get-BackdropConfigValue 'source' | Should -Be 'apod' + } + It 'overwrites an existing key' { + Initialize-BackdropConfig + Set-BackdropConfigValue 'source' 'bing' + Set-BackdropConfigValue 'source' 'wmc' + Get-BackdropConfigValue 'source' | Should -Be 'wmc' + } + It 'preserves other keys when updating one' { + Initialize-BackdropConfig + Set-BackdropConfigValue 'source' 'eo' + Get-BackdropConfigValue 'screen_aspect_ratio' | Should -Be '1.7778' + } + } + + # ------------------------------------------------------------------------- + # Import-BackdropConfig + # ------------------------------------------------------------------------- + + Describe 'Import-BackdropConfig' { + It 'reads config values into module globals' { + Set-Content $script:ConfigFile @" +screen_aspect_ratio = 2.3333 +zoom_min_coverage = 0.75 +timer_time = 10:30 +rotate_interval = 15 +user_agent = test-agent/1.0 +"@ + Import-BackdropConfig + $script:ScreenAspectRatio | Should -Be 2.3333 + $script:ZoomMinCoverage | Should -Be 0.75 + $script:TimerTime | Should -Be '10:30' + $script:RotateInterval | Should -Be 15 + $script:UserAgent | Should -Be 'test-agent/1.0' + } + It 'leaves globals at defaults when config is missing' { + Import-BackdropConfig + $script:ScreenAspectRatio | Should -Be 1.7778 + $script:ZoomMinCoverage | Should -Be 0.55 + $script:TimerTime | Should -Be '08:00' + $script:RotateInterval | Should -Be 0 + } + } + + # ------------------------------------------------------------------------- + # Get-ScreenAspectRatio + # ------------------------------------------------------------------------- + + Describe 'Get-ScreenAspectRatio' { + It 'returns a positive numeric aspect ratio' { + $ar = Get-ScreenAspectRatio + $ar | Should -BeOfType [double] + $ar | Should -BeGreaterThan 0 + } + } + + # ------------------------------------------------------------------------- + # Get-PictureOption + # ------------------------------------------------------------------------- + + Describe 'Get-PictureOption' { + It 'returns zoom for a wide image on a 16:9 screen' { + Mock Get-ImageDimensions { @{ Width = 1920; Height = 1080 } } + Mock Get-ScreenAspectRatio { 1.7778 } + Get-PictureOption 'dummy.jpg' | Should -Be 'zoom' + } + It 'returns scaled for a tall image on a 16:9 screen' { + Mock Get-ImageDimensions { @{ Width = 1080; Height = 1920 } } + Mock Get-ScreenAspectRatio { 1.7778 } + Get-PictureOption 'dummy.jpg' | Should -Be 'scaled' + } + It 'returns zoom for a square image (coverage 0.5625 >= threshold 0.55)' { + Mock Get-ImageDimensions { @{ Width = 1000; Height = 1000 } } + Mock Get-ScreenAspectRatio { 1.7778 } + $script:ZoomMinCoverage = 0.55 + Get-PictureOption 'dummy.jpg' | Should -Be 'zoom' + } + It 'returns zoom when image dimensions cannot be read' { + Mock Get-ImageDimensions { $null } + Get-PictureOption 'dummy.jpg' | Should -Be 'zoom' + } + } + + # ------------------------------------------------------------------------- + # Strip-Html + # ------------------------------------------------------------------------- + + Describe 'Strip-Html' { + It 'removes HTML tags' { + Strip-Html 'Hello World' | Should -Be 'Hello World' + } + It 'decodes common HTML entities' { + Strip-Html '& < > " '' | Should -Be '& < > " ''' + } + It 'decodes numeric entities with leading zeros' { + Strip-Html "San Francisco's Streets" | Should -Be "San Francisco's Streets" + } + It 'collapses whitespace' { + Strip-Html ' foo bar ' | Should -Be 'foo bar' + } + } + + # ------------------------------------------------------------------------- + # Write-MetaFile / Get-MetaValue + # ------------------------------------------------------------------------- + + Describe 'Write-MetaFile / Get-MetaValue' { + It 'reads a key from a meta file' { + $f = Join-Path $script:StateDir 'test.meta' + Set-Content $f "title = My Image`ndesc = Some desc`nurl = https://example.com/" + Get-MetaValue $f 'title' | Should -Be 'My Image' + } + It 'returns null for a missing file' { + Get-MetaValue (Join-Path $script:StateDir 'nonexistent.meta') 'title' | Should -BeNullOrEmpty + } + It 'returns null for a missing key' { + $f = Join-Path $script:StateDir 'test.meta' + Set-Content $f 'url = https://example.com/' + Get-MetaValue $f 'title' | Should -BeNullOrEmpty + } + It 'writes non-empty fields to a .meta file' { + $dest = Join-Path $script:StateDir 'src-2025-01-01.jpg' + $null = New-Item $dest -ItemType File -Force + Write-MetaFile $dest @{ Title = 'Test Image'; Desc = 'A test description'; Url = 'https://example.com/' } + $meta = [System.IO.Path]::ChangeExtension($dest, 'meta') + Get-MetaValue $meta 'title' | Should -Be 'Test Image' + Get-MetaValue $meta 'desc' | Should -Be 'A test description' + Get-MetaValue $meta 'url' | Should -Be 'https://example.com/' + } + It 'omits empty fields from the .meta file' { + $dest = Join-Path $script:StateDir 'src-2025-01-01.jpg' + $null = New-Item $dest -ItemType File -Force + Write-MetaFile $dest @{ Title = ''; Desc = ''; Url = 'https://example.com/' } + $meta = [System.IO.Path]::ChangeExtension($dest, 'meta') + Get-MetaValue $meta 'title' | Should -BeNullOrEmpty + Get-MetaValue $meta 'url' | Should -Be 'https://example.com/' + } + } + + # ------------------------------------------------------------------------- + # Get-BackdropSources + # ------------------------------------------------------------------------- + + Describe 'Get-BackdropSources' { + It 'returns default source when config does not exist' { + Get-BackdropSources | Should -Be @('iotd') + } + It 'returns a single configured source' { + Set-Content $script:ConfigFile 'source = bing' + Get-BackdropSources | Should -Be @('bing') + } + It 'returns a list for multiple configured sources' { + Set-Content $script:ConfigFile 'source = iotd apod bing' + Get-BackdropSources | Should -Be @('iotd', 'apod', 'bing') + } + It "expands 'all' to every valid source" { + Set-Content $script:ConfigFile 'source = all' + Get-BackdropSources | Should -Be $script:ValidSources + } + } + + # ------------------------------------------------------------------------- + # Get-ActiveSource + # ------------------------------------------------------------------------- + + Describe 'Get-ActiveSource' { + It 'returns the single configured source' { + Set-Content $script:ConfigFile 'source = apod' + $script:RotateInterval = 0 + Get-ActiveSource | Should -Be 'apod' + } + It 'returns the first source when rotation is disabled' { + Set-Content $script:ConfigFile 'source = iotd apod bing' + $script:RotateInterval = 0 + Get-ActiveSource | Should -Be 'iotd' + } + It 'returns index 0 at epoch (timestamp=0)' { + Set-Content $script:ConfigFile 'source = iotd apod bing' + $script:RotateInterval = 60 + Mock Get-UnixTimestamp { 0 } + Get-ActiveSource | Should -Be 'iotd' + } + It 'advances to the next source after one full interval' { + # 60 minutes elapsed, interval=60min, 3 sources -> slot 1 -> apod + Set-Content $script:ConfigFile 'source = iotd apod bing' + $script:RotateInterval = 60 + Mock Get-UnixTimestamp { 3600 } + Get-ActiveSource | Should -Be 'apod' + } + It 'wraps around after all sources are used' { + # 180 minutes elapsed, interval=60min, 3 sources -> slot 3 % 3 = 0 -> iotd + Set-Content $script:ConfigFile 'source = iotd apod bing' + $script:RotateInterval = 60 + Mock Get-UnixTimestamp { 10800 } + Get-ActiveSource | Should -Be 'iotd' + } + It 'floors partial intervals' { + # 90 minutes elapsed -> floor(90/60)=1 -> apod + Set-Content $script:ConfigFile 'source = iotd apod bing' + $script:RotateInterval = 60 + Mock Get-UnixTimestamp { 5400 } + Get-ActiveSource | Should -Be 'apod' + } + } + + # ------------------------------------------------------------------------- + # Resolve-Iotd + # ------------------------------------------------------------------------- + + Describe 'Resolve-Iotd' { + It 'returns image URL and metadata from RSS feed' { + Mock Invoke-BackdropRequest { + @' + + <![CDATA[Test IOTD Image]]> + A test NASA image. + https://www.nasa.gov/image-of-the-day/test/ + + +'@ + } + $r = Resolve-Iotd + $r.ImageUrls | Should -Be @('https://www.nasa.gov/wp-content/uploads/2025/01/test.jpg') + $r.Title | Should -Be 'Test IOTD Image' + $r.Url | Should -Be 'https://www.nasa.gov/image-of-the-day/test/' + } + It 'returns null when no enclosure URL is present' { + Mock Invoke-BackdropRequest { + '<![CDATA[No Image Today]]>' + } + Resolve-Iotd | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Iotd } | Should -Throw + } + } + + # ------------------------------------------------------------------------- + # Resolve-Apod + # ------------------------------------------------------------------------- + + Describe 'Resolve-Apod' { + It 'returns image URL and metadata from HTML page' { + Mock Invoke-BackdropRequest { + @' + +

Starry Night +image +Explanation: A wonderful view of stars. + +'@ + } + $r = Resolve-Apod + $r.ImageUrls | Should -Be @('https://apod.nasa.gov/apod/image/2025/starry_night.jpg') + $r.Title | Should -Be 'Starry Night' + } + It 'returns null when no image link is found' { + Mock Invoke-BackdropRequest { 'No image today.' } + Resolve-Apod | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Apod } | Should -Throw + } + } + + # ------------------------------------------------------------------------- + # Resolve-Bing + # ------------------------------------------------------------------------- + + Describe 'Resolve-Bing' { + It 'returns 4K and fallback URLs with metadata' { + Mock Invoke-BackdropRequest { + '{"images":[{"urlbase":"/th/id/OHR.TestImage","url":"/th/id/OHR.TestImage_1920x1080.jpg","title":"Test Bing Image","copyright":"Test copyright 2025"}]}' + } + $r = Resolve-Bing + $r.ImageUrls[0] | Should -Be 'https://www.bing.com/th/id/OHR.TestImage_UHD.jpg' + $r.ImageUrls[1] | Should -Be 'https://www.bing.com/th/id/OHR.TestImage_1920x1080.jpg' + $r.Title | Should -Be 'Test Bing Image' + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Bing } | Should -Throw + } + } + + # ------------------------------------------------------------------------- + # Resolve-Eo + # ------------------------------------------------------------------------- + + Describe 'Resolve-Eo' { + It 'returns 4K and base URLs with metadata from RSS feed' { + Mock Invoke-BackdropRequest { + @' + + <![CDATA[Earth at Night]]> + https://earthobservatory.nasa.gov/images/12345/earth-at-night +

https://assets.science.nasa.gov/dynamicimage/eo/2025/01/photo.jpg

+
+'@ + } + $r = Resolve-Eo + $r.ImageUrls[0] | Should -Be 'https://assets.science.nasa.gov/dynamicimage/eo/2025/01/photo.jpg?w=3840' + $r.ImageUrls[1] | Should -Be 'https://assets.science.nasa.gov/dynamicimage/eo/2025/01/photo.jpg' + $r.Title | Should -Be 'Earth at Night' + } + It 'returns null when no asset URL found' { + Mock Invoke-BackdropRequest { + '<![CDATA[No Image]]>' + } + Resolve-Eo | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Eo } | Should -Throw + } + } + + # ------------------------------------------------------------------------- + # Resolve-Natgeo + # ------------------------------------------------------------------------- + + Describe 'Resolve-Natgeo' { + It 'returns high-res and base URLs from og:image' { + Mock Invoke-BackdropRequest { + '' + } + $r = Resolve-Natgeo + $r.ImageUrls[0] | Should -Be 'https://i.natgeofe.com/n/abc123/photo.jpg?w=5120' + $r.ImageUrls[1] | Should -Be 'https://i.natgeofe.com/n/abc123/photo.jpg' + } + It 'strips the site suffix from og:title' { + Mock Invoke-BackdropRequest { + @' + + +'@ + } + (Resolve-Natgeo).Title | Should -Be 'Forever in Motion' + } + It 'returns null when no natgeofe og:image is found' { + Mock Invoke-BackdropRequest { + '' + } + Resolve-Natgeo | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Natgeo } | Should -Throw + } + } + + # ------------------------------------------------------------------------- + # Resolve-Earth + # ------------------------------------------------------------------------- + + Describe 'Resolve-Earth' { + It 'returns URL and metadata from the article page' { + Mock Invoke-BackdropRequest { + param($Uri) + if ($Uri -match 'gallery') { + return '' + } + @' + + + +"https://cff2.earth.com/uploads/2025/10/01/photo.jpg" +'@ + } + $r = Resolve-Earth + $r.ImageUrls | Should -Be @('https://cff2.earth.com/uploads/2025/10/01/photo.jpg') + $r.Title | Should -Be 'Test Title' + } + It 'returns null when no article link is found' { + Mock Invoke-BackdropRequest { 'no image here' } + Resolve-Earth | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Earth } | Should -Throw + } + } + + # ------------------------------------------------------------------------- + # Resolve-Wmc + # ------------------------------------------------------------------------- + + Describe 'Resolve-Wmc' { + It 'returns thumbnail and base URLs with metadata' { + Mock Invoke-BackdropRequest { + param($Uri) + if ($Uri -match 'imageinfo') { + return '{"query":{"pages":{"-1":{"imageinfo":[{"thumburl":"https://upload.wikimedia.org/thumb/photo.jpg","url":"https://upload.wikimedia.org/photo.jpg"}]}}}}' + } + if ($Uri -match '\(en\)') { + return '{"expandtemplates":{"wikitext":"A beautiful photograph of the day."}}' + } + return '{"expandtemplates":{"wikitext":"File:Test Photo.jpg"}}' + } + $r = Resolve-Wmc + $r.ImageUrls[0] | Should -Be 'https://upload.wikimedia.org/thumb/photo.jpg' + $r.ImageUrls[1] | Should -Be 'https://upload.wikimedia.org/photo.jpg' + $r.Title | Should -Be 'Test Photo' + } + It 'returns null when no file is found' { + Mock Invoke-BackdropRequest { '{"expandtemplates":{"wikitext":""}}' } + Resolve-Wmc | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Wmc } | Should -Throw + } + } + +} From 93e5828252ec6ab7a05d2cd13fe8319bb84f2b8d Mon Sep 17 00:00:00 2001 From: Andrew Ensley Date: Mon, 29 Jun 2026 19:37:31 -0500 Subject: [PATCH 02/10] build(deps-dev): :package: update dependencies --- package-lock.json | 126 +++++++++++++++++++++++++--------------------- package.json | 4 +- 2 files changed, 70 insertions(+), 60 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5976c2a..4927df8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "backdrop", - "version": "1.6.1", + "version": "1.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "backdrop", - "version": "1.6.1", + "version": "1.6.2", "license": "MIT", "devDependencies": { "@semantic-release/commit-analyzer": "^13.0.1", @@ -16,9 +16,9 @@ "@semantic-release/npm": "^13.1.5", "@semantic-release/release-notes-generator": "^14.1.1", "bats": "^1.13.0", - "conventional-changelog-conventionalcommits": "^9.3.1", + "conventional-changelog-conventionalcommits": "^10.2.0", "czg": "^1.13.1", - "prettier": "^3.8.4", + "prettier": "^3.9.3", "semantic-release": "^25.0.5", "simple-git-hooks": "^2.13.1" } @@ -108,6 +108,16 @@ "node": ">=0.1.90" } }, + "node_modules/@conventional-changelog/template": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.2.0.tgz", + "integrity": "sha512-12qHxvlKjHmP0PQ+17EREgC7lWyLwbph1RKcZQZ7k7ZWGmrxfxC9gadHGfvzr0g0u8BhiBGg3tks93txodlyRQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, "node_modules/@octokit/auth-token": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", @@ -225,9 +235,9 @@ } }, "node_modules/@octokit/request": { - "version": "10.0.10", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.10.tgz", - "integrity": "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w==", + "version": "10.0.11", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.11.tgz", + "integrity": "sha512-+s7HUxjfFqOMS9VlIwDffq0MikjSAK0gSpG73W+meAvVAvX4MBrHYTK5Bj3Uot55qFT4gzUtfzE4mGWY4Br8/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -977,9 +987,9 @@ } }, "node_modules/cli-highlight/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", "dev": true, "license": "MIT", "dependencies": { @@ -1147,16 +1157,16 @@ } }, "node_modules/conventional-changelog-conventionalcommits": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz", - "integrity": "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.2.0.tgz", + "integrity": "sha512-UtlM9GqolY7OmlQh5L/UEVoKsTUpTgUVy1PU8JN5gl5Ydaejb7WRklGliG1SKPxxj7hzA173eG3Kt5fYWE2pmg==", "dev": true, "license": "ISC", "dependencies": { - "compare-func": "^2.0.0" + "@conventional-changelog/template": "^1.2.0" }, "engines": { - "node": ">=18" + "node": ">=22" } }, "node_modules/conventional-changelog-writer": { @@ -1709,9 +1719,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", "dev": true, "license": "MIT", "dependencies": { @@ -2120,9 +2130,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -2487,9 +2497,9 @@ } }, "node_modules/npm": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.17.0.tgz", - "integrity": "sha512-PurxiZexEHDTE4SSaLI3ZrnbAGiZfeyUcQcxcP5D+hfytNAze/D1IzDuInTn9XVLIbAQUnQuSPXJx02LHjLvQw==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.18.0.tgz", + "integrity": "sha512-T67M4L5wNm0cZ7EBLErcEkY1SmzEW/WJ+SADBzsFUY1UdAPfFHXFQtZ6SEXiK0+vzXysCvAsepbMaBTwnrAD+w==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -2568,8 +2578,8 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.8.0", - "@npmcli/config": "^10.11.0", + "@npmcli/arborist": "^9.9.0", + "@npmcli/config": "^10.12.0", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", @@ -2593,11 +2603,11 @@ "is-cidr": "^6.0.4", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.1.10", - "libnpmexec": "^10.3.0", - "libnpmfund": "^7.0.24", + "libnpmdiff": "^8.1.11", + "libnpmexec": "^10.3.1", + "libnpmfund": "^7.0.25", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.1.10", + "libnpmpack": "^9.1.11", "libnpmpublish": "^11.2.0", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", @@ -2613,7 +2623,7 @@ "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.2", "npm-pick-manifest": "^11.0.3", - "npm-profile": "^12.0.1", + "npm-profile": "^12.0.2", "npm-registry-fetch": "^19.1.1", "npm-user-validate": "^4.0.0", "p-map": "^7.0.4", @@ -2622,11 +2632,11 @@ "proc-log": "^6.1.0", "qrcode-terminal": "^0.12.0", "read": "^5.0.1", - "semver": "^7.8.4", + "semver": "^7.8.5", "spdx-expression-parse": "^4.0.0", "ssri": "^13.0.1", "supports-color": "^10.2.2", - "tar": "^7.5.16", + "tar": "^7.5.19", "text-table": "~0.2.0", "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", @@ -2715,7 +2725,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.8.0", + "version": "9.9.0", "dev": true, "inBundle": true, "license": "ISC", @@ -2763,7 +2773,7 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.11.0", + "version": "10.12.0", "dev": true, "inBundle": true, "license": "ISC", @@ -3108,7 +3118,7 @@ } }, "node_modules/npm/node_modules/brace-expansion": { - "version": "5.0.6", + "version": "5.0.7", "dev": true, "inBundle": true, "license": "MIT", @@ -3482,12 +3492,12 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.1.10", + "version": "8.1.11", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.8.0", + "@npmcli/arborist": "^9.9.0", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -3501,13 +3511,13 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.3.0", + "version": "10.3.1", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", - "@npmcli/arborist": "^9.8.0", + "@npmcli/arborist": "^9.9.0", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -3524,12 +3534,12 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.24", + "version": "7.0.25", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.8.0" + "@npmcli/arborist": "^9.9.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -3549,12 +3559,12 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.1.10", + "version": "9.1.11", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.8.0", + "@npmcli/arborist": "^9.9.0", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -3923,13 +3933,13 @@ } }, "node_modules/npm/node_modules/npm-profile": { - "version": "12.0.1", + "version": "12.0.2", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0" + "proc-log": "^6.1.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -4134,7 +4144,7 @@ "optional": true }, "node_modules/npm/node_modules/semver": { - "version": "7.8.4", + "version": "7.8.5", "dev": true, "inBundle": true, "license": "ISC", @@ -4259,7 +4269,7 @@ } }, "node_modules/npm/node_modules/tar": { - "version": "7.5.16", + "version": "7.5.19", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -4355,7 +4365,7 @@ } }, "node_modules/npm/node_modules/undici": { - "version": "6.26.0", + "version": "6.27.0", "dev": true, "inBundle": true, "license": "MIT", @@ -4521,9 +4531,9 @@ } }, "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz", + "integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==", "dev": true, "license": "MIT", "engines": { @@ -4709,9 +4719,9 @@ } }, "node_modules/prettier": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", - "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.3.tgz", + "integrity": "sha512-HWmu+K+zvHNpaMfSnYeqdqrDbR16cuIXaPx8WoHaviQkDJh1/0BNtOZmHVQI5jc3wXv0H1yXc9wjvFdXh+n3hQ==", "dev": true, "license": "MIT", "bin": { @@ -5129,9 +5139,9 @@ } }, "node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { diff --git a/package.json b/package.json index 6dca877..99c31a9 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,9 @@ "@semantic-release/npm": "^13.1.5", "@semantic-release/release-notes-generator": "^14.1.1", "bats": "^1.13.0", - "conventional-changelog-conventionalcommits": "^9.3.1", + "conventional-changelog-conventionalcommits": "^10.2.0", "czg": "^1.13.1", - "prettier": "^3.8.4", + "prettier": "^3.9.3", "semantic-release": "^25.0.5", "simple-git-hooks": "^2.13.1" }, From 3c903362a77003538890f125ce036d82c8d94b3c Mon Sep 17 00:00:00 2001 From: Andrew Ensley Date: Mon, 29 Jun 2026 19:39:28 -0500 Subject: [PATCH 03/10] style: :lipstick: format changes --- src/backdrop.psd1 | 26 +- src/backdrop.psm1 | 1083 +++++++++++++++++++++++---------------------- 2 files changed, 564 insertions(+), 545 deletions(-) diff --git a/src/backdrop.psd1 b/src/backdrop.psd1 index 19d26de..56bb2e6 100644 --- a/src/backdrop.psd1 +++ b/src/backdrop.psd1 @@ -1,16 +1,16 @@ @{ - ModuleVersion = '1.6.1' - GUID = '3D6C8A5B-1F2E-4A0D-9B7C-E6F5D4C3B2A1' - Author = 'Andrew Ensley' - Description = 'Set a new desktop wallpaper every day from various sources (apod, bing, earth, eo, iotd, natgeo, wmc)' - PowerShellVersion = '5.1' - RootModule = 'backdrop.psm1' - FunctionsToExport = @('backdrop') - PrivateData = @{ - PSData = @{ - Tags = @('wallpaper', 'desktop', 'background', 'Windows') - ProjectUri = 'https://github.com/aensley/backdrop' - LicenseUri = 'https://github.com/aensley/backdrop/blob/main/LICENSE' - } + ModuleVersion = '1.6.1' + GUID = '3D6C8A5B-1F2E-4A0D-9B7C-E6F5D4C3B2A1' + Author = 'Andrew Ensley' + Description = 'Set a new desktop wallpaper every day from various sources (apod, bing, earth, eo, iotd, natgeo, wmc)' + PowerShellVersion = '5.1' + RootModule = 'backdrop.psm1' + FunctionsToExport = @('backdrop') + PrivateData = @{ + PSData = @{ + Tags = @('wallpaper', 'desktop', 'background', 'Windows') + ProjectUri = 'https://github.com/aensley/backdrop' + LicenseUri = 'https://github.com/aensley/backdrop/blob/main/LICENSE' } + } } diff --git a/src/backdrop.psm1 b/src/backdrop.psm1 index db655be..ec482fe 100644 --- a/src/backdrop.psm1 +++ b/src/backdrop.psm1 @@ -3,20 +3,20 @@ #region Module state -$script:Version = '1.6.1' -$script:StateDir = Join-Path $env:LOCALAPPDATA 'backdrop' -$script:ConfigDir = Join-Path $env:APPDATA 'backdrop' -$script:ConfigFile = Join-Path $script:ConfigDir 'config' -$script:ValidSources = @('apod', 'bing', 'earth', 'iotd', 'natgeo', 'eo', 'wmc') -$script:TaskName = 'backdrop' +$script:Version = '1.6.1' +$script:StateDir = Join-Path $env:LOCALAPPDATA 'backdrop' +$script:ConfigDir = Join-Path $env:APPDATA 'backdrop' +$script:ConfigFile = Join-Path $script:ConfigDir 'config' +$script:ValidSources = @('apod', 'bing', 'earth', 'iotd', 'natgeo', 'eo', 'wmc') +$script:TaskName = 'backdrop' # Built-in defaults (overridden by config file) -$script:Source = 'iotd' -$script:RotateInterval = 0 +$script:Source = 'iotd' +$script:RotateInterval = 0 $script:ScreenAspectRatio = 1.7778 -$script:ZoomMinCoverage = 0.55 -$script:UserAgent = "backdrop/1.6 (personal daily wallpaper script)" -$script:TimerTime = '08:00' +$script:ZoomMinCoverage = 0.55 +$script:UserAgent = "backdrop/1.6 (personal daily wallpaper script)" +$script:TimerTime = '08:00' $null = New-Item -ItemType Directory -Force -Path $script:StateDir, $script:ConfigDir @@ -25,28 +25,29 @@ $null = New-Item -ItemType Directory -Force -Path $script:StateDir, $script:Conf #region Helpers function Invoke-BackdropRequest { - param([string]$Uri, [int]$TimeoutSec = 30) - $ProgressPreference = 'SilentlyContinue' - (Invoke-WebRequest -Uri $Uri -UseBasicParsing -UserAgent $script:UserAgent ` - -TimeoutSec $TimeoutSec -ErrorAction Stop).Content + param([string]$Uri, [int]$TimeoutSec = 30) + $ProgressPreference = 'SilentlyContinue' + (Invoke-WebRequest -Uri $Uri -UseBasicParsing -UserAgent $script:UserAgent ` + -TimeoutSec $TimeoutSec -ErrorAction Stop).Content } function Save-BackdropFile { - param([string]$Uri, [string]$OutFile, [int]$TimeoutSec = 120) - $ProgressPreference = 'SilentlyContinue' - Invoke-WebRequest -Uri $Uri -UseBasicParsing -UserAgent $script:UserAgent ` - -OutFile $OutFile -TimeoutSec $TimeoutSec -ErrorAction Stop | Out-Null + param([string]$Uri, [string]$OutFile, [int]$TimeoutSec = 120) + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $Uri -UseBasicParsing -UserAgent $script:UserAgent ` + -OutFile $OutFile -TimeoutSec $TimeoutSec -ErrorAction Stop | Out-Null } function Strip-Html { - param([string]$s) - if (-not $s) { return '' } - $s = $s -replace '<[^>]+>', '' - try { - Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue - $s = [System.Web.HttpUtility]::HtmlDecode($s) - } catch { } - ($s -replace '\s+', ' ').Trim() + param([string]$s) + if (-not $s) { return '' } + $s = $s -replace '<[^>]+>', '' + try { + Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue + $s = [System.Web.HttpUtility]::HtmlDecode($s) + } + catch { } + ($s -replace '\s+', ' ').Trim() } #endregion @@ -54,34 +55,35 @@ function Strip-Html { #region Config function Get-BackdropConfigValue { - param([string]$Key) - if (-not (Test-Path $script:ConfigFile)) { return $null } - $content = Get-Content $script:ConfigFile -Raw -ErrorAction SilentlyContinue - if (-not $content) { return $null } - if ($content -match "(?m)^\s*$([regex]::Escape($Key))\s*=\s*(.+?)\s*$") { - $v = $Matches[1] - if ($v -match '^"(.+)"$' -or $v -match "^'(.+)'$") { return $Matches[1] } - return $v - } - return $null + param([string]$Key) + if (-not (Test-Path $script:ConfigFile)) { return $null } + $content = Get-Content $script:ConfigFile -Raw -ErrorAction SilentlyContinue + if (-not $content) { return $null } + if ($content -match "(?m)^\s*$([regex]::Escape($Key))\s*=\s*(.+?)\s*$") { + $v = $Matches[1] + if ($v -match '^"(.+)"$' -or $v -match "^'(.+)'$") { return $Matches[1] } + return $v + } + return $null } function Set-BackdropConfigValue { - param([string]$Key, [string]$Value) - Initialize-BackdropConfig - $content = Get-Content $script:ConfigFile -Raw -ErrorAction SilentlyContinue - if (-not $content) { $content = '' } - if ($content -match "(?m)^\s*$([regex]::Escape($Key))\s*=") { - $content = $content -replace "(?m)^(\s*$([regex]::Escape($Key))\s*=\s*).*", "`${1}$Value" - } else { - $content = $content.TrimEnd() + "`n$Key = $Value`n" - } - Set-Content -Path $script:ConfigFile -Value $content -NoNewline + param([string]$Key, [string]$Value) + Initialize-BackdropConfig + $content = Get-Content $script:ConfigFile -Raw -ErrorAction SilentlyContinue + if (-not $content) { $content = '' } + if ($content -match "(?m)^\s*$([regex]::Escape($Key))\s*=") { + $content = $content -replace "(?m)^(\s*$([regex]::Escape($Key))\s*=\s*).*", "`${1}$Value" + } + else { + $content = $content.TrimEnd() + "`n$Key = $Value`n" + } + Set-Content -Path $script:ConfigFile -Value $content -NoNewline } function Initialize-BackdropConfig { - if (Test-Path $script:ConfigFile) { return } - Set-Content -Path $script:ConfigFile -Value @" + if (Test-Path $script:ConfigFile) { return } + Set-Content -Path $script:ConfigFile -Value @" # backdrop configuration (key = value; lines starting with # are ignored) # Active wallpaper source(s): iotd | apod | bing | wmc | eo | earth | natgeo @@ -113,17 +115,17 @@ timer_time = $($script:TimerTime) } function Import-BackdropConfig { - Initialize-BackdropConfig - $v = Get-BackdropConfigValue 'screen_aspect_ratio' - if ($v) { $script:ScreenAspectRatio = [double]$v } - $v = Get-BackdropConfigValue 'zoom_min_coverage' - if ($v) { $script:ZoomMinCoverage = [double]$v } - $v = Get-BackdropConfigValue 'user_agent' - if ($v) { $script:UserAgent = $v } - $v = Get-BackdropConfigValue 'timer_time' - if ($v) { $script:TimerTime = $v } - $v = Get-BackdropConfigValue 'rotate_interval' - if ($v -match '^\d+$') { $script:RotateInterval = [int]$v } + Initialize-BackdropConfig + $v = Get-BackdropConfigValue 'screen_aspect_ratio' + if ($v) { $script:ScreenAspectRatio = [double]$v } + $v = Get-BackdropConfigValue 'zoom_min_coverage' + if ($v) { $script:ZoomMinCoverage = [double]$v } + $v = Get-BackdropConfigValue 'user_agent' + if ($v) { $script:UserAgent = $v } + $v = Get-BackdropConfigValue 'timer_time' + if ($v) { $script:TimerTime = $v } + $v = Get-BackdropConfigValue 'rotate_interval' + if ($v -match '^\d+$') { $script:RotateInterval = [int]$v } } #endregion @@ -134,150 +136,151 @@ function Import-BackdropConfig { # Throws on network failure. function Resolve-Iotd { - $feed = Invoke-BackdropRequest 'https://www.nasa.gov/feeds/iotd-feed/' - [xml]$xml = $feed - $item = $xml.rss.channel.item | Select-Object -First 1 - $imageUrl = $item.enclosure.url - if (-not $imageUrl) { return $null } - @{ - Title = Strip-Html $item.title - Desc = Strip-Html $item.description - Url = if ($item.link) { $item.link } else { 'https://www.nasa.gov/image-of-the-day/' } - ImageUrls = @($imageUrl) - } + $feed = Invoke-BackdropRequest 'https://www.nasa.gov/feeds/iotd-feed/' + [xml]$xml = $feed + $item = $xml.rss.channel.item | Select-Object -First 1 + $imageUrl = $item.enclosure.url + if (-not $imageUrl) { return $null } + @{ + Title = Strip-Html $item.title + Desc = Strip-Html $item.description + Url = if ($item.link) { $item.link } else { 'https://www.nasa.gov/image-of-the-day/' } + ImageUrls = @($imageUrl) + } } function Resolve-Apod { - $page = Invoke-BackdropRequest 'https://apod.nasa.gov/apod/astropix.html' - $rel = $null - if ($page -match '(?i)href="(image/[^"]+\.(jpg|jpeg|png|gif))"') { $rel = $Matches[1] } - if (-not $rel) { return $null } - $title = '' - $m = [regex]::Match($page, '(?i)
\s*([^<]+)\s*\s*Explanation:\s*(.*?)(?=

|)') - if ($m.Success) { - $desc = ($m.Groups[1].Value -replace '<[^>]+>', '') -replace '\s+', ' ' - $desc = $desc.Trim() - if ($desc.Length -gt 400) { $desc = $desc.Substring(0, 400) } - } - @{ - Title = $title - Desc = $desc - Url = 'https://apod.nasa.gov/apod/astropix.html' - ImageUrls = @("https://apod.nasa.gov/apod/$rel") - } + $page = Invoke-BackdropRequest 'https://apod.nasa.gov/apod/astropix.html' + $rel = $null + if ($page -match '(?i)href="(image/[^"]+\.(jpg|jpeg|png|gif))"') { $rel = $Matches[1] } + if (-not $rel) { return $null } + $title = '' + $m = [regex]::Match($page, '(?i)

\s*([^<]+)\s*\s*Explanation:\s*(.*?)(?=

|)') + if ($m.Success) { + $desc = ($m.Groups[1].Value -replace '<[^>]+>', '') -replace '\s+', ' ' + $desc = $desc.Trim() + if ($desc.Length -gt 400) { $desc = $desc.Substring(0, 400) } + } + @{ + Title = $title + Desc = $desc + Url = 'https://apod.nasa.gov/apod/astropix.html' + ImageUrls = @("https://apod.nasa.gov/apod/$rel") + } } function Resolve-Bing { - $json = Invoke-BackdropRequest 'https://www.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=en-US' - $img = ($json | ConvertFrom-Json).images[0] - $urls = @() - if ($img.urlbase) { $urls += "https://www.bing.com$($img.urlbase)_UHD.jpg" } - if ($img.url) { $urls += "https://www.bing.com$($img.url)" } - if (-not $urls) { return $null } - @{ - Title = $img.title - Desc = $img.copyright - Url = 'https://www.bing.com/' - ImageUrls = $urls - } + $json = Invoke-BackdropRequest 'https://www.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=en-US' + $img = ($json | ConvertFrom-Json).images[0] + $urls = @() + if ($img.urlbase) { $urls += "https://www.bing.com$($img.urlbase)_UHD.jpg" } + if ($img.url) { $urls += "https://www.bing.com$($img.url)" } + if (-not $urls) { return $null } + @{ + Title = $img.title + Desc = $img.copyright + Url = 'https://www.bing.com/' + ImageUrls = $urls + } } function Resolve-Wmc { - $date = Get-Date -Format 'yyyy-MM-dd' - $resp = Invoke-BackdropRequest "https://commons.wikimedia.org/w/api.php?action=expandtemplates&format=json&prop=wikitext&text=%7B%7BPotd/$date%7D%7D" - $file = ($resp | ConvertFrom-Json).expandtemplates.wikitext - if (-not $file) { return $null } - $title = ($file -replace '^File:', '') -replace '\.[^.]+$', '' -replace '_', ' ' - $desc = '' - try { - $dresp = Invoke-BackdropRequest "https://commons.wikimedia.org/w/api.php?action=expandtemplates&format=json&prop=wikitext&text=%7B%7BPotd/$($date)%20(en)%7D%7D" - $rawDesc = ($dresp | ConvertFrom-Json).expandtemplates.wikitext - $rawDesc = $rawDesc -replace '\[\[(?:[^|\]]*\|)?([^\]]*)\]\]', '$1' -replace '\{\{[^}]*\}\}', '' - $desc = ($rawDesc -replace '\s+', ' ').Trim() - if ($desc.Length -gt 300) { $desc = $desc.Substring(0, 300) } - } catch { } - $enc = [Uri]::EscapeDataString($file) - $iresp = Invoke-BackdropRequest "https://commons.wikimedia.org/w/api.php?action=query&format=json&prop=imageinfo&iiprop=url&iiurlwidth=3840&titles=File:$enc" - $info = (($iresp | ConvertFrom-Json).query.pages.PSObject.Properties | Select-Object -First 1).Value.imageinfo[0] - $urls = @() - if ($info.thumburl) { $urls += $info.thumburl } - if ($info.url) { $urls += $info.url } - if (-not $urls) { return $null } - @{ - Title = Strip-Html $title - Desc = Strip-Html $desc - Url = "https://commons.wikimedia.org/wiki/File:$([Uri]::EscapeDataString(($file -replace '^File:', '')))" - ImageUrls = $urls - } + $date = Get-Date -Format 'yyyy-MM-dd' + $resp = Invoke-BackdropRequest "https://commons.wikimedia.org/w/api.php?action=expandtemplates&format=json&prop=wikitext&text=%7B%7BPotd/$date%7D%7D" + $file = ($resp | ConvertFrom-Json).expandtemplates.wikitext + if (-not $file) { return $null } + $title = ($file -replace '^File:', '') -replace '\.[^.]+$', '' -replace '_', ' ' + $desc = '' + try { + $dresp = Invoke-BackdropRequest "https://commons.wikimedia.org/w/api.php?action=expandtemplates&format=json&prop=wikitext&text=%7B%7BPotd/$($date)%20(en)%7D%7D" + $rawDesc = ($dresp | ConvertFrom-Json).expandtemplates.wikitext + $rawDesc = $rawDesc -replace '\[\[(?:[^|\]]*\|)?([^\]]*)\]\]', '$1' -replace '\{\{[^}]*\}\}', '' + $desc = ($rawDesc -replace '\s+', ' ').Trim() + if ($desc.Length -gt 300) { $desc = $desc.Substring(0, 300) } + } + catch { } + $enc = [Uri]::EscapeDataString($file) + $iresp = Invoke-BackdropRequest "https://commons.wikimedia.org/w/api.php?action=query&format=json&prop=imageinfo&iiprop=url&iiurlwidth=3840&titles=File:$enc" + $info = (($iresp | ConvertFrom-Json).query.pages.PSObject.Properties | Select-Object -First 1).Value.imageinfo[0] + $urls = @() + if ($info.thumburl) { $urls += $info.thumburl } + if ($info.url) { $urls += $info.url } + if (-not $urls) { return $null } + @{ + Title = Strip-Html $title + Desc = Strip-Html $desc + Url = "https://commons.wikimedia.org/wiki/File:$([Uri]::EscapeDataString(($file -replace '^File:', '')))" + ImageUrls = $urls + } } function Resolve-Eo { - $feed = Invoke-BackdropRequest 'https://earthobservatory.nasa.gov/feeds/image-of-the-day.rss' - $m = [regex]::Match($feed, '(?is)(.*?)') - if (-not $m.Success) { return $null } - $item = $m.Groups[1].Value - $imgUrl = $null - if ($item -match '(?i)(https://assets\.science\.nasa\.gov/dynamicimage/[^"?]+\.(jpg|jpeg|png))') { - $imgUrl = $Matches[1] - } - if (-not $imgUrl) { return $null } - $title = '' - if ($item -match '(?i)]*>') { $title = $Matches[1] } - elseif ($item -match '(?i)]*>([^<]+)') { $title = $Matches[1] } - $link = '' - if ($item -match '(?i)([^<]+)') { $link = $Matches[1] } - elseif ($item -match '(?i)(https://earthobservatory\.nasa\.gov/images/\d+[^"< ]*)') { $link = $Matches[1] } - @{ - Title = Strip-Html $title - Desc = '' - Url = if ($link) { $link.Trim() } else { 'https://earthobservatory.nasa.gov/' } - ImageUrls = @("${imgUrl}?w=3840", $imgUrl) - } + $feed = Invoke-BackdropRequest 'https://earthobservatory.nasa.gov/feeds/image-of-the-day.rss' + $m = [regex]::Match($feed, '(?is)(.*?)') + if (-not $m.Success) { return $null } + $item = $m.Groups[1].Value + $imgUrl = $null + if ($item -match '(?i)(https://assets\.science\.nasa\.gov/dynamicimage/[^"?]+\.(jpg|jpeg|png))') { + $imgUrl = $Matches[1] + } + if (-not $imgUrl) { return $null } + $title = '' + if ($item -match '(?i)]*>') { $title = $Matches[1] } + elseif ($item -match '(?i)]*>([^<]+)') { $title = $Matches[1] } + $link = '' + if ($item -match '(?i)([^<]+)') { $link = $Matches[1] } + elseif ($item -match '(?i)(https://earthobservatory\.nasa\.gov/images/\d+[^"< ]*)') { $link = $Matches[1] } + @{ + Title = Strip-Html $title + Desc = '' + Url = if ($link) { $link.Trim() } else { 'https://earthobservatory.nasa.gov/' } + ImageUrls = @("${imgUrl}?w=3840", $imgUrl) + } } function Resolve-Natgeo { - $page = Invoke-BackdropRequest 'https://www.nationalgeographic.com/photo-of-the-day/' - $url = $null - if ($page -match '(?i)property="og:image"\s+content="(https://i\.natgeofe\.com/[^"]+)"') { $url = $Matches[1] } - if (-not $url) { return $null } - $ogTitle = '' - $ogDesc = '' - $ogUrl = '' - if ($page -match '(?i)property="og:title"\s+content="([^"]+)"') { $ogTitle = $Matches[1] -replace ' \|.*', '' } - if ($page -match '(?i)property="og:description"\s+content="([^"]+)"') { $ogDesc = $Matches[1] } - if ($page -match '(?i)property="og:url"\s+content="([^"]+)"') { $ogUrl = $Matches[1] } - @{ - Title = Strip-Html $ogTitle - Desc = Strip-Html $ogDesc - Url = if ($ogUrl) { $ogUrl } else { 'https://www.nationalgeographic.com/photo-of-the-day/' } - ImageUrls = @("${url}?w=5120", $url) - } + $page = Invoke-BackdropRequest 'https://www.nationalgeographic.com/photo-of-the-day/' + $url = $null + if ($page -match '(?i)property="og:image"\s+content="(https://i\.natgeofe\.com/[^"]+)"') { $url = $Matches[1] } + if (-not $url) { return $null } + $ogTitle = '' + $ogDesc = '' + $ogUrl = '' + if ($page -match '(?i)property="og:title"\s+content="([^"]+)"') { $ogTitle = $Matches[1] -replace ' \|.*', '' } + if ($page -match '(?i)property="og:description"\s+content="([^"]+)"') { $ogDesc = $Matches[1] } + if ($page -match '(?i)property="og:url"\s+content="([^"]+)"') { $ogUrl = $Matches[1] } + @{ + Title = Strip-Html $ogTitle + Desc = Strip-Html $ogDesc + Url = if ($ogUrl) { $ogUrl } else { 'https://www.nationalgeographic.com/photo-of-the-day/' } + ImageUrls = @("${url}?w=5120", $url) + } } function Resolve-Earth { - $page = Invoke-BackdropRequest 'https://www.earth.com/gallery/images-of-the-day/' - $articleUrl = $null - if ($page -match '(?i)href="(https://www\.earth\.com/image/[^"]+)"') { $articleUrl = $Matches[1] } - if (-not $articleUrl) { return $null } - $article = Invoke-BackdropRequest $articleUrl - $url = $null - if ($article -match '(?i)(https://cff2\.earth\.com/uploads/[^"]+\.(jpg|jpeg|png))') { $url = $Matches[1] } - if (-not $url) { return $null } - $ogTitle = '' - $ogDesc = '' - $ogUrl = '' - if ($article -match '(?i)property="og:title"\s+content="([^"]+)"') { $ogTitle = $Matches[1] } - if ($article -match '(?i)property="og:description"\s+content="([^"]+)"') { $ogDesc = $Matches[1] } - if ($article -match '(?i)property="og:url"\s+content="([^"]+)"') { $ogUrl = $Matches[1] } - @{ - Title = Strip-Html $ogTitle - Desc = Strip-Html $ogDesc - Url = if ($ogUrl) { $ogUrl } else { $articleUrl } - ImageUrls = @($url) - } + $page = Invoke-BackdropRequest 'https://www.earth.com/gallery/images-of-the-day/' + $articleUrl = $null + if ($page -match '(?i)href="(https://www\.earth\.com/image/[^"]+)"') { $articleUrl = $Matches[1] } + if (-not $articleUrl) { return $null } + $article = Invoke-BackdropRequest $articleUrl + $url = $null + if ($article -match '(?i)(https://cff2\.earth\.com/uploads/[^"]+\.(jpg|jpeg|png))') { $url = $Matches[1] } + if (-not $url) { return $null } + $ogTitle = '' + $ogDesc = '' + $ogUrl = '' + if ($article -match '(?i)property="og:title"\s+content="([^"]+)"') { $ogTitle = $Matches[1] } + if ($article -match '(?i)property="og:description"\s+content="([^"]+)"') { $ogDesc = $Matches[1] } + if ($article -match '(?i)property="og:url"\s+content="([^"]+)"') { $ogUrl = $Matches[1] } + @{ + Title = Strip-Html $ogTitle + Desc = Strip-Html $ogDesc + Url = if ($ogUrl) { $ogUrl } else { $articleUrl } + ImageUrls = @($url) + } } #endregion @@ -285,35 +288,37 @@ function Resolve-Earth { #region Geometry function Get-ImageDimensions { - param([string]$Path) - try { - Add-Type -AssemblyName System.Drawing -ErrorAction Stop - $img = [System.Drawing.Image]::FromFile($Path) - $dims = @{ Width = $img.Width; Height = $img.Height } - $img.Dispose() - return $dims - } catch { - return $null - } + param([string]$Path) + try { + Add-Type -AssemblyName System.Drawing -ErrorAction Stop + $img = [System.Drawing.Image]::FromFile($Path) + $dims = @{ Width = $img.Width; Height = $img.Height } + $img.Dispose() + return $dims + } + catch { + return $null + } } function Get-ScreenAspectRatio { - try { - Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop - $s = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds - if ($s.Height -gt 0) { return [double]$s.Width / $s.Height } - } catch { } - return [double]$script:ScreenAspectRatio + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop + $s = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + if ($s.Height -gt 0) { return [double]$s.Width / $s.Height } + } + catch { } + return [double]$script:ScreenAspectRatio } function Get-PictureOption { - param([string]$Path) - $dims = Get-ImageDimensions $Path - if (-not $dims -or $dims.Width -le 0 -or $dims.Height -le 0) { return 'zoom' } - $iar = [double]$dims.Width / $dims.Height - $sar = Get-ScreenAspectRatio - $cov = if ($iar -lt $sar) { $iar / $sar } else { $sar / $iar } - if ($cov -ge $script:ZoomMinCoverage) { 'zoom' } else { 'scaled' } + param([string]$Path) + $dims = Get-ImageDimensions $Path + if (-not $dims -or $dims.Width -le 0 -or $dims.Height -le 0) { return 'zoom' } + $iar = [double]$dims.Width / $dims.Height + $sar = Get-ScreenAspectRatio + $cov = if ($iar -lt $sar) { $iar / $sar } else { $sar / $iar } + if ($cov -ge $script:ZoomMinCoverage) { 'zoom' } else { 'scaled' } } #endregion @@ -321,7 +326,7 @@ function Get-PictureOption { #region Wallpaper if (-not ([System.Management.Automation.PSTypeName]'BackdropWallpaper').Type) { - Add-Type @' + Add-Type @' using System.Runtime.InteropServices; public class BackdropWallpaper { [DllImport("user32.dll", CharSet = CharSet.Auto)] @@ -331,12 +336,12 @@ public class BackdropWallpaper { } function Set-Wallpaper { - param([string]$Path, [string]$Option) - # WallpaperStyle: 10=Fill (zoom/crop), 6=Fit (scaled/letterbox) - $style = if ($Option -eq 'zoom') { '10' } else { '6' } - Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name WallpaperStyle -Value $style - Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name TileWallpaper -Value '0' - [BackdropWallpaper]::SystemParametersInfo(20, 0, $Path, 3) | Out-Null + param([string]$Path, [string]$Option) + # WallpaperStyle: 10=Fill (zoom/crop), 6=Fit (scaled/letterbox) + $style = if ($Option -eq 'zoom') { '10' } else { '6' } + Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name WallpaperStyle -Value $style + Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name TileWallpaper -Value '0' + [BackdropWallpaper]::SystemParametersInfo(20, 0, $Path, 3) | Out-Null } #endregion @@ -344,33 +349,35 @@ function Set-Wallpaper { #region Scheduling function Invoke-TimerConfig { - $action = New-ScheduledTaskAction ` - -Execute 'powershell.exe' ` - -Argument "-NonInteractive -WindowStyle Hidden -Command `"Import-Module backdrop; backdrop update`"" - - if ($script:RotateInterval -gt 0) { - $timeTrigger = New-ScheduledTaskTrigger -Daily -At '00:00' - $timeTrigger.Repetition.Interval = "PT$($script:RotateInterval)M" - $timeTrigger.Repetition.Duration = 'P1D' - } else { - $timeTrigger = New-ScheduledTaskTrigger -Daily -At $script:TimerTime - } - - $logon = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME - $logon.Delay = 'PT2M' - - $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited - $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 10) ` - -StartWhenAvailable -MultipleInstances IgnoreNew - - $existing = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue - if ($existing) { - Set-ScheduledTask -TaskName $script:TaskName -Action $action ` - -Trigger @($timeTrigger, $logon) -Principal $principal -Settings $settings | Out-Null - } else { - Register-ScheduledTask -TaskName $script:TaskName -Action $action ` - -Trigger @($timeTrigger, $logon) -Principal $principal -Settings $settings | Out-Null - } + $action = New-ScheduledTaskAction ` + -Execute 'powershell.exe' ` + -Argument "-NonInteractive -WindowStyle Hidden -Command `"Import-Module backdrop; backdrop update`"" + + if ($script:RotateInterval -gt 0) { + $timeTrigger = New-ScheduledTaskTrigger -Daily -At '00:00' + $timeTrigger.Repetition.Interval = "PT$($script:RotateInterval)M" + $timeTrigger.Repetition.Duration = 'P1D' + } + else { + $timeTrigger = New-ScheduledTaskTrigger -Daily -At $script:TimerTime + } + + $logon = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME + $logon.Delay = 'PT2M' + + $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited + $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 10) ` + -StartWhenAvailable -MultipleInstances IgnoreNew + + $existing = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + if ($existing) { + Set-ScheduledTask -TaskName $script:TaskName -Action $action ` + -Trigger @($timeTrigger, $logon) -Principal $principal -Settings $settings | Out-Null + } + else { + Register-ScheduledTask -TaskName $script:TaskName -Action $action ` + -Trigger @($timeTrigger, $logon) -Principal $principal -Settings $settings | Out-Null + } } #endregion @@ -378,50 +385,50 @@ function Invoke-TimerConfig { #region Source and meta helpers function Get-BackdropSources { - $s = Get-BackdropConfigValue 'source' - if ($s) { - if ($s -eq 'all') { return $script:ValidSources } - return @($s -split '\s+') - } - return @($script:Source) + $s = Get-BackdropConfigValue 'source' + if ($s) { + if ($s -eq 'all') { return $script:ValidSources } + return @($s -split '\s+') + } + return @($script:Source) } function Get-UnixTimestamp { [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } function Get-ActiveSource { - $srcs = @(Get-BackdropSources) - if ($srcs.Count -le 1 -or $script:RotateInterval -le 0) { - return if ($srcs.Count -gt 0) { $srcs[0] } else { $script:Source } - } - $idx = [int]((Get-UnixTimestamp) / 60 / $script:RotateInterval) % $srcs.Count - return $srcs[$idx] + $srcs = @(Get-BackdropSources) + if ($srcs.Count -le 1 -or $script:RotateInterval -le 0) { + return if ($srcs.Count -gt 0) { $srcs[0] } else { $script:Source } + } + $idx = [int]((Get-UnixTimestamp) / 60 / $script:RotateInterval) % $srcs.Count + return $srcs[$idx] } function Test-ValidSource { - param([string]$s) - $script:ValidSources -contains $s + param([string]$s) + $script:ValidSources -contains $s } function Write-MetaFile { - param([string]$Dest, [hashtable]$Meta) - $metaPath = [System.IO.Path]::ChangeExtension($Dest, 'meta') - $lines = @() - if ($Meta.Title) { $lines += "title = $($Meta.Title)" } - if ($Meta.Desc) { - $d = $Meta.Desc - if ($d.Length -gt 200) { $d = $d.Substring(0, 200) } - $lines += "desc = $d" - } - if ($Meta.Url) { $lines += "url = $($Meta.Url)" } - Set-Content -Path $metaPath -Value $lines + param([string]$Dest, [hashtable]$Meta) + $metaPath = [System.IO.Path]::ChangeExtension($Dest, 'meta') + $lines = @() + if ($Meta.Title) { $lines += "title = $($Meta.Title)" } + if ($Meta.Desc) { + $d = $Meta.Desc + if ($d.Length -gt 200) { $d = $d.Substring(0, 200) } + $lines += "desc = $d" + } + if ($Meta.Url) { $lines += "url = $($Meta.Url)" } + Set-Content -Path $metaPath -Value $lines } function Get-MetaValue { - param([string]$MetaFile, [string]$Key) - if (-not (Test-Path $MetaFile)) { return $null } - $content = Get-Content $MetaFile -Raw -ErrorAction SilentlyContinue - if ($content -match "(?m)^\s*$([regex]::Escape($Key))\s*=\s*(.+?)\s*$") { return $Matches[1] } - return $null + param([string]$MetaFile, [string]$Key) + if (-not (Test-Path $MetaFile)) { return $null } + $content = Get-Content $MetaFile -Raw -ErrorAction SilentlyContinue + if ($content -match "(?m)^\s*$([regex]::Escape($Key))\s*=\s*(.+?)\s*$") { return $Matches[1] } + return $null } #endregion @@ -429,56 +436,56 @@ function Get-MetaValue { #region Core function Invoke-ApplyWallpaper { - param([string]$Src, [bool]$Force = $false) - if (-not (Test-ValidSource $Src)) { - throw "backdrop: unknown source '$Src' (valid: $($script:ValidSources -join ', '))" - } - - $dest = Join-Path $script:StateDir "$Src-$(Get-Date -Format 'yyyy-MM-dd').jpg" - - if ((Test-Path $dest) -and -not $Force) { - $opt = Get-PictureOption $dest - Set-Wallpaper $dest $opt - Set-Content -Path (Join-Path $script:StateDir 'current') -Value $dest - $dims = Get-ImageDimensions $dest - $dimsStr = if ($dims) { "$($dims.Width)x$($dims.Height)" } else { 'unknown' } - Write-Host "backdrop: set from $Src [$dimsStr, $opt] -> $dest (cached)" - return - } + param([string]$Src, [bool]$Force = $false) + if (-not (Test-ValidSource $Src)) { + throw "backdrop: unknown source '$Src' (valid: $($script:ValidSources -join ', '))" + } - $result = switch ($Src) { - 'iotd' { Resolve-Iotd } - 'apod' { Resolve-Apod } - 'bing' { Resolve-Bing } - 'wmc' { Resolve-Wmc } - 'eo' { Resolve-Eo } - 'natgeo' { Resolve-Natgeo } - 'earth' { Resolve-Earth } - } - - if ($null -eq $result) { - Write-Host "backdrop: $Src has no image today (e.g. APOD video day); wallpaper unchanged." - return - } - - $ok = $false - foreach ($url in $result.ImageUrls) { - try { Save-BackdropFile $url $dest; $ok = $true; break } catch { } - } - if (-not $ok) { throw "backdrop: could not download any image for $Src" } + $dest = Join-Path $script:StateDir "$Src-$(Get-Date -Format 'yyyy-MM-dd').jpg" + if ((Test-Path $dest) -and -not $Force) { $opt = Get-PictureOption $dest Set-Wallpaper $dest $opt Set-Content -Path (Join-Path $script:StateDir 'current') -Value $dest - Write-MetaFile $dest $result - - Get-ChildItem $script:StateDir -File | - Where-Object { $_.Extension -in @('.jpg', '.meta') -and $_.LastWriteTime -lt (Get-Date).AddDays(-14) } | - Remove-Item -Force -ErrorAction SilentlyContinue - - $dims = Get-ImageDimensions $dest + $dims = Get-ImageDimensions $dest $dimsStr = if ($dims) { "$($dims.Width)x$($dims.Height)" } else { 'unknown' } - Write-Host "backdrop: set from $Src [$dimsStr, $opt] -> $dest" + Write-Host "backdrop: set from $Src [$dimsStr, $opt] -> $dest (cached)" + return + } + + $result = switch ($Src) { + 'iotd' { Resolve-Iotd } + 'apod' { Resolve-Apod } + 'bing' { Resolve-Bing } + 'wmc' { Resolve-Wmc } + 'eo' { Resolve-Eo } + 'natgeo' { Resolve-Natgeo } + 'earth' { Resolve-Earth } + } + + if ($null -eq $result) { + Write-Host "backdrop: $Src has no image today (e.g. APOD video day); wallpaper unchanged." + return + } + + $ok = $false + foreach ($url in $result.ImageUrls) { + try { Save-BackdropFile $url $dest; $ok = $true; break } catch { } + } + if (-not $ok) { throw "backdrop: could not download any image for $Src" } + + $opt = Get-PictureOption $dest + Set-Wallpaper $dest $opt + Set-Content -Path (Join-Path $script:StateDir 'current') -Value $dest + Write-MetaFile $dest $result + + Get-ChildItem $script:StateDir -File | + Where-Object { $_.Extension -in @('.jpg', '.meta') -and $_.LastWriteTime -lt (Get-Date).AddDays(-14) } | + Remove-Item -Force -ErrorAction SilentlyContinue + + $dims = Get-ImageDimensions $dest + $dimsStr = if ($dims) { "$($dims.Width)x$($dims.Height)" } else { 'unknown' } + Write-Host "backdrop: set from $Src [$dimsStr, $opt] -> $dest" } #endregion @@ -486,222 +493,233 @@ function Invoke-ApplyWallpaper { #region Commands function Invoke-BackdropUpdate { - param([switch]$Force) - Invoke-ApplyWallpaper (Get-ActiveSource) $Force.IsPresent + param([switch]$Force) + Invoke-ApplyWallpaper (Get-ActiveSource) $Force.IsPresent } function Invoke-BackdropRandom { - param([switch]$Force) - Invoke-ApplyWallpaper ($script:ValidSources | Get-Random) $Force.IsPresent + param([switch]$Force) + Invoke-ApplyWallpaper ($script:ValidSources | Get-Random) $Force.IsPresent } function Invoke-BackdropEnable { - Invoke-TimerConfig - $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue - if ($task -and $task.State -eq 'Disabled') { - Enable-ScheduledTask -TaskName $script:TaskName | Out-Null - } - if ($script:RotateInterval -gt 0) { - Write-Host "backdrop: timer enabled (rotating every $($script:RotateInterval) min)." - } else { - Write-Host "backdrop: daily timer enabled (runs at $($script:TimerTime))." - } - Invoke-BackdropUpdate + Invoke-TimerConfig + $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + if ($task -and $task.State -eq 'Disabled') { + Enable-ScheduledTask -TaskName $script:TaskName | Out-Null + } + if ($script:RotateInterval -gt 0) { + Write-Host "backdrop: timer enabled (rotating every $($script:RotateInterval) min)." + } + else { + Write-Host "backdrop: daily timer enabled (runs at $($script:TimerTime))." + } + Invoke-BackdropUpdate } function Invoke-BackdropDisable { - Disable-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue | Out-Null - Write-Host "backdrop: daily timer disabled." + Disable-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue | Out-Null + Write-Host "backdrop: daily timer disabled." } function Invoke-BackdropSetTime { - param([string]$Time) - if ($Time -notmatch '^([01]\d|2[0-3]):[0-5]\d$') { - throw "backdrop: set-time: expected HH:MM (24-hour), e.g. 08:00" - } - $script:TimerTime = $Time - Set-BackdropConfigValue 'timer_time' $Time - $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue - if ($task) { - Invoke-TimerConfig - Write-Host "backdrop: timer time set to $Time and task updated." - } else { - Write-Host "backdrop: timer time set to $Time (run 'backdrop enable' to start the timer)." - } + param([string]$Time) + if ($Time -notmatch '^([01]\d|2[0-3]):[0-5]\d$') { + throw "backdrop: set-time: expected HH:MM (24-hour), e.g. 08:00" + } + $script:TimerTime = $Time + Set-BackdropConfigValue 'timer_time' $Time + $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + if ($task) { + Invoke-TimerConfig + Write-Host "backdrop: timer time set to $Time and task updated." + } + else { + Write-Host "backdrop: timer time set to $Time (run 'backdrop enable' to start the timer)." + } } function Invoke-BackdropSetRotateInterval { - param([string]$Minutes) - if ($Minutes -notmatch '^\d+$') { - throw "backdrop: set-rotate-interval: expected number of minutes (0 to disable), e.g. 60" - } - $script:RotateInterval = [int]$Minutes - Set-BackdropConfigValue 'rotate_interval' $Minutes - $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue - if ($task) { - Invoke-TimerConfig - if ([int]$Minutes -gt 0) { - Write-Host "backdrop: rotate interval set to $Minutes min and task updated." - } else { - Write-Host "backdrop: rotation disabled, task reset to daily at $($script:TimerTime)." - } - } else { - if ([int]$Minutes -gt 0) { - Write-Host "backdrop: rotate interval set to $Minutes min (run 'backdrop enable' to start the timer)." - } else { - Write-Host "backdrop: rotation disabled (run 'backdrop enable' to start the daily timer)." - } - } -} - -function Invoke-BackdropSet { - param([string[]]$Sources, [bool]$Force = $false) - if (-not $Sources -or $Sources.Count -eq 0) { - throw "backdrop: set: choose one or more sources ($($script:ValidSources -join ', ')) or 'all'" + param([string]$Minutes) + if ($Minutes -notmatch '^\d+$') { + throw "backdrop: set-rotate-interval: expected number of minutes (0 to disable), e.g. 60" + } + $script:RotateInterval = [int]$Minutes + Set-BackdropConfigValue 'rotate_interval' $Minutes + $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + if ($task) { + Invoke-TimerConfig + if ([int]$Minutes -gt 0) { + Write-Host "backdrop: rotate interval set to $Minutes min and task updated." } - if ($Sources.Count -eq 1 -and $Sources[0] -eq 'all') { $Sources = $script:ValidSources } - foreach ($s in $Sources) { - if (-not (Test-ValidSource $s)) { - throw "backdrop: set: unknown source '$s' (valid: $($script:ValidSources -join ', '))" - } + else { + Write-Host "backdrop: rotation disabled, task reset to daily at $($script:TimerTime)." } - Set-BackdropConfigValue 'source' ($Sources -join ' ') - $timerChanged = $false - if ($Sources.Count -gt 1) { - if ($script:RotateInterval -le 0) { - $script:RotateInterval = 30 - Set-BackdropConfigValue 'rotate_interval' '30' - $timerChanged = $true - } - Write-Host "backdrop: active sources: $($Sources -join ' ') (rotating every $($script:RotateInterval) min)" - } else { - if ($script:RotateInterval -gt 0) { - $script:RotateInterval = 0 - Set-BackdropConfigValue 'rotate_interval' '0' - $timerChanged = $true - } - Write-Host "backdrop: active source is now '$($Sources[0])'" + } + else { + if ([int]$Minutes -gt 0) { + Write-Host "backdrop: rotate interval set to $Minutes min (run 'backdrop enable' to start the timer)." } - if ($timerChanged) { - $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue - if ($task) { Invoke-TimerConfig } + else { + Write-Host "backdrop: rotation disabled (run 'backdrop enable' to start the daily timer)." } - Invoke-ApplyWallpaper (Get-ActiveSource) $Force + } } -function Invoke-BackdropStatus { - Write-Host "backdrop v$($script:Version)" - Write-Host '' - - $activeSrcs = @(Get-BackdropSources) - $activeSrc = Get-ActiveSource - $latest = $null - - $currentFile = Join-Path $script:StateDir 'current' - if (Test-Path $currentFile) { - $p = (Get-Content $currentFile -Raw).Trim() - if (Test-Path $p) { $latest = $p } - } - if (-not $latest) { - $latest = Get-ChildItem $script:StateDir -Filter "$activeSrc-*.jpg" -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName - } - - $displayedSrc = $activeSrc - if ($latest) { - $candidate = [System.IO.Path]::GetFileNameWithoutExtension($latest) -replace '-\d{4}-\d{2}-\d{2}$', '' - if (Test-ValidSource $candidate) { $displayedSrc = $candidate } - } - - if ($activeSrcs.Count -gt 1) { - $labeled = ($activeSrcs | ForEach-Object { if ($_ -eq $displayedSrc) { "[$_]" } else { $_ } }) -join ' ' - Write-Host "Active sources: $labeled" - } else { - Write-Host "Active source: $activeSrc" +function Invoke-BackdropSet { + param([string[]]$Sources, [bool]$Force = $false) + if (-not $Sources -or $Sources.Count -eq 0) { + throw "backdrop: set: choose one or more sources ($($script:ValidSources -join ', ')) or 'all'" + } + if ($Sources.Count -eq 1 -and $Sources[0] -eq 'all') { $Sources = $script:ValidSources } + foreach ($s in $Sources) { + if (-not (Test-ValidSource $s)) { + throw "backdrop: set: unknown source '$s' (valid: $($script:ValidSources -join ', '))" + } + } + Set-BackdropConfigValue 'source' ($Sources -join ' ') + $timerChanged = $false + if ($Sources.Count -gt 1) { + if ($script:RotateInterval -le 0) { + $script:RotateInterval = 30 + Set-BackdropConfigValue 'rotate_interval' '30' + $timerChanged = $true + } + Write-Host "backdrop: active sources: $($Sources -join ' ') (rotating every $($script:RotateInterval) min)" + } + else { + if ($script:RotateInterval -gt 0) { + $script:RotateInterval = 0 + Set-BackdropConfigValue 'rotate_interval' '0' + $timerChanged = $true } - + Write-Host "backdrop: active source is now '$($Sources[0])'" + } + if ($timerChanged) { $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue - if ($task -and $task.State -ne 'Disabled') { - if ($script:RotateInterval -gt 0) { - Write-Host "Timer: enabled (rotating every $($script:RotateInterval) min)" - } else { - Write-Host "Timer: enabled (runs at $($script:TimerTime))" - } - } else { - Write-Host "Timer: disabled" - } - - Write-Host '' - if ($latest) { - Write-Host "Current image: $latest" - $metaFile = [System.IO.Path]::ChangeExtension($latest, 'meta') - $mv = Get-MetaValue $metaFile 'title' - if ($mv) { - if ($mv.Length -gt 77) { $mv = $mv.Substring(0, 77) + '...' } - Write-Host "Title: $mv" - } - $mv = Get-MetaValue $metaFile 'desc' - if ($mv) { - if ($mv.Length -gt 77) { $mv = $mv.Substring(0, 77) + '...' } - Write-Host "Description: $mv" - } - $mv = Get-MetaValue $metaFile 'url' - if ($mv) { Write-Host "URL: $mv" } - } + if ($task) { Invoke-TimerConfig } + } + Invoke-ApplyWallpaper (Get-ActiveSource) $Force +} - $styleNum = (Get-ItemProperty 'HKCU:\Control Panel\Desktop' -ErrorAction SilentlyContinue).WallpaperStyle - $styleStr = switch ($styleNum) { '10' { 'zoom' } '6' { 'scaled' } default { "style=$styleNum" } } - Write-Host '' - Write-Host "Display method: windows, $styleStr" - Write-Host "Aspect ratio: $(Get-ScreenAspectRatio), $($script:ZoomMinCoverage) min coverage" - Write-Host "Config file: $($script:ConfigFile)" - Write-Host '' - Write-Host "Use 'backdrop help' for usage information." - Write-Host '' +function Invoke-BackdropStatus { + Write-Host "backdrop v$($script:Version)" + Write-Host '' + + $activeSrcs = @(Get-BackdropSources) + $activeSrc = Get-ActiveSource + $latest = $null + + $currentFile = Join-Path $script:StateDir 'current' + if (Test-Path $currentFile) { + $p = (Get-Content $currentFile -Raw).Trim() + if (Test-Path $p) { $latest = $p } + } + if (-not $latest) { + $latest = Get-ChildItem $script:StateDir -Filter "$activeSrc-*.jpg" -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName + } + + $displayedSrc = $activeSrc + if ($latest) { + $candidate = [System.IO.Path]::GetFileNameWithoutExtension($latest) -replace '-\d{4}-\d{2}-\d{2}$', '' + if (Test-ValidSource $candidate) { $displayedSrc = $candidate } + } + + if ($activeSrcs.Count -gt 1) { + $labeled = ($activeSrcs | ForEach-Object { if ($_ -eq $displayedSrc) { "[$_]" } else { $_ } }) -join ' ' + Write-Host "Active sources: $labeled" + } + else { + Write-Host "Active source: $activeSrc" + } + + $task = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue + if ($task -and $task.State -ne 'Disabled') { + if ($script:RotateInterval -gt 0) { + Write-Host "Timer: enabled (rotating every $($script:RotateInterval) min)" + } + else { + Write-Host "Timer: enabled (runs at $($script:TimerTime))" + } + } + else { + Write-Host "Timer: disabled" + } + + Write-Host '' + if ($latest) { + Write-Host "Current image: $latest" + $metaFile = [System.IO.Path]::ChangeExtension($latest, 'meta') + $mv = Get-MetaValue $metaFile 'title' + if ($mv) { + if ($mv.Length -gt 77) { $mv = $mv.Substring(0, 77) + '...' } + Write-Host "Title: $mv" + } + $mv = Get-MetaValue $metaFile 'desc' + if ($mv) { + if ($mv.Length -gt 77) { $mv = $mv.Substring(0, 77) + '...' } + Write-Host "Description: $mv" + } + $mv = Get-MetaValue $metaFile 'url' + if ($mv) { Write-Host "URL: $mv" } + } + + $styleNum = (Get-ItemProperty 'HKCU:\Control Panel\Desktop' -ErrorAction SilentlyContinue).WallpaperStyle + $styleStr = switch ($styleNum) { '10' { 'zoom' } '6' { 'scaled' } default { "style=$styleNum" } } + Write-Host '' + Write-Host "Display method: windows, $styleStr" + Write-Host "Aspect ratio: $(Get-ScreenAspectRatio), $($script:ZoomMinCoverage) min coverage" + Write-Host "Config file: $($script:ConfigFile)" + Write-Host '' + Write-Host "Use 'backdrop help' for usage information." + Write-Host '' } function Invoke-BackdropUpgrade { - Write-Host "backdrop: checking for updates (current: v$($script:Version))..." - $apiResp = Invoke-BackdropRequest 'https://api.github.com/repos/aensley/backdrop/releases/latest' -TimeoutSec 15 - $release = $apiResp | ConvertFrom-Json - $latestTag = $release.tag_name - $latestVer = $latestTag.TrimStart('v') - if ([version]$latestVer -le [version]$script:Version) { - Write-Host "backdrop: already up to date (v$($script:Version))." - return - } - Write-Host "backdrop: upgrading v$($script:Version) -> v$latestVer..." - $rawUrl = "https://raw.githubusercontent.com/aensley/backdrop/$latestTag/src/backdrop.psm1" - $modPath = (Get-Module backdrop -ErrorAction SilentlyContinue).Path - if (-not $modPath) { throw 'backdrop: upgrade: could not locate installed module path' } - $tmp = [System.IO.Path]::GetTempFileName() - try { - Save-BackdropFile $rawUrl $tmp -TimeoutSec 60 - Copy-Item $tmp $modPath -Force - Write-Host "backdrop: upgraded to v$latestVer. Restart PowerShell to use the new version." - } finally { - Remove-Item $tmp -Force -ErrorAction SilentlyContinue - } + Write-Host "backdrop: checking for updates (current: v$($script:Version))..." + $apiResp = Invoke-BackdropRequest 'https://api.github.com/repos/aensley/backdrop/releases/latest' -TimeoutSec 15 + $release = $apiResp | ConvertFrom-Json + $latestTag = $release.tag_name + $latestVer = $latestTag.TrimStart('v') + if ([version]$latestVer -le [version]$script:Version) { + Write-Host "backdrop: already up to date (v$($script:Version))." + return + } + Write-Host "backdrop: upgrading v$($script:Version) -> v$latestVer..." + $rawUrl = "https://raw.githubusercontent.com/aensley/backdrop/$latestTag/src/backdrop.psm1" + $modPath = (Get-Module backdrop -ErrorAction SilentlyContinue).Path + if (-not $modPath) { throw 'backdrop: upgrade: could not locate installed module path' } + $tmp = [System.IO.Path]::GetTempFileName() + try { + Save-BackdropFile $rawUrl $tmp -TimeoutSec 60 + Copy-Item $tmp $modPath -Force + Write-Host "backdrop: upgraded to v$latestVer. Restart PowerShell to use the new version." + } + finally { + Remove-Item $tmp -Force -ErrorAction SilentlyContinue + } } function Invoke-BackdropUninstall { - param([switch]$Purge) - Disable-ScheduledTask -TaskName $script:TaskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null - Unregister-ScheduledTask -TaskName $script:TaskName -Confirm:$false -ErrorAction SilentlyContinue - $modDir = Split-Path -Parent (Get-Module backdrop -ErrorAction SilentlyContinue).Path - if ($modDir -and (Test-Path $modDir)) { Remove-Item $modDir -Recurse -Force } - if ($Purge) { - Remove-Item $script:ConfigDir -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item $script:StateDir -Recurse -Force -ErrorAction SilentlyContinue - Write-Host 'backdrop: uninstalled. Config and cached wallpapers removed.' - } else { - Write-Host 'backdrop: uninstalled.' - Write-Host "Note: config and cached wallpapers were not removed. Run 'backdrop uninstall --purge' to delete them." - } + param([switch]$Purge) + Disable-ScheduledTask -TaskName $script:TaskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + Unregister-ScheduledTask -TaskName $script:TaskName -Confirm:$false -ErrorAction SilentlyContinue + $modDir = Split-Path -Parent (Get-Module backdrop -ErrorAction SilentlyContinue).Path + if ($modDir -and (Test-Path $modDir)) { Remove-Item $modDir -Recurse -Force } + if ($Purge) { + Remove-Item $script:ConfigDir -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item $script:StateDir -Recurse -Force -ErrorAction SilentlyContinue + Write-Host 'backdrop: uninstalled. Config and cached wallpapers removed.' + } + else { + Write-Host 'backdrop: uninstalled.' + Write-Host "Note: config and cached wallpapers were not removed. Run 'backdrop uninstall --purge' to delete them." + } } function Invoke-BackdropHelp { - Write-Host @" + Write-Host @" backdrop v$($script:Version) Usage: backdrop @@ -735,44 +753,45 @@ Sources: #region Dispatch function backdrop { - <# + <# .SYNOPSIS Set a new desktop wallpaper every day from various sources. .DESCRIPTION Downloads the image of the day from a configurable source and sets it as the desktop wallpaper. Run 'backdrop help' for available commands and sources. #> - param( - [Parameter(Position = 0)] - [string]$Command = 'status', - [Parameter(ValueFromRemainingArguments)] - [string[]]$Rest - ) - - if ($Command -ne 'uninstall') { Import-BackdropConfig } - - $force = $Rest -contains '--force' - $args = @($Rest | Where-Object { $_ -ne '--force' }) - - try { - switch ($Command) { - { $_ -in 'update', 'refresh' } { Invoke-BackdropUpdate -Force:$force } - { $_ -in 'set', 'use' } { Invoke-BackdropSet -Sources $args -Force $force } - 'status' { Invoke-BackdropStatus } - 'random' { Invoke-BackdropRandom -Force:$force } - 'enable' { Invoke-BackdropEnable } - 'disable' { Invoke-BackdropDisable } - 'set-time' { Invoke-BackdropSetTime ($args | Select-Object -First 1) } - 'set-rotate-interval' { Invoke-BackdropSetRotateInterval ($args | Select-Object -First 1) } - 'upgrade' { Invoke-BackdropUpgrade } - 'uninstall' { Invoke-BackdropUninstall -Purge:($Rest -contains '--purge') } - { $_ -in '-h', '--help', 'help' } { Invoke-BackdropHelp } - default { throw "backdrop: unknown command '$Command' (try: backdrop help)" } - } - } catch { - Write-Host $_.Exception.Message -ForegroundColor Red - exit 1 - } + param( + [Parameter(Position = 0)] + [string]$Command = 'status', + [Parameter(ValueFromRemainingArguments)] + [string[]]$Rest + ) + + if ($Command -ne 'uninstall') { Import-BackdropConfig } + + $force = $Rest -contains '--force' + $args = @($Rest | Where-Object { $_ -ne '--force' }) + + try { + switch ($Command) { + { $_ -in 'update', 'refresh' } { Invoke-BackdropUpdate -Force:$force } + { $_ -in 'set', 'use' } { Invoke-BackdropSet -Sources $args -Force $force } + 'status' { Invoke-BackdropStatus } + 'random' { Invoke-BackdropRandom -Force:$force } + 'enable' { Invoke-BackdropEnable } + 'disable' { Invoke-BackdropDisable } + 'set-time' { Invoke-BackdropSetTime ($args | Select-Object -First 1) } + 'set-rotate-interval' { Invoke-BackdropSetRotateInterval ($args | Select-Object -First 1) } + 'upgrade' { Invoke-BackdropUpgrade } + 'uninstall' { Invoke-BackdropUninstall -Purge:($Rest -contains '--purge') } + { $_ -in '-h', '--help', 'help' } { Invoke-BackdropHelp } + default { throw "backdrop: unknown command '$Command' (try: backdrop help)" } + } + } + catch { + Write-Host $_.Exception.Message -ForegroundColor Red + exit 1 + } } Export-ModuleMember -Function 'backdrop' From 51432e5eb44ad8de913018d3ad335d7a757b9731 Mon Sep 17 00:00:00 2001 From: Andrew Ensley Date: Mon, 29 Jun 2026 19:44:42 -0500 Subject: [PATCH 04/10] fix: :bug: fix tests --- .github/workflows/test.yml | 2 +- src/backdrop.psm1 | 2 +- test/backdrop.tests.ps1 | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index afd693f..f28898a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,7 +50,7 @@ jobs: - name: Lint shell: pwsh run: | - $results = Invoke-ScriptAnalyzer -Path src/backdrop.psm1, src/install.ps1 -Severity @('Error', 'Warning') + $results = Invoke-ScriptAnalyzer -Path @('src/backdrop.psm1', 'src/install.ps1') -Severity @('Error', 'Warning') if ($results) { $results | Format-Table RuleName, Severity, Line, Message -AutoSize exit 1 diff --git a/src/backdrop.psm1 b/src/backdrop.psm1 index ec482fe..598772c 100644 --- a/src/backdrop.psm1 +++ b/src/backdrop.psm1 @@ -15,7 +15,7 @@ $script:Source = 'iotd' $script:RotateInterval = 0 $script:ScreenAspectRatio = 1.7778 $script:ZoomMinCoverage = 0.55 -$script:UserAgent = "backdrop/1.6 (personal daily wallpaper script)" +$script:UserAgent = "backdrop/$(($script:Version -split '\.')[0..1] -join '.') (personal daily wallpaper script)" $script:TimerTime = '08:00' $null = New-Item -ItemType Directory -Force -Path $script:StateDir, $script:ConfigDir diff --git a/test/backdrop.tests.ps1 b/test/backdrop.tests.ps1 index c6eddbe..121493f 100644 --- a/test/backdrop.tests.ps1 +++ b/test/backdrop.tests.ps1 @@ -20,7 +20,7 @@ InModuleScope backdrop { $script:ScreenAspectRatio = 1.7778 $script:ZoomMinCoverage = 0.55 $script:TimerTime = '08:00' - $script:UserAgent = 'backdrop/1.6 (personal daily wallpaper script)' + $script:UserAgent = "backdrop/$(($script:Version -split '\.')[0..1] -join '.') (personal daily wallpaper script)" New-Item -ItemType Directory -Force -Path $script:ConfigDir, $script:StateDir | Out-Null } From 4af88f6ce86b1832c82f545fb625a9849c3b52f0 Mon Sep 17 00:00:00 2001 From: Andrew Ensley Date: Mon, 29 Jun 2026 19:54:32 -0500 Subject: [PATCH 05/10] fix: :bug: fix tests --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f28898a..3e1688a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,7 +50,7 @@ jobs: - name: Lint shell: pwsh run: | - $results = Invoke-ScriptAnalyzer -Path @('src/backdrop.psm1', 'src/install.ps1') -Severity @('Error', 'Warning') + $results = 'src/backdrop.psm1', 'src/install.ps1' | ForEach-Object { Invoke-ScriptAnalyzer -Path $_ -Severity @('Error', 'Warning') } if ($results) { $results | Format-Table RuleName, Severity, Line, Message -AutoSize exit 1 From 3894b12436713c18857c918c552ccaba549edc61 Mon Sep 17 00:00:00 2001 From: Andrew Ensley Date: Tue, 30 Jun 2026 12:13:10 -0500 Subject: [PATCH 06/10] fix: :bug: fix tests --- .github/workflows/test.yml | 3 ++- src/backdrop.psm1 | 52 +++++++++++++++++++------------------- test/backdrop.tests.ps1 | 32 +++++++++++------------ 3 files changed, 44 insertions(+), 43 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3e1688a..c18b80e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,7 +50,8 @@ jobs: - name: Lint shell: pwsh run: | - $results = 'src/backdrop.psm1', 'src/install.ps1' | ForEach-Object { Invoke-ScriptAnalyzer -Path $_ -Severity @('Error', 'Warning') } + $exclude = @('PSAvoidUsingWriteHost', 'PSUseShouldProcessForStateChangingFunctions') + $results = 'src/backdrop.psm1', 'src/install.ps1' | ForEach-Object { Invoke-ScriptAnalyzer -Path $_ -Severity @('Error', 'Warning') -ExcludeRule $exclude } if ($results) { $results | Format-Table RuleName, Severity, Line, Message -AutoSize exit 1 diff --git a/src/backdrop.psm1 b/src/backdrop.psm1 index 598772c..52bc87e 100644 --- a/src/backdrop.psm1 +++ b/src/backdrop.psm1 @@ -38,7 +38,7 @@ function Save-BackdropFile { -OutFile $OutFile -TimeoutSec $TimeoutSec -ErrorAction Stop | Out-Null } -function Strip-Html { +function Remove-HtmlMarkup { param([string]$s) if (-not $s) { return '' } $s = $s -replace '<[^>]+>', '' @@ -46,7 +46,7 @@ function Strip-Html { Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue $s = [System.Web.HttpUtility]::HtmlDecode($s) } - catch { } + catch { $null = $_ } ($s -replace '\s+', ' ').Trim() } @@ -142,8 +142,8 @@ function Resolve-Iotd { $imageUrl = $item.enclosure.url if (-not $imageUrl) { return $null } @{ - Title = Strip-Html $item.title - Desc = Strip-Html $item.description + Title = Remove-HtmlMarkup $item.title + Desc = Remove-HtmlMarkup $item.description Url = if ($item.link) { $item.link } else { 'https://www.nasa.gov/image-of-the-day/' } ImageUrls = @($imageUrl) } @@ -156,7 +156,7 @@ function Resolve-Apod { if (-not $rel) { return $null } $title = '' $m = [regex]::Match($page, '(?i)

\s*([^<]+)\s*\s*Explanation:\s*(.*?)(?=

|)') if ($m.Success) { @@ -201,7 +201,7 @@ function Resolve-Wmc { $desc = ($rawDesc -replace '\s+', ' ').Trim() if ($desc.Length -gt 300) { $desc = $desc.Substring(0, 300) } } - catch { } + catch { $null = $_ } $enc = [Uri]::EscapeDataString($file) $iresp = Invoke-BackdropRequest "https://commons.wikimedia.org/w/api.php?action=query&format=json&prop=imageinfo&iiprop=url&iiurlwidth=3840&titles=File:$enc" $info = (($iresp | ConvertFrom-Json).query.pages.PSObject.Properties | Select-Object -First 1).Value.imageinfo[0] @@ -210,8 +210,8 @@ function Resolve-Wmc { if ($info.url) { $urls += $info.url } if (-not $urls) { return $null } @{ - Title = Strip-Html $title - Desc = Strip-Html $desc + Title = Remove-HtmlMarkup $title + Desc = Remove-HtmlMarkup $desc Url = "https://commons.wikimedia.org/wiki/File:$([Uri]::EscapeDataString(($file -replace '^File:', '')))" ImageUrls = $urls } @@ -234,7 +234,7 @@ function Resolve-Eo { if ($item -match '(?i)([^<]+)') { $link = $Matches[1] } elseif ($item -match '(?i)(https://earthobservatory\.nasa\.gov/images/\d+[^"< ]*)') { $link = $Matches[1] } @{ - Title = Strip-Html $title + Title = Remove-HtmlMarkup $title Desc = '' Url = if ($link) { $link.Trim() } else { 'https://earthobservatory.nasa.gov/' } ImageUrls = @("${imgUrl}?w=3840", $imgUrl) @@ -253,8 +253,8 @@ function Resolve-Natgeo { if ($page -match '(?i)property="og:description"\s+content="([^"]+)"') { $ogDesc = $Matches[1] } if ($page -match '(?i)property="og:url"\s+content="([^"]+)"') { $ogUrl = $Matches[1] } @{ - Title = Strip-Html $ogTitle - Desc = Strip-Html $ogDesc + Title = Remove-HtmlMarkup $ogTitle + Desc = Remove-HtmlMarkup $ogDesc Url = if ($ogUrl) { $ogUrl } else { 'https://www.nationalgeographic.com/photo-of-the-day/' } ImageUrls = @("${url}?w=5120", $url) } @@ -276,8 +276,8 @@ function Resolve-Earth { if ($article -match '(?i)property="og:description"\s+content="([^"]+)"') { $ogDesc = $Matches[1] } if ($article -match '(?i)property="og:url"\s+content="([^"]+)"') { $ogUrl = $Matches[1] } @{ - Title = Strip-Html $ogTitle - Desc = Strip-Html $ogDesc + Title = Remove-HtmlMarkup $ogTitle + Desc = Remove-HtmlMarkup $ogDesc Url = if ($ogUrl) { $ogUrl } else { $articleUrl } ImageUrls = @($url) } @@ -287,7 +287,7 @@ function Resolve-Earth { #region Geometry -function Get-ImageDimensions { +function Get-ImageDimension { param([string]$Path) try { Add-Type -AssemblyName System.Drawing -ErrorAction Stop @@ -307,13 +307,13 @@ function Get-ScreenAspectRatio { $s = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds if ($s.Height -gt 0) { return [double]$s.Width / $s.Height } } - catch { } + catch { $null = $_ } return [double]$script:ScreenAspectRatio } function Get-PictureOption { param([string]$Path) - $dims = Get-ImageDimensions $Path + $dims = Get-ImageDimension $Path if (-not $dims -or $dims.Width -le 0 -or $dims.Height -le 0) { return 'zoom' } $iar = [double]$dims.Width / $dims.Height $sar = Get-ScreenAspectRatio @@ -384,7 +384,7 @@ function Invoke-TimerConfig { #region Source and meta helpers -function Get-BackdropSources { +function Get-BackdropSource { $s = Get-BackdropConfigValue 'source' if ($s) { if ($s -eq 'all') { return $script:ValidSources } @@ -396,7 +396,7 @@ function Get-BackdropSources { function Get-UnixTimestamp { [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } function Get-ActiveSource { - $srcs = @(Get-BackdropSources) + $srcs = @(Get-BackdropSource) if ($srcs.Count -le 1 -or $script:RotateInterval -le 0) { return if ($srcs.Count -gt 0) { $srcs[0] } else { $script:Source } } @@ -447,7 +447,7 @@ function Invoke-ApplyWallpaper { $opt = Get-PictureOption $dest Set-Wallpaper $dest $opt Set-Content -Path (Join-Path $script:StateDir 'current') -Value $dest - $dims = Get-ImageDimensions $dest + $dims = Get-ImageDimension $dest $dimsStr = if ($dims) { "$($dims.Width)x$($dims.Height)" } else { 'unknown' } Write-Host "backdrop: set from $Src [$dimsStr, $opt] -> $dest (cached)" return @@ -470,7 +470,7 @@ function Invoke-ApplyWallpaper { $ok = $false foreach ($url in $result.ImageUrls) { - try { Save-BackdropFile $url $dest; $ok = $true; break } catch { } + try { Save-BackdropFile $url $dest; $ok = $true; break } catch { $null = $_ } } if (-not $ok) { throw "backdrop: could not download any image for $Src" } @@ -483,7 +483,7 @@ function Invoke-ApplyWallpaper { Where-Object { $_.Extension -in @('.jpg', '.meta') -and $_.LastWriteTime -lt (Get-Date).AddDays(-14) } | Remove-Item -Force -ErrorAction SilentlyContinue - $dims = Get-ImageDimensions $dest + $dims = Get-ImageDimension $dest $dimsStr = if ($dims) { "$($dims.Width)x$($dims.Height)" } else { 'unknown' } Write-Host "backdrop: set from $Src [$dimsStr, $opt] -> $dest" } @@ -606,7 +606,7 @@ function Invoke-BackdropStatus { Write-Host "backdrop v$($script:Version)" Write-Host '' - $activeSrcs = @(Get-BackdropSources) + $activeSrcs = @(Get-BackdropSource) $activeSrc = Get-ActiveSource $latest = $null @@ -770,18 +770,18 @@ function backdrop { if ($Command -ne 'uninstall') { Import-BackdropConfig } $force = $Rest -contains '--force' - $args = @($Rest | Where-Object { $_ -ne '--force' }) + $filteredArgs = @($Rest | Where-Object { $_ -ne '--force' }) try { switch ($Command) { { $_ -in 'update', 'refresh' } { Invoke-BackdropUpdate -Force:$force } - { $_ -in 'set', 'use' } { Invoke-BackdropSet -Sources $args -Force $force } + { $_ -in 'set', 'use' } { Invoke-BackdropSet -Sources $filteredArgs -Force $force } 'status' { Invoke-BackdropStatus } 'random' { Invoke-BackdropRandom -Force:$force } 'enable' { Invoke-BackdropEnable } 'disable' { Invoke-BackdropDisable } - 'set-time' { Invoke-BackdropSetTime ($args | Select-Object -First 1) } - 'set-rotate-interval' { Invoke-BackdropSetRotateInterval ($args | Select-Object -First 1) } + 'set-time' { Invoke-BackdropSetTime ($filteredArgs | Select-Object -First 1) } + 'set-rotate-interval' { Invoke-BackdropSetRotateInterval ($filteredArgs | Select-Object -First 1) } 'upgrade' { Invoke-BackdropUpgrade } 'uninstall' { Invoke-BackdropUninstall -Purge:($Rest -contains '--purge') } { $_ -in '-h', '--help', 'help' } { Invoke-BackdropHelp } diff --git a/test/backdrop.tests.ps1 b/test/backdrop.tests.ps1 index 121493f..c6f99aa 100644 --- a/test/backdrop.tests.ps1 +++ b/test/backdrop.tests.ps1 @@ -142,43 +142,43 @@ user_agent = test-agent/1.0 Describe 'Get-PictureOption' { It 'returns zoom for a wide image on a 16:9 screen' { - Mock Get-ImageDimensions { @{ Width = 1920; Height = 1080 } } + Mock Get-ImageDimension { @{ Width = 1920; Height = 1080 } } Mock Get-ScreenAspectRatio { 1.7778 } Get-PictureOption 'dummy.jpg' | Should -Be 'zoom' } It 'returns scaled for a tall image on a 16:9 screen' { - Mock Get-ImageDimensions { @{ Width = 1080; Height = 1920 } } + Mock Get-ImageDimension { @{ Width = 1080; Height = 1920 } } Mock Get-ScreenAspectRatio { 1.7778 } Get-PictureOption 'dummy.jpg' | Should -Be 'scaled' } It 'returns zoom for a square image (coverage 0.5625 >= threshold 0.55)' { - Mock Get-ImageDimensions { @{ Width = 1000; Height = 1000 } } + Mock Get-ImageDimension { @{ Width = 1000; Height = 1000 } } Mock Get-ScreenAspectRatio { 1.7778 } $script:ZoomMinCoverage = 0.55 Get-PictureOption 'dummy.jpg' | Should -Be 'zoom' } It 'returns zoom when image dimensions cannot be read' { - Mock Get-ImageDimensions { $null } + Mock Get-ImageDimension { $null } Get-PictureOption 'dummy.jpg' | Should -Be 'zoom' } } # ------------------------------------------------------------------------- - # Strip-Html + # Remove-HtmlMarkup # ------------------------------------------------------------------------- - Describe 'Strip-Html' { + Describe 'Remove-HtmlMarkup' { It 'removes HTML tags' { - Strip-Html 'Hello World' | Should -Be 'Hello World' + Remove-HtmlMarkup 'Hello World' | Should -Be 'Hello World' } It 'decodes common HTML entities' { - Strip-Html '& < > " '' | Should -Be '& < > " ''' + Remove-HtmlMarkup '& < > " '' | Should -Be '& < > " ''' } It 'decodes numeric entities with leading zeros' { - Strip-Html "San Francisco's Streets" | Should -Be "San Francisco's Streets" + Remove-HtmlMarkup "San Francisco's Streets" | Should -Be "San Francisco's Streets" } It 'collapses whitespace' { - Strip-Html ' foo bar ' | Should -Be 'foo bar' + Remove-HtmlMarkup ' foo bar ' | Should -Be 'foo bar' } } @@ -220,24 +220,24 @@ user_agent = test-agent/1.0 } # ------------------------------------------------------------------------- - # Get-BackdropSources + # Get-BackdropSource # ------------------------------------------------------------------------- - Describe 'Get-BackdropSources' { + Describe 'Get-BackdropSource' { It 'returns default source when config does not exist' { - Get-BackdropSources | Should -Be @('iotd') + Get-BackdropSource | Should -Be @('iotd') } It 'returns a single configured source' { Set-Content $script:ConfigFile 'source = bing' - Get-BackdropSources | Should -Be @('bing') + Get-BackdropSource | Should -Be @('bing') } It 'returns a list for multiple configured sources' { Set-Content $script:ConfigFile 'source = iotd apod bing' - Get-BackdropSources | Should -Be @('iotd', 'apod', 'bing') + Get-BackdropSource | Should -Be @('iotd', 'apod', 'bing') } It "expands 'all' to every valid source" { Set-Content $script:ConfigFile 'source = all' - Get-BackdropSources | Should -Be $script:ValidSources + Get-BackdropSource | Should -Be $script:ValidSources } } From 93aa9a0ec87ad53d19b13b5eaed11ed5fb1d7c40 Mon Sep 17 00:00:00 2001 From: Andrew Ensley Date: Tue, 30 Jun 2026 12:20:16 -0500 Subject: [PATCH 07/10] fix: :bug: fix tests --- test/backdrop.tests.ps1 | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/backdrop.tests.ps1 b/test/backdrop.tests.ps1 index c6f99aa..901ecd9 100644 --- a/test/backdrop.tests.ps1 +++ b/test/backdrop.tests.ps1 @@ -1,11 +1,11 @@ #Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } -BeforeAll { - # Ensure APPDATA/LOCALAPPDATA are set. They don't exist on Linux but the module reads them at load time. - if (-not $env:APPDATA) { $env:APPDATA = [System.IO.Path]::GetTempPath() } - if (-not $env:LOCALAPPDATA) { $env:LOCALAPPDATA = [System.IO.Path]::GetTempPath() } - Import-Module "$PSScriptRoot/../src/backdrop.psm1" -Force -} +# Must run at script scope (not inside BeforeAll) so the module is loaded during Pester's discovery +# phase, which is required for InModuleScope to work. APPDATA/LOCALAPPDATA don't exist on Linux +# but the module reads them at load time, so they must be set before the import. +if (-not $env:APPDATA) { $env:APPDATA = [System.IO.Path]::GetTempPath() } +if (-not $env:LOCALAPPDATA) { $env:LOCALAPPDATA = [System.IO.Path]::GetTempPath() } +Import-Module "$PSScriptRoot/../src/backdrop.psm1" -Force # All tests run inside the module scope so private functions are directly callable. InModuleScope backdrop { From 6c26aaa56c4ec43458a42fa3f71462c9291f7f34 Mon Sep 17 00:00:00 2001 From: Andrew Ensley Date: Tue, 30 Jun 2026 12:40:36 -0500 Subject: [PATCH 08/10] fix: :bug: fix tests --- test/backdrop.tests.ps1 | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/test/backdrop.tests.ps1 b/test/backdrop.tests.ps1 index 901ecd9..4550aa6 100644 --- a/test/backdrop.tests.ps1 +++ b/test/backdrop.tests.ps1 @@ -1,12 +1,19 @@ #Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } -# Must run at script scope (not inside BeforeAll) so the module is loaded during Pester's discovery -# phase, which is required for InModuleScope to work. APPDATA/LOCALAPPDATA don't exist on Linux -# but the module reads them at load time, so they must be set before the import. +# Script-scope import: runs during Pester discovery so InModuleScope can find the module. +# APPDATA/LOCALAPPDATA don't exist on Linux but the module reads them at load time. if (-not $env:APPDATA) { $env:APPDATA = [System.IO.Path]::GetTempPath() } if (-not $env:LOCALAPPDATA) { $env:LOCALAPPDATA = [System.IO.Path]::GetTempPath() } Import-Module "$PSScriptRoot/../src/backdrop.psm1" -Force +BeforeAll { + # Pester does not re-execute the script body during the run phase, so we must re-import here to + # ensure the module is present when InModuleScope enters it for test execution. + if (-not $env:APPDATA) { $env:APPDATA = [System.IO.Path]::GetTempPath() } + if (-not $env:LOCALAPPDATA) { $env:LOCALAPPDATA = [System.IO.Path]::GetTempPath() } + Import-Module "$PSScriptRoot/../src/backdrop.psm1" -Force +} + # All tests run inside the module scope so private functions are directly callable. InModuleScope backdrop { From 423d1a7bacaf773a5b739b5542c55e0122aa8b88 Mon Sep 17 00:00:00 2001 From: Andrew Ensley Date: Tue, 30 Jun 2026 14:11:24 -0500 Subject: [PATCH 09/10] fix: :bug: fix tests --- src/backdrop.psm1 | 10 +- test/backdrop.tests.ps1 | 800 ++++++++++++++++++++-------------------- 2 files changed, 409 insertions(+), 401 deletions(-) diff --git a/src/backdrop.psm1 b/src/backdrop.psm1 index 52bc87e..2c4cd3e 100644 --- a/src/backdrop.psm1 +++ b/src/backdrop.psm1 @@ -39,8 +39,10 @@ function Save-BackdropFile { } function Remove-HtmlMarkup { - param([string]$s) + param($s) + if ($s -is [System.Xml.XmlNode]) { $s = $s.InnerText } if (-not $s) { return '' } + $s = [string]$s $s = $s -replace '<[^>]+>', '' try { Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue @@ -138,7 +140,7 @@ function Import-BackdropConfig { function Resolve-Iotd { $feed = Invoke-BackdropRequest 'https://www.nasa.gov/feeds/iotd-feed/' [xml]$xml = $feed - $item = $xml.rss.channel.item | Select-Object -First 1 + $item = @($xml.rss.channel.item)[0] $imageUrl = $item.enclosure.url if (-not $imageUrl) { return $null } @{ @@ -398,9 +400,9 @@ function Get-UnixTimestamp { [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } function Get-ActiveSource { $srcs = @(Get-BackdropSource) if ($srcs.Count -le 1 -or $script:RotateInterval -le 0) { - return if ($srcs.Count -gt 0) { $srcs[0] } else { $script:Source } + if ($srcs.Count -gt 0) { return $srcs[0] } else { return $script:Source } } - $idx = [int]((Get-UnixTimestamp) / 60 / $script:RotateInterval) % $srcs.Count + $idx = [Math]::Floor((Get-UnixTimestamp) / 60 / $script:RotateInterval) % $srcs.Count return $srcs[$idx] } diff --git a/test/backdrop.tests.ps1 b/test/backdrop.tests.ps1 index 4550aa6..4fbec62 100644 --- a/test/backdrop.tests.ps1 +++ b/test/backdrop.tests.ps1 @@ -17,289 +17,293 @@ BeforeAll { # All tests run inside the module scope so private functions are directly callable. InModuleScope backdrop { - BeforeEach { - # Redirect config/state to an isolated per-test directory. - $script:ConfigDir = Join-Path $TestDrive 'config' - $script:ConfigFile = Join-Path $TestDrive 'config' 'config' - $script:StateDir = Join-Path $TestDrive 'state' - $script:Source = 'iotd' - $script:RotateInterval = 0 - $script:ScreenAspectRatio = 1.7778 - $script:ZoomMinCoverage = 0.55 - $script:TimerTime = '08:00' - $script:UserAgent = "backdrop/$(($script:Version -split '\.')[0..1] -join '.') (personal daily wallpaper script)" - New-Item -ItemType Directory -Force -Path $script:ConfigDir, $script:StateDir | Out-Null - } - - # ------------------------------------------------------------------------- - # Test-ValidSource - # ------------------------------------------------------------------------- - - Describe 'Test-ValidSource' { - It 'accepts built-in source: <_>' -ForEach @('iotd', 'apod', 'bing', 'wmc', 'eo', 'earth', 'natgeo') { - Test-ValidSource $_ | Should -BeTrue - } - It 'rejects an unknown source' { - Test-ValidSource 'unknown' | Should -BeFalse - } - It 'rejects an empty string' { - Test-ValidSource '' | Should -BeFalse + # Pester v5 does not allow BeforeEach directly at the InModuleScope root level; wrap in Describe. + Describe 'backdrop' { + + BeforeEach { + # $TestDrive is shared across all tests in a file, so use a unique subdirectory per test. + $testId = [System.Guid]::NewGuid().ToString('N') + $script:ConfigDir = Join-Path $TestDrive "config-$testId" + $script:ConfigFile = Join-Path $script:ConfigDir 'config' + $script:StateDir = Join-Path $TestDrive "state-$testId" + $script:Source = 'iotd' + $script:RotateInterval = 0 + $script:ScreenAspectRatio = 1.7778 + $script:ZoomMinCoverage = 0.55 + $script:TimerTime = '08:00' + $script:UserAgent = "backdrop/$(($script:Version -split '\.')[0..1] -join '.') (personal daily wallpaper script)" + New-Item -ItemType Directory -Force -Path $script:ConfigDir, $script:StateDir | Out-Null } - } - # ------------------------------------------------------------------------- - # Get-BackdropConfigValue - # ------------------------------------------------------------------------- + # ------------------------------------------------------------------------- + # Test-ValidSource + # ------------------------------------------------------------------------- - Describe 'Get-BackdropConfigValue' { - It 'returns null when config does not exist' { - Get-BackdropConfigValue 'source' | Should -BeNullOrEmpty - } - It 'reads a simple key = value' { - Set-Content $script:ConfigFile 'source = bing' - Get-BackdropConfigValue 'source' | Should -Be 'bing' - } - It 'strips surrounding double quotes' { - Set-Content $script:ConfigFile 'user_agent = "my agent"' - Get-BackdropConfigValue 'user_agent' | Should -Be 'my agent' - } - It 'strips surrounding single quotes' { - Set-Content $script:ConfigFile "user_agent = 'my agent'" - Get-BackdropConfigValue 'user_agent' | Should -Be 'my agent' - } - It 'ignores comment lines' { - Set-Content $script:ConfigFile "# source = apod`nsource = wmc" - Get-BackdropConfigValue 'source' | Should -Be 'wmc' - } - } - - # ------------------------------------------------------------------------- - # Initialize-BackdropConfig / Set-BackdropConfigValue - # ------------------------------------------------------------------------- - - Describe 'Initialize-BackdropConfig / Set-BackdropConfigValue' { - It 'creates the config file with built-in defaults' { - Initialize-BackdropConfig - $script:ConfigFile | Should -Exist - Get-BackdropConfigValue 'source' | Should -Be 'iotd' - Get-BackdropConfigValue 'screen_aspect_ratio' | Should -Be '1.7778' - } - It 'writes a new key' { - Initialize-BackdropConfig - Set-BackdropConfigValue 'source' 'apod' - Get-BackdropConfigValue 'source' | Should -Be 'apod' + Describe 'Test-ValidSource' { + It 'accepts built-in source: <_>' -ForEach @('iotd', 'apod', 'bing', 'wmc', 'eo', 'earth', 'natgeo') { + Test-ValidSource $_ | Should -BeTrue + } + It 'rejects an unknown source' { + Test-ValidSource 'unknown' | Should -BeFalse + } + It 'rejects an empty string' { + Test-ValidSource '' | Should -BeFalse + } } - It 'overwrites an existing key' { - Initialize-BackdropConfig - Set-BackdropConfigValue 'source' 'bing' - Set-BackdropConfigValue 'source' 'wmc' - Get-BackdropConfigValue 'source' | Should -Be 'wmc' + + # ------------------------------------------------------------------------- + # Get-BackdropConfigValue + # ------------------------------------------------------------------------- + + Describe 'Get-BackdropConfigValue' { + It 'returns null when config does not exist' { + Get-BackdropConfigValue 'source' | Should -BeNullOrEmpty + } + It 'reads a simple key = value' { + Set-Content $script:ConfigFile 'source = bing' + Get-BackdropConfigValue 'source' | Should -Be 'bing' + } + It 'strips surrounding double quotes' { + Set-Content $script:ConfigFile 'user_agent = "my agent"' + Get-BackdropConfigValue 'user_agent' | Should -Be 'my agent' + } + It 'strips surrounding single quotes' { + Set-Content $script:ConfigFile "user_agent = 'my agent'" + Get-BackdropConfigValue 'user_agent' | Should -Be 'my agent' + } + It 'ignores comment lines' { + Set-Content $script:ConfigFile "# source = apod`nsource = wmc" + Get-BackdropConfigValue 'source' | Should -Be 'wmc' + } } - It 'preserves other keys when updating one' { - Initialize-BackdropConfig - Set-BackdropConfigValue 'source' 'eo' - Get-BackdropConfigValue 'screen_aspect_ratio' | Should -Be '1.7778' + + # ------------------------------------------------------------------------- + # Initialize-BackdropConfig / Set-BackdropConfigValue + # ------------------------------------------------------------------------- + + Describe 'Initialize-BackdropConfig / Set-BackdropConfigValue' { + It 'creates the config file with built-in defaults' { + Initialize-BackdropConfig + $script:ConfigFile | Should -Exist + Get-BackdropConfigValue 'source' | Should -Be 'iotd' + Get-BackdropConfigValue 'screen_aspect_ratio' | Should -Be '1.7778' + } + It 'writes a new key' { + Initialize-BackdropConfig + Set-BackdropConfigValue 'source' 'apod' + Get-BackdropConfigValue 'source' | Should -Be 'apod' + } + It 'overwrites an existing key' { + Initialize-BackdropConfig + Set-BackdropConfigValue 'source' 'bing' + Set-BackdropConfigValue 'source' 'wmc' + Get-BackdropConfigValue 'source' | Should -Be 'wmc' + } + It 'preserves other keys when updating one' { + Initialize-BackdropConfig + Set-BackdropConfigValue 'source' 'eo' + Get-BackdropConfigValue 'screen_aspect_ratio' | Should -Be '1.7778' + } } - } - # ------------------------------------------------------------------------- - # Import-BackdropConfig - # ------------------------------------------------------------------------- + # ------------------------------------------------------------------------- + # Import-BackdropConfig + # ------------------------------------------------------------------------- - Describe 'Import-BackdropConfig' { - It 'reads config values into module globals' { - Set-Content $script:ConfigFile @" + Describe 'Import-BackdropConfig' { + It 'reads config values into module globals' { + Set-Content $script:ConfigFile @" screen_aspect_ratio = 2.3333 zoom_min_coverage = 0.75 timer_time = 10:30 rotate_interval = 15 user_agent = test-agent/1.0 "@ - Import-BackdropConfig - $script:ScreenAspectRatio | Should -Be 2.3333 - $script:ZoomMinCoverage | Should -Be 0.75 - $script:TimerTime | Should -Be '10:30' - $script:RotateInterval | Should -Be 15 - $script:UserAgent | Should -Be 'test-agent/1.0' - } - It 'leaves globals at defaults when config is missing' { - Import-BackdropConfig - $script:ScreenAspectRatio | Should -Be 1.7778 - $script:ZoomMinCoverage | Should -Be 0.55 - $script:TimerTime | Should -Be '08:00' - $script:RotateInterval | Should -Be 0 + Import-BackdropConfig + $script:ScreenAspectRatio | Should -Be 2.3333 + $script:ZoomMinCoverage | Should -Be 0.75 + $script:TimerTime | Should -Be '10:30' + $script:RotateInterval | Should -Be 15 + $script:UserAgent | Should -Be 'test-agent/1.0' + } + It 'leaves globals at defaults when config is missing' { + Import-BackdropConfig + $script:ScreenAspectRatio | Should -Be 1.7778 + $script:ZoomMinCoverage | Should -Be 0.55 + $script:TimerTime | Should -Be '08:00' + $script:RotateInterval | Should -Be 0 + } } - } - # ------------------------------------------------------------------------- - # Get-ScreenAspectRatio - # ------------------------------------------------------------------------- + # ------------------------------------------------------------------------- + # Get-ScreenAspectRatio + # ------------------------------------------------------------------------- - Describe 'Get-ScreenAspectRatio' { - It 'returns a positive numeric aspect ratio' { - $ar = Get-ScreenAspectRatio - $ar | Should -BeOfType [double] - $ar | Should -BeGreaterThan 0 + Describe 'Get-ScreenAspectRatio' { + It 'returns a positive numeric aspect ratio' { + $ar = Get-ScreenAspectRatio + $ar | Should -BeOfType [double] + $ar | Should -BeGreaterThan 0 + } } - } - # ------------------------------------------------------------------------- - # Get-PictureOption - # ------------------------------------------------------------------------- + # ------------------------------------------------------------------------- + # Get-PictureOption + # ------------------------------------------------------------------------- - Describe 'Get-PictureOption' { - It 'returns zoom for a wide image on a 16:9 screen' { - Mock Get-ImageDimension { @{ Width = 1920; Height = 1080 } } - Mock Get-ScreenAspectRatio { 1.7778 } - Get-PictureOption 'dummy.jpg' | Should -Be 'zoom' - } - It 'returns scaled for a tall image on a 16:9 screen' { - Mock Get-ImageDimension { @{ Width = 1080; Height = 1920 } } - Mock Get-ScreenAspectRatio { 1.7778 } - Get-PictureOption 'dummy.jpg' | Should -Be 'scaled' - } - It 'returns zoom for a square image (coverage 0.5625 >= threshold 0.55)' { - Mock Get-ImageDimension { @{ Width = 1000; Height = 1000 } } - Mock Get-ScreenAspectRatio { 1.7778 } - $script:ZoomMinCoverage = 0.55 - Get-PictureOption 'dummy.jpg' | Should -Be 'zoom' - } - It 'returns zoom when image dimensions cannot be read' { - Mock Get-ImageDimension { $null } - Get-PictureOption 'dummy.jpg' | Should -Be 'zoom' + Describe 'Get-PictureOption' { + It 'returns zoom for a wide image on a 16:9 screen' { + Mock Get-ImageDimension { @{ Width = 1920; Height = 1080 } } + Mock Get-ScreenAspectRatio { 1.7778 } + Get-PictureOption 'dummy.jpg' | Should -Be 'zoom' + } + It 'returns scaled for a tall image on a 16:9 screen' { + Mock Get-ImageDimension { @{ Width = 1080; Height = 1920 } } + Mock Get-ScreenAspectRatio { 1.7778 } + Get-PictureOption 'dummy.jpg' | Should -Be 'scaled' + } + It 'returns zoom for a square image (coverage 0.5625 >= threshold 0.55)' { + Mock Get-ImageDimension { @{ Width = 1000; Height = 1000 } } + Mock Get-ScreenAspectRatio { 1.7778 } + $script:ZoomMinCoverage = 0.55 + Get-PictureOption 'dummy.jpg' | Should -Be 'zoom' + } + It 'returns zoom when image dimensions cannot be read' { + Mock Get-ImageDimension { $null } + Get-PictureOption 'dummy.jpg' | Should -Be 'zoom' + } } - } - # ------------------------------------------------------------------------- - # Remove-HtmlMarkup - # ------------------------------------------------------------------------- + # ------------------------------------------------------------------------- + # Remove-HtmlMarkup + # ------------------------------------------------------------------------- - Describe 'Remove-HtmlMarkup' { - It 'removes HTML tags' { - Remove-HtmlMarkup 'Hello World' | Should -Be 'Hello World' - } - It 'decodes common HTML entities' { - Remove-HtmlMarkup '& < > " '' | Should -Be '& < > " ''' - } - It 'decodes numeric entities with leading zeros' { - Remove-HtmlMarkup "San Francisco's Streets" | Should -Be "San Francisco's Streets" - } - It 'collapses whitespace' { - Remove-HtmlMarkup ' foo bar ' | Should -Be 'foo bar' + Describe 'Remove-HtmlMarkup' { + It 'removes HTML tags' { + Remove-HtmlMarkup 'Hello World' | Should -Be 'Hello World' + } + It 'decodes common HTML entities' { + Remove-HtmlMarkup '& < > " '' | Should -Be '& < > " ''' + } + It 'decodes numeric entities with leading zeros' { + Remove-HtmlMarkup "San Francisco's Streets" | Should -Be "San Francisco's Streets" + } + It 'collapses whitespace' { + Remove-HtmlMarkup ' foo bar ' | Should -Be 'foo bar' + } } - } - # ------------------------------------------------------------------------- - # Write-MetaFile / Get-MetaValue - # ------------------------------------------------------------------------- + # ------------------------------------------------------------------------- + # Write-MetaFile / Get-MetaValue + # ------------------------------------------------------------------------- - Describe 'Write-MetaFile / Get-MetaValue' { - It 'reads a key from a meta file' { - $f = Join-Path $script:StateDir 'test.meta' - Set-Content $f "title = My Image`ndesc = Some desc`nurl = https://example.com/" - Get-MetaValue $f 'title' | Should -Be 'My Image' - } - It 'returns null for a missing file' { - Get-MetaValue (Join-Path $script:StateDir 'nonexistent.meta') 'title' | Should -BeNullOrEmpty - } - It 'returns null for a missing key' { - $f = Join-Path $script:StateDir 'test.meta' - Set-Content $f 'url = https://example.com/' - Get-MetaValue $f 'title' | Should -BeNullOrEmpty - } - It 'writes non-empty fields to a .meta file' { - $dest = Join-Path $script:StateDir 'src-2025-01-01.jpg' - $null = New-Item $dest -ItemType File -Force - Write-MetaFile $dest @{ Title = 'Test Image'; Desc = 'A test description'; Url = 'https://example.com/' } - $meta = [System.IO.Path]::ChangeExtension($dest, 'meta') - Get-MetaValue $meta 'title' | Should -Be 'Test Image' - Get-MetaValue $meta 'desc' | Should -Be 'A test description' - Get-MetaValue $meta 'url' | Should -Be 'https://example.com/' - } - It 'omits empty fields from the .meta file' { - $dest = Join-Path $script:StateDir 'src-2025-01-01.jpg' - $null = New-Item $dest -ItemType File -Force - Write-MetaFile $dest @{ Title = ''; Desc = ''; Url = 'https://example.com/' } - $meta = [System.IO.Path]::ChangeExtension($dest, 'meta') - Get-MetaValue $meta 'title' | Should -BeNullOrEmpty - Get-MetaValue $meta 'url' | Should -Be 'https://example.com/' + Describe 'Write-MetaFile / Get-MetaValue' { + It 'reads a key from a meta file' { + $f = Join-Path $script:StateDir 'test.meta' + Set-Content $f "title = My Image`ndesc = Some desc`nurl = https://example.com/" + Get-MetaValue $f 'title' | Should -Be 'My Image' + } + It 'returns null for a missing file' { + Get-MetaValue (Join-Path $script:StateDir 'nonexistent.meta') 'title' | Should -BeNullOrEmpty + } + It 'returns null for a missing key' { + $f = Join-Path $script:StateDir 'test.meta' + Set-Content $f 'url = https://example.com/' + Get-MetaValue $f 'title' | Should -BeNullOrEmpty + } + It 'writes non-empty fields to a .meta file' { + $dest = Join-Path $script:StateDir 'src-2025-01-01.jpg' + $null = New-Item $dest -ItemType File -Force + Write-MetaFile $dest @{ Title = 'Test Image'; Desc = 'A test description'; Url = 'https://example.com/' } + $meta = [System.IO.Path]::ChangeExtension($dest, 'meta') + Get-MetaValue $meta 'title' | Should -Be 'Test Image' + Get-MetaValue $meta 'desc' | Should -Be 'A test description' + Get-MetaValue $meta 'url' | Should -Be 'https://example.com/' + } + It 'omits empty fields from the .meta file' { + $dest = Join-Path $script:StateDir 'src-2025-01-01.jpg' + $null = New-Item $dest -ItemType File -Force + Write-MetaFile $dest @{ Title = ''; Desc = ''; Url = 'https://example.com/' } + $meta = [System.IO.Path]::ChangeExtension($dest, 'meta') + Get-MetaValue $meta 'title' | Should -BeNullOrEmpty + Get-MetaValue $meta 'url' | Should -Be 'https://example.com/' + } } - } - # ------------------------------------------------------------------------- - # Get-BackdropSource - # ------------------------------------------------------------------------- + # ------------------------------------------------------------------------- + # Get-BackdropSource + # ------------------------------------------------------------------------- - Describe 'Get-BackdropSource' { - It 'returns default source when config does not exist' { - Get-BackdropSource | Should -Be @('iotd') - } - It 'returns a single configured source' { - Set-Content $script:ConfigFile 'source = bing' - Get-BackdropSource | Should -Be @('bing') - } - It 'returns a list for multiple configured sources' { - Set-Content $script:ConfigFile 'source = iotd apod bing' - Get-BackdropSource | Should -Be @('iotd', 'apod', 'bing') - } - It "expands 'all' to every valid source" { - Set-Content $script:ConfigFile 'source = all' - Get-BackdropSource | Should -Be $script:ValidSources + Describe 'Get-BackdropSource' { + It 'returns default source when config does not exist' { + Get-BackdropSource | Should -Be @('iotd') + } + It 'returns a single configured source' { + Set-Content $script:ConfigFile 'source = bing' + Get-BackdropSource | Should -Be @('bing') + } + It 'returns a list for multiple configured sources' { + Set-Content $script:ConfigFile 'source = iotd apod bing' + Get-BackdropSource | Should -Be @('iotd', 'apod', 'bing') + } + It "expands 'all' to every valid source" { + Set-Content $script:ConfigFile 'source = all' + Get-BackdropSource | Should -Be $script:ValidSources + } } - } - # ------------------------------------------------------------------------- - # Get-ActiveSource - # ------------------------------------------------------------------------- + # ------------------------------------------------------------------------- + # Get-ActiveSource + # ------------------------------------------------------------------------- - Describe 'Get-ActiveSource' { - It 'returns the single configured source' { - Set-Content $script:ConfigFile 'source = apod' - $script:RotateInterval = 0 - Get-ActiveSource | Should -Be 'apod' - } - It 'returns the first source when rotation is disabled' { - Set-Content $script:ConfigFile 'source = iotd apod bing' - $script:RotateInterval = 0 - Get-ActiveSource | Should -Be 'iotd' - } - It 'returns index 0 at epoch (timestamp=0)' { - Set-Content $script:ConfigFile 'source = iotd apod bing' - $script:RotateInterval = 60 - Mock Get-UnixTimestamp { 0 } - Get-ActiveSource | Should -Be 'iotd' - } - It 'advances to the next source after one full interval' { - # 60 minutes elapsed, interval=60min, 3 sources -> slot 1 -> apod - Set-Content $script:ConfigFile 'source = iotd apod bing' - $script:RotateInterval = 60 - Mock Get-UnixTimestamp { 3600 } - Get-ActiveSource | Should -Be 'apod' - } - It 'wraps around after all sources are used' { - # 180 minutes elapsed, interval=60min, 3 sources -> slot 3 % 3 = 0 -> iotd - Set-Content $script:ConfigFile 'source = iotd apod bing' - $script:RotateInterval = 60 - Mock Get-UnixTimestamp { 10800 } - Get-ActiveSource | Should -Be 'iotd' - } - It 'floors partial intervals' { - # 90 minutes elapsed -> floor(90/60)=1 -> apod - Set-Content $script:ConfigFile 'source = iotd apod bing' - $script:RotateInterval = 60 - Mock Get-UnixTimestamp { 5400 } - Get-ActiveSource | Should -Be 'apod' + Describe 'Get-ActiveSource' { + It 'returns the single configured source' { + Set-Content $script:ConfigFile 'source = apod' + $script:RotateInterval = 0 + Get-ActiveSource | Should -Be 'apod' + } + It 'returns the first source when rotation is disabled' { + Set-Content $script:ConfigFile 'source = iotd apod bing' + $script:RotateInterval = 0 + Get-ActiveSource | Should -Be 'iotd' + } + It 'returns index 0 at epoch (timestamp=0)' { + Set-Content $script:ConfigFile 'source = iotd apod bing' + $script:RotateInterval = 60 + Mock Get-UnixTimestamp { 0 } + Get-ActiveSource | Should -Be 'iotd' + } + It 'advances to the next source after one full interval' { + # 60 minutes elapsed, interval=60min, 3 sources -> slot 1 -> apod + Set-Content $script:ConfigFile 'source = iotd apod bing' + $script:RotateInterval = 60 + Mock Get-UnixTimestamp { 3600 } + Get-ActiveSource | Should -Be 'apod' + } + It 'wraps around after all sources are used' { + # 180 minutes elapsed, interval=60min, 3 sources -> slot 3 % 3 = 0 -> iotd + Set-Content $script:ConfigFile 'source = iotd apod bing' + $script:RotateInterval = 60 + Mock Get-UnixTimestamp { 10800 } + Get-ActiveSource | Should -Be 'iotd' + } + It 'floors partial intervals' { + # 90 minutes elapsed -> floor(90/60)=1 -> apod + Set-Content $script:ConfigFile 'source = iotd apod bing' + $script:RotateInterval = 60 + Mock Get-UnixTimestamp { 5400 } + Get-ActiveSource | Should -Be 'apod' + } } - } - # ------------------------------------------------------------------------- - # Resolve-Iotd - # ------------------------------------------------------------------------- + # ------------------------------------------------------------------------- + # Resolve-Iotd + # ------------------------------------------------------------------------- - Describe 'Resolve-Iotd' { - It 'returns image URL and metadata from RSS feed' { - Mock Invoke-BackdropRequest { - @' + Describe 'Resolve-Iotd' { + It 'returns image URL and metadata from RSS feed' { + Mock Invoke-BackdropRequest { + @' <![CDATA[Test IOTD Image]]> A test NASA image. @@ -307,200 +311,202 @@ user_agent = test-agent/1.0 '@ + } + $r = Resolve-Iotd + $r.ImageUrls | Should -Be @('https://www.nasa.gov/wp-content/uploads/2025/01/test.jpg') + $r.Title | Should -Be 'Test IOTD Image' + $r.Url | Should -Be 'https://www.nasa.gov/image-of-the-day/test/' } - $r = Resolve-Iotd - $r.ImageUrls | Should -Be @('https://www.nasa.gov/wp-content/uploads/2025/01/test.jpg') - $r.Title | Should -Be 'Test IOTD Image' - $r.Url | Should -Be 'https://www.nasa.gov/image-of-the-day/test/' - } - It 'returns null when no enclosure URL is present' { - Mock Invoke-BackdropRequest { - '<![CDATA[No Image Today]]>' + It 'returns null when no enclosure URL is present' { + Mock Invoke-BackdropRequest { + '<![CDATA[No Image Today]]>' + } + Resolve-Iotd | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Iotd } | Should -Throw } - Resolve-Iotd | Should -BeNull - } - It 'throws on network failure' { - Mock Invoke-BackdropRequest { throw 'network error' } - { Resolve-Iotd } | Should -Throw } - } - # ------------------------------------------------------------------------- - # Resolve-Apod - # ------------------------------------------------------------------------- + # ------------------------------------------------------------------------- + # Resolve-Apod + # ------------------------------------------------------------------------- - Describe 'Resolve-Apod' { - It 'returns image URL and metadata from HTML page' { - Mock Invoke-BackdropRequest { - @' + Describe 'Resolve-Apod' { + It 'returns image URL and metadata from HTML page' { + Mock Invoke-BackdropRequest { + @'

Starry Night image Explanation: A wonderful view of stars. '@ + } + $r = Resolve-Apod + $r.ImageUrls | Should -Be @('https://apod.nasa.gov/apod/image/2025/starry_night.jpg') + $r.Title | Should -Be 'Starry Night' + } + It 'returns null when no image link is found' { + Mock Invoke-BackdropRequest { 'No image today.' } + Resolve-Apod | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Apod } | Should -Throw } - $r = Resolve-Apod - $r.ImageUrls | Should -Be @('https://apod.nasa.gov/apod/image/2025/starry_night.jpg') - $r.Title | Should -Be 'Starry Night' - } - It 'returns null when no image link is found' { - Mock Invoke-BackdropRequest { 'No image today.' } - Resolve-Apod | Should -BeNull - } - It 'throws on network failure' { - Mock Invoke-BackdropRequest { throw 'network error' } - { Resolve-Apod } | Should -Throw } - } - # ------------------------------------------------------------------------- - # Resolve-Bing - # ------------------------------------------------------------------------- + # ------------------------------------------------------------------------- + # Resolve-Bing + # ------------------------------------------------------------------------- - Describe 'Resolve-Bing' { - It 'returns 4K and fallback URLs with metadata' { - Mock Invoke-BackdropRequest { - '{"images":[{"urlbase":"/th/id/OHR.TestImage","url":"/th/id/OHR.TestImage_1920x1080.jpg","title":"Test Bing Image","copyright":"Test copyright 2025"}]}' + Describe 'Resolve-Bing' { + It 'returns 4K and fallback URLs with metadata' { + Mock Invoke-BackdropRequest { + '{"images":[{"urlbase":"/th/id/OHR.TestImage","url":"/th/id/OHR.TestImage_1920x1080.jpg","title":"Test Bing Image","copyright":"Test copyright 2025"}]}' + } + $r = Resolve-Bing + $r.ImageUrls[0] | Should -Be 'https://www.bing.com/th/id/OHR.TestImage_UHD.jpg' + $r.ImageUrls[1] | Should -Be 'https://www.bing.com/th/id/OHR.TestImage_1920x1080.jpg' + $r.Title | Should -Be 'Test Bing Image' + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Bing } | Should -Throw } - $r = Resolve-Bing - $r.ImageUrls[0] | Should -Be 'https://www.bing.com/th/id/OHR.TestImage_UHD.jpg' - $r.ImageUrls[1] | Should -Be 'https://www.bing.com/th/id/OHR.TestImage_1920x1080.jpg' - $r.Title | Should -Be 'Test Bing Image' - } - It 'throws on network failure' { - Mock Invoke-BackdropRequest { throw 'network error' } - { Resolve-Bing } | Should -Throw } - } - # ------------------------------------------------------------------------- - # Resolve-Eo - # ------------------------------------------------------------------------- + # ------------------------------------------------------------------------- + # Resolve-Eo + # ------------------------------------------------------------------------- - Describe 'Resolve-Eo' { - It 'returns 4K and base URLs with metadata from RSS feed' { - Mock Invoke-BackdropRequest { - @' + Describe 'Resolve-Eo' { + It 'returns 4K and base URLs with metadata from RSS feed' { + Mock Invoke-BackdropRequest { + @' <![CDATA[Earth at Night]]> https://earthobservatory.nasa.gov/images/12345/earth-at-night

https://assets.science.nasa.gov/dynamicimage/eo/2025/01/photo.jpg

'@ + } + $r = Resolve-Eo + $r.ImageUrls[0] | Should -Be 'https://assets.science.nasa.gov/dynamicimage/eo/2025/01/photo.jpg?w=3840' + $r.ImageUrls[1] | Should -Be 'https://assets.science.nasa.gov/dynamicimage/eo/2025/01/photo.jpg' + $r.Title | Should -Be 'Earth at Night' } - $r = Resolve-Eo - $r.ImageUrls[0] | Should -Be 'https://assets.science.nasa.gov/dynamicimage/eo/2025/01/photo.jpg?w=3840' - $r.ImageUrls[1] | Should -Be 'https://assets.science.nasa.gov/dynamicimage/eo/2025/01/photo.jpg' - $r.Title | Should -Be 'Earth at Night' - } - It 'returns null when no asset URL found' { - Mock Invoke-BackdropRequest { - '<![CDATA[No Image]]>' + It 'returns null when no asset URL found' { + Mock Invoke-BackdropRequest { + '<![CDATA[No Image]]>' + } + Resolve-Eo | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Eo } | Should -Throw } - Resolve-Eo | Should -BeNull - } - It 'throws on network failure' { - Mock Invoke-BackdropRequest { throw 'network error' } - { Resolve-Eo } | Should -Throw } - } - # ------------------------------------------------------------------------- - # Resolve-Natgeo - # ------------------------------------------------------------------------- + # ------------------------------------------------------------------------- + # Resolve-Natgeo + # ------------------------------------------------------------------------- - Describe 'Resolve-Natgeo' { - It 'returns high-res and base URLs from og:image' { - Mock Invoke-BackdropRequest { - '' + Describe 'Resolve-Natgeo' { + It 'returns high-res and base URLs from og:image' { + Mock Invoke-BackdropRequest { + '' + } + $r = Resolve-Natgeo + $r.ImageUrls[0] | Should -Be 'https://i.natgeofe.com/n/abc123/photo.jpg?w=5120' + $r.ImageUrls[1] | Should -Be 'https://i.natgeofe.com/n/abc123/photo.jpg' } - $r = Resolve-Natgeo - $r.ImageUrls[0] | Should -Be 'https://i.natgeofe.com/n/abc123/photo.jpg?w=5120' - $r.ImageUrls[1] | Should -Be 'https://i.natgeofe.com/n/abc123/photo.jpg' - } - It 'strips the site suffix from og:title' { - Mock Invoke-BackdropRequest { - @' + It 'strips the site suffix from og:title' { + Mock Invoke-BackdropRequest { + @' '@ + } + (Resolve-Natgeo).Title | Should -Be 'Forever in Motion' } - (Resolve-Natgeo).Title | Should -Be 'Forever in Motion' - } - It 'returns null when no natgeofe og:image is found' { - Mock Invoke-BackdropRequest { - '' + It 'returns null when no natgeofe og:image is found' { + Mock Invoke-BackdropRequest { + '' + } + Resolve-Natgeo | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Natgeo } | Should -Throw } - Resolve-Natgeo | Should -BeNull - } - It 'throws on network failure' { - Mock Invoke-BackdropRequest { throw 'network error' } - { Resolve-Natgeo } | Should -Throw } - } - - # ------------------------------------------------------------------------- - # Resolve-Earth - # ------------------------------------------------------------------------- - - Describe 'Resolve-Earth' { - It 'returns URL and metadata from the article page' { - Mock Invoke-BackdropRequest { - param($Uri) - if ($Uri -match 'gallery') { - return '' - } - @' + + # ------------------------------------------------------------------------- + # Resolve-Earth + # ------------------------------------------------------------------------- + + Describe 'Resolve-Earth' { + It 'returns URL and metadata from the article page' { + Mock Invoke-BackdropRequest { + param($Uri) + if ($Uri -match 'gallery') { + return '' + } + @' "https://cff2.earth.com/uploads/2025/10/01/photo.jpg" '@ + } + $r = Resolve-Earth + $r.ImageUrls | Should -Be @('https://cff2.earth.com/uploads/2025/10/01/photo.jpg') + $r.Title | Should -Be 'Test Title' + } + It 'returns null when no article link is found' { + Mock Invoke-BackdropRequest { 'no image here' } + Resolve-Earth | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Earth } | Should -Throw } - $r = Resolve-Earth - $r.ImageUrls | Should -Be @('https://cff2.earth.com/uploads/2025/10/01/photo.jpg') - $r.Title | Should -Be 'Test Title' - } - It 'returns null when no article link is found' { - Mock Invoke-BackdropRequest { 'no image here' } - Resolve-Earth | Should -BeNull - } - It 'throws on network failure' { - Mock Invoke-BackdropRequest { throw 'network error' } - { Resolve-Earth } | Should -Throw } - } - - # ------------------------------------------------------------------------- - # Resolve-Wmc - # ------------------------------------------------------------------------- - - Describe 'Resolve-Wmc' { - It 'returns thumbnail and base URLs with metadata' { - Mock Invoke-BackdropRequest { - param($Uri) - if ($Uri -match 'imageinfo') { - return '{"query":{"pages":{"-1":{"imageinfo":[{"thumburl":"https://upload.wikimedia.org/thumb/photo.jpg","url":"https://upload.wikimedia.org/photo.jpg"}]}}}}' - } - if ($Uri -match '\(en\)') { - return '{"expandtemplates":{"wikitext":"A beautiful photograph of the day."}}' + + # ------------------------------------------------------------------------- + # Resolve-Wmc + # ------------------------------------------------------------------------- + + Describe 'Resolve-Wmc' { + It 'returns thumbnail and base URLs with metadata' { + Mock Invoke-BackdropRequest { + param($Uri) + if ($Uri -match 'imageinfo') { + return '{"query":{"pages":{"-1":{"imageinfo":[{"thumburl":"https://upload.wikimedia.org/thumb/photo.jpg","url":"https://upload.wikimedia.org/photo.jpg"}]}}}}' + } + if ($Uri -match '\(en\)') { + return '{"expandtemplates":{"wikitext":"A beautiful photograph of the day."}}' + } + return '{"expandtemplates":{"wikitext":"File:Test Photo.jpg"}}' } - return '{"expandtemplates":{"wikitext":"File:Test Photo.jpg"}}' + $r = Resolve-Wmc + $r.ImageUrls[0] | Should -Be 'https://upload.wikimedia.org/thumb/photo.jpg' + $r.ImageUrls[1] | Should -Be 'https://upload.wikimedia.org/photo.jpg' + $r.Title | Should -Be 'Test Photo' + } + It 'returns null when no file is found' { + Mock Invoke-BackdropRequest { '{"expandtemplates":{"wikitext":""}}' } + Resolve-Wmc | Should -BeNull + } + It 'throws on network failure' { + Mock Invoke-BackdropRequest { throw 'network error' } + { Resolve-Wmc } | Should -Throw } - $r = Resolve-Wmc - $r.ImageUrls[0] | Should -Be 'https://upload.wikimedia.org/thumb/photo.jpg' - $r.ImageUrls[1] | Should -Be 'https://upload.wikimedia.org/photo.jpg' - $r.Title | Should -Be 'Test Photo' - } - It 'returns null when no file is found' { - Mock Invoke-BackdropRequest { '{"expandtemplates":{"wikitext":""}}' } - Resolve-Wmc | Should -BeNull - } - It 'throws on network failure' { - Mock Invoke-BackdropRequest { throw 'network error' } - { Resolve-Wmc } | Should -Throw } - } -} + } # Describe 'backdrop' + +} # InModuleScope backdrop From 07a3396ce751845dfde1c3ee649e177a6b056401 Mon Sep 17 00:00:00 2001 From: Andrew Ensley Date: Sat, 4 Jul 2026 13:00:11 -0500 Subject: [PATCH 10/10] docs: :memo: update contributing docs and test ci commands --- .github/CONTRIBUTING.md | 40 ++++++++++++++++++++++++++++++++++---- .github/workflows/test.yml | 23 +++++----------------- package.json | 13 ++++++++++--- 3 files changed, 51 insertions(+), 25 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index faeac03..8ecd44b 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -21,10 +21,10 @@ npm install This registers the git hooks via `simple-git-hooks`: -- **pre-commit** - runs Prettier, shfmt, ShellCheck, and the BATS test suite +- **pre-commit** - runs Prettier, shfmt, ShellCheck, and BATS against the bash implementation, and PSScriptAnalyzer and Pester against the PowerShell implementation - **prepare-commit-msg** - launches an interactive conventional commit prompt (`czg`) -You also need [**shellcheck**](https://github.com/koalaman/shellcheck) and [**shfmt**](https://github.com/mvdan/sh) installed locally for the hooks and `npm run lint` / `npm test` to work: +You also need [**shellcheck**](https://github.com/koalaman/shellcheck) and [**shfmt**](https://github.com/mvdan/sh) installed locally for `npm run lint:sh` / `npm run test:sh` to work: ```bash # Ubuntu/Debian @@ -34,7 +34,13 @@ sudo apt install shellcheck shfmt brew install shellcheck shfmt ``` -> **Note:** shfmt and ShellCheck only apply to `backdrop.sh`. The PowerShell module (`backdrop.psm1`) is not checked by the pre-commit hook. +For the PowerShell side, you need [**PowerShell 7+**](https://github.com/PowerShell/PowerShell) (`pwsh`) installed. Then install PSScriptAnalyzer and Pester: + +```bash +npm run install:ps +``` + +This is required for `npm run lint:ps` / `npm run test:ps` to work. Since the pre-commit hook runs `npm run lint` and `npm run test` (which cover both implementations), it won't pass without these installed. ## Commits @@ -46,12 +52,24 @@ npm run commit ## Testing -To run the full test suite (ShellCheck + BATS): +To run the full test suite (ShellCheck + BATS, plus Pester for PowerShell): ```bash npm test ``` +To run just one implementation's tests: + +```bash +npm run test:sh +``` + +or + +```bash +npm run test:ps +``` + Tests live in `test/backdrop.bats` and cover the pure and file-I/O functions in `src/backdrop.sh`; things like config read/write, source validation, image dimension detection, wallpaper option selection, metadata read/write, version comparison, and source rotation. Source resolver functions are tested using a stub `curl` script injected via `PATH`. The `_rotation_index` helper takes a unix timestamp as a parameter rather than calling `date`, so rotation tests run without needing to stub `date`; `get_active_source` integration tests stub `date` via `PATH` in the same way as `curl`. The PowerShell module (`src/backdrop.psm1`) is tested with [Pester 5](https://pester.dev/). Tests live in `test/backdrop.tests.ps1` and run via `Invoke-Pester test/backdrop.tests.ps1`. The `Get-UnixTimestamp` helper is extracted so rotation tests can mock it without stubbing `[DateTimeOffset]::UtcNow` directly. @@ -72,8 +90,22 @@ To check formatting: npm run lint ``` +To check just one implementation: + +```bash +npm run lint:sh +``` + +or + +```bash +npm run lint:ps +``` + To auto-fix formatting: ```bash npm run lint:fix ``` + +`lint:ps:fix` uses `Invoke-ScriptAnalyzer -Fix`, which only corrects rules that support automatic fixes (mostly formatting/whitespace). Violations like `PSAvoidUsingCmdletAliases` still need to be fixed by hand; re-run `npm run lint:ps` afterward to see what's left. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c18b80e..52948dc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,7 @@ jobs: run: go install mvdan.cc/sh/v3/cmd/shfmt@latest - name: Lint - run: npm run lint + run: npm run lint:sh - name: Install ShellCheck v0.11.0 run: | @@ -33,7 +33,7 @@ jobs: sudo mv shellcheck-v0.11.0/shellcheck /usr/local/bin/shellcheck - name: Test - run: npm test + run: npm run test:sh test-ps: name: test (powershell) @@ -42,23 +42,10 @@ jobs: - uses: actions/checkout@v7 - name: Install PSScriptAnalyzer and Pester - shell: pwsh - run: | - Install-Module -Name PSScriptAnalyzer -Scope CurrentUser -Force -ErrorAction Stop - Install-Module -Name Pester -Scope CurrentUser -Force -MinimumVersion 5.0 -ErrorAction Stop + run: npm run install:ps - name: Lint - shell: pwsh - run: | - $exclude = @('PSAvoidUsingWriteHost', 'PSUseShouldProcessForStateChangingFunctions') - $results = 'src/backdrop.psm1', 'src/install.ps1' | ForEach-Object { Invoke-ScriptAnalyzer -Path $_ -Severity @('Error', 'Warning') -ExcludeRule $exclude } - if ($results) { - $results | Format-Table RuleName, Severity, Line, Message -AutoSize - exit 1 - } + run: npm run lint:ps - name: Test - shell: pwsh - run: | - $result = Invoke-Pester test/backdrop.tests.ps1 -Output Detailed -PassThru - if ($result.FailedCount -gt 0) { exit 1 } + run: npm run test:ps diff --git a/package.json b/package.json index 99c31a9..a8f5a05 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,16 @@ "license": "MIT", "scripts": { "commit": "czg", - "test": "shellcheck src/backdrop.sh src/install.sh test/backdrop.bats && bats test/", - "lint": "prettier --check . && ~/go/bin/shfmt -d -i 2 -ci src/backdrop.sh src/install.sh test/backdrop.bats", - "lint:fix": "prettier --write . && ~/go/bin/shfmt -w -i 2 -ci src/backdrop.sh src/install.sh test/backdrop.bats", + "test": "npm run test:sh && npm run test:ps", + "test:sh": "shellcheck src/backdrop.sh src/install.sh test/backdrop.bats && bats test/", + "test:ps": "pwsh -Command \"\\$result = Invoke-Pester test/backdrop.tests.ps1 -Output Detailed -PassThru; if (\\$result.FailedCount -gt 0) { exit 1 }\"", + "lint": "npm run lint:sh && npm run lint:ps", + "lint:sh": "prettier --check . && ~/go/bin/shfmt -d -i 2 -ci src/backdrop.sh src/install.sh test/backdrop.bats", + "lint:ps": "pwsh -Command \"\\$exclude = @('PSAvoidUsingWriteHost', 'PSUseShouldProcessForStateChangingFunctions'); \\$results = 'src/backdrop.psm1', 'src/install.ps1' | ForEach-Object { Invoke-ScriptAnalyzer -Path \\$_ -Severity @('Error', 'Warning') -ExcludeRule \\$exclude }; if (\\$results) { \\$results | Format-Table RuleName, Severity, Line, Message -AutoSize; exit 1 }\"", + "lint:fix": "npm run lint:sh:fix && npm run lint:ps:fix", + "lint:sh:fix": "prettier --write . && ~/go/bin/shfmt -w -i 2 -ci src/backdrop.sh src/install.sh test/backdrop.bats", + "lint:ps:fix": "pwsh -Command \"\\$exclude = @('PSAvoidUsingWriteHost', 'PSUseShouldProcessForStateChangingFunctions'); 'src/backdrop.psm1', 'src/install.ps1' | ForEach-Object { Invoke-ScriptAnalyzer -Path \\$_ -Severity @('Error', 'Warning') -ExcludeRule \\$exclude -Fix }\"", + "install:ps": "pwsh -Command \"Install-Module -Name PSScriptAnalyzer -Scope CurrentUser -Force -ErrorAction Stop; Install-Module -Name Pester -Scope CurrentUser -Force -MinimumVersion 5.0 -ErrorAction Stop\"", "prepare": "npx simple-git-hooks", "update": "npx npm-check-updates -u && npm update" },