feat: implement main window view models

This commit is contained in:
yyc12345 committed 2026-10-22 21:19:02 +08:00
1 parent 34a588864f
commit 996c0a9515
7 files changed
+1121 -6

No files matched your search

+73
View File
@@ -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);
}
}
+317
View File
@@ -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;
}
}
+163
View File
@@ -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);
}
}
+37
View File
@@ -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();
}
}
+375 -5
View File
@@ -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
}
+155
View File
@@ -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);
}
}
+1 -1
View File
@@ -15,6 +15,6 @@
<vm:MainWindowViewModel/>
</Design.DataContext>
<TextBlock Text="{Binding Greeting}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Grid />
</Window>