Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions Src/FdoUi/DetailRules/DetailSequenceMove.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// 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 SIL.LCModel;
using SIL.LCModel.Core.Cellar;
using SIL.LCModel.Infrastructure;

// Rules both detail views share. Nothing here may depend on WinForms, Avalonia, or a slice
// or control type, so the rules outlive WinForms. DetailRulesBoundaryTests enforces this.
namespace SIL.FieldWorks.Common.DetailRules
{
/// <summary>
/// The Move Up and Move Down commands of a detail row: whether the row's object can move
/// one place within its owning sequence, and the move itself.
/// </summary>
public static class DetailSequenceMove
{
/// <summary>
/// Whether <paramref name="target"/> sits in an owning sequence, so the move items
/// apply to it at all: false for an unowned object and for one in an atomic or
/// collection field.
/// </summary>
public static bool IsInSequence(ICmObject target)
{
if (target == null || !target.IsValidObject || target.Owner == null || target.OwningFlid <= 0)
return false;
return (CellarPropertyType)target.Cache.MetaDataCacheAccessor.GetFieldType(target.OwningFlid)
== CellarPropertyType.OwningSequence;
}

/// <summary>
/// Whether <paramref name="target"/> can move one place up: it is in a sequence and
/// not first. The first slot of an entry's EntryRefs belongs to a complex form, so
/// while the first ref is one the second cannot move up.
/// </summary>
public static bool CanMoveUp(ICmObject target)
{
if (!IsInSequence(target))
return false;
var index = IndexOf(target);
if (index <= 0)
return false;
if (index == 1 && target.OwningFlid == LexEntryTags.kflidEntryRefs)
return ((ILexEntry)target.Owner).EntryRefsOS[0].ComplexEntryTypesRS.Count == 0;
return true;
}

/// <summary>Whether <paramref name="target"/> can move one place down: it is in a
/// sequence and not last.</summary>
public static bool CanMoveDown(ICmObject target)
{
if (!IsInSequence(target))
return false;
var index = IndexOf(target);
return index >= 0 && index + 1 < Count(target);
}

/// <summary>Moves <paramref name="target"/> one place up in one undoable step.</summary>
/// <returns>True when it moved.</returns>
public static bool MoveUp(ICmObject target, string undoText, string redoText)
{
if (!CanMoveUp(target))
return false;
var index = IndexOf(target);
Move(target, index, index - 1, undoText, redoText);
return true;
}

/// <summary>Moves <paramref name="target"/> one place down in one undoable
/// step.</summary>
/// <returns>True when it moved.</returns>
public static bool MoveDown(ICmObject target, string undoText, string redoText)
{
if (!CanMoveDown(target))
return false;
var index = IndexOf(target);
// MoveOwnSeq inserts before the destination, so one place down is two on.
Move(target, index, index + 2, undoText, redoText);
return true;
}

private static void Move(ICmObject target, int index, int destination, string undoText, string redoText)
{
var cache = target.Cache;
var owner = target.Owner.Hvo;
var flid = target.OwningFlid;
UndoableUnitOfWorkHelper.Do(undoText, redoText, cache.ActionHandlerAccessor,
() => cache.DomainDataByFlid.MoveOwnSeq(owner, flid, index, index, owner, flid, destination));
}

private static int IndexOf(ICmObject target)
=> target.Cache.DomainDataByFlid.GetObjIndex(target.Owner.Hvo, target.OwningFlid, target.Hvo);

private static int Count(ICmObject target)
=> target.Cache.DomainDataByFlid.get_VecSize(target.Owner.Hvo, target.OwningFlid);
}
}
103 changes: 103 additions & 0 deletions Src/FdoUi/DetailRules/MediaFileInsertion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// 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.Collections.Generic;
using System.Linq;
using SIL.FieldWorks.Common.FwUtils;
using SIL.LCModel;
using SIL.LCModel.DomainServices;
using SIL.LCModel.Infrastructure;

// Rules both detail views share. Nothing here may depend on WinForms, Avalonia, or a slice
// or control type, so the rules outlive WinForms. DetailRulesBoundaryTests enforces this.
namespace SIL.FieldWorks.Common.DetailRules
{
/// <summary>
/// The Insert Sound or Movie command: which pronunciation takes the files, and the media
/// links it gets. Choosing the files stays with the caller.
/// </summary>
public static class MediaFileInsertion
{
/// <summary>The strings-table key of the media folder's name.</summary>
public const string MediaFolderKey = "kstidMediaFolder";

/// <summary>
/// The entry whose pronunciation takes the files: the one <paramref name="rowObject"/>
/// is or belongs to, else <paramref name="root"/> when that is an entry; null when
/// neither is.
/// </summary>
public static ILexEntry TargetEntry(ICmObject rowObject, ICmObject root)
{
var entry = rowObject as ILexEntry ?? rowObject?.OwnerOfClass<ILexEntry>();
return entry ?? root as ILexEntry;
}

/// <summary>
/// The pronunciation the files go to: <paramref name="rowObject"/> when it is one,
/// else the first pronunciation of <see cref="TargetEntry"/>; null when there is no
/// entry or it has no pronunciation yet (see <see cref="FirstOrCreatePronunciation"/>).
/// </summary>
public static ILexPronunciation TargetPronunciation(ICmObject rowObject, ICmObject root)
{
if (rowObject is ILexPronunciation pronunciation)
return pronunciation;
return TargetEntry(rowObject, root)?.PronunciationsOS.FirstOrDefault();
}

/// <summary>
/// The entry's first pronunciation, created in its own undoable step when it has none.
/// The default pronunciation writing system is read first, which initializes the
/// pronunciation writing systems (FWR-2086).
/// </summary>
public static ILexPronunciation FirstOrCreatePronunciation(ILexEntry entry, string undoText, string redoText)
{
if (entry.PronunciationsOS.Count == 0)
{
var cache = entry.Cache;
_ = cache.DefaultPronunciationWs;
UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW(undoText, redoText, cache.ActionHandlerAccessor,
() => entry.PronunciationsOS.Add(cache.ServiceLocator.GetInstance<ILexPronunciationFactory>().Create()));
}
return entry.PronunciationsOS[0];
}

/// <summary>
/// Links each file to <paramref name="pronunciation"/> as a new media object whose
/// file sits in the project's media folder, in one undoable step. The links follow
/// the existing ones in reverse name order, since the file dialog lists the last
/// picked file first. Empty names are skipped.
/// </summary>
/// <returns>The media objects created, in their order in the pronunciation.</returns>
public static IReadOnlyList<ICmMedia> Insert(ILexPronunciation pronunciation, IEnumerable<string> fileNames,
string undoText, string redoText)
{
var created = new List<ICmMedia>();
var names = fileNames.Where(f => !string.IsNullOrEmpty(f)).ToList();
if (names.Count == 0 || !pronunciation.IsValidObject)
return created;
var cache = pronunciation.Cache;
UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW(undoText, redoText, cache.ActionHandlerAccessor, () =>
{
var folder = DomainObjectServices.FindOrCreateFolder(cache, LangProjectTags.kflidMedia, MediaFolderName());
var end = pronunciation.MediaFilesOS.Count;
foreach (var name in names)
{
var media = cache.ServiceLocator.GetInstance<ICmMediaFactory>().Create();
pronunciation.MediaFilesOS.Insert(end, media);
media.MediaFileRA = DomainObjectServices.FindOrCreateFile(folder, name);
created.Insert(0, media);
}
});
return created;
}

/// <summary>The media folder's name: the strings-table entry, else the default
/// folder.</summary>
public static string MediaFolderName()
{
var name = StringTable.Table.GetString(MediaFolderKey);
return string.IsNullOrEmpty(name) || name == "*" + MediaFolderKey + "*" ? CmFolderTags.LocalMedia : name;
}
}
}
125 changes: 125 additions & 0 deletions Src/FdoUi/FdoUiTests/DetailRules/DetailSequenceMoveTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// 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.Linq;
using NUnit.Framework;
using SIL.FieldWorks.Common.DetailRules;
using SIL.LCModel;
using SIL.LCModel.Infrastructure;

namespace SIL.FieldWorks.FdoUi
{
/// <summary>
/// The Move Up / Move Down rule of a detail row: where it applies, the ends of the
/// sequence, the complex-form slot of an entry's EntryRefs, and the moves themselves.
/// </summary>
[TestFixture]
public class DetailSequenceMoveTests : MemoryOnlyBackendProviderTestBase
{
private ILexEntry _entry;
private ILexPronunciation _first;
private ILexPronunciation _middle;
private ILexPronunciation _last;
private ILexEntryRef _complexForm;
private ILexEntryRef _variant1;
private ILexEntryRef _variant2;

// An entry with a lexeme form, three pronunciations and three entry refs: a complex
// form first, then two variants.
[SetUp]
public void MakeEntry()
{
NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
{
_entry = Cache.ServiceLocator.GetInstance<ILexEntryFactory>().Create();
_entry.LexemeFormOA = Cache.ServiceLocator.GetInstance<IMoStemAllomorphFactory>().Create();
var pronunciations = Cache.ServiceLocator.GetInstance<ILexPronunciationFactory>();
_entry.PronunciationsOS.Add(_first = pronunciations.Create());
_entry.PronunciationsOS.Add(_middle = pronunciations.Create());
_entry.PronunciationsOS.Add(_last = pronunciations.Create());
var refs = Cache.ServiceLocator.GetInstance<ILexEntryRefFactory>();
_entry.EntryRefsOS.Add(_complexForm = refs.Create());
_complexForm.RefType = LexEntryRefTags.krtComplexForm;
_complexForm.ComplexEntryTypesRS.Add(Cache.ServiceLocator.GetInstance<ILexEntryTypeRepository>()
.GetObject(LexEntryTypeTags.kguidLexTypCompound));
_entry.EntryRefsOS.Add(_variant1 = refs.Create());
_variant1.RefType = LexEntryRefTags.krtVariant;
_entry.EntryRefsOS.Add(_variant2 = refs.Create());
_variant2.RefType = LexEntryRefTags.krtVariant;
});
}

[TearDown]
public void DeleteEntry()
{
NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
{
if (_entry.IsValidObject)
_entry.Delete();
});
}

[Test]
public void IsInSequence_OnlyForAnObjectInAnOwningSequence()
{
Assert.That(DetailSequenceMove.IsInSequence(_middle), Is.True);
Assert.That(DetailSequenceMove.IsInSequence(_entry), Is.False, "unowned");
Assert.That(DetailSequenceMove.IsInSequence(_entry.LexemeFormOA), Is.False, "an atomic field");
Assert.That(DetailSequenceMove.IsInSequence(null), Is.False);
}

[Test]
public void CanMoveUp_AndDown_StopAtTheEnds()
{
Assert.That(DetailSequenceMove.CanMoveUp(_first), Is.False);
Assert.That(DetailSequenceMove.CanMoveUp(_middle), Is.True);
Assert.That(DetailSequenceMove.CanMoveUp(_last), Is.True);
Assert.That(DetailSequenceMove.CanMoveDown(_first), Is.True);
Assert.That(DetailSequenceMove.CanMoveDown(_middle), Is.True);
Assert.That(DetailSequenceMove.CanMoveDown(_last), Is.False);
Assert.That(DetailSequenceMove.CanMoveUp(_entry), Is.False, "not in a sequence");
Assert.That(DetailSequenceMove.CanMoveDown(_entry), Is.False);
}

// The first EntryRefs slot is the complex form's: the second ref cannot move up while
// the first is a complex form, and can once it is not.
[Test]
public void CanMoveUp_InEntryRefs_KeepsTheComplexFormFirst()
{
Assert.That(DetailSequenceMove.CanMoveUp(_variant1), Is.False, "it would displace the complex form");
Assert.That(DetailSequenceMove.CanMoveUp(_variant2), Is.True, "the third ref only moves to second");
Assert.That(DetailSequenceMove.CanMoveDown(_complexForm), Is.True, "the rule covers Move Up only, as before");

NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => _complexForm.ComplexEntryTypesRS.Clear());

Assert.That(DetailSequenceMove.CanMoveUp(_variant1), Is.True, "a first ref that is no complex form reserves nothing");
}

[Test]
public void MoveUp_AndMoveDown_SwapNeighbours_InOneUndoableStep()
{
Assert.That(DetailSequenceMove.MoveUp(_last, "undo", "redo"), Is.True);
Assert.That(_entry.PronunciationsOS.ToList(), Is.EqualTo(new[] { _first, _last, _middle }));
Assert.That(Cache.ActionHandlerAccessor.GetUndoText(), Is.EqualTo("undo"));
Cache.ActionHandlerAccessor.Undo();
Assert.That(_entry.PronunciationsOS.ToList(), Is.EqualTo(new[] { _first, _middle, _last }), "one Undo restores the order");

Assert.That(DetailSequenceMove.MoveDown(_first, "undo", "redo"), Is.True);
Assert.That(_entry.PronunciationsOS.ToList(), Is.EqualTo(new[] { _middle, _first, _last }));
Cache.ActionHandlerAccessor.Undo();
Assert.That(_entry.PronunciationsOS.ToList(), Is.EqualTo(new[] { _first, _middle, _last }));
}

[Test]
public void MoveUp_AndMoveDown_DoNothing_WhereTheRuleRefuses()
{
Assert.That(DetailSequenceMove.MoveUp(_first, "undo", "redo"), Is.False);
Assert.That(DetailSequenceMove.MoveDown(_last, "undo", "redo"), Is.False);
Assert.That(DetailSequenceMove.MoveUp(_variant1, "undo", "redo"), Is.False, "the complex form keeps its slot");
Assert.That(DetailSequenceMove.MoveUp(_entry, "undo", "redo"), Is.False);
Assert.That(_entry.PronunciationsOS.ToList(), Is.EqualTo(new[] { _first, _middle, _last }));
Assert.That(_entry.EntryRefsOS.ToList(), Is.EqualTo(new[] { _complexForm, _variant1, _variant2 }));
}
}
}
Loading
Loading