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 @@
-
+