Files
upstream-tldraw/internal/scripts/i18n-fix-strings.ts
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

1481 lines
51 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Key, LokaliseApi } from '@lokalise/node-api'
// Corrects individual machine translations directly in Lokalise. The automated
// i18n pipeline orders Google translations for new keys (see
// i18n-upload-strings.ts), and occasionally a string comes back wrong: a word
// translated in the wrong sense, or placeholders/tags scrambled. This script
// patches those specific (key, locale) translations in place.
//
// It only ever touches the translations listed in FIXES, never English, and by
// default runs as a dry run. Pass --apply to actually write to Lokalise.
//
// Lokalise holds two separate projects and a run targets exactly one of them,
// so every fix declares which project it belongs to and --project selects it:
//
// yarn i18n-fix-strings --project=sdk # dry run, prints the before/after diff
// yarn i18n-fix-strings --project=dotcom --apply # write the corrections to Lokalise
/** Gap between Lokalise calls; the API allows six requests a second per token. */
const REQUEST_INTERVAL_MS = 250
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
type ProjectName = 'sdk' | 'dotcom'
/** Which env var holds each project's Lokalise ID. */
const PROJECT_ENV_VAR: Record<ProjectName, string> = {
sdk: 'LOKALISE_TLDRAW_PROJECT_ID',
dotcom: 'LOKALISE_PROJECT_ID',
}
/**
* A corrected value. Plain keys take a string; a Lokalise plural key takes the
* full map of plural forms, because writing a bare string to one would collapse
* every form into a single value.
*/
type FixValue = string | Record<string, string>
interface StringFix {
/** Which Lokalise project this key lives in. */
project: ProjectName
/** Lokalise key name (the hashed key shared across all locales). */
key: string
/** English source string, for reviewer context and placeholder validation. */
english: string
/** Corrected translations, keyed by repo locale code (e.g. 'fr', 'ko-kr'). */
translations: Record<string, { value: FixValue; note: string }>
}
const FIXES: StringFix[] = [
{
project: 'dotcom',
key: '9983381c21',
english: 'Clear search',
translations: {
fr: {
value: 'Effacer la recherche',
note: 'Was "Recherche propre" (clean/proper search) — translated "Clear" as an adjective instead of the verb.',
},
},
},
{
project: 'dotcom',
key: '029f235f01',
english: 'We’re working on support for files from <a><strong>[%1$s:appName]</strong></a>.',
translations: {
bn: {
value:
'আমরা <a><strong>[%1$s:appName]</strong></a> থেকে আসা ফাইলগুলির জন্য সমর্থন যোগ করার কাজ করছি।',
note: 'Closing </a> was dropped, which fails ICU parsing with UNCLOSED_TAG and breaks build-i18n.',
},
},
},
{
project: 'dotcom',
key: '093ac7483a',
english: 'Can’t open [%1$s:extension] files yet',
translations: {
nl: {
value: 'Kan [%1$s:extension] bestanden nog niet openen.',
note: 'Placeholder name was localised ([%1$s:extensie]), so it never interpolated at runtime.',
},
tr: {
value: 'Henüz [%1$s:extension] dosyaları açılamıyor.',
note: 'Placeholder name was localised ([%1$s:uzantı]), so it never interpolated at runtime.',
},
'ko-kr': {
value: '[%1$s:extension] 파일을 아직 열 수 없습니다.',
note: 'Placeholder name was localised ([%1$s:확장자]), so it never interpolated at runtime.',
},
gl: {
value: 'Aínda non se poden abrir os ficheiros [%1$s:extension]',
note: 'Placeholder name was localised ([%1$s:extensión]), so it never interpolated at runtime.',
},
},
},
{
project: 'dotcom',
key: '2755e3ab39',
english: '<name>[%1$s:author]</name> commented on your board',
translations: {
'zh-tw': {
value: '<name>[%1$s:author]</name> 在您的白板上留言',
note: 'The <name> tag was dropped and replaced with the literal word "作者" ("author"), so the name lost its styling.',
},
'zh-cn': {
value: '<name>[%1$s:author]</name> 在您的白板上发表了评论',
note: 'The <name> tag was dropped and replaced with the literal word "作者" ("author"), so the name lost its styling.',
},
},
},
{
project: 'dotcom',
key: '53770fd374',
english: 'Unread',
translations: {
pl: {
value: 'Nieprzeczytane',
note: 'Was "Niewykształcony" — "uneducated". Wrong sense of "unread" entirely.',
},
'hi-in': {
value: 'अपठित',
note: 'Was "अपठित ग" with a stray trailing character.',
},
el: {
value: 'Μη αναγνωσμένα',
note: 'Was "Αδιάβαστος" — masculine adjective for a person who has not studied.',
},
'ko-kr': {
value: '읽지 않음',
note: 'Was "읽히지 않는", a passive participle that cannot stand alone as a filter label.',
},
cs: {
value: 'Nepřečtené',
note: 'Was "Nepřečtený", masculine singular; the label covers notifications (neuter plural).',
},
},
},
{
project: 'dotcom',
key: '545241e7dd',
english: '[%1$s:count] unread',
translations: {
no: {
value: '[%1$s:count] ulest',
note: 'Placeholder name was localised ([%1$s:antall]), so it never interpolated at runtime.',
},
cs: {
value: '[%1$s:count] nepřečtených',
note: 'Placeholder name was localised ([%1$s:počet]), so it never interpolated at runtime.',
},
da: {
value: '[%1$s:count] ulæste',
note: 'Placeholder name was localised ([%1$s:antal]), so it never interpolated at runtime.',
},
uk: {
value: 'Непрочитані [%1$s:count]',
note: 'Placeholder name was localised ([%1$s:кількість]), so it never interpolated at runtime.',
},
hr: {
value: '[%1$s:count] nepročitano',
note: 'Placeholder name was localised ([%1$s:broj]), so it never interpolated at runtime.',
},
ne: {
value: '[%1$s:count] नपढिएको',
note: 'Placeholder name was localised ([%1$s:गणना]), so it never interpolated at runtime.',
},
'gu-in': {
value: '[%1$s:count] વાંચ્યા વગરની',
note: 'Placeholder name was localised ([%1$s:ગણતરી]), so it never interpolated at runtime.',
},
sl: {
value: '[%1$s:count] neprebranih',
note: 'Placeholder name was localised ([%1$s:število]), so it never interpolated at runtime.',
},
},
},
{
project: 'dotcom',
key: '5bffeebf03',
english: '<name>[%1$s:author]</name> reacted to your comment',
translations: {
'zh-tw': {
value: '<name>[%1$s:author]</name> 對您的留言做出了回應',
note: 'The <name> tag was dropped and the name wrapped in literal parentheses after the word "作者".',
},
'zh-cn': {
value: '<name>[%1$s:author]</name> 对您的评论做出了回应',
note: 'The <name> tag was dropped and the name wrapped in literal parentheses after the word "作者".',
},
},
},
{
project: 'dotcom',
key: '608e9d366e',
english: 'You’re all caught up.',
translations: {
cs: {
value: 'Máte přečteno vše.',
note: 'Was "Všechno máte v plánu." ("You have everything planned").',
},
hr: {
value: 'Sve ste pročitali.',
note: 'Was "Sve ste shvatili." ("You understood everything").',
},
hu: {
value: 'Mindent elolvastál.',
note: 'Was "Mindennel fel vagy készülve." ("You are prepared with everything").',
},
el: {
value: 'Τα έχετε δει όλα.',
note: 'Was "Τα έχεις όλα καταφέρει." ("You have achieved everything").',
},
fa: {
value: 'همه را دیده‌اید.',
note: 'Was "همه‌تون گرفتارین." ("You are all busy/stuck") — opposite meaning.',
},
ur: {
value: 'آپ سب کچھ دیکھ چکے ہیں۔',
note: 'Was "آپ سب پکڑے گئے ہیں۔" ("You have all been caught/arrested") — literal reading of "caught up".',
},
tl: {
value: 'Nabasa mo na ang lahat.',
note: 'Was "Nahuli na kayong lahat." ("You have all been caught/are late").',
},
so: {
value: 'Wax cusub ma jiraan.',
note: 'Was "Dhammaantiin waad la qabsateen." ("You have all adapted").',
},
'km-kh': {
value: 'អ្នកបានមើលទាំងអស់ហើយ។',
note: 'Was "អ្នកបានរៀនចប់អស់ហើយ។" ("You have finished studying").',
},
ml: {
value: 'എല്ലാം വായിച്ചുകഴിഞ്ഞു.',
note: 'Was "നിങ്ങൾക്ക് എല്ലാം മനസ്സിലായി." ("You understood everything").',
},
pa: {
value: 'ਤੁਸੀਂ ਸਭ ਕੁਝ ਪੜ੍ਹ ਲਿਆ ਹੈ।',
note: 'Was "ਤੁਸੀਂ ਸਭ ਕੁਝ ਸਮਝ ਲਿਆ ਹੈ।" ("You understood everything").',
},
ne: {
value: 'तपाईंले सबै हेरिसक्नुभयो।',
note: 'Was "तिमी सबै कुरा बुझिसक्यौ।" ("You understood everything") and used the intimate register.',
},
ru: {
value: 'Вы всё просмотрели.',
note: 'Was "Вы всё в курсе." which is ungrammatical.',
},
sl: {
value: 'Vse ste prebrali.',
note: 'Was "Vse si ujela." — feminine singular and "caught" in the literal sense.',
},
ro: {
value: 'Ești la zi.',
note: 'Was "Ești la zi cu toții." which mixes singular and plural.',
},
no: {
value: 'Du er ajour.',
note: 'Was "Du er ferdig med alt." ("You are done with everything").',
},
sv: {
value: 'Du är ikapp.',
note: 'Was "Du har allt ikapp." which is not idiomatic.',
},
fi: {
value: 'Olet ajan tasalla.',
note: 'Was "Olet kärryillä.", too colloquial for UI.',
},
ms: {
value: 'Anda sudah membaca semuanya.',
note: 'Was "Korang semua dah selesai.", heavy slang.',
},
'pt-pt': {
value: 'Está a par de tudo.',
note: 'Was the pt-BR wording "Você está totalmente a par de tudo."',
},
},
},
{
project: 'dotcom',
key: '61fb03fd7b',
english:
'{count, plural, one {Someone reacted to your comment} other {# people reacted to your comment}}',
translations: {
ru: {
value: {
one: 'Кто-то отреагировал на ваш комментарий',
few: '# человека отреагировали на ваш комментарий',
many: '# человек отреагировали на ваш комментарий',
other: '# человека отреагировали на ваш комментарий',
},
note: 'few/many were empty, so counts of 2–4 and 5+ rendered a blank notification. Also fixed the mid-sentence capital in "Люди".',
},
pl: {
value: {
one: 'Ktoś zareagował na Twój komentarz',
few: '# osoby zareagowały na Twój komentarz',
many: '# osób zareagowało na Twój komentarz',
other: '# osób zareagowało na Twój komentarz',
},
note: 'few/many were empty, so counts of 2–4 and 5+ rendered blank.',
},
cs: {
value: {
one: 'Někdo reagoval na váš komentář',
few: '# lidé reagovali na váš komentář',
many: '# lidí reagovalo na váš komentář',
other: '# lidí reagovalo na váš komentář',
},
note: 'few/many were empty, so counts of 2–4 rendered blank.',
},
uk: {
value: {
one: 'Хтось відреагував на ваш коментар',
few: '# особи відреагували на ваш коментар',
many: '# осіб відреагували на ваш коментар',
other: '# осіб відреагували на ваш коментар',
},
note: 'few/many were empty, so counts of 2–4 and 5+ rendered blank.',
},
ar: {
value: {
zero: 'لم يتفاعل أحد مع تعليقك',
one: 'تفاعل شخص واحد مع تعليقك',
two: 'تفاعل شخصان مع تعليقك',
few: 'تفاعل # أشخاص مع تعليقك',
many: 'تفاعل # شخصًا مع تعليقك',
other: 'تفاعل # شخص مع تعليقك',
},
note: 'zero/two/few/many were all empty, and the other form had no # at all, so Arabic never showed a number.',
},
he: {
value: {
one: 'מישהו הגיב לתגובה שלך',
two: '# אנשים הגיבו לתגובה שלך',
many: '# אנשים הגיבו לתגובה שלך',
other: '# אנשים הגיבו לתגובה שלך',
},
note: 'two/many were empty, so those counts rendered blank.',
},
hr: {
value: {
one: 'Netko je reagirao na tvoj komentar',
few: '# osobe reagirale su na tvoj komentar',
other: '# osoba reagiralo je na tvoj komentar',
},
note: 'few was empty, so counts of 2–4 rendered blank. Also aligned the other form to the informal "tvoj" used in the one form.',
},
ro: {
value: {
one: 'Cineva a reacționat la comentariul tău',
few: '# persoane au reacționat la comentariul tău',
other: '# de persoane au reacționat la comentariul tău',
},
note: 'few was empty, so counts of 2–19 rendered blank.',
},
sl: {
value: {
one: 'Nekdo se je odzval na tvoj komentar',
two: '# osebi sta se odzvali na tvoj komentar',
few: '# osebe so se odzvale na tvoj komentar',
other: '# oseb se je odzvalo na tvoj komentar',
},
note: 'two/few were empty, so those counts rendered blank. Also aligned the other form to the informal "tvoj".',
},
tl: {
value: {
one: 'May nag-react sa komento mo',
other: '# tao ang nag-react sa komento mo',
},
note: 'zero/two/few/many were empty placeholders Tagalog does not use; removing them leaves one/other, which is what CLDR defines for tl.',
},
},
},
{
project: 'dotcom',
key: 'b03400e2db',
english: '<name>[%1$s:author]</name> replied',
translations: {
ms: {
value: '<name>[%1$s:author]</name> membalas',
note: 'Placeholder name was localised ([%1$s:pengarang]), so it never interpolated at runtime.',
},
sv: {
value: '<name>[%1$s:author]</name> svarade',
note: 'Placeholder name was localised ([%1$s:författare]), so it never interpolated at runtime.',
},
no: {
value: '<name>[%1$s:author]</name> svarte',
note: 'Placeholder name was localised ([%1$s:forfatter]), so it never interpolated at runtime.',
},
hu: {
value: '<name>[%1$s:author]</name> válaszolt',
note: 'Placeholder name was localised ([%1$s:szerző]), so it never interpolated at runtime.',
},
pa: {
value: '<name>[%1$s:author]</name> ਨੇ ਜਵਾਬ ਦਿੱਤਾ',
note: 'Placeholder name was localised ([%1$s:ਲੇਖਕ]), so it never interpolated at runtime.',
},
ne: {
value: '<name>[%1$s:author]</name> ले जवाफ दिनुभयो',
note: 'Placeholder name was localised ([%1$s:लेखक]), so it never interpolated at runtime.',
},
'gu-in': {
value: '<name>[%1$s:author]</name> એ જવાબ આપ્યો',
note: 'Placeholder name was localised ([%1$s:લેખક]), so it never interpolated at runtime.',
},
so: {
value: '<name>[%1$s:author]</name> ayaa ku jawaabay',
note: 'Placeholder name was localised ([%1$s:author]), so it never interpolated at runtime.',
},
sl: {
value: '<name>[%1$s:author]</name> je odgovoril',
note: 'Placeholder name was localised ([%1$s:avtor]), so it never interpolated at runtime.',
},
te: {
value: '<name>[%1$s:author]</name> ప్రత్యుత్తరం ఇచ్చారు',
note: 'Placeholder name was localised ([%1$s:రచయిత]), so it never interpolated at runtime.',
},
},
},
{
project: 'dotcom',
key: 'b1c94ca2fb',
english: 'All',
translations: {
el: {
value: 'Όλα',
note: 'Was "Ολοι" — masculine plural and missing its accent.',
},
he: {
value: 'הכול',
note: 'Was "כֹּל" with vocalisation marks and in construct form.',
},
ar: {
value: 'الكل',
note: 'Was "الجميع" ("everyone"), which reads as people rather than notifications.',
},
tr: {
value: 'Tümü',
note: 'Was "Tüm", a determiner that cannot stand alone.',
},
},
},
{
project: 'dotcom',
key: 'd0e3556476',
english:
'<name>[%1$s:author]</name> and {count, plural, one {# other} other {# others}} reacted to your comment',
translations: {
it: {
value:
'<name>[%1$s:author]</name> e {count, plural, one {# altro} other {# altri}} hanno reagito al tuo commento',
note: 'ICU keywords were translated ("plurale", "uno", "altro"), so the argument type is invalid. Keywords restored, Italian text kept.',
},
el: {
value:
'<name>[%1$s:author]</name> και {count, plural, one {# ακόμη άτομο} other {# ακόμη άτομα}} αντέδρασαν στο σχόλιό σας',
note: 'Second selector keyword was missing entirely (MISSING_OTHER_CLAUSE) and branch text was left in English. Also dropped the duplicated "ο χρήστης".',
},
'hi-in': {
value:
'<name>[%1$s:author]</name> और {count, plural, one {# अन्य} other {# अन्य लोगों}} ने आपकी टिप्पणी पर प्रतिक्रिया दी',
note: 'The "other" selector keyword was translated to "अन्य", leaving no other clause. Keyword restored and branch text translated.',
},
so: {
value:
'<name>[%1$s:author]</name> iyo {count, plural, one {# qof kale} other {# dad kale}} ayaa ka falceliyay faalladaada',
note: 'ICU keywords were translated ("jamac", "hal", "kale"), making the argument type invalid.',
},
ur: {
value:
'<name>[%1$s:author]</name> اور {count, plural, one {# اور} other {# اور لوگوں}} نے آپ کے تبصرے پر ردعمل ظاہر کیا',
note: 'The "other" selector keyword was translated to "دوسرے", leaving no other clause.',
},
'ko-kr': {
value:
'<name>[%1$s:author]</name>님과 {count, plural, one {# 명} other {# 명}}이 회원님의 댓글에 반응했습니다.',
note: 'Plural selector syntax was mangled (stray comma between branches), failing EXPECT_PLURAL_ARGUMENT_SELECTOR_FRAGMENT.',
},
},
},
{
project: 'dotcom',
key: 'e65b2dcb6f',
english: '<name>[%1$s:a]</name> and <name>[%1$s:b]</name> reacted to your comment',
translations: {
ur: {
value: '<name>[%1$s:a]</name> اور <name>[%1$s:b]</name> نے آپ کے تبصرے پر ردعمل ظاہر کیا',
note: 'Second placeholder was corrupted to "[%1$s:b>", so {b} never renders.',
},
},
},
{
project: 'dotcom',
key: 'f476ba8646',
english:
'<name>[%1$s:a]</name>, <name>[%1$s:b]</name> and {count, plural, one {# other} other {# others}} reacted to your comment',
translations: {
so: {
value:
'<name>[%1$s:a]</name>, <name>[%1$s:b]</name> iyo {count, plural, one {# qof kale} other {# dad kale}} ayaa ka falceliyay faalladaada',
note: 'Tag names were translated (<magac> opened, </mage> closed — unmatched) and ICU keywords were translated.',
},
it: {
value:
'<name>[%1$s:a]</name>, <name>[%1$s:b]</name> e {count, plural, one {# altro} other {# altri}} hanno reagito al tuo commento',
note: 'ICU keywords were translated ("plurale", "uno", "altro").',
},
ms: {
value:
'<name>[%1$s:a]</name>, <name>[%1$s:b]</name> dan {count, plural, one {# lagi} other {# lagi}} telah memberi reaksi terhadap komen anda',
note: 'ICU keywords were translated ("jamak", "seorang") and the other clause was missing.',
},
el: {
value:
'<name>[%1$s:a]</name>, <name>[%1$s:b]</name> και {count, plural, one {# ακόμη άτομο} other {# ακόμη άτομα}} αντέδρασαν στο σχόλιό σας',
note: 'The "other" selector keyword was translated to "άλλοι", leaving no other clause.',
},
ur: {
value:
'<name>[%1$s:a]</name>، <name>[%1$s:b]</name> اور {count, plural, one {# اور} other {# اور لوگوں}} نے آپ کے تبصرے پر ردعمل ظاہر کیا',
note: 'Second <name> tag was corrupted to "[%1$s:b>" (placeholder and tag merged), losing {b}; the other clause keyword was also translated.',
},
tl: {
value:
'Sina <name>[%1$s:a]</name>, <name>[%1$s:b]</name> at {count, plural, one {# pa} other {# pa}} ang nag-react sa iyong komento',
note: 'Plural selector keywords were translated ("isa", "iba pang"), failing EXPECT_PLURAL_ARGUMENT_SELECTOR_FRAGMENT.',
},
},
},
{
project: 'dotcom',
key: 'f5ddc8d9c1',
english: 'For now, you can <a>export as a [%1$s:extension] file</a> to use it here.',
translations: {
'pt-pt': {
value:
'Por enquanto, você pode <a>exportar como um arquivo [%1$s:extension]</a> para usá-lo aqui.',
note: 'Placeholder name was localised ([%1$s:extensão]), so it never interpolated at runtime.',
},
'pt-br': {
value:
'Por enquanto, você pode <a>exportar como um arquivo [%1$s:extension]</a> para usá-lo aqui.',
note: 'Placeholder name was localised ([%1$s:extensão]), so it never interpolated at runtime.',
},
nl: {
value:
'Voorlopig kun je het <a>exporteren als een [%1$s:extension] bestand</a> om het hier te gebruiken.',
note: 'Placeholder name was localised ([%1$s:extensie]), so it never interpolated at runtime.',
},
'ko-kr': {
value:
'지금은 <a>[%1$s:extension] 파일로 내보냄</a>을 사용하여 여기에서 사용할 수 있습니다.',
note: 'Placeholder name was localised ([%1$s:확장자]), so it never interpolated at runtime.',
},
'zh-tw': {
value: '目前,您可以<a>匯出為 [%1$s:extension] 檔案</a>以便在此處使用。',
note: 'The <a> link tag was dropped, so the export action had nothing to link.',
},
'zh-cn': {
value: '目前,您可以<a>导出为 [%1$s:extension] 文件</a>以便在此处使用。',
note: 'The <a> link tag was dropped, so the export action had nothing to link.',
},
ja: {
value:
'今のところ、<a>[%1$s:extension] ファイルとしてエクスポート</a>すればここで使用できます。',
note: 'The <a> link tag was dropped, so the export action had nothing to link.',
},
},
},
{
project: 'sdk',
key: 'action.copy-hovered-styles',
english: 'Copy hovered styles',
translations: {
sl: {
value: 'Kopiraj sloge oblike pod kazalcem',
note: 'Was a garbled run-on repeating "nad elementi, nad katerimi smo se nahajali nad elementi".',
},
hr: {
value: 'Kopiraj stilove oblika ispod pokazivača',
note: 'Was a garbled run-on repeating "iznad predmeta iznad kojeg".',
},
ro: {
value: 'Copiază stilurile formei de sub cursor',
note: 'Was "cu cursorul cursorului" — "cursor" duplicated.',
},
da: {
value: 'Kopiér typografier fra formen under markøren',
note: 'Was "svævende stilarter" (floating styles) — read "hovered" as "floating".',
},
nl: {
value: 'Kopieer stijlen van vorm onder de cursor',
note: 'Was "zwevende stijlen" (floating styles) — read "hovered" as "floating".',
},
ms: {
value: 'Salin gaya bentuk di bawah kursor',
note: 'Was "gaya berlegar" (hovering/floating styles).',
},
fi: {
value: 'Kopioi kursorin alla olevan muodon tyylit',
note: 'Was "hiirtä osoittavat tyylit" (styles pointing at the mouse) — the relationship was backwards.',
},
no: {
value: 'Kopier stiler fra formen under musepekeren',
note: 'Was "stiler som holdes over musepekeren" (styles held over the pointer) — backwards.',
},
'pt-pt': {
value: 'Copiar estilos da forma sob o cursor',
note: 'Was "estilos percorridos" (traversed styles).',
},
ur: {
value: 'کرسر کے نیچے شکل کے اسٹائل کاپی کریں',
note: 'Was "ہورڈ اسٹائل" — "hovered" mis-transliterated as "hoard".',
},
so: {
value: 'Koobi qaababka qaabka cursor-ka hoostiisa',
note: 'Was "Qaababka Nuqulka lagu dul hago" — word order made it "styles of the copy".',
},
sv: {
value: 'Kopiera stilar från formen under pekaren',
note: 'Was "hovrade stilar", an anglicism that is not idiomatic Swedish.',
},
},
},
{
project: 'sdk',
key: 'action.frame-selection',
english: 'Frame selection',
translations: {
de: {
value: 'Auswahl umrahmen',
note: 'Was "Rahmenauswahl" (choosing a frame); this action puts the selection into a frame.',
},
es: {
value: 'Enmarcar selección',
note: 'Was "Selección de marco" (selection of a frame).',
},
'pt-br': {
value: 'Enquadrar seleção',
note: 'Was "Seleção de moldura" (selection of a picture frame).',
},
'pt-pt': {
value: 'Enquadrar seleção',
note: 'Was "Seleção de molduras" (selection of picture frames).',
},
it: {
value: 'Inquadra selezione',
note: 'Was "Selezione della cornice" (selection of the frame).',
},
nl: {
value: 'Selectie omkaderen',
note: 'Was "Frame selectie", an untranslated noun phrase.',
},
pl: {
value: 'Umieść zaznaczenie w ramce',
note: 'Was "Wybór ramki" (choice of frame).',
},
ru: {
value: 'Поместить выделение в рамку',
note: 'Was "Выбор рамки" (choice of frame).',
},
uk: {
value: 'Помістити вибране в рамку',
note: 'Was "Вибір кадру" (choice of a film frame).',
},
cs: {
value: 'Vložit výběr do rámce',
note: 'Was "Výběr rámu" (selection of a frame).',
},
sv: {
value: 'Rama in markering',
note: 'Was "Val av ram" (choice of frame).',
},
da: {
value: 'Indram markering',
note: 'Was "Valg af ramme" (choice of frame).',
},
no: {
value: 'Ramme inn utvalg',
note: 'Was "Rammevalg" (frame choice).',
},
fi: {
value: 'Kehystä valinta',
note: 'Was "Kehyksen valinta" (selection of the frame).',
},
tr: {
value: 'Seçimi çerçevele',
note: 'Was "Çerçeve seçimi" (frame selection as a noun).',
},
el: {
value: 'Πλαισίωση επιλογής',
note: 'Was "Επιλογή πλαισίου" (choice of frame).',
},
'ko-kr': {
value: '선택 항목을 프레임으로',
note: 'Was "프레임 선택" (select a frame).',
},
'zh-cn': {
value: '将所选内容放入画框',
note: 'Was "框架选择" (frame selection as a noun).',
},
'zh-tw': {
value: '將選取範圍放入畫框',
note: 'Was "選取畫框" (select the frame).',
},
ca: {
value: 'Emmarca la selecció',
note: 'Was "Selecció de fotograma" (selection of a film frame).',
},
gl: {
value: 'Enmarcar a selección',
note: 'Was "Selección de fotogramas" (selection of film frames).',
},
hu: {
value: 'Kijelölés keretezése',
note: 'Was "Keretkiválasztás" (frame selection as a noun).',
},
ro: {
value: 'Încadrează selecția',
note: 'Was "Selectarea cadrului" (selecting the frame).',
},
hr: {
value: 'Uokviri odabir',
note: 'Was "Odabir okvira" (choice of frame).',
},
sl: {
value: 'Uokviri izbor',
note: 'Was "Izbira okvirja" (choice of frame).',
},
id: {
value: 'Bingkai pilihan',
note: 'Was "Pemilihan bingkai" (choosing a frame).',
},
ms: {
value: 'Bingkaikan pilihan',
note: 'Was "Pemilihan bingkai" (choosing a frame).',
},
th: {
value: 'ใส่กรอบให้สิ่งที่เลือก',
note: 'Was "ตัวเลือกกรอบ" (frame options).',
},
he: {
value: 'מסגור הבחירה',
note: 'Was "בחירת מסגרת" (choosing a frame).',
},
ar: {
value: 'تأطير التحديد',
note: 'Was "اختيار الإطار" (choosing the frame).',
},
fa: {
value: 'قاب‌بندی انتخاب',
note: 'Was "انتخاب قاب" (choosing a frame).',
},
'hi-in': {
value: 'चयन को फ़्रेम करें',
note: 'Was "फ्रेम चयन" (frame selection as a noun).',
},
bn: {
value: 'নির্বাচনকে ফ্রেম করুন',
note: 'Was "ফ্রেম নির্বাচন" (frame selection as a noun).',
},
ur: {
value: 'انتخاب کو فریم کریں',
note: 'Was "فریم کا انتخاب" (choice of frame).',
},
ta: {
value: 'தேர்வைச் சட்டகமிடு',
note: 'Was "சட்டகத் தேர்வு" (frame selection as a noun).',
},
te: {
value: 'ఎంపికను ఫ్రేమ్ చేయండి',
note: 'Was "ఫ్రేమ్ ఎంపిక" (frame selection as a noun).',
},
kn: {
value: 'ಆಯ್ಕೆಯನ್ನು ಫ್ರೇಮ್ ಮಾಡಿ',
note: 'Was "ಫ್ರೇಮ್ ಆಯ್ಕೆ" (frame selection as a noun).',
},
ml: {
value: 'തിരഞ്ഞെടുപ്പ് ഫ്രെയിം ചെയ്യുക',
note: 'Was "ഫ്രെയിം തിരഞ്ഞെടുക്കൽ" (choosing a frame).',
},
mr: {
value: 'निवड फ्रेम करा',
note: 'Was "फ्रेम निवड" (frame selection as a noun).',
},
'gu-in': {
value: 'પસંદગીને ફ્રેમ કરો',
note: 'Was "ફ્રેમ પસંદગી" (frame selection as a noun).',
},
pa: {
value: 'ਚੋਣ ਨੂੰ ਫਰੇਮ ਕਰੋ',
note: 'Was "ਫਰੇਮ ਚੋਣ" (frame selection as a noun).',
},
ne: {
value: 'चयनलाई फ्रेम गर्नुहोस्',
note: 'Was "फ्रेम चयन" (frame selection as a noun).',
},
'km-kh': {
value: 'ដាក់ស៊ុមលើជម្រើស',
note: 'Was "ការជ្រើសរើសស៊ុម" (choosing a frame).',
},
tl: {
value: 'I-frame ang napili',
note: 'Was "Pagpili ng frame" (choosing a frame).',
},
so: {
value: 'Ku duub xulashada',
note: 'Was "Xulashada qaab-dhismeedka" (selection of the structure).',
},
},
},
{
project: 'sdk',
key: 'comments.mention-you',
english: 'You',
translations: {
'pt-pt': {
value: 'Você',
note: 'Was "Eu" ("I/me"), which contradicts the English label and diverges from pt-BR.',
},
ms: {
value: 'Anda',
note: 'Was "Awak", the informal second person; the rest of the Malay UI uses "Anda".',
},
bn: {
value: 'আপনি',
note: 'Was "তুমি", the intimate second person.',
},
ml: {
value: 'നിങ്ങൾ',
note: 'Was "നീ", the intimate second person.',
},
hr: {
value: 'Vi',
note: 'Was "Vas", the accusative form; a standalone label takes the nominative.',
},
he: {
value: 'אתה',
note: 'Was "אַתָה" with vocalisation marks, which no UI string in this project uses.',
},
},
},
{
project: 'sdk',
key: 'comments.only-unread',
english: 'Only unread',
translations: {
fr: {
value: 'Non lus uniquement',
note: 'Was "Seulement les pages non lues" — filters comments, not pages.',
},
tl: {
value: 'Hindi pa nababasa lamang',
note: 'Was "Hindi pa nababasa" — dropped "Only", making it identical to the Unread label.',
},
},
},
{
project: 'sdk',
key: 'comments.pin-label',
english: 'Comment by {name}',
translations: {
nl: {
value: 'Reactie van {name}',
note: 'Placeholder name was localised ({naam}), so it never interpolated at runtime.',
},
sv: {
value: 'Kommentar av {name}',
note: 'Placeholder name was localised ({namn}), so it never interpolated at runtime.',
},
id: {
value: 'Komentar oleh {name}',
note: 'Placeholder name was localised ({nama}), so it never interpolated at runtime.',
},
no: {
value: 'Kommentar av {name}',
note: 'Placeholder name was localised ({navn}), so it never interpolated at runtime.',
},
da: {
value: 'Kommentar af {name}',
note: 'Placeholder name was localised ({navn}), so it never interpolated at runtime.',
},
pa: {
value: '{name} ਦੁਆਰਾ ਟਿੱਪਣੀ',
note: 'Placeholder name was localised ({ਨਾਮ}), so it never interpolated at runtime.',
},
ne: {
value: '{name} द्वारा टिप्पणी',
note: 'Placeholder name was localised ({नाम}), so it never interpolated at runtime.',
},
'gu-in': {
value: '{name} દ્વારા ટિપ્પણી',
note: 'Placeholder name was localised ({નામ}), so it never interpolated at runtime.',
},
'km-kh': {
value: 'មតិយោបល់ដោយ {name}',
note: 'Placeholder name was localised ({ឈ្មោះ}), so it never interpolated at runtime.',
},
},
},
{
project: 'sdk',
key: 'comments.pin-label-resolved',
english: 'Resolved comment by {name}',
translations: {
nl: {
value: 'Reactie van {name} opgelost',
note: 'Placeholder name was localised ({naam}), so it never interpolated at runtime.',
},
id: {
value: 'Komentar oleh {name} telah diselesaikan.',
note: 'Placeholder name was localised ({nama}), so it never interpolated at runtime.',
},
},
},
{
project: 'sdk',
key: 'comments.preview-more',
english: '{count} more',
translations: {
fa: {
value: '{count} بیشتر',
note: 'Placeholder name was localised ({تعداد}), so it never interpolated at runtime.',
},
ms: {
value: '{count} lagi',
note: 'Placeholder name was localised ({kiraan}), so it never interpolated at runtime.',
},
id: {
value: '{count} lagi',
note: 'Placeholder name was localised ({hitung}), so it never interpolated at runtime.',
},
no: {
value: '{count} flere',
note: 'Placeholder name was localised ({antall}), so it never interpolated at runtime.',
},
da: {
value: '{count} mere',
note: 'Placeholder name was localised ({antal}), so it never interpolated at runtime.',
},
hu: {
value: '{count} további',
note: 'Placeholder name was localised ({szám}), so it never interpolated at runtime.',
},
pa: {
value: '{count} ਹੋਰ',
note: 'Placeholder name was localised ({ਗਿਣਤੀ}), so it never interpolated at runtime.',
},
hr: {
value: 'još {count}',
note: 'Placeholder name was localised ({broj}), so it never interpolated at runtime.',
},
ne: {
value: '{count} बढी',
note: 'Placeholder name was localised ({गन्ती}), so it never interpolated at runtime.',
},
'gu-in': {
value: '{count} વધુ',
note: 'Placeholder name was localised ({ગણતરી}), so it never interpolated at runtime.',
},
so: {
value: '{count} wax badan',
note: 'Placeholder name was localised ({tirin}), so it never interpolated at runtime.',
},
tl: {
value: '{count} pa',
note: 'Placeholder name was localised ({bilangin}), so it never interpolated at runtime.',
},
kn: {
value: 'ಇನ್ನೂ {count}',
note: 'Placeholder name was localised ({ಎಣಿಕೆ}), so it never interpolated at runtime.',
},
ml: {
value: '{count} കൂടി',
note: 'Placeholder name was localised ({എണ്ണുക}), so it never interpolated at runtime.',
},
'km-kh': {
value: '{count} ទៀត',
note: 'The placeholder name was translated out of the braces, leaving an empty {} that never interpolates.',
},
ta: {
value: 'மேலும் {count}',
note: 'The {count} placeholder was dropped entirely, so no number showed.',
},
},
},
{
project: 'sdk',
key: 'comments.reacted-1',
english: '{a} reacted',
translations: {
he: {
value: '{a} הגיב',
note: 'Placeholder name was localised ({א}), so it never interpolated at runtime.',
},
sv: {
value: '{a} reagerade',
note: 'The {a} placeholder was dropped, so the string read just "reagerade" with no name.',
},
},
},
{
project: 'sdk',
key: 'comments.reacted-2',
english: '{a} and {b} reacted',
translations: {
fa: {
value: '{a} و {b} واکنش نشان دادند',
note: 'Placeholder name was localised ({الف}, {ب}), so it never interpolated at runtime.',
},
he: {
value: '{a} ו-{b} הגיבו',
note: 'Placeholder name was localised ({א}, {ב}), so it never interpolated at runtime.',
},
},
},
{
project: 'sdk',
key: 'comments.reacted-3',
english: '{a}, {b} and {c} reacted',
translations: {
he: {
value: '{a}, {b} ו-{c} הגיבו',
note: 'Placeholder name was localised ({א}, {ב}, {ג}), so it never interpolated at runtime.',
},
},
},
{
project: 'sdk',
key: 'comments.reacted-more',
english: '{a}, {b}, {c} and {count} others reacted',
translations: {
pa: {
value: '{a}, {b}, {c} ਅਤੇ {count} ਹੋਰਾਂ ਨੇ ਪ੍ਰਤੀਕਿਰਿਆ ਦਿੱਤੀ',
note: 'Placeholder name was localised ({a}, {b}, {c}, {ਗਿਣਤੀ}), so it never interpolated at runtime.',
},
'gu-in': {
value: '{a}, {b}, {c} અને {count} અન્ય લોકોએ પ્રતિક્રિયા આપી',
note: 'Placeholder name was localised ({a}, {b}, {c}, {ગણતરી}), so it never interpolated at runtime.',
},
ml: {
value: '{a}, {b}, {c} എന്നിവരും മറ്റ് {count} പേരും പ്രതികരിച്ചു',
note: 'The {count} placeholder was dropped, so the number of other reactors never showed.',
},
},
},
{
project: 'sdk',
key: 'comments.reopen',
english: 'Reopen',
translations: {
el: {
value: 'Άνοιγμα ξανά',
note: 'Was "Ξανανοίγω" — first-person "I reopen".',
},
hr: {
value: 'Ponovno otvori',
note: 'Was "Ponovo otvoriti", the infinitive; buttons take the imperative.',
},
pl: {
value: 'Otwórz ponownie',
note: 'Was "Otworzyć na nowo", the infinitive.',
},
sl: {
value: 'Ponovno odpri',
note: 'Was "Ponovno odprto" — the adjective "reopened".',
},
'zh-tw': {
value: '重新開啟',
note: 'Was "重新開放" — reopening to the public.',
},
},
},
{
project: 'sdk',
key: 'comments.replies',
english: '{count} replies',
translations: {
hr: {
value: '{count} odgovora',
note: 'Placeholder name was localised ({broj}), so it never interpolated at runtime.',
},
ne: {
value: '{count} उत्तरहरू',
note: 'Placeholder name was localised ({गणना}), so it never interpolated at runtime.',
},
'gu-in': {
value: '{count} જવાબો',
note: 'Placeholder name was localised ({ગણતરી}), so it never interpolated at runtime.',
},
'km-kh': {
value: 'ចម្លើយចំនួន {count}',
note: 'Placeholder name was localised ({រាប់}), so it never interpolated at runtime.',
},
so: {
value: '{count} jawaabo',
note: 'Placeholder name was localised ({tiro}), so it never interpolated at runtime.',
},
sl: {
value: '{count} odgovorov',
note: 'Placeholder name was localised ({število}), so it never interpolated at runtime.',
},
},
},
{
project: 'sdk',
key: 'comments.resolve',
english: 'Resolve',
translations: {
cs: {
value: 'Vyřešit',
note: 'Was "Odhodlání" — the noun "resolve/determination" (willpower), not the verb.',
},
hr: {
value: 'Riješi',
note: 'Was "Odlučnost" — the noun "decisiveness".',
},
hu: {
value: 'Megoldás',
note: 'Was "Elhatározás" — the noun "resolution/determination" (willpower).',
},
el: {
value: 'Επίλυση',
note: 'Was "Αποφασίζω" — first-person "I decide".',
},
ar: {
value: 'حل',
note: 'Was "الحل" — the definite noun "the solution".',
},
fa: {
value: 'حل کردن',
note: 'Was "حل و فصل" — legal "adjudication/settlement".',
},
pl: {
value: 'Rozwiąż',
note: 'Was "Rozstrzygać" — imperfective infinitive "to adjudicate".',
},
ru: {
value: 'Решить',
note: 'Was "Урегулировать" — "to settle/regulate", wrong register for a thread action.',
},
th: {
value: 'ทำเครื่องหมายว่าแก้ไขแล้ว',
note: 'Was "แก้ปัญหา" — "solve a problem".',
},
fi: {
value: 'Ratkaise',
note: 'Was "Ratkaista", the infinitive; buttons take the imperative.',
},
sv: {
value: 'Lös',
note: 'Was "Lösa", the infinitive; buttons take the imperative.',
},
no: {
value: 'Løs',
note: 'Was "Løse", the infinitive; buttons take the imperative.',
},
ro: {
value: 'Rezolvă',
note: 'Was "Rezolva", missing the imperative form.',
},
ta: {
value: 'தீர்க்கப்பட்டதாகக் குறி',
note: 'Was "தீர்வு" — the noun "solution".',
},
he: {
value: 'סגור נושא',
note: 'Was "לִפְתוֹר" — vocalised infinitive "to solve", wrong register for UI.',
},
de: {
value: 'Erledigen',
note: 'Was "Klären" (clarify); "Erledigen" is what German UIs use for resolving a comment thread.',
},
},
},
{
project: 'sdk',
key: 'comments.resolved',
english: 'Resolved',
translations: {
el: {
value: 'Επιλύθηκε',
note: 'Was "Αποφασισμένος" — masculine adjective "determined (person)".',
},
ru: {
value: 'Решено',
note: 'Was "Урегулированы" — "settled/regulated", wrong register and plural agreement.',
},
th: {
value: 'แก้ไขแล้ว',
note: 'Was "แก้ปัญหาแล้ว" — "the problem was solved".',
},
de: {
value: 'Erledigt',
note: 'Was "Geklärt" (clarified); matches the "Erledigen" action.',
},
},
},
{
project: 'sdk',
key: 'comments.resolved-by',
english: 'Resolved by {name}',
translations: {
nl: {
value: 'Opgelost door {name}',
note: 'Placeholder name was localised ({naam}), so it never interpolated at runtime.',
},
sv: {
value: 'Löst av {name}',
note: 'Placeholder name was localised ({namn}), so it never interpolated at runtime.',
},
id: {
value: 'Diselesaikan oleh {name}',
note: 'Placeholder name was localised ({nama}), so it never interpolated at runtime.',
},
no: {
value: 'Løst av {name}',
note: 'Placeholder name was localised ({navn}), so it never interpolated at runtime.',
},
el: {
value: 'Επιλύθηκε από {name}',
note: 'Placeholder name was localised ({όνομα}), so it never interpolated at runtime.',
},
da: {
value: 'Løst af {name}',
note: 'Placeholder name was localised ({navn}), so it never interpolated at runtime.',
},
kn: {
value: '{name} ಅವರಿಂದ ಪರಿಹರಿಸಲಾಗಿದೆ',
note: 'Placeholder name was localised ({ಹೆಸರು}), so it never interpolated at runtime.',
},
},
},
{
project: 'sdk',
key: 'comments.stack-label',
english: '{count} comments at this spot',
translations: {
'gu-in': {
value: 'આ સ્થાન પર {count} ટિપ્પણીઓ',
note: 'Placeholder name was localised ({ગણતરી}), so it never interpolated at runtime.',
},
},
},
// Note: the workspace-invite scramble found in the auto-generated i18n PR lived on
// key a8c9ad7ea5, which no longer exists in Lokalise — the English string was edited
// (now key 23dcace54a, "...Create a free account to continue.") and is currently
// untranslated. Re-review those invite strings after the next translation order.
// When adding placeholder-bearing fixes, write `value` in Lokalise's native
// format, e.g. `[%1$s:workspaceName]`. Validation matches it against the ICU
// `{workspaceName}` that the export produces by variable name, so the two forms
// line up — but the value written to Lokalise must be the native one.
]
function formatError(error: unknown) {
if (error instanceof Error) {
return error.stack ?? error.message
}
try {
return JSON.stringify(error, null, 2)
} catch {
return String(error)
}
}
function getEnv(name: string) {
const value = process.env[name]
if (!value) throw new Error(`Missing required env var: ${name}`)
return value
}
/** Lokalise uses ISO codes like `ko_KR`; the repo uses `ko-kr`. Normalize both for matching. */
function normalizeIso(iso: string) {
return iso.toLowerCase().replace(/_/g, '-')
}
/** A key name is per-platform in Lokalise; the hashed key is identical across platforms. */
function keyNameMatches(key: Key, name: string) {
const keyName = key.key_name as unknown
if (typeof keyName === 'string') return keyName === name
if (keyName && typeof keyName === 'object') return Object.values(keyName).includes(name)
return false
}
/**
* Reduce a placeholder or tag to a format-independent identity, so the same
* variable matches across the formats this pipeline mixes:
* - ICU `{workspaceName}` — what the Lokalise export produces (used in `english`)
* - Lokalise native `[%1$s:workspaceName]` — preferred in corrected `value`s
* - bare printf `%1$s` / `%s` / `%d` / `%@`
* Named placeholders collapse to `var:<name>`; unnamed positional ones to
* `pos:<n>` (or `pos:<order>` when unindexed). HTML tags become `tag:<name>` /
* `tag:/<name>`, attributes ignored, so open/close balance is still checked.
*/
function placeholderIdentities(text: string): string[] {
const ids: string[] = []
let unnamed = 0
// HTML tags: <b> / </b> / <br/>, attributes ignored.
for (const m of text.matchAll(/<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g)) {
ids.push(`tag:${m[1]}${m[2].toLowerCase()}`)
}
// Lokalise placeholders `[% … ]`: named -> var:<name>, otherwise positional.
// Strip them so the bare-printf scan below doesn't also match their insides.
const stripped = text.replace(/\[%([^\]]*)\]/g, (_full, inner: string) => {
const named = inner.match(/:([a-zA-Z0-9_]+)\s*$/)
if (named) {
ids.push(`var:${named[1]}`)
} else {
const indexed = inner.match(/(\d+)\$/)
ids.push(indexed ? `pos:${indexed[1]}` : `pos:${unnamed++}`)
}
return ''
})
// ICU placeholders `{name}`.
for (const m of stripped.matchAll(/\{([a-zA-Z0-9_]+)\}/g)) ids.push(`var:${m[1]}`)
// Bare printf specifiers: `%1$s` (indexed) and `%s` / `%d` / `%@` (unindexed).
for (const m of stripped.matchAll(/%(\d+)\$?[sd@]/g)) ids.push(`pos:${m[1]}`)
for (const _m of stripped.matchAll(/%[sd@]/g)) ids.push(`pos:${unnamed++}`)
return ids
}
/** Placeholder/tag identities present in `required` but missing (or under-counted) in `candidate`. */
function missingPlaceholders(required: string, candidate: string): string[] {
const have = new Map<string, number>()
for (const id of placeholderIdentities(candidate)) {
have.set(id, (have.get(id) ?? 0) + 1)
}
const missing: string[] = []
for (const id of placeholderIdentities(required)) {
const count = have.get(id) ?? 0
if (count <= 0) missing.push(id)
else have.set(id, count - 1)
}
return missing
}
/** Flatten a plural map to one string so placeholder checks see every form. */
function fixValueText(value: FixValue) {
return typeof value === 'string' ? value : Object.values(value).join(' ')
}
function parseProjectArg(): ProjectName {
const arg = process.argv.find((a) => a.startsWith('--project='))?.split('=')[1]
if (arg === 'sdk' || arg === 'dotcom') return arg
throw new Error(
`Pass --project=sdk or --project=dotcom. A run targets one Lokalise project at a time.`
)
}
async function i18nFixStrings() {
const apply = process.argv.includes('--apply')
const markReviewed = process.argv.includes('--mark-reviewed')
const project = parseProjectArg()
const projectId = getEnv(PROJECT_ENV_VAR[project])
const apiKey = getEnv('LOKALISE_API_TOKEN')
const lokaliseApi = new LokaliseApi({ apiKey })
const fixes = FIXES.filter((fix) => fix.project === project)
console.log(
apply
? `Applying ${project} translation fixes to Lokalise...`
: `Dry run for ${project} — pass --apply to write these fixes to Lokalise.`
)
let applied = 0
let alreadyCorrect = 0
let skipped = 0
let refused = 0
const failed: string[] = []
for (const fix of fixes) {
// Lokalise allows six requests a second per token, and a full run makes one
// lookup per key plus one write per corrected translation. Space them out so
// a large batch doesn't die halfway through on a 429.
await sleep(REQUEST_INTERVAL_MS)
const { items } = await lokaliseApi.keys().list({
project_id: projectId,
filter_keys: fix.key,
include_translations: 1,
limit: 100,
})
const key = items.find((item) => keyNameMatches(item, fix.key))
if (!key) {
console.warn(`\n⚠ Key ${fix.key} ("${fix.english}") not found in project — skipping.`)
skipped += Object.keys(fix.translations).length
continue
}
console.log(`\n${fix.key} — "${fix.english}"`)
for (const [locale, { value, note }] of Object.entries(fix.translations)) {
const translation = key.translations.find(
(t) => normalizeIso(t.language_iso) === normalizeIso(locale)
)
if (!translation) {
console.warn(` ${locale}: no translation found in Lokalise — skipping.`)
skipped++
continue
}
const current = translation.translation
const currentText = typeof current === 'string' ? current : JSON.stringify(current)
const valueText = typeof value === 'string' ? value : JSON.stringify(value)
if (currentText === valueText) {
console.log(` ${locale}: already correct.`)
alreadyCorrect++
continue
}
const missing = missingPlaceholders(fix.english, fixValueText(value))
if (missing.length) {
console.error(
` ${locale}: REFUSED — corrected value is missing placeholder(s)/tag(s): ${missing.join(', ')}`
)
refused++
continue
}
console.log(` ${locale}:`)
console.log(` - ${currentText}`)
console.log(` + ${valueText}`)
console.log(` why: ${note}`)
if (apply) {
await sleep(REQUEST_INTERVAL_MS)
try {
await lokaliseApi.translations().update(
translation.translation_id,
{
// Plural keys take a map of plural forms here, which the SDK's
// `string` type doesn't describe but the API accepts.
translation: value as string,
...(markReviewed ? { is_reviewed: true, is_unverified: false } : {}),
},
{ project_id: projectId }
)
applied++
} catch (error) {
// One rejected write shouldn't abandon the rest of the batch — a
// reviewed translation comes back as a 409 "Translation is locked",
// and the run is far more useful having applied everything else.
const message = error instanceof Error ? error.message : JSON.stringify(error)
console.error(` FAILED to write ${locale}: ${message}`)
failed.push(`${fix.key} ${locale}: ${message}`)
}
}
}
}
console.log(
`\nSummary: ${apply ? `updated ${applied}` : 'dry run (no changes written)'}, ` +
`already correct ${alreadyCorrect}, skipped ${skipped}, refused ${refused}, ` +
`failed ${failed.length}.`
)
if (failed.length) {
console.error(`\n${failed.length} translation(s) could not be written:`)
for (const line of failed) console.error(` ${line}`)
}
if (!apply) {
console.log('Re-run with --apply to write these changes to Lokalise.')
}
if (refused > 0 || failed.length > 0) {
process.exit(1)
}
}
i18nFixStrings().catch((error) => {
console.error('Failed to fix i18n strings:')
console.error(formatError(error))
process.exit(1)
})