Determine which string table entries are in use

Is there any way of determining which entries in a string table are actually referenced by a blueprint/UMG widget. I’ve been maintaining string tables while I build our UI but I know some strings I added in an earlier iteration aren’t being used anymore. I’d like to be able to clean them up before we send them to be localized so we don’t pay for translations we no longer need. Does this feature exist or am I going to have to either figure out how to do it myself or assemble this list by hand?

I wasn’t hopeful that it already existed but wanted to confirm before I started making my own solution. I was planning on making a commandlet if I had to make it myself so thanks for the guidance, I’ll check that class out.

Nothing for this exists, and it will be quite expensive to do as String Table entry references aren’t stored in the localisation cache of a package (since they’re not gathered).

It should be pretty easy to make a commandlet to do it though, and you could probably use a custom archive to handle the detection (see FTextKeyingArchive in StabilizeLocalizationKeys.cpp, it would be like that except you’d be looking for String Table entry references rather than mismatched package IDs).

Hi kgamble,

I’m currently facing the same problem you mentioned, and was about to embark on doing this myself. Did you ever manage to implement a commandlet for this?

Hey, kgamble,

I’d be interested in this as well: my idea is to gather all the source references into a separate column in a string table, to be able to see where exactly this or that string table entry is used :slight_smile: For the sake of context.

Did you make any progress with this? I’d appreciate any help as I’m not really a programmer…

No, never actually got around to figuring this out.

I never actually got around to it, sorry. Good luck.

I made a solution Unreal 4.27.2. Keep in mind if u run this will cause unreal to hitch as it needs to load the assets to check FTexts. There are quite a few methods needed here.

Main one is GatherLocalizationInfoOnObjectType - which will give u a nice log of all the texts, and will check if there is a matching text in a libaraby that is simple not connected.

Besides that I just made a small untest func GatherAllUnusedTextsFromLocalTables which will give u LibaryStringTexts that are not referenced anywhere. Meaning they could be deleted from translation list or need to be implmented.

TODO GetAllFTexts - could also search arrays and struct upropertys, didnt add that

Unreal 4.27.2 GL HF bois :smiley:


In your .h some structs 

USTRUCT(BlueprintType)
struct FBlueprintClassSearchInfo
{
	GENERATED_BODY()

public:
	FBlueprintClassSearchInfo(UClass* Class_ = nullptr, FString BlueprintName_ = "") : Class(Class_), BlueprintName(BlueprintName_) {}

	UPROPERTY(BlueprintReadWrite, EditAnywhere)
	UClass* Class;

	//because getting name of class just gives us "GeneratedClass"
	UPROPERTY(BlueprintReadWrite, EditAnywhere)
	FString BlueprintName;
};

USTRUCT(BlueprintType)
struct FTextPropertyInfo
{
	GENERATED_BODY()

public:
	FTextPropertyInfo(UClass* Class_ = nullptr, FString TextVariableName_ = "", FText TextVariableValue_ = FText::FromString(""))
	:Class(Class_), TextVariableName(TextVariableName_), TextVariableValue(TextVariableValue_){}

	UPROPERTY(BlueprintReadWrite, EditAnywhere)
	UClass* Class;
	
	UPROPERTY(BlueprintReadWrite, EditAnywhere)
	FString TextVariableName;

	UPROPERTY(BlueprintReadWrite, EditAnywhere)
	FText TextVariableValue;
};



USTRUCT(BlueprintType)
struct FLocalizationCheckInfo
{
	GENERATED_BODY()

public:
	FLocalizationCheckInfo(bool bIsConnectedToStringTable_ = false, bool bFoundMatchTextInStringTable_ = false, FString TextVariableName_ = "", FString TextVariableValue_ = "", FString FoundKey_ = "", FBlueprintClassSearchInfo BpSearchInfo_ = FBlueprintClassSearchInfo())
	: bIsConnectedToStringTable(bIsConnectedToStringTable_),bFoundMatchTextInStringTable(bFoundMatchTextInStringTable_), TextVariableName(TextVariableName_), TextVariableValue(TextVariableValue_), FoundKey(FoundKey_), BpSearchInfo(BpSearchInfo_){}

	UPROPERTY(BlueprintReadWrite, EditAnywhere)
	bool bIsConnectedToStringTable;

	UPROPERTY(BlueprintReadWrite, EditAnywhere)
	bool bFoundMatchTextInStringTable;

	UPROPERTY(BlueprintReadWrite, EditAnywhere)
	FString TextVariableName;

	UPROPERTY(BlueprintReadWrite, EditAnywhere)
	FString TextVariableValue;

	UPROPERTY(BlueprintReadWrite, EditAnywhere)
	FString FoundKey;
	
	UPROPERTY(BlueprintReadWrite, EditAnywhere)
	FBlueprintClassSearchInfo BpSearchInfo;
};


and .cpp

TArray<FLocalizationCheckInfo> UAIP_Utils::GatherLocalizationInfoOnObjectType(TSubclassOf<UObject> ObjectType, FString OptionalPath, bool bPrintLogUnlocalizedTexts)
{
	//collect all blueprint subclasses of type widget	
	TArray<FBlueprintClassSearchInfo> BlueprintClassSearchResults;
	GetAllBlueprintSubclasses(ObjectType,BlueprintClassSearchResults, OptionalPath);
	
	TArray<FLocalizationCheckInfo> OutResults;
	
	for (FBlueprintClassSearchInfo Info : BlueprintClassSearchResults)
	{
		//common ones in widgets we dont need, also ignore emptys
		TArray<FString> IgnoreTexts = {TEXT(""), TEXT("Tooltip"), TEXT("PaletteCategory")};
		TArray<FTextPropertyInfo> FoundTexts = GetAllFTexts(Info.Class, IgnoreTexts);

		for (FTextPropertyInfo Text : FoundTexts)
		{
			if (!UKismetTextLibrary::TextIsFromStringTable(Text.TextVariableValue))
			{
				//check if text exists in string table (just not connected"!) // often happens by accident text was connected then clicked on once and it disconnects.
				TArray<FName> AllStringTableID = UKismetStringTableLibrary::GetRegisteredStringTables();

				bool bFound = false;
				
				for (FName StringTableID : AllStringTableID)
				{
					TArray<FString> AllStringsInTable = UKismetStringTableLibrary::GetKeysFromStringTable(StringTableID);

					for (FString TableKey: AllStringsInTable)
					{
						FString FindLocalOutText;
						FindLocalizedText(UKismetStringTableLibrary::GetTableNamespace(StringTableID),TableKey,FindLocalOutText);
						
						//we often have pattern we manually wrote in text and without "."
						if (FindLocalOutText.Equals(Text.TextVariableValue.ToString(),ESearchCase::IgnoreCase) || FindLocalOutText.Equals(Text.TextVariableValue.ToString() + ".",ESearchCase::IgnoreCase))
						{
							bFound = true;
							OutResults.Add(FLocalizationCheckInfo(false, true, Text.TextVariableName, Text.TextVariableValue.ToString(),TableKey,Info));
							break;
						}
					}
				}

				if (!bFound)
					OutResults.Add(FLocalizationCheckInfo(false, false, Text.TextVariableName, Text.TextVariableValue.ToString(),"",Info));
			} else
			{
				OutResults.Add(FLocalizationCheckInfo(true, true, Text.TextVariableName, Text.TextVariableValue.ToString(),"",Info));
			}
		}
	}
	
	//3 Log to screen and logfile
	if (bPrintLogUnlocalizedTexts)
	{
		FString LogString;
	
		for (FLocalizationCheckInfo LocalInfo: OutResults)
		{
			if (!LocalInfo.bIsConnectedToStringTable)
			{
				FString LocalInfoString =  LocalInfo.BpSearchInfo.BlueprintName + "	   Variable name: " + LocalInfo.TextVariableName + "	Variable Value: " + LocalInfo.TextVariableValue + "   Match in string table " + UKismetStringLibrary::Conv_BoolToString(LocalInfo.bFoundMatchTextInStringTable) + "      Text ID Found " + LocalInfo.FoundKey;
				LogString.Append(LocalInfoString + "\n");
			}
		}
			
		const FString FilePath = FPaths::ConvertRelativePathToFull(FPaths::ProjectSavedDir()) + TEXT("/Logs/UnlocalizedTexts.txt");
		FFileHelper::SaveStringToFile(LogString, *FilePath, FFileHelper::EEncodingOptions::AutoDetect, &IFileManager::Get(), EFileWrite::FILEWRITE_Append);
	
		UE_LOG(LogTemp,Warning,TEXT(" \n %s"),*LogString);
	}
	
	return OutResults;
}

bool UAIP_Utils::FindLocalizedText(const FString& Namespace, const FString& Key, FString& OutText)
{
	FTextDisplayStringPtr FoundString = FTextLocalizationManager::Get().FindDisplayString(Namespace, Key);
	if (FoundString.IsValid())
	{
		OutText = *FoundString;
		return true;
	}
	return false;
}

TArray<FTextPropertyInfo> UAIP_Utils::GetAllFTexts(TSubclassOf<UObject> ObjectType, TArray<FString> IgnoreTextsVariables, bool bIgnoreEmpty)
{
	//todo could part structs and arrays for texts as well in theory
	TArray <FTextPropertyInfo> OutArray;
	for (TFieldIterator<FTextProperty> Property(ObjectType); Property; ++Property)
	{
		const FString TextVariableName = Property->GetFName().GetPlainNameString();
		const FText& TextVariableValue = Property->GetPropertyValue_InContainer(ObjectType->GetDefaultObject());

		bool bIgnore = false;
		
		for (FString IgnoreName: IgnoreTextsVariables)
		{
			if (IgnoreName.Equals(TextVariableName,ESearchCase::IgnoreCase))
			{
				bIgnore = true;
				break;
			}
		}

		if (bIgnoreEmpty)
		{
			if (TextVariableValue.ToString() == "" || TextVariableValue.ToString() == "Artifacts")
			{
				bIgnore = true;
			}
		}
		
		if (!bIgnore)
			OutArray.Add(FTextPropertyInfo(ObjectType,TextVariableName,TextVariableValue));
	}
	
	return OutArray;
}

void UAIP_Utils::GetAllBlueprintSubclasses(UClass* BaseClass, TArray<FBlueprintClassSearchInfo>& BlueprintSearchResults, FString OptionalPath = "")
{
	FName BaseClassName = BaseClass->GetFName();
	//UE_LOG(LogTemp, Log, TEXT("Getting all blueprint subclasses of '%s'"), *BaseClassName.ToString());

	FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked<FAssetRegistryModule>("AssetRegistry");
	IAssetRegistry& AssetRegistry = AssetRegistryModule.Get();
	TArray<FAssetData> AssetData;
	// The asset registry is populated asynchronously at startup, so there's no guarantee it has finished.
	// This simple approach just runs a synchronous scan on the entire content directory.
	// Better solutions would be to specify only the path to where the relevant blueprints are,
	// or to register a callback with the asset registry to be notified of when it's finished populating.
	TArray< FString > ContentPaths;
	
	ContentPaths.Add(TEXT("/Game") + OptionalPath);
	AssetRegistry.ScanPathsSynchronous(ContentPaths);

	// Use the asset registry to get the set of all class names deriving from Base
	TSet< FName > DerivedNames;
	{
		TArray< FName > BaseNames;
		BaseNames.Add(BaseClassName);

		TSet< FName > Excluded;
        AssetRegistry.GetDerivedClassNames(BaseNames, Excluded, DerivedNames);
		// AssetRegistry.GetDerivedClassNames(BaseNames, Excluded, DerivedNames);
	}
	
	FARFilter Filter;
    //Filter.ClassPaths.Add(UBlueprint::StaticClass()->GetClassPathName());
	Filter.ClassNames.Add(UBlueprint::StaticClass()->GetFName());
	Filter.bRecursiveClasses = true;
	Filter.bRecursivePaths = true;

	TArray< FAssetData > AssetList;
	AssetRegistry.GetAssets(Filter, AssetList);
	
	// Iterate over retrieved blueprint assets
	for(auto const& Asset : AssetList) {
		
		// Get the the class this blueprint generates (this is stored as a full path)
        auto GeneratedClassPathPtr = Asset.TagsAndValues.FindTag(TEXT("GeneratedClass")).AsString();
		if(!GeneratedClassPathPtr.IsEmpty()) {

			//UE_LOG(LogTemp,Warning, TEXT("PrintLogOfAllUnlocalizedTexts 1"));
			
			// Convert path to just the name part
			const FString ClassObjectPath = FPackageName::ExportTextPathToObjectPath(*GeneratedClassPathPtr);
			const FString ObjectClassName = FPackageName::ObjectPathToObjectName(ClassObjectPath);
			
			// Check if this class is in the derived set
			if(!DerivedNames.Contains(*ObjectClassName)) {
				
				continue;
			}
			
			UClass* Class = nullptr;
			//load asset
			const UBlueprint* BlueprintAsset = Cast<UBlueprint>(Asset.GetAsset());
			if (BlueprintAsset) {
				Class = BlueprintAsset->GeneratedClass;
			} else {
				
			}
			if (Class) {
				BlueprintSearchResults.Add(FBlueprintClassSearchInfo(Class, ObjectClassName.LeftChop(2)));
			} else {
				
			}
		}
	}
}

//note will cause hitch
TArray<FLocalizationCheckInfo> UAIP_Utils::GatherAllUnusedTextsFromLocalTables()
{
	TArray<FLocalizationCheckInfo> GatheredInfo = GatherLocalizationInfoOnObjectType(UObject::StaticClass(),"", false);
		
	//gather just all keys and string from all tables.
	TArray<FLocalizationCheckInfo> OutAllUnusedLocalInfo;

	TArray<FName> AllStringTableID = UKismetStringTableLibrary::GetRegisteredStringTables();
	for (FName StringTableID : AllStringTableID)
	{
		TArray<FString> AllStringsInTable = UKismetStringTableLibrary::GetKeysFromStringTable(StringTableID);

		for (FString TableKey: AllStringsInTable)
		{	
			FString FindLocalOutText;

			//for now ignore other tables we want to test with text
			if (!UKismetStringTableLibrary::GetTableNamespace(StringTableID).Contains("TEXT"))
				continue;
			
			FindLocalizedText(UKismetStringTableLibrary::GetTableNamespace(StringTableID),TableKey,FindLocalOutText);
			OutAllUnusedLocalInfo.Add(FLocalizationCheckInfo(true,true,"",FindLocalOutText,TableKey, FBlueprintClassSearchInfo()));
		}
	}
	
	for (FLocalizationCheckInfo Info :GatheredInfo)
	{
		if (Info.bIsConnectedToStringTable || Info.bFoundMatchTextInStringTable)
		{
			int FoundIndex = -1;

			for (int i = 0; i< OutAllUnusedLocalInfo.Num(); i++)
			{
				if (OutAllUnusedLocalInfo[i].TextVariableValue == Info.TextVariableValue)
				{	
					FoundIndex = i;
					break;
				}
			}
			
			if (FoundIndex > -1)
			{
				OutAllUnusedLocalInfo.RemoveAt(FoundIndex);
			}
		}
	}
	
	//3 Log to screen and logfile
	FString LogString;
	
	for (FLocalizationCheckInfo LocalInfo: OutAllUnusedLocalInfo)
	{
		FString LocalInfoString = "Unused Local Text Key: " + LocalInfo.FoundKey + " Variable value: " + LocalInfo.TextVariableValue;
		LogString.Append(LocalInfoString + "\n");
	}
			
	const FString FilePath = FPaths::ConvertRelativePathToFull(FPaths::ProjectSavedDir()) + TEXT("/Logs/UsedTextFromStringLibrary.txt");
	FFileHelper::SaveStringToFile(LogString, *FilePath, FFileHelper::EEncodingOptions::AutoDetect, &IFileManager::Get(), EFileWrite::FILEWRITE_Append);
	
	UE_LOG(LogTemp,Warning,TEXT("\n %s"),*LogString);
	
	return OutAllUnusedLocalInfo;
}