diff --git a/DistFiles/Language Explorer/Configuration/Main.xml b/DistFiles/Language Explorer/Configuration/Main.xml index 7aea0a296a..018a23f6df 100644 --- a/DistFiles/Language Explorer/Configuration/Main.xml +++ b/DistFiles/Language Explorer/Configuration/Main.xml @@ -31,6 +31,7 @@ + @@ -566,6 +567,7 @@ + diff --git a/Src/Common/FwUtils/FwUtilsTests/MotifApplyRunnerTests.cs b/Src/Common/FwUtils/FwUtilsTests/MotifApplyRunnerTests.cs new file mode 100644 index 0000000000..55f11377cc --- /dev/null +++ b/Src/Common/FwUtils/FwUtilsTests/MotifApplyRunnerTests.cs @@ -0,0 +1,273 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Diagnostics; +using System.IO; +using Microsoft.Win32; +using NUnit.Framework; + +namespace SIL.FieldWorks.Common.FwUtils +{ + [TestFixture] + public class MotifApplyRunnerTests + { + [Test] + public void ResolverPrefersMotifDirectoryEnvironmentVariable() + { + var resolver = new MotifExecutableResolver( + () => @"C:\Motif Dev", + view => @"C:\Motif Registered", + path => path == @"C:\Motif Dev\motif.exe" || path == @"C:\Motif Registered\motif.exe"); + + Assert.That(resolver.FindExecutable(), Is.EqualTo(@"C:\Motif Dev\motif.exe")); + } + + [TestCase(RegistryView.Registry64)] + [TestCase(RegistryView.Registry32)] + public void ResolverChecksBothRegistryViewsWhenEnvironmentIsMissing(RegistryView installedView) + { + var resolver = new MotifExecutableResolver( + () => null, + view => view == installedView ? @"C:\Motif Registered" : null, + path => path == @"C:\Motif Registered\motif.exe"); + + Assert.That(resolver.FindExecutable(), Is.EqualTo(@"C:\Motif Registered\motif.exe")); + } + + [Test] + public void ResolverFallsBackToRegistryWhenEnvironmentDirectoryHasNoExecutable() + { + var resolver = new MotifExecutableResolver( + () => @"C:\Motif Dev", + view => @"C:\Motif Registered", + path => path == @"C:\Motif Registered\motif.exe"); + + Assert.That(resolver.FindExecutable(), Is.EqualTo(@"C:\Motif Registered\motif.exe")); + } + + [Test] + public void ResolverReturnsNullWhenMotifIsNotInstalled() + { + var resolver = new MotifExecutableResolver(() => null, view => null, path => false); + + Assert.That(resolver.FindExecutable(), Is.Null); + } + + [Test] + public void ApplyQuotesProjectPathAndCapturesBothOutputStreams() + { + ProcessStartInfo startInfo = null; + var runner = CreateRunner( + new MotifProcessOutput(true, 0, "{\"ok\":true,\"applied\":true}", ""), + info => startInfo = info); + + var result = runner.Apply(@"C:\Users\linguist\My Project\Field Data.fwdata"); + + Assert.That(result.Outcome, Is.EqualTo(MotifApplyOutcome.Applied)); + Assert.That(startInfo.FileName, Is.EqualTo(@"C:\Motif\motif.exe")); + Assert.That(startInfo.Arguments, Is.EqualTo("apply --all-pending --project \"C:\\Users\\linguist\\My Project\\Field Data.fwdata\" --json")); + Assert.That(startInfo.UseShellExecute, Is.False); + Assert.That(startInfo.RedirectStandardOutput, Is.True); + Assert.That(startInfo.RedirectStandardError, Is.True); + } + + [Test] + public void ApplyReadsOptionalSummaryAndIgnoresUnknownJsonFields() + { + var runner = CreateRunner(new MotifProcessOutput(true, 0, + "{\"ok\":true,\"applied\":true,\"summary\":\"2 changes applied\",\"futureField\":7}", "")); + + var result = runner.Apply(@"C:\Project\My Project.fwdata"); + + Assert.That(result.Outcome, Is.EqualTo(MotifApplyOutcome.Applied)); + Assert.That(result.Summary, Is.EqualTo("2 changes applied")); + } + + [Test] + public void ApplyTreatsSuccessfulNoOpAsNoChangesWithoutReceipt() + { + var runner = CreateRunner(new MotifProcessOutput(true, 0, + "{\"ok\":true,\"applied\":false,\"receipt\":null,\"futureField\":true}", "")); + + Assert.That(runner.Apply(@"C:\Project\My Project.fwdata").Outcome, + Is.EqualTo(MotifApplyOutcome.NoChanges)); + } + + [Test] + public void ExitTwoIsRefusal() + { + var runner = CreateRunner(new MotifProcessOutput(true, 2, + "", "{\"ok\":false,\"code\":\"apply.change-no-longer-fits\"}")); + + Assert.That(runner.Apply(@"C:\Project\My Project.fwdata").Outcome, + Is.EqualTo(MotifApplyOutcome.Refused)); + } + + [Test] + public void ExitThreeIsBusy() + { + var runner = CreateRunner(new MotifProcessOutput(true, 3, "", "not json")); + + Assert.That(runner.Apply(@"C:\Project\My Project.fwdata").Outcome, + Is.EqualTo(MotifApplyOutcome.Busy)); + } + + [Test] + public void ExitFourRequiresReconciliation() + { + var invocationCount = 0; + var runner = CreateRunner(new MotifProcessOutput(true, 4, "", "not json"), + _ => invocationCount++); + + Assert.That(runner.Apply(@"C:\Project\My Project.fwdata").Outcome, + Is.EqualTo(MotifApplyOutcome.ReconciliationRequired)); + Assert.That(invocationCount, Is.EqualTo(1)); + } + + [Test] + public void SuccessfulExitWithFalseOkIsAmbiguous() + { + var runner = CreateRunner(new MotifProcessOutput(true, 0, + "{\"ok\":false,\"applied\":true}", "")); + + Assert.That(runner.Apply(@"C:\Project\My Project.fwdata").Outcome, + Is.EqualTo(MotifApplyOutcome.Ambiguous)); + } + + [Test] + public void SuccessfulExitWithoutAppliedFieldIsAmbiguous() + { + var runner = CreateRunner(new MotifProcessOutput(true, 0, "{\"ok\":true}", "")); + + Assert.That(runner.Apply(@"C:\Project\My Project.fwdata").Outcome, + Is.EqualTo(MotifApplyOutcome.Ambiguous)); + } + + [Test] + public void EmptyStandardOutputIsAmbiguous() + { + var runner = CreateRunner(new MotifProcessOutput(true, 0, "", "")); + + Assert.That(runner.Apply(@"C:\Project\My Project.fwdata").Outcome, + Is.EqualTo(MotifApplyOutcome.Ambiguous)); + } + + [Test] + public void ProcessRunnerExceptionIsAmbiguous() + { + var resolver = new MotifExecutableResolver( + () => @"C:\Motif", view => null, path => path == @"C:\Motif\motif.exe"); + var runner = new MotifApplyRunner(resolver, + _ => throw new System.InvalidOperationException("process failed")); + + Assert.That(runner.Apply(@"C:\Project\My Project.fwdata").Outcome, + Is.EqualTo(MotifApplyOutcome.Ambiguous)); + } + + [Test] + public void ProcessThatDidNotStartIsCouldNotStart() + { + var runner = CreateRunner(new MotifProcessOutput(false, null, "", "")); + + Assert.That(runner.Apply(@"C:\Project\My Project.fwdata").Outcome, + Is.EqualTo(MotifApplyOutcome.CouldNotStart)); + } + + [Test] + public void RunProcessDrainsLargeStandardOutputAndStandardError() + { + var commandProcessor = Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.System), "cmd.exe"); + var startInfo = new ProcessStartInfo + { + FileName = commandProcessor, + Arguments = "/d /c \"for /L %i in (1,1,4096) do @echo 12345678901234567890 & " + + "@echo 12345678901234567890 1>&2\"", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + var output = MotifApplyRunner.RunProcess(startInfo); + + Assert.That(output.Started, Is.True); + Assert.That(output.ExitCode, Is.EqualTo(0)); + Assert.That(output.StandardOutput.Length, Is.GreaterThan(64 * 1024)); + Assert.That(output.StandardError.Length, Is.GreaterThan(64 * 1024)); + } + + [Test] + public void ReconciliationCodeRequiresReloadEvenWhenExitCodeIsTwo() + { + var runner = CreateRunner(new MotifProcessOutput(true, 2, "", + "{\"ok\":false,\"code\":\"apply.reconciliation-needed\"}")); + + Assert.That(runner.Apply(@"C:\Project\My Project.fwdata").Outcome, + Is.EqualTo(MotifApplyOutcome.ReconciliationRequired)); + } + + [TestCase(1)] + [TestCase(5)] + public void OtherExitCodesAreAmbiguous(int exitCode) + { + var runner = CreateRunner(new MotifProcessOutput(true, exitCode, "", "not json")); + + Assert.That(runner.Apply(@"C:\Project\My Project.fwdata").Outcome, + Is.EqualTo(MotifApplyOutcome.Ambiguous)); + } + + [Test] + public void MalformedSuccessJsonIsAmbiguous() + { + var runner = CreateRunner(new MotifProcessOutput(true, 0, "{broken", "")); + + Assert.That(runner.Apply(@"C:\Project\My Project.fwdata").Outcome, + Is.EqualTo(MotifApplyOutcome.Ambiguous)); + } + + [Test] + public void JsonFailureEnvelopeCanBeReadFromStandardError() + { + var runner = CreateRunner(new MotifProcessOutput(true, 4, "", + "{\"ok\":false,\"code\":\"apply.reconciliation-needed\",\"extra\":{\"ignored\":true}}")); + + var result = runner.Apply(@"C:\Project\My Project.fwdata"); + + Assert.That(result.Outcome, Is.EqualTo(MotifApplyOutcome.ReconciliationRequired)); + Assert.That(result.ErrorCode, Is.EqualTo("apply.reconciliation-needed")); + } + + [Test] + public void MissingExecutableDoesNotCreateProcess() + { + var processCalled = false; + var resolver = new MotifExecutableResolver(() => null, view => null, path => false); + var runner = new MotifApplyRunner(resolver, info => + { + processCalled = true; + return new MotifProcessOutput(true, 0, "", ""); + }); + + Assert.That(runner.Apply(@"C:\Project\My Project.fwdata").Outcome, + Is.EqualTo(MotifApplyOutcome.NotInstalled)); + Assert.That(processCalled, Is.False); + } + + private static MotifApplyRunner CreateRunner(MotifProcessOutput output, + System.Action inspectStartInfo = null) + { + var resolver = new MotifExecutableResolver( + () => @"C:\Motif", + view => null, + path => path == @"C:\Motif\motif.exe"); + return new MotifApplyRunner(resolver, info => + { + if (inspectStartInfo != null) + inspectStartInfo(info); + return output; + }); + } + } +} diff --git a/Src/Common/FwUtils/MotifApplyRunner.cs b/Src/Common/FwUtils/MotifApplyRunner.cs new file mode 100644 index 0000000000..58bb9c54f7 --- /dev/null +++ b/Src/Common/FwUtils/MotifApplyRunner.cs @@ -0,0 +1,243 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.Serialization; +using System.Runtime.Serialization.Json; +using System.Text; +using System.Threading.Tasks; + +namespace SIL.FieldWorks.Common.FwUtils +{ + /// The classified result of one Motif pending-changes invocation. + public enum MotifApplyOutcome + { + /// No configured Motif executable was found. + NotInstalled, + /// The operating system did not start the executable. + CouldNotStart, + /// Motif applied the pending changes. + Applied, + /// Motif found no pending changes to apply. + NoChanges, + /// Motif refused the request without changing the project. + Refused, + /// The project could not be opened because another process is using + /// it. + Busy, + /// The project may have changed and must be reopened. + ReconciliationRequired, + /// The result could not be classified safely. + Ambiguous + } + + /// The exit code and captured output from a Motif process. + public sealed class MotifProcessOutput + { + /// Creates a process result for the runner. + /// Whether the operating system started the process. + /// The process exit code, or null if it did not exit. + /// Text captured from standard output. + /// Text captured from standard error. + public MotifProcessOutput(bool started, int? exitCode, string standardOutput, string standardError) + { + Started = started; + ExitCode = exitCode; + StandardOutput = standardOutput ?? string.Empty; + StandardError = standardError ?? string.Empty; + } + + /// Whether the operating system started the process. + public bool Started { get; } + /// The process exit code, or null if it did not exit. + public int? ExitCode { get; } + /// Text captured from standard output. + public string StandardOutput { get; } + /// Text captured from standard error. + public string StandardError { get; } + } + + /// The stable result FieldWorks uses to restore the project lifecycle. + public sealed class MotifApplyResult + { + internal MotifApplyResult(MotifApplyOutcome outcome, int? exitCode, string errorCode, + string summary) + { + Outcome = outcome; + ExitCode = exitCode; + ErrorCode = errorCode; + Summary = summary; + } + + /// The result category derived from the exit code and stable JSON + /// fields. + public MotifApplyOutcome Outcome { get; } + /// The process exit code, when Motif started. + public int? ExitCode { get; } + /// The stable Motif failure code, when the JSON response provides one. + public string ErrorCode { get; } + /// The optional display summary supplied by Motif. + public string Summary { get; } + /// Whether FieldWorks must reload because the project may have + /// changed. + public bool RequiresReload => Outcome == MotifApplyOutcome.Applied || + Outcome == MotifApplyOutcome.ReconciliationRequired || Outcome == MotifApplyOutcome.Ambiguous; + } + + /// Runs Motif once and classifies its stable process response. + public sealed class MotifApplyRunner + { + private const string ReconciliationCode = "apply.reconciliation-needed"; + private readonly MotifExecutableResolver _executableResolver; + private readonly Func _runProcess; + + /// Creates a runner that starts Motif through the operating system. + public MotifApplyRunner() + : this(new MotifExecutableResolver(), RunProcess) + { + } + + /// Creates a runner with injectable executable discovery and process + /// execution. + /// Resolves the configured Motif executable. + /// Starts one process and returns its exit code and captured + /// streams. + public MotifApplyRunner(MotifExecutableResolver executableResolver, + Func runProcess) + { + _executableResolver = executableResolver ?? throw new ArgumentNullException(nameof(executableResolver)); + _runProcess = runProcess ?? throw new ArgumentNullException(nameof(runProcess)); + } + + /// Whether Motif is currently discoverable. + public bool IsAvailable => _executableResolver.FindExecutable() != null; + + /// Runs one pending-changes apply for an absolute FieldWorks project + /// path. + /// The path to the project's `.fwdata` file. + public MotifApplyResult Apply(string projectFilePath) + { + if (string.IsNullOrWhiteSpace(projectFilePath)) + throw new ArgumentException("A project file path is required.", nameof(projectFilePath)); + + var executable = _executableResolver.FindExecutable(); + if (executable == null) + return new MotifApplyResult(MotifApplyOutcome.NotInstalled, null, null, null); + + var project = Path.GetFullPath(projectFilePath); + var startInfo = new ProcessStartInfo + { + FileName = executable, + Arguments = "apply --all-pending --project " + QuoteArgument(project) + " --json", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = new UTF8Encoding(false), + StandardErrorEncoding = new UTF8Encoding(false) + }; + + MotifProcessOutput output; + try + { + output = _runProcess(startInfo); + } + catch + { + return new MotifApplyResult(MotifApplyOutcome.Ambiguous, null, null, null); + } + + if (output == null) + return new MotifApplyResult(MotifApplyOutcome.Ambiguous, null, null, null); + if (!output.Started) + return new MotifApplyResult(MotifApplyOutcome.CouldNotStart, null, null, null); + + var response = ReadResponse(output.StandardOutput) ?? ReadResponse(output.StandardError); + var errorCode = response?.Code; + if (errorCode == ReconciliationCode || output.ExitCode == 4) + return new MotifApplyResult(MotifApplyOutcome.ReconciliationRequired, + output.ExitCode, errorCode, response?.Summary); + if (output.ExitCode == 2) + return new MotifApplyResult(MotifApplyOutcome.Refused, output.ExitCode, errorCode, response?.Summary); + if (output.ExitCode == 3) + return new MotifApplyResult(MotifApplyOutcome.Busy, output.ExitCode, errorCode, response?.Summary); + if (output.ExitCode != 0) + return new MotifApplyResult(MotifApplyOutcome.Ambiguous, output.ExitCode, errorCode, response?.Summary); + + if (response?.Ok != true || !response.Applied.HasValue) + return new MotifApplyResult(MotifApplyOutcome.Ambiguous, output.ExitCode, errorCode, response?.Summary); + + return new MotifApplyResult(response.Applied.Value ? MotifApplyOutcome.Applied : MotifApplyOutcome.NoChanges, + output.ExitCode, errorCode, response.Summary); + } + + private static string QuoteArgument(string argument) + { + return "\"" + argument.Replace("\"", "\\\"") + "\""; + } + + private static MotifJsonResponse ReadResponse(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + var serializer = new DataContractJsonSerializer(typeof(MotifJsonResponse)); + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(json))) + return (MotifJsonResponse)serializer.ReadObject(stream); + } + catch (SerializationException) + { + return null; + } + catch (System.Xml.XmlException) + { + return null; + } + } + + internal static MotifProcessOutput RunProcess(ProcessStartInfo startInfo) + { + Process process; + try + { + process = Process.Start(startInfo); + } + catch (Exception error) when (error is InvalidOperationException || + error is System.ComponentModel.Win32Exception || error is FileNotFoundException || + error is UnauthorizedAccessException) + { + return new MotifProcessOutput(false, null, null, null); + } + + if (process == null) + return new MotifProcessOutput(false, null, null, null); + + using (process) + { + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + Task.WaitAll(outputTask, errorTask); + return new MotifProcessOutput(true, process.ExitCode, outputTask.Result, errorTask.Result); + } + } + + [DataContract] + private sealed class MotifJsonResponse + { + [DataMember(Name = "ok")] + public bool? Ok { get; set; } + [DataMember(Name = "applied")] + public bool? Applied { get; set; } + [DataMember(Name = "summary")] + public string Summary { get; set; } + [DataMember(Name = "code")] + public string Code { get; set; } + } + } +} diff --git a/Src/Common/FwUtils/MotifExecutableResolver.cs b/Src/Common/FwUtils/MotifExecutableResolver.cs new file mode 100644 index 0000000000..05b02c8396 --- /dev/null +++ b/Src/Common/FwUtils/MotifExecutableResolver.cs @@ -0,0 +1,90 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.IO; +using Microsoft.Win32; +using SIL.PlatformUtilities; + +namespace SIL.FieldWorks.Common.FwUtils +{ + /// Finds the installed Motif command-line executable. + public sealed class MotifExecutableResolver + { + private const string MotifDirectoryVariable = "MOTIF_DIR"; + private const string MotifRegistryPath = @"SOFTWARE\SIL\Motif"; + private const string MotifExecutableName = "motif.exe"; + private readonly Func _getEnvironmentDirectory; + private readonly Func _getRegisteredDirectory; + private readonly Func _fileExists; + + /// Creates a resolver for the current machine. + public MotifExecutableResolver() + : this(() => Environment.GetEnvironmentVariable(MotifDirectoryVariable), + ReadRegisteredDirectory, File.Exists) + { + } + + /// Creates a resolver with injectable machine discovery. + /// Returns the configured Motif directory. + /// Returns an install directory for a registry + /// view. + /// Checks whether a candidate executable exists. + public MotifExecutableResolver(Func getEnvironmentDirectory, + Func getRegisteredDirectory, Func fileExists) + { + _getEnvironmentDirectory = getEnvironmentDirectory ?? throw new ArgumentNullException(nameof(getEnvironmentDirectory)); + _getRegisteredDirectory = getRegisteredDirectory ?? throw new ArgumentNullException(nameof(getRegisteredDirectory)); + _fileExists = fileExists ?? throw new ArgumentNullException(nameof(fileExists)); + } + + /// Returns the executable path, or null when no configured installation + /// exists. + public string FindExecutable() + { + var executable = FindInDirectory(_getEnvironmentDirectory()); + if (executable != null) + return executable; + + foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 }) + { + executable = FindInDirectory(_getRegisteredDirectory(view)); + if (executable != null) + return executable; + } + + return null; + } + + private string FindInDirectory(string directory) + { + if (string.IsNullOrWhiteSpace(directory)) + return null; + + var candidate = Path.Combine(directory, MotifExecutableName); + return _fileExists(candidate) ? Path.GetFullPath(candidate) : null; + } + + private static string ReadRegisteredDirectory(RegistryView view) + { + if (!Platform.IsWindows) + return null; + + try + { + using (var localMachine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view)) + using (var motifKey = localMachine.OpenSubKey(MotifRegistryPath)) + { + return motifKey?.GetValue("InstallationDir") as string; + } + } + catch (Exception error) when (error is IOException || + error is UnauthorizedAccessException || error is System.Security.SecurityException || + error is PlatformNotSupportedException) + { + return null; + } + } + } +} diff --git a/Src/LexText/Lexicon/FLExBridgeListener.cs b/Src/LexText/Lexicon/FLExBridgeListener.cs index 40d42022b0..9689d74d76 100644 --- a/Src/LexText/Lexicon/FLExBridgeListener.cs +++ b/Src/LexText/Lexicon/FLExBridgeListener.cs @@ -41,6 +41,7 @@ sealed class FLExBridgeListener : IxCoreColleague, IDisposable private Form _parentForm; private string _liftPathname; private IProgress _progressDlg; + private bool? _motifAvailable; private LcmCache Cache { get; set; } /// @@ -109,6 +110,122 @@ public bool ShouldNotCall #region XCore message handlers + /// Controls whether the Motif apply command is available. + /// The XCore command parameters. + /// The command's display properties. + /// True when the command has been handled. + public bool OnDisplayApplyMotifChanges(object parameters, ref UIItemDisplayProperties display) + { + display.Visible = true; + display.Enabled = IsMotifAvailable(); + return true; + } + + /// Applies approved pending Motif changes to the open project. + /// The XCore command object. + /// True when the command has been handled. + public bool OnApplyMotifChanges(object commandObject) + { + Application.DoEvents(); + var runner = new MotifApplyRunner(); + var motifAvailable = IsMotifAvailable(); + var precheckMessage = MotifApplyWorkflow.GetPrecheckMessage(motifAvailable, false); + if (precheckMessage != MotifApplyMessage.None) + { + ShowMotifApplyMessage(precheckMessage); + return true; + } + + var fieldWorksBusy = _progressDlg != null || SharedBackendServices.AreMultipleApplicationsConnected(Cache); + precheckMessage = MotifApplyWorkflow.GetPrecheckMessage(motifAvailable, fieldWorksBusy); + if (precheckMessage != MotifApplyMessage.None) + { + ShowMotifApplyMessage(precheckMessage); + return true; + } + + if (MessageBox.Show(_parentForm, LexEdStrings.ksApplyMotifConfirmation, + LexEdStrings.ksApplyMotifChangesTitle, MessageBoxButtons.OKCancel, + MessageBoxIcon.Question) != DialogResult.OK) + return true; + + StopParser(); + var projectFilePath = Path.GetFullPath(GetFullProjectFileName()); + var workflow = new MotifApplyWorkflow(ValidatePendingEditorData, + () => ProjectLockingService.UnlockCurrentProject(Cache), + () => runner.Apply(projectFilePath), + () => + { + var reopenedWindow = RefreshCacheWindowAndAll( + _propertyTable.GetValue("App"), projectFilePath); + _parentForm = reopenedWindow; + }, + () => ProjectLockingService.LockCurrentProject(Cache)); + MotifApplyWorkflowResult workflowResult; + using (new WaitCursor(_parentForm)) + workflowResult = workflow.Execute(); + + ShowMotifApplyMessage(workflowResult.Message, workflowResult.ApplyResult.Summary); + return true; + } + + private bool IsMotifAvailable() + { + if (!_motifAvailable.HasValue) + _motifAvailable = new MotifApplyRunner().IsAvailable; + return _motifAvailable.Value; + } + + private void ShowMotifApplyMessage(MotifApplyMessage message, string summary = null) + { + string content; + MessageBoxIcon icon; + switch (message) + { + case MotifApplyMessage.Applied: + content = LexEdStrings.ksApplyMotifSuccess; + icon = MessageBoxIcon.Information; + break; + case MotifApplyMessage.AppliedWithSummary: + content = string.Format(LexEdStrings.ksApplyMotifSuccessWithSummary, summary); + icon = MessageBoxIcon.Information; + break; + case MotifApplyMessage.NoChanges: + content = LexEdStrings.ksApplyMotifNoChanges; + icon = MessageBoxIcon.Information; + break; + case MotifApplyMessage.Refused: + content = LexEdStrings.ksApplyMotifRefused; + icon = MessageBoxIcon.Warning; + break; + case MotifApplyMessage.MotifBusy: + content = LexEdStrings.ksMotifBusy; + icon = MessageBoxIcon.Warning; + break; + case MotifApplyMessage.FieldWorksBusy: + content = LexEdStrings.ksApplyMotifBusy; + icon = MessageBoxIcon.Warning; + break; + case MotifApplyMessage.NotInstalled: + content = LexEdStrings.ksMotifNotFound; + icon = MessageBoxIcon.Warning; + break; + case MotifApplyMessage.CouldNotStart: + content = LexEdStrings.ksMotifCouldNotStart; + icon = MessageBoxIcon.Error; + break; + case MotifApplyMessage.Reconciliation: + content = LexEdStrings.ksApplyMotifReconciliation; + icon = MessageBoxIcon.Error; + break; + default: + return; + } + + MessageBox.Show(_parentForm, content, LexEdStrings.ksApplyMotifChangesTitle, + MessageBoxButtons.OK, icon); + } + #region FLExLiftBridge Toolbar messages /// /// Determine whether or not to enable the S/R toolbar icon and its hotkey. @@ -526,15 +643,17 @@ private string GetFullProjectFileName() private void SaveAllDataToDisk() { - //Give all forms the opportunity to save any uncommitted data - //(important for analysis sandboxes) + ValidatePendingEditorData(); + ProjectLockingService.UnlockCurrentProject(Cache); + } + + private void ValidatePendingEditorData() + { var activeForm = _propertyTable.GetValue
("window"); if (activeForm != null) { activeForm.ValidateChildren(ValidationConstraints.Enabled); } - //Commit all the data in the cache and save to disk - ProjectLockingService.UnlockCurrentProject(Cache); } #endregion LiftBridge S/R messages diff --git a/Src/LexText/Lexicon/LexEdDllTests/MotifApplyConfigurationTests.cs b/Src/LexText/Lexicon/LexEdDllTests/MotifApplyConfigurationTests.cs new file mode 100644 index 0000000000..1987e5703c --- /dev/null +++ b/Src/LexText/Lexicon/LexEdDllTests/MotifApplyConfigurationTests.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.IO; +using System.Linq; +using System.Xml.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwUtils; + +namespace LexEdDllTests +{ + [TestFixture] + public class MotifApplyConfigurationTests + { + [Test] + public void MainDefinesTheGlobalToolsCommandAndItsListener() + { + var repositoryRoot = Directory.GetParent(FwDirectoryFinder.SourceDirectory).FullName; + var configRoot = Path.Combine(repositoryRoot, "DistFiles", "Language Explorer", "Configuration"); + var areaPath = Path.Combine(configRoot, "Lexicon", "areaConfiguration.xml"); + var area = XDocument.Load(areaPath); + Assert.That(area.Descendants("command").Any(element => + (string)element.Attribute("id") == "CmdApplyMotifChanges"), Is.False); + Assert.That(area.Descendants("menu").Any(element => + (string)element.Attribute("id") == "Tools" && element.Elements("item").Any(item => + (string)item.Attribute("command") == "CmdApplyMotifChanges")), Is.False); + + var main = XDocument.Load(Path.Combine(configRoot, "Main.xml")); + var command = main.Descendants("command").SingleOrDefault(element => + (string)element.Attribute("id") == "CmdApplyMotifChanges"); + + Assert.That(command, Is.Not.Null); + Assert.That((string)command.Attribute("label"), Is.EqualTo("_Apply Motif changes...")); + Assert.That((string)command.Attribute("message"), Is.EqualTo("ApplyMotifChanges")); + var toolsMenu = main.Descendants("menu").Single(element => + (string)element.Attribute("id") == "Tools"); + Assert.That(toolsMenu.Elements("item").Any(item => + (string)item.Attribute("command") == "CmdApplyMotifChanges"), Is.True); + Assert.That(main.Descendants("include").Any(element => + (string)element.Attribute("path") == "Lexicon/areaConfiguration.xml" && + ((string)element.Attribute("query") ?? string.Empty).Contains("root/commands")), Is.True); + Assert.That(main.Descendants("include").Any(element => + (string)element.Attribute("path") == "Lexicon/areaConfiguration.xml" && + ((string)element.Attribute("query") ?? string.Empty).Contains("menu[@id='Tools']")), Is.True); + Assert.That(main.Descendants("listener").Any(element => + (string)element.Attribute("assemblyPath") == "LexEdDll.dll" && + (string)element.Attribute("class") == "SIL.FieldWorks.XWorks.LexEd.FLExBridgeListener"), Is.True); + } + } +} diff --git a/Src/LexText/Lexicon/LexEdDllTests/MotifApplyWorkflowTests.cs b/Src/LexText/Lexicon/LexEdDllTests/MotifApplyWorkflowTests.cs new file mode 100644 index 0000000000..4a2265d1d4 --- /dev/null +++ b/Src/LexText/Lexicon/LexEdDllTests/MotifApplyWorkflowTests.cs @@ -0,0 +1,143 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwUtils; +using SIL.FieldWorks.XWorks.LexEd; + +namespace LexEdDllTests +{ + [TestFixture] + public class MotifApplyWorkflowTests + { + [Test] + public void ExecuteCommitsEditsAndUnlocksBeforeOneMotifInvocation() + { + var actions = new List(); + var runCount = 0; + var workflow = new MotifApplyWorkflow( + () => actions.Add("validate-edits"), + () => actions.Add("save-and-unlock"), + () => + { + actions.Add("run-motif"); + runCount++; + return CreateResult(0, "{\"ok\":true,\"applied\":true}"); + }, + () => actions.Add("reload"), + () => actions.Add("relock")); + + var result = workflow.Execute(); + + Assert.That(actions, Is.EqualTo(new[] { "validate-edits", "save-and-unlock", "run-motif", "reload" })); + Assert.That(runCount, Is.EqualTo(1)); + Assert.That(result.Reloaded, Is.True); + Assert.That(result.Relocked, Is.False); + Assert.That(result.Message, Is.EqualTo(MotifApplyMessage.Applied)); + } + + [TestCase(MotifApplyOutcome.Applied, 0, "{\"ok\":true,\"applied\":true}", true, (int)MotifApplyMessage.Applied, true, true)] + [TestCase(MotifApplyOutcome.NoChanges, 0, "{\"ok\":true,\"applied\":false}", false, (int)MotifApplyMessage.NoChanges, true, true)] + [TestCase(MotifApplyOutcome.Refused, 2, "", false, (int)MotifApplyMessage.Refused, true, true)] + [TestCase(MotifApplyOutcome.Busy, 3, "", false, (int)MotifApplyMessage.MotifBusy, true, true)] + [TestCase(MotifApplyOutcome.NotInstalled, 0, "", false, (int)MotifApplyMessage.NotInstalled, true, false)] + [TestCase(MotifApplyOutcome.CouldNotStart, -1, "", false, (int)MotifApplyMessage.CouldNotStart, false, true)] + [TestCase(MotifApplyOutcome.ReconciliationRequired, 4, "", true, (int)MotifApplyMessage.Reconciliation, true, true)] + [TestCase(MotifApplyOutcome.Ambiguous, 8, "", true, (int)MotifApplyMessage.Reconciliation, true, true)] + public void ExecuteSelectsMessageForEachOutcome(MotifApplyOutcome expectedOutcome, + int exitCode, string json, bool reload, int expectedMessage, + bool processStarted, bool executableAvailable) + { + var actions = new List(); + var workflow = CreateWorkflow(actions, + () => CreateResult(exitCode, json, processStarted, executableAvailable)); + + var result = workflow.Execute(); + + Assert.That(result.ApplyResult.Outcome, Is.EqualTo(expectedOutcome)); + Assert.That(result.Reloaded, Is.EqualTo(reload)); + Assert.That(result.Relocked, Is.EqualTo(!reload)); + Assert.That(result.Message, Is.EqualTo((MotifApplyMessage)expectedMessage)); + Assert.That(actions, Does.Contain(reload ? "reload" : "relock")); + } + + [Test] + public void RelockFailureReloadsAndPreservesMotifOutcomeMessage() + { + var actions = new List(); + var workflow = new MotifApplyWorkflow( + () => actions.Add("validate-edits"), + () => actions.Add("save-and-unlock"), + () => + { + actions.Add("run-motif"); + return CreateResult(2, ""); + }, + () => actions.Add("reload"), + () => + { + actions.Add("relock"); + throw new InvalidOperationException("lock failed"); + }); + + var result = workflow.Execute(); + + Assert.That(actions, Is.EqualTo(new[] + { "validate-edits", "save-and-unlock", "run-motif", "relock", "reload" })); + Assert.That(result.Reloaded, Is.True); + Assert.That(result.Relocked, Is.False); + Assert.That(result.ApplyResult.Outcome, Is.EqualTo(MotifApplyOutcome.Refused)); + Assert.That(result.Message, Is.EqualTo(MotifApplyMessage.Refused)); + } + + [Test] + public void ReloadFailurePropagates() + { + var workflow = new MotifApplyWorkflow( + () => { }, + () => { }, + () => CreateResult(0, "{\"ok\":true,\"applied\":true}"), + () => throw new InvalidOperationException("reload failed"), + () => Assert.Fail("Applied outcome must reload instead of relocking.")); + + Assert.Throws(() => workflow.Execute()); + } + + [TestCase(false, false, (int)MotifApplyMessage.NotInstalled)] + [TestCase(true, true, (int)MotifApplyMessage.FieldWorksBusy)] + [TestCase(true, false, (int)MotifApplyMessage.None)] + [TestCase(false, true, (int)MotifApplyMessage.NotInstalled)] + public void PrecheckSelectsTheExpectedMessage(bool motifAvailable, bool fieldWorksBusy, + int expectedMessage) + { + Assert.That(MotifApplyWorkflow.GetPrecheckMessage(motifAvailable, fieldWorksBusy), + Is.EqualTo((MotifApplyMessage)expectedMessage)); + } + + private static MotifApplyWorkflow CreateWorkflow(ICollection actions, + Func apply) + { + return new MotifApplyWorkflow( + () => actions.Add("validate-edits"), + () => actions.Add("save-and-unlock"), + apply, + () => actions.Add("reload"), + () => actions.Add("relock")); + } + + private static MotifApplyResult CreateResult(int exitCode, string json, + bool started = true, bool executableAvailable = true) + { + var resolver = new MotifExecutableResolver( + () => executableAvailable ? @"C:\Motif" : null, + view => null, + path => executableAvailable && path == @"C:\Motif\motif.exe"); + var runner = new MotifApplyRunner(resolver, + info => new MotifProcessOutput(started, exitCode, json, "")); + return runner.Apply(@"C:\Project\Field Data.fwdata"); + } + } +} diff --git a/Src/LexText/Lexicon/LexEdStrings.Designer.cs b/Src/LexText/Lexicon/LexEdStrings.Designer.cs index 12c42f1f90..72c9eff7f7 100644 --- a/Src/LexText/Lexicon/LexEdStrings.Designer.cs +++ b/Src/LexText/Lexicon/LexEdStrings.Designer.cs @@ -19,7 +19,7 @@ namespace SIL.FieldWorks.XWorks.LexEd { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class LexEdStrings { @@ -285,6 +285,78 @@ internal static string ksAlwaysVisible { } } + /// + /// Looks up a localized string similar to This project is in use by another FieldWorks window or a transfer. Finish the other work, then try again.. + /// + internal static string ksApplyMotifBusy { + get { + return ResourceManager.GetString("ksApplyMotifBusy", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Apply Motif changes. + /// + internal static string ksApplyMotifChangesTitle { + get { + return ResourceManager.GetString("ksApplyMotifChangesTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Save this project and apply the changes you approved in Motif? FieldWorks may reload the project afterward.. + /// + internal static string ksApplyMotifConfirmation { + get { + return ResourceManager.GetString("ksApplyMotifConfirmation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to There are no Motif changes waiting to be applied.. + /// + internal static string ksApplyMotifNoChanges { + get { + return ResourceManager.GetString("ksApplyMotifNoChanges", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Motif could not confirm whether the changes were applied. FieldWorks has reloaded the project. Open Motif to see what happened before you try again.. + /// + internal static string ksApplyMotifReconciliation { + get { + return ResourceManager.GetString("ksApplyMotifReconciliation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Some Motif changes no longer fit this project. No changes were applied. Open Motif to review them, then try again.. + /// + internal static string ksApplyMotifRefused { + get { + return ResourceManager.GetString("ksApplyMotifRefused", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Motif changes were applied. FieldWorks has reloaded the project.. + /// + internal static string ksApplyMotifSuccess { + get { + return ResourceManager.GetString("ksApplyMotifSuccess", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Motif changes were applied. FieldWorks has reloaded the project. {0}. + /// + internal static string ksApplyMotifSuccessWithSummary { + get { + return ResourceManager.GetString("ksApplyMotifSuccessWithSummary", resourceCulture); + } + } + /// /// Looks up a localized string similar to Remove circular references to complex forms. /// @@ -735,6 +807,33 @@ internal static string ksMakeComponentOf { } } + /// + /// Looks up a localized string similar to Motif is busy with this project.. + /// + internal static string ksMotifBusy { + get { + return ResourceManager.GetString("ksMotifBusy", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Motif could not be started. Check the installation and try again.. + /// + internal static string ksMotifCouldNotStart { + get { + return ResourceManager.GetString("ksMotifCouldNotStart", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Motif is not installed on this computer.. + /// + internal static string ksMotifNotFound { + get { + return ResourceManager.GetString("ksMotifNotFound", resourceCulture); + } + } + /// /// Looks up a localized string similar to Move Reversal Entry. /// diff --git a/Src/LexText/Lexicon/LexEdStrings.resx b/Src/LexText/Lexicon/LexEdStrings.resx index 4ae891d96f..aef3f4e6ac 100644 --- a/Src/LexText/Lexicon/LexEdStrings.resx +++ b/Src/LexText/Lexicon/LexEdStrings.resx @@ -631,6 +631,50 @@ Reversal subentry sorting is completed + + Apply Motif changes + Title for messages shown while applying changes from Motif to the open FieldWorks project. + + + Save this project and apply the changes you approved in Motif? FieldWorks may reload the project afterward. + Confirmation shown before FieldWorks saves, unlocks, and passes the current project to Motif. + + + There are no Motif changes waiting to be applied. + Shown when Motif reports that it had no approved changes for this project. + + + Motif changes were applied. FieldWorks has reloaded the project. + Shown after Motif applies changes and FieldWorks successfully reopens the project. + + + Motif changes were applied. FieldWorks has reloaded the project. {0} + Shown after a successful apply when Motif provides a concise summary. The placeholder {0} is Motif's English text. + + + Some Motif changes no longer fit this project. No changes were applied. Open Motif to review them, then try again. + Shown when Motif refuses the apply without changing the project because approved changes no longer fit. + + + This project is in use by another FieldWorks window or a transfer. Finish the other work, then try again. + Shown when another FieldWorks window or a transfer prevents Motif from safely using the project. + + + Motif is busy with this project. + Shown when Motif cannot apply changes because the project is in use by another process. + + + Motif could not confirm whether the changes were applied. FieldWorks has reloaded the project. Open Motif to see what happened before you try again. + Shown when Motif may have changed the project but FieldWorks cannot confirm the outcome. + + + Motif is not installed on this computer. + Shown when FieldWorks cannot find the Motif executable in its configured installation location. + + + Motif could not be started. Check the installation and try again. + Shown when Windows cannot start the configured Motif executable. + Remove circular references to complex forms @@ -669,4 +713,4 @@ </ul></body></html> HTML used to display instructions for users when the Send/Receive for the first time menu item is used - \ No newline at end of file + diff --git a/Src/LexText/Lexicon/MotifApplyWorkflow.cs b/Src/LexText/Lexicon/MotifApplyWorkflow.cs new file mode 100644 index 0000000000..b0991ada98 --- /dev/null +++ b/Src/LexText/Lexicon/MotifApplyWorkflow.cs @@ -0,0 +1,118 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using SIL.FieldWorks.Common.FwUtils; + +namespace SIL.FieldWorks.XWorks.LexEd +{ + internal sealed class MotifApplyWorkflow + { + private readonly Action _validatePendingEdits; + private readonly Action _saveAndUnlockProject; + private readonly Func _runMotif; + private readonly Action _reloadProject; + private readonly Action _relockProject; + + internal MotifApplyWorkflow(Action validatePendingEdits, Action saveAndUnlockProject, + Func runMotif, Action reloadProject, Action relockProject) + { + _validatePendingEdits = validatePendingEdits ?? throw new ArgumentNullException(nameof(validatePendingEdits)); + _saveAndUnlockProject = saveAndUnlockProject ?? throw new ArgumentNullException(nameof(saveAndUnlockProject)); + _runMotif = runMotif ?? throw new ArgumentNullException(nameof(runMotif)); + _reloadProject = reloadProject ?? throw new ArgumentNullException(nameof(reloadProject)); + _relockProject = relockProject ?? throw new ArgumentNullException(nameof(relockProject)); + } + + internal MotifApplyWorkflowResult Execute() + { + _validatePendingEdits(); + _saveAndUnlockProject(); + var result = _runMotif(); + var reloaded = false; + var relocked = false; + if (result.RequiresReload) + { + _reloadProject(); + reloaded = true; + } + else + { + try + { + _relockProject(); + relocked = true; + } + catch + { + _reloadProject(); + reloaded = true; + } + } + + return new MotifApplyWorkflowResult(result, reloaded, relocked, SelectMessage(result)); + } + + internal static MotifApplyMessage GetPrecheckMessage(bool motifAvailable, bool fieldWorksBusy) + { + if (!motifAvailable) + return MotifApplyMessage.NotInstalled; + return fieldWorksBusy ? MotifApplyMessage.FieldWorksBusy : MotifApplyMessage.None; + } + + private static MotifApplyMessage SelectMessage(MotifApplyResult result) + { + switch (result.Outcome) + { + case MotifApplyOutcome.Applied: + return string.IsNullOrWhiteSpace(result.Summary) + ? MotifApplyMessage.Applied + : MotifApplyMessage.AppliedWithSummary; + case MotifApplyOutcome.NoChanges: + return MotifApplyMessage.NoChanges; + case MotifApplyOutcome.Refused: + return MotifApplyMessage.Refused; + case MotifApplyOutcome.Busy: + return MotifApplyMessage.MotifBusy; + case MotifApplyOutcome.NotInstalled: + return MotifApplyMessage.NotInstalled; + case MotifApplyOutcome.CouldNotStart: + return MotifApplyMessage.CouldNotStart; + default: + return MotifApplyMessage.Reconciliation; + } + } + } + + internal enum MotifApplyMessage + { + None, + Applied, + AppliedWithSummary, + NoChanges, + Refused, + MotifBusy, + FieldWorksBusy, + NotInstalled, + CouldNotStart, + Reconciliation + } + + internal sealed class MotifApplyWorkflowResult + { + internal MotifApplyWorkflowResult(MotifApplyResult applyResult, bool reloaded, bool relocked, + MotifApplyMessage message) + { + ApplyResult = applyResult; + Reloaded = reloaded; + Relocked = relocked; + Message = message; + } + + internal MotifApplyResult ApplyResult { get; } + internal bool Reloaded { get; } + internal bool Relocked { get; } + internal MotifApplyMessage Message { get; } + } +}