chromium/ash/webui/recorder_app_ui/resources/core/i18n.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {join} from 'chrome://resources/mwc/lit/index.js';

import {usePlatformHandler} from './lit/context.js';
import {forceCast, upcast} from './utils/type_utils.js';

type I18nArgType = number|string;

/**
 * Helper for defining a localized string with arguments.
 */
function withArgs<Args extends I18nArgType[]>(): Args {
  // This is only used as a type level info.
  return forceCast<Args>(null);
}

const noArgStringNames = [
  'exportDialogAudioFormatWebmOption',
  'exportDialogAudioHeader',
  'exportDialogCancelButton',
  'exportDialogHeader',
  'exportDialogSaveButton',
  'exportDialogTranscriptionFormatTxtOption',
  'exportDialogTranscriptionHeader',
  'genAiDisclaimerText',
  'genAiErrorGeneralLabel',
  'genAiErrorSummaryTrustAndSafetyLabel',
  'genAiErrorTitleSuggestionTrustAndSafetyLabel',
  'genAiExperimentBadge',
  'genAiLearnMoreLink',
  'mainRecordingBarLandmarkAriaLabel',
  'mainRecordingsListLandmarkAriaLabel',
  'mainSearchLandmarkAriaLabel',
  'mainStartRecordNudge',
  'micSelectionMenuChromebookAudioOption',
  'onboardingDialogSpeakerLabelAllowButton',
  'onboardingDialogSpeakerLabelDeferButton',
  'onboardingDialogSpeakerLabelDescriptionListItem1',
  'onboardingDialogSpeakerLabelDescriptionListItem2',
  'onboardingDialogSpeakerLabelDescriptionListItem3',
  'onboardingDialogSpeakerLabelDescriptionPrefix',
  'onboardingDialogSpeakerLabelDescriptionSuffix',
  'onboardingDialogSpeakerLabelDisallowButton',
  'onboardingDialogSpeakerLabelHeader',
  'onboardingDialogSpeakerLabelLearnMoreLink',
  'onboardingDialogTranscriptionCancelButton',
  'onboardingDialogTranscriptionDeferButton',
  'onboardingDialogTranscriptionDescription',
  'onboardingDialogTranscriptionHeader',
  'onboardingDialogTranscriptionTurnOnButton',
  'onboardingDialogWelcomeDescription',
  'onboardingDialogWelcomeHeader',
  'onboardingDialogWelcomeNextButton',
  'playbackControlsLandmarkAriaLabel',
  'playbackMenuDeleteOption',
  'playbackMenuExportOption',
  'playbackMenuShowDetailOption',
  'playbackSpeedNormalOption',
  'recordDeleteDialogCancelButton',
  'recordDeleteDialogCurrentHeader',
  'recordDeleteDialogDeleteButton',
  'recordDeleteDialogDescription',
  'recordDeleteDialogHeader',
  'recordExitDialogCancelButton',
  'recordExitDialogDeleteButton',
  'recordExitDialogDescription',
  'recordExitDialogHeader',
  'recordExitDialogSaveAndExitButton',
  'recordInfoDialogDateLabel',
  'recordInfoDialogDurationLabel',
  'recordInfoDialogHeader',
  'recordInfoDialogSizeLabel',
  'recordInfoDialogTitleLabel',
  'recordMenuDeleteOption',
  'recordMenuToggleTranscriptionOption',
  'recordStopButton',
  'recordTranscriptionEntryPointDescription',
  'recordTranscriptionEntryPointDisableButton',
  'recordTranscriptionEntryPointEnableButton',
  'recordTranscriptionEntryPointHeader',
  'recordTranscriptionOffDescription',
  'recordTranscriptionOffHeader',
  'recordingListHeader',
  'recordingListNoMatchText',
  'recordingListSearchBoxPlaceholder',
  'recordingListSortByDateOption',
  'recordingListSortByNameOption',
  'recordingListThisMonthHeader',
  'recordingListTodayHeader',
  'recordingListYesterdayHeader',
  'settingsHeader',
  'settingsOptionsDoNotDisturbDescription',
  'settingsOptionsDoNotDisturbLabel',
  'settingsOptionsKeepScreenOnLabel',
  'settingsOptionsSpeakerLabelDescription',
  'settingsOptionsSpeakerLabelLabel',
  'settingsOptionsSummaryDescription',
  'settingsOptionsSummaryDownloadButton',
  'settingsOptionsSummaryDownloadingButton',
  'settingsOptionsSummaryLabel',
  'settingsOptionsSummaryLearnMoreLink',
  'settingsOptionsTranscriptionDownloadButton',
  'settingsOptionsTranscriptionDownloadingButton',
  'settingsOptionsTranscriptionLabel',
  'settingsSectionGeneralHeader',
  'settingsSectionTranscriptionSummaryHeader',
  'summaryDisabledLabel',
  'summaryDownloadModelDescription',
  'summaryDownloadModelDisableButton',
  'summaryDownloadModelDownloadButton',
  'summaryDownloadModelHeader',
  'summaryHeader',
  'titleRenameTooltip',
  'titleSuggestionHeader',
  'transcriptionAutoscrollButton',
  'transcriptionNoSpeechText',
  'transcriptionSpeakerLabelPendingLabel',
  'transcriptionWaitingSpeechText',
] as const;

export type NoArgStringName = (typeof noArgStringNames)[number];

const withArgsStringNames = {
  // This contains all the other strings that needs argument.
  // Usage example:
  // Add `fooBar: withArgs<[number, string]>(),` here,
  // then `i18n.fooBar(1, '2')` works.
  settingsOptionsSummaryDownloadingProgressDescription: withArgs<[number]>(),
  settingsOptionsTranscriptionDownloadingProgressDescription:
    withArgs<[number]>(),
  summaryDownloadingProgressDescription: withArgs<[number]>(),
  transcriptionSpeakerLabelLabel: withArgs<[string]>(),
} satisfies Record<string, I18nArgType[]>;
type WithArgsStringNames = typeof withArgsStringNames;

type I18nType = Record<NoArgStringName, string>&{
  [k in keyof WithArgsStringNames]: (...args: WithArgsStringNames[k]) => string;
};

/**
 * Entry point for accessing localized strings.
 *
 * @example
 *   i18n.foo  // For strings without arguments.
 *   i18n.bar('arg1', 2)  // For strings with arguments.
 */
// TODO(pihsun): Have some initialize code to initialize i18n to a concrete
// object instead of having it as a proxy. Since it use usePlatformHandler()
// which are not available at module import time, we'll need to initialize it
// separately similar to other context states.
//
// forceCast: The proxy wrapper changed the type of the target.
export const i18n = forceCast<I18nType>(
  new Proxy(
    {},
    {
      get(_target, name) {
        if (typeof name !== 'string') {
          return;
        }
        if (upcast<readonly string[]>(noArgStringNames).includes(name)) {
          return usePlatformHandler().getStringF(name);
        }
        if (Object.hasOwn(withArgsStringNames, name)) {
          return (...args: I18nArgType[]) => {
            return usePlatformHandler().getStringF(name, ...args);
          };
        }
        return undefined;
      },
    },
  ),
);

/**
 * Replaces `placeholder` in a string with the given lit template.
 *
 * Note that this shouldn't be used when `html` is a simple string. In that
 * case, use the standard $0, $1 as an argument and `withArgsStrings` above.
 *
 * @param s The translated string.
 * @param placeholder The placeholder string to be replaced.
 * @param html The lit template to replace `placeholder` with.
 * @return The result template.
 */
export function replacePlaceholderWithHtml(
  s: string,
  placeholder: string,
  html: RenderResult,
): RenderResult {
  const parts = s.split(placeholder);
  if (parts.length <= 1) {
    // The placeholder should still exist after translation, so this is likely
    // an error in translation.
    console.error(
      `Translated string doesn't contain expected placeholder`,
      s,
      placeholder,
    );
  }
  return join(parts, () => html);
}