/// <summary> /// Returns an <see cref="IEnumerable"/> containing the string /// table entries for the base language for the specified Yarn /// script. /// </summary> /// <param name="serializedObject">A serialized object that /// represents a <see cref="YarnScript"/>.</param> /// <returns>The string table entries.</returns> private static IEnumerable <StringTableEntry> GetBaseLanguageStringsForSelectedObject(SerializedObject serializedObject) { var baseLanguageProperty = serializedObject.FindProperty("baseLanguage"); // Get the TextAsset that contains the base string table CSV TextAsset textAsset = baseLanguageProperty.objectReferenceValue as TextAsset; if (textAsset == null) { throw new System.NullReferenceException($"The base language table asset for {serializedObject.targetObject.name} is either null or not a TextAsset. Did the script fail to compile?"); } var baseLanguageTableText = textAsset.text; // Parse this CSV into StringTableEntry structs return(StringTableEntry.ParseFromCSV(baseLanguageTableText) .OrderBy(entry => entry.File) .ThenBy(entry => int.Parse(entry.LineNumber))); }
private void UpdateTargetObjectMappingTable() { var canvasText = serializedObject.targetObject as YarnLinesAsCanvasText; if (!(_yarnProgramProperty.objectReferenceValue is YarnProgram yarnProgram)) { // No program means no strings available, so clear it and // bail out if (canvasText.stringsToViews.Count != 0) { // Modify the dictionary directly, to make Unity // realise that the property is dirty _stringsToViewsProperty.FindPropertyRelative("keys").ClearArray(); // And clear the in-memory representation as well canvasText.stringsToViews.Clear(); serializedObject.ApplyModifiedProperties(); } return; } var stringTableAsset = yarnProgram.baseLocalisationStringTable.text; var stringIDs = StringTableEntry.ParseFromCSV(stringTableAsset) .Select(e => e.ID); var extraneousIDs = canvasText.stringsToViews.Keys.Except(stringIDs).ToList(); var missingIDs = stringIDs.Except(canvasText.stringsToViews.Keys).ToList(); foreach (var id in extraneousIDs) { canvasText.stringsToViews.Remove(id); } foreach (var id in missingIDs) { canvasText.stringsToViews.Add(id, null); } }
/// <summary> /// Verifies the TextAsset referred to by <paramref /// name="destinationLocalizationAsset"/>, and updates it if /// necessary. /// </summary> /// <param name="baseLocalizationStrings">A collection of <see /// cref="StringTableEntry"/></param> /// <param name="language">The language that <paramref /// name="destinationLocalizationAsset"/> provides strings /// for.false</param> /// <param name="destinationLocalizationAsset">A TextAsset /// containing localized strings in CSV format.</param> /// <returns>Whether <paramref /// name="destinationLocalizationAsset"/> was modified.</returns> private static bool UpdateLocalizationFile(IEnumerable <StringTableEntry> baseLocalizationStrings, string language, TextAsset destinationLocalizationAsset) { var translatedStrings = StringTableEntry.ParseFromCSV(destinationLocalizationAsset.text); // Convert both enumerables to dictionaries, for easier lookup var baseDictionary = baseLocalizationStrings.ToDictionary(entry => entry.ID); var translatedDictionary = translatedStrings.ToDictionary(entry => entry.ID); // The list of line IDs present in each localisation var baseIDs = baseLocalizationStrings.Select(entry => entry.ID); var translatedIDs = translatedStrings.Select(entry => entry.ID); // The list of line IDs that are ONLY present in each // localisation var onlyInBaseIDs = baseIDs.Except(translatedIDs); var onlyInTranslatedIDs = translatedIDs.Except(baseIDs); // Tracks if the translated localisation needed modifications // (either new lines added, old lines removed, or changed lines // flagged) var modificationsNeeded = false; // Remove every entry whose ID is only present in the // translated set. This entry has been removed from the base // localization. foreach (var id in onlyInTranslatedIDs.ToList()) { translatedDictionary.Remove(id); modificationsNeeded = true; } // Conversely, for every entry that is only present in the base // localisation, we need to create a new entry for it. foreach (var id in onlyInBaseIDs) { StringTableEntry baseEntry = baseDictionary[id]; var newEntry = new StringTableEntry(baseEntry) { // Empty this text, so that it's apparent that a // translated version needs to be provided. Text = string.Empty, Language = language, }; translatedDictionary.Add(id, newEntry); modificationsNeeded = true; } // Finally, we need to check for any entries in the translated // localisation that: // 1. have the same line ID as one in the base, but // 2. have a different Lock (the hash of the text), which // indicates that the base text has changed. // First, get the list of IDs that are in both base and // translated, and then filter this list to any where the lock // values differ var outOfDateLockIDs = baseDictionary.Keys .Intersect(translatedDictionary.Keys) .Where(id => baseDictionary[id].Lock != translatedDictionary[id].Lock); // Now loop over all of these, and update our translated // dictionary to include a note that it needs attention foreach (var id in outOfDateLockIDs) { // Get the translated entry as it currently exists var entry = translatedDictionary[id]; // Include a note that this entry is out of date entry.Text = $"(NEEDS UPDATE) {entry.Text}"; // Update the lock to match the new one entry.Lock = baseDictionary[id].Lock; // Put this modified entry back in the table translatedDictionary[id] = entry; modificationsNeeded = true; } // We're all done! if (modificationsNeeded == false) { // No changes needed to be done to the translated string // table entries. Stop here. return(false); } // We need to produce a replacement CSV file for the translated // entries. var outputStringEntries = translatedDictionary.Values .OrderBy(entry => entry.File) .ThenBy(entry => int.Parse(entry.LineNumber)); var outputCSV = StringTableEntry.CreateCSV(outputStringEntries); // Write out the replacement text to this existing file, // replacing its existing contents var outputFile = AssetDatabase.GetAssetPath(destinationLocalizationAsset); File.WriteAllText(outputFile, outputCSV, System.Text.Encoding.UTF8); // Tell the asset database that the file needs to be reimported AssetDatabase.ImportAsset(outputFile); // Signal that the file was changed return(true); }
public override void OnImportAsset(AssetImportContext ctx) { #if YARNSPINNER_DEBUG UnityEngine.Profiling.Profiler.enabled = true; #endif var project = ScriptableObject.CreateInstance <YarnProject>(); project.name = Path.GetFileNameWithoutExtension(ctx.assetPath); // Start by creating the asset - no matter what, we need to // produce an asset, even if it doesn't contain valid Yarn // bytecode, so that other assets don't lose their references. ctx.AddObjectToAsset("Project", project); ctx.SetMainObject(project); foreach (var script in sourceScripts) { string path = AssetDatabase.GetAssetPath(script); if (string.IsNullOrEmpty(path)) { // This is, for some reason, not a valid script we can // use. Don't add a dependency on it. continue; } ctx.DependsOnSourceAsset(path); } // Parse declarations var localDeclarationsCompileJob = CompilationJob.CreateFromFiles(ctx.assetPath); localDeclarationsCompileJob.CompilationType = CompilationJob.Type.DeclarationsOnly; IEnumerable <Declaration> localDeclarations; compileError = null; try { var result = Compiler.Compiler.Compile(localDeclarationsCompileJob); localDeclarations = result.Declarations; } catch (ParseException e) { ctx.LogImportError($"Error in Yarn Project: {e.Message}"); compileError = $"Error in Yarn Project {ctx.assetPath}: {e.Message}"; return; } // Store these so that we can continue displaying them after // this import step, in case there are compile errors later. // We'll replace this with a more complete list later if // compilation succeeds. serializedDeclarations = localDeclarations .Where(decl => decl.DeclarationType == Declaration.Type.Variable) .Select(decl => new SerializedDeclaration(decl)).ToList(); // We're done processing this file - we've parsed it, and // pulled any information out of it that we need to. Now to // compile the scripts associated with this project. var scriptImporters = sourceScripts.Where(s => s != null).Select(s => AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(s)) as YarnImporter); // First step: check to see if there's any parse errors in the // files. var scriptsWithParseErrors = scriptImporters.Where(script => script.isSuccessfullyParsed == false); if (scriptsWithParseErrors.Count() != 0) { // Parse errors! We can't continue. string failingScriptNameList = string.Join("\n", scriptsWithParseErrors.Select(script => script.assetPath)); compileError = $"Parse errors exist in the following files:\n{failingScriptNameList}"; return; } // Get paths to the scripts we're importing, and also map them // to their corresponding importer var pathsToImporters = scriptImporters.ToDictionary(script => script.assetPath, script => script); if (pathsToImporters.Count == 0) { return; // nothing further to do here } // We now now compile! var job = CompilationJob.CreateFromFiles(pathsToImporters.Keys); job.VariableDeclarations = localDeclarations; CompilationResult compilationResult; try { compilationResult = Compiler.Compiler.Compile(job); } catch (TypeException e) { ctx.LogImportError($"Error compiling: {e.Message}"); compileError = e.Message; var importer = pathsToImporters[e.FileName]; importer.parseErrorMessage = e.Message; EditorUtility.SetDirty(importer); return; } catch (ParseException e) { ctx.LogImportError(e.Message); compileError = e.Message; var importer = pathsToImporters[e.FileName]; importer.parseErrorMessage = e.Message; EditorUtility.SetDirty(importer); return; } if (compilationResult.Program == null) { ctx.LogImportError("Internal error: Failed to compile: resulting program was null, but compiler did not throw a parse exception."); return; } // Store _all_ declarations - both the ones in this // .yarnproject file, and the ones inside the .yarn files serializedDeclarations = localDeclarations .Concat(compilationResult.Declarations) .Where(decl => decl.DeclarationType == Declaration.Type.Variable) .Select(decl => new SerializedDeclaration(decl)).ToList(); // Clear error messages from all scripts - they've all passed // compilation foreach (var importer in pathsToImporters.Values) { importer.parseErrorMessage = null; } // Will we need to create a default localization? This variable // will be set to false if any of the languages we've // configured in languagesToSourceAssets is the default // language. var shouldAddDefaultLocalization = true; foreach (var pair in languagesToSourceAssets) { // Don't create a localization if the language ID was not // provided if (string.IsNullOrEmpty(pair.languageID)) { Debug.LogWarning($"Not creating a localization for {project.name} because the language ID wasn't provided. Add the language ID to the localization in the Yarn Project's inspector."); continue; } IEnumerable <StringTableEntry> stringTable; // Where do we get our strings from? If it's the default // language, we'll pull it from the scripts. If it's from // any other source, we'll pull it from the CSVs. if (pair.languageID == defaultLanguage) { // We'll use the program-supplied string table. stringTable = GenerateStringsTable(); // We don't need to add a default localization. shouldAddDefaultLocalization = false; } else { try { if (pair.stringsFile == null) { // We can't create this localization because we // don't have any data for it. Debug.LogWarning($"Not creating a localization for {pair.languageID} in the Yarn Project {project.name} because a text asset containing the strings wasn't found. Add a .csv file containing the translated lines to the Yarn Project's inspector."); continue; } stringTable = StringTableEntry.ParseFromCSV(pair.stringsFile.text); } catch (System.ArgumentException e) { Debug.LogWarning($"Not creating a localization for {pair.languageID} in the Yarn Project {project.name} because an error was encountered during text parsing: {e}"); continue; } } var newLocalization = ScriptableObject.CreateInstance <Localization>(); newLocalization.LocaleCode = pair.languageID; newLocalization.AddLocalizedStrings(stringTable); project.localizations.Add(newLocalization); newLocalization.name = pair.languageID; if (pair.assetsFolder != null) { var assetsFolderPath = AssetDatabase.GetAssetPath(pair.assetsFolder); if (assetsFolderPath == null) { // This was somehow not a valid reference? Debug.LogWarning($"Can't find assets for localization {pair.languageID} in {project.name} because a path for the provided assets folder couldn't be found."); } else { var stringIDsToAssets = FindAssetsForLineIDs(stringTable.Select(s => s.ID), assetsFolderPath); #if YARNSPINNER_DEBUG var stopwatch = System.Diagnostics.Stopwatch.StartNew(); #endif newLocalization.AddLocalizedObjects(stringIDsToAssets.AsEnumerable()); #if YARNSPINNER_DEBUG stopwatch.Stop(); Debug.Log($"Imported {stringIDsToAssets.Count()} assets for {project.name} \"{pair.languageID}\" in {stopwatch.ElapsedMilliseconds}ms"); #endif } } ctx.AddObjectToAsset("localization-" + pair.languageID, newLocalization); if (pair.languageID == defaultLanguage) { // If this is our default language, set it as such project.baseLocalization = newLocalization; } else { // This localization depends upon a source asset. Make // this asset get re-imported if this source asset was // modified ctx.DependsOnSourceAsset(AssetDatabase.GetAssetPath(pair.stringsFile)); } } if (shouldAddDefaultLocalization) { // We didn't add a localization for the default language. // Create one for it now. var developmentLocalization = ScriptableObject.CreateInstance <Localization>(); developmentLocalization.LocaleCode = defaultLanguage; var stringTableEntries = compilationResult.StringTable.Select(x => new StringTableEntry { ID = x.Key, Language = defaultLanguage, Text = x.Value.text, File = x.Value.fileName, Node = x.Value.nodeName, LineNumber = x.Value.lineNumber.ToString(), Lock = YarnImporter.GetHashString(x.Value.text, 8), }); developmentLocalization.AddLocalizedStrings(stringTableEntries); project.baseLocalization = developmentLocalization; project.localizations.Add(project.baseLocalization); developmentLocalization.name = $"Default ({defaultLanguage})"; ctx.AddObjectToAsset("default-language", developmentLocalization); } // Store the compiled program byte[] compiledBytes = null; using (var memoryStream = new MemoryStream()) using (var outputStream = new Google.Protobuf.CodedOutputStream(memoryStream)) { // Serialize the compiled program to memory compilationResult.Program.WriteTo(outputStream); outputStream.Flush(); compiledBytes = memoryStream.ToArray(); } project.compiledYarnProgram = compiledBytes; #if YARNSPINNER_DEBUG UnityEngine.Profiling.Profiler.enabled = false; #endif }