# Scoped Translation Exceptions — request

**Audience:** AI coding agents first.
**Status:** DONE
**Task ID:** 8, 13
**Source request:** Make “do not translate” settings global and per-language; fully redesign Settings → Exceptions from supplied master-detail design. Include search, filter, pagination, added-on date, inline edit, import, and export. On 2026-08-14, the owner explicitly requested deployment to dev1.
**Goal:** Make "do not translate" exceptions scopable to Global or one target language, rebuild the Settings → Exceptions tab as a master-detail page, and deploy the landed plugin to dev1.
**Preserved WIP:** Feature landed on `origin/master` as `3f7e6436e`; dev1 release metadata and deployment receipt landed as `b9cc1c6a2`; unrelated shared-checkout work remained untouched.
**Constraints:** Preserve all unrelated owner WIP. Do not touch `ExceptionSync.php`, api.press.zone, or abandoned translation plugins. Do not run additional functional or browser smoke tests against dev1; use only the canonical deployment tool's required installation and byte-integrity checks. Do not touch dev3 or production. Any delegated reviewer or worker MUST work directly and MUST NOT delegate to another subagent, invoke a skill, use external execution, or call another model.

**Current receipt (2026-08-14):** Scoped storage, fail-closed legacy migration, target-language payload filtering, ID-based REST CRUD/import, master-detail admin UI, focused PHPUnit coverage, and comprehensive Playwright journey are complete. Clean deterministic gates: PHPUnit 41 tests/183 assertions; PHPStan; task-owned content PHPCS; admin Vitest 36 files/574 tests; production Webpack build; `git diff --check`; target-safety 6/6. Chromium and Firefox each passed the accepted journey on the isolated remote WordPress stack with retries disabled, zero skips, 375/768/1280 responsive captures, hidden mobile navigation assertion, and no horizontal overflow. The guarded repository lander merged the feature to `origin/master` as `3f7e6436e`. Local activation verification confirmed the scoped API, REST route, and built assets without changing unrelated shared-checkout WIP. After explicit owner authorization, the canonical distribution builder created `international-press-zone-0.9.54.zip` from clean source commit `2f9b509bbd62d3e0c3f7fe150bc0527a32784a15` with 193 runtime files and runtime SHA-256 `cde147af90642314bd467265c2c46afd1e3e5dec44348207a25b6da23c291237`. The canonical dev1 deployer replaced active version 0.9.53 without deactivation, confirmed version 0.9.54 active, and byte-verified every declared admin asset against the uploaded archive. No additional functional or browser smoke test ran against dev1; dev3 and production were untouched.

**Acceptance delta:** PASS — dev1 runs active version 0.9.54 and all canonical deployment integrity checks passed.

**Next executable action:** None.

**Spec:** `docs/specs/2026-08-13-scoped-translation-exceptions-design.md` — authoritative for every contract below; read it first.

**Context:** Plugin `plugins/international-press-zone`. Exceptions today are a flat JSON array of `{text, match_type}` in option `presszone_international_exceptions`, managed by `Translation\Settings`, exposed via `API\ExceptionsController` (`international-press-zone/v1/exceptions`), rendered by `admin/src/components/ExceptionsTab.js` (mounted at `admin/src/pages/settings.js:228`), and attached to every translation request by `Translation\ExceptionPayload::rules()`. The backend (api.press.zone) stores nothing — it applies whatever list each request carries. `Translation\ExceptionSync` is dead code; do not touch it.

**Files:**
- Modify `plugins/international-press-zone/includes/Translation/Settings.php` — scoped entry storage + migrate-on-first-read
- Modify `plugins/international-press-zone/includes/Translation/ExceptionPayload.php` — target-language filtering + merged dedupe
- Modify `plugins/international-press-zone/includes/API/ExceptionsController.php` — scope/id-aware routes
- Modify call sites to pass the request's target language to `ExceptionPayload::rules()`:
  `includes/Translation/JobSender.php:153`, `includes/Translation/BulkActions.php:455`, `includes/API/TranslateController.php:786,1285`, `includes/API/StringTranslateController.php:~792`, `includes/API/TranslateJobsController.php:712` (each has a single target language in scope at the call point)
- Rewrite `plugins/international-press-zone/admin/src/components/ExceptionsTab.js` — master-detail UI
- Create SCSS partial(s) under `plugins/international-press-zone/admin/src/styles/` following existing partial conventions (BEM, `ipz-` prefix, CSS variables, ABSOLUTELY no inline CSS) and wire into `main.scss`
- Create Playwright spec under `plugins/international-press-zone/tests/e2e/`
- PHP tests following the plugin's existing test layout

**Contract:**

Stored entry (JSON array in option `presszone_international_exceptions`):
```json
{ "id": "<uuid>", "text": "LAUNCH", "match_type": "exact", "scope": "global", "created_at": "2026-08-13T09:00:00Z" }
```
- `id`: server-generated (`wp_generate_uuid4()` acceptable), collision-safe, **durable** — an id returned by GET must resolve on later PUT/DELETE.
- `scope`: `"global"` or a configured language code (validated against `LanguageManager` + `"global"`).
- `created_at`: ISO-8601 UTC, set on create, immutable. Migrated legacy entries get `created_at: null` (UI renders "—"; never fabricate a date).
- Migration: `Settings::get_exceptions()` normalizes legacy entries (missing id/scope/created_at → generated id, `scope:"global"`, `created_at:null`) and **persists immediately** when anything changed (one-time write). Never regenerate ids per read.
- Caps: total 1000 entries across all scopes (existing `MAX_STORED_EXCEPTIONS`); text ≤ 500 chars. Same `text` allowed in different scopes; duplicate `text` within one scope rejected.

`Translation\Settings` signatures:
- `get_exceptions(?string $scope = null): array`
- `add_exception(string $text, string $match_type, string $scope): bool`
- `update_exception(string $id, array $changes): bool` — `text`/`match_type` only; `scope`/`created_at` immutable
- `remove_exception(string $id): bool` — replaces delete-by-text
- `set_exceptions(array $entries): bool` — unchanged

`Translation\ExceptionPayload`:
- `rules(?Settings $settings = null, ?string $target_language = null): array` — entries with scope `global` OR `$target_language`, deduped by `text` (**language-scoped entry wins over global**), stripped to wire shape `{text, match_type}`. `null` target → global-only. Keep existing sanitation (length, `__TAG_/__EXCPT_` marker regex, `MAX_EXCEPTIONS` cap).

REST (`international-press-zone/v1`, envelope `{success, data, message}` unchanged, permission `manage_options` unchanged, existing sanitation retained):
- `GET /exceptions` → `data.items` = ALL entries, full shape (id/scope/created_at included)
- `POST /exceptions` `{text, match_type, scope}` → 201 + full items; unknown scope → 400; per-scope duplicate → 409
- `PUT /exceptions/{id}` `{text?, match_type?}` → 200 + full items; unknown id → 404; same-scope text collision → 409
- `DELETE /exceptions/{id}` → 200 + full items (replaces body-text DELETE; the SPA is the only client)
- `POST /exceptions/import` `{text, scope}` → newline-separated, added as `exact` into scope, per-scope dedupe, → `{added, skipped, items}`. Cap hit mid-batch: partial add, remainder counted in `skipped`, message states cap reached.
- No export endpoint — export is client-side.

**Behavior (frontend, `ExceptionsTab.js` rewrite):** master-detail layout per spec §5.
- Container loads `GET /exceptions` + `GET /languages` once; state: entries, selectedScope (default `global`), search, typeFilter, page, pageSize; per-scope counts and visible page derived by pure functions.
- Left panel: "Global — All languages" card + one card per configured language (active AND inactive; verify unfiltered `GET /languages` returns inactive too — if not, extend that route, do not add a parallel one), showing name, native name, flag (reuse Languages-page flag rendering), count badge. Keyboard navigable (`aria-pressed` or radiogroup). No "Add Language" action; entries whose scope matches no configured language surface under an "Unknown language" fallback card so they can be deleted.
- Right panel: scope header + contextual note (Global: "applies to every language"; language: "applies only when translating into {language}" — never assert application *ordering*), actions menu (Import…, Export scope). Add form (text + match-type select + Add; Enter submits; Exact/Contains hint below). Table columns: Exception / Matching type badge / Added on / Actions (edit, delete). Client-side case-insensitive substring search, match-type filter (All/Exact/Contains), pagination 10/25/50 (default 10) with "Showing X to Y of Z". Search/filter change resets to page 1. Distinct empty states: no entries vs no matches.
- Inline edit: row swaps to text input + type select + save/cancel → `PUT /exceptions/{id}`.
- Delete with per-row pending state. Import textarea (one per line) into selected scope. Export: client-side `.txt` download (one entry text per line) of the selected scope — no server call.
- Every mutation applies the returned canonical `items` (no optimistic patching); 409 → duplicate-specific toast; other errors → `Toast.error` with server message fallback.
- Bottom help band: existing Exact/Contains content in the mockup's 3-column layout + new note "Global exceptions apply to all languages; per-language exceptions apply only when translating into that language."
- All strings through the existing `__(…, 'international-press-zone')` helper; escaping/a11y per repo standards.

**Out of scope:** api.press.zone backend, `ExceptionSync.php`, language management UI, regex/wildcard matching, scope-moving of entries, match-type round-trip in import/export, option locking/versioning (last-write-wins on concurrent tabs is accepted, pre-existing).

**Acceptance:**
- PHP tests pass covering: legacy migration (durable ids, one-time persist), scoped CRUD, per-scope dedupe + cross-scope same-text allowed, 1000 cap incl. partial import, `rules()` scope filtering (`fr` request carries global+fr and not `he`; language-scoped wins merged dedupe; `null` target → global-only), REST 400/404/409 paths. Run: `composer test` from `plugins/international-press-zone/` (phpunit, tests in `tests/unit/`); `composer phpcs` and `composer phpstan` stay clean.
- Build: `cd plugins/international-press-zone/admin && npm run build` — exits 0, dist updated.
- E2E: new Playwright spec in `tests/e2e/` passes (`npx playwright test` from `tests/e2e/`) covering: flat-legacy list renders under Global, add global + per-language entries with counts updating, search/filter/pagination, inline edit, delete, import into a language scope, export download.
- Release gates in `plugins/international-press-zone/.claude/agents/expert.md` pass.
