source_utils_translate.bs

' Custom translation system: replaces Roku's built-in tr() with the JSON locale files
' shipped under locale/custom/.
' Translation data is loaded into m.global as flat roAssociativeArray objects
' for O(1) key lookups. Two AAs are kept in memory: the active locale and
' en_US as fallback (~150KB total including AA overhead).
'
' Regional locale layering: when a regional locale like "fr_CA" is requested,
' the base language ("fr") is loaded first, then regional translations are
' overlaid on top. This ensures users get the maximum number of translated
' strings: regional-specific where available, base language otherwise.
'
' Chinese script code layering: Chinese locales use script codes (zh_Hans,
' zh_Hant, zh_Hant_HK) instead of region codes. The loader reads a base zh.json
' first, but none ships, so that layer is empty. The layers in effect are zh_Hans;
' zh_Hant; and for zh_Hant_HK, zh_Hant then zh_Hant_HK.

' Translate a key, optionally substituting indexed placeholders {0}, {1}, etc.
' Falls back to en_US if key is missing from active locale, or returns the key
' itself if missing from both (makes untranslated strings visible during dev).
' @param {string} key - Translation key (for example "ButtonPlay", "MessageCastCanceled")
' @param {object} params - Optional array of string replacements for {0}, {1}, etc.
' @return {string} Translated string, or the key itself if not found
function translate(key as string, params = invalid as object) as string
  if key = invalid or key = "" then return ""
  value = m.global.translations[key]

  ' Fallback to en_US
  if value = invalid
    value = m.global.translationsFallback[key]
  end if

  ' Fallback to key itself (visible during development)
  if value = invalid
    return key
  end if

  ' Substitute indexed placeholders
  if params <> invalid and type(params) = "roArray" and params.Count() > 0
    for i = 0 to params.Count() - 1
      value = value.Replace("{" + i.toStr() + "}", params[i])
    end for
  end if

  return value
end function

' translateCached: translate(), memoized for one batch.
'
' Memoized on the KEY rather than on the item: a batch labels many items with a
' few distinct keys, and keying on the item would miss on every one (see
' personCreditLabel in people.bs). `cache` may be invalid, which only means no reuse.
'
' A caller may instead seed `cache` with the table itself (`m.global.translations`,
' read once), so every key it holds is already a hit and a missing one still falls
' back through translate(). Which seed is cheaper depends on how many distinct keys
' the batch needs. The cast needs a few, and copying the ~150 KB table on
' every row cost MORE than the memo (measured 2026-09-20 on a Stick 4K, 31-person
' cast: castMs +13.5 ms, n=8 per arm interleaved; a build making ZERO m.global reads
' was no faster again, so the rendezvous is not what that loop costs). A subtitle
' list needs a language name per track, often dozens, so sortSubtitles() copies the
' table once per list instead.
function translateCached(key as string, cache as dynamic) as string
  if cache = invalid then return translate(key)
  if cache[key] <> invalid then return cache[key]

  value = translate(key)
  cache[key] = value
  return value
end function

' Translate a plural key using zero/one/many suffix convention.
' Selects the appropriate variant based on count, then delegates to translate().
' @param {string} baseKey - Base key without suffix (for example "LabelEpisodeCount")
' @param {integer} count - The count to determine plural form
' @param {object} params - Optional placeholder params
' @return {string} Translated plural string
function translatePlural(baseKey as string, count as integer, params = invalid as object) as string
  if count = 0
    suffix = "Zero"
  else if count = 1
    suffix = "One"
  else
    suffix = "Many"
  end if

  return translate(baseKey + suffix, params)
end function

' Load translation files for a given locale into m.global.
' Always loads en_US as fallback. When the active locale is en_US,
' both m.global.translations and m.global.translationsFallback are set
' from the same AA.
' @param {string} locale - Locale code in standard format (for example "fr_CA", "pt_BR", "zh_Hans")
sub loadTranslations(locale as string)
  if locale = "" then locale = "en_US"

  ' Always load en_US as fallback
  fallbackJson = ReadAsciiFile("pkg:/locale/custom/en_US.json")
  fallbackAA = ParseJson(fallbackJson)
  if fallbackAA = invalid then fallbackAA = {}

  ' Load requested locale
  if locale = "en_US"
    ' Both fields are set from the same AA
    activeAA = fallbackAA
  else
    activeAA = loadLocaleFile(locale)
    if activeAA = invalid
      ' No file found for this locale: fall back to en_US
      activeAA = fallbackAA
      locale = "en_US"
    end if
  end if

  m.global.setFields({
    translations: activeAA,
    translationsFallback: fallbackAA,
    translationLocale: locale
  })
end sub

' Load a locale's translations, layering regional over base when applicable.
' For regional locales (for example "fr_CA"), loads the base language ("fr") first,
' then overlays regional translations on top. Regional values win on conflicts.
' For Chinese script codes (for example "zh_Hant_HK"), hands off to
' loadChineseLocaleFile(), which layers zh_Hant under zh_Hant_HK.
' For base locales (for example "fr"), loads directly.
' @param {string} locale - Locale code (for example "fr_CA", "de", "zh_Hans", "zh_Hant_HK")
' @return {object} Parsed roAssociativeArray, or invalid if no file found
function loadLocaleFile(locale as string) as object
  ' Chinese script code locales: zh_Hans, zh_Hant, zh_Hant_HK
  ' These use script codes instead of region codes and need special layering
  if Left(locale, 3) = "zh_"
    return loadChineseLocaleFile(locale)
  end if

  ' Check if this is a regional locale (contains an underscore)
  underscorePos = inStr(1, locale, "_")

  if underscorePos > 0
    ' Regional locale: layer base under regional
    baseLang = Left(locale, underscorePos - 1)
    baseJson = ReadAsciiFile("pkg:/locale/custom/" + baseLang + ".json")
    baseAA = ParseJson(baseJson)

    regionalJson = ReadAsciiFile("pkg:/locale/custom/" + locale + ".json")
    regionalAA = ParseJson(regionalJson)

    if baseAA <> invalid and regionalAA <> invalid
      ' Layer: start with base, overlay regional on top
      baseAA.Append(regionalAA)
      return baseAA
    else if regionalAA <> invalid
      return regionalAA
    else if baseAA <> invalid
      return baseAA
    end if
  else
    ' Base locale: load directly
    json = ReadAsciiFile("pkg:/locale/custom/" + locale + ".json")
    aa = ParseJson(json)
    if aa <> invalid then return aa
  end if

  return invalid
end function

' Load Chinese locale translations with script code layering.
' Chinese locales use script codes (Hans=Simplified, Hant=Traditional)
' instead of region codes. The loader reads a base zh.json first, but none
' ships under locale/custom/, so that layer is empty. The layers in effect:
'   zh_Hans:    zh_Hans.json
'   zh_Hant:    zh_Hant.json
'   zh_Hant_HK: zh_Hant.json -> zh_Hant_HK.json
' @param {string} locale - Chinese locale code (for example "zh_Hans", "zh_Hant", "zh_Hant_HK")
' @return {object} Parsed roAssociativeArray, or invalid if no file found
function loadChineseLocaleFile(locale as string) as object
  ' Layer 1: base Chinese (zh.json). None ships, so this read gives "" and
  ' ParseJson gives invalid: the layer is empty.
  baseJson = ReadAsciiFile("pkg:/locale/custom/zh.json")
  result = ParseJson(baseJson)

  ' Layer 2: Script variant (zh_Hant.json for zh_Hant_HK, or the locale itself)
  if locale = "zh_Hant_HK"
    intermediateJson = ReadAsciiFile("pkg:/locale/custom/zh_Hant.json")
    intermediateAA = ParseJson(intermediateJson)
    if intermediateAA <> invalid
      if result <> invalid
        result.Append(intermediateAA)
      else
        result = intermediateAA
      end if
    end if
  end if

  ' Final layer: The exact locale file
  localeJson = ReadAsciiFile("pkg:/locale/custom/" + locale + ".json")
  localeAA = ParseJson(localeJson)
  if localeAA <> invalid
    if result <> invalid
      result.Append(localeAA)
    else
      result = localeAA
    end if
  end if

  return result
end function