| | 1 | | // Copyright (c) 2020-2024 dotBunny Inc. |
| | 2 | | // dotBunny licenses this file to you under the BSL-1.0 license. |
| | 3 | | // See the LICENSE file in the project root for more information. |
| | 4 | |
|
| | 5 | | using System; |
| | 6 | | using System.Collections.Generic; |
| | 7 | | using System.IO; |
| | 8 | | using System.Runtime.CompilerServices; |
| | 9 | | using System.Text; |
| | 10 | | using UnityEngine; |
| | 11 | | using TextGenerator = GDX.Developer.TextGenerator; |
| | 12 | |
|
| | 13 | | namespace GDX.DataTables.DataBinding.Formats |
| | 14 | | { |
| | 15 | | /// <summary> |
| | 16 | | /// A comma-seperated values format. |
| | 17 | | /// </summary> |
| | 18 | | class CommaSeperatedValueFormat : FormatBase |
| | 19 | | { |
| 1 | 20 | | public CommaSeperatedValueFormat() |
| 1 | 21 | | { |
| | 22 | | // We can register the format this way because we automatically include these formats in the |
| | 23 | | // DataBindingsProvider as private members. |
| 1 | 24 | | DataBindingProvider.RegisterFormat(this); |
| 1 | 25 | | } |
| | 26 | |
|
| | 27 | | ~CommaSeperatedValueFormat() |
| 0 | 28 | | { |
| 0 | 29 | | DataBindingProvider.UnregisterFormat(this); |
| 0 | 30 | | } |
| | 31 | |
|
| | 32 | | /// <inheritdoc /> |
| | 33 | | public override DateTime GetBindingTimestamp(string uri) |
| 0 | 34 | | { |
| 0 | 35 | | return File.GetLastWriteTimeUtc(Path.Combine(Application.dataPath, uri)); |
| 0 | 36 | | } |
| | 37 | |
|
| | 38 | | /// <inheritdoc /> |
| | 39 | | public override string GetFilePreferredExtension() |
| 0 | 40 | | { |
| 0 | 41 | | return "csv"; |
| 0 | 42 | | } |
| | 43 | |
|
| | 44 | | /// <inheritdoc /> |
| | 45 | | public override string GetFriendlyName() |
| 4 | 46 | | { |
| 4 | 47 | | return "CSV"; |
| 4 | 48 | | } |
| | 49 | |
|
| | 50 | | /// <inheritdoc /> |
| | 51 | | public override bool IsFileHeader(string header) |
| 0 | 52 | | { |
| 0 | 53 | | return header.StartsWith("Row Identifier, Row Name,", StringComparison.OrdinalIgnoreCase); |
| 0 | 54 | | } |
| | 55 | |
|
| | 56 | | /// <inheritdoc /> |
| | 57 | | public override string[] GetImportDialogExtensions() |
| 0 | 58 | | { |
| 0 | 59 | | return new[] { GetFriendlyName(), GetFilePreferredExtension() }; |
| 0 | 60 | | } |
| | 61 | |
|
| | 62 | | /// <inheritdoc /> |
| | 63 | | public override bool IsOnDiskFormat() |
| 4 | 64 | | { |
| 4 | 65 | | return true; |
| 4 | 66 | | } |
| | 67 | |
|
| | 68 | | /// <inheritdoc /> |
| | 69 | | public override bool IsUri(string uri) |
| 0 | 70 | | { |
| 0 | 71 | | return uri.EndsWith(GetFilePreferredExtension(), StringComparison.OrdinalIgnoreCase); |
| 0 | 72 | | } |
| | 73 | |
|
| | 74 | | /// <inheritdoc /> |
| | 75 | | public override SerializableTable Pull(string uri, ulong currentDataVersion, int currentStructuralVersion) |
| 0 | 76 | | { |
| 0 | 77 | | if (uri == null || !File.Exists(uri)) |
| 0 | 78 | | { |
| 0 | 79 | | return null; |
| | 80 | | } |
| | 81 | |
|
| 0 | 82 | | return Parse(File.ReadAllLines(uri)); |
| 0 | 83 | | } |
| | 84 | |
|
| | 85 | | /// <inheritdoc /> |
| | 86 | | public override bool Push(string uri, SerializableTable serializableTable) |
| 0 | 87 | | { |
| 0 | 88 | | if (uri == null) |
| 0 | 89 | | { |
| 0 | 90 | | return false; |
| | 91 | | } |
| | 92 | |
|
| 0 | 93 | | File.WriteAllText(uri, Generate(serializableTable), new UTF8Encoding()); |
| 0 | 94 | | return File.Exists(uri); |
| 0 | 95 | | } |
| | 96 | |
|
| | 97 | | /// <summary> |
| | 98 | | /// Creates the content for a Comma Seperated Values file from a <see cref="SerializableTable" />. |
| | 99 | | /// </summary> |
| | 100 | | /// <param name="serializableTable">The target to create the file from.</param> |
| | 101 | | /// <returns>The content of the file.</returns> |
| | 102 | | static string Generate(SerializableTable serializableTable) |
| 0 | 103 | | { |
| 0 | 104 | | int rowCount = serializableTable.Rows.Length; |
| 0 | 105 | | int columnCount = serializableTable.Types.Length; |
| | 106 | |
|
| 0 | 107 | | TextGenerator generator = new TextGenerator(); |
| | 108 | |
|
| | 109 | | // Build first line |
| 0 | 110 | | generator.Append("Row Identifier, Row Name"); |
| 0 | 111 | | for (int i = 0; i < columnCount; i++) |
| 0 | 112 | | { |
| 0 | 113 | | generator.Append(", "); |
| 0 | 114 | | generator.Append(serializableTable.Headers[i]); |
| 0 | 115 | | } |
| | 116 | |
|
| 0 | 117 | | generator.NextLine(); |
| | 118 | |
|
| | 119 | | // Build info line |
| 0 | 120 | | generator.Append( |
| | 121 | | $"{serializableTable.DataVersion.ToString()}, {serializableTable.StructureVersion.ToString()}"); |
| 0 | 122 | | for (int i = 0; i < columnCount; i++) |
| 0 | 123 | | { |
| 0 | 124 | | generator.Append(", "); |
| 0 | 125 | | generator.Append(serializableTable.Types[i]); |
| 0 | 126 | | } |
| | 127 | |
|
| 0 | 128 | | generator.NextLine(); |
| | 129 | |
|
| | 130 | | // Build lines for rows |
| 0 | 131 | | for (int r = 0; r < rowCount; r++) |
| 0 | 132 | | { |
| 0 | 133 | | SerializableRow transferRow = serializableTable.Rows[r]; |
| | 134 | |
|
| 0 | 135 | | generator.Append($"{transferRow.Identifier.ToString()}, {MakeCommaSeperatedValue(transferRow.Name)}"); |
| 0 | 136 | | for (int c = 0; c < columnCount; c++) |
| 0 | 137 | | { |
| 0 | 138 | | generator.Append(", "); |
| | 139 | |
|
| 0 | 140 | | if (serializableTable.Types[c] == Serializable.SerializableTypes.String.GetLabel() || |
| | 141 | | serializableTable.Types[c] == Serializable.SerializableTypes.Char.GetLabel()) |
| 0 | 142 | | { |
| 0 | 143 | | generator.Append(MakeCommaSeperatedValue(transferRow.Data[c])); |
| 0 | 144 | | } |
| | 145 | | else |
| 0 | 146 | | { |
| 0 | 147 | | generator.Append(transferRow.Data[c]); |
| 0 | 148 | | } |
| 0 | 149 | | } |
| | 150 | |
|
| 0 | 151 | | generator.NextLine(); |
| 0 | 152 | | } |
| | 153 | |
|
| 0 | 154 | | return generator.ToString(); |
| 0 | 155 | | } |
| | 156 | |
|
| | 157 | | /// <summary> |
| | 158 | | /// Make a CSV safe version of the provided content. |
| | 159 | | /// </summary> |
| | 160 | | /// <param name="content">The content which needs to be made safe for CSV.</param> |
| | 161 | | /// <returns>A CSV safe value string.</returns> |
| | 162 | | [MethodImpl(MethodImplOptions.AggressiveInlining)] |
| | 163 | | static string MakeCommaSeperatedValue(string content) |
| 0 | 164 | | { |
| 0 | 165 | | if (content == null) |
| 0 | 166 | | { |
| 0 | 167 | | return string.Empty; |
| | 168 | | } |
| | 169 | |
|
| | 170 | | // Double quote quotes |
| 0 | 171 | | if (content.IndexOf('"') != -1) |
| 0 | 172 | | { |
| 0 | 173 | | content = content.Replace("\"", "\"\""); |
| 0 | 174 | | } |
| | 175 | |
|
| | 176 | | // Ensure quotes for commas |
| 0 | 177 | | return content.IndexOf(',') != -1 ? $"\"{content}\"" : content; |
| 0 | 178 | | } |
| | 179 | |
|
| | 180 | | /// <summary> |
| | 181 | | /// Creates a <see cref="SerializableTable" /> from a CSV files contents. |
| | 182 | | /// </summary> |
| | 183 | | /// <param name="fileContent">An array of lines from a csv file.</param> |
| | 184 | | /// <returns>An object if it successfully parses, or null if it fails.</returns> |
| | 185 | | static SerializableTable Parse(string[] fileContent) |
| 0 | 186 | | { |
| | 187 | | try |
| 0 | 188 | | { |
| 0 | 189 | | SerializableTable returnSerializableTable = new SerializableTable(); |
| | 190 | |
|
| 0 | 191 | | int rowCount = fileContent.Length - 2; |
| 0 | 192 | | if (rowCount <= 0) |
| 0 | 193 | | { |
| 0 | 194 | | return null; |
| | 195 | | } |
| | 196 | |
|
| | 197 | | // Build headers |
| 0 | 198 | | string[] headers = ParseCommaSeperatedValues(fileContent[0]); |
| 0 | 199 | | int actualHeaderCount = headers.Length - 2; |
| 0 | 200 | | returnSerializableTable.Headers = new string[actualHeaderCount]; |
| 0 | 201 | | Array.Copy(headers, 2, returnSerializableTable.Headers, 0, actualHeaderCount); |
| | 202 | |
|
| | 203 | | // Build types plus additional packed versions |
| 0 | 204 | | string[] types = ParseCommaSeperatedValues(fileContent[1]); |
| 0 | 205 | | int actualTypesCount = types.Length - 2; |
| 0 | 206 | | returnSerializableTable.Types = new string[actualTypesCount]; |
| 0 | 207 | | returnSerializableTable.DataVersion = ulong.Parse(types[0]); |
| 0 | 208 | | returnSerializableTable.StructureVersion = int.Parse(types[1]); |
| 0 | 209 | | Array.Copy(types, 2, returnSerializableTable.Types, 0, actualTypesCount); |
| | 210 | |
|
| | 211 | | // Extract rows |
| 0 | 212 | | returnSerializableTable.Rows = new SerializableRow[rowCount]; |
| 0 | 213 | | int rowIndex = 0; |
| 0 | 214 | | for (int i = 2; i < fileContent.Length; i++) |
| 0 | 215 | | { |
| 0 | 216 | | string[] rowData = ParseCommaSeperatedValues(fileContent[i]); |
| | 217 | |
|
| 0 | 218 | | SerializableRow transferRow = new SerializableRow(actualTypesCount) |
| | 219 | | { |
| | 220 | | Identifier = int.Parse(rowData[0]), Name = rowData[1], Data = new string[actualTypesCount] |
| | 221 | | }; |
| 0 | 222 | | Array.Copy(rowData, 2, transferRow.Data, 0, actualTypesCount); |
| | 223 | |
|
| 0 | 224 | | returnSerializableTable.Rows[rowIndex] = transferRow; |
| 0 | 225 | | rowIndex++; |
| 0 | 226 | | } |
| | 227 | |
|
| | 228 | | // Return our built object from CSV |
| 0 | 229 | | return returnSerializableTable; |
| | 230 | | } |
| 0 | 231 | | catch (Exception e) |
| 0 | 232 | | { |
| 0 | 233 | | Debug.LogWarning($"Unable to parse provided CVS\n{e.Message}"); |
| 0 | 234 | | return null; |
| | 235 | | } |
| 0 | 236 | | } |
| | 237 | |
|
| | 238 | | /// <summary> |
| | 239 | | /// Parse a given <paramref name="line" /> into seperated values. |
| | 240 | | /// </summary> |
| | 241 | | /// <param name="line">The CSV line.</param> |
| | 242 | | /// <returns>An array of string values.</returns> |
| | 243 | | static string[] ParseCommaSeperatedValues(string line) |
| 0 | 244 | | { |
| 0 | 245 | | List<string> returnStrings = new List<string>(); |
| 0 | 246 | | int lastIndex = -1; |
| 0 | 247 | | int currentIndex = 0; |
| 0 | 248 | | bool isQuoted = false; |
| 0 | 249 | | int length = line.Length; |
| 0 | 250 | | while (currentIndex < length) |
| 0 | 251 | | { |
| 0 | 252 | | switch (line[currentIndex]) |
| | 253 | | { |
| | 254 | | case '"': |
| 0 | 255 | | isQuoted = !isQuoted; |
| 0 | 256 | | break; |
| | 257 | | case ',': |
| 0 | 258 | | if (!isQuoted) |
| 0 | 259 | | { |
| 0 | 260 | | returnStrings.Add(line.Substring(lastIndex + 1, currentIndex - lastIndex).Trim(' ', ',')); |
| 0 | 261 | | lastIndex = currentIndex; |
| 0 | 262 | | } |
| | 263 | |
|
| 0 | 264 | | break; |
| | 265 | | } |
| | 266 | |
|
| 0 | 267 | | currentIndex++; |
| 0 | 268 | | } |
| | 269 | |
|
| 0 | 270 | | if (lastIndex != line.Length - 1) |
| 0 | 271 | | { |
| 0 | 272 | | returnStrings.Add(line.Substring(lastIndex + 1).Trim()); |
| 0 | 273 | | } |
| | 274 | |
|
| 0 | 275 | | return returnStrings.ToArray(); |
| 0 | 276 | | } |
| | 277 | | } |
| | 278 | | } |