diff --git a/Craftimizer/Utils/MacroCopy.cs b/Craftimizer/Utils/MacroCopy.cs index 10ecb50..abdc02f 100644 --- a/Craftimizer/Utils/MacroCopy.cs +++ b/Craftimizer/Utils/MacroCopy.cs @@ -144,7 +144,7 @@ public static class MacroCopy var module = RaptureMacroModule.Instance(); var macro = module->GetMacro(isShared ? 1u : 0u, (uint)idx); - var text = Utf8String.FromString(macroText.Replace(Environment.NewLine, "\n")); + var text = Utf8String.FromString(macroText.ReplaceLineEndings("\n")); module->ReplaceMacroLines(macro, text); text->Dtor(); IMemorySpace.Free(text); diff --git a/Craftimizer/Utils/MacroImport.cs b/Craftimizer/Utils/MacroImport.cs new file mode 100644 index 0000000..7ef72c4 --- /dev/null +++ b/Craftimizer/Utils/MacroImport.cs @@ -0,0 +1,269 @@ +using Craftimizer.Plugin; +using Craftimizer.Simulator; +using Craftimizer.Simulator.Actions; +using Dalamud.Networking.Http; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using static System.Runtime.InteropServices.JavaScript.JSType; + +namespace Craftimizer.Utils; + +public static class MacroImport +{ + public static IReadOnlyList? TryParseMacro(string inputMacro) + { + var actions = new List(); + foreach(var line in inputMacro.ReplaceLineEndings("\n").Split("\n")) + { + if (TryParseLine(line) is { } action) + actions.Add(action); + } + return actions.Count > 0 ? actions : null; + } + + private static ActionType? TryParseLine(string line) + { + if (line.StartsWith("/ac", StringComparison.OrdinalIgnoreCase)) + line = line[3..]; + else if (line.StartsWith("/action", StringComparison.OrdinalIgnoreCase)) + line = line[7..]; + else + return null; + + line = line.TrimStart(); + + // get first word + if (line.StartsWith('"')) + { + line = line[1..]; + + var end = line.IndexOf('"', 1); + if (end != -1) + line = line[..end]; + } + else + { + var end = line.IndexOf(' ', 1); + if (end != -1) + line = line[..end]; + } + + foreach(var action in Enum.GetValues()) + { + if (line.Equals(action.GetName(ClassJob.Carpenter), StringComparison.OrdinalIgnoreCase)) + return action; + } + return null; + } + + public static bool TryParseUrl(string url, out Uri uri) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out uri!)) + return false; + + if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + return false; + + if (!uri.IsDefaultPort) + return false; + + return uri.DnsSafeHost is "ffxivteamcraft.com" or "craftingway.app"; + } + + public static Task RetrieveUrl(string url, CancellationToken token) + { + if (!TryParseUrl(url, out var uri)) + throw new ArgumentException("Unsupported url", nameof(url)); + + switch (uri.DnsSafeHost) + { + case "ffxivteamcraft.com": + return RetrieveTeamcraftUrl(uri, token); + case "craftingway.app": + return RetrieveCraftingwayUrl(uri, token); + default: + throw new UnreachableException("TryParseUrl should handle miscellaneous edge cases"); + } + } + + private sealed record TeamcraftMacro + { + public sealed record StringValue + { + [JsonPropertyName("stringValue")] + [JsonRequired] + public required string Value { get; set; } + + public static implicit operator string(StringValue v) => v.Value; + } + + public sealed record IntegerValue + { + [JsonPropertyName("integerValue")] + [JsonRequired] + public required int Value { get; set; } + + public static implicit operator int(IntegerValue v) => v.Value; + } + + public sealed record MapValue + { + public sealed record ValueData + { + [JsonRequired] + public required T Fields { get; set; } + } + + [JsonPropertyName("mapValue")] + [JsonRequired] + public required ValueData Data { get; set; } + + public T Value => Data.Fields; + public static implicit operator T(MapValue v) => v.Value; + } + + public sealed record ArrayValue + { + public sealed record ValueData + { + [JsonRequired] + public required T[] Values { get; set; } + } + + [JsonPropertyName("arrayValue")] + [JsonRequired] + public required ValueData Data { get; set; } + + public T[] Value => Data.Values; + public static implicit operator T[](ArrayValue v) => v.Value; + } + + public sealed record RecipeFieldData + { + [JsonRequired] + public required IntegerValue RLvl { get; set; } + [JsonRequired] + public required IntegerValue Durability { get; set; } + } + + public sealed record FieldData + { + public StringValue? Name { get; set; } + [JsonRequired] + public required ArrayValue Rotation { get; set; } + public MapValue? Recipe { get; set; } + } + + public sealed record ErrorData + { + public required int Code { get; set; } + public required string Message { get; set; } + public required string Status { get; set; } + } + + public FieldData? Fields { get; set; } + + public ErrorData? Error { get; set; } + } + + public readonly record struct RetrievedMacro(string Name, IReadOnlyList Actions); + + private static async Task RetrieveTeamcraftUrl(Uri uri, CancellationToken token) + { + using var heCallback = new HappyEyeballsCallback(); + using var client = new HttpClient(new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.All, + ConnectCallback = heCallback.ConnectCallback, + }); + + var path = uri.GetComponents(UriComponents.Path, UriFormat.SafeUnescaped); + if (!path.StartsWith("simulator/", StringComparison.Ordinal)) + throw new ArgumentException("Teamcraft macro url should start with /simulator", nameof(uri)); + path = path[10..]; + + var lastSlash = path.LastIndexOf('/'); + if (lastSlash == -1) + throw new ArgumentException("Teamcraft macro url is not in the right format", nameof(uri)); + + var id = path[(lastSlash + 1)..]; + + var resp = await client.GetFromJsonAsync($"https://firestore.googleapis.com/v1beta1/projects/ffxivteamcraft/databases/(default)/documents/rotations/{id}", token).ConfigureAwait(false); + if (resp is null) + throw new Exception("Internal error; failed to retrieve macro"); + if (resp.Error is { } error) + throw new Exception($"Internal server error ({error.Status}); {error.Message}"); + if (resp.Fields is not { } rotation) + throw new Exception($"Internal error; No fields or error was returned"); + // https://github.com/ffxiv-teamcraft/ffxiv-teamcraft/blob/67f453041c6b2b31d32fcf6e1fd53aa38ed7a12b/apps/client/src/app/model/other/crafting-rotation.ts#L49 + var name = rotation.Name?.Value ?? + (rotation.Recipe is { Value: var recipe } ? + $"rlvl{recipe.RLvl.Value} - {rotation.Rotation.Value.Length} steps, {recipe.Durability.Value} dur" : + "New Teamcraft Rotation"); + var actions = new List(); + foreach(var action in rotation.Rotation.Value) + { + ActionType? actionType = action.Value switch + { + "BasicSynthesis" => ActionType.BasicSynthesis, + "CarefulSynthesis" => ActionType.CarefulSynthesis, + "PrudentSynthesis" => ActionType.PrudentSynthesis, + "RapidSynthesis" => ActionType.RapidSynthesis, + "Groundwork" => ActionType.Groundwork, + "FocusedSynthesis" => ActionType.FocusedSynthesis, + "MuscleMemory" => ActionType.MuscleMemory, + "IntensiveSynthesis" => ActionType.IntensiveSynthesis, + "BasicTouch" => ActionType.BasicTouch, + "StandardTouch" => ActionType.StandardTouch, + "AdvancedTouch" => ActionType.AdvancedTouch, + "HastyTouch" => ActionType.HastyTouch, + "ByregotsBlessing" => ActionType.ByregotsBlessing, + "PreciseTouch" => ActionType.PreciseTouch, + "FocusedTouch" => ActionType.FocusedTouch, + "PrudentTouch" => ActionType.PrudentTouch, + "TrainedEye" => ActionType.TrainedEye, + "PreparatoryTouch" => ActionType.PreparatoryTouch, + "Reflect" => ActionType.Reflect, + "TrainedFinesse" => ActionType.TrainedFinesse, + "TricksOfTheTrade" => ActionType.TricksOfTheTrade, + "MastersMend" => ActionType.MastersMend, + "Manipulation" => ActionType.Manipulation, + "WasteNot" => ActionType.WasteNot, + "WasteNotII" => ActionType.WasteNot2, + "GreatStrides" => ActionType.GreatStrides, + "Innovation" => ActionType.Innovation, + "Veneration" => ActionType.Veneration, + "FinalAppraisal" => ActionType.FinalAppraisal, + "Observe" => ActionType.Observe, + "HeartAndSoul" => ActionType.HeartAndSoul, + "CarefulObservation" => ActionType.CarefulObservation, + "DelicateSynthesis" => ActionType.DelicateSynthesis, + "RemoveFinalAppraisal" => throw new Exception("Removing Final Appraisal is an unsupported action"), + null => null, + { } actionValue => throw new Exception($"Unknown action {actionValue}"), + }; + if (actionType.HasValue) + actions.Add(actionType.Value); + } + return new(name, actions); + } + + private static async Task RetrieveCraftingwayUrl(Uri uri, CancellationToken token) + { + using var heCallback = new HappyEyeballsCallback(); + using var client = new HttpClient(new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.All, + ConnectCallback = heCallback.ConnectCallback, + }); + + throw new NotImplementedException(); + } +} diff --git a/Craftimizer/Windows/MacroEditor.cs b/Craftimizer/Windows/MacroEditor.cs index 69a91af..9d4f9e1 100644 --- a/Craftimizer/Windows/MacroEditor.cs +++ b/Craftimizer/Windows/MacroEditor.cs @@ -6,8 +6,10 @@ using Craftimizer.Utils; using Dalamud.Game.ClientState.Statuses; using Dalamud.Game.Text; using Dalamud.Interface; +using Dalamud.Interface.Colors; using Dalamud.Interface.GameFonts; using Dalamud.Interface.Internal; +using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; using Dalamud.Utility; @@ -104,6 +106,14 @@ public sealed class MacroEditor : Window, IDisposable private IDalamudTextureWrap EatFromTheHandBadge { get; } private GameFontHandle AxisFont { get; } + private string popupSaveAsMacroName = string.Empty; + + private string popupImportText = string.Empty; + private string popupImportUrl = string.Empty; + private string popupImportError = string.Empty; + private CancellationTokenSource? popupImportUrlTokenSource; + private MacroImport.RetrievedMacro? popupImportUrlMacro; + public MacroEditor(CharacterStats characterStats, RecipeData recipeData, CrafterBuffs buffs, IEnumerable actions, Action>? setter) : base("Craftimizer Macro Editor", WindowFlags, false) { CharacterStats = characterStats; @@ -168,7 +178,8 @@ public sealed class MacroEditor : Window, IDisposable ImGui.Separator(); - using (var table = ImRaii.Table("macroInfo", 2, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingStretchSame)) { + using (var table = ImRaii.Table("macroInfo", 2, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingStretchSame)) + { if (table) { ImGui.TableSetupColumn("col1", ImGuiTableColumnFlags.WidthStretch, 2); @@ -691,7 +702,7 @@ public sealed class MacroEditor : Window, IDisposable ImGui.AlignTextToFramePadding(); ImGui.Image(Service.IconManager.GetIcon(RecipeData.Recipe.ItemResult.Value!.Icon).ImGuiHandle, new Vector2(imageSize)); - + ImGui.SameLine(0, 5); ushort? newRecipe = null; @@ -983,7 +994,7 @@ public sealed class MacroEditor : Window, IDisposable DrawBars(datas); } } - + using (var panel = ImGuiUtils.GroupPanel("Buffs", -1, out _)) { using var _font = ImRaii.PushFont(AxisFont.ImFont); @@ -995,7 +1006,7 @@ public sealed class MacroEditor : Window, IDisposable ImGui.SameLine(0, 0); var effects = State.ActiveEffects; - foreach(var effect in Enum.GetValues()) + foreach (var effect in Enum.GetValues()) { if (!effects.HasEffect(effect)) continue; @@ -1040,7 +1051,7 @@ public sealed class MacroEditor : Window, IDisposable }); var maxSize = (textSize - 2 * spacing - ImGui.CalcTextSize("/").X) / 2; var barSize = totalSize - textSize - spacing; - foreach(var bar in bars) + foreach (var bar in bars) { using var panel = ImGuiUtils.GroupPanel(bar.Name, totalSize, out _); if (bar.Condition is { } condition) @@ -1138,7 +1149,7 @@ public sealed class MacroEditor : Window, IDisposable { var height = ImGui.GetFrameHeight(); var spacing = ImGui.GetStyle().ItemSpacing.X; - var width = availWidth - (spacing + height) * (DefaultActions.Length > 0 ? 3 : 2); // small buttons at the end + var width = availWidth - ((spacing + height) * (3 + (DefaultActions.Length > 0 ? 1 : 0))); // small buttons at the end var halfWidth = (width - spacing) / 2f; var quarterWidth = (halfWidth - spacing) / 2f; @@ -1191,6 +1202,15 @@ public sealed class MacroEditor : Window, IDisposable if (ImGui.IsItemHovered()) ImGui.SetTooltip("Copy to Clipboard"); ImGui.SameLine(); + using (var _disabled = ImRaii.Disabled(SolverRunning)) + { + if (ImGuiUtils.IconButtonSized(FontAwesomeIcon.FileImport, new(height))) + ShowImportPopup(); + } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + ImGui.SetTooltip("Import Macro"); + DrawImportPopup(); + ImGui.SameLine(); if (DefaultActions.Length > 0) { using (var _disabled = ImRaii.Disabled(SolverRunning)) @@ -1219,11 +1239,10 @@ public sealed class MacroEditor : Window, IDisposable ImGui.SetTooltip("Clear"); } - private string popupMacroName = string.Empty; private void ShowSaveAsPopup() { ImGui.OpenPopup($"##saveAsPopup"); - popupMacroName = string.Empty; + popupSaveAsMacroName = string.Empty; ImGui.SetNextWindowPos(ImGui.GetMousePos() - new Vector2(ImGui.CalcItemWidth() * .25f, ImGui.GetFrameHeight() + ImGui.GetStyle().WindowPadding.Y * 2)); } @@ -1235,11 +1254,11 @@ public sealed class MacroEditor : Window, IDisposable if (ImGui.IsWindowAppearing()) ImGui.SetKeyboardFocusHere(); ImGui.SetNextItemWidth(ImGui.CalcItemWidth()); - if (ImGui.InputTextWithHint($"##setName", "Name", ref popupMacroName, 100, ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.EnterReturnsTrue)) + if (ImGui.InputTextWithHint($"##setName", "Name", ref popupSaveAsMacroName, 100, ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.EnterReturnsTrue)) { - if (!string.IsNullOrWhiteSpace(popupMacroName)) + if (!string.IsNullOrWhiteSpace(popupSaveAsMacroName)) { - var newMacro = new Macro() { Name = popupMacroName, Actions = Macro.Select(s => s.Action).ToArray() }; + var newMacro = new Macro() { Name = popupSaveAsMacroName, Actions = Macro.Select(s => s.Action).ToArray() }; Service.Configuration.AddMacro(newMacro); MacroSetter = actions => { @@ -1252,6 +1271,152 @@ public sealed class MacroEditor : Window, IDisposable } } + private void ShowImportPopup() + { + ImGui.OpenPopup($"##importPopup"); + popupImportText = string.Empty; + popupImportUrl = string.Empty; + popupImportError = string.Empty; + popupImportUrlMacro = null; + popupImportUrlTokenSource = null; + } + + private void DrawImportPopup() + { + const string ExampleMacro = "/mlock\n/ac \"Muscle Memory\" \n/ac Manipulation \n/ac Veneration \n/ac \"Waste Not II\" \n/ac Groundwork \n/ac Innovation \n/ac \"Preparatory Touch\" \n/ac \"Preparatory Touch\" \n/ac \"Preparatory Touch\" \n/ac \"Preparatory Touch\" \n/ac \"Great Strides\" \n/ac \"Byregot's Blessing\" \n/ac \"Careful Synthesis\" "; + const string ExampleUrl = "https://ffxivteamcraft.com/simulator/39630/35499/9XOZDZKhbVXJUIPXjM63"; + + ImGui.SetNextWindowPos(ImGui.GetMainViewport().GetCenter(), ImGuiCond.Always, new Vector2(0.5f)); + ImGui.SetNextWindowSizeConstraints(new(400, 0), new(float.PositiveInfinity)); + using var popup = ImRaii.Popup($"##importPopup", ImGuiWindowFlags.Modal | ImGuiWindowFlags.NoMove); + if (popup) + { + bool submittedText, submittedUrl; + + using (var panel = ImGuiUtils.GroupPanel("##text", -1, out var availWidth)) + { + ImGui.AlignTextToFramePadding(); + ImGuiUtils.TextCentered("Paste your macro here"); + { + using var font = ImRaii.PushFont(UiBuilder.MonoFont); + ImGuiUtils.InputTextMultilineWithHint("", ExampleMacro, ref popupImportText, 2048, new(availWidth, ImGui.GetTextLineHeight() * 15 + ImGui.GetStyle().FramePadding.Y), ImGuiInputTextFlags.AutoSelectAll); + } + using (var _disabled = ImRaii.Disabled(popupImportUrlTokenSource != null)) + submittedText = ImGui.Button("Import", new(availWidth, 0)); + } + + using (var panel = ImGuiUtils.GroupPanel("##url", -1, out var availWidth)) + { + var availOffset = ImGui.GetContentRegionAvail().X - availWidth; + + ImGui.AlignTextToFramePadding(); + ImGuiUtils.TextCentered("or provide a url to it"); + ImGui.SameLine(); + using (var color = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudGrey)) + { + using var font = ImRaii.PushFont(UiBuilder.IconFont); + ImGuiUtils.TextRight(FontAwesomeIcon.InfoCircle.ToIconString(), ImGui.GetContentRegionAvail().X - availOffset); + } + if (ImGui.IsItemHovered()) + { + using var t = ImRaii.Tooltip(); + ImGui.Text("Supported sites:"); + ImGui.BulletText("ffxivteamcraft.com"); + ImGui.BulletText("craftingway.app"); + ImGui.Text("More suggestions are appreciated!"); + } + ImGui.SetNextItemWidth(availWidth); + submittedUrl = ImGui.InputTextWithHint("", ExampleUrl, ref popupImportUrl, 2048, ImGuiInputTextFlags.AutoSelectAll | ImGuiInputTextFlags.EnterReturnsTrue); + using (var _disabled = ImRaii.Disabled(popupImportUrlTokenSource != null)) + submittedUrl = ImGui.Button("Import", new(availWidth, 0)) || submittedUrl; + } + + ImGui.Dummy(default); + + if (!string.IsNullOrWhiteSpace(popupImportError)) + { + using (var c = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudRed)) + ImGui.TextWrapped(popupImportError); + ImGui.Dummy(default); + } + + if (ImGuiUtils.ButtonCentered("Nevermind", new(ImGui.GetContentRegionAvail().X / 2f, 0))) + { + popupImportUrlTokenSource?.Cancel(); + ImGui.CloseCurrentPopup(); + } + + if (popupImportUrlTokenSource == null) + { + if (submittedText) + { + if (MacroImport.TryParseMacro(popupImportText) is { } parsedActions) + { + popupImportUrlTokenSource?.Cancel(); + Macro.Clear(); + foreach (var action in parsedActions) + AddStep(action); + + Service.PluginInterface.UiBuilder.AddNotification($"Imported macro with {parsedActions.Count} step{(parsedActions.Count != 1 ? "s" : "")}", "Craftimizer Macro Imported", NotificationType.Success); + popupImportUrlTokenSource?.Cancel(); + ImGui.CloseCurrentPopup(); + } + else + popupImportError = "Could not find any actions to import. Is it a valid macro?"; + } + if (submittedUrl) + { + if (MacroImport.TryParseUrl(popupImportUrl, out _)) + { + popupImportUrlTokenSource = new(); + popupImportUrlMacro = null; + var token = popupImportUrlTokenSource.Token; + var url = popupImportUrl; + + var task = Task.Run(() => MacroImport.RetrieveUrl(url, token), token); + _ = task.ContinueWith(t => + { + if (token == popupImportUrlTokenSource.Token) + popupImportUrlTokenSource = null; + }); + _ = task.ContinueWith(t => + { + if (token.IsCancellationRequested) + return; + + try + { + t.Exception!.Flatten().Handle(ex => ex is TaskCanceledException or OperationCanceledException); + } + catch (AggregateException e) + { + popupImportError = e.Message; + Log.Error(e, "Retrieving macro failed"); + } + }, TaskContinuationOptions.OnlyOnFaulted); + _ = task.ContinueWith(t => popupImportUrlMacro = t.Result, TaskContinuationOptions.OnlyOnRanToCompletion); + } + else + popupImportError = "The url is not in the right format for any supported sites."; + } + if (popupImportUrlMacro is { Name: var name, Actions: var actions }) + { + Macro.Clear(); + foreach(var action in actions) + AddStep(action); + Service.PluginInterface.UiBuilder.AddNotification($"Imported macro \"{name}\"", "Craftimizer Macro Imported", NotificationType.Success); + + popupImportUrlTokenSource?.Cancel(); + ImGui.CloseCurrentPopup(); + } + } + } + else + { + popupImportUrlTokenSource?.Cancel(); + popupImportUrlTokenSource = null; + } + } private void CalculateBestMacro() { SolverTokenSource?.Cancel(); @@ -1294,7 +1459,7 @@ public sealed class MacroEditor : Window, IDisposable var solver = new Solver.Solver(config, state) { Token = token }; solver.OnLog += Log.Debug; - solver.OnNewAction += a => AddStep(a, isMacro: true); + solver.OnNewAction += a => AddStep(a, isSolver: true); solver.Start(); _ = solver.GetTask().GetAwaiter().GetResult(); @@ -1321,11 +1486,11 @@ public sealed class MacroEditor : Window, IDisposable lastState = ((step.Response, step.State) = sim.Execute(lastState, step.Action)).State; } - private void AddStep(ActionType action, int index = -1, bool isMacro = false) + private void AddStep(ActionType action, int index = -1, bool isSolver = false) { if (index < -1 || index >= Macro.Count) throw new ArgumentOutOfRangeException(nameof(index)); - if (!isMacro && SolverRunning) + if (!isSolver && SolverRunning) throw new InvalidOperationException("Cannot add steps while solver is running"); if (!SolverRunning) SolverStartStepCount = null; @@ -1336,13 +1501,14 @@ public sealed class MacroEditor : Window, IDisposable var resp = sim.Execute(State, action); Macro.Add(new() { Action = action, Response = resp.Response, State = resp.NewState }); } - else { + else + { var state = index == 0 ? InitialState : Macro[index - 1].State; var sim = new Sim(state); var resp = sim.Execute(state, action); Macro.Insert(index, new() { Action = action, Response = resp.Response, State = resp.NewState }); state = resp.NewState; - for(var i = index + 1; i < Macro.Count; i++) + for (var i = index + 1; i < Macro.Count; i++) state = ((Macro[i].Response, Macro[i].State) = sim.Execute(state, Macro[i].Action)).State; } }