feat: implement main window view models
This commit is contained in:
1 parent
34a588864f
commit
996c0a9515
7 files changed
+1121
-6
No files matched your search
@@ -0,0 +1,73 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
using SongTidy.Services;
|
||||
|
||||
namespace SongTidy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for <see cref="AudioFileScanner"/>.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public sealed class AudioFileScannerTests {
|
||||
private string _tempDirectory = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a unique temporary directory for the current test.
|
||||
/// </summary>
|
||||
[TestInitialize]
|
||||
public void Initialize() {
|
||||
_tempDirectory = Path.Combine(Path.GetTempPath(), $"SongTidy Scanner {Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the temporary directory created for the current test.
|
||||
/// </summary>
|
||||
[TestCleanup]
|
||||
public void Cleanup() {
|
||||
Directory.Delete(_tempDirectory, recursive: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that supported extensions are matched case-insensitively and others are ignored.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ScansSupportedExtensionsCaseInsensitively() {
|
||||
CreateFile("a.mp3");
|
||||
CreateFile("b.FLAC");
|
||||
CreateFile("c.txt");
|
||||
|
||||
IReadOnlyList<string> 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)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that subdirectories are not scanned.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DoesNotScanSubdirectories() {
|
||||
CreateFile("root.mp3");
|
||||
CreateFile(Path.Combine("sub", "nested.wav"));
|
||||
|
||||
IReadOnlyList<string> files = AudioFileScanner.Scan(_tempDirectory);
|
||||
|
||||
Assert.HasCount(1, files);
|
||||
Assert.IsTrue(files[0].EndsWith("root.mp3", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty file inside the test directory.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The relative file name, which may include a subdirectory.</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
using SongTidy.Models;
|
||||
using SongTidy.Services;
|
||||
using SongTidy.ViewModels;
|
||||
|
||||
namespace SongTidy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for <see cref="MainWindowViewModel"/>.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public sealed class MainWindowViewModelTests {
|
||||
private string _tempDirectory = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a unique temporary directory for the current test.
|
||||
/// </summary>
|
||||
[TestInitialize]
|
||||
public void Initialize() {
|
||||
_tempDirectory = Path.Combine(Path.GetTempPath(), $"SongTidy ViewModel {Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the temporary directory created for the current test.
|
||||
/// </summary>
|
||||
[TestCleanup]
|
||||
public void Cleanup() {
|
||||
Directory.Delete(_tempDirectory, recursive: true);
|
||||
}
|
||||
|
||||
#region Input and merge rules
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that added files are parsed and appended.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that duplicate paths keep the existing entry and its edits.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that duplicate detection compares full path strings case-sensitively.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that adding only duplicates does not change the dirty state.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that folder input uses the audio extension filter.
|
||||
/// </summary>
|
||||
[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
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that field edits mark the document dirty and reverting does not clear it.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that changing the format updates previews and marks the document dirty.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that selecting the current format does not mark the document dirty.
|
||||
/// </summary>
|
||||
[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
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that starting a new plan clears entries, path, and dirty state.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that closing a plan clears entries, path, and dirty state.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that loading a plan restores its format, entries, and path.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void LoadPlanRestoresDocument() {
|
||||
string planPath = Path.Combine(_tempDirectory, "plan.xml");
|
||||
SongPlan plan = new() {
|
||||
Format = RenameFormat.TitleFirst,
|
||||
Items = new List<PlanItem> {
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies saving to a path and then using the existing-path save overload.
|
||||
/// </summary>
|
||||
[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
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that validation uses the current entries.
|
||||
/// </summary>
|
||||
[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
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty file inside the test directory.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The relative file name, which may include a subdirectory.</param>
|
||||
/// <returns>The full path of the created file.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.IO;
|
||||
|
||||
using SongTidy.Models;
|
||||
using SongTidy.ViewModels;
|
||||
|
||||
namespace SongTidy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for <see cref="SongEntryViewModel"/>.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public sealed class SongEntryViewModelTests {
|
||||
#region Preview
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the initial fields and target preview.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that changing a field immediately updates the target preview.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void UpdatesTargetPreviewWhenFieldChanges() {
|
||||
SongEntryViewModel entry = CreateEntry();
|
||||
|
||||
entry.Artist = "New Artist";
|
||||
|
||||
Assert.AreEqual("New Artist - Title (Live).mp3", entry.TargetFileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that changing the format updates the target preview.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void UpdatesTargetPreviewWhenFormatChanges() {
|
||||
SongEntryViewModel entry = CreateEntry();
|
||||
|
||||
entry.Format = RenameFormat.TitleFirst;
|
||||
|
||||
Assert.AreEqual("Title (Live) - Artist.mp3", entry.TargetFileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the original extension is preserved for other file types.
|
||||
/// </summary>
|
||||
[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
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that editing any field raises the edited event.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that assigning the current value does not raise the edited event.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DoesNotRaiseEditedWhenFieldValueIsUnchanged() {
|
||||
SongEntryViewModel entry = CreateEntry();
|
||||
int editCount = 0;
|
||||
entry.Edited += (_, _) => editCount++;
|
||||
|
||||
entry.Artist = entry.Artist;
|
||||
|
||||
Assert.AreEqual(0, editCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that changing the format does not itself count as a field edit.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ChangingFormatDoesNotRaiseEdited() {
|
||||
SongEntryViewModel entry = CreateEntry();
|
||||
int editCount = 0;
|
||||
entry.Edited += (_, _) => editCount++;
|
||||
|
||||
entry.Format = RenameFormat.TitleFirst;
|
||||
|
||||
Assert.AreEqual(0, editCount);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Swap
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that swapping exchanges the artist and title values.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that swapping raises edited notifications for both changed fields.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void SwapRaisesEditedForBothFields() {
|
||||
SongEntryViewModel entry = CreateEntry();
|
||||
int editCount = 0;
|
||||
entry.Edited += (_, _) => editCount++;
|
||||
|
||||
entry.SwapArtistAndTitle();
|
||||
|
||||
Assert.AreEqual(2, editCount);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Creates a standard test entry.
|
||||
/// </summary>
|
||||
/// <returns>The created entry.</returns>
|
||||
private static SongEntryViewModel CreateEntry() {
|
||||
return new SongEntryViewModel(
|
||||
Path.Combine("Music", "A.mp3"),
|
||||
"Artist",
|
||||
"Title",
|
||||
"Live",
|
||||
RenameFormat.ArtistFirst);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace SongTidy.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Finds supported audio files in a single folder.
|
||||
/// </summary>
|
||||
public static class AudioFileScanner {
|
||||
private static readonly HashSet<string> s_audioExtensions = new(StringComparer.OrdinalIgnoreCase) {
|
||||
".mp3",
|
||||
".flac",
|
||||
".wav",
|
||||
".ogg",
|
||||
".m4a",
|
||||
".aac",
|
||||
".wma",
|
||||
".opus",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Scans one folder without searching its subfolders.
|
||||
/// </summary>
|
||||
/// <param name="folderPath">The folder to scan.</param>
|
||||
/// <returns>The full paths of matching audio files, sorted by path.</returns>
|
||||
public static IReadOnlyList<string> 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state and commands of the main application window.
|
||||
/// Represents the state and operations of the main application window.
|
||||
/// </summary>
|
||||
public class MainWindowViewModel : ViewModelBase {
|
||||
public sealed class MainWindowViewModel : ViewModelBase {
|
||||
#region Fields
|
||||
|
||||
private readonly ObservableCollection<SongEntryViewModel> _entries = new();
|
||||
private readonly HashSet<string> _paths = new(StringComparer.Ordinal);
|
||||
private readonly ObservableAsPropertyHelper<int> _entryCount;
|
||||
private RenameFormat _selectedFormat = RenameFormat.ArtistFirst;
|
||||
private string? _planPath;
|
||||
private bool _isDirty;
|
||||
private bool _suppressDirty;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Construction
|
||||
|
||||
/// <summary>
|
||||
/// Gets a temporary greeting displayed by the template view.
|
||||
/// Creates the main window view model.
|
||||
/// </summary>
|
||||
public string Greeting { get; } = "Welcome to Avalonia!";
|
||||
public MainWindowViewModel() {
|
||||
Entries = new ReadOnlyObservableCollection<SongEntryViewModel>(_entries);
|
||||
|
||||
// Keep the information-bar count synchronized with the entry collection.
|
||||
_entryCount = Observable
|
||||
.FromEventPattern<NotifyCollectionChangedEventHandler, NotifyCollectionChangedEventArgs>(
|
||||
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<IReadOnlyList<string>>(paths => AddFiles(paths));
|
||||
ScanFolderCommand = ReactiveCommand.Create<string>(AddFilesFromFolder);
|
||||
LoadPlanCommand = ReactiveCommand.Create<string>(LoadPlan);
|
||||
SavePlanCommand = ReactiveCommand.Create<string>(filePath => SavePlan(filePath));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Document state
|
||||
|
||||
/// <summary>
|
||||
/// Gets the read-only collection of song entries.
|
||||
/// </summary>
|
||||
public ReadOnlyObservableCollection<SongEntryViewModel> Entries { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of entries in the current plan.
|
||||
/// </summary>
|
||||
public int EntryCount => _entryCount.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the naming format used for target previews and saving.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path of the current plan file, or <see langword="null"/> when the plan has not been saved.
|
||||
/// </summary>
|
||||
public string? PlanPath {
|
||||
get => _planPath;
|
||||
private set {
|
||||
if (_planPath == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
_planPath = value;
|
||||
this.RaisePropertyChanged(nameof(PlanPath));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the current plan has unsaved changes.
|
||||
/// </summary>
|
||||
public bool IsDirty {
|
||||
get => _isDirty;
|
||||
private set {
|
||||
if (_isDirty == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isDirty = value;
|
||||
this.RaisePropertyChanged(nameof(IsDirty));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Commands
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command that adds selected file paths to the plan.
|
||||
/// </summary>
|
||||
public ReactiveCommand<IReadOnlyList<string>, Unit> AddFilesCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command that scans a folder and adds its audio files.
|
||||
/// </summary>
|
||||
public ReactiveCommand<string, Unit> ScanFolderCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command that loads a plan from the supplied path.
|
||||
/// </summary>
|
||||
public ReactiveCommand<string, Unit> LoadPlanCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command that saves the plan to the supplied path.
|
||||
/// </summary>
|
||||
public ReactiveCommand<string, Unit> SavePlanCommand { get; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Input
|
||||
|
||||
/// <summary>
|
||||
/// Parses and appends files that are not already present in the plan.
|
||||
/// </summary>
|
||||
/// <param name="filePaths">The source file paths to add.</param>
|
||||
public void AddFiles(IEnumerable<string> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans one folder and appends its supported audio files.
|
||||
/// </summary>
|
||||
/// <param name="folderPath">The folder to scan.</param>
|
||||
public void AddFilesFromFolder(string folderPath) {
|
||||
AddFiles(AudioFileScanner.Scan(folderPath));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Plan operations
|
||||
|
||||
/// <summary>
|
||||
/// Saves the plan to its current path when one exists.
|
||||
/// </summary>
|
||||
/// <returns><see langword="true"/> when the plan had a path and was saved.</returns>
|
||||
public bool SavePlan() {
|
||||
if (PlanPath is null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SavePlan(PlanPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the plan to the supplied path and clears the dirty state.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The destination plan file path.</param>
|
||||
public void SavePlan(string filePath) {
|
||||
SongPlan plan = CreatePlan();
|
||||
PlanXmlIO.Save(plan, filePath);
|
||||
|
||||
PlanPath = filePath;
|
||||
MarkClean();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a plan from the supplied path and replaces the current document.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The source plan file path.</param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the current document and starts a new empty plan.
|
||||
/// </summary>
|
||||
public void NewPlan() {
|
||||
ClearDocument();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the current document and leaves an empty plan.
|
||||
/// </summary>
|
||||
public void ClosePlan() {
|
||||
ClearDocument();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a serializable plan from the current document state.
|
||||
/// </summary>
|
||||
/// <returns>The current plan.</returns>
|
||||
public SongPlan CreatePlan() {
|
||||
return new SongPlan {
|
||||
Version = SongPlan.CurrentVersion,
|
||||
Format = SelectedFormat,
|
||||
Items = CreatePlanItems(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates model entries from the current document state.
|
||||
/// </summary>
|
||||
/// <returns>The current entries as plan items.</returns>
|
||||
public List<PlanItem> CreatePlanItems() {
|
||||
List<PlanItem> 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
|
||||
|
||||
/// <summary>
|
||||
/// Validates the current document for renaming.
|
||||
/// </summary>
|
||||
/// <returns>All validation problems found.</returns>
|
||||
public RenameValidationResult ValidateRename() {
|
||||
return RenameValidator.Validate(CreatePlanItems(), SelectedFormat);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dirty tracking
|
||||
|
||||
/// <summary>
|
||||
/// Marks the document as dirty unless changes are currently suppressed.
|
||||
/// </summary>
|
||||
private void MarkDirty() {
|
||||
if (!_suppressDirty) {
|
||||
IsDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the dirty state after loading, saving, or clearing the document.
|
||||
/// </summary>
|
||||
private void MarkClean() {
|
||||
IsDirty = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the document dirty when an entry field changes.
|
||||
/// </summary>
|
||||
/// <param name="sender">The entry that changed.</param>
|
||||
/// <param name="e">The event arguments.</param>
|
||||
private void OnEntryEdited(object? sender, EventArgs e) {
|
||||
MarkDirty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the document dirty when the entry collection changes.
|
||||
/// </summary>
|
||||
/// <param name="sender">The collection that changed.</param>
|
||||
/// <param name="e">The collection change arguments.</param>
|
||||
private void OnEntriesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) {
|
||||
MarkDirty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the supplied path is already present in the plan.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The path to search for.</param>
|
||||
/// <returns><see langword="true"/> when an entry has the exact same path.</returns>
|
||||
private bool ContainsPath(string filePath) {
|
||||
return _paths.Contains(filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a row view model, subscribes to its edits, and appends it to the collection.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The source file path.</param>
|
||||
/// <param name="artist">The artist value.</param>
|
||||
/// <param name="title">The title value.</param>
|
||||
/// <param name="comment">The comment value.</param>
|
||||
/// <param name="format">The naming format used for the target preview.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all entries and unsubscribes from their edit notifications.
|
||||
/// </summary>
|
||||
private void ClearEntries() {
|
||||
foreach (SongEntryViewModel entry in _entries) {
|
||||
entry.Edited -= OnEntryEdited;
|
||||
}
|
||||
|
||||
_entries.Clear();
|
||||
_paths.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the document state and resets the dirty flag.
|
||||
/// </summary>
|
||||
private void ClearDocument() {
|
||||
_suppressDirty = true;
|
||||
try {
|
||||
ClearEntries();
|
||||
PlanPath = null;
|
||||
} finally {
|
||||
_suppressDirty = false;
|
||||
}
|
||||
|
||||
MarkClean();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reactive;
|
||||
|
||||
using ReactiveUI;
|
||||
|
||||
using SongTidy.Models;
|
||||
|
||||
namespace SongTidy.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Represents one editable song entry row in the main window.
|
||||
/// </summary>
|
||||
public sealed class SongEntryViewModel : ViewModelBase {
|
||||
private readonly ObservableAsPropertyHelper<string> _targetFileName;
|
||||
private string _artist;
|
||||
private string _title;
|
||||
private string _comment;
|
||||
private RenameFormat _format;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a row view model for one source file.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The absolute source file path.</param>
|
||||
/// <param name="artist">The initial artist value.</param>
|
||||
/// <param name="title">The initial title value.</param>
|
||||
/// <param name="comment">The initial comment value.</param>
|
||||
/// <param name="format">The naming format used for the target preview.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised when an editable field changes.
|
||||
/// </summary>
|
||||
public event EventHandler? Edited;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the absolute source file path.
|
||||
/// </summary>
|
||||
public string FilePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the original file name without its directory.
|
||||
/// </summary>
|
||||
public string OriginalFileName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the artist value.
|
||||
/// </summary>
|
||||
public string Artist {
|
||||
get => _artist;
|
||||
set {
|
||||
if (string.Equals(_artist, value, StringComparison.Ordinal)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.RaiseAndSetIfChanged(ref _artist, value);
|
||||
OnEdited();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the title value.
|
||||
/// </summary>
|
||||
public string Title {
|
||||
get => _title;
|
||||
set {
|
||||
if (string.Equals(_title, value, StringComparison.Ordinal)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.RaiseAndSetIfChanged(ref _title, value);
|
||||
OnEdited();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the comment value.
|
||||
/// </summary>
|
||||
public string Comment {
|
||||
get => _comment;
|
||||
set {
|
||||
if (string.Equals(_comment, value, StringComparison.Ordinal)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.RaiseAndSetIfChanged(ref _comment, value);
|
||||
OnEdited();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the naming format used for the target preview.
|
||||
/// </summary>
|
||||
public RenameFormat Format {
|
||||
get => _format;
|
||||
set => this.RaiseAndSetIfChanged(ref _format, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the target file name preview, including the original extension.
|
||||
/// </summary>
|
||||
public string TargetFileName => _targetFileName.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command that swaps the artist and title values.
|
||||
/// </summary>
|
||||
public ReactiveCommand<Unit, Unit> SwapArtistAndTitleCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Swaps the artist and title values.
|
||||
/// </summary>
|
||||
public void SwapArtistAndTitle() {
|
||||
string artist = Artist;
|
||||
Artist = Title;
|
||||
Title = artist;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the target file name preview from the supplied field values.
|
||||
/// </summary>
|
||||
/// <param name="artist">The artist value.</param>
|
||||
/// <param name="title">The title value.</param>
|
||||
/// <param name="comment">The comment value.</param>
|
||||
/// <param name="format">The naming format to apply.</param>
|
||||
/// <returns>The target file name, including the original extension.</returns>
|
||||
private string BuildTargetFileName(string artist, string title, string comment, RenameFormat format) {
|
||||
string stem = RenameFormats.GenerateStem(format, artist, title, comment);
|
||||
return stem + Path.GetExtension(FilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the <see cref="Edited"/> event.
|
||||
/// </summary>
|
||||
private void OnEdited() {
|
||||
Edited?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,6 @@
|
||||
<vm:MainWindowViewModel/>
|
||||
</Design.DataContext>
|
||||
|
||||
<TextBlock Text="{Binding Greeting}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<Grid />
|
||||
|
||||
</Window>
|
||||
Reference in new issue
Block a user