Skip to content

feat: AI-vs-reviewed match stats endpoints#3749

Closed
JanCizmar wants to merge 60 commits into
mainfrom
jancizmar/ai-match-stats
Closed

feat: AI-vs-reviewed match stats endpoints#3749
JanCizmar wants to merge 60 commits into
mainfrom
jancizmar/ai-match-stats

Conversation

@JanCizmar

Copy link
Copy Markdown
Member

What

Read-only endpoints under GET /v2/projects/{projectId}/ai-match-stats that answer: when a reviewer approved an AI/MT translation, how much did they change the AI's output? (word-level edit-distance similarity, 0–100; 100 = approved verbatim). Three views:

  • …/ai-match-stats — project summary (score buckets b100/b9990/b8980/b7970/bno, reviewed/not-reviewed totals, avgMatchScore, reviewedPct)
  • …/ai-match-stats/languages — per-language breakdown
  • …/ai-match-stats/prompts — per prompt and prompt version

Scope: translations.view, API-access enabled, default branch.

How it works

  • Per reviewed translation, the AI output the reviewer saw is reconstructed from the activity log (it can't be read off the live row, since editing resets auto/mtProvider/promptId), compared to the final reviewed text, and scored word-level (Levenshtein over tokens; an exact match is the only 100). Everything is word-weighted.
  • Results are stored in a materialized translation_ai_match table, maintained on demand, watermark-gated: each read advances a per-project activity_revision watermark and recomputes only the translations touched since — set-based (batched reads + bulk INSERT … ON CONFLICT / bulk delete), so no N+1 and no per-row writes.
  • The first-time backfill is bounded and resumable (capped per request, resumes on the next), so a read on a multi-million-key project never runs unbounded; the summary returns upToDate to signal catch-up.
  • Per-prompt rows attribute each translation to the prompt version live when it was produced, derived from the prompt's own activity history — no schema change, lets a prompt's accuracy be compared before/after an edit.

Tests

  • Unit: scorer, history reconstruction, prompt-version resolution.
  • Integration: drives real auto-translate/edit/review through the API with a mocked clock, asserting stats recompute correctly.
  • Performance: 10k reviewed AI translations backfill in <1s with a sub-linear JDBC statement count (guards against N+1).

Notes / open question

  • notReviewed per prompt is reported at the prompt level (on the newest version row), since an unreviewed cell has no review timestamp to pin a version to.
  • Open product decision: DISABLED AI cells are currently excluded from the not-reviewed count.

JanCizmar added 30 commits June 10, 2026 16:13
Introduces the foundations of the Tolgee Apps plugin system: a third-party
app, described by a JSON manifest, can be registered at the organization
level, enabled per project, and surface its declared pages in the project
sidebar.

Scopes included
- Plugin registered for an organization: manifest-URL-based registration,
  refresh, and removal under /v2/organizations/{orgId}/apps. New org
  settings section "Custom apps" with a register dialog and per-app
  refresh/remove actions.
- Plugin enabled per project: join entity AppEnabledForProject with
  PUT/DELETE/GET endpoints under /v2/projects/{projectId}/apps. New
  project settings "Apps" tab with a toggle per registered app. Removing
  the org-level install also clears its enablements.
- Project sidebar entries: ProjectMenu now appends one item per enabled
  app's project-dashboard-page module with the manifest's icon and title.
  A new placeholder route /projects/{id}/apps/{installId}/{moduleKey}
  shows the page metadata; the iframe + signed context token come in the
  next scope.

Other artifacts
- dev-plugin/: a Vite + React + TS scaffold that serves a minimal manifest
  at /manifest.json and a Hello-World page to drive the end-to-end flow
  locally on http://localhost:5180.
- docs/dev-notes/TOLGEE_APPS_ARCHITECTURE.md: design memo covering the
  manifest model, multi-tenancy, signed context tokens, postMessage
  transport, iframe sandboxing, and the planned dev CLI.
- Backend tests: OrganizationAppsControllerTest (8) and
  ProjectAppsControllerTest (7) — all green.

What's intentionally not included yet
- Sandboxed iframe rendering and the signed context token (next scope).
- REST API client + auth scopes, webhooks, inline annotations, dev CLI.
- Translation keys via the Tolgee API — UI uses defaultValue fallbacks.
Two follow-ups worth tracking outside of the PoC scopes file:

- Incremental consent flow when a plugin tries to use a scope it
  wasn't granted at install time (ask for additional scopes).
- Caching the install's granted scopes so the auth filter doesn't
  re-query on every request.
Wires Tolgee Apps into the existing permission model:
- Manifest declares `scopes` (strings matching `Scope.value`).
- Registration is now a 2-step flow: preview the manifest, show the
  requested scopes, then approve & install.
- Refresh re-prompts for consent, highlighting added vs removed scopes.
- A new app-issued JWT (audience `tg.app`) carries only identity
  claims (installId / projectId / userId). Permissions are resolved
  from the DB on every request, so revocation works the moment any of
  install, enablement, user, or `tokensValidNotBefore` changes.
- `SecurityService.getCurrentPermittedScopes` intersects the install's
  granted scopes with the user's project scopes — same pattern as PAK.
- `OrganizationAuthorizationInterceptor` rejects app tokens outright;
  apps never satisfy `@RequiresOrganizationRole`.
- `canUseAdminPermissions` returns false for app auth so an admin
  using a plugin is still capped by the install's grant.

Tests
- OrganizationAppsControllerTest extended: preview returns scopes,
  register/refresh persist `grantedScopes`, unknown scope is rejected,
  empty scopes block stores no granted scopes.
- New AppPermissionInterceptionTest: full pipeline coverage —
  happy-path intersection, install-lacks-scope deny, install-removed
  401, enablement-removed 401, tokensValidNotBefore 401, org endpoint
  rejection, `@UseDefaultPermissions` accept, malformed token 401.

Out of scope (deferred)
- Install-context (server-to-server) tokens — ship with webhooks.
- Refresh-token hook on the SDK side.
- Separate signing key for app tokens.
- Granular per-scope opt-out during consent.
Closes the loop on Tolgee Apps PoC: a user opens a plugin page in
their project, an app-context JWT is minted server-side, the iframe
loads at the plugin's entry URL with the token, and the plugin calls
Tolgee's REST API on the user's behalf using @tginternal/client. The
intersection of install.grantedScopes and the user's project role
gates every call.

Backend
- POST /v2/projects/{projectId}/apps/{installId}/token mints a
  user-context app token. @UseDefaultPermissions — any project
  member can mint; 404 if the install isn't enabled for this project.

Frontend
- ProjectAppPageView swaps the placeholder for a real sandboxed
  iframe. Calls the new mint endpoint, then sets the iframe `src` to
  `<baseUrl><entry>?token=<jwt>&projectId=<id>&apiUrl=<tolgee-api>`.
- Iframe sandbox is `allow-scripts allow-forms` (no same-origin) so
  the plugin runs in an opaque-origin browser sandbox.

Dev plugin
- Depends on @tginternal/client@0.0.0-prerelease.ec3da97e7
- Manifest declares scopes `["translations.view", "keys.view"]`
- App.tsx reads token/projectId/apiUrl from the iframe URL,
  constructs an ApiClient with `userToken: <jwt>`, and fetches the
  current project's name + the first 20 keys with translations.
  Renders a simple table so the end-to-end auth path is observable.

Verification path
- Run dev backend + webapp, register the dev-plugin against an org,
  enable it for a project, click the "Hello World" sidebar entry.
  The placeholder is gone; the iframe shows "Hello World from
  <project-name>" + a table of keys/translations fetched via the
  REST API using the minted token.
- Pass token/projectId/apiUrl to the iframe via a ready/init postMessage
  handshake instead of URL query string. Platform checks event.origin
  and event.source; iframe mounts only after the token mutation returns
  so the listener is in place before the plugin posts ready.
- Render the iframe chrome-less (no border, transparent background) and
  drop the in-page title in favor of the breadcrumb. The dev-plugin's
  index.css is transparent so the host's background shows through.
- Return 403 with required scopes (instead of 404) when an app token's
  install has no granted scopes intersecting the project — the user
  reached the page through the install, so hiding existence is the
  wrong threat model.
- Show raw scope ids on consent / refresh / list chips instead of
  redundant friendly labels.
- Use typed components from @tginternal/client (KeyWithTranslationsModel,
  LanguageModel) in the dev-plugin instead of guessed shapes.
… backend

- Manifest can declare webhooks: { events: ["translation.set"], url }. On
  enable, AppManagedAutomationService auto-creates a hidden WebhookConfig +
  Automation + AutomationTrigger(ACTIVITY, SET_TRANSLATIONS) + WEBHOOK action
  per (install, project). Disable / refresh / uninstall cascade through. The
  user-facing webhook list filters out app-managed configs.
- AppInstall now carries three credentials: clientId (tgapp_), clientSecret
  (tgapps_, hashed at rest like PATs), webhookSecret (tgappw_, cleartext —
  HMAC signing needs it). Registration response exposes the cleartext
  clientSecret exactly once; subsequent reads show only the prefix.
- AuthenticationFilter accepts two header forms for app credentials:
  X-API-Key: tgapps_... and Authorization: Basic base64(clientId:clientSecret).
  Optional X-Tolgee-Act-As-User-Id header downgrades the install's scopes to
  the intersection with that user's project scopes.
- New event-validation whitelist on manifest webhooks; new error
  APP_ACTING_AS_USER_NOT_PROJECT_MEMBER.
- Webapp: register dialog reveals the credentials once after install;
  registered-apps row shows clientId, masked clientSecret prefix, and a
  reveal-toggle for webhookSecret; refresh dialog diffs requested events
  alongside the existing scope diff.
- Dev plugin grows an Express backend at :5181 (proxied through Vite) that
  receives webhooks (HMAC-verified when TOLGEE_WEBHOOK_SECRET is set),
  persists emoji + updatedAt in server/data.json, and serves /api/state +
  /api/emoji to the iframe. A new "Note" column with a 5-emoji palette
  demonstrates the round-trip.
- Server logs an ISO timestamp per translation update into a 14-day retention
  buffer (`data.events[translationId][]`), exposed alongside emojis +
  updatedAt via /api/state. Webhook payload's `modifiedEntities` is parsed
  as `Record<entityClass, ModifiedEntityModel[]>` (it's a map, not a flat
  array).
- Frontend gains an "Activity" column with an inline 14-bar SVG sparkline
  per key, aggregating events across every translation of the key. Bucket
  math uses calendar-day alignment so today's edits land in today's bar
  rather than getting dropped.
- Webhook URL in manifest is now absolute `http://localhost:5181/webhook` so
  Tolgee's server-side dispatcher reaches Express directly without
  depending on Vite being up. Vite's `/webhook` proxy is dropped; `/api/*`
  stays for browser-originated calls.
- `tsx watch` loads `.env.local` via `--env-file-if-exists` so the operator
  can paste `TOLGEE_CLIENT_ID` / `TOLGEE_CLIENT_SECRET` /
  `TOLGEE_WEBHOOK_SECRET` once and restart.
Adds a second module type so apps can contribute collapsible panels into
the translations view's right-hand tools panel, alongside Comments,
History, MT and TM. Each app panel mounts a sandboxed iframe with its
own signed token; selection identifiers (keyId/languageId/translationId)
flow via postMessage rather than the JWT and update while the panel is
open. The iframe can self-size via tolgee-app:resize. Dev plugin ships
an Activity sparkline tab that exercises the new flow.
Adds a new extension point for apps: small icon buttons rendered next to
the existing edit / screenshot / comments controls on each key row and
each translation cell. The icon is a navigation shortcut — heavier UI
lives in existing surfaces (the tools panel, the key-edit dialog).

Manifest additions:
- key-edit-tab module: tabs rendered inside the key-edit dialog (parallel
  to translation-tools-panel)
- key-action / translation-action modules with type link | tab | panel.
  Link opens a URL (manifest urlTemplate or per-key dynamic URL). Tab
  references a key-edit-tab and opens the dialog with that tab focused.
  Panel references a translation-tools-panel and opens the side panel
  scrolled to it.
- decoratorsUrl top-level field for dynamic decorators

Dynamic decorators are fetched frontend-direct: the webapp POSTs to the
plugin's decoratorsUrl with the user-context JWT we already mint for the
iframe; no Tolgee-side proxy endpoint. The plugin must set CORS. Static
manifest actions always render with URL-template interpolation
({keyName}, {keyId}, {languageTag}, etc.); dynamic responses can override
URLs and gate visibility per (keyId, languageTag).

Permissions: the key dialog opens via plugin tabs even without keys.edit.
In that mode native tabs and Save are hidden, leaving only the plugin
tabs as a read-only key-info view.

Shared iframe handshake (useAppIframeMessaging) replaces the per-component
postMessage wiring so the tools-panel iframe, the key-edit-tab iframe,
and any future iframe surface share one implementation.

Dev plugin demonstrates every shape: a static link key-action, a dynamic
tab key-action pointing at a new Audit tab, and a dynamic panel
translation-action pointing at the existing Activity side panel.

Note: backend test suite was not re-run as part of this commit because
the local Docker daemon is currently unresponsive; gradle compileKotlin
succeeded. Webapp eslint + tsc and dev-plugin tsc all pass. The schema
file (apiSchema.generated.ts) was patched manually for the same reason;
a real regen via scripts/regenerate-api-schema.sh will replace it
cleanly when docker recovers.
AppRegistrationResponseModel didn't pass decoratorsUrl to its parent
AppInstallModel constructor, so newly-registered apps reported null even
when the manifest declared the URL. Threads decoratorsUrl through the
response model and its controller call site.

Schema regenerated from the rebuilt backend so the generated TS types now
match what Spring actually emits.
The decorators hook was bypassing the webapp's HTTP client to mint the
install-context token, so the request hit /v2/projects/.../token without
the Authorization: Bearer <user-jwt> header and the backend rejected it
with 403. Adds the header from tokenService.getToken() before the fetch.
Clicking a translation-action of type 'panel' previously did nothing
useful: the side panel didn't open, and even if it did the panels
inside don't render until a cursor is set (ToolsPanel renders the
placeholder when no key+language is selected).

The click now also calls setEdit({keyId, language: languageTag}) to
focus the cell — which sets the cursor, opens the side panel, and lets
ToolsPanel render the panels for that translation. The existing
panelRevealEvent flow then ensures the plugin's panel is the one
expanded and scrolled into view.

A pending-reveal slot in panelRevealEvent handles the mount race: if
the side panel was closed at click time, FloatingToolsPanel (which
hosts the listener) hadn't mounted yet, so the live event would be
lost. ToolsPanel now consumes any pending request on first mount.

Verified end-to-end via Playwright against the running dev webapp:
clicking the 📈 decorator opens the side panel with the Activity panel
expanded and showing the sparkline for the focused translation.
Adds two refinements to the action-decorator system:

- visibility: 'always' | 'on-hover' on key-action and translation-action
  manifest entries. Default is 'on-hover' (matches the native edit-pencil
  pattern). The dynamic decorators response can override visibility per
  (keyId, languageTag) instance — precedence is dynamic > manifest >
  default 'on-hover'. Lets a plugin keep an icon hover-only by default
  but elevate it for items needing attention.
- count?: number on the dynamic response. When present and > 0 the icon
  is wrapped in a small MUI Badge mirroring the native unresolved-
  comments style. count === 0 hides the badge but keeps the icon.

Backend: new AppActionVisibility enum, visibility field on both action
modules, test asserts visibility round-trips through the response.
Dev-plugin demos the feature: static view-source link is on-hover,
show-activity is manifest-always but the /decorators handler emits a
varying count per (keyId, languageTag) and dynamically downgrades to
on-hover when the count is 0. Architecture doc updated.

Verified: webapp eslint + tsc clean, dev-plugin tsc clean, gradle
OrganizationAppsControllerTest passes. Browser e2e to follow after
the IDE-launched backend is restarted (currently unresponsive on 8824).
Bumps @tginternal/client to 0.0.0-prerelease.c8329b552 which now ships
typed webhook payloads — each activity type has its own
AppWebhookPayload_<TYPE> schema covering eventType, activityData (with
the discriminating `type` literal), modifiedEntities (typed per activity
variant), webhookConfigId, timestamps, etc.

Replaces the dev-plugin's hand-rolled payload type with a generated
discriminated union assembled from all AppWebhookPayload_* schemas. The
handler can now narrow by `payload.activityData?.type` if it later wants
per-activity behavior; the current Translation-only logic stays as it
was but with proper ModifiedEntityModel typing on the iterated entries.
Adds dev-plugin/server/webhookHandler.ts exporting:
- WebhookType — keyof the schema's `webhooks` interface
- WebhookPayloadFor<T> — concrete payload for a given event
- AppWebhookPayload — discriminated union of every typed payload
- onWebhook(payload, types, handler) — invokes handler with a
  payload narrowed to the supplied type(s). Accepts a single event
  name or an array; returns true on match so handlers can be chained.

Refactors the /webhook route to use it. Activity-specific code now
runs inside a lambda where TypeScript knows the exact payload shape
(modifiedEntities.Translation typed straight from the schema, no
manual casts).
Splits the monolithic server/index.ts into focused modules:

  server/
  ├── index.ts         — assembly + listen (~25 lines)
  ├── config.ts        — env + paths + retention
  ├── store.ts         — in-memory state + JSON persistence
  ├── cors.ts          — CORS middleware
  ├── signature.ts     — HMAC verification + parsing helpers
  ├── webhookHandler.ts — (existing) typed onWebhook utility
  └── routes/
      ├── webhook.ts   — POST /webhook
      ├── decorators.ts — POST /decorators
      ├── state.ts     — GET /api/state
      └── emoji.ts     — PUT /api/emoji

Each route module exports a single register…(app) function and otherwise
contains small handler + helper functions (parse, build, persist). The
store now owns persistence; routes no longer touch the filesystem
directly. The webhook route delegates activity recording to the typed
onWebhook utility.

Also moves the local-only state file from server/data.json into
server/.data/data.json so the leading-dot directory makes it obvious
it's not part of the source tree. .gitignore updated accordingly; the
store mkdirs the directory on first persist.
Two related extensions to the apps SDK.

1. Webhook subscription opened up
   AppManifestFetcher.SUPPORTED_APP_EVENTS now resolves to every
   ActivityType enum name; AppManagedAutomationService maps the event
   string straight via ActivityType.valueOf. Plugins can subscribe to
   any activity (SET_TRANSLATIONS, CREATE_KEY, BATCH_KEY_RESTORE, ...)
   instead of just the hardcoded translation.set. Dev plugin now
   subscribes to four events; existing webhook payload typing via the
   client schema's `webhooks` interface continues to narrow per event.

2. Modal as a fourth action behavior + five new trigger anchors
   - New `modal` module declares iframe content (entry/width/height).
   - New `key-action`/`translation-action`/etc. type `modal` plus the
     existing `link` keep referencing it by `modalKey`.
   - Five new trigger module types — `bulk-action`,
     `translations-toolbar-action`, `project-menu-action`,
     `key-edit-footer-action`, `shortcut` — each attaches plugin
     items to a specific Tolgee surface and dispatches link or modal
     on click. Shortcuts use mousetrap-style combinations (Mod+...).
   - Cross-ref validation in AppManifestFetcher rejects dangling
     modalKey, blank shortcut combinations, and link types missing
     urlTemplate. Tests cover happy-path and three rejection cases.
   - useAppIframeMessaging gains onClose and extraInitPayload so each
     trigger anchor can pass its own selection context (bulk-action
     forwards selectedKeyIds, key-edit-footer forwards keyId).
   - AppModalProvider hosts a single dialog slot mounted at
     ProjectRouter level. Generic useAppTriggers + useAppTriggerDispatch
     reused by all five anchors.

End-to-end verified via Playwright against the dev-plugin: every
trigger surface is visible at the right place, click opens the modal
with the correct init payload, and the iframe's tolgee-app:close
closes the dialog.
Plugin `icon` strings are now resolved against a merged registry of
@untitled-ui/icons-react and tg.component/CustomIcons before falling
back to literal text. This lets plugin authors pick "Settings01" and
get the same outline icon as the native sidebar, while keeping the
existing emoji path working for `"⚙️"`.

The AppIcon component centralises the lookup. All nine plugin-icon
render sites (decorators, modal title, tools panel, key-edit tabs,
sidebar entries, project-menu actions, toolbar/bulk/key-edit-footer
trigger actions) now route through it.

The dev-plugin manifest mixes native names with one emoji-only entry
(Hello World 👋) and one unknown name ("Robot") to exercise both
fallback paths.
The footer-action trigger rendered after the Cancel/Save buttons in the
key-edit dialog, which read as misplaced — Cancel/Save are the dialog's
terminal actions, not a leading-edge toolbar. Plugins that want to act
on the open key still have key-edit-tab, key-action decorators, and the
modal trigger surfaces.

Removes the trigger end-to-end: webapp UI, TriggerModuleKey, manifest
DTO + validation, generated API schema, controller-test fixtures, dev
plugin manifest, and docs.
Batch activity webhooks carry only a revisionId; the actual modified
entities are fetched separately via the modified-entities-by-revision
endpoint (paginated, filterable by entity class). Without this note
plugin authors hit BATCH_* events and assume the payload is incomplete
or malformed.
Add `PATCH /v2/organizations/{orgId}/apps/{installId}/manifest-url` so
an existing install can be re-pointed at a new manifest URL without
delete + re-register. The new manifest must still declare the same
`appId`. This unlocks the local-dev workflow where a Cloudflare quick
tunnel hands out a different URL every restart.

Wire the dev-plugin to use it:

- `manifest.json` is now served by Express from a template, with the
  base URL read on every request from `.tolgee-dev/tunnel.json`.
  Falls back to `http://localhost:5180` when no tunnel is active so
  local-only iframe development keeps working unchanged.
- `scripts/dev.ts` boots a Cloudflare quick tunnel via the
  `cloudflared` npm package, writes the assigned URL into the tunnel
  state file, and PATCHes the install (when one is registered) so the
  Tolgee snapshot points at the live tunnel URL.
- `scripts/register.ts` handles the one-time bootstrap: prompts for a
  PAT + org, registers the plugin with a fresh tunnel URL, and stores
  the install id + PAT in `.tolgee-dev/install.json` (gitignored).
- Vite proxies `/manifest.json`, `/webhook`, `/decorators`, `/state`,
  `/emoji` to Express so a single quick-tunnel hostname exposes
  everything Tolgee needs to reach.

The webapp's generated API schema isn't regenerated here — only dev
scripts call the new endpoint, and the running backend needs a restart
before regen can pick it up.
Top-level layout for the Tolgee Apps work:

    apps/
      tolgee-apps-sdk/   — @tolgee/apps-sdk skeleton package
      example-apps/
        dev-plugin/      — moved from the repo root

The SDK package is a scaffold (package.json + README + empty index.ts)
that captures what'll be published: manifest schema, postMessage
handshake, JWT decoder, webhook signature verifier, typed event
dispatcher. Implementations land as the contract stabilises; the
reference code currently lives inside example-apps/dev-plugin.

No behavioural change — git mv preserves the dev-plugin tree as-is.
No external references to the old path exist (checked: docs only mention
"the dev plugin" conceptually; no gradle/build scripts touch it).
Lift webhook + JWT + postMessage helpers out of dev-plugin into a
shared SDK so create-tolgee-app's template (and external plugin
authors) can depend on a typed surface instead of copy-pasting.

Three sub-entry points keep environment-specific code out of the
wrong bundle:

  @tolgee/apps-sdk           shared types only
  @tolgee/apps-sdk/server    WebCrypto webhook signature verifier,
                             typed onWebhook dispatcher, JWT decoder
  @tolgee/apps-sdk/browser   createTolgeeApp() postMessage handle +
                             createTolgeeAppClient() REST wrapper

WebCrypto for HMAC means the server helpers run unchanged on Node 20+,
Cloudflare Workers, Bun, and Deno.

dev-plugin now imports from the SDK: server/signature.ts and
server/webhookHandler.ts deleted (lifted verbatim), all four iframe
files replace their inline postMessage handshake with createTolgeeApp()
and the inline createApiClient with createTolgeeAppClient(ctx).

Wired in via npm workspaces at the platform root so dev-plugin
resolves @tolgee/apps-sdk to the source tree without a publish step.
tsconfig.node.json broadened to include server/** and scripts/** so
tsc catches the same diagnostics IDEA already shows.

Pinned @tginternal/client to the exact prerelease version: caret
semver lets npm pick a later tag that ships an empty `webhooks`
interface, silently breaking WebhookPayloadFor<T>.
Scaffold a working Tolgee Apps plugin in one prompt-driven flow.

Wizard (via @clack/prompts) collects:
- App identifier (kebab-case slug → manifest.id + dir name)
- Display name
- Modules to scaffold (multi-select from all 10 module types)
- Webhook events to subscribe to
- Tolgee URL
- Whether to install deps + git init

Generated project lifts dev-plugin's working dev environment:
- Vite + React + TypeScript + Express
- scripts/dev.ts boots a Cloudflare quick tunnel and PATCHes the
  install's manifest-url on every restart
- scripts/register.ts handles one-time install
- server/manifest.ts substitutes the live tunnel URL into the
  manifest at request time

Uses @tolgee/apps-sdk for the postMessage handshake, typed webhook
dispatcher, signature verifier, and REST client wrapper — no glue
copy-pasted into the generated project.

Template files use `_X` → `.X` renames for dotfiles and
`_package.json` → `package.json` so the template itself doesn't
register as an npm package. Two placeholders (`{{routes}}` and
`{{webhookHandlers}}`) are populated by targeted edits after the
mustache-style substitution pass, since their bodies are computed
from the selected modules / events.

For now the SDK is private, so generated projects must live inside
the platform npm workspace (apps/example-apps/) to resolve
`@tolgee/apps-sdk`. Real npm publishing is a later milestone.
`npm run dev` was unconditionally booting a Cloudflare quick tunnel,
even when the registered Tolgee instance lives on localhost — where
Tolgee can reach the plugin directly and the tunnel is pure overhead.

The dev orchestrator now skips cloudflared when any of these holds:
- the install record's `tolgeeUrl` is localhost / 127.0.0.1 / ::1
- `TOLGEE_URL` env var (loaded from .env.local) is localhost
- `TOLGEE_DEV_TUNNEL=none` is set (explicit override)

In the skip path the tunnel state file is written with
`http://localhost:5180` and the manifest URL is PATCHed to the same,
so the rest of the dev flow (manifest re-pointing, hot reload of
manifest.template.json) keeps working identically.

`dev:tunnel` now loads `.env.local` via tsx's --env-file-if-exists
flag so a `TOLGEE_URL` set there actually reaches the orchestrator.

Same fix applied to the create-tolgee-app template; future generated
projects get the behaviour out of the box, and the .env.example calls
it out.
Mirror the dev-orchestrator's local-Tolgee detection in register: when
the target Tolgee URL is localhost / 127.0.0.1 / ::1, skip cloudflared
entirely and use http://localhost:5181/manifest.json (Express direct)
as the registered manifest URL.

The bug surfaced because Cloudflare quick tunnels go stale — the
cloudflared daemon stays alive locally but Cloudflare's edge starts
returning 530 after some time. Tolgee then can't fetch the manifest
during install, and the POST fails with app_manifest_fetch_failed.

For local Tolgee the tunnel was always pure overhead since Tolgee can
reach localhost directly. For remote Tolgee the tunnel path is
unchanged.

Also added an early `fetch(manifestUrl)` sanity check before posting,
so the script fails with a clear "is npm run dev running?" message
instead of a server-side fetch-failed error.

Reproduced + verified against a local Tolgee at localhost:8824: POST
with the tunnel URL → 530-wrapped app_manifest_fetch_failed; POST with
http://localhost:5181/manifest.json → 200.
VITE_PORT and SERVER_PORT are now read from .env.local (default
5180/5181). Both templates and the dev-plugin reference plugin honour
the override across vite.config.ts (via loadEnv), server/config.ts,
server/manifest.ts, scripts/dev.ts and scripts/register.ts.

Drop a `VITE_PORT=5280` / `SERVER_PORT=5281` pair into a second
plugin's .env.local and the two run alongside without colliding on
ports — and Tolgee no longer serves whichever vite happened to bind
5180 first when rendering iframes for the other install.

Also fix: when the tunnel orchestrator skips cloudflared (local
Tolgee), it now parks on a never-firing interval instead of exiting,
so `concurrently -k` keeps vite and the server alive.

.env.example documents the two new vars and the package.json
register script now loads .env.local so the same TOLGEE_URL signal
that drives the dev-time tunnel skip also reaches register.
The translation-tools-panel iframe watches the focused cell,
fetches the base + focused texts via Tolgee's REST API, then POSTs
the pair to the plugin's own /translate-back. The server calls
Anthropic to back-translate and classify the result as match /
close / mismatch, with a one-line explanation. Results are cached
per (baseLang, baseText, sourceLang, text) so re-focusing a cell
hits memory instead of the API.

Skips on the base language and on missing texts. Returns 503 with
a friendly error when ANTHROPIC_API_KEY is unset.

ANTHROPIC_MODEL is overridable; defaults to claude-haiku-4-5 for
speed, with claude-sonnet-4-6 documented as the quality knob.

Routes/glue: server/routes/translateBack.ts, server/config.ts (key +
model env), server/index.ts (mount + startup warning), vite.config.ts
(proxy /translate-back to Express). Iframe rewritten end-to-end; CSS
added; .env.example documents the new vars.

To use: drop ANTHROPIC_API_KEY=sk-ant-… into .env.local and restart.
JanCizmar added 18 commits June 10, 2026 16:13
…richer template

@tolgee/apps-sdk:
- typed AppManifest + decorator types (single source of truth for the builder,
  template, and hand-written apps)
- framework-agnostic server helpers: receiveWebhook, tolgeeAppCorsHeaders,
  renderManifest, loadTolgeeAppConfig (no Express dependency)

@tolgee/apps-dev (new): the `tolgee-app` bin (dev tunnel + manifest repoint,
browser register), extracted from the generated template's scripts/ so apps no
longer copy ~490 LOC of toolchain.

create-tolgee-app:
- scope selection, a type:link action example, and a real /decorators demo with
  a dynamic:true action (closes manifest-capability gaps)
- headless mode: `--yes` plus --modules/--scopes/--webhooks/--tolgee-url/
  --vite-port/--server-port flags
- writes a ready-to-run .env.local with an auto-detected free port pair; ports
  also promptable in the wizard; URL input rejects duplicate/invalid schemes
- template consumes the SDK helpers and reads the webhook secret from the
  install record instead of requiring it in env
- @tolgee/apps-sdk gains a real tsup build (ESM + d.ts for ., /server, /browser);
  exports/main/types point at dist, ships dist only, un-private.
- @tolgee/apps-dev + create-tolgee-app un-private with publishConfig.
- create-tolgee-app injects the published @tolgee/* version into generated
  projects (its own version, or `*` when run unpublished from the repo).
- Lerna 6 (fixed versioning, npm workspaces) for lockstep alpha publishing;
  example-apps stay private so they're never published. `npm run build:apps`
  builds all in topo order. Lerna 8's nx-based discovery finds 0 packages in
  this git-worktree layout, so we pin v6.
- apps-sdk / apps-dev / create-tolgee-app published to npm under the `alpha`
  dist-tag at 0.0.1-alpha.0; lerna.json + package.json versions reflect it so
  the next publish increments to alpha.1.
- create-tolgee-app now decides the generated app's @tolgee/* dep version by
  workspace-sibling presence (in-repo → `*`, published → own version) instead
  of the version number, so it keeps linking the workspace after release bumps.
- create-tolgee-app now scaffolds a CLAUDE.md that guides AI-assisted development:
  typesafety workflow (verify with `npm run typecheck` after every change, prefer the
  typed SDK), and where to find docs, SDK types, and the example apps.
- add a `typecheck` script (tsc -b) and a `pull-context` script that snapshots the SDK
  source, example apps, and docs into a gitignored .context/ (shallow + sparse, ~1 MB).
- default the scaffolder's Tolgee URL to https://apps.preview.tolgee.io.
…eachability

`tolgee-app register` against a remote Tolgee always opened a second quick tunnel and
fetched it once before it was edge-routable, failing even though `tolgee-app dev` had a
live tunnel serving the manifest. register now reuses dev's tunnel (read from
.tolgee-dev/tunnel.json, gated by a manifest reachability check so stale URLs fall back),
and verifyManifestReachable retries up to 5×/1s for the cold-start (no dev) case.

- lib.ts: add readTunnelState() (read counterpart to write/clearTunnelState).
- register.ts: reusableTunnel() helper; remote branch reuses-or-starts; retry/backoff.
The project-dashboard-page app iframe was capped at maxWidth 1200 with a fixed
min-height of 600px. Make it full-bleed: maxWidth="max" + the BaseView `stretch`
row so the content fills the viewport height, and the iframe is width/height 100%.
…oject menu

The tools-panel header and project side-menu rendered whatever icon each panel /
menu item passed, so Tolgee app icons (18-20px) came out smaller than the native
icons (24px) next to them. Give PanelHeader and SideMenuItem a single fixed icon
slot (24px) that normalizes every icon — native and app alike — and drop the now
redundant per-call sizes from the app-icon usages.
The AuthenticationFilter constructor gained main's appTokenService/keyGenerator/
permissionService alongside our appInstallService/appEnablementService, so the
two security filter tests no longer matched it positionally. Pass arguments by
name (and add the missing service mocks) so the tests are robust to ordering.
Picks up main's new endpoints (per-language QA on/off, announcement banner,
language selector) alongside the apps endpoints, so the webapp type-checks.
…t format

After the rebase the filter's JWT path consults appTokenService; the unstubbed
mock returned null and NPEd on claims.installId. Stub validateToken to throw for
non-app tokens (matching production, so the filter falls back to user JWT auth).
Also apply ktlint formatting to the apps controller tests.
diffChangeLog drift after rebase: app_install.manifest_json is nullable in the
entity (installs are created before the manifest is fetched), app_install_webhook
_subscription.event is a string, and the two collection tables have no surrogate
PK. Add the generated changesets so the changelog matches the entity model.
…languages

Adds a new manifest module shown in the translations tools panel when no cell is
selected; it receives the view's currently selected language tags
(selection.selectedLanguages, updated as the user changes the language selector).

- backend: new module in AppManifestModules (reuses TranslationToolsPanelModule)
- sdk: manifest type + selectedLanguages on TolgeeAppSelection, wired through the
  init / selection-changed messages
- webapp: render app empty panels in the no-selection tools panel, open the side
  panel by default when an app contributes one, expand plugin panels by default,
  drop the hint title, plugins before shortcuts, keep the close button visible
- create-tolgee-app: scaffold the new module (registry + manifest + template stub)
- dev-plugin example: panel that fetches and lists the selected languages
Hands every app iframe the host's resolved theme (mode + a curated color
palette) at init and on every light/dark toggle, so plugins can match Tolgee.

- sdk: TolgeeAppTheme on context, onThemeChanged(), and applyTolgeeTheme() which
  exposes the palette as --tg-color-* CSS vars + sets data-tg-theme/color-scheme;
  init now also delivers the initial selection/theme to handlers registered
  before init (so onThemeChanged fires on first load, not only on change)
- webapp: useAppIframeMessaging sends theme in init + a theme-changed message;
  ProjectAppPageView folded onto the shared hook so the dashboard page is themed too
- create-tolgee-app: module stubs apply the theme; base CSS keys off --tg-color-*
- examples: dev-plugin + back-translate fully themed via the SDK vars
… URLs

Mirrors tolgee.webhook.allow-local-addresses: a config flag that lets app
manifest URLs target otherwise-blocked local/private addresses (off by default).
AppManifestFetcher passes it to UrlSecurity.validateUrl.
Adds read-only endpoints under /v2/projects/{projectId}/ai-match-stats that
report how much reviewers changed AI/MT output before approving it
(word-level edit-distance similarity, 0-100; 100 = approved verbatim),
broken down into a project summary, per language, and per prompt + prompt
version.

The score per reviewed translation is reconstructed from the activity log
(the AI output the reviewer saw vs. the final reviewed text) and stored in a
materialized translation_ai_match table. The table is maintained on demand:
each read advances a per-project revision watermark and recomputes only the
translations touched since, set-based (bulk reads + bulk upsert/delete). The
backfill is bounded and resumable, so a first read on a very large project
never runs unbounded; the summary exposes an upToDate flag for that case.

Per-prompt rows attribute each translation to the prompt version that was
live when it was produced (derived from the prompt's own activity history),
so a prompt's accuracy before vs. after an edit can be compared.
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e57bc9d5-65f5-4b88-b2ac-75350397adb1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jancizmar/ai-match-stats

Comment @coderabbitai help to get the list of available commands and usage tips.

…jancizmar/ai-match-stats

# Conflicts:
#	backend/data/src/main/resources/db/changelog/schema.xml
The app iframe sandbox omitted allow-popups, so target="_blank" links and window.open
from inside an app were silently blocked. Add allow-popups (so the popup opens) and
allow-popups-to-escape-sandbox (so the opened tab is a normal, non-sandboxed page) to all
app iframe surfaces: project dashboard page, modal, tools panel, and key-edit tab.
# Conflicts:
#	backend/data/src/main/resources/db/changelog/schema.xml
Sandboxed app iframes couldn't navigate the top-level window, so target="_top" links
(and breaking out to a Tolgee route) were blocked. Add allow-top-navigation-by-user-activation
to all app iframe surfaces — permits navigation on a real user gesture while still blocking
silent programmatic redirects of the host page.
@github-actions

Copy link
Copy Markdown
Contributor

This PR is stale because it has been open for 30 days with no activity.

@github-actions github-actions Bot added the stale label Jul 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR was closed because it has been inactive for 14 days since being marked as stale.

@github-actions github-actions Bot closed this Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant