From b7d85dc136b0a188b13f46b655b8642abdeae491 Mon Sep 17 00:00:00 2001 From: Ryan Welcher Date: Tue, 6 Oct 2026 14:14:58 -0400 Subject: [PATCH 1/2] Translate ticket and branch notices and input errors Wraps the ticket card's questions and notices, the changes note, the merge-in-progress and legacy-site notices, the deep-link banners, the folder-open failures, the ticket and issue parsers' refusals, and the ticket-branch errors main returns. Sentences that spliced a noun or a count are now whole strings per kind, with _n for counts; the changes note is one createInterpolateElement sentence per case. Fixes #629. --- src/editor-launch.js | 11 +- src/main.js | 12 +- src/renderer/changes-note.cjs | 156 +++++++--- .../components/discard-changes-link.jsx | 7 +- src/renderer/deep-link-notice.cjs | 30 +- src/renderer/github-issue.cjs | 30 +- src/renderer/hooks/use-site-ticket.jsx | 9 +- src/renderer/hooks/use-trunk-update.jsx | 4 +- src/renderer/index.jsx | 77 +++-- src/renderer/legacy-site.cjs | 26 +- src/renderer/merge-in-progress.cjs | 120 +++++-- src/renderer/open-failure.cjs | 27 +- src/renderer/ticket-actions.cjs | 112 +++++-- src/renderer/ticket-trunk-notice.cjs | 111 +++++-- src/renderer/trac-ticket.cjs | 26 +- src/ticket-branches.js | 37 ++- tests/e2e/journeys/i18n.spec.js | 293 ++++++++++++++++++ tests/unit/changes-note.test.cjs | 73 +++-- tests/unit/editor-launch.test.cjs | 16 + tests/unit/github-issue.test.cjs | 19 ++ tests/unit/legacy-site.test.cjs | 22 +- tests/unit/merge-in-progress.test.cjs | 24 ++ .../unit/ticket-branches.integration.test.cjs | 36 +++ tests/unit/ticket-trunk-notice.test.cjs | 21 ++ tests/unit/trac-ticket.test.cjs | 17 + 25 files changed, 1067 insertions(+), 249 deletions(-) diff --git a/src/editor-launch.js b/src/editor-launch.js index a1c2cbdc..eaaa9262 100644 --- a/src/editor-launch.js +++ b/src/editor-launch.js @@ -30,6 +30,7 @@ const path = require('path'); const { describeRefused } = require('./safe-log'); const { isActionableSite } = require('./site-registry'); +const { __, sprintf } = require('@wordpress/i18n'); // Path semantics follow the platform being asked about, not the platform the // test happens to run on: `path.isAbsolute('C:\\x')` is false under POSIX, and a @@ -385,7 +386,15 @@ function awaitLaunch(child, { platform } = {}) { child.on('close', (code) => { settle(code === 0 ? { ok: true } - : { ok: false, reason: REFUSAL_REASONS.SPAWN_FAILED, error: `the editor could not be opened (exit code ${code})` }); + : { + ok: false, + reason: REFUSAL_REASONS.SPAWN_FAILED, + error: sprintf( + // translators: %s: the exit code of the command that opens the editor, such as 1. Follows "The application would not start:", so it starts in lower case. + __('the editor could not be opened (exit code %s)'), + code + ) + }); }); return; } diff --git a/src/main.js b/src/main.js index c0730cde..8c940664 100644 --- a/src/main.js +++ b/src/main.js @@ -84,7 +84,7 @@ const DEEP_LINK_CHANNEL = 'deep-link:ticket'; // Trac parser is reached through it rather than directly, so no handler // here has to know which kind it is holding. const { workItemProvider } = require('./work-item.cjs'); -const { LEGACY_SITE_ERROR } = require('./renderer/legacy-site.cjs'); +const { legacySiteError } = require('./renderer/legacy-site.cjs'); const { resolveCatalog, languageChoices } = require('./i18n.cjs'); const { isPseudoLocale } = require('./renderer/pseudo-locale.cjs'); const { applyLocale } = require('./renderer/locale-setup.cjs'); @@ -1600,7 +1600,7 @@ async function noOriginBlock(sitePath) { async function legacySiteBlock(sitePath) { if (!await isLegacySite(sitePath)) return null; - return { ok: false, code: 'legacy-site', error: LEGACY_SITE_ERROR }; + return { ok: false, code: 'legacy-site', error: legacySiteError() }; } /** @@ -3233,7 +3233,7 @@ ipcMain.handle('sites:set-ticket', async (event, sitePath, ref, options) => with // own work to restore, so loose edits cannot ride into it. return { ok: false, - error: 'There is uncommitted work on trunk — decide what happens to it before starting the ticket', + error: __('There is uncommitted work on trunk — decide what happens to it before starting the ticket'), code: 'dirty-trunk', canCarry: true, files, @@ -3327,11 +3327,11 @@ ipcMain.handle('branches:rebase', async (event, sitePath) => withRegisteredSite( if (blocked) return blocked; const { ref, meta } = await activeBranch(sitePath, { migrate: true }); if (ref === TRUNK) { - return { ok: false, code: 'on-trunk', error: 'Link a ticket first: trunk is what tickets are measured against, not a ticket.' }; + return { ok: false, code: 'on-trunk', error: __('Link a ticket first: trunk is what tickets are measured against, not a ticket.') }; } - if (ticketIdFromRef(ref) === null) return { ok: false, code: 'not-a-ticket-branch', error: 'Only a ticket branch can be moved onto the current trunk.' }; + if (ticketIdFromRef(ref) === null) return { ok: false, code: 'not-a-ticket-branch', error: __('Only a ticket branch can be moved onto the current trunk.') }; if (!meta || !meta.baseOid) { - return { ok: false, code: 'no-base', error: 'This ticket has no recorded starting point, so the app cannot move its work onto the current trunk.' }; + return { ok: false, code: 'no-base', error: __('This ticket has no recorded starting point, so the app cannot move its work onto the current trunk.') }; } const progress = switchProgressReporter(event, sitePath); let result; diff --git a/src/renderer/changes-note.cjs b/src/renderer/changes-note.cjs index 71fb1105..8be5c32d 100644 --- a/src/renderer/changes-note.cjs +++ b/src/renderer/changes-note.cjs @@ -12,11 +12,13 @@ // to nothing is news for the buttons that would give it somewhere to go. 'use strict'; -const { __, sprintf } = require('@wordpress/i18n'); +const { __, _n, sprintf } = require('@wordpress/i18n'); // Byte-identical to the confirm the dirty-update modal has always used, so // the same action reads the same everywhere it can be triggered. -const DISCARD_CONFIRM_MESSAGE = 'Discard all local changes? This cannot be undone.'; +function discardConfirmMessage() { + return __('Discard all local changes? This cannot be undone.'); +} // The branch namespaces a work item gets (#251): `ticket/` on a Core site, // `issue/` on a Gutenberg one. Spelled out here rather than imported because @@ -26,62 +28,139 @@ const DISCARD_CONFIRM_MESSAGE = 'Discard all local changes? This cannot be undon const WORK_ITEM_BRANCH = /^(?:ticket|issue)\//; /** - * The changes note, split into parts the component interleaves with its two - * link buttons, or null when there is nothing to say. + * The changes note, as one sentence with its two links marked in it, or null + * when there is nothing to say. `` is the link that opens the review, + * `` the one that discards; the component fills them in with + * `createInterpolateElement`, so each sentence is whole for a translator. * * `changedCount` can be missing: the dirty probe may have answered before a * count existed, and "You have changes" is still true then. * - * The action labels move with the placement. By the buttons the modal has - * not been named yet, so the link says what it produces — a patch. In the + * The review link's words move with the placement. By the buttons the modal + * has not been named yet, so the link says what it produces — a patch. In the * ticket card the sentence already says where the changes are going, so the * link borrows the modal's own name, "review and submit". The ticket card * also carries a reassurance the buttons never need: Unlink sits right * above, and the changes must not look like they hang on it. * * `workItemNoun` is what the site calls its work item (#251), `ticket` unless - * told otherwise; every sentence that names it reads the noun from there. + * told otherwise; every sentence that names it is written once per kind. * * @param {{dirty?: boolean, changedCount?: number, tracTicket?: *, pullRequest?: Object, workItemNoun?: string}} state - * @return {{placement: 'buttons'|'ticket', lead: string, patchLabel: string, - * middle: string, discardLabel: string, end: string, - * unlinkNote?: string}|null} + * @return {{placement: 'buttons'|'ticket', sentence: string, unlinkNote?: string}|null} */ function changesNoteParts({ dirty, changedCount, tracTicket, pullRequest, workItemNoun = 'ticket' } = {}) { if (!dirty) return null; const count = Number.isInteger(changedCount) && changedCount > 0 ? changedCount : null; - const noun = count === 1 ? 'change' : 'changes'; + const issue = workItemNoun === 'issue'; if (pullRequest && Number.isInteger(pullRequest.number)) { const hasReturnDestination = typeof pullRequest.returnTo === 'string' && pullRequest.returnTo.length > 0; const returnsToTicket = hasReturnDestination ? WORK_ITEM_BRANCH.test(pullRequest.returnTo) : Boolean(tracTicket); - return { - placement: returnsToTicket ? 'ticket' : 'buttons', - lead: `You have ${count === null ? '' : `${count} `}${noun} on top of PR #${pullRequest.number}. You can `, - patchLabel: 'review them', - middle: ' or ', - discardLabel: 'discard your changes', - end: `. They stay with this pull request's local copy when you revert this PR.` - }; + return { placement: returnsToTicket ? 'ticket' : 'buttons', sentence: pullRequestChangesSentence({ count, number: pullRequest.number }) }; } if (tracTicket) { return { placement: 'ticket', - lead: `You have ${count === null ? '' : `${count} `}unsubmitted ${noun} for ${workItemNoun} #${tracTicket}. You can `, - patchLabel: 'review and submit', - middle: ' or ', - discardLabel: 'discard your changes', - end: '.', - unlinkNote: `Unlinking this ${workItemNoun} doesn\'t affect your local changes for this ${workItemNoun} — they remain attached to it in this site, ready for when you link it again.` + sentence: workItemChangesSentence({ count, issue, number: tracTicket }), + unlinkNote: issue + ? __("Unlinking this issue doesn't affect your local changes for this issue — they remain attached to it in this site, ready for when you link it again.") + : __("Unlinking this ticket doesn't affect your local changes for this ticket — they remain attached to it in this site, ready for when you link it again.") }; } - return { - placement: 'buttons', - lead: `You have ${count === null ? '' : `${count} `}${noun} not assigned to any ${workItemNoun}. You can `, - patchLabel: 'create and save a patch', - middle: ' or ', - discardLabel: 'discard your changes', - end: '.' - }; + return { placement: 'buttons', sentence: unassignedChangesSentence({ count, issue }) }; +} + +// The note's sentence for changes on top of a checked-out pull request. +function pullRequestChangesSentence({ count, number }) { + if (count === null) { + return sprintf( + // translators: %d: a pull request number. and are links. + __("You have changes on top of PR #%d. You can review them or discard your changes. They stay with this pull request's local copy when you revert this PR."), + number + ); + } + return sprintf( + // translators: 1: how many files have changes. 2: a pull request number. and are links. + _n( + "You have %1$d change on top of PR #%2$d. You can review them or discard your changes. They stay with this pull request's local copy when you revert this PR.", + "You have %1$d changes on top of PR #%2$d. You can review them or discard your changes. They stay with this pull request's local copy when you revert this PR.", + count + ), + count, + number + ); +} + +// The note's sentence for changes on a linked ticket or issue. +function workItemChangesSentence({ count, issue, number }) { + if (count === null && issue) { + return sprintf( + // translators: %s: a GitHub issue number. and are links. + __('You have unsubmitted changes for issue #%s. You can review and submit or discard your changes.'), + number + ); + } + if (count === null) { + return sprintf( + // translators: %s: a Trac ticket number. and are links. + __('You have unsubmitted changes for ticket #%s. You can review and submit or discard your changes.'), + number + ); + } + if (issue) { + return sprintf( + // translators: 1: how many files have changes. 2: a GitHub issue number. and are links. + _n( + 'You have %1$d unsubmitted change for issue #%2$s. You can review and submit or discard your changes.', + 'You have %1$d unsubmitted changes for issue #%2$s. You can review and submit or discard your changes.', + count + ), + count, + number + ); + } + return sprintf( + // translators: 1: how many files have changes. 2: a Trac ticket number. and are links. + _n( + 'You have %1$d unsubmitted change for ticket #%2$s. You can review and submit or discard your changes.', + 'You have %1$d unsubmitted changes for ticket #%2$s. You can review and submit or discard your changes.', + count + ), + count, + number + ); +} + +// The note's sentence for changes that belong to nothing yet. +function unassignedChangesSentence({ count, issue }) { + if (count === null && issue) { + // translators: and are links. + return __('You have changes not assigned to any issue. You can create and save a patch or discard your changes.'); + } + if (count === null) { + // translators: and are links. + return __('You have changes not assigned to any ticket. You can create and save a patch or discard your changes.'); + } + if (issue) { + return sprintf( + // translators: %d: how many files have changes. and are links. + _n( + 'You have %d change not assigned to any issue. You can create and save a patch or discard your changes.', + 'You have %d changes not assigned to any issue. You can create and save a patch or discard your changes.', + count + ), + count + ); + } + return sprintf( + // translators: %d: how many files have changes. and are links. + _n( + 'You have %d change not assigned to any ticket. You can create and save a patch or discard your changes.', + 'You have %d changes not assigned to any ticket. You can create and save a patch or discard your changes.', + count + ), + count + ); } /** @@ -104,7 +183,14 @@ function discardOutcome(res) { ? { ok: true, dirty: res.dirty, changedCount: res.changedCount } : { ok: true }; } - return { ok: false, message: `Failed to discard changes: ${res && res.error ? res.error : 'Unknown error'}` }; + return { + ok: false, + message: sprintf( + // translators: %s: why the discard failed, usually Git's own message in English. + __('Failed to discard changes: %s'), + res && res.error ? res.error : __('Unknown error') + ) + }; } /** @@ -224,4 +310,4 @@ function patchReviewContext({ pullRequest, tracTicket, workItemNoun = 'ticket' } }; } -module.exports = { patchReviewContext, changesNoteParts, discardOutcome, applyFeedbackAfterDiscard, noteAfterDiscard, noteAfterProbe, discardBlocked, discardDisabledReason, DISCARD_CONFIRM_MESSAGE }; +module.exports = { patchReviewContext, changesNoteParts, discardOutcome, applyFeedbackAfterDiscard, noteAfterDiscard, noteAfterProbe, discardBlocked, discardDisabledReason, discardConfirmMessage }; diff --git a/src/renderer/components/discard-changes-link.jsx b/src/renderer/components/discard-changes-link.jsx index 1c174a7f..e90958f6 100644 --- a/src/renderer/components/discard-changes-link.jsx +++ b/src/renderer/components/discard-changes-link.jsx @@ -2,7 +2,8 @@ import { ReasonedButton } from './reasoned-button.jsx'; // One discard action, wherever it is offered. Keeping the disabled rendering // here means the ticket note cannot lose the explanation while the review -// modal keeps it (or vice versa). -export function DiscardChangesLink({ label, onClick, reason }) { - return {label}; +// modal keeps it (or vice versa). Its words are `label`, or, inside a +// sentence, the ones the sentence marks for it. +export function DiscardChangesLink({ label, children, onClick, reason }) { + return {children ?? label}; } diff --git a/src/renderer/deep-link-notice.cjs b/src/renderer/deep-link-notice.cjs index c966fd26..805ae9c0 100644 --- a/src/renderer/deep-link-notice.cjs +++ b/src/renderer/deep-link-notice.cjs @@ -18,6 +18,8 @@ // turns an address someone else chose into something the contributor did. 'use strict'; +const { __, sprintf } = require('@wordpress/i18n'); + /** * The banner for a ticket that arrived from a link, or null when none has. * @@ -56,8 +58,13 @@ function deepLinkNotice({ ticket = null, siteLabel = '', currentTicket = null, p if (siteLabel && provider !== 'trac') { return { state: 'unsupported', - title: `Ticket #${ticket} cannot be linked to ${siteLabel}.`, - body: 'A Trac ticket belongs on a WordPress Core site. Open one and the app will offer the ticket there.', + title: sprintf( + // translators: 1: a Trac ticket number. 2: the name of the site that is open. + __('Ticket #%1$s cannot be linked to %2$s.'), + ticket, + siteLabel + ), + body: __('A Trac ticket belongs on a WordPress Core site. Open one and the app will offer the ticket there.'), confirmLabel: null }; } @@ -65,16 +72,25 @@ function deepLinkNotice({ ticket = null, siteLabel = '', currentTicket = null, p if (siteLabel) { return { state: 'confirm', - title: `Link ticket #${ticket} to ${siteLabel}?`, - body: 'The ticket number came from a link. Linking it parks whatever the site is on now and checks out this ticket’s branch, so the app asks first.', - confirmLabel: 'Link ticket' + title: sprintf( + // translators: 1: a Trac ticket number. 2: the name of the site that is open. + __('Link ticket #%1$s to %2$s?'), + ticket, + siteLabel + ), + body: __('The ticket number came from a link. Linking it parks whatever the site is on now and checks out this ticket’s branch, so the app asks first.'), + confirmLabel: __('Link ticket') }; } return { state: 'no-sites', - title: `Ticket #${ticket} is ready to link.`, - body: 'There is no site to work on it in yet. Create one from the sidebar and the app will offer this ticket when it is ready.', + title: sprintf( + // translators: %s: a Trac ticket number. + __('Ticket #%s is ready to link.'), + ticket + ), + body: __('There is no site to work on it in yet. Create one from the sidebar and the app will offer this ticket when it is ready.'), confirmLabel: null }; } diff --git a/src/renderer/github-issue.cjs b/src/renderer/github-issue.cjs index 15f98e82..b87765d4 100644 --- a/src/renderer/github-issue.cjs +++ b/src/renderer/github-issue.cjs @@ -10,10 +10,12 @@ * Keeping the two behind one interface is what lets the rest of the app ask * "which work item is this site on?" without knowing where the answer lives. * - * Kept pure and dependency-free so it can be unit tested without a DOM: the - * renderer bundle imports it and `node --test` requires it directly. + * Kept pure so it can be unit tested without a DOM: the renderer bundle + * imports it and `node --test` requires it directly. */ +const { __, sprintf } = require('@wordpress/i18n'); + const GITHUB_HOST = 'github.com'; // Gutenberg is in the 70,000s. Seven digits leaves decades of headroom while @@ -46,9 +48,13 @@ function issueUrl(id, repoPath) { * @return {{ok: true, id: number, url: string}|{ok: false, error: string}} */ function parseIssueRef(input, { repoPath = 'WordPress/gutenberg' } = {}) { - const notAnIssue = `Enter an issue number like 1234, or a ${repoPath} issue URL.`; + const notAnIssue = () => sprintf( + // translators: %s: a GitHub repository, such as WordPress/gutenberg. + __('Enter an issue number like 1234, or a %s issue URL.'), + repoPath + ); const raw = typeof input === 'string' ? input.trim() : ''; - if (!raw) return { ok: false, error: 'Enter an issue number or URL.' }; + if (!raw) return { ok: false, error: __('Enter an issue number or URL.') }; // Both branches below go through this, the way the Trac parser routes both of // its own through fromDigits. A URL's digits are no more trustworthy than a @@ -58,7 +64,7 @@ function parseIssueRef(input, { repoPath = 'WordPress/gutenberg' } = {}) { const fromDigits = (digits) => { const id = Number(digits); if (!Number.isSafeInteger(id) || id < 1 || id > MAX_ISSUE_ID) { - return { ok: false, error: notAnIssue }; + return { ok: false, error: notAnIssue() }; } return { ok: true, id, url: issueUrl(id, repoPath) }; }; @@ -73,28 +79,30 @@ function parseIssueRef(input, { repoPath = 'WordPress/gutenberg' } = {}) { // as a wrong host rather than as not-an-issue. const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw); if (!hasScheme && !raw.includes('/') && !raw.includes('.')) { - return { ok: false, error: notAnIssue }; + return { ok: false, error: notAnIssue() }; } let parsed; try { parsed = new URL(hasScheme ? raw : `https://${raw}`); } catch { - return { ok: false, error: notAnIssue }; + return { ok: false, error: notAnIssue() }; } if (parsed.hostname.toLowerCase() !== GITHUB_HOST) { - return { ok: false, error: `Only ${GITHUB_HOST} issues are supported.` }; + // translators: %s: GitHub's host name, github.com. + return { ok: false, error: sprintf(__('Only %s issues are supported.'), GITHUB_HOST) }; } // Reading the id off the path drops ?foo= and #issuecomment- for free. const match = /^\/([^/]+\/[^/]+)\/(issues|pull)\/(\d+)\/?$/.exec(parsed.pathname); - if (!match) return { ok: false, error: notAnIssue }; + if (!match) return { ok: false, error: notAnIssue() }; if (match[2] === 'pull') { - return { ok: false, error: 'That is a pull request. Link the issue it fixes instead.' }; + return { ok: false, error: __('That is a pull request. Link the issue it fixes instead.') }; } if (match[1].toLowerCase() !== String(repoPath).toLowerCase()) { - return { ok: false, error: `Only ${repoPath} issues can be linked here.` }; + // translators: %s: a GitHub repository, such as WordPress/gutenberg. + return { ok: false, error: sprintf(__('Only %s issues can be linked here.'), repoPath) }; } return fromDigits(match[3]); } diff --git a/src/renderer/hooks/use-site-ticket.jsx b/src/renderer/hooks/use-site-ticket.jsx index d9e111dc..998077e0 100644 --- a/src/renderer/hooks/use-site-ticket.jsx +++ b/src/renderer/hooks/use-site-ticket.jsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { __ } from '@wordpress/i18n'; import { discardOutcome } from '../changes-note.cjs'; import { rebaseRefusal } from '../ticket-trunk-notice.cjs'; @@ -111,7 +112,7 @@ export function useSiteTicket({ sitePath, workItem, tracTicket, setTracTicket, s ticket: res.ticket || (parsedRef.ok ? parsedRef.id : null) }); } else { - setTicketError(res?.error || 'Could not save the ticket.'); + setTicketError(res?.error || __('Could not save the ticket.')); } return; } @@ -175,7 +176,7 @@ export function useSiteTicket({ sitePath, workItem, tracTicket, setTracTicket, s try { const res = await window.api.discardChanges(sitePath); if (!res?.ok) { - setTicketError(res?.error || 'Could not discard the changes.'); + setTicketError(res?.error || __('Could not discard the changes.')); return; } setPatchSavedTo(''); @@ -214,7 +215,7 @@ export function useSiteTicket({ sitePath, workItem, tracTicket, setTracTicket, s const res = await window.api.savePatch(sitePath); if (res?.canceled) return; if (!res?.ok) { - setTicketError(res?.error || 'Could not save the patch.'); + setTicketError(res?.error || __('Could not save the patch.')); return; } savedTo = res.filePath || ''; @@ -242,7 +243,7 @@ export function useSiteTicket({ sitePath, workItem, tracTicket, setTracTicket, s try { const res = await window.api.deleteBranch(sitePath, ref); if (!res?.ok) { - setTicketError(res?.error || 'Could not delete the branch.'); + setTicketError(res?.error || __('Could not delete the branch.')); return; } await loadBranches(); diff --git a/src/renderer/hooks/use-trunk-update.jsx b/src/renderer/hooks/use-trunk-update.jsx index e6795649..b37fafac 100644 --- a/src/renderer/hooks/use-trunk-update.jsx +++ b/src/renderer/hooks/use-trunk-update.jsx @@ -4,7 +4,7 @@ import { savedAndResetMessage } from '../confirmations.cjs'; import { planUpdateSteps, updateStepStatuses, SKIP_INSTALL_MESSAGE, planWatchImpact } from '../update-plan.cjs'; import { planUpdateHandOff } from '../update-handoff.cjs'; import { watchOccupiesBuild } from '../watch-waiters.cjs'; -import { discardOutcome, DISCARD_CONFIRM_MESSAGE } from '../changes-note.cjs'; +import { discardOutcome, discardConfirmMessage } from '../changes-note.cjs'; import { pathBasename } from '../path-basename.cjs'; import { updateHeldReason } from '../site-menu.cjs'; @@ -276,7 +276,7 @@ export function useTrunkUpdate({ sitePath, confirm, confirmAnd, installing, buil } }; - const dirtyDiscardAndUpdate = () => confirmAnd(DISCARD_CONFIRM_MESSAGE, async () => { + const dirtyDiscardAndUpdate = () => confirmAnd(discardConfirmMessage(), async () => { setDirtyError(null); const d = await window.api.discardChanges(sitePath); if (!d || !d.ok) { diff --git a/src/renderer/index.jsx b/src/renderer/index.jsx index 3acb12e4..2e949c64 100644 --- a/src/renderer/index.jsx +++ b/src/renderer/index.jsx @@ -6,7 +6,8 @@ import { SlotFillProvider } from '@wordpress/components'; import { Page } from '@wordpress/admin-ui'; -import { __, _x, setLocaleData } from '@wordpress/i18n'; +import { createInterpolateElement } from '@wordpress/element'; +import { __, _n, _x, sprintf, setLocaleData } from '@wordpress/i18n'; import { addFilter } from '@wordpress/hooks'; import { drawerLeft, globe } from '@wordpress/icons'; import { Badge, Button as UiButton, Card as UiCard, EmptyState, IconButton, Notice, Spinner as UiSpinner, Stack, Text, VisuallyHidden } from '@wordpress/ui'; @@ -46,7 +47,7 @@ import { mergeInProgressNotice } from './merge-in-progress.cjs'; import { describePrCheckout, describePrPreview, prSubmissionBlocked } from './pr-checkout.cjs'; import { describeSwitchProgress } from '../switch-progress.cjs'; import { hasDiffLines } from './diff-highlight.cjs'; -import { patchReviewContext, changesNoteParts, discardOutcome, applyFeedbackAfterDiscard, noteAfterDiscard, noteAfterProbe, discardBlocked, discardDisabledReason, DISCARD_CONFIRM_MESSAGE } from './changes-note.cjs'; +import { patchReviewContext, changesNoteParts, discardOutcome, applyFeedbackAfterDiscard, noteAfterDiscard, noteAfterProbe, discardBlocked, discardDisabledReason, discardConfirmMessage } from './changes-note.cjs'; import { ticketActionDisabledReason, rebaseDisabledReason, dirtyTrunkQuestion } from './ticket-actions.cjs'; import { initialConfirmations, confirmationReducer, deleteFailureMessage, setupFailureMessage, patchSavedMessage } from './confirmations.cjs'; import { ReasonedUiButton } from './components/reasoned-button.jsx'; @@ -608,7 +609,7 @@ function App() { {notice.title} {notice.body} - Dismiss + {__('Dismiss')} ); @@ -1347,7 +1348,16 @@ function SiteRow({ sitePath, initialized, createdAt, label, projectType = null, const carriedNotice = carriedWork ? ( - Your {carriedWork.files} uncommitted {carriedWork.files === 1 ? 'change' : 'changes'} came along into #{carriedWork.ticket}, and will go into its patch. + {sprintf( + // translators: 1: how many files had uncommitted changes. 2: the number of the ticket or issue they went into. + _n( + 'Your %1$d uncommitted change came along into #%2$s, and will go into its patch.', + 'Your %1$d uncommitted changes came along into #%2$s, and will go into its patch.', + carriedWork.files + ), + carriedWork.files, + carriedWork.ticket + )} ) : null; @@ -1359,7 +1369,11 @@ function SiteRow({ sitePath, initialized, createdAt, label, projectType = null, const savedCleanNotice = patchSavedNotice ? ( - Your edits were saved to {patchSavedNotice} and are no longer in the working tree. + {sprintf( + // translators: %s: the path of the patch file the edits were saved to. + __('Your edits were saved to %s and are no longer in the working tree.'), + patchSavedNotice + )} ) : null; @@ -1393,7 +1407,11 @@ function SiteRow({ sitePath, initialized, createdAt, label, projectType = null, {dirtyQuestion.question} {patchSavedTo ? ( - Saved to {patchSavedTo}. The edits are still in the working tree. + {sprintf( + // translators: %s: the path of the patch file the edits were saved to. + __('Saved to %s. The edits are still in the working tree.'), + patchSavedTo + )} ) : null} @@ -1414,7 +1432,7 @@ function SiteRow({ sitePath, initialized, createdAt, label, projectType = null, tone="neutral" size="compact" reason={ticketActionsReason} - onClick={() => confirmAnd('Discard the uncommitted edits on trunk? This cannot be undone.', () => discardTrunkWorkAndSwitch(blockedByTrunkWork))} + onClick={() => confirmAnd(__('Discard the uncommitted edits on trunk? This cannot be undone.'), () => discardTrunkWorkAndSwitch(blockedByTrunkWork))} >{dirtyQuestion.discard} {/* The way out that touches nothing — three consequential actions with no fourth door is its own trap (#234). */} @@ -1673,7 +1691,7 @@ function SiteRow({ sitePath, initialized, createdAt, label, projectType = null, // native confirm blocks the renderer, so the states discardBlocked names // cannot flip while the dialog is up — a check after it would read the // same render-time values the disabled prop already enforced. - const discardAllChanges = () => confirmAnd(DISCARD_CONFIRM_MESSAGE, async () => { + const discardAllChanges = () => confirmAnd(discardConfirmMessage(), async () => { setDiscarding(true); setDiscardError(null); try { @@ -1704,24 +1722,27 @@ function SiteRow({ sitePath, initialized, createdAt, label, projectType = null, }); // The sentence is one thing wherever it renders; only the wrapper differs. + // Its two links are marked in it, so it reaches a translator whole. const changesNoteBody = changesNote ? ( <> - {changesNote.lead} - - {changesNote.middle} - + {createInterpolateElement(changesNote.sentence, { + review: