From 04a115a6261124e172f22e4e2c8b4d9fbdbdfe86 Mon Sep 17 00:00:00 2001
From: chrisgregan <chris@snozbot.com>
Date: Mon, 16 Mar 2015 11:13:04 +0000
Subject: [PATCH] Export and import dialog text for editing in a text editor
 #86

Added support for Fountain export / import.
http://fountain.io/
---
 .../FungusScript/Editor/CommandEditor.cs      |   4 +
 .../FungusScript/Editor/CommandListAdaptor.cs |   2 +
 .../FungusScript/Editor/FungusScriptEditor.cs |  17 +-
 .../FungusScript/Editor/FungusScriptWindow.cs |   1 +
 .../FungusScript/Editor/SequenceEditor.cs     |   8 +-
 Assets/Fungus/FungusScript/Scripts/Command.cs |   3 +
 .../FungusScript/Scripts/FountainExporter.cs  | 187 ++++++++++++++++++
 .../Scripts/FountainExporter.cs.meta          |  12 ++
 .../FungusScript/Scripts/FungusScript.cs      |  33 ++++
 .../FungusScript/Scripts/StringsParser.cs     | 125 ++++++------
 10 files changed, 319 insertions(+), 73 deletions(-)
 create mode 100644 Assets/Fungus/FungusScript/Scripts/FountainExporter.cs
 create mode 100644 Assets/Fungus/FungusScript/Scripts/FountainExporter.cs.meta

diff --git a/Assets/Fungus/FungusScript/Editor/CommandEditor.cs b/Assets/Fungus/FungusScript/Editor/CommandEditor.cs
index 8887165c..f2e7e2db 100644
--- a/Assets/Fungus/FungusScript/Editor/CommandEditor.cs
+++ b/Assets/Fungus/FungusScript/Editor/CommandEditor.cs
@@ -71,6 +71,10 @@ namespace Fungus
 
 			GUILayout.FlexibleSpace();
 
+			GUILayout.Label(new GUIContent("(" + t.commandId + ")"));
+
+			GUILayout.Space(10);
+
 			GUI.backgroundColor = Color.white;
 			bool enabled = t.enabled;
 			enabled = GUILayout.Toggle(enabled, new GUIContent());
diff --git a/Assets/Fungus/FungusScript/Editor/CommandListAdaptor.cs b/Assets/Fungus/FungusScript/Editor/CommandListAdaptor.cs
index 99c8f0bc..9f94b94a 100644
--- a/Assets/Fungus/FungusScript/Editor/CommandListAdaptor.cs
+++ b/Assets/Fungus/FungusScript/Editor/CommandListAdaptor.cs
@@ -94,6 +94,7 @@ namespace Fungus
 			}
 			
 			Command newCommand = Undo.AddComponent<Comment>(sequence.gameObject) as Command;
+			newCommand.commandId = fungusScript.NextCommandId();
 			fungusScript.ClearSelectedCommands();
 			fungusScript.AddSelectedCommand(newCommand);
 			
@@ -109,6 +110,7 @@ namespace Fungus
 			
 			System.Type type = command.GetType();
 			Command newCommand = Undo.AddComponent(parentSequence.gameObject, type) as Command;
+			newCommand.commandId = newCommand.GetFungusScript().NextCommandId();
 			System.Reflection.FieldInfo[] fields = type.GetFields();
 			foreach (System.Reflection.FieldInfo field in fields)
 			{
diff --git a/Assets/Fungus/FungusScript/Editor/FungusScriptEditor.cs b/Assets/Fungus/FungusScript/Editor/FungusScriptEditor.cs
index 7f77c4c5..3019dfab 100644
--- a/Assets/Fungus/FungusScript/Editor/FungusScriptEditor.cs
+++ b/Assets/Fungus/FungusScript/Editor/FungusScriptEditor.cs
@@ -5,6 +5,7 @@ using System.Collections.Generic;
 using Rotorz.ReorderableList;
 using System.Linq;
 using System.Reflection;
+using System.IO;
 
 namespace Fungus
 {
@@ -21,6 +22,7 @@ namespace Fungus
 		protected SerializedProperty colorCommandsProp;
 		protected SerializedProperty hideComponentsProp;
 		protected SerializedProperty runSlowDurationProp;
+		protected SerializedProperty saveSelectionProp;
 		protected SerializedProperty variablesProp;
 
 		protected virtual void OnEnable()
@@ -29,6 +31,7 @@ namespace Fungus
 			colorCommandsProp = serializedObject.FindProperty("colorCommands");
 			hideComponentsProp = serializedObject.FindProperty("hideComponents");
 			runSlowDurationProp = serializedObject.FindProperty("runSlowDuration");
+			saveSelectionProp = serializedObject.FindProperty("saveSelection");
 			variablesProp = serializedObject.FindProperty("variables");
 		}
 
@@ -44,6 +47,7 @@ namespace Fungus
 			EditorGUILayout.PropertyField(colorCommandsProp);
 			EditorGUILayout.PropertyField(hideComponentsProp);
 			EditorGUILayout.PropertyField(runSlowDurationProp);
+			EditorGUILayout.PropertyField(saveSelectionProp);
 
 			GUILayout.BeginHorizontal();
 			GUILayout.FlexibleSpace();
@@ -51,6 +55,15 @@ namespace Fungus
 			{
 				EditorWindow.GetWindow(typeof(FungusScriptWindow), false, "Fungus Script");
 			}
+			if (GUILayout.Button(new GUIContent("Export Text", "Export all story text in .fountain format.")))
+			{
+				FountainExporter.ExportStrings(fungusScript);
+			}
+			if (GUILayout.Button(new GUIContent("Import Text", "Import story text from a file in .fountain format.")))
+			{
+				FountainExporter.ImportStrings(fungusScript);
+			}
+
 			GUILayout.FlexibleSpace();
 			GUILayout.EndHorizontal();
 
@@ -213,7 +226,7 @@ namespace Fungus
 					       derivedType.IsAssignableFrom(t)
 					       ).ToList();
 			
-		} 
+		}
 	}
-	
+
 }
\ No newline at end of file
diff --git a/Assets/Fungus/FungusScript/Editor/FungusScriptWindow.cs b/Assets/Fungus/FungusScript/Editor/FungusScriptWindow.cs
index 1244496a..ae6a954f 100755
--- a/Assets/Fungus/FungusScript/Editor/FungusScriptWindow.cs
+++ b/Assets/Fungus/FungusScript/Editor/FungusScriptWindow.cs
@@ -689,6 +689,7 @@ namespace Fungus
 				{
 					field.SetValue(newCommand, field.GetValue(command));
 				}
+				newCommand.commandId = fungusScript.NextCommandId();
 				newSequence.commandList.Add(newCommand);
 			}
 
diff --git a/Assets/Fungus/FungusScript/Editor/SequenceEditor.cs b/Assets/Fungus/FungusScript/Editor/SequenceEditor.cs
index 7a0b194c..4ac5c4b2 100644
--- a/Assets/Fungus/FungusScript/Editor/SequenceEditor.cs
+++ b/Assets/Fungus/FungusScript/Editor/SequenceEditor.cs
@@ -528,12 +528,15 @@ namespace Fungus
 			{
 				return;
 			}
-			
-			sequence.GetFungusScript().ClearSelectedCommands();
+
+			FungusScript fungusScript = sequence.GetFungusScript();
+
+			fungusScript.ClearSelectedCommands();
 			
 			Command newCommand = Undo.AddComponent(sequence.gameObject, commandOperation.commandType) as Command;
 			sequence.GetFungusScript().AddSelectedCommand(newCommand);
 			newCommand.parentSequence = sequence;
+			newCommand.commandId = fungusScript.NextCommandId();
 
 			// Let command know it has just been added to the sequence
 			newCommand.OnCommandAdded(sequence);
@@ -741,6 +744,7 @@ namespace Fungus
 						Command pastedCommand = commands.Last<Command>();
 						if (pastedCommand != null)
 						{
+							pastedCommand.commandId = fungusScript.NextCommandId();
 							fungusScript.selectedSequence.commandList.Insert(pasteIndex++, pastedCommand);
 						}
 					}
diff --git a/Assets/Fungus/FungusScript/Scripts/Command.cs b/Assets/Fungus/FungusScript/Scripts/Command.cs
index 256c5b94..49ad8971 100644
--- a/Assets/Fungus/FungusScript/Scripts/Command.cs
+++ b/Assets/Fungus/FungusScript/Scripts/Command.cs
@@ -31,6 +31,9 @@ namespace Fungus
 
 	public class Command : MonoBehaviour
 	{
+		[HideInInspector]
+		public int commandId = -1; // Invalid command id
+
 		[HideInInspector]
 		public string errorMessage = "";
 
diff --git a/Assets/Fungus/FungusScript/Scripts/FountainExporter.cs b/Assets/Fungus/FungusScript/Scripts/FountainExporter.cs
new file mode 100644
index 00000000..abcb6f9b
--- /dev/null
+++ b/Assets/Fungus/FungusScript/Scripts/FountainExporter.cs
@@ -0,0 +1,187 @@
+using UnityEditor;
+using UnityEngine;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.IO;
+
+namespace Fungus
+{
+
+	/**
+	 * Import and export a Fungus story in the .fountain screenplay format.
+	 * The exported file contains special tags in note blocks which map the
+	 * story text to the corresponding commands.
+	 */
+	public class FountainExporter 
+	{
+
+		public static void ExportStrings(FungusScript fungusScript)
+		{
+			if (fungusScript == null)
+			{
+				return;
+			}
+			
+			string path = EditorUtility.SaveFilePanel("Export strings", "",
+			                                          fungusScript.name + ".txt", "");
+			
+			if(path.Length == 0) 
+			{
+				return;
+			}
+			
+			// Write out character names
+			
+			string exportText = "Title: " + fungusScript.name + "\n";
+			exportText += "Draft date: " + System.DateTime.Today.ToString("d") + "\n";
+			exportText += "\n";
+			
+			// In every sequence, write out Say & Menu text in order
+			Sequence[] sequences = fungusScript.GetComponentsInChildren<Sequence>();
+			foreach (Sequence s in sequences)
+			{
+				// Check for any Say, Menu or Comment commands
+				bool hasText = false;
+				foreach (Command c in s.commandList)
+				{
+					System.Type t = c.GetType();
+					if (t == typeof(Say) ||
+					    t == typeof(Menu) ||
+					    t == typeof(Comment))
+					{
+						hasText = true;
+					}
+				}
+				if (!hasText)
+				{
+					continue;
+				}
+				
+				exportText += "." + s.sequenceName.ToUpper() + "\n\n";
+				
+				foreach (Command c in s.commandList)
+				{
+					if (c.GetType() == typeof(Say))
+					{
+						string idText = "";
+						Say say = c as Say;
+						
+						if (say.character == null)
+						{
+							exportText += "NO CHARACTER\n";
+						}
+						else
+						{
+							exportText += say.character.nameText.ToUpper() + "\n";
+						}
+						
+						idText += "[[Say," + c.commandId + "]]\n";
+						
+						exportText += idText;
+						
+						// Fountain requires blank dialogue lines to contain 2 spaces or else
+						// they will be interpreted as ACTION text.
+						string trimmedText = say.storyText.Trim();
+						string[] lines = trimmedText.Split(new [] { '\r', '\n' });
+						foreach (string line in lines)
+						{
+							string trimmed = line.Trim();
+							if (line.Length == 0)
+							{
+								exportText += "  \n";
+							}
+							else
+							{
+								exportText += trimmed + "\n";
+							}
+						}
+						
+						exportText += "\n";
+					}
+					else if (c.GetType() == typeof(Menu))
+					{
+						exportText += "MENU\n";
+						
+						string idText = "";
+						Menu menu = c as Menu;
+						idText += "[[Menu," + c.commandId + "]]\n";
+						
+						exportText += idText + menu.text.Trim() + "\n\n";
+					}
+					else if (c.GetType() == typeof(Comment))
+					{
+						string idText = "";
+						Comment comment = c as Comment;
+						idText += "[[Comment," + c.commandId + "]]\n";
+						
+						exportText += idText + comment.commentText.Trim() + "\n\n";
+					}
+				}
+			}
+			
+			File.WriteAllText(path, exportText);
+		}
+		
+		public static void ImportStrings(FungusScript fungusScript)
+		{
+			string path = EditorUtility.OpenFilePanel("Import strings", "", "");
+			
+			if(path.Length == 0) 
+			{
+				return;
+			}
+			
+			string stringsFile = File.ReadAllText(path);
+			
+			StringsParser parser = new StringsParser();
+			List<StringsParser.StringItem> items = parser.ProcessText(stringsFile);
+			
+			// Build dict of commands
+			Dictionary<int, Command> commandDict = new Dictionary<int, Command>();
+			foreach (Command c in fungusScript.gameObject.GetComponentsInChildren<Command>())
+			{
+				commandDict.Add (c.commandId, c);
+			}
+			
+			foreach (StringsParser.StringItem item in items)
+			{
+				if (item.parameters.Length != 2)
+				{
+					continue;
+				}
+				
+				string stringType = item.parameters[0];
+				if (stringType == "Say")
+				{
+					int commandId = int.Parse(item.parameters[1]);					
+					Say sayCommand = commandDict[commandId] as Say;
+					if (sayCommand != null)
+					{
+						sayCommand.storyText = item.bodyText;
+					}
+				}
+				else if (stringType == "Menu")
+				{
+					int commandId = int.Parse(item.parameters[1]);					
+					Menu menuCommand = commandDict[commandId] as Menu;
+					if (menuCommand != null)
+					{
+						menuCommand.text = item.bodyText;
+					}
+				}
+				else if (stringType == "Comment")
+				{
+					int commandId = int.Parse(item.parameters[1]);					
+					Comment commentCommand = commandDict[commandId] as Comment;
+					if (commentCommand != null)
+					{
+						commentCommand.commentText = item.bodyText;
+					}
+				}
+			}
+		}
+	}
+
+}
diff --git a/Assets/Fungus/FungusScript/Scripts/FountainExporter.cs.meta b/Assets/Fungus/FungusScript/Scripts/FountainExporter.cs.meta
new file mode 100644
index 00000000..e54b9dc3
--- /dev/null
+++ b/Assets/Fungus/FungusScript/Scripts/FountainExporter.cs.meta
@@ -0,0 +1,12 @@
+fileFormatVersion: 2
+guid: c91ad6ef6a0734046bd93dde4b0e59d1
+timeCreated: 1426502899
+licenseType: Free
+MonoImporter:
+  serializedVersion: 2
+  defaultReferences: []
+  executionOrder: 0
+  icon: {instanceID: 0}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 
diff --git a/Assets/Fungus/FungusScript/Scripts/FungusScript.cs b/Assets/Fungus/FungusScript/Scripts/FungusScript.cs
index 19908782..448cd0d6 100644
--- a/Assets/Fungus/FungusScript/Scripts/FungusScript.cs
+++ b/Assets/Fungus/FungusScript/Scripts/FungusScript.cs
@@ -12,6 +12,7 @@ namespace Fungus
 	 * Visual scripting controller for the Fungus Script programming language.
 	 * FungusScript objects may be edited visually using the Fungus Script editor window.
 	 */
+	[ExecuteInEditMode]
 	[AddComponentMenu("Fungus/Fungus Script")]
 	public class FungusScript : MonoBehaviour 
 	{
@@ -91,6 +92,38 @@ namespace Fungus
 		[Tooltip("Saves the selected sequence and commands when saving the scene.")]
 		public bool saveSelection = true;
 
+		/**
+		 * Unique id to assign to the next created command.
+		 * Increases monotonically every time a new command is added to the sequence.
+		 */
+		[SerializeField]
+		protected int nextCommandId = 0;
+
+		/**
+		 * Returns the next command id to assign to a new command.
+		 * Command ids increase monotically so they are guaranteed to
+		 * be unique within a FungusScript.
+		 */
+		public int NextCommandId()
+		{
+			return nextCommandId++;
+		}
+
+		public virtual void OnEnable()
+		{
+			// Assign a command id to any command that doesn't have one yet.
+			// This should only happen after loading a legacy FungusScript
+			Command[] commands = GetComponentsInChildren<Command>();
+		 	foreach (Command command in commands)
+			{
+				if (command.commandId == -1)
+				{
+					command.commandId = NextCommandId();
+					Debug.Log("Assigning command id: " + command.commandId);
+				}
+			}
+		}
+
 		protected virtual Sequence CreateSequenceComponent(GameObject parent)
 		{
 			Sequence s = parent.AddComponent<Sequence>();
diff --git a/Assets/Fungus/FungusScript/Scripts/StringsParser.cs b/Assets/Fungus/FungusScript/Scripts/StringsParser.cs
index e88269b3..9dce7ea9 100644
--- a/Assets/Fungus/FungusScript/Scripts/StringsParser.cs
+++ b/Assets/Fungus/FungusScript/Scripts/StringsParser.cs
@@ -7,103 +7,90 @@ using Fungus;
 namespace Fungus
 {
 	/**
-	 * Parses a text file using a simple format and adds string values to the global string table.
-	 * The format is:
-	 * $FirstString
-	 * The first string text goes here
-	 * $SecondString
-	 * The second string text goes here
-	 * # This is a comment line and will be ignored by the parser
+	 * Parses an exported strings file using the Fountain file format for screenplays
+	 * See http://fountain.io for details.
+	 * We only support a small subset of Fountain markup, and use note tags to embed meta data to
+	 * bind dialogue text to the corresponding Say / Menu commands.
 	 */
-	public class StringsParser : MonoBehaviour
+	public class StringsParser
 	{
-		public TextAsset stringsFile;
-		
-		private enum ParseMode
+		public class StringItem
 		{
-			Idle,
-			Text,
-		};
-
-		public virtual void Start()
-		{
-			ProcessText(stringsFile.text);
+			public string[] parameters;
+			public string bodyText;
 		}
-		
-		protected virtual void ProcessText(string text) 
+
+		public virtual List<StringItem> ProcessText(string text) 
 		{
+			List<StringItem> items = new List<StringItem>();
+
 			// Split text into lines. Add a newline at end to ensure last command is always parsed
 			string[] lines = Regex.Split(text + "\n", "(?<=\n)");
-			
-			string blockBuffer = "";
 
-			ParseMode mode = ParseMode.Idle;
-
-			string blockTag = "";
-			for (int i = 0; i < lines.Length; ++i)
+			int i = 0;
+			while (i < lines.Length)
 			{
-				string line = lines[i];
-
-				bool newBlock = line.StartsWith("$");
+				string line = lines[i].Trim();
 
-				if (mode == ParseMode.Idle && !newBlock)
+				if (!(line.StartsWith("[[") && line.EndsWith("]]")))
 				{
-					// Ignore any text not preceded by a label tag
+					i++;
 					continue;
 				}
 
-				string newBlockTag = "";
-				if (newBlock)
-				{
-					newBlockTag = line.Replace ("\n", "");
-				}
-
-				bool endOfFile = (i == lines.Length-1);
+				string blockTag = line.Substring(2, line.Length - 4);
 
-				bool storeBlock = false;
-
-				if (newBlock)
+				// Find next empty line, #, [[ or eof
+				int start = i + 1;
+				int end = lines.Length - 1;
+				for (int j = start; j <= end; ++j)
 				{
-					storeBlock = true;
-				}
-				else if (mode == ParseMode.Text && endOfFile)
-				{
-					storeBlock = true;
-					if (!line.StartsWith("#"))
+					string line2 = lines[j].Trim();
+
+					if (line2.Length == 0 ||
+					    line2.StartsWith("#") ||
+					    line2.StartsWith("[["))
 					{
-						blockBuffer += line;
+						end = j;
+						break;
 					}
 				}
-				else
+
+				if (end > start)
 				{
-					if (!line.StartsWith("#"))
+					string blockBuffer = "";
+					for (int j = start; j <= end; ++j)
 					{
-						blockBuffer += line;
+						blockBuffer += lines[j].Trim() + "\n";
 					}
-				}
 
-				if (storeBlock)
-				{
-					if (blockTag.Length > 0 && blockBuffer.Length > 0)
+					blockBuffer = blockBuffer.Trim();
+
+					StringItem item = CreateItem(blockTag, blockBuffer);
+					if (item != null)
 					{
-						// Trim off last newline
-						blockBuffer = blockBuffer.TrimEnd( '\r', '\n', ' ', '\t');
+						items.Add(item);
+					}					
+				}
 
-						// TODO: Store in a string table class
-						// GlobalVariables.SetString(blockTag, blockBuffer);
-					}
+				i = end + 1;
+			}
 
-					// Prepare to parse next block
-					mode = ParseMode.Idle;
-					if (newBlock)
-					{
-						mode = ParseMode.Text;
-						blockTag = newBlockTag;
-					}
+			return items;
+		}
 
-					blockBuffer = "";
-				}
+		protected StringItem CreateItem(string commandInfo, string bodyText)
+		{
+			string[] parameters = commandInfo.Split(new char[] { ',' });
+			if (parameters.Length > 0)
+			{
+				StringItem item = new StringItem();
+				item.parameters = parameters;
+				item.bodyText = bodyText;
+				return item;
 			}
+
+			return null;
 		}
 	}
 }