@scoutello/i18n-magic 0.53.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/utils.ts CHANGED
@@ -3,6 +3,7 @@ import { Parser } from "i18next-scanner"
3
3
  import { minimatch } from "minimatch"
4
4
  import fs from "node:fs"
5
5
  import path from "node:path"
6
+ import { performance } from "node:perf_hooks"
6
7
  import type OpenAI from "openai"
7
8
  import prompts from "prompts"
8
9
  import { languages } from "./languges.js"
@@ -688,31 +689,53 @@ export class TranslationError extends Error {
688
689
  }
689
690
 
690
691
  /**
691
- * Add a translation key with an English value to the locale files.
692
- * This function always adds to the "en" locale, and if the defaultLocale is different,
693
- * it will also translate and save to the defaultLocale right away.
692
+ * Add a translation key with a value in a specific language to the locale files.
693
+ * This function adds to the specified language locale, and will also translate
694
+ * and save to other locales if OpenAI is configured.
694
695
  */
695
- export const addTranslationKey = async ({
696
- key,
697
- value,
696
+ /**
697
+ * Add multiple translation keys in batch. This is optimized for performance:
698
+ * - Single codebase scan for all keys
699
+ * - Batched file I/O operations
700
+ * - Batched translations per locale
701
+ */
702
+ export const addTranslationKeys = async ({
703
+ keys,
698
704
  config,
699
705
  }: {
700
- key: string
701
- value: string
706
+ keys: Array<{ key: string; value: string; language?: string }>
702
707
  config: Configuration
703
708
  }) => {
709
+ const startTime = performance.now()
704
710
  const { loadPath, savePath, defaultNamespace, namespaces, globPatterns, defaultLocale, openai, context, model } = config
705
711
 
706
- // Try to find which namespaces this key is used in by scanning the codebase
707
- const affectedNamespaces: string[] = []
708
-
712
+ if (keys.length === 0) {
713
+ return { results: [], performance: { totalTime: 0, scanTime: 0, translationTime: 0, fileIOTime: 0 } }
714
+ }
715
+
716
+ const log = console.log
717
+ log(`🚀 Batch adding ${keys.length} translation key(s)...`)
718
+
719
+ // Step 1: Single codebase scan for all keys (most expensive operation)
720
+ const scanStartTime = performance.now()
721
+ let keysWithNamespaces: Array<{ key: string; namespaces: string[]; file: string }> = []
709
722
  try {
710
- // Scan the codebase to find where this key is already being used
711
- const keysWithNamespaces = await getKeysWithNamespaces({
723
+ keysWithNamespaces = await getKeysWithNamespaces({
712
724
  globPatterns,
713
725
  defaultNamespace,
714
726
  })
727
+ } catch (error) {
728
+ console.error(`Warning: Failed to scan codebase for key usage: ${error}`)
729
+ }
730
+ const scanTime = performance.now() - scanStartTime
731
+ log(`⏱️ Codebase scan completed in ${scanTime.toFixed(2)}ms`)
715
732
 
733
+ // Step 2: Determine namespaces for each key
734
+ const keyToNamespaces = new Map<string, Set<string>>()
735
+
736
+ for (const { key, language = "en" } of keys) {
737
+ const affectedNamespaces: string[] = []
738
+
716
739
  // Find entries for this specific key
717
740
  const keyEntries = keysWithNamespaces.filter(
718
741
  (entry) => entry.key === key || entry.key === `${defaultNamespace}:${key}`
@@ -728,97 +751,219 @@ export const addTranslationKey = async ({
728
751
 
729
752
  if (foundNamespaces.size > 0) {
730
753
  affectedNamespaces.push(...Array.from(foundNamespaces))
731
- console.log(
732
- `🔍 Found key "${key}" in use across ${affectedNamespaces.length} namespace(s): ${affectedNamespaces.join(", ")}`
733
- )
754
+ } else {
755
+ // If the key is not found in the codebase, use the default namespace
756
+ affectedNamespaces.push(defaultNamespace)
734
757
  }
735
- } catch (error) {
736
- // If scanning fails, continue with fallback logic
737
- console.error(`Warning: Failed to scan codebase for key usage: ${error}`)
738
- }
739
758
 
740
- if (affectedNamespaces.length === 0) {
741
- // If the key is not found in the codebase, use the default namespace
742
- affectedNamespaces.push(defaultNamespace)
743
- console.log(
744
- `📝 Key "${key}" not found in codebase. Adding to default namespace "${defaultNamespace}".`
745
- )
759
+ keyToNamespaces.set(key, new Set(affectedNamespaces))
746
760
  }
747
761
 
748
- // Always use "en" as the locale for adding keys (English)
749
- const locale = "en"
750
-
751
- // Use console.error for logging when called from MCP server (console.log is suppressed)
752
- const log = console.log
753
-
754
- for (const targetNamespace of affectedNamespaces) {
755
- log(`➕ Adding translation key "${key}" to namespace "${targetNamespace}" (${locale})`)
756
-
757
- // Load existing keys for the English locale
758
- let existingKeys: Record<string, string>
759
- try {
760
- existingKeys = await loadLocalesFile(loadPath, locale, targetNamespace)
761
- } catch (error) {
762
- // If file doesn't exist, start with empty object
763
- log(`📄 Creating new namespace file for ${locale}/${targetNamespace}`)
764
- existingKeys = {}
762
+ // Step 3: Group keys by namespace and locale
763
+ const namespaceLocaleToKeys = new Map<string, Array<{ key: string; value: string; language: string }>>()
764
+
765
+ for (const { key, value, language = "en" } of keys) {
766
+ const namespaces = keyToNamespaces.get(key) || new Set([defaultNamespace])
767
+ for (const namespace of namespaces) {
768
+ const mapKey = `${namespace}:${language}`
769
+ if (!namespaceLocaleToKeys.has(mapKey)) {
770
+ namespaceLocaleToKeys.set(mapKey, [])
771
+ }
772
+ namespaceLocaleToKeys.get(mapKey)!.push({ key, value, language })
765
773
  }
774
+ }
766
775
 
767
- // Check if key already exists
768
- if (existingKeys[key]) {
769
- log(`⚠️ Key "${key}" already exists in ${locale}/${targetNamespace} with value: "${existingKeys[key]}"`)
770
- log(` Updating to new value: "${value}"`)
776
+ // Step 4: Batch load all locale files needed
777
+ const fileIOStartTime = performance.now()
778
+ const localeFiles = new Map<string, Record<string, string>>()
779
+ const loadPromises: Promise<void>[] = []
780
+
781
+ for (const [namespaceLocale, _] of namespaceLocaleToKeys) {
782
+ const [namespace, locale] = namespaceLocale.split(":")
783
+ const fileKey = `${locale}:${namespace}`
784
+
785
+ if (!localeFiles.has(fileKey)) {
786
+ loadPromises.push(
787
+ loadLocalesFile(loadPath, locale, namespace)
788
+ .then((keys) => {
789
+ localeFiles.set(fileKey, keys)
790
+ })
791
+ .catch(() => {
792
+ localeFiles.set(fileKey, {})
793
+ })
794
+ )
771
795
  }
796
+ }
797
+
798
+ await Promise.all(loadPromises)
799
+ const fileIOTime = performance.now() - fileIOStartTime
800
+ log(`⏱️ File I/O (load) completed in ${fileIOTime.toFixed(2)}ms`)
801
+
802
+ // Step 5: Add keys to loaded files
803
+ for (const [namespaceLocale, keyValues] of namespaceLocaleToKeys) {
804
+ const [namespace, locale] = namespaceLocale.split(":")
805
+ const fileKey = `${locale}:${namespace}`
806
+ const existingKeys = localeFiles.get(fileKey) || {}
807
+
808
+ for (const { key, value } of keyValues) {
809
+ existingKeys[key] = value
810
+ }
811
+
812
+ localeFiles.set(fileKey, existingKeys)
813
+ }
772
814
 
773
- // Add or update the key
774
- existingKeys[key] = value
775
-
776
- // Save the updated keys using the writeLocalesFile function
777
- await writeLocalesFile(savePath, locale, targetNamespace, existingKeys)
778
- log(`✅ Successfully saved key to ${locale}/${targetNamespace}`)
815
+ // Step 6: Batch translate if OpenAI is configured
816
+ const translationStartTime = performance.now()
817
+ const translationCache = new Map<string, Record<string, string>>()
818
+
819
+ if (openai) {
820
+ // Group keys by input language
821
+ const keysByLanguage = new Map<string, Array<{ key: string; value: string; namespaces: Set<string> }>>()
822
+
823
+ for (const { key, value, language = "en" } of keys) {
824
+ if (!keysByLanguage.has(language)) {
825
+ keysByLanguage.set(language, [])
826
+ }
827
+ keysByLanguage.get(language)!.push({
828
+ key,
829
+ value,
830
+ namespaces: keyToNamespaces.get(key) || new Set([defaultNamespace]),
831
+ })
832
+ }
779
833
 
780
- // If defaultLocale is different from "en", translate and save to defaultLocale
781
- if (defaultLocale !== "en" && openai) {
782
- log(`🌐 Translating key "${key}" to ${defaultLocale}...`)
834
+ // Translate each language group to all other locales
835
+ const translationPromises: Promise<void>[] = []
836
+
837
+ for (const [inputLanguage, keyValues] of keysByLanguage) {
838
+ const otherLocales = config.locales.filter(l => l !== inputLanguage)
783
839
 
784
- try {
785
- // Translate the single key
786
- const translatedValue = await translateKey({
787
- inputLanguage: "en",
788
- outputLanguage: defaultLocale,
789
- context: context || "",
790
- object: { [key]: value },
791
- openai,
792
- model,
793
- })
794
-
795
- // Load existing keys for the default locale
796
- let defaultLocaleKeys: Record<string, string>
797
- try {
798
- defaultLocaleKeys = await loadLocalesFile(loadPath, defaultLocale, targetNamespace)
799
- } catch (error) {
800
- // If file doesn't exist, start with empty object
801
- log(`📄 Creating new namespace file for ${defaultLocale}/${targetNamespace}`)
802
- defaultLocaleKeys = {}
840
+ for (const targetLocale of otherLocales) {
841
+ const keysToTranslate = Object.fromEntries(
842
+ keyValues.map(({ key, value }) => [key, value])
843
+ )
844
+
845
+ translationPromises.push(
846
+ translateKey({
847
+ inputLanguage,
848
+ outputLanguage: targetLocale,
849
+ context: context || "",
850
+ object: keysToTranslate,
851
+ openai,
852
+ model,
853
+ })
854
+ .then((translated) => {
855
+ translationCache.set(`${inputLanguage}:${targetLocale}`, translated)
856
+ })
857
+ .catch((error) => {
858
+ log(`⚠️ Failed to translate ${keyValues.length} key(s) from ${inputLanguage} to ${targetLocale}: ${error instanceof Error ? error.message : "Unknown error"}`)
859
+ })
860
+ )
861
+ }
862
+ }
863
+
864
+ await Promise.all(translationPromises)
865
+
866
+ // Add translated keys to locale files
867
+ for (const [inputLanguage, keyValues] of keysByLanguage) {
868
+ const otherLocales = config.locales.filter(l => l !== inputLanguage)
869
+
870
+ for (const targetLocale of otherLocales) {
871
+ const translated = translationCache.get(`${inputLanguage}:${targetLocale}`)
872
+ if (!translated) continue
873
+
874
+ for (const { key, namespaces } of keyValues) {
875
+ if (translated[key]) {
876
+ for (const namespace of namespaces) {
877
+ const fileKey = `${targetLocale}:${namespace}`
878
+ if (!localeFiles.has(fileKey)) {
879
+ localeFiles.set(fileKey, {})
880
+ }
881
+ localeFiles.get(fileKey)![key] = translated[key]
882
+ }
883
+ }
803
884
  }
804
-
805
- // Add the translated key
806
- defaultLocaleKeys[key] = translatedValue[key]
807
-
808
- // Save the updated keys
809
- await writeLocalesFile(savePath, defaultLocale, targetNamespace, defaultLocaleKeys)
810
- log(`✅ Successfully translated and saved key to ${defaultLocale}/${targetNamespace}`)
811
- } catch (error) {
812
- log(`⚠️ Failed to translate key to ${defaultLocale}: ${error instanceof Error ? error.message : "Unknown error"}`)
813
- log(` You can run 'i18n-magic sync' to translate this key later`)
814
885
  }
815
886
  }
816
887
  }
888
+
889
+ const translationTime = performance.now() - translationStartTime
890
+ if (openai && translationTime > 0) {
891
+ log(`⏱️ Translation completed in ${translationTime.toFixed(2)}ms`)
892
+ }
893
+
894
+ // Step 7: Batch write all files
895
+ const writeStartTime = performance.now()
896
+ const writePromises: Promise<void>[] = []
897
+
898
+ for (const [fileKey, keys] of localeFiles) {
899
+ const [locale, namespace] = fileKey.split(":")
900
+ writePromises.push(
901
+ writeLocalesFile(savePath, locale, namespace, keys)
902
+ )
903
+ }
904
+
905
+ await Promise.all(writePromises)
906
+ const writeTime = performance.now() - writeStartTime
907
+ log(`⏱️ File I/O (write) completed in ${writeTime.toFixed(2)}ms`)
908
+
909
+ const totalTime = performance.now() - startTime
910
+ log(`✅ Batch operation completed in ${totalTime.toFixed(2)}ms (${(totalTime / keys.length).toFixed(2)}ms per key)`)
911
+
912
+ // Build results
913
+ const results = keys.map(({ key, value, language = "en" }) => {
914
+ const namespaces = Array.from(keyToNamespaces.get(key) || new Set([defaultNamespace]))
915
+ const savedLocales = new Set<string>([language])
916
+
917
+ if (openai) {
918
+ config.locales.forEach(locale => {
919
+ if (locale !== language) {
920
+ const translated = translationCache.get(`${language}:${locale}`)
921
+ if (translated?.[key]) {
922
+ savedLocales.add(locale)
923
+ }
924
+ }
925
+ })
926
+ }
927
+
928
+ return {
929
+ key,
930
+ value,
931
+ namespace: namespaces.join(", "),
932
+ locale: Array.from(savedLocales).sort().join(", "),
933
+ }
934
+ })
817
935
 
818
936
  return {
819
- key,
820
- value,
821
- namespace: affectedNamespaces.join(", "), // Return all affected namespaces
822
- locale: defaultLocale !== "en" ? `en, ${defaultLocale}` : locale,
937
+ results,
938
+ performance: {
939
+ totalTime,
940
+ scanTime,
941
+ translationTime,
942
+ fileIOTime: fileIOTime + writeTime,
943
+ },
823
944
  }
824
945
  }
946
+
947
+ export const addTranslationKey = async ({
948
+ key,
949
+ value,
950
+ language = "en",
951
+ config,
952
+ }: {
953
+ key: string
954
+ value: string
955
+ language?: string
956
+ config: Configuration
957
+ }) => {
958
+ const startTime = performance.now()
959
+ const result = await addTranslationKeys({
960
+ keys: [{ key, value, language }],
961
+ config,
962
+ })
963
+ const totalTime = performance.now() - startTime
964
+
965
+ const log = console.log
966
+ log(`⏱️ Single key operation completed in ${totalTime.toFixed(2)}ms`)
967
+
968
+ return result.results[0]
969
+ }