diff --git a/src/AppCommon/FileTime.App.Core.Abstraction/Interactions/DoubleTextListPreview.cs b/src/AppCommon/FileTime.App.Core.Abstraction/Interactions/DoubleTextListPreview.cs new file mode 100644 index 0000000..2530761 --- /dev/null +++ b/src/AppCommon/FileTime.App.Core.Abstraction/Interactions/DoubleTextListPreview.cs @@ -0,0 +1,11 @@ +using System.Collections.ObjectModel; +using FileTime.Core.Interactions; + +namespace FileTime.App.Core.Interactions; + +public class DoubleTextListPreview : IPreviewElement +{ + public ObservableCollection Items { get; } = new(); + public PreviewType PreviewType { get; } = PreviewType.DoubleTextList; + object IPreviewElement.PreviewType => PreviewType; +} \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.Core.Abstraction/Interactions/DoubleTextPreview.cs b/src/AppCommon/FileTime.App.Core.Abstraction/Interactions/DoubleTextPreview.cs new file mode 100644 index 0000000..8d04b40 --- /dev/null +++ b/src/AppCommon/FileTime.App.Core.Abstraction/Interactions/DoubleTextPreview.cs @@ -0,0 +1,14 @@ +using System.Reactive.Subjects; +using FileTime.App.Core.Models; +using FileTime.Core.Interactions; + +namespace FileTime.App.Core.Interactions; + +public class DoubleTextPreview : IPreviewElement +{ + public IObservable> Text1 { get; init; } = new BehaviorSubject>(new()); + public IObservable> Text2 { get; init; } = new BehaviorSubject>(new()); + + public PreviewType PreviewType { get; } = PreviewType.DoubleTextList; + object IPreviewElement.PreviewType => PreviewType; +} \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.Core.Abstraction/Interactions/PreviewType.cs b/src/AppCommon/FileTime.App.Core.Abstraction/Interactions/PreviewType.cs new file mode 100644 index 0000000..c98b8db --- /dev/null +++ b/src/AppCommon/FileTime.App.Core.Abstraction/Interactions/PreviewType.cs @@ -0,0 +1,7 @@ +namespace FileTime.App.Core.Interactions; + +public enum PreviewType +{ + DoubleText, + DoubleTextList +} \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.Core/Services/UserCommandHandler/ItemManipulationUserCommandHandlerService.cs b/src/AppCommon/FileTime.App.Core/Services/UserCommandHandler/ItemManipulationUserCommandHandlerService.cs index 89dbacc..3713a7e 100644 --- a/src/AppCommon/FileTime.App.Core/Services/UserCommandHandler/ItemManipulationUserCommandHandlerService.cs +++ b/src/AppCommon/FileTime.App.Core/Services/UserCommandHandler/ItemManipulationUserCommandHandlerService.cs @@ -1,4 +1,9 @@ using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Text.RegularExpressions; +using DynamicData; +using FileTime.App.Core.Interactions; +using FileTime.App.Core.Models; using FileTime.App.Core.Models.Enums; using FileTime.App.Core.UserCommand; using FileTime.App.Core.ViewModels; @@ -175,25 +180,143 @@ public class ItemManipulationUserCommandHandlerService : UserCommandHandlerServi private async Task Rename(RenameCommand command) { - //TODO: group rename - List itemsToMove = new(); - if (_currentSelectedItem?.BaseItem?.FullName is null) return; + if ((_markedItems?.Collection?.Count ?? 0) > 0) + { + BehaviorSubject templateRegexValue = new(string.Empty); + BehaviorSubject newNameSchemaValue = new(string.Empty); - var item = await _timelessContentProvider.GetItemByFullNameAsync(_currentSelectedItem.BaseItem.FullName, PointInTime.Present); + var itemPreviews = _markedItems!.Collection! + .Select(item => + { + var originalName = item.GetName(); - if (item is null) return; + var decoratedOriginalName = templateRegexValue.Select(templateRegex => + { + try + { + if (string.IsNullOrWhiteSpace(templateRegex)) + return new List {new(originalName)}; - var renameInput = new TextInputElement("New name", item.Name); + var regex = new Regex(templateRegex); + var match = regex.Match(originalName); + if (!match.Success) return new List {new(originalName)}; - await _userCommunicationService.ReadInputs(renameInput); + var matchGroups = match.Groups; - //TODO: should check these... - var newPath = item.FullName!.GetParent()!.GetChild(renameInput.Value!); - itemsToMove.Add(new ItemToMove(item.FullName, newPath)); + var indices = Enumerable.Empty() + .Prepend(0) + .Concat( + ((IList) match.Groups).Skip(1).SelectMany(g => new[] {g.Index, g.Index + g.Length}) + ) + .Append(originalName.Length) + .ToList(); - var moveCommandFactory = _serviceProvider.GetRequiredService(); - var moveCommand = moveCommandFactory.GenerateCommand(itemsToMove); - await AddCommand(moveCommand); + var itemNameParts = new List(); + for (var i = 0; i < indices.Count - 1; i++) + { + var text = originalName.Substring(indices[i], indices[i + 1] - indices[i]); + itemNameParts.Add(new ItemNamePart(text, i % 2 == 1)); + } + + return itemNameParts; + } + catch + { + return new List {new(originalName)}; + } + } + ); + + var text2 = Observable.CombineLatest( + templateRegexValue, + newNameSchemaValue, + (templateRegex, newNameSchema) => + { + try + { + if (string.IsNullOrWhiteSpace(templateRegex) + || string.IsNullOrWhiteSpace(newNameSchema)) return new List {new(originalName)}; + + var regex = new Regex(templateRegex); + var match = regex.Match(originalName); + if (!match.Success) return new List {new(originalName)}; + + var matchGroups = match.Groups; + + var newNameParts = Enumerable.Range(1, matchGroups.Count).Aggregate( + (IEnumerable) new List {newNameSchema}, + (acc, i) => + acc.SelectMany(item2 => + item2 + .Split($"/{i}/") + .SelectMany(e => new[] {e, $"/{i}/"}) + .SkipLast(1) + ) + ); + + + var itemNameParts = newNameParts.Select(namePart => + namePart.StartsWith("/") + && namePart.EndsWith("/") + && namePart.Length > 2 + && int.TryParse(namePart.AsSpan(1, namePart.Length - 2), out var index) + && index > 0 + && index <= matchGroups.Count + ? new ItemNamePart(matchGroups[index].Value, true) + : new ItemNamePart(namePart, false) + ); + + return itemNameParts.ToList(); + } + catch + { + return new List {new(originalName)}; + } + } + ); + + var preview = new DoubleTextPreview + { + Text1 = decoratedOriginalName, + Text2 = text2 + }; + return preview; + } + ); + + DoubleTextListPreview doubleTextListPreview = new(); + doubleTextListPreview.Items.AddRange(itemPreviews); + + var templateRegex = new TextInputElement("Template regex", string.Empty, + s => templateRegexValue.OnNext(s!)); + var newNameSchema = new TextInputElement("New name schema", string.Empty, + s => newNameSchemaValue.OnNext(s!)); + await _userCommunicationService.ReadInputs( + new[] {templateRegex, newNameSchema}, + new[] {doubleTextListPreview} + ); + } + else + { + List itemsToMove = new(); + if (_currentSelectedItem?.BaseItem?.FullName is null) return; + + var item = await _timelessContentProvider.GetItemByFullNameAsync(_currentSelectedItem.BaseItem.FullName, PointInTime.Present); + + if (item is null) return; + + var renameInput = new TextInputElement("New name", item.Name); + + await _userCommunicationService.ReadInputs(renameInput); + + //TODO: should check these... + var newPath = item.FullName!.GetParent()!.GetChild(renameInput.Value!); + itemsToMove.Add(new ItemToMove(item.FullName, newPath)); + + var moveCommandFactory = _serviceProvider.GetRequiredService(); + var moveCommand = moveCommandFactory.GenerateCommand(itemsToMove); + await AddCommand(moveCommand); + } } private async Task Delete(DeleteCommand command) diff --git a/src/Core/FileTime.Core.Abstraction/Interactions/IInputElement.cs b/src/Core/FileTime.Core.Abstraction/Interactions/IInputElement.cs index fe418bc..0a2f8a4 100644 --- a/src/Core/FileTime.Core.Abstraction/Interactions/IInputElement.cs +++ b/src/Core/FileTime.Core.Abstraction/Interactions/IInputElement.cs @@ -4,4 +4,7 @@ public interface IInputElement { InputType Type { get; } string Label { get; } + IObservable IsValid { get; } + void SetValid(); + void SetInvalid(); } \ No newline at end of file diff --git a/src/Core/FileTime.Core.Abstraction/Interactions/IPreviewElement.cs b/src/Core/FileTime.Core.Abstraction/Interactions/IPreviewElement.cs new file mode 100644 index 0000000..3e04779 --- /dev/null +++ b/src/Core/FileTime.Core.Abstraction/Interactions/IPreviewElement.cs @@ -0,0 +1,6 @@ +namespace FileTime.Core.Interactions; + +public interface IPreviewElement +{ + object PreviewType { get; } +} \ No newline at end of file diff --git a/src/Core/FileTime.Core.Abstraction/Interactions/IUserCommunicationService.cs b/src/Core/FileTime.Core.Abstraction/Interactions/IUserCommunicationService.cs index 99697d7..85ce28f 100644 --- a/src/Core/FileTime.Core.Abstraction/Interactions/IUserCommunicationService.cs +++ b/src/Core/FileTime.Core.Abstraction/Interactions/IUserCommunicationService.cs @@ -3,6 +3,8 @@ namespace FileTime.Core.Interactions; public interface IUserCommunicationService { Task ReadInputs(params IInputElement[] fields); + Task ReadInputs(IInputElement field, IEnumerable? previews = null); + Task ReadInputs(IEnumerable fields, IEnumerable? previews = null); void ShowToastMessage(string text); Task ShowMessageBox(string text); } \ No newline at end of file diff --git a/src/Core/FileTime.Core.Abstraction/Interactions/InputElementBase.cs b/src/Core/FileTime.Core.Abstraction/Interactions/InputElementBase.cs index 48dadd5..cf7e57d 100644 --- a/src/Core/FileTime.Core.Abstraction/Interactions/InputElementBase.cs +++ b/src/Core/FileTime.Core.Abstraction/Interactions/InputElementBase.cs @@ -1,13 +1,23 @@ +using System.Reactive.Linq; +using System.Reactive.Subjects; + namespace FileTime.Core.Interactions; public abstract class InputElementBase : IInputElement { + private readonly BehaviorSubject _isValid = new(true); public InputType Type { get; } public string Label { get; } + public IObservable IsValid { get; } protected InputElementBase(string label, InputType type) { Label = label; Type = type; + IsValid = _isValid.AsObservable(); } + + public void SetValid() => _isValid.OnNext(true); + + public void SetInvalid() => _isValid.OnNext(false); } \ No newline at end of file diff --git a/src/Core/FileTime.Core.Abstraction/Interactions/TextInputElement.cs b/src/Core/FileTime.Core.Abstraction/Interactions/TextInputElement.cs index 3e1db72..3e661b5 100644 --- a/src/Core/FileTime.Core.Abstraction/Interactions/TextInputElement.cs +++ b/src/Core/FileTime.Core.Abstraction/Interactions/TextInputElement.cs @@ -3,12 +3,19 @@ using PropertyChanged.SourceGenerator; namespace FileTime.Core.Interactions; -public partial class TextInputElement : InputElementBase, INotifyPropertyChanged +public partial class TextInputElement : InputElementBase { [Notify] private string? _value; + private readonly Action? _update; - public TextInputElement(string label, string? value = null) : base(label, InputType.Text) + public TextInputElement( + string label, + string? value = null, + Action? update = null) : base(label, InputType.Text) { _value = value; + _update = update; } + + private void OnValueChanged(string? oldValue, string? newValue) => _update?.Invoke(newValue); } \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.Abstractions/Services/IDialogService.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.Abstractions/Services/IDialogService.cs index aae4f25..75c1957 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.Abstractions/Services/IDialogService.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.Abstractions/Services/IDialogService.cs @@ -7,5 +7,4 @@ public interface IDialogService : IUserCommunicationService { IObservable ReadInput { get; } IObservable LastMessageBox { get; } - void ReadInputs(IEnumerable inputs, Action inputHandler, Action? cancelHandler = null); } \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.Abstractions/ViewModels/ReadInputsViewModel.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.Abstractions/ViewModels/ReadInputsViewModel.cs index eb3843b..4e6befc 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.Abstractions/ViewModels/ReadInputsViewModel.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.Abstractions/ViewModels/ReadInputsViewModel.cs @@ -1,28 +1,33 @@ +using System.Collections.ObjectModel; using FileTime.App.Core.ViewModels; using FileTime.Core.Interactions; -using MvvmGen; +using PropertyChanged.SourceGenerator; namespace FileTime.GuiApp.ViewModels; -[ViewModel] -[Inject(typeof(Action), "_cancel")] -[Inject(typeof(Action), "_process")] -public partial class ReadInputsViewModel : IModalViewModel +public class ReadInputsViewModel : IModalViewModel { public string Name => "ReadInputs"; - public List Inputs { get; set; } - public Action SuccessHandler { get; set; } - public Action? CancelHandler { get; set; } + public required List Inputs { get; init; } + public required Action SuccessHandler { get; init; } + public required Action? CancelHandler { get; init; } + public ObservableCollection Previews { get; } = new(); - [Command] - public void Process() + public ReadInputsViewModel() { - _process.Invoke(this); } - [Command] - public void Cancel() + public ReadInputsViewModel( + List inputs, + Action successHandler, + Action? cancelHandler = null) { - _cancel.Invoke(this); + Inputs = inputs; + SuccessHandler = successHandler; + CancelHandler = cancelHandler; } + + public void Process() => SuccessHandler.Invoke(this); + + public void Cancel() => CancelHandler?.Invoke(this); } \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp/Converters/TextDecorationConverter.cs b/src/GuiApp/Avalonia/FileTime.GuiApp/Converters/TextDecorationConverter.cs new file mode 100644 index 0000000..bf7ebfb --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp/Converters/TextDecorationConverter.cs @@ -0,0 +1,16 @@ +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace FileTime.GuiApp.Converters; + +public class TextDecorationConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool b && b) return TextDecorations.Underline; + return null; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotImplementedException(); +} \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp/Resources/Converters.axaml b/src/GuiApp/Avalonia/FileTime.GuiApp/Resources/Converters.axaml index 381e21d..d8644aa 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp/Resources/Converters.axaml +++ b/src/GuiApp/Avalonia/FileTime.GuiApp/Resources/Converters.axaml @@ -1,10 +1,11 @@ - + - + @@ -26,18 +27,20 @@ x:Key="ItemViewModeToBackgroundConverter" /> - + - + - - + + + \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp/Services/DialogService.cs b/src/GuiApp/Avalonia/FileTime.GuiApp/Services/DialogService.cs index 623b45b..155f4ee 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp/Services/DialogService.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp/Services/DialogService.cs @@ -37,16 +37,76 @@ public class DialogService : IDialogService .Select(m => m.LastOrDefault()); } - public void ReadInputs(IEnumerable inputs, Action inputHandler, Action? cancelHandler = null) + private void ReadInputs( + IEnumerable inputs, + Action inputHandler, + Action? cancelHandler = null, + IEnumerable? previews = null) { - var modalViewModel = new ReadInputsViewModel(HandleReadInputsSuccess, HandleReadInputsCancel) + var modalViewModel = new ReadInputsViewModel { Inputs = inputs.ToList(), - SuccessHandler = inputHandler, - CancelHandler = cancelHandler + SuccessHandler = HandleReadInputsSuccess, + CancelHandler = HandleReadInputsCancel }; + if (previews is not null) + { + modalViewModel.Previews.AddRange(previews); + } + _modalService.OpenModal(modalViewModel); + + void HandleReadInputsSuccess(ReadInputsViewModel readInputsViewModel) + { + _modalService.CloseModal(readInputsViewModel); + inputHandler(); + } + + void HandleReadInputsCancel(ReadInputsViewModel readInputsViewModel) + { + _modalService.CloseModal(readInputsViewModel); + cancelHandler?.Invoke(); + } + } + + public Task ReadInputs(IEnumerable fields, IEnumerable? previews = null) + { + var taskCompletionSource = new TaskCompletionSource(); + ReadInputs( + fields, + () => taskCompletionSource.SetResult(true), + () => taskCompletionSource.SetResult(false), + previews + ); + + return taskCompletionSource.Task; + } + + + public Task ReadInputs(params IInputElement[] fields) + { + var taskCompletionSource = new TaskCompletionSource(); + ReadInputs( + fields, + () => taskCompletionSource.SetResult(true), + () => taskCompletionSource.SetResult(false) + ); + + return taskCompletionSource.Task; + } + + public Task ReadInputs(IInputElement field, IEnumerable? previews = null) + { + var taskCompletionSource = new TaskCompletionSource(); + ReadInputs( + new[] {field}, + () => taskCompletionSource.SetResult(true), + () => taskCompletionSource.SetResult(false), + previews + ); + + return taskCompletionSource.Task; } public void ShowToastMessage(string text) @@ -70,24 +130,4 @@ public class DialogService : IDialogService return taskCompletionSource.Task; } - - private void HandleReadInputsSuccess(ReadInputsViewModel readInputsViewModel) - { - _modalService.CloseModal(readInputsViewModel); - readInputsViewModel.SuccessHandler.Invoke(); - } - - private void HandleReadInputsCancel(ReadInputsViewModel readInputsViewModel) - { - _modalService.CloseModal(readInputsViewModel); - readInputsViewModel.CancelHandler?.Invoke(); - } - - public Task ReadInputs(params IInputElement[] fields) - { - var taskCompletionSource = new TaskCompletionSource(); - ReadInputs(fields, () => taskCompletionSource.SetResult(true), () => taskCompletionSource.SetResult(false)); - - return taskCompletionSource.Task; - } } \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp/Views/MainWindow.axaml b/src/GuiApp/Avalonia/FileTime.GuiApp/Views/MainWindow.axaml index e70c001..6724665 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp/Views/MainWindow.axaml +++ b/src/GuiApp/Avalonia/FileTime.GuiApp/Views/MainWindow.axaml @@ -19,6 +19,7 @@ x:DataType="vm:IMainWindowViewModelBase" xmlns="https://github.com/avaloniaui" xmlns:appCoreModels="using:FileTime.App.Core.Models" + xmlns:appInteractions="using:FileTime.App.Core.Interactions" xmlns:config="using:FileTime.GuiApp.Configuration" xmlns:corevm="using:FileTime.App.Core.ViewModels" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" @@ -578,7 +579,7 @@ HorizontalAlignment="Center" Padding="20" VerticalAlignment="Center"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +