# Scoped Translation Exceptions — Design

**PLAN_SLUG:** `scoped-translation-exceptions`
**Date:** 2026-08-13
**Plugin:** `plugins/international-press-zone`
**Source request:** Settings → Exceptions currently holds one flat site-wide "do not translate" list. Owner wants exceptions scoped **globally and per language**, plus a complete UX redesign of the Exceptions tab following a supplied master-detail mockup (scope selector left, exception table right, search/filter/pagination, added-on dates, inline edit, import/export). All mockup features confirmed in scope.

## 1. Goal & Success Criteria

- An exception can be **global** (applies to every translation) or **scoped to one target language** (applies only when translating into that language).
- Redesigned Exceptions tab matches the mockup's structure: left panel = scope selector (Global card + one card per configured language, each with entry count), right panel = the selected scope's exception manager (add form, searchable/filterable/paginated table with match-type badge, added-on date, edit and delete actions, import/export menu), bottom = "How matching works" help in a 3-column layout.
- Existing flat exception lists migrate silently and losslessly to global scope; no owner action needed.
- Every translation request sends `global + target-language` rules merged; behavior for existing users is unchanged until they add per-language entries.
- Release gates from `.claude/agents/expert.md` pass; admin bundle rebuilt.

## 2. Architecture Overview

Storage stays in the existing single WP option (JSON) managed by `Translation\Settings` — Approach 1 from brainstorm (option-based, scoped entries). No custom table: list is capped at 1000 entries, making server-side querying overhead with no benefit. Search/filter/pagination are client-side in the admin SPA.

Data flow (unchanged shape, new filter step):

```
Settings option (scoped entries)
        │
        ├── ExceptionsController (REST, admin SPA CRUD, scope-aware)
        │
        └── ExceptionPayload::rules(settings, target_language)
                → merged global + per-language rules
                → sent with every translation request (backend stores nothing)
```

The old `Translation\ExceptionSync` class is dead code (referenced nowhere in the live tree); it is **out of scope** — do not touch it.

## 3. Data Model (seam)

Stored entry shape (JSON array in option `presszone_international_exceptions`):

```json
{
  "id": "a1b2c3d4",
  "text": "LAUNCH",
  "match_type": "exact",
  "scope": "global",
  "created_at": "2026-08-13T09:00:00Z"
}
```

- `id`: opaque unique string generated server-side on create (stable across edits; delete/edit key). Generation via `wp_generate_uuid4()` or short unique id — implementer's choice, but MUST be collision-safe within the list.
- `scope`: `"global"` or a configured language code (e.g. `"fr"`, `"he"`). Validated against `LanguageManager`'s configured languages on write.
- `match_type`: `"exact" | "contains"` (unchanged semantics).
- `created_at`: ISO-8601 UTC, set on create, immutable.

**Migration (migrate-on-first-read, in `Settings::get_exceptions()`):** any stored entry missing `id`/`scope`/`created_at` is normalized — `scope: "global"`, generated `id`, `created_at: null` (rendered as "—" in UI; never fabricate a date) — and the normalized list is **persisted immediately** when normalization changed anything (one-time write). Ids MUST be durable before the SPA ever sees them: an id returned by `GET /exceptions` must resolve on a later `PUT`/`DELETE` — never regenerate ids per read. Old readers ignore unknown fields, so rollback is safe (two-way door).

**Caps:** existing `MAX_STORED_EXCEPTIONS = 1000` applies to the TOTAL across all scopes. Duplicate rule: same `text` may exist in different scopes; duplicate `text` within one scope is rejected (409).

## 4. Backend Contracts

### 4.1 `Translation\Settings` (extend, same class)

- `get_exceptions(?string $scope = null): array` — all entries, or entries whose scope equals `$scope`. Applies lazy normalization (§3).
- `add_exception(string $text, string $match_type, string $scope): bool` — appends normalized entry with generated id + timestamp.
- `update_exception(string $id, array $changes): bool` — mutates `text` and/or `match_type` of the entry with that id; `scope`/`created_at` immutable.
- `remove_exception(string $id): bool` — delete by id (replaces delete-by-text).
- `set_exceptions(array $entries): bool` — unchanged (import path).
- Uninstall cleanup list already includes the option — no change.

### 4.2 `Translation\ExceptionPayload`

- `rules(?Settings $settings = null, ?string $target_language = null): array` — returns entries with `scope === 'global'` OR `scope === $target_language`, stripped to the wire shape `{text, match_type}` (backend contract unchanged — the backend never sees scopes). `null` target → global-only. Existing sanitation (length, marker regex, MAX_EXCEPTIONS cap) retained.
- **Merged-duplicate rule:** the same `text` may exist in both global and the target scope; the merged wire list is deduped by `text` with the **language-scoped entry winning** over the global one (its match_type applies).
- **Every call site passes the request's target language:** `JobSender` (~:153), `BulkActions` (~:455), `TranslateController` (:786, :1285), `StringTranslateController` (:794), `TranslateJobsController` (:712). Each already has the target language in scope at the call point; the plan enumerates each.

### 4.3 `API\ExceptionsController` (REST, namespace `international-press-zone/v1`)

Routes and shapes (response envelope `{success, data, message}` unchanged):

- `GET /exceptions` → `data.items`: ALL entries (full shape incl. id/scope/created_at). SPA groups by scope client-side; counts for the scope selector derive from this one response.
- `POST /exceptions` `{text, match_type, scope}` → 201, full items. Scope validated against configured languages + `"global"` (400 on unknown). Per-scope duplicate → 409.
- `PUT /exceptions/{id}` `{text?, match_type?}` → 200, full items. Unknown id → 404. Text collision within same scope → 409.
- `DELETE /exceptions/{id}` → 200, full items. (Replaces body-text DELETE; the SPA is the only client, updated in the same release.)
- `POST /exceptions/import` `{text, scope}` → adds newline-separated entries as `exact` into the given scope; per-scope dedupe; returns `{added, skipped, items}`. Hitting the 1000-total cap mid-batch: partial add — entries add until the cap, the remainder counts into `skipped`, and the response message states the cap was reached.
- No export endpoint: the SPA already holds all entries from `GET /exceptions`; export is built client-side (see §5.1). Plain text only — match types are not round-tripped (import is exact-only, matching current behavior).

Permission: `current_user_can('manage_options')` (unchanged). Existing sanitation/length/cap validation retained on all writes.

## 5. Frontend (admin SPA)

Rewrite `admin/src/components/ExceptionsTab.js` as the mockup's master-detail layout, mounted unchanged from `pages/settings.js:228`. Vanilla-DOM `el()` pattern, existing `API`, `Toast`, `Button` components; new styles in `admin/src/styles/` following the plugin's SCSS partial conventions (BEM, `ipz-` prefix, CSS variables, no inline CSS).

### 5.1 Component responsibilities

**ExceptionsTab (container)**
- Loads `GET /exceptions` and `GET /languages` once; holds all state: `entries`, `selectedScope` (default `global`), `search`, `typeFilter`, `page`, `pageSize`, pending flags.
- Derives per-scope counts and the visible page from state (pure functions; independently testable).

**Scope selector (left panel)**
- "Global — All languages" card + one card per configured language (active AND inactive — matching how the translate endpoints already use all configured languages), each showing name, native name, flag (reuse the existing flag rendering used by the Languages page), and entry count badge. Selected card highlighted; keyboard navigable (radiogroup semantics or buttons with `aria-pressed`).
- **Plan verify item:** confirm the existing `GET /languages` route returns inactive languages too (it takes an `active`-only filter param, so the unfiltered call should); if it returns only active, extend that route rather than adding a parallel endpoint.
- No "Add Language" action here (mockup shows one, but language management belongs to the Languages page — link there instead if the list is empty of languages).

**Scope panel (right)**
- Header: scope title + contextual note (Global: "applies to every language"; language: "applies only when translating into {language}") + actions menu (Import…, Export scope). Copy must not assert application *ordering* — the backend receives one flat merged list.
- Add form: text input + match-type select + Add button; Enter submits; inline Exact/Contains hint under the form (per mockup).
- Table: columns Exception / Matching type (badge) / Added on / Actions (edit, delete). Client-side search (case-insensitive substring), match-type filter (All/Exact/Contains), pagination (10/25/50 per page, default 10) with "Showing X to Y of Z" footer. Empty states: no entries in scope vs no matches for the current search/filter (distinct messages).
- Edit: row enters inline edit mode (text input + type select + save/cancel) → `PUT /exceptions/{id}`.
- Delete: `DELETE /exceptions/{id}` with per-row pending state (existing pattern).
- Import: modal or inline panel with textarea (one per line) targeting the selected scope.
- Export: builds a `.txt` (one entry text per line) client-side from the selected scope's entries already in state and triggers download — no server round-trip.

**Help section (bottom)**
- Same content as today (Exact / Contains explanation + notes list), rearranged into the mockup's three-column band. Add one note: "Global exceptions apply to all languages; per-language exceptions apply only when translating into that language."

### 5.2 State & error handling

- All mutations follow the existing pattern: pending flag → API call → apply returned `items` → toast; 409 → specific duplicate message; other errors → `Toast.error` with server message fallback.
- Scope switching, search, filter, pagination are pure client-side state changes — no refetch.
- Search/filter changes reset `page` to 1.

## 6. Error Handling Strategy

- Backend is the source of truth for validation (text length ≤ 500, cap 1000 total, scope validity, per-scope uniqueness); the SPA mirrors only the empty-text check for UX.
- Every write returns the full canonical `items` — the SPA never patches local state optimistically, eliminating drift.
- Unknown/removed language scope: entries whose scope no longer matches a configured language remain stored and visible under their scope card only if that language still exists; otherwise they surface under a fallback "Unknown language" group in the selector so they can be deleted (never silently hidden or dropped).

## 7. Testing Strategy

- **PHP:** extend the plugin's existing test approach for `Settings` (migration/normalization, scoped CRUD, per-scope dedupe, cap) and `ExceptionPayload::rules` (scope filtering, null target, sanitation retained). REST route tests for the new/changed routes incl. 404/409/400 paths.
- **E2E (Playwright, `tests/e2e/`):** one spec covering — migrate-from-flat rendering, add global + per-language entries, scope counts update, search/filter/pagination, inline edit, delete, import into a language scope, export.
- **Payload verification:** assert a translate request for target `fr` carries global+fr rules and not `he` rules (PHP-level test on the payload builder call sites' wiring, not a live backend call).
- Build gate: `cd admin && npm run build`; release gates in `.claude/agents/expert.md`.

## 8. Out of Scope

- Backend (api.press.zone) changes — wire format to the backend is unchanged.
- `ExceptionSync` (dead code) — untouched.
- Language management from the Exceptions page.
- Regex/wildcard matching, per-entry scope *moving* (delete + re-add covers it).
- Round-tripping match types through import/export.

## Architecture Decisions

- **Option storage over custom table (accepted):** 1000-entry cap makes a table pure infrastructure cost; option pattern is the plugin's established storage seam; migration is a lossless field addition (two-way door).
- **Single flat entry list with `scope` field over per-scope options/keys (accepted):** one persistence path, one cap, one dedupe rule; grouping is a pure function in both PHP and JS.
- **Client-side list controls (accepted):** at ≤1000 entries server-side search/pagination fails the deletion test — removing it changes nothing observable.
- **Delete/edit by id over by text (accepted):** text is no longer unique across scopes; id is the only stable key once edit exists.
- **Rejected — separate PerLanguageExceptions module:** fails the deletion test; scoping is a filter argument on the two existing seams (`Settings`, `ExceptionPayload`), not a new boundary.
- **Rejected — export REST endpoint:** fails the deletion test; the SPA already holds every entry from `GET /exceptions`, so export is a pure client-side transform.
- **Accepted — last-write-wins on concurrent admin tabs:** read-modify-write on one option is the plugin's pre-existing behavior; risk unchanged by this feature. Do NOT add locking/versioning.
