using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using pkNX.Containers;
using pkNX.Containers.VFS;
using pkNX.Game;
using pkNX.Structures;
namespace pkNX.WinForms;
///
/// Interaction logic for WPFTextEditor.xaml
///
public partial class WPFTextEditor
{
public record TextEditorEntry : INotifyPropertyChanged
{
private string _variable;
public int Line { get; init; }
public string Variable
{
get => _variable;
set
{
_variable = value;
Hash = FnvHash.HashFnv1a_64(_variable);
OnPropertyChanged();
OnPropertyChanged(nameof(Hash));
}
}
public ulong Hash { get; private set; }
public string Text { get; set; }
public bool IsReadOnly { get; }
public TextEditorEntry(int line, string variable, ulong hash, string text, bool isLastRow = false)
{
Line = line;
_variable = variable;
Hash = hash;
Text = text;
IsReadOnly = isLastRow;
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
public ObservableCollection Entries { get; } = [];
public enum TextEditorMode
{
Common,
Script,
}
private readonly TextEditorMode Mode;
public bool Modified { get; set; }
public int LoadedFileIndex { get; set; } = -1;
public IReadOnlyList FileNames { get; init; }
private IReadOnlyList TextFiles { get; }
private IReadOnlyList TableFiles { get; }
public readonly TextConfig? Config;
public WPFTextEditor(TextEditorMode mode, TextConfig? config = null)
{
Mode = mode;
Config = config;
var path = GamePath.GetDirectoryPath(mode == TextEditorMode.Common ? GameFile.GameText : GameFile.StoryText);
var files = VirtualFileSystem.Current.GetFiles(path);
var fileNames = new List();
var textFiles = new List();
var variableFiles = new List();
foreach (var file in files)
{
var (name, ext) = file.GetFileNameAndExtension();
switch (ext)
{
case "dat":
textFiles.Add(file);
fileNames.Add(name); // Note: We should only add the file name once. Since each .dat file has a .tbl file with the same name, we can just do this once here.
break;
case "tbl":
variableFiles.Add(file);
break;
}
}
FileNames = fileNames;
TableFiles = variableFiles;
TextFiles = textFiles;
InitializeComponent();
var settings = ProgramSettings.LoadSettings();
CHK_ShowHashes.IsChecked = settings.DisplayAdvanced;
CB_Entry.SelectedIndex = 0;
}
private bool ImportTextFiles(string fileName)
{
static bool ValidateSeparatorIndex(int nextSeparator, int lineNumber, ReadOnlySpan line)
{
if (nextSeparator != -1)
return true;
WinFormsUtil.Error($"Invalid Line @ {lineNumber}, expected '|', but was not found near '{line}'");
return false;
}
bool forceImport = false;
if (Path.GetFileNameWithoutExtension(fileName) != FileNames[LoadedFileIndex])
{
if (WinFormsUtil.Warn(MessageBoxButton.YesNo,
"The filename does not match the currently selected file. Using the wrong data will result in game crashes!",
"Import anyway?") != MessageBoxResult.Yes)
{
return false;
}
forceImport = true;
}
string[] fileText = File.ReadAllLines(fileName, Encoding.Unicode);
List entries = [];
for (int i = 0; i < fileText.Length; i++)
{
ReadOnlySpan line = fileText[i].AsSpan();
// Check each line and make sure it starts with '|', if not that line should be appended to the text value of the last line
if (!line.StartsWith("|"))
{
var lastEntry = entries[^1];
entries[^1] = lastEntry with { Text = lastEntry.Text + "\\n" + line.ToString() };
continue;
}
line = line[1..]; // Skip the first '|'
int nextSeparator = line.IndexOf('|');
if (!ValidateSeparatorIndex(nextSeparator, i, line))
return false;
string variable = line[..nextSeparator].ToString();
line = line[(nextSeparator + 1)..]; // Skip over the variable and the second '|'
nextSeparator = line.IndexOf('|');
if (!ValidateSeparatorIndex(nextSeparator, i, line))
return false;
// Skip the first two characters '0x'
if (!ulong.TryParse(line[2..nextSeparator], NumberStyles.HexNumber, null, out var hash))
{
hash = FnvHash.HashFnv1a_64(variable);
}
line = line[(nextSeparator + 1)..]; // Skip over the hash and the third '|'
string text = line.ToString(); // The rest of the line is the text
entries.Add(new TextEditorEntry(entries.Count, variable, hash, text));
}
if (!forceImport && Entries.Count != entries.Count)
{
if (WinFormsUtil.Prompt(MessageBoxButton.YesNo,
$"The number of lines imported ({entries.Count}), does not match the number of lines in editor ({Entries.Count}). This might be okay if you intend to add additional entries.",
"Import anyway?") != MessageBoxResult.Yes)
{
return false;
}
}
Entries.Clear();
foreach (var entry in entries)
Entries.Add(entry);
return true;
}
public void ExportTextFile(string fileName, bool replaceNewline)
{
using var ms = new MemoryStream();
ms.Write([0xFF, 0xFE], 0, 2); // Write Unicode BOM
using (TextWriter tw = new StreamWriter(ms, new UnicodeEncoding()))
{
foreach (var entry in Entries)
{
var textString = entry.Text;
if (replaceNewline)
{
textString = textString.Replace("\\n", "\n");
}
tw.WriteLine($"|{entry.Variable}|0x{entry.Hash:X16}|{textString}");
}
}
File.WriteAllBytes(fileName, ms.ToArray());
}
public void InsertEntry(int index)
{
if (Entries.Count == 0 || index < 0 || index >= Entries.Count - 1)
{
Entries.Add(new TextEditorEntry(Entries.Count, "", 0, ""));
DG_Text.SelectedIndex = Entries.Count - 1;
return;
}
bool ctrlPressed = (Keyboard.Modifiers & ModifierKeys.Control) > 0;
if (index != 0 && !ctrlPressed)
{
if (WinFormsUtil.Prompt(MessageBoxButton.YesNo, "Inserting in between rows will shift all subsequent lines.", "Continue? (Hold ctrl to ignore prompt)") != MessageBoxResult.Yes)
return;
}
// Insert new Row after current row.
int nextLine = index + 1;
Entries.Insert(nextLine, new TextEditorEntry(nextLine, "", 0, ""));
DG_Text.SelectedIndex = nextLine;
for (int i = nextLine + 1; i < Entries.Count; i++)
Entries[i] = Entries[i] with { Line = i };
}
public void RemoveEntry(int index)
{
if (Entries.Count == 0 || index < 0 || index >= Entries.Count)
{
return;
}
bool ctrlPressed = (Keyboard.Modifiers & ModifierKeys.Control) > 0;
if (index < Entries.Count - 1 && !ctrlPressed)
{
if (WinFormsUtil.Prompt(MessageBoxButton.YesNo, "Deleting a row above other lines will shift all subsequent lines.", "Continue? (Hold ctrl to ignore prompt)") != MessageBoxResult.Yes)
return;
}
Entries.RemoveAt(index);
for (int i = index; i < Entries.Count; i++)
Entries[i] = Entries[i] with { Line = i };
}
public void LoadSelectedFile()
{
LoadedFileIndex = CB_Entry.SelectedIndex;
Entries.Clear();
var textFile = TextFiles[LoadedFileIndex];
var tblFile = TableFiles[LoadedFileIndex];
var lines = new TextFile(textFile.ReadAllBytes(), Config).Lines;
var tbl = new AHTB(tblFile.Open());
// The table has 1 more entry than the dat to show when the table ends
if (tbl.Entries.Length != lines.Length + 1)
{
var result = WinFormsUtil.Warn(MessageBoxButton.YesNo, "Data corruption detected!", "The number of labels in the table does not match the number of lines in the text file.", "Do you wish to restore the original file?");
if (result != MessageBoxResult.Yes)
return;
textFile.Delete(DeleteMode.TopMostWriteableLayer);
tblFile.Delete(DeleteMode.TopMostWriteableLayer);
lines = new TextFile(textFile.ReadAllBytes(), Config).Lines; // Reopen the original file
tbl = new AHTB(tblFile.Open()); // Reopen the original file
Debug.Assert(tbl.Entries.Length == lines.Length + 1);
}
for (int i = 0; i < tbl.Count; ++i)
{
var label = tbl.Entries[i];
bool isLast = i == tbl.Count - 1;
var text = isLast ? "" : lines[i];
Entries.Add(new TextEditorEntry(i, label.Name, label.Hash, text, isLast));
}
}
public void SaveCurrentFile()
{
if (!Modified)
return;
var textBytes = TextFile.GetBytes(Entries.SkipLast(1).Select(x => x.Text), Config);
TextFiles[LoadedFileIndex].WriteAllBytes(textBytes);
AHTB tbl = new(Entries.ToDictionary(x => x.Hash, y => y.Variable));
TableFiles[LoadedFileIndex].WriteAllBytes(tbl);
Modified = false;
}
public void PromptSaveCurrentFile(CancelEventArgs e)
{
if (!Modified)
return;
var result = WinFormsUtil.Prompt(MessageBoxButton.YesNoCancel, "Would you like to save your changes?");
switch (result)
{
case MessageBoxResult.Cancel:
e.Cancel = true;
break;
case MessageBoxResult.Yes:
SaveCurrentFile();
break;
}
}
private void CB_Entry_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (CB_Entry.SelectedIndex == LoadedFileIndex)
return;
CancelEventArgs args = new();
PromptSaveCurrentFile(args);
if (args.Cancel)
{
CB_Entry.SelectedIndex = LoadedFileIndex;
return;
}
LoadSelectedFile();
}
private void B_AddLine_Click(object sender, RoutedEventArgs e)
{
InsertEntry(DG_Text.SelectedIndex);
DG_Text.ScrollIntoView(DG_Text.SelectedItem);
}
private void B_RemoveLine_Click(object sender, RoutedEventArgs e)
{
RemoveEntry(DG_Text.SelectedIndex);
}
private void B_Export_Click(object sender, RoutedEventArgs e)
{
if (Entries.Count == 0)
return;
if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift))
{
BatchExport();
return;
}
using var dump = new System.Windows.Forms.SaveFileDialog();
dump.Filter = @"Text File|*.txt";
dump.FileName = FileNames[LoadedFileIndex] + ".txt";
if (dump.ShowDialog() != System.Windows.Forms.DialogResult.OK)
return;
var result = WinFormsUtil.Prompt(MessageBoxButton.YesNo, "Would you like to unescape newline characters?");
bool newline = result == MessageBoxResult.Yes;
string path = dump.FileName;
ExportTextFile(path, newline);
System.Media.SystemSounds.Asterisk.Play();
}
private void BatchExport()
{
using var dumpFolder = new System.Windows.Forms.FolderBrowserDialog();
dumpFolder.Description = "Select export folder";
dumpFolder.UseDescriptionForTitle = true;
dumpFolder.ShowNewFolderButton = true;
if (dumpFolder.ShowDialog() != System.Windows.Forms.DialogResult.OK)
return;
// If not directory is empty, prompt to replace all files
if (Directory.EnumerateFiles(dumpFolder.SelectedPath).Any())
{
var replaceResult = WinFormsUtil.Prompt(MessageBoxButton.YesNoCancel, "The selected folder is not empty. Would you like to continue and replace all text files?");
if (replaceResult == MessageBoxResult.Cancel)
return;
}
var result = WinFormsUtil.Prompt(MessageBoxButton.YesNo, "Would you like to unescape newline characters?");
bool newline = result == MessageBoxResult.Yes;
foreach (var fileName in FileNames)
{
string path = Path.Combine(dumpFolder.SelectedPath, fileName + ".txt");
ExportTextFile(path, newline);
}
System.Media.SystemSounds.Asterisk.Play();
}
private void B_Import_Click(object sender, RoutedEventArgs e)
{
using var dump = new System.Windows.Forms.OpenFileDialog();
dump.Filter = @"Text File|*.txt";
dump.FileName = FileNames[LoadedFileIndex] + ".txt";
if (dump.ShowDialog() != System.Windows.Forms.DialogResult.OK)
return;
string path = dump.FileName;
if (!ImportTextFiles(path))
return;
System.Media.SystemSounds.Asterisk.Play();
}
private void DG_Text_CellEditEnding(object sender, DataGridCellEditEndingEventArgs e)
{
if (e.EditAction == DataGridEditAction.Cancel)
return;
var entry = (TextEditorEntry)e.Row.Item;
var text = ((TextBox)e.EditingElement).Text;
switch (e.Column.DisplayIndex)
{
case 1:
Modified |= entry.Variable != text;
break;
case 3:
Modified |= entry.Text != text;
break;
}
}
private void Window_Closing(object sender, CancelEventArgs e)
{
PromptSaveCurrentFile(e);
}
private void B_Save_Click(object sender, RoutedEventArgs e)
{
SaveCurrentFile();
}
}