// Copyright Keysight Technologies 2012-2025 // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, you can obtain one at http://mozilla.org/MPL/2.0/. using System; using System.Collections.Generic; using System.ComponentModel; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Resources; using System.Threading; using OpenTap.Cli; using OpenTap.Package; using OpenTap.Translation; namespace OpenTap.Sdk.New; /// /// This class contains helpful utilities for creating translations of packages. /// [Display("translate", Group: "sdk", Description: "Create a new translation template for a package.")] public class TranslateAction : ICliAction { /// /// The packages to translate /// [UnnamedCommandLineArgument(nameof(Package), Description = "The package to translate.")] [Display("Package", "The packages to translate")] public string Package { get; set; } private static readonly TraceSource log = Log.CreateSource("Translate"); /// public int Execute(CancellationToken cancellationToken) { // Ensure translations are generated for the default language (english) using var session = Session.Create(SessionOptions.OverlayComponentSettings); EngineSettings.Current.Language = CultureInfo.InvariantCulture; var install = Installation.Current; if (string.IsNullOrWhiteSpace(Package)) { log.Error($"Please specify a package name."); return 1; } var pkg = install.FindPackage(Package); if (pkg == null) { log.Error($"Package '{Package}' is not installed."); return 1; } var outputdir = TranslationManager.TranslationDirectory; var outputFileName = Path.Combine(outputdir, pkg.Name + ".resx"); if (!Directory.Exists(outputdir)) Directory.CreateDirectory(outputdir); var types = new List(); // first add all plugins types.AddRange(TypeData.GetDerivedTypes()); types = [.. types.Distinct()]; static void recursivelyAddReferencedTypes(ITypeData td, HashSet seen) { try { foreach (var mem in td.GetMembers()) { // These types would be filtered out later anyway, but let's just not even consider them if (mem.TypeDescriptor.Name.StartsWith("System.") || mem.TypeDescriptor.Name.StartsWith("Microsoft.")) continue; if (SkipMem(td, mem)) { continue; } // Always translate the member type if it is embedded. if (mem.HasAttribute() || SkipType(mem.TypeDescriptor) == false) { if (seen.Add(mem.TypeDescriptor)) { recursivelyAddReferencedTypes(mem.TypeDescriptor, seen); } } } } catch { // This happens if a typedata implementation throws in GetMembers() // We cannot really do anything about this } } var seen = new HashSet(types); foreach (var td in types) { recursivelyAddReferencedTypes(td, seen); } types = [.. seen]; { // We are not interested in creating a different translation for each // variant of a generic type we use. // Remove all instances of generic types, and add a single reference to the generic variant. HashSet add = []; HashSet remove = []; foreach (var type in types) { if (AsTypeData(type) is { } td && td.Type.IsGenericType) { remove.Add(td); var gen = TypeData.FromType(td.Type.GetGenericTypeDefinition()); add.Add(gen); } } types.RemoveAll(remove.Contains); types.AddRange(add); } static string normalizePath(string path) => path.Replace('\\', '/'); var typesSources = types.Select(x => Path.GetFullPath(TypeData.GetTypeDataSource(x).Location)) .Where(x => x.StartsWith(install.Directory, StringComparison.OrdinalIgnoreCase)) .Select(x => x.Substring(install.Directory.Length + 1)) .Select(normalizePath) .ToArray(); var outdir = Path.GetDirectoryName(outputFileName); if (!string.IsNullOrWhiteSpace(outdir)) Directory.CreateDirectory(outdir); var writer = new ResXWriter(outputFileName); var packageFiles = new HashSet(pkg.Files.Select(x => normalizePath(x.FileName)), StringComparer.OrdinalIgnoreCase); List packageTypes = []; for (int i = 0; i < typesSources.Length; i++) { if (typesSources[i] == null) continue; if (packageFiles.Contains(typesSources[i])) { packageTypes.Add(types[i]); } } if (!packageTypes.Any()) { log.Error($"0 types discovered for package '{Package}'. This is likely a bug."); return 1; } foreach (var type in packageTypes) { if (SkipType(type)) continue; if (type.DescendsTo(typeof(Enum)) && AsTypeData(type)?.Type is Type enumType) { // Special handling for enums. We need to write each enum variant WriteEnumMembers(writer, enumType); continue; } if (type.DescendsTo(typeof(IStringLocalizer)) && type.CanCreateInstance && type.CreateInstance() is IStringLocalizer t) { WriteStringLocalizerStrings(writer, t); } var members = GetMembers(type); var typeDisplay = type.GetDisplayAttribute(); WriteAttribute(writer, type.Name, typeDisplay); foreach (var mem in members) { if (SkipMem(type, mem)) continue; var memDisplay = mem.GetDisplayAttribute(); WriteAttribute(writer, $"{type.Name}.{mem.Name}", memDisplay); } } // Also add all display attributes defined in the plugin { var assemblyFiles = pkg.Files.Where(f => !f.FileName.StartsWith("Dependencies/")).Where(x => x.FileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) || x.FileName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) .ToArray(); List assemblies = []; foreach (var f in assemblyFiles) { try { var asm = Assembly.LoadFrom(f.FileName); assemblies.Add(asm); } catch { // ignore } } foreach (var asm in assemblies) { foreach (var type in asm.ExportedTypes) { if (type.GetCustomAttribute() is { } typeDisplay) { WriteAttribute(writer, type.FullName, typeDisplay); } foreach (var mem in type.GetMembers()) { // Inherited members should be translated on the base type. Otherwise translators would // have to duplicate translation of inherited members. if (mem.DeclaringType != type) continue; if (mem.GetCustomAttribute() is { } memDisplay) { WriteAttribute(writer, $"{type.FullName}.{mem.Name}", memDisplay); } } } } } writer.Generate(); log.Info($"Created translation template file at {outputFileName}"); return 0; } private static TypeData AsTypeData(ITypeData type) { do { if (type is TypeData td) return td; type = type?.BaseType; } while (type != null); return null; } private static void WriteEnumMembers(ResXWriter writer, Type enumType) { var names = Enum.GetNames(enumType); foreach (var name in names) { MemberInfo type = enumType.GetMember(name).FirstOrDefault(); DisplayAttribute attr = type.GetCustomAttribute(); attr ??= new DisplayAttribute(type.Name, null, Order: -10000, Collapsed: false); WriteAttribute(writer, $"{enumType.FullName}.{name}", attr); } } private static void WriteStringLocalizerStrings(ResXWriter writer, IStringLocalizer obj) { var t = obj.GetType(); HashSet added = []; Func hook = (localizer, neutral, key, language) => { var fullkey = $"{t.FullName}.{key}"; if (added.Add(fullkey)) writer.AddResource(fullkey, neutral); return neutral; }; // inject hook var mgr = typeof(TranslationManager); mgr.GetField("TranslateFunction", BindingFlags.Static | BindingFlags.NonPublic)?.SetValue(null, hook); // we need to call the property getter for all properties to trigger all calls to Translate() foreach (var prop in t.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) { if (prop.PropertyType != typeof(string) && prop.PropertyType != typeof(FormatString)) continue; try { object owner = prop.GetGetMethod().IsStatic ? null : obj; prop.GetValue(owner); } catch { // ignore } } } private static void WriteAttribute(ResXWriter writer, string prefix, DisplayAttribute disp) { writer.AddResource($"{prefix}.Name", disp.Name ?? ""); if (!string.IsNullOrWhiteSpace(disp.Description)) writer.AddResource($"{prefix}.Description", disp.Description ?? ""); if (disp.Order != DisplayAttribute.DefaultOrder) { writer.AddResource($"{prefix}.Order", disp.Order); } if (disp.Group.Length > 0) { writer.AddResource($"{prefix}.Group", string.Join(" \\ ", disp.Group)); } } static bool SkipType(ITypeData type) { try { if (type.DescendsTo(typeof(IStringLocalizer))) { if (type.CanCreateInstance) return false; // Skip abstract types with no error if (AsTypeData(type)?.Type?.IsAbstract == true) return true; // It is currently a requirement that IStringLocalizer can be instantiated. // In the future, we can improve the string detection algorithm to relax this requirement, // but for now we should warn the user that this will not work. log.Error($"String localizer '{type.Name}' does not have an empty constructor, and will not be translated."); return true; } return false; } catch (Exception ex) { // ignore. This can happen for bad typedata implementations. We should just ignore the type in this case // since we can't translate it if we can't enumerate the members. log.Error($"Error reflecting type '{type.Name}'. This type will not be translated."); log.Debug(ex); } return true; } static IMemberData[] GetMembers(ITypeData type) { try { return type.GetMembers().ToArray(); } catch (Exception ex) { // ignore. This can happen for bad typedata implementations. We should just ignore the type in this case // since we can't translate it if we can't enumerate the members. log.Error($"Error reflecting type '{type.Name}'. Properties will not be translated."); log.Debug(ex); } return []; } static bool SkipMem(ITypeData type, IMemberData mem) { try { // If this member is inherited, the translation should happen in the base class. if (!Equals(mem.DeclaringType, type)) return true; // Skip the member if it is unbrowsable var browsable = mem.GetAttribute()?.Browsable; if (browsable != null) return !browsable.Value; // Otherwise skip the member if it is not writable. // This is the primary factor determining whether or not something is visible in most UIs. return !mem.Writable; } catch (Exception ex) { log.Error($"Error reflecting member '{type.Name}.{mem.Name}'. This member will not be translated."); log.Debug(ex); return true; } } }