From 9e28f4dbe001c7fd54a78bf9c825efaf53734b1b Mon Sep 17 00:00:00 2001 From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Tue, 4 Apr 2023 12:05:50 -0700 Subject: [PATCH] Fix pluralization issues in translations --- _locales/en/messages.json | 64 +++++++++++++------------- build/intl-linter/linter.ts | 13 ++++-- build/intl-linter/rules/pluralPound.ts | 37 +++++++++++++++ build/intl-linter/utils/rule.ts | 8 +++- build/intl-linter/utils/traverse.ts | 46 +++++++++--------- 5 files changed, 108 insertions(+), 60 deletions(-) create mode 100644 build/intl-linter/rules/pluralPound.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 34a451862..373ce7cd5 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -100,7 +100,7 @@ "description": "(deleted 03/29/2023) Shown below the group name when selecting a group to invite a contact to" }, "icu:GroupListItem__message-default": { - "messageformat": "{count, plural, one {1 member} other {# members}}", + "messageformat": "{count, plural, one {# member} other {# members}}", "description": "Shown below the group name when selecting a group to invite a contact to" }, "GroupListItem__message-already-member": { @@ -768,7 +768,7 @@ "description": "(deleted 03/25/2023) Message shown on the loading screen when we're catching up on the backlog of messages" }, "icu:loadingMessages--other": { - "messageformat": "Loading messages from {daysAgo, plural, one {1 day} other {# days}} ago...", + "messageformat": "Loading messages from {daysAgo, plural, one {# day} other {# days}} ago...", "description": "Message shown on the loading screen when we're catching up on the backlog of messages from day before yesterday and earlier" }, "icu:loadingMessages--yesterday": { @@ -832,7 +832,7 @@ "description": "(deleted 03/29/2023) Text for unread message separator, with count" }, "icu:unreadMessages": { - "messageformat": "{count, plural, one {1 Unread Message} other {# Unread Messages}}", + "messageformat": "{count, plural, one {# Unread Message} other {# Unread Messages}}", "description": "Text for unread message separator, with count" }, "messageHistoryUnsynced": { @@ -916,7 +916,7 @@ "description": "Shown to enter 'review' mode if more than five contacts have changed safety numbers" }, "icu:safetyNumberChangeDialog__many-contacts": { - "messageformat": "You have {count, plural, one {1 connection} other {# connections}} who may have reinstalled Signal or changed devices. You can optionally review their safety numbers before sending.", + "messageformat": "You have {count, plural, one {# connection} other {# connections}} who may have reinstalled Signal or changed devices. You can optionally review their safety numbers before sending.", "description": "Shown during an attempted send when more than five contacts have changed their safety numbers" }, "safetyNumberChangeDialog__post-review": { @@ -928,7 +928,7 @@ "description": "Shown after reviewing large number of contacts" }, "icu:safetyNumberChangeDialog__confirm-remove-all": { - "messageformat": "Are you sure you want to remove {count, plural, one {1 recipient} other {# recipients}} from story {story}?", + "messageformat": "Are you sure you want to remove {count, plural, one {# recipient} other {# recipients}} from story {story}?", "description": "Shown if user selects 'remove all' option to remove all potentially untrusted contacts from a given story" }, "safetyNumberChangeDialog__remove-all": { @@ -4384,7 +4384,7 @@ "description": "(deleted 03/29/2023) Aria label for the conversation list item" }, "icu:ConversationList__aria-label": { - "messageformat": "Conversation with {title}, {unreadCount, plural, one {1 new message} other {# new messages}}, last message: {lastMessage}.", + "messageformat": "Conversation with {title}, {unreadCount, plural, one {# new message} other {# new messages}}, last message: {lastMessage}.", "description": "Aria label for the conversation list item" }, "ConversationList__last-message-undefined": { @@ -4568,15 +4568,15 @@ "description": "Shown to label a donation badge you've replied to." }, "icu:message--donation--remaining--days": { - "messageformat": "{days, plural, one {1 day} other {# days}} remaining", + "messageformat": "{days, plural, one {# day} other {# days}} remaining", "description": "Describes how long remains for the donation badge you've redeemed on another device (only rendered for days > 1)." }, "icu:message--donation--remaining--hours": { - "messageformat": "{hours, plural, one {1 hour} other {# hours}} remaining", + "messageformat": "{hours, plural, one {# hour} other {# hours}} remaining", "description": "Describes how long remains for the donation badge you've redeemed on another device (only rendered for hours > 1)" }, "icu:message--donation--remaining--minutes": { - "messageformat": "{minutes, plural, one {1 minute} other {# minutes}} remaining", + "messageformat": "{minutes, plural, one {# minute} other {# minutes}} remaining", "description": "Describes how long remains for the donation badge you've redeemed on another device." }, "icu:message--donation--expired": { @@ -5716,7 +5716,7 @@ "description": "(deleted 03/15/2023) Text which is shared to social media platforms for sticker packs" }, "icu:StickerCreator--Toasts--imagesAdded": { - "messageformat": "{count, plural, one {1 image} other {# images}} added", + "messageformat": "{count, plural, one {# image} other {# images}} added", "description": "(deleted 03/15/2023) Text for the toast when images are added to the sticker creator" }, "StickerCreator--Toasts--animated": { @@ -6028,7 +6028,7 @@ "description": "(deleted 03/29/2023) Shown at the end of profile sharing messages as a link." }, "icu:ConversationHero--members": { - "messageformat": "{count, plural, one {1 member} other {# members}}", + "messageformat": "{count, plural, one {# member} other {# members}}", "description": "Specifies the number of members in a group conversation" }, "member-of-1-group": { @@ -6268,7 +6268,7 @@ "description": "(deleted 03/29/2023) Shown in the incoming call bar when someone is ringing you for a group call" }, "icu:incomingGroupCall__ringing-many": { - "messageformat": "{ringer} is calling you, {first}, {second}, and {remaining, plural, one {1 other} other {# others}}", + "messageformat": "{ringer} is calling you, {first}, {second}, and {remaining, plural, one {# other} other {# others}}", "description": "Shown in the incoming call bar when someone is ringing you for a group call" }, "outgoingCallRinging": { @@ -6816,7 +6816,7 @@ "description": "(deleted 03/29/2023) A holder for two pieces of information - the type of conversation, and the member count" }, "icu:GroupV2--join--group-metadata--full": { - "messageformat": "Group · {memberCount, plural, one {1 member} other {# members}}", + "messageformat": "Group · {memberCount, plural, one {# member} other {# members}}", "description": "A holder for two pieces of information - the type of conversation, and the member count" }, "GroupV2--join--requested": { @@ -8544,7 +8544,7 @@ "description": "(deleted 03/29/2023) This is the number of members in a group" }, "icu:ConversationDetailsHeader--members": { - "messageformat": "{number, plural, one {1 member} other {# members}}", + "messageformat": "{number, plural, one {# member} other {# members}}", "description": "This is the number of members in a group" }, "ConversationDetailsMediaList--shared-media": { @@ -8568,7 +8568,7 @@ "description": "(deleted 03/29/2023) The title of the membership list panel" }, "icu:ConversationDetailsMembershipList--title": { - "messageformat": "{number, plural, one {1 member} other {# members}}", + "messageformat": "{number, plural, one {# member} other {# members}}", "description": "The title of the membership list panel" }, "ConversationDetailsMembershipList--add-members": { @@ -8588,7 +8588,7 @@ "description": "This is a button on the conversation details to show all members" }, "icu:ConversationDetailsGroups--title": { - "messageformat": "{count, plural, one {1 group} other {# groups}} in common", + "messageformat": "{count, plural, one {# group} other {# groups}} in common", "description": "Title of the groups-in-common panel, in the contact details" }, "icu:ConversationDetailsGroups--title--with-zero-groups-in-common": { @@ -8796,7 +8796,7 @@ "description": "(deleted 03/29/2023) This is the modal content when confirming revoking multiple invites" }, "icu:PendingInvites--revoke-from": { - "messageformat": "Revoke {number, plural, one {1 invite} other {# invites}} sent by \"{name}\"?", + "messageformat": "Revoke {number, plural, one {# invite} other {# invites}} sent by \"{name}\"?", "description": "This is the modal content when confirming revoking multiple invites" }, "PendingInvites--revoke": { @@ -9260,11 +9260,11 @@ "description": "(deleted 03/29/2023) Shown in the timeline warning when you multiple group members have the same name" }, "icu:ContactSpoofing__same-name-in-group": { - "messageformat": "{count, plural, one {1 group member} other {# group members}} have the same name. {link}", - "description": "Shown in the timeline warning when you multiple group members have the same name" + "messageformat": "{count, plural, one {# group member has} other {# group members have}} the same name. {link}", + "description": "(deleted 04/04/2023) Shown in the timeline warning when you multiple group members have the same name" }, "icu:ContactSpoofing__same-name-in-group--link": { - "messageformat": "{count, plural, one {1 group member} other {# group members}} have the same name. Review request", + "messageformat": "{count, plural, one {# group member has} other {# group members have}} the same name. Review request", "description": "Shown in the timeline warning when you multiple group members have the same name" }, "ContactSpoofing__same-name__link": { @@ -9328,7 +9328,7 @@ "description": "(deleted 03/29/2023) Description for the group contact spoofing review dialog" }, "icu:ContactSpoofingReviewDialog__group__description": { - "messageformat": "{count, plural, one {1 group member} other {# group members}} have similar names. Review the members below or choose to take action.", + "messageformat": "{count, plural, one {# group member} other {# group members}} have similar names. Review the members below or choose to take action.", "description": "Description for the group contact spoofing review dialog" }, "ContactSpoofingReviewDialog__group__members-header": { @@ -9456,7 +9456,7 @@ "description": "(deleted 03/29/2023) Confirm message for deleting custom color" }, "icu:ChatColorPicker__delete--message": { - "messageformat": "This custom color is used in {num, plural, one {1 chat} other {# chats}}. Do you want to delete it for all chats?", + "messageformat": "This custom color is used in {num, plural, one {# chat} other {# chats}}. Do you want to delete it for all chats?", "description": "Confirm message for deleting custom color" }, "ChatColorPicker__global-chat-color": { @@ -10360,7 +10360,7 @@ "description": "(deleted 03/29/2023) Number of contacts blocked plural" }, "icu:Preferences--blocked-count": { - "messageformat": "{num, plural, one {1 contact} other {# contacts}}", + "messageformat": "{num, plural, one {# contact} other {# contacts}}", "description": "Number of contacts blocked plural" }, "Preferences__privacy--description": { @@ -10900,11 +10900,11 @@ "description": "(deleted 03/29/2023) Number of views your story has" }, "icu:MyStories__views": { - "messageformat": "{views, plural, one {1 view} other {# views}}", + "messageformat": "{views, plural, one {# view} other {# views}}", "description": "Number of views your story has" }, "icu:MyStories__views--strong": { - "messageformat": "{views, plural, one {1 view} other {# views}}", + "messageformat": "{views, plural, one {# view} other {# views}}", "description": "Number of views your story has" }, "icu:MyStories__views-off": { @@ -11204,7 +11204,7 @@ "description": "Story settings modal group story selection subtitle" }, "icu:StoriesSettings__viewers": { - "messageformat": "{count, plural, one {1 viewer} other {# viewers}}", + "messageformat": "{count, plural, one {# viewer} other {# viewers}}", "description": "The number of viewers for a story distribution list" }, "StoriesSettings__who-can-see": { @@ -11484,7 +11484,7 @@ "description": "Subtitle for My Story when the user has chosen an exclude list" }, "icu:SendStoryModal__excluded": { - "messageformat": "{count, plural, one {1 excluded} other {# excluded}}", + "messageformat": "{count, plural, one {# excluded} other {# excluded}}", "description": "Label for excluded count for My Story as an exclude list" }, "SendStoryModal__new": { @@ -11588,19 +11588,19 @@ "description": "Alert body for groups that non-admins cannot send stories to" }, "icu:SendStoryModal__my-stories-description-all": { - "messageformat": "All Signal connections · {viewersCount, plural, one {1 viewer} other {# viewers}}", + "messageformat": "All Signal connections · {viewersCount, plural, one {# viewer} other {# viewers}}", "description": "Shown as a subtitle under My Stories option in the send-story-to dialog when not exluding anyone" }, "icu:SendStoryModal__my-stories-description-excluding": { - "messageformat": "All Signal connections · {excludedCount, plural, one {1 excluded} other {# excluded}}", + "messageformat": "All Signal connections · {excludedCount, plural, one {# excluded} other {# excluded}}", "description": "Shown as a subtitle under My Stories option in the send-story-to dialog when excluding some" }, "icu:SendStoryModal__private-story-description": { - "messageformat": "Private story · {viewersCount, plural, one {1 viewer} other {# viewers}}", + "messageformat": "Private story · {viewersCount, plural, one {# viewer} other {# viewers}}", "description": "Shown as a subtitle of each private story in the send-story-to dialog" }, "icu:SendStoryModal__group-story-description": { - "messageformat": "Group story · {membersCount, plural, one {1 member} other {# members}}", + "messageformat": "Group story · {membersCount, plural, one {# member} other {# members}}", "description": "Shown as a subtitle of each group story in the send-story-to dialog" }, "Stories__settings-toggle--title": { @@ -11908,7 +11908,7 @@ "description": "Error string for when a video post to story fails" }, "icu:StoryCreator__error--video-too-long": { - "messageformat": "Cannot post video to story because it is longer than {maxDurationInSec, plural, one {1 second} other {# seconds}}.", + "messageformat": "Cannot post video to story because it is longer than {maxDurationInSec, plural, one {# second} other {# seconds}}.", "description": "Error string for when a video post to story fails because video's duration is too long" }, "icu:StoryCreator__error--video-too-big": { diff --git a/build/intl-linter/linter.ts b/build/intl-linter/linter.ts index b2f4b1c4e..58c8e0a1f 100644 --- a/build/intl-linter/linter.ts +++ b/build/intl-linter/linter.ts @@ -19,6 +19,7 @@ import noLegacyVariables from './rules/noLegacyVariables'; import noNestedChoice from './rules/noNestedChoice'; import noOffset from './rules/noOffset'; import noOrdinal from './rules/noOrdinal'; +import pluralPound from './rules/pluralPound'; const RULES = [ icuPrefix, @@ -27,6 +28,7 @@ const RULES = [ noOffset, noOrdinal, onePlural, + pluralPound, ]; type Test = { @@ -65,6 +67,7 @@ type Report = { id: string; message: string; location: Location | void; + locationOffset: number; }; function lintMessage( @@ -76,8 +79,8 @@ function lintMessage( for (const rule of rules) { rule.run(elements, { messageId, - report(message, location) { - reports.push({ id: rule.id, message, location }); + report(message, location, locationOffset = 0) { + reports.push({ id: rule.id, message, location, locationOffset }); }, }); } @@ -151,11 +154,13 @@ async function lintMessages() { const line = icuMesssageLiteral.loc.start.line + (report.location.start.line - 1); const column = - icuMesssageLiteral.loc.start.column + report.location.start.column; + icuMesssageLiteral.loc.start.column + + report.location.start.column + + report.locationOffset; loc = `:${line}:${column}`; } else if (icuMesssageLiteral.loc != null) { const { line, column } = icuMesssageLiteral.loc.start; - loc = `:${line}:${column}`; + loc = `:${line}:${column + report.locationOffset}`; } // eslint-disable-next-line no-console diff --git a/build/intl-linter/rules/pluralPound.ts b/build/intl-linter/rules/pluralPound.ts new file mode 100644 index 000000000..e55561e16 --- /dev/null +++ b/build/intl-linter/rules/pluralPound.ts @@ -0,0 +1,37 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { PluralElement } from '@formatjs/icu-messageformat-parser'; +import { rule } from '../utils/rule'; + +export default rule('pluralPound', context => { + const stack: Array = []; + return { + enterPlural(element) { + stack.push(element); + }, + exitPlural() { + stack.pop(); + }, + enterLiteral(element, parent) { + // Note: Without the stack this could be turned into a rule to check for + // explicit numbers anywhere in the message. + if (parent == null) { + return; + } + if (parent !== stack.at(-1)) { + return; + } + // Adapted from https://github.com/TalhaAwan/get-numbers + // Checks for explicit whitespace before and after the number. + const index = element.value.search(/(^| )(-\d+|\d+)(,\d+)*(\.\d+)*($| )/); + if (index > -1) { + context.report( + 'Use # instead of an explicit number', + element.location, + index + ); + } + }, + }; +}); diff --git a/build/intl-linter/utils/rule.ts b/build/intl-linter/utils/rule.ts index f444165f0..f4bad3bb5 100644 --- a/build/intl-linter/utils/rule.ts +++ b/build/intl-linter/utils/rule.ts @@ -11,7 +11,11 @@ export { Location }; export type Context = { messageId: string; - report(message: string, location: Location | void): void; + report( + message: string, + location: Location | void, + locationOffset?: number + ): void; }; export type RuleFactory = { @@ -27,7 +31,7 @@ export function rule(id: string, ruleFactory: RuleFactory): Rule { return { id, run(elements, context) { - traverse(elements, ruleFactory(context)); + traverse(null, elements, ruleFactory(context)); }, }; } diff --git a/build/intl-linter/utils/traverse.ts b/build/intl-linter/utils/traverse.ts index 9ad7f5ba5..924502351 100644 --- a/build/intl-linter/utils/traverse.ts +++ b/build/intl-linter/utils/traverse.ts @@ -16,7 +16,8 @@ import type { import { TYPE } from '@formatjs/icu-messageformat-parser'; export type VisitorMethod = ( - element: T + element: T, + parent: MessageFormatElement | null ) => void; export type Visitor = { @@ -41,44 +42,45 @@ export type Visitor = { }; export function traverse( + parent: MessageFormatElement | null, elements: Array, visitor: Visitor ): void { for (const element of elements) { if (element.type === TYPE.literal) { - visitor.enterLiteral?.(element); - visitor.exitLiteral?.(element); + visitor.enterLiteral?.(element, parent); + visitor.exitLiteral?.(element, parent); } else if (element.type === TYPE.argument) { - visitor.enterArgument?.(element); - visitor.exitArgument?.(element); + visitor.enterArgument?.(element, parent); + visitor.exitArgument?.(element, parent); } else if (element.type === TYPE.number) { - visitor.enterNumber?.(element); - visitor.exitNumber?.(element); + visitor.enterNumber?.(element, parent); + visitor.exitNumber?.(element, parent); } else if (element.type === TYPE.date) { - visitor.enterDate?.(element); - visitor.exitDate?.(element); + visitor.enterDate?.(element, parent); + visitor.exitDate?.(element, parent); } else if (element.type === TYPE.time) { - visitor.enterTime?.(element); - visitor.exitTime?.(element); + visitor.enterTime?.(element, parent); + visitor.exitTime?.(element, parent); } else if (element.type === TYPE.select) { - visitor.enterSelect?.(element); + visitor.enterSelect?.(element, parent); for (const node of Object.values(element.options)) { - traverse(node.value, visitor); + traverse(element, node.value, visitor); } - visitor.exitSelect?.(element); + visitor.exitSelect?.(element, parent); } else if (element.type === TYPE.plural) { - visitor.enterPlural?.(element); + visitor.enterPlural?.(element, parent); for (const node of Object.values(element.options)) { - traverse(node.value, visitor); + traverse(element, node.value, visitor); } - visitor.exitPlural?.(element); + visitor.exitPlural?.(element, parent); } else if (element.type === TYPE.pound) { - visitor.enterPound?.(element); - visitor.exitPound?.(element); + visitor.enterPound?.(element, parent); + visitor.exitPound?.(element, parent); } else if (element.type === TYPE.tag) { - visitor.enterTag?.(element); - traverse(element.children, visitor); - visitor.exitTag?.(element); + visitor.enterTag?.(element, parent); + traverse(element, element.children, visitor); + visitor.exitTag?.(element, parent); } else { unreachable(element); }