diff --git a/SongTidy.Tests/AudioFileScannerTests.cs b/SongTidy.Tests/AudioFileScannerTests.cs new file mode 100644 index 0000000..1dcaa18 --- /dev/null +++ b/SongTidy.Tests/AudioFileScannerTests.cs @@ -0,0 +1,73 @@ +using System.IO; +using System.Linq; + +using SongTidy.Services; + +namespace SongTidy.Tests; + +/// +/// Contains tests for . +/// +[TestClass] +public sealed class AudioFileScannerTests { + private string _tempDirectory = string.Empty; + + /// + /// Creates a unique temporary directory for the current test. + /// + [TestInitialize] + public void Initialize() { + _tempDirectory = Path.Combine(Path.GetTempPath(), $"SongTidy Scanner {Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDirectory); + } + + /// + /// Removes the temporary directory created for the current test. + /// + [TestCleanup] + public void Cleanup() { + Directory.Delete(_tempDirectory, recursive: true); + } + + /// + /// Verifies that supported extensions are matched case-insensitively and others are ignored. + /// + [TestMethod] + public void ScansSupportedExtensionsCaseInsensitively() { + CreateFile("a.mp3"); + CreateFile("b.FLAC"); + CreateFile("c.txt"); + + IReadOnlyList files = AudioFileScanner.Scan(_tempDirectory); + + Assert.HasCount(2, files); + Assert.IsTrue(files.Any(file => file.EndsWith("a.mp3", StringComparison.OrdinalIgnoreCase))); + Assert.IsTrue(files.Any(file => file.EndsWith("b.FLAC", StringComparison.OrdinalIgnoreCase))); + } + + /// + /// Verifies that subdirectories are not scanned. + /// + [TestMethod] + public void DoesNotScanSubdirectories() { + CreateFile("root.mp3"); + CreateFile(Path.Combine("sub", "nested.wav")); + + IReadOnlyList files = AudioFileScanner.Scan(_tempDirectory); + + Assert.HasCount(1, files); + Assert.IsTrue(files[0].EndsWith("root.mp3", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Creates an empty file inside the test directory. + /// + /// The relative file name, which may include a subdirectory. + private void CreateFile(string fileName) { + string path = Path.Combine(_tempDirectory, fileName); + string directory = Path.GetDirectoryName(path) ?? _tempDirectory; + + Directory.CreateDirectory(directory); + File.WriteAllText(path, string.Empty); + } +} diff --git a/SongTidy.Tests/MainWindowViewModelTests.cs b/SongTidy.Tests/MainWindowViewModelTests.cs new file mode 100644 index 0000000..4b5f232 --- /dev/null +++ b/SongTidy.Tests/MainWindowViewModelTests.cs @@ -0,0 +1,317 @@ +using System.IO; +using System.Linq; + +using SongTidy.Models; +using SongTidy.Services; +using SongTidy.ViewModels; + +namespace SongTidy.Tests; + +/// +/// Contains tests for . +/// +[TestClass] +public sealed class MainWindowViewModelTests { + private string _tempDirectory = string.Empty; + + /// + /// Creates a unique temporary directory for the current test. + /// + [TestInitialize] + public void Initialize() { + _tempDirectory = Path.Combine(Path.GetTempPath(), $"SongTidy ViewModel {Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDirectory); + } + + /// + /// Removes the temporary directory created for the current test. + /// + [TestCleanup] + public void Cleanup() { + Directory.Delete(_tempDirectory, recursive: true); + } + + #region Input and merge rules + + /// + /// Verifies that added files are parsed and appended. + /// + [TestMethod] + public void AddFilesParsesAndAppendsEntries() { + MainWindowViewModel viewModel = new(); + string sourcePath = Path.Combine(_tempDirectory, "A - B (Live).mp3"); + + viewModel.AddFiles(new[] { sourcePath }); + + Assert.HasCount(1, viewModel.Entries); + Assert.AreEqual(1, viewModel.EntryCount); + Assert.IsTrue(viewModel.IsDirty); + + SongEntryViewModel entry = viewModel.Entries[0]; + Assert.AreEqual(sourcePath, entry.FilePath); + Assert.AreEqual("A", entry.Artist); + Assert.AreEqual("B", entry.Title); + Assert.AreEqual("Live", entry.Comment); + Assert.AreEqual("A - B (Live).mp3", entry.OriginalFileName); + Assert.AreEqual("A - B (Live).mp3", entry.TargetFileName); + } + + /// + /// Verifies that duplicate paths keep the existing entry and its edits. + /// + [TestMethod] + public void AddFilesSkipsDuplicatesAndPreservesEdits() { + MainWindowViewModel viewModel = new(); + string sourcePath = Path.Combine(_tempDirectory, "A - B.mp3"); + viewModel.AddFiles(new[] { sourcePath }); + viewModel.Entries[0].Artist = "Edited"; + + viewModel.AddFiles(new[] { sourcePath }); + + Assert.HasCount(1, viewModel.Entries); + Assert.AreEqual("Edited", viewModel.Entries[0].Artist); + } + + /// + /// Verifies that duplicate detection compares full path strings case-sensitively. + /// + [TestMethod] + public void AddFilesTreatsPathsAsCaseSensitive() { + MainWindowViewModel viewModel = new(); + + viewModel.AddFiles(new[] { + Path.Combine(_tempDirectory, "a.mp3"), + Path.Combine(_tempDirectory, "A.mp3"), + }); + + Assert.HasCount(2, viewModel.Entries); + } + + /// + /// Verifies that adding only duplicates does not change the dirty state. + /// + [TestMethod] + public void AddFilesWithOnlyDuplicatesDoesNotDirty() { + MainWindowViewModel viewModel = new(); + string sourcePath = Path.Combine(_tempDirectory, "A.mp3"); + viewModel.AddFiles(new[] { sourcePath }); + viewModel.SavePlan(Path.Combine(_tempDirectory, "plan.xml")); + + viewModel.AddFiles(new[] { sourcePath }); + + Assert.IsFalse(viewModel.IsDirty); + } + + /// + /// Verifies that folder input uses the audio extension filter. + /// + [TestMethod] + public void AddFilesFromFolderAddsOnlyAudioFiles() { + MainWindowViewModel viewModel = new(); + CreateFile("A.mp3"); + CreateFile("B.FLAC"); + CreateFile("C.txt"); + CreateFile(Path.Combine("sub", "D.wav")); + + viewModel.AddFilesFromFolder(_tempDirectory); + + Assert.HasCount(2, viewModel.Entries); + } + + #endregion + + #region Dirty tracking + + /// + /// Verifies that field edits mark the document dirty and reverting does not clear it. + /// + [TestMethod] + public void EntryEditMarksDirtyAndRevertingDoesNotClear() { + MainWindowViewModel viewModel = new(); + string sourcePath = Path.Combine(_tempDirectory, "A.mp3"); + viewModel.AddFiles(new[] { sourcePath }); + viewModel.SavePlan(Path.Combine(_tempDirectory, "plan.xml")); + SongEntryViewModel entry = viewModel.Entries[0]; + string originalArtist = entry.Artist; + + entry.Artist = "Changed"; + Assert.IsTrue(viewModel.IsDirty); + + entry.Artist = originalArtist; + Assert.IsTrue(viewModel.IsDirty); + } + + /// + /// Verifies that changing the format updates previews and marks the document dirty. + /// + [TestMethod] + public void FormatChangeUpdatesPreviewsAndMarksDirty() { + MainWindowViewModel viewModel = new(); + string sourcePath = Path.Combine(_tempDirectory, "A - B (Live).mp3"); + viewModel.AddFiles(new[] { sourcePath }); + viewModel.SavePlan(Path.Combine(_tempDirectory, "plan.xml")); + + viewModel.SelectedFormat = RenameFormat.TitleFirst; + + Assert.IsTrue(viewModel.IsDirty); + Assert.AreEqual("B (Live) - A.mp3", viewModel.Entries[0].TargetFileName); + } + + /// + /// Verifies that selecting the current format does not mark the document dirty. + /// + [TestMethod] + public void SettingSameFormatDoesNotDirty() { + MainWindowViewModel viewModel = new(); + string sourcePath = Path.Combine(_tempDirectory, "A.mp3"); + viewModel.AddFiles(new[] { sourcePath }); + viewModel.SavePlan(Path.Combine(_tempDirectory, "plan.xml")); + + viewModel.SelectedFormat = RenameFormat.ArtistFirst; + + Assert.IsFalse(viewModel.IsDirty); + } + + #endregion + + #region Plan operations + + /// + /// Verifies that starting a new plan clears entries, path, and dirty state. + /// + [TestMethod] + public void NewPlanClearsDocument() { + MainWindowViewModel viewModel = new(); + string sourcePath = Path.Combine(_tempDirectory, "A.mp3"); + viewModel.AddFiles(new[] { sourcePath }); + viewModel.SavePlan(Path.Combine(_tempDirectory, "plan.xml")); + + viewModel.NewPlan(); + + Assert.HasCount(0, viewModel.Entries); + Assert.AreEqual(0, viewModel.EntryCount); + Assert.IsNull(viewModel.PlanPath); + Assert.IsFalse(viewModel.IsDirty); + } + + /// + /// Verifies that closing a plan clears entries, path, and dirty state. + /// + [TestMethod] + public void ClosePlanClearsDocument() { + MainWindowViewModel viewModel = new(); + string sourcePath = Path.Combine(_tempDirectory, "A.mp3"); + viewModel.AddFiles(new[] { sourcePath }); + viewModel.SavePlan(Path.Combine(_tempDirectory, "plan.xml")); + + viewModel.ClosePlan(); + + Assert.HasCount(0, viewModel.Entries); + Assert.AreEqual(0, viewModel.EntryCount); + Assert.IsNull(viewModel.PlanPath); + Assert.IsFalse(viewModel.IsDirty); + } + + /// + /// Verifies that loading a plan restores its format, entries, and path. + /// + [TestMethod] + public void LoadPlanRestoresDocument() { + string planPath = Path.Combine(_tempDirectory, "plan.xml"); + SongPlan plan = new() { + Format = RenameFormat.TitleFirst, + Items = new List { + new PlanItem { + Path = Path.Combine(_tempDirectory, "A - B.mp3"), + Artist = "A", + Title = "B", + }, + new PlanItem { + Path = Path.Combine(_tempDirectory, "C.mp3"), + Title = "C", + Comment = "Live", + }, + }, + }; + PlanXmlIO.Save(plan, planPath); + + MainWindowViewModel viewModel = new(); + viewModel.LoadPlan(planPath); + + Assert.AreEqual(RenameFormat.TitleFirst, viewModel.SelectedFormat); + Assert.HasCount(2, viewModel.Entries); + Assert.AreEqual(2, viewModel.EntryCount); + Assert.AreEqual(planPath, viewModel.PlanPath); + Assert.IsFalse(viewModel.IsDirty); + Assert.AreEqual("B - A.mp3", viewModel.Entries[0].TargetFileName); + Assert.AreEqual("C (Live).mp3", viewModel.Entries[1].TargetFileName); + } + + /// + /// Verifies saving to a path and then using the existing-path save overload. + /// + [TestMethod] + public void SavePlanWritesDocumentAndClearsDirty() { + MainWindowViewModel viewModel = new(); + string sourcePath = Path.Combine(_tempDirectory, "A - B (Live).mp3"); + viewModel.AddFiles(new[] { sourcePath }); + viewModel.SelectedFormat = RenameFormat.TitleFirst; + string planPath = Path.Combine(_tempDirectory, "saved.xml"); + + viewModel.SavePlan(planPath); + + Assert.AreEqual(planPath, viewModel.PlanPath); + Assert.IsFalse(viewModel.IsDirty); + + SongPlan saved = PlanXmlIO.Load(planPath); + Assert.AreEqual(RenameFormat.TitleFirst, saved.Format); + Assert.HasCount(1, saved.Items); + Assert.AreEqual("A", saved.Items[0].Artist); + Assert.AreEqual("B", saved.Items[0].Title); + Assert.AreEqual("Live", saved.Items[0].Comment); + + viewModel.Entries[0].Comment = "Changed"; + Assert.IsTrue(viewModel.IsDirty); + Assert.IsTrue(viewModel.SavePlan()); + Assert.IsFalse(viewModel.IsDirty); + + SongPlan reloaded = PlanXmlIO.Load(planPath); + Assert.AreEqual("Changed", reloaded.Items[0].Comment); + } + + #endregion + + #region Validation + + /// + /// Verifies that validation uses the current entries. + /// + [TestMethod] + public void ValidateRenameUsesCurrentDocument() { + string sourcePath = CreateFile("A.mp3"); + MainWindowViewModel viewModel = new(); + viewModel.AddFiles(new[] { sourcePath }); + viewModel.Entries[0].Title = string.Empty; + + RenameValidationResult result = viewModel.ValidateRename(); + + Assert.IsTrue(result.Issues.Any(issue => issue.Problem == RenameValidationProblem.EmptyTitle)); + } + + #endregion + + /// + /// Creates an empty file inside the test directory. + /// + /// The relative file name, which may include a subdirectory. + /// The full path of the created file. + private string CreateFile(string fileName) { + string path = Path.Combine(_tempDirectory, fileName); + string directory = Path.GetDirectoryName(path) ?? _tempDirectory; + + Directory.CreateDirectory(directory); + File.WriteAllText(path, string.Empty); + + return path; + } +} diff --git a/SongTidy.Tests/SongEntryViewModelTests.cs b/SongTidy.Tests/SongEntryViewModelTests.cs new file mode 100644 index 0000000..9926558 --- /dev/null +++ b/SongTidy.Tests/SongEntryViewModelTests.cs @@ -0,0 +1,163 @@ +using System.IO; + +using SongTidy.Models; +using SongTidy.ViewModels; + +namespace SongTidy.Tests; + +/// +/// Contains tests for . +/// +[TestClass] +public sealed class SongEntryViewModelTests { + #region Preview + + /// + /// Verifies the initial fields and target preview. + /// + [TestMethod] + public void InitializesFieldsAndTargetPreview() { + SongEntryViewModel entry = CreateEntry(); + + Assert.AreEqual("Artist", entry.Artist); + Assert.AreEqual("Title", entry.Title); + Assert.AreEqual("Live", entry.Comment); + Assert.AreEqual(RenameFormat.ArtistFirst, entry.Format); + Assert.AreEqual("A.mp3", entry.OriginalFileName); + Assert.AreEqual("Artist - Title (Live).mp3", entry.TargetFileName); + } + + /// + /// Verifies that changing a field immediately updates the target preview. + /// + [TestMethod] + public void UpdatesTargetPreviewWhenFieldChanges() { + SongEntryViewModel entry = CreateEntry(); + + entry.Artist = "New Artist"; + + Assert.AreEqual("New Artist - Title (Live).mp3", entry.TargetFileName); + } + + /// + /// Verifies that changing the format updates the target preview. + /// + [TestMethod] + public void UpdatesTargetPreviewWhenFormatChanges() { + SongEntryViewModel entry = CreateEntry(); + + entry.Format = RenameFormat.TitleFirst; + + Assert.AreEqual("Title (Live) - Artist.mp3", entry.TargetFileName); + } + + /// + /// Verifies that the original extension is preserved for other file types. + /// + [TestMethod] + public void PreservesOriginalExtension() { + SongEntryViewModel entry = new( + Path.Combine("Music", "A.flac"), + string.Empty, + "Title", + string.Empty, + RenameFormat.ArtistFirst); + + Assert.AreEqual("Title.flac", entry.TargetFileName); + } + + #endregion + + #region Dirty notifications + + /// + /// Verifies that editing any field raises the edited event. + /// + [TestMethod] + public void RaisesEditedWhenFieldChanges() { + SongEntryViewModel entry = CreateEntry(); + int editCount = 0; + entry.Edited += (_, _) => editCount++; + + entry.Artist = "New Artist"; + entry.Title = "New Title"; + entry.Comment = "New Comment"; + + Assert.AreEqual(3, editCount); + } + + /// + /// Verifies that assigning the current value does not raise the edited event. + /// + [TestMethod] + public void DoesNotRaiseEditedWhenFieldValueIsUnchanged() { + SongEntryViewModel entry = CreateEntry(); + int editCount = 0; + entry.Edited += (_, _) => editCount++; + + entry.Artist = entry.Artist; + + Assert.AreEqual(0, editCount); + } + + /// + /// Verifies that changing the format does not itself count as a field edit. + /// + [TestMethod] + public void ChangingFormatDoesNotRaiseEdited() { + SongEntryViewModel entry = CreateEntry(); + int editCount = 0; + entry.Edited += (_, _) => editCount++; + + entry.Format = RenameFormat.TitleFirst; + + Assert.AreEqual(0, editCount); + } + + #endregion + + #region Swap + + /// + /// Verifies that swapping exchanges the artist and title values. + /// + [TestMethod] + public void SwapsArtistAndTitle() { + SongEntryViewModel entry = CreateEntry(); + + entry.SwapArtistAndTitle(); + + Assert.AreEqual("Title", entry.Artist); + Assert.AreEqual("Artist", entry.Title); + Assert.AreEqual("Title - Artist (Live).mp3", entry.TargetFileName); + } + + /// + /// Verifies that swapping raises edited notifications for both changed fields. + /// + [TestMethod] + public void SwapRaisesEditedForBothFields() { + SongEntryViewModel entry = CreateEntry(); + int editCount = 0; + entry.Edited += (_, _) => editCount++; + + entry.SwapArtistAndTitle(); + + Assert.AreEqual(2, editCount); + } + + #endregion + + /// + /// Creates a standard test entry. + /// + /// The created entry. + private static SongEntryViewModel CreateEntry() { + return new SongEntryViewModel( + Path.Combine("Music", "A.mp3"), + "Artist", + "Title", + "Live", + RenameFormat.ArtistFirst); + } +} diff --git a/SongTidy/Services/AudioFileScanner.cs b/SongTidy/Services/AudioFileScanner.cs new file mode 100644 index 0000000..6d62c49 --- /dev/null +++ b/SongTidy/Services/AudioFileScanner.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace SongTidy.Services; + +/// +/// Finds supported audio files in a single folder. +/// +public static class AudioFileScanner { + private static readonly HashSet s_audioExtensions = new(StringComparer.OrdinalIgnoreCase) { + ".mp3", + ".flac", + ".wav", + ".ogg", + ".m4a", + ".aac", + ".wma", + ".opus", + }; + + /// + /// Scans one folder without searching its subfolders. + /// + /// The folder to scan. + /// The full paths of matching audio files, sorted by path. + public static IReadOnlyList Scan(string folderPath) { + string fullFolderPath = Path.GetFullPath(folderPath); + + return Directory + .EnumerateFiles(fullFolderPath, "*", SearchOption.TopDirectoryOnly) + .Where(filePath => s_audioExtensions.Contains(Path.GetExtension(filePath))) + .OrderBy(filePath => filePath, StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} diff --git a/SongTidy/ViewModels/MainWindowViewModel.cs b/SongTidy/ViewModels/MainWindowViewModel.cs index 34283cf..5a685ca 100644 --- a/SongTidy/ViewModels/MainWindowViewModel.cs +++ b/SongTidy/ViewModels/MainWindowViewModel.cs @@ -1,11 +1,381 @@ -namespace SongTidy.ViewModels; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.IO; +using System.Reactive; +using System.Reactive.Linq; + +using ReactiveUI; + +using SongTidy.Models; +using SongTidy.Services; + +namespace SongTidy.ViewModels; /// -/// Represents the state and commands of the main application window. +/// Represents the state and operations of the main application window. /// -public class MainWindowViewModel : ViewModelBase { +public sealed class MainWindowViewModel : ViewModelBase { + #region Fields + + private readonly ObservableCollection _entries = new(); + private readonly HashSet _paths = new(StringComparer.Ordinal); + private readonly ObservableAsPropertyHelper _entryCount; + private RenameFormat _selectedFormat = RenameFormat.ArtistFirst; + private string? _planPath; + private bool _isDirty; + private bool _suppressDirty; + + #endregion + + #region Construction + /// - /// Gets a temporary greeting displayed by the template view. + /// Creates the main window view model. /// - public string Greeting { get; } = "Welcome to Avalonia!"; + public MainWindowViewModel() { + Entries = new ReadOnlyObservableCollection(_entries); + + // Keep the information-bar count synchronized with the entry collection. + _entryCount = Observable + .FromEventPattern( + handler => _entries.CollectionChanged += handler, + handler => _entries.CollectionChanged -= handler) + .Select(_ => _entries.Count) + .StartWith(_entries.Count) + .ToProperty(this, x => x.EntryCount); + + _entries.CollectionChanged += OnEntriesCollectionChanged; + + AddFilesCommand = ReactiveCommand.Create>(paths => AddFiles(paths)); + ScanFolderCommand = ReactiveCommand.Create(AddFilesFromFolder); + LoadPlanCommand = ReactiveCommand.Create(LoadPlan); + SavePlanCommand = ReactiveCommand.Create(filePath => SavePlan(filePath)); + } + + #endregion + + #region Document state + + /// + /// Gets the read-only collection of song entries. + /// + public ReadOnlyObservableCollection Entries { get; } + + /// + /// Gets the number of entries in the current plan. + /// + public int EntryCount => _entryCount.Value; + + /// + /// Gets or sets the naming format used for target previews and saving. + /// + public RenameFormat SelectedFormat { + get => _selectedFormat; + set { + if (_selectedFormat == value) { + return; + } + + _selectedFormat = value; + this.RaisePropertyChanged(nameof(SelectedFormat)); + + // Refresh every row preview when the format changes. + foreach (SongEntryViewModel entry in _entries) { + entry.Format = value; + } + + MarkDirty(); + } + } + + /// + /// Gets the path of the current plan file, or when the plan has not been saved. + /// + public string? PlanPath { + get => _planPath; + private set { + if (_planPath == value) { + return; + } + + _planPath = value; + this.RaisePropertyChanged(nameof(PlanPath)); + } + } + + /// + /// Gets a value indicating whether the current plan has unsaved changes. + /// + public bool IsDirty { + get => _isDirty; + private set { + if (_isDirty == value) { + return; + } + + _isDirty = value; + this.RaisePropertyChanged(nameof(IsDirty)); + } + } + + #endregion + + #region Commands + + /// + /// Gets the command that adds selected file paths to the plan. + /// + public ReactiveCommand, Unit> AddFilesCommand { get; } + + /// + /// Gets the command that scans a folder and adds its audio files. + /// + public ReactiveCommand ScanFolderCommand { get; } + + /// + /// Gets the command that loads a plan from the supplied path. + /// + public ReactiveCommand LoadPlanCommand { get; } + + /// + /// Gets the command that saves the plan to the supplied path. + /// + public ReactiveCommand SavePlanCommand { get; } + + #endregion + + #region Input + + /// + /// Parses and appends files that are not already present in the plan. + /// + /// The source file paths to add. + public void AddFiles(IEnumerable filePaths) { + foreach (string filePath in filePaths) { + // Duplicate paths keep the existing row and all of its edits. + if (ContainsPath(filePath)) { + continue; + } + + string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(filePath); + FilenameParseResult parsed = FilenameParser.Parse(fileNameWithoutExtension); + AddEntry(filePath, parsed.Artist, parsed.Title, parsed.Comment, SelectedFormat); + } + } + + /// + /// Scans one folder and appends its supported audio files. + /// + /// The folder to scan. + public void AddFilesFromFolder(string folderPath) { + AddFiles(AudioFileScanner.Scan(folderPath)); + } + + #endregion + + #region Plan operations + + /// + /// Saves the plan to its current path when one exists. + /// + /// when the plan had a path and was saved. + public bool SavePlan() { + if (PlanPath is null) { + return false; + } + + SavePlan(PlanPath); + return true; + } + + /// + /// Saves the plan to the supplied path and clears the dirty state. + /// + /// The destination plan file path. + public void SavePlan(string filePath) { + SongPlan plan = CreatePlan(); + PlanXmlIO.Save(plan, filePath); + + PlanPath = filePath; + MarkClean(); + } + + /// + /// Loads a plan from the supplied path and replaces the current document. + /// + /// The source plan file path. + public void LoadPlan(string filePath) { + SongPlan plan = PlanXmlIO.Load(filePath); + + // Replace the document without treating the replacement as user edits. + _suppressDirty = true; + try { + ClearEntries(); + SelectedFormat = plan.Format; + + foreach (PlanItem item in plan.Items) { + AddEntry(item.Path, item.Artist, item.Title, item.Comment, plan.Format); + } + + PlanPath = filePath; + } finally { + _suppressDirty = false; + } + + MarkClean(); + } + + /// + /// Clears the current document and starts a new empty plan. + /// + public void NewPlan() { + ClearDocument(); + } + + /// + /// Closes the current document and leaves an empty plan. + /// + public void ClosePlan() { + ClearDocument(); + } + + /// + /// Creates a serializable plan from the current document state. + /// + /// The current plan. + public SongPlan CreatePlan() { + return new SongPlan { + Version = SongPlan.CurrentVersion, + Format = SelectedFormat, + Items = CreatePlanItems(), + }; + } + + /// + /// Creates model entries from the current document state. + /// + /// The current entries as plan items. + public List CreatePlanItems() { + List items = new(_entries.Count); + + foreach (SongEntryViewModel entry in _entries) { + items.Add(new PlanItem { + Path = entry.FilePath, + Artist = entry.Artist, + Title = entry.Title, + Comment = entry.Comment, + }); + } + + return items; + } + + #endregion + + #region Validation + + /// + /// Validates the current document for renaming. + /// + /// All validation problems found. + public RenameValidationResult ValidateRename() { + return RenameValidator.Validate(CreatePlanItems(), SelectedFormat); + } + + #endregion + + #region Dirty tracking + + /// + /// Marks the document as dirty unless changes are currently suppressed. + /// + private void MarkDirty() { + if (!_suppressDirty) { + IsDirty = true; + } + } + + /// + /// Clears the dirty state after loading, saving, or clearing the document. + /// + private void MarkClean() { + IsDirty = false; + } + + /// + /// Marks the document dirty when an entry field changes. + /// + /// The entry that changed. + /// The event arguments. + private void OnEntryEdited(object? sender, EventArgs e) { + MarkDirty(); + } + + /// + /// Marks the document dirty when the entry collection changes. + /// + /// The collection that changed. + /// The collection change arguments. + private void OnEntriesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { + MarkDirty(); + } + + #endregion + + #region Helpers + + /// + /// Determines whether the supplied path is already present in the plan. + /// + /// The path to search for. + /// when an entry has the exact same path. + private bool ContainsPath(string filePath) { + return _paths.Contains(filePath); + } + + /// + /// Creates a row view model, subscribes to its edits, and appends it to the collection. + /// + /// The source file path. + /// The artist value. + /// The title value. + /// The comment value. + /// The naming format used for the target preview. + private void AddEntry(string filePath, string artist, string title, string comment, RenameFormat format) { + SongEntryViewModel entry = new(filePath, artist, title, comment, format); + entry.Edited += OnEntryEdited; + _entries.Add(entry); + _paths.Add(filePath); + } + + /// + /// Removes all entries and unsubscribes from their edit notifications. + /// + private void ClearEntries() { + foreach (SongEntryViewModel entry in _entries) { + entry.Edited -= OnEntryEdited; + } + + _entries.Clear(); + _paths.Clear(); + } + + /// + /// Clears the document state and resets the dirty flag. + /// + private void ClearDocument() { + _suppressDirty = true; + try { + ClearEntries(); + PlanPath = null; + } finally { + _suppressDirty = false; + } + + MarkClean(); + } + + #endregion } diff --git a/SongTidy/ViewModels/SongEntryViewModel.cs b/SongTidy/ViewModels/SongEntryViewModel.cs new file mode 100644 index 0000000..8b39fe3 --- /dev/null +++ b/SongTidy/ViewModels/SongEntryViewModel.cs @@ -0,0 +1,155 @@ +using System; +using System.IO; +using System.Reactive; + +using ReactiveUI; + +using SongTidy.Models; + +namespace SongTidy.ViewModels; + +/// +/// Represents one editable song entry row in the main window. +/// +public sealed class SongEntryViewModel : ViewModelBase { + private readonly ObservableAsPropertyHelper _targetFileName; + private string _artist; + private string _title; + private string _comment; + private RenameFormat _format; + + /// + /// Creates a row view model for one source file. + /// + /// The absolute source file path. + /// The initial artist value. + /// The initial title value. + /// The initial comment value. + /// The naming format used for the target preview. + public SongEntryViewModel(string filePath, string artist, string title, string comment, RenameFormat format) { + FilePath = filePath; + OriginalFileName = Path.GetFileName(filePath); + _artist = artist; + _title = title; + _comment = comment; + _format = format; + + // Keep the target preview synchronized with every field that affects it. + _targetFileName = this.WhenAnyValue( + x => x.Artist, + x => x.Title, + x => x.Comment, + x => x.Format, + (artist, title, comment, format) => BuildTargetFileName(artist, title, comment, format)) + .ToProperty(this, x => x.TargetFileName); + + SwapArtistAndTitleCommand = ReactiveCommand.Create(SwapArtistAndTitle); + } + + /// + /// Raised when an editable field changes. + /// + public event EventHandler? Edited; + + /// + /// Gets the absolute source file path. + /// + public string FilePath { get; } + + /// + /// Gets the original file name without its directory. + /// + public string OriginalFileName { get; } + + /// + /// Gets or sets the artist value. + /// + public string Artist { + get => _artist; + set { + if (string.Equals(_artist, value, StringComparison.Ordinal)) { + return; + } + + this.RaiseAndSetIfChanged(ref _artist, value); + OnEdited(); + } + } + + /// + /// Gets or sets the title value. + /// + public string Title { + get => _title; + set { + if (string.Equals(_title, value, StringComparison.Ordinal)) { + return; + } + + this.RaiseAndSetIfChanged(ref _title, value); + OnEdited(); + } + } + + /// + /// Gets or sets the comment value. + /// + public string Comment { + get => _comment; + set { + if (string.Equals(_comment, value, StringComparison.Ordinal)) { + return; + } + + this.RaiseAndSetIfChanged(ref _comment, value); + OnEdited(); + } + } + + /// + /// Gets or sets the naming format used for the target preview. + /// + public RenameFormat Format { + get => _format; + set => this.RaiseAndSetIfChanged(ref _format, value); + } + + /// + /// Gets the target file name preview, including the original extension. + /// + public string TargetFileName => _targetFileName.Value; + + /// + /// Gets the command that swaps the artist and title values. + /// + public ReactiveCommand SwapArtistAndTitleCommand { get; } + + /// + /// Swaps the artist and title values. + /// + public void SwapArtistAndTitle() { + string artist = Artist; + Artist = Title; + Title = artist; + } + + /// + /// Builds the target file name preview from the supplied field values. + /// + /// The artist value. + /// The title value. + /// The comment value. + /// The naming format to apply. + /// The target file name, including the original extension. + private string BuildTargetFileName(string artist, string title, string comment, RenameFormat format) { + string stem = RenameFormats.GenerateStem(format, artist, title, comment); + return stem + Path.GetExtension(FilePath); + } + + /// + /// Raises the event. + /// + private void OnEdited() { + Edited?.Invoke(this, EventArgs.Empty); + } +} diff --git a/SongTidy/Views/MainWindow.axaml b/SongTidy/Views/MainWindow.axaml index c4d4169..bc6c9a5 100644 --- a/SongTidy/Views/MainWindow.axaml +++ b/SongTidy/Views/MainWindow.axaml @@ -15,6 +15,6 @@ - +