Feature: Media cleanup in the SPA: Settings card, purge dialog and Media Duplicates button As a RenewCMS owner or admin I want to turn on automatic duplicate purging and to purge duplicates by hand So that identical images are merged without leaving Settings or the Media library Background: Given the API of media_purge.feature and media_purge_scheduler.feature is live And every call goes through useApi; 401 is global (/login?redirect=&reason=expired); a 403 on a verified account has no local copy and redirects nowhere And the calls are: | call | body or response | | GET /v1/media/purge-settings | {auto_purge_enabled, auto_purge_interval_days, last_purge_at, last_purge_merged, last_purge_articles_updated, enabled_at, next_purge_at} | | PATCH /v1/media/purge-settings | {"auto_purge_enabled": bool} or {"auto_purge_interval_days": int}, answers the settings above | | GET /v1/media/purge-preview | {groups, copies, articles, list: [{keep_id, filename, url, used_in, merged}], more, fingerprint} | | POST /v1/media/purge | {"fingerprint": ""}, answers {groups_total, groups_purged, merged, articles_updated, failed_groups} | And errors are plain text; 409 carries "The library changed, so nothing was purged. Check the new summary and try again." (stale fingerprint) or "A purge is already running." (busy) And "owner or admin" is session.role in ('owner', 'admin'); only those two roles exist, so any other role is a mocked value in tests And /settings is the verified route of settings_page.feature; the Media cleanup card is its third card, below Version and Updates, 20 px under the previous card, in a page at most 720 px wide And the card is --ui-bg with a 1 px --ui-border, 20 px padding (16 px on mobile), 16 px gap, as drawn in docs/design/source/AppFrame.dc.html And the components are Nuxt UI v4 only (UCard, USwitch, UInputNumber, UFormField, UAlert, UButton, UModal, USkeleton, useToast) and design tokens only And PurgeDuplicatesDialog is one component shared by the card and the Media Duplicates tab: props open, preview, status (loading, ready, purging, nothing, error, partial, conflict); it emits purged with the counts; the parent shows the toast And plural rules: "1 group" / "N groups", "1 copy" / "N copies", "1 article" / "N articles" And skeleton placeholders show only after 150 ms of loading And a Partial result is a 200 whose failed_groups is above 0 (the board's 207 is only a label) And dates are shown in the browser's timezone, from last_purge_at and next_purge_at in fixtures And no state here is clock-sensitive Rule: The Media cleanup card on /settings Scenario: Default: off, never run Given GET /v1/media/purge-settings resolves 200 with auto_purge_enabled false, auto_purge_interval_days 30, last_purge_at null, next_purge_at null And the user is the owner When the user opens /settings Then the card titled "Media cleanup" reads "Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one." And "Automatic purge" with the helper "Runs in the background. Off by default." and a switch that is off And no "Every" row and no "Next purge" line And the line "Never run" is shown And a divider, then "Purge now" with the helper "Merge every group of duplicates that has an image in use, right away." and an outline button "Purge duplicates" with the merge icon And the card has no Save button Scenario: On: 30 days, last run Given GET resolves auto_purge_enabled true, auto_purge_interval_days 30, last_purge_at "2026-06-14T09:00:00Z", last_purge_merged 3, last_purge_articles_updated 5, next_purge_at "2026-07-14T09:00:00Z" When the user opens /settings Then the switch is on And "Every" with the helper "Between 1 and 365 days." and a number input showing 30 with the suffix "days" And the lines "Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated" and "Next purge: 14 Jul 2026" Scenario Outline: The Last purge line is pluralised Given last_purge_at is "2026-06-14T09:00:00Z", last_purge_merged and last_purge_articles_updated When the card renders Then the line reads "" Examples: | merged | articles | line | | 3 | 5 | Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated | | 1 | 1 | Last purge: 14 Jun 2026 · 1 copy merged · 1 article updated | | 0 | 0 | Last purge: 14 Jun 2026 · 0 copies merged · 0 articles updated | Scenario: Turning the switch on saves at once Given the Default state When the user turns the switch on Then PATCH /v1/media/purge-settings is sent with exactly {"auto_purge_enabled": true} And while it is pending "Saving…" with loader-circle sits in the card header and the controls stay usable When it resolves 200 with auto_purge_interval_days 30 and next_purge_at "2026-11-04T09:00:00Z" Then "Saving…" is replaced by "Saved" with a check, in the success colour, in the card header And the "Every" row shows 30 and "Next purge: 4 Nov 2026" is shown And "Saved" is announced politely and disappears after 2 s Scenario: Turning the switch off saves at once and hides the schedule Given the On state When the user turns the switch off Then PATCH is sent with exactly {"auto_purge_enabled": false} And on 200 with next_purge_at null the "Every" row and the "Next purge" line are hidden And the "Last purge" line is unchanged Scenario Outline: The days field saves on blur or Enter, once Given the On state and the days field holds 30 When the user types "" and Then PATCH is sent exactly once with {"auto_purge_interval_days": } And on 200 "Saved" shows in the header and "Next purge" follows the response's next_purge_at Examples: | typed | commit | sent | | 45 | leaves the field | 45 | | 45 | presses Enter | 45 | | 1 | presses Enter | 1 | | 365 | leaves the field | 365 | Scenario: Enter then the resulting blur is one request Given the On state When the user types 45, presses Enter and the field then loses focus Then exactly one PATCH is sent Scenario: An unchanged value sends nothing Given the On state and the days field holds 30 When the user focuses the field and leaves it without changing it Then no PATCH is sent and no "Saving…" shows Scenario Outline: Validation error: nothing is sent Given the On state When the user types "" and leaves the field or presses Enter Then no PATCH is sent And the field keeps "" and gets the error border And under the field, with circle-alert, the message "Enter a whole number of days between 1 and 365." shows And no "Saving…" and no "Saved" show Examples: | typed | | | | 0 | | 366 | | 400 | | 1.5 | Scenario: A validation error clears when the value becomes valid Given the Validation error state with "400" When the user types 60 and presses Enter Then the message disappears and PATCH is sent with {"auto_purge_interval_days": 60} Scenario Outline: Save failed: the control reverts Given the On state with 30 days When the user and PATCH answers Then the control returns to its previous value () And "Couldn't save. Try again in a moment." shows under the field (under the "Automatic purge" row for the switch) And no "Saved" shows Examples: | action | failure | previous | | types 45 and presses Enter | 500 | 30 | | types 45 and presses Enter | a network error | 30 | | turns the switch off | 500 | on | Scenario: The save-failed message goes away on the next successful save Given the Save failed state When the user types 45 and presses Enter and PATCH answers 200 Then the failure message is gone and "Saved" shows Scenario: Role changed (403 on PATCH) Given the On state for an owner who has been changed to a role that is not owner or admin When the user turns the switch off and PATCH answers 403 Then the switch returns to on, with no message, no toast and no redirect And the session is refreshed and the card switches to the Read-only state Scenario: Read-only (non-admin) Given the session role is not owner or admin When the user opens /settings Then GET /v1/media/purge-settings is not sent And the switch is off, the days input shows 30 and the line "Never run" shows, as placeholders And the switch and the days input are disabled And an info alert reads "Only owners and admins can change this." And the "Purge now" divider, row and button are not rendered And no PATCH can be sent from the card Scenario: Loading skeleton after 150 ms Given GET /v1/media/purge-settings has not resolved When the user opens /settings Then during the first 150 ms the card shows no skeleton And after 150 ms a skeleton replaces the card body (title bar, two text bars, switch row, schedule bar, divider, button row), the card is aria-busy="true" and the button is disabled When it resolves Then the Default or On state replaces it and aria-busy is gone Scenario Outline: The settings could not be loaded Given GET /v1/media/purge-settings When the user opens /settings Then the card shows an error alert "Couldn't reach RenewCMS. Check your connection and try again." with "Try again" (rotate-cw) And the Version and Updates cards are unaffected When the user activates "Try again" Then the loading state shows and GET is sent again Examples: | failure | | answers 500 | | fails (network) | Scenario: Offline Given navigator.onLine is false When the user opens /settings Then a banner reads "You're offline. Changing automatic purge and purging duplicates need a connection." And the switch, the days input and "Purge duplicates" are disabled (40 % opacity) And nothing is sent when they are activated Scenario: 401 is global Given any call of the card answers 401 Then the user goes to /login?redirect=/settings&reason=expired and the card shows no copy of its own Rule: PurgeDuplicatesDialog opened from "Purge duplicates" in the card Background: Given an owner on /settings and the card in the On state Scenario: Opening shows the preview loading When the user activates "Purge duplicates" Then GET /v1/media/purge-preview is sent And a dialog opens (UModal, sm:max-w-md, a bottom sheet on mobile) titled "Purge duplicates?" with the description "Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone." And focus is on the first button, "Cancel" And after 150 ms skeleton rows replace the summary and list, and the confirm button, labelled "Purge duplicates", is disabled Scenario: Confirm: summary, up to 5 groups and the link Given the preview is groups 4, copies 9, articles 12, a list of 4 groups, more 0 When it resolves Then the summary reads "4 groups · 9 copies will be deleted · 12 articles will be updated" And a bordered list shows a row per group: a 40 x 30 thumbnail of its url, the filename, "Kept · used in N articles" and, right-aligned, "N copies merged" And no "and N more" line And the link "Review in Duplicates" (arrow-right) points to /media?filter=duplicates And the buttons are "Cancel" and the danger confirm "Purge 4 groups" Scenario Outline: The summary and rows are pluralised Given the preview is groups , copies , articles with one group kept in article(s) and merged Then the summary reads "" And the confirm reads "" And the row reads "Kept · used in " and "" Examples: | g | c | a | u | m | summary | confirm | ku | km | | 4 | 9 | 12 | 4 | 2 | 4 groups · 9 copies will be deleted · 12 articles will be updated | Purge 4 groups | 4 articles | 2 copies merged | | 1 | 1 | 2 | 2 | 1 | 1 group · 1 copy will be deleted · 2 articles will be updated | Purge 1 group | 2 articles | 1 copy merged | | 1 | 1 | 1 | 1 | 1 | 1 group · 1 copy will be deleted · 1 article will be updated | Purge 1 group | 1 article | 1 copy merged | Scenario: Confirm: many groups Given the preview is groups 8, copies 15, articles 24, a list of 5 groups and more 3 When it resolves Then exactly 5 rows show, followed by "and 3 more groups" And the confirm reads "Purge 8 groups" Scenario: "and 1 more group" is singular Given the preview has more 1 Then the line reads "and 1 more group" Scenario: Nothing to purge Given the preview is groups 0 with an empty list When it resolves Then the dialog shows an info icon, "Nothing to purge" and "No duplicate is used in an article, so there is nothing to merge." And the only button is "Close"; there is no confirm, no summary and no link And "Close" closes the dialog and sends nothing else Scenario: The preview could not be loaded Given GET /v1/media/purge-preview answers 500 or fails Then the dialog shows the error notice "Couldn't purge the duplicates. Nothing was changed. Try again in a moment." And the confirm is disabled and offers no purge until the preview loads again Scenario: Cancelling changes nothing Given the Confirm state When the user activates "Cancel", presses Escape or clicks the overlay Then the dialog closes, no POST is sent and focus returns to "Purge duplicates" Scenario: The fingerprint goes from the preview to the purge Given the preview carries fingerprint "ab12" When the user activates "Purge 4 groups" Then POST /v1/media/purge is sent once with exactly {"fingerprint": "ab12"} Scenario: In progress: the dialog cannot be dismissed Given POST /v1/media/purge has not resolved Then the confirm reads "Purging…" with loader-circle and is disabled And "Cancel" is disabled And Escape, a click on the overlay and a swipe-down do nothing Scenario: Success: the dialog closes, a toast shows and Last purge updates in place Given the preview was groups 4, copies 9, articles 12 When POST answers 200 with {groups_total 4, groups_purged 4, merged 9, articles_updated 12, failed_groups 0} Then the dialog closes, the dialog emits purged with merged 9 and articles_updated 12 And a toast with circle-check shows for 5 s reading "Duplicates purged" / "9 copies merged. 12 articles updated." And GET /v1/media/purge-settings is sent again And with last_purge_at "2026-10-05T09:00:00Z", last_purge_merged 9, last_purge_articles_updated 12 the line reads "Last purge: 5 Oct 2026 · 9 copies merged · 12 articles updated", without a page reload And focus returns to "Purge duplicates" Scenario: Success with automatic purge on moves Next purge forward Given automatic purge is on with interval 30 When a purge succeeds and the refreshed settings carry next_purge_at "2026-11-04T09:00:00Z" Then "Next purge: 4 Nov 2026" shows, and with automatic purge off no "Next purge" line shows Scenario: The toast is pluralised When POST answers 200 with merged 1, articles_updated 1 and failed_groups 0 Then the toast reads "Duplicates purged" / "1 copy merged. 1 article updated." Scenario Outline: Error: the dialog stays open and confirm retries Given the Confirm state with fingerprint "ab12" When POST answers Then an error notice (soft alert, circle-x) reads "Couldn't purge the duplicates. Nothing was changed. Try again in a moment." above the summary And the dialog stays open with the summary and list unchanged, and "Cancel" and "Purge 4 groups" enabled When the user activates "Purge 4 groups" again Then POST is sent again with {"fingerprint": "ab12"} And no toast shows and "Last purge" is unchanged Examples: | failure | | 500 | | a network error | Scenario: Partial: some groups failed Given the preview was 4 groups When POST answers 200 with {groups_total 4, groups_purged 3, merged 8, articles_updated 10, failed_groups 1} Then a warning notice (soft alert, triangle-alert) reads "Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish." And GET /v1/media/purge-preview is sent again and the summary and list refresh to the remaining groups And GET /v1/media/purge-settings is sent again so "Last purge" refreshes And the buttons are "Close" and "Retry" (rotate-cw, danger) And no toast shows and the dialog does not close When the user activates "Retry" Then POST is sent with the refreshed fingerprint Scenario: Partial that leaves nothing to purge Given a Partial result and the refreshed preview has groups 0 Then the dialog shows "Nothing to purge" with "Close" Scenario: Conflict (409): the library changed Given the Confirm state with fingerprint "ab12" When POST answers 409 with "The library changed, so nothing was purged. Check the new summary and try again." Then a warning notice reads "The library changed, so nothing was purged. Check the new summary and try again." And GET /v1/media/purge-preview is sent again and the summary refreshes (for example 3 groups · 7 copies · 10 articles) And the buttons are "Cancel" and "Purge 3 groups" And no toast shows and "Last purge" is unchanged When the user activates "Purge 3 groups" Then POST is sent with the new fingerprint Scenario: Conflict (409): a purge is already running When POST answers 409 with "A purge is already running." Then a warning notice shows the server's text, the preview refreshes and the dialog stays open Scenario: Offline Given navigator.onLine is false Then "Purge duplicates" in the card is disabled and the dialog cannot be opened Rule: The Duplicates toolbar button on /media Scenario: The button shows on the Duplicates tab for an owner or admin Given the user is owner or admin and /media?filter=duplicates holds at least one duplicate group Then the secondary (outline) button "Purge duplicates" with the merge icon shows after the sort menu and before the view toggle in the toolbar, as MediaFrame.dc.html draws it And on a stacked mobile toolbar it is the last row, full width and 44 px high, under the sort and view row Scenario Outline: The button is hidden Given Then the toolbar has no "Purge duplicates" button Examples: | condition | | the filter is All | | the filter is Unused | | the Duplicates tab has no groups ("No duplicates" shows) | | the session role is not owner or admin | Scenario: Offline Given navigator.onLine is false on the Duplicates tab Then the button is disabled, the existing offline banner of Media shows and nothing opens Scenario: The button opens the same dialog When the user activates "Purge duplicates" on the Duplicates tab Then GET /v1/media/purge-preview is sent and PurgeDuplicatesDialog opens in the loading state And every status, notice, button and request of the previous Rule behaves identically Scenario: After a purge from Media the list reloads like after a merge Given the Duplicates tab lists 4 groups When a purge succeeds (200, failed_groups 0) Then the toast "Duplicates purged" / "9 copies merged. 12 articles updated." shows And the list and the tab counts are requested again, as after "Merge into this one" And when no group is left "No duplicates" shows and the button is gone Scenario: Partial from Media reloads the list when the dialog closes When the purge answers 200 with failed_groups 1 and the user activates "Close" Then the list and the tab counts are requested again Scenario: "Review in Duplicates" from Media only closes the dialog Given the dialog is open on /media?filter=duplicates When the user follows "Review in Duplicates" Then the dialog closes and the route is unchanged Scenario: "Merge into this one" is unchanged Given the Duplicates tab lists a group Then each group still shows "Merge into this one" and it still sends POST /v1/media/merge with {keep_id, merge_ids}