# Advanced Settings Port: TPZ → IPZ

**Slug:** `ipz-advanced-settings`
**Date:** 2026-08-04
**Plugin:** `international-press-zone` (IPZ)
**Reference (read-only):** `translate-press-zone` (TPZ) — superseded; **never edit that tree**

---

## 1. Goal

Bring TPZ's full settings surface into IPZ:

| Tab | Feature | IPZ state today |
|-----|---------|-----------------|
| General | Translation tone (neutral / formal / casual) | model exists, **no UI** |
| General | Auto-publish translations | model exists, **not wired, no UI** |
| Advanced | Debug mode / detailed logging | **duplicated option, UI toggle inert** |
| Exceptions | exact/contains exception strings | model exists, **no REST, no UI, no backend sync** |
| Exceptions | Bulk import | **absent** |

**Scope is closed.** TPZ's settings page has exactly three tabs — General, Advanced, Exceptions — and its only other setting is the API key, which IPZ already handles through its own licensing flow. The four items above are therefore TPZ's *entire* remaining settings surface; "all the advanced settings" is demonstrably complete, not assumed.

**Answer: it cannot be copied as-is.** IPZ already carries the PHP model (`Translation\Settings`, prefix `presszone_international_`) with tone / auto_publish / debug / exceptions, and tone already reaches the backend from `JobSender`, `BulkActions`, `StringTranslateController`, `TranslateController`. What is missing is the *wiring and surface*: REST endpoints, admin UI, sync-state, real auto-publish behavior, and a single debug store. This is a wiring job, not a file copy.

---

## 2. Decisions

### 2.1 Exceptions are dual-store, and the backend store is the one that matters

TPZ keeps exceptions **both** locally and on the backend. Reason, verified in the backend source:

- `api/src/routes/translate.ts` — `translateSchema` and `bulkTranslateSchema` contain **no `exceptions` field**. Zod strips unknown keys, so IPZ's current `$body['exceptions']` (`includes/Translation/BulkActions.php:445`) is **silently discarded on every request today**.
- `api/src/services/translationService.ts` applies exceptions server-side by calling `exceptionService.replaceExceptions(userId, …)`, reading Prisma `translationException` rows keyed by `userId`.

So: the **local WP option is the editing store; the backend list is the enforcement store.** A local-only implementation would render a list in wp-admin that protects nothing. Sync is therefore required for the feature to function — it is not an optional hardening step, and there is no abuse dimension either way.

**Consequence to accept:** backend exceptions are keyed by **user account, not by site**. Every site activated under one license shares one exception list. This is the backend's existing data model; this plan does not change it.

Auth is not a blocker: `/v1/exceptions*` sits behind the same `authenticateApiKey` middleware as `/v1/translate`, and IPZ already sends `Authorization: Bearer <license_key>` to `https://api.press.zone`.

### 2.2 Auto-publish keeps TPZ semantics, routed through IPZ's capability gate

TPZ's behavior (`class-tpz-metabox.php:427`, `class-tpz-rest-api.php:657`) is binary: `auto_publish ? 'publish' : 'draft'`. No source-status mirroring, no capability check.

IPZ keeps the binary semantics but routes them through `TranslationFinalizer`'s existing `publish_posts` capability gate (`includes/Translation/TranslationFinalizer.php:232-242`), falling back to `draft` when the acting user lacks the capability. Never a silent privilege escalation.

**`auto_translate` and `auto_publish` are different settings and must not be conflated:**
- `ipz_auto_translate` — *whether to translate* when a post is published.
- `presszone_international_auto_publish` — *what status the resulting translation gets*.

### 2.3 One debug store

Two options exist today:
- `presszone_international_debug_mode` — read by `Translation\Logger::log()` (`includes/Translation/Logger.php:41`). **This is canonical.**
- `ipz_debug_mode` — written by `API\SettingsController`, read by nothing functional. This is why the Advanced-tab toggle currently does nothing.

`ipz_debug_mode` is migrated into the canonical option and deleted. `ipz_log_queries` is a **separate** concern (DB query logging) and is out of scope — left exactly as it is.

The whole debug story (migration, model, REST read/write, reset) is owned by one task chain so reset cannot end up restoring a key nothing reads.

---

## 3. Seams

### 3.1 `Translation\Settings` — additions

```
get_exceptions_synced_at(): ?string      // option presszone_international_exceptions_synced_at; ISO-8601 or null
set_exceptions_synced_at(): void         // writes gmdate('c')
clear_exceptions_synced_at(): void       // marks the local list dirty (never synced / diverged)
```

`get_all()` gains `'exceptions_synced_at' => $this->get_exceptions_synced_at()`.

Every local mutation of the exceptions list (`add_exception`, `remove_exception`, `set_exceptions`) calls `clear_exceptions_synced_at()`. Sync state is therefore derivable, never separately tracked: `synced_at === null` ⇒ dirty.

### 3.2 `Translation\ExceptionSync` — new

```
push(): array   // ['success'=>bool, 'count'=>int, 'message'=>string]
pull(): array   // ['success'=>bool, 'count'=>int, 'message'=>string]
```

- `push()` — `POST {api_url}/v1/exceptions/sync`, body `{"exceptions":[{"text":string,"match_type":"exact"|"contains"}]}`. Full replace. On HTTP 2xx → `Settings::set_exceptions_synced_at()`.
- `pull()` — `GET {api_url}/v1/exceptions?limit=100`, replaces the local list via `Settings::set_exceptions()`, then `set_exceptions_synced_at()`.
- Headers match the existing outbound convention in `BulkActions.php:434-447`: `Authorization: Bearer <license_key>`, `X-Plugin-Version: IPZ_VERSION`, `X-Site-URL: home_url()`, `X-Plugin: international`.

**Backend constraints, verified in `api/src/routes/exceptions.ts` — the payload must satisfy these:**
- `text`: 1–500 chars
- `match_type`: `"exact" | "contains"` **only** (TPZ's UI offered `regex`; the backend rejects it, so IPZ does not offer it)
- `/v1/exceptions/sync` accepts at most 5000 items; `/v1/exceptions/bulk` at most 1000

**Failure semantics — pin exactly:** the local write is authoritative and always succeeds first; the push is best-effort and runs after. A failed push leaves the local list intact with `synced_at === null` (dirty) and returns a non-fatal message. A bulk import of 500 lines must never half-apply because the network failed.

Same task removes the dead `$body['exceptions']` line from `BulkActions.php` — it is stripped by the backend and misleads the next reader.

### 3.3 `API\ExceptionsController` — new

Namespace `international-press-zone/v1`, base `exceptions`. All routes `permission_callback` → `current_user_can('manage_options')`. Preserve the plugin's established namespace; NEVER introduce `presszone-international/v1`.

| Method | Route | Body / params | Behavior |
|--------|-------|---------------|----------|
| GET | `/exceptions` | — | `{items: [{text, match_type}], synced_at: string\|null}` |
| POST | `/exceptions` | `{text, match_type}` | add one; 409-shaped failure response on duplicate text |
| DELETE | `/exceptions` | `{text}` | remove by exact text (body param, **not** a path segment — TPZ's `/(?P<text>.+)` route breaks on slashes and encoded characters) |
| POST | `/exceptions/import` | `{text: string}` | newline-separated bulk import; blank lines skipped, duplicates skipped, each entry `match_type: "exact"`; returns `{added:int, skipped:int}` |
| POST | `/exceptions/sync` | — | `ExceptionSync::push()` |
| POST | `/exceptions/pull` | — | `ExceptionSync::pull()` |

All responses use the plugin's existing envelope: `{success, data, message}`.

Registration goes in `includes/Core/Plugin.php` beside the existing `new \InternationalPressZone\API\SettingsController($this->cache)` at line 650. **This task is the sole owner of `Plugin.php`.**

### 3.4 `API\SettingsController` — contract delta

The settings wire shape is **flat** in both directions and already agreed by both sides (`getSettings` returns a flat object under `data`; `settings.js` sends a flat payload). The nested branch at `admin/src/pages/settings.js:494-508` is dead legacy handling. **Pin flat. Do not introduce nesting.**

GET `/settings` `data` gains exactly three keys:

```json
{
  "tone": "neutral|formal|casual",
  "auto_publish": false,
  "debug_mode": false
}
```

`debug_mode` already exists as a key — what changes is its **source**: it now reads `Translation\Settings::instance()->is_debug_mode()` instead of `get_option('ipz_debug_mode')`. `tone` and `auto_publish` likewise delegate to `Translation\Settings`.

PUT `/settings` accepts `tone`, `auto_publish`, `debug_mode` and routes them to `Translation\Settings::set_tone()` / `set_auto_publish()` / `set_debug_mode()`. Invalid tone values are rejected (`set_tone` already validates against `neutral|formal|casual`).

`resetSettings()` stops resetting `ipz_debug_mode` and instead resets the canonical settings to their defaults: `tone => 'formal'`, `auto_publish => false`, `debug_mode => false`. `ipz_log_queries` handling is untouched.

`args` schema entries are added for the three new keys, matching the existing style at line 555.

### 3.5 `Translation\TranslationFinalizer` — auto-publish

The desired-status resolution (line 160, validating against `draft|publish|pending|private`) gains one rule: when no explicit status is supplied by the caller and `Translation\Settings::instance()->get_auto_publish()` is true, the desired status becomes `publish`. An explicit caller-supplied status always wins.

The existing capability gate (lines 232-242) is unchanged and still applies — auto-publish that fails the `publish_posts` check degrades to `draft`.

### 3.6 Debug migration

New `includes/Migrations/Migration20260804MigrateDebugMode.php`, following the existing `AbstractMigration` pattern (`version`, `description`, `up()`, `down()`; discovered by `Core\Migrations` from `includes/Migrations/Migration*.php`).

- `up()` — if `presszone_international_debug_mode` is unset and `ipz_debug_mode` exists, copy the value across; then `delete_option('ipz_debug_mode')`.
- `down()` — restore `ipz_debug_mode` from the canonical option.

Trigger: `Core\Migrations::migrate()` runs from two sites — the activation hook (`international-press-zone.php:163`) and `Core\Plugin::checkUpgrades()`, hooked on `admin_init` (`includes/Core/Plugin.php:221`, gated on `current_user_can('update_plugins')`). Existing installs therefore migrate on the next admin page load by an update-capable user; no new hook is added.

### 3.7 Admin UI

**`admin/src/components/ExceptionsTab.js` — new.** Follows the existing vanilla-JS component idiom (`el()` builder, `api.get/post`, `Toast`, `__()`). Responsibilities:

- add-form: text input + match-type select (`exact` / `contains`) + Add button
- table of current exceptions with a per-row Delete
- bulk-import textarea + Import button; helper text "Enter one exception per line."
- sync row: last-synced timestamp (or "Never synced"), a state dot, "Sync to server" and "Pull from server" buttons
- state dot classes: `ipz-exceptions__sync-dot--pending | --synced | --unsynced`

**`admin/src/pages/settings.js`** — sole owner of that file. Adds to `renderGeneralSettings()`: a tone `<select>` (Neutral / Formal / Casual) and an auto-publish toggle; extends the flat `saveSettings` payload with `tone`, `auto_publish`; registers the new Exceptions tab alongside `general | cache | performance | advanced`.

**Styling:** new `admin/src/styles/pages/_exceptions.scss` plus one `@use 'pages/exceptions';` line in `admin/src/styles/main.scss` (that task is the sole owner of `main.scss`). **Absolute ban on inline CSS** — monorepo rule, no exceptions.

---

## 4. Parallelization

Standing instruction: *"i want this plan to be paralelized as much as possible to get fast results, run with paralel luna subagents for non conflicting tasks in a way they wont touch each others work"*.

Every task below owns a **strictly disjoint** file set. No file appears under two tasks.

| Wave | Task | Files (exclusive) |
|------|------|-------------------|
| 1 | t1 Settings model: synced_at + dirty-on-mutation | `includes/Translation/Settings.php` |
| 1 | t2 Debug migration | `includes/Migrations/Migration20260804MigrateDebugMode.php` (new) |
| 1 | t3 Exception sync client + dead-key removal | `includes/Translation/ExceptionSync.php` (new), `includes/Translation/BulkActions.php` |
| 1 | t4 Exceptions styles | `admin/src/styles/pages/_exceptions.scss` (new), `admin/src/styles/main.scss` |
| 2 | t5 Exceptions REST controller + registration | `includes/API/ExceptionsController.php` (new), `includes/Core/Plugin.php` |
| 2 | t6 Settings REST delegation + reset | `includes/API/SettingsController.php` |
| 2 | t7 Auto-publish wiring | `includes/Translation/TranslationFinalizer.php` |
| 3 | t8 Exceptions tab component | `admin/src/components/ExceptionsTab.js` (new) |
| 3 | t9 Settings page UI | `admin/src/pages/settings.js` |
| 4 | t10 Admin build + clean-warning gate | `admin/dist/*` (build output) |
| 4 | t15 Auto-publish integration check (PHP, no browser) | `tests/integration/AutoPublishStatusTest.php` (new) |
| 4 | t16 E2E journey runner | `tests/e2e/run-journey.sh` (new) |
| 5 | t11 Journey: tone + auto-publish persist | `tests/e2e/journeys/UJ-022-set-translation-tone.*` (new) |
| 5 | t12 Journey: debug round-trip + reset defaults | `tests/e2e/journeys/UJ-023-debug-mode-round-trip.*` (new) |
| 5 | t13 Journey: add / delete an exception | `tests/e2e/journeys/UJ-024-manage-exceptions.*` (new) |
| 5 | t14 Journey: bulk import added/skipped counts | `tests/e2e/journeys/UJ-025-bulk-import-exceptions.*` (new) |

Wave 2 depends on wave 1 only through `Translation\Settings` (t1) and `ExceptionSync` (t3). Wave 3 depends on the REST surface from wave 2. Within a wave, tasks are fully independent and run concurrently.

Two authoring rules the plan doc must honour, because the wave-3 and wave-4 tasks are written by agents that never see wave 2's diff:

- **t8** must carry the §3.3 route table and response envelopes **inline in its own task entry**, verbatim — not as a cross-reference to t5's entry. Otherwise the coder invents endpoint names.
- **t9** must carry the §3.4 three-key GET/PUT delta (`tone`, `auto_publish`, `debug_mode`) inline in its own task entry, same reason.

---

## 5. Testing

- **E2E must run through `~/.claude/bin/e2e-remote`.** Local headless browser launches exit 97 by design; a bare `npx playwright test` burns the wave. The remote box reaches the dev WordPress through its private Tailscale URL, not through the laptop's loopback. `tests/e2e/run-journey.sh` (plan Task 16) discovers `home_url()` from `devzone-wordpress`, fail-closes unless its host is in Tailscale CGNAT `100.64.0.0/10`, then passes that exact URL and host allowlist to `e2e-remote`. NEVER hard-code a LAN/Tailscale IP and NEVER target a public site.
- New Playwright journeys under `tests/e2e/journeys/`, one spec + manifest per journey, following the existing `UJ-0NN-*` convention:
  - set translation tone and confirm it persists across a reload
  - toggle debug mode and confirm the canonical option flips (and that reset clears it)
  - add / delete an exception
  - bulk-import several lines and confirm the added/skipped counts
- **Auto-publish is NOT a Playwright journey.** Asserting it through the UI would require a live backend translation round-trip; the dev instance has no public URL and the run must not depend on `api.press.zone` credits. Instead a PHP integration check (`tests/integration/AutoPublishStatusTest.php`, run via `podman exec devzone-wordpress php …`) drives `TranslationFinalizer` directly with a stubbed translation payload and asserts `post_status` in the database across four cases: auto-publish off → `draft`; on → `publish`; on but caller supplied an explicit status → caller's status wins; on but the user lacks `publish_posts` → `draft`.
- Build gate: `cd admin && npm run build` must be clean — **no warnings pass silently.**

---

## 6. Architecture Decisions

- **`ExceptionSync` earns its own module (deletion test: passes).** Deleting it scatters HTTP-client construction, header assembly, payload capping and sync-timestamp bookkeeping into both the REST controller and the settings model. Depth: medium — callers see `push()`/`pull()` and never the transport.
- **No `ExceptionRepository` abstraction.** `Translation\Settings` already owns the option store; a repository seam over it would be decorative (single-adapter test: fails). Collapsed into the existing model.
- **No separate sync-state field.** Derived from `synced_at === null`, so a dirty flag cannot drift out of step with the timestamp.
- **DELETE takes a body param, not a path segment.** TPZ's `/exceptions/(?P<text>.+)` route mishandles slashes and encoded characters in exception text; IPZ does not reproduce that defect.
- **`regex` match type dropped.** TPZ's UI offers it; the backend zod enum rejects it. Offering a mode the enforcement store refuses would ship a broken control.
