diff --git a/.editorconfig b/.editorconfig index b359993..41fa6e1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,7 +18,7 @@ indent_size = 4 tab_width = 4 # New line preferences -insert_final_newline = false +insert_final_newline = true #### .NET Coding Conventions #### [*.{cs,vb}] diff --git a/YKanBan.Tests/Export/MarkdownExporterTests.cs b/YKanBan.Tests/Export/MarkdownExporterTests.cs index c7f9a75..6c98aa7 100644 --- a/YKanBan.Tests/Export/MarkdownExporterTests.cs +++ b/YKanBan.Tests/Export/MarkdownExporterTests.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Text; + using YKanBan.Export; using YKanBan.Models; @@ -14,8 +15,7 @@ namespace YKanBan.Tests.Export; /// [TestClass] [DoNotParallelize] -public class MarkdownExporterTests -{ +public class MarkdownExporterTests { private static readonly DateTimeOffset ExportedAt = new(2026, 9, 30, 14, 25, 37, TimeSpan.Zero); /// @@ -46,8 +46,7 @@ public class MarkdownExporterTests /// Builds the board fixture: one working column with two cards, one empty column and two tags. /// /// The columns, cards and tag/usage tuples. - private static (IReadOnlyList Columns, IReadOnlyList Cards, IReadOnlyList<(TagModel, long)> Tags) CreateBoard() - { + private static (IReadOnlyList Columns, IReadOnlyList Cards, IReadOnlyList<(TagModel, long)> Tags) CreateBoard() { long created = UnixTimeOf(2026, 9, 1, 10, 0); long updated = UnixTimeOf(2026, 9, 28, 18, 30); @@ -71,8 +70,7 @@ public class MarkdownExporterTests /// Renders the fixture board with the exporter under the current culture. /// /// The produced Markdown document. - private static string ExportBoard() - { + private static string ExportBoard() { (IReadOnlyList columns, IReadOnlyList cards, IReadOnlyList<(TagModel, long)> tags) = CreateBoard(); return MarkdownExporter.Export("MyRepo", columns, cards, tags, ExportedAt); } @@ -82,8 +80,7 @@ public class MarkdownExporterTests /// /// The UI culture. /// The expected Markdown document. - private static string BuildExpected(CultureInfo culture) - { + private static string BuildExpected(CultureInfo culture) { string created = Local(2026, 9, 1, 10, 0).ToString(Resources.Export_CardTimePattern, culture); string updated = Local(2026, 9, 28, 18, 30).ToString(Resources.Export_CardTimePattern, culture); @@ -133,38 +130,30 @@ public class MarkdownExporterTests /// Asserts that the exporter output matches the resource-built expected document. /// /// The UI culture to test. - private static void AssertOutputMatches(CultureInfo culture) - { + private static void AssertOutputMatches(CultureInfo culture) { CultureInfo? original = Resources.Culture; - try - { + try { Resources.Culture = culture; Assert.AreEqual(Normalize(BuildExpected(culture)), Normalize(ExportBoard())); - } - finally - { + } finally { Resources.Culture = original; } } [TestMethod] - public void EnglishOutputMatchesTemplate() - { + public void EnglishOutputMatchesTemplate() { AssertOutputMatches(new CultureInfo("en")); } [TestMethod] - public void ChineseOutputMatchesTemplate() - { + public void ChineseOutputMatchesTemplate() { AssertOutputMatches(new CultureInfo("zh-Hans")); } [TestMethod] - public void FileNameFollowsLanguage() - { + public void FileNameFollowsLanguage() { CultureInfo? original = Resources.Culture; - try - { + try { Resources.Culture = new CultureInfo("en"); Assert.AreEqual( string.Format(Resources.Culture, Resources.Export_FileNamePattern, "MyRepo", ExportedAt), @@ -174,19 +163,15 @@ public class MarkdownExporterTests Assert.AreEqual( string.Format(Resources.Culture, Resources.Export_FileNamePattern, "MyRepo", ExportedAt), MarkdownExporter.BuildFileName("MyRepo", ExportedAt)); - } - finally - { + } finally { Resources.Culture = original; } } [TestMethod] - public void TagTableCellsEscapePipesAndLineBreaks() - { + public void TagTableCellsEscapePipesAndLineBreaks() { CultureInfo? original = Resources.Culture; - try - { + try { Resources.Culture = new CultureInfo("en"); var tag = new TagModel(1, "a|b", RgbColor.Parse("#112233"), "first\nsecond"); string markdown = MarkdownExporter.Export( @@ -197,9 +182,7 @@ public class MarkdownExporterTests ExportedAt); StringAssert.Contains(markdown, "| a\\|b | #112233 | first second | 0 |"); - } - finally - { + } finally { Resources.Culture = original; } } diff --git a/YKanBan.Tests/Launching/LaunchArgumentsTests.cs b/YKanBan.Tests/Launching/LaunchArgumentsTests.cs index 3413b64..22c118a 100644 --- a/YKanBan.Tests/Launching/LaunchArgumentsTests.cs +++ b/YKanBan.Tests/Launching/LaunchArgumentsTests.cs @@ -7,32 +7,27 @@ namespace YKanBan.Tests.Launching; /// three branches: no argument, one valid path, and invalid input. /// [TestClass] -public class LaunchArgumentsTests -{ +public class LaunchArgumentsTests { [TestMethod] - public void NoArgumentsIsRejected() - { + public void NoArgumentsIsRejected() { Assert.IsFalse(LaunchArguments.TryParse([], out string? path)); Assert.IsNull(path); } [TestMethod] - public void BlankArgumentIsRejected() - { + public void BlankArgumentIsRejected() { Assert.IsFalse(LaunchArguments.TryParse([" "], out string? path)); Assert.IsNull(path); } [TestMethod] - public void MoreThanOneArgumentIsRejected() - { + public void MoreThanOneArgumentIsRejected() { Assert.IsFalse(LaunchArguments.TryParse(["a", "b"], out string? path)); Assert.IsNull(path); } [TestMethod] - public void SinglePathIsAccepted() - { + public void SinglePathIsAccepted() { bool parsed = LaunchArguments.TryParse([@"C:\Repo\MyProject"], out string? path); Assert.IsTrue(parsed); Assert.AreEqual(@"C:\Repo\MyProject", path); diff --git a/YKanBan.Tests/Models/RgbColorTests.cs b/YKanBan.Tests/Models/RgbColorTests.cs index 35db145..7fc2fa6 100644 --- a/YKanBan.Tests/Models/RgbColorTests.cs +++ b/YKanBan.Tests/Models/RgbColorTests.cs @@ -7,11 +7,9 @@ namespace YKanBan.Tests.Models; /// boundary in both directions. /// [TestClass] -public class RgbColorTests -{ +public class RgbColorTests { [TestMethod] - public void ParseSplitsComponents() - { + public void ParseSplitsComponents() { RgbColor color = RgbColor.Parse("#102030"); Assert.AreEqual((byte)0x10, color.R); Assert.AreEqual((byte)0x20, color.G); @@ -22,8 +20,7 @@ public class RgbColorTests [DataRow("#a1B2c3")] [DataRow("#A1B2C3")] [DataRow("#a1b2c3")] - public void ParseIsCaseInsensitive(string text) - { + public void ParseIsCaseInsensitive(string text) { Assert.AreEqual(new RgbColor(0xA1, 0xB2, 0xC3), RgbColor.Parse(text)); } @@ -36,41 +33,35 @@ public class RgbColorTests [DataRow("#12345g")] [DataRow(" #112233")] [DataRow("#112233 ")] - public void ParseRejectsMalformedText(string text) - { + public void ParseRejectsMalformedText(string text) { Assert.ThrowsExactly(() => RgbColor.Parse(text)); } [TestMethod] - public void TryParseRejectsNull() - { + public void TryParseRejectsNull() { Assert.IsFalse(RgbColor.TryParse(null, out RgbColor color)); Assert.AreEqual(default, color); } [TestMethod] - public void TryParseAcceptsValidText() - { + public void TryParseAcceptsValidText() { Assert.IsTrue(RgbColor.TryParse("#010203", out RgbColor color)); Assert.AreEqual(new RgbColor(1, 2, 3), color); } [TestMethod] - public void ToStringUsesCanonicalLowercase() - { + public void ToStringUsesCanonicalLowercase() { Assert.AreEqual("#ff0a00", new RgbColor(0xFF, 0x0A, 0x00).ToString()); } [TestMethod] - public void TextRoundTrips() - { + public void TextRoundTrips() { var color = new RgbColor(0x12, 0xAB, 0xCD); Assert.AreEqual(color, RgbColor.Parse(color.ToString())); } [TestMethod] - public void EqualityComparesComponents() - { + public void EqualityComparesComponents() { Assert.AreEqual(new RgbColor(1, 2, 3), new RgbColor(1, 2, 3)); Assert.AreNotEqual(new RgbColor(1, 2, 3), new RgbColor(1, 2, 4)); } diff --git a/YKanBan.Tests/ProjectReferenceTests.cs b/YKanBan.Tests/ProjectReferenceTests.cs index a33704f..ae569b2 100644 --- a/YKanBan.Tests/ProjectReferenceTests.cs +++ b/YKanBan.Tests/ProjectReferenceTests.cs @@ -7,15 +7,13 @@ namespace YKanBan.Tests; /// application project and that the test runner can discover and execute tests. /// [TestClass] -public class ProjectReferenceTests -{ +public class ProjectReferenceTests { /// /// Verifies that a type defined in the application assembly can be /// referenced from the test project. /// [TestMethod] - public void ApplicationAssemblyIsReferenceable() - { + public void ApplicationAssemblyIsReferenceable() { Assert.IsNotNull(typeof(App)); } } diff --git a/YKanBan.Tests/Search/SearchNodeComparer.cs b/YKanBan.Tests/Search/SearchNodeComparer.cs index 652c3a4..8fdeb6e 100644 --- a/YKanBan.Tests/Search/SearchNodeComparer.cs +++ b/YKanBan.Tests/Search/SearchNodeComparer.cs @@ -7,8 +7,7 @@ namespace YKanBan.Tests.Search; /// records compare by reference for their Terms collections by design (see the /// YYC MARK note in SearchNodes.cs), so tests provide equality explicitly. /// -public sealed class SearchNodeComparer : IEqualityComparer -{ +public sealed class SearchNodeComparer : IEqualityComparer { /// /// Gets the shared comparer instance. /// @@ -20,19 +19,15 @@ public sealed class SearchNodeComparer : IEqualityComparer /// The first node. /// The second node. /// when the nodes are structurally equal. - public bool Equals(SearchNode? x, SearchNode? y) - { - if (ReferenceEquals(x, y)) - { + public bool Equals(SearchNode? x, SearchNode? y) { + if (ReferenceEquals(x, y)) { return true; } - if (x is null || y is null || x.GetType() != y.GetType()) - { + if (x is null || y is null || x.GetType() != y.GetType()) { return false; } - return (x, y) switch - { + return (x, y) switch { (SearchNode.Or a, SearchNode.Or b) => SequencesEqual(a.Terms, b.Terms), (SearchNode.And a, SearchNode.And b) => SequencesEqual(a.Terms, b.Terms), (SearchNode.FreeText a, SearchNode.FreeText b) => a.Text == b.Text, @@ -58,10 +53,8 @@ public sealed class SearchNodeComparer : IEqualityComparer /// The first sequence. /// The second sequence. /// when both sequences match element-wise. - private static bool SequencesEqual(IReadOnlyList x, IReadOnlyList y) - { - if (x.Count != y.Count) - { + private static bool SequencesEqual(IReadOnlyList x, IReadOnlyList y) { + if (x.Count != y.Count) { return false; } return x.Zip(y).All(pair => Instance.Equals(pair.First, pair.Second)); diff --git a/YKanBan.Tests/Search/SearchQueryParserTests.cs b/YKanBan.Tests/Search/SearchQueryParserTests.cs index d17ce04..fae748f 100644 --- a/YKanBan.Tests/Search/SearchQueryParserTests.cs +++ b/YKanBan.Tests/Search/SearchQueryParserTests.cs @@ -8,15 +8,13 @@ namespace YKanBan.Tests.Search; /// ). /// [TestClass] -public class SearchQueryParserTests -{ +public class SearchQueryParserTests { /// /// Parses and asserts structural equality against the expected AST. /// /// The expected AST. /// The search text. - private static void AssertParsesTo(SearchNode expected, string text) - { + private static void AssertParsesTo(SearchNode expected, string text) { SearchNode? parsed = SearchQueryParser.Parse(text); Assert.IsNotNull(parsed, $"Blank-looking input: '{text}'."); Assert.AreEqual(expected, parsed, SearchNodeComparer.Instance); @@ -26,53 +24,46 @@ public class SearchQueryParserTests /// Asserts that the input violates the grammar. /// /// The search text. - private static void AssertSyntaxError(string text) - { + private static void AssertSyntaxError(string text) { Assert.ThrowsExactly(() => SearchQueryParser.Parse(text)); } #region Successful parses [TestMethod] - public void BlankInputReturnsNull() - { + public void BlankInputReturnsNull() { Assert.IsNull(SearchQueryParser.Parse(string.Empty)); Assert.IsNull(SearchQueryParser.Parse(" ")); } [TestMethod] - public void SingleWordIsFreeText() - { + public void SingleWordIsFreeText() { AssertParsesTo(new SearchNode.FreeText("login"), "login"); } [TestMethod] - public void AdjacentWordsAreImplicitAnd() - { + public void AdjacentWordsAreImplicitAnd() { AssertParsesTo( new SearchNode.And([new SearchNode.FreeText("fix"), new SearchNode.FreeText("login")]), "fix login"); } [TestMethod] - public void ExplicitAndKeyword() - { + public void ExplicitAndKeyword() { AssertParsesTo( new SearchNode.And([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]), "a AND b"); } [TestMethod] - public void OrKeyword() - { + public void OrKeyword() { AssertParsesTo( new SearchNode.Or([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]), "a OR b"); } [TestMethod] - public void ImplicitAndBindsTighterThanOr() - { + public void ImplicitAndBindsTighterThanOr() { AssertParsesTo( new SearchNode.Or([ new SearchNode.And([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]), @@ -81,8 +72,7 @@ public class SearchQueryParserTests } [TestMethod] - public void ParenthesesForceGrouping() - { + public void ParenthesesForceGrouping() { AssertParsesTo( new SearchNode.And([ new SearchNode.FreeText("a"), @@ -91,16 +81,14 @@ public class SearchQueryParserTests } [TestMethod] - public void NestedParenthesesCollapse() - { + public void NestedParenthesesCollapse() { AssertParsesTo( new SearchNode.Or([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]), "((a OR b))"); } [TestMethod] - public void LowercaseKeywordsArePlainWords() - { + public void LowercaseKeywordsArePlainWords() { AssertParsesTo( new SearchNode.And([ new SearchNode.FreeText("a"), @@ -111,14 +99,12 @@ public class SearchQueryParserTests } [TestMethod] - public void PhraseIsFreeText() - { + public void PhraseIsFreeText() { AssertParsesTo(new SearchNode.FreeText("hello world"), "\"hello world\""); } [TestMethod] - public void QuotedOperatorKeywordIsFreeText() - { + public void QuotedOperatorKeywordIsFreeText() { // A quoted AND/OR is literal text, not an operator. AssertParsesTo(new SearchNode.FreeText("AND"), "\"AND\""); AssertParsesTo( @@ -127,54 +113,46 @@ public class SearchQueryParserTests } [TestMethod] - public void PhraseSupportsEscapes() - { + public void PhraseSupportsEscapes() { // Escaped quote and escaped backslash. AssertParsesTo(new SearchNode.FreeText("a\"b"), "\"a\\\"b\""); AssertParsesTo(new SearchNode.FreeText("a\\b"), "\"a\\\\b\""); } [TestMethod] - public void TagQualifier() - { + public void TagQualifier() { AssertParsesTo(new SearchNode.TagName("bug"), "tag:bug"); } [TestMethod] - public void TagQualifierAcceptsPhraseValue() - { + public void TagQualifierAcceptsPhraseValue() { AssertParsesTo(new SearchNode.TagName("a b"), "tag:\"a b\""); } [TestMethod] - public void QualifierValueMayContainColons() - { + public void QualifierValueMayContainColons() { AssertParsesTo(new SearchNode.TagName("a:b"), "tag:a:b"); } [TestMethod] - public void TitleAndContentQualifiers() - { + public void TitleAndContentQualifiers() { AssertParsesTo(new SearchNode.FieldText(SearchField.Title, "foo"), "title:foo"); AssertParsesTo(new SearchNode.FieldText(SearchField.Content, "foo"), "content:foo"); } [TestMethod] - public void ColumnQualifierAcceptsPhraseValue() - { + public void ColumnQualifierAcceptsPhraseValue() { AssertParsesTo(new SearchNode.ColumnTitle("To Do"), "column:\"To Do\""); } [TestMethod] - public void IdQualifierParsesNumber() - { + public void IdQualifierParsesNumber() { AssertParsesTo(new SearchNode.CardId(42), "id:42"); AssertParsesTo(new SearchNode.CardId(12), "id:\"12\""); } [TestMethod] - public void ColonWithoutKeyPrefixIsFreeText() - { + public void ColonWithoutKeyPrefixIsFreeText() { AssertParsesTo(new SearchNode.FreeText(":foo"), ":foo"); } @@ -183,106 +161,89 @@ public class SearchQueryParserTests #region Strict failures [TestMethod] - public void UnterminatedPhraseIsAnError() - { + public void UnterminatedPhraseIsAnError() { AssertSyntaxError("\"abc def"); } [TestMethod] - public void TrailingUnterminatedPhraseIsAnError() - { + public void TrailingUnterminatedPhraseIsAnError() { AssertSyntaxError("a \"abc"); } [TestMethod] - public void InvalidEscapeInPhraseIsAnError() - { + public void InvalidEscapeInPhraseIsAnError() { // A bare backslash (not followed by a quote or another backslash) is invalid. AssertSyntaxError("\"a\\b\""); } [TestMethod] - public void InvalidEscapeInQualifierPhraseIsAnError() - { + public void InvalidEscapeInQualifierPhraseIsAnError() { AssertSyntaxError("tag:\"a\\x\""); } [TestMethod] - public void UnbalancedOpenParenIsAnError() - { + public void UnbalancedOpenParenIsAnError() { AssertSyntaxError("(a"); } [TestMethod] - public void StrayCloseParenIsAnError() - { + public void StrayCloseParenIsAnError() { AssertSyntaxError("a)"); } [TestMethod] - public void EmptyParensAreAnError() - { + public void EmptyParensAreAnError() { AssertSyntaxError("()"); } [TestMethod] - public void DanglingOrIsAnError() - { + public void DanglingOrIsAnError() { AssertSyntaxError("a OR"); } [TestMethod] - public void DanglingAndIsAnError() - { + public void DanglingAndIsAnError() { AssertSyntaxError("a AND"); } [TestMethod] - public void LeadingAndIsAnError() - { + public void LeadingAndIsAnError() { AssertSyntaxError("AND a"); } [TestMethod] - public void OperatorAtOperandPositionIsAnError() - { + public void OperatorAtOperandPositionIsAnError() { AssertSyntaxError("a AND OR b"); } [TestMethod] - public void QualifierWithoutValueIsAnError() - { + public void QualifierWithoutValueIsAnError() { AssertSyntaxError("tag:"); } [TestMethod] - public void SpacedQualifierValueIsAnError() - { + public void SpacedQualifierValueIsAnError() { AssertSyntaxError("tag: bug"); } [TestMethod] - public void NonNumericIdIsAnError() - { + public void NonNumericIdIsAnError() { AssertSyntaxError("id:12x"); } [TestMethod] - public void UnknownQualifierKeyIsAnError() - { + public void UnknownQualifierKeyIsAnError() { AssertSyntaxError("foo:bar"); } [TestMethod] - public void QualifierKeysAreCaseSensitive() - { + public void QualifierKeysAreCaseSensitive() { // "Tag" is a key-like prefix but not the known lowercase "tag", so it is unknown. AssertSyntaxError("Tag:x"); } [TestMethod] - public void SyntaxErrorCarriesDescriptiveMessage() - { + public void SyntaxErrorCarriesDescriptiveMessage() { SearchSyntaxException exception = Assert.ThrowsExactly( () => SearchQueryParser.Parse("a AND")); Assert.IsFalse(string.IsNullOrEmpty(exception.Message)); diff --git a/YKanBan.Tests/Search/SearchSqlCompilerTests.cs b/YKanBan.Tests/Search/SearchSqlCompilerTests.cs index 836b4f5..7c8fc04 100644 --- a/YKanBan.Tests/Search/SearchSqlCompilerTests.cs +++ b/YKanBan.Tests/Search/SearchSqlCompilerTests.cs @@ -1,4 +1,5 @@ using Microsoft.Data.Sqlite; + using YKanBan.Search; using YKanBan.Storage.Workspace; using YKanBan.Tests.TestUtilities; @@ -10,22 +11,18 @@ namespace YKanBan.Tests.Search; /// workspace database. /// [TestClass] -public class SearchSqlCompilerTests -{ +public class SearchSqlCompilerTests { /// /// Owns a workspace with a fixed card/tag fixture. /// - private sealed class SearchFixture : IDisposable - { + private sealed class SearchFixture : IDisposable { public TempDirectory Directory { get; } = new(); public SqliteConnection Connection { get; } - public SearchFixture() - { + public SearchFixture() { WorkspaceInitializer.Initialize(Directory.FullPath); - using (SqliteConnection setup = SqliteTestHelper.OpenWorkspace(Directory.FullPath)) - { + using (SqliteConnection setup = SqliteTestHelper.OpenWorkspace(Directory.FullPath)) { // Columns: 1 "To Do", 2 "In Progress". SqliteTestHelper.Exec(setup, "INSERT INTO columns (title, description, created_at, updated_at) VALUES ('To Do', '', 1, 1);"); SqliteTestHelper.Exec(setup, "INSERT INTO columns (title, description, created_at, updated_at) VALUES ('In Progress', '', 1, 1);"); @@ -54,38 +51,33 @@ public class SearchSqlCompilerTests /// /// The search text. /// The matching card ids in id order. - public List Search(string queryText) - { + public List Search(string queryText) { SearchNode? query = SearchQueryParser.Parse(queryText); Assert.IsNotNull(query, "Blank fixture queries are not expected here."); CompiledSearch compiled = SearchSqlCompiler.Compile(query); using var command = Connection.CreateCommand(); command.CommandText = $"SELECT c.id FROM cards c WHERE {compiled.Predicate} ORDER BY c.id ASC;"; - foreach ((string name, object value) in compiled.Parameters) - { + foreach ((string name, object value) in compiled.Parameters) { command.Parameters.AddWithValue(name, value); } using var reader = command.ExecuteReader(); var ids = new List(); - while (reader.Read()) - { + while (reader.Read()) { ids.Add(reader.GetInt64(0)); } return ids; } - public void Dispose() - { + public void Dispose() { Connection.Dispose(); Directory.Dispose(); } } [TestMethod] - public void FreeTextMatchesTitleOrContentCaseInsensitively() - { + public void FreeTextMatchesTitleOrContentCaseInsensitively() { using var fixture = new SearchFixture(); CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("apple")); CollectionAssert.AreEqual(new[] { 1L }, fixture.Search("login")); @@ -94,8 +86,7 @@ public class SearchSqlCompilerTests } [TestMethod] - public void FieldQualifiersScopeToTheirField() - { + public void FieldQualifiersScopeToTheirField() { using var fixture = new SearchFixture(); CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("title:apple")); CollectionAssert.AreEqual(new[] { 3L }, fixture.Search("content:banana")); @@ -103,8 +94,7 @@ public class SearchSqlCompilerTests } [TestMethod] - public void TagQualifierMatchesExactlyAndCaseInsensitively() - { + public void TagQualifierMatchesExactlyAndCaseInsensitively() { using var fixture = new SearchFixture(); CollectionAssert.AreEqual(new[] { 1L }, fixture.Search("tag:bug")); CollectionAssert.AreEqual(new[] { 2L, 3L }, fixture.Search("tag:ui")); @@ -116,8 +106,7 @@ public class SearchSqlCompilerTests } [TestMethod] - public void ColumnQualifierMatchesColumnTitleExactly() - { + public void ColumnQualifierMatchesColumnTitleExactly() { using var fixture = new SearchFixture(); CollectionAssert.AreEqual(new[] { 1L, 4L }, fixture.Search("column:\"to do\"")); CollectionAssert.AreEqual(new[] { 2L, 3L }, fixture.Search("column:\"in progress\"")); @@ -128,16 +117,14 @@ public class SearchSqlCompilerTests } [TestMethod] - public void IdQualifierMatchesExactly() - { + public void IdQualifierMatchesExactly() { using var fixture = new SearchFixture(); CollectionAssert.AreEqual(new[] { 3L }, fixture.Search("id:3")); Assert.AreEqual(0, fixture.Search("id:99").Count); } [TestMethod] - public void AndOrCombination() - { + public void AndOrCombination() { using var fixture = new SearchFixture(); CollectionAssert.AreEqual(new[] { 2L, 3L }, fixture.Search("apple OR banana")); CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("apple AND id:2")); @@ -145,8 +132,7 @@ public class SearchSqlCompilerTests } [TestMethod] - public void ParenthesizedGrouping() - { + public void ParenthesizedGrouping() { using var fixture = new SearchFixture(); // (apple OR banana) hits cards 2,3; intersected with column "In Progress" (2,3). @@ -157,16 +143,14 @@ public class SearchSqlCompilerTests } [TestMethod] - public void LikeWildcardsInUserTextMatchLiterally() - { + public void LikeWildcardsInUserTextMatchLiterally() { using var fixture = new SearchFixture(); CollectionAssert.AreEqual(new[] { 4L }, fixture.Search("100%")); CollectionAssert.AreEqual(new[] { 4L }, fixture.Search("a_b")); } [TestMethod] - public void CompiledPredicateUsesNamedParameters() - { + public void CompiledPredicateUsesNamedParameters() { CompiledSearch compiled = SearchSqlCompiler.Compile(new SearchNode.FreeText("x")); StringAssert.Contains(compiled.Predicate, "$p0"); diff --git a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs index fe45e21..c1a3b1f 100644 --- a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs +++ b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs @@ -8,11 +8,9 @@ namespace YKanBan.Tests.Storage.AppData; /// degradation on a missing or corrupt file. /// [TestClass] -public class AppConfigStoreTests -{ +public class AppConfigStoreTests { [TestMethod] - public void LoadMissingFileReturnsDefaults() - { + public void LoadMissingFileReturnsDefaults() { using var directory = new TempDirectory(); var store = new AppConfigStore(Path.Combine(directory.FullPath, "app.json")); @@ -32,8 +30,7 @@ public class AppConfigStoreTests } [TestMethod] - public void LoadCorruptFileBacksUpAndReturnsDefaults() - { + public void LoadCorruptFileBacksUpAndReturnsDefaults() { using var directory = new TempDirectory(); string path = Path.Combine(directory.FullPath, "app.json"); File.WriteAllText(path, "{ this is not valid json"); @@ -47,8 +44,7 @@ public class AppConfigStoreTests } [TestMethod] - public void LoadJsonNullReturnsDefaults() - { + public void LoadJsonNullReturnsDefaults() { using var directory = new TempDirectory(); string path = Path.Combine(directory.FullPath, "app.json"); File.WriteAllText(path, "null"); @@ -58,8 +54,7 @@ public class AppConfigStoreTests } [TestMethod] - public void LoadReadsKebabCaseKeysAndEnumValues() - { + public void LoadReadsKebabCaseKeysAndEnumValues() { using var directory = new TempDirectory(); string path = Path.Combine(directory.FullPath, "app.json"); File.WriteAllText(path, $$""" @@ -97,8 +92,7 @@ public class AppConfigStoreTests } [TestMethod] - public void LoadMissingSectionsFillsDefaults() - { + public void LoadMissingSectionsFillsDefaults() { using var directory = new TempDirectory(); string path = Path.Combine(directory.FullPath, "app.json"); File.WriteAllText(path, $$"""{ "version": {{AppConfig.CurrentFormatVersion}} }"""); @@ -113,17 +107,14 @@ public class AppConfigStoreTests } [TestMethod] - public void SaveWritesKebabCaseKeysAndEnumValues() - { + public void SaveWritesKebabCaseKeysAndEnumValues() { using var directory = new TempDirectory(); string path = Path.Combine(directory.FullPath, "app.json"); var store = new AppConfigStore(path); - var config = new AppConfig - { + var config = new AppConfig { Theme = ThemeOption.FollowSystem, - Display = new DisplaySettings - { + Display = new DisplaySettings { CardSort = SortOption.UpdatedDescending, ColumnSort = SortOption.CreatedAscending, ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Custom, CustomPixels = 500 }, @@ -147,18 +138,15 @@ public class AppConfigStoreTests } [TestMethod] - public void SaveThenLoadRoundTrips() - { + public void SaveThenLoadRoundTrips() { using var directory = new TempDirectory(); string path = Path.Combine(directory.FullPath, "app.json"); var store = new AppConfigStore(path); - var config = new AppConfig - { + var config = new AppConfig { Language = "zh-Hans", Theme = ThemeOption.Light, - Display = new DisplaySettings - { + Display = new DisplaySettings { CardSort = SortOption.CreatedDescending, ColumnSort = SortOption.TitleAscending, ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Wide }, diff --git a/YKanBan.Tests/Storage/SqliteDatabaseTests.cs b/YKanBan.Tests/Storage/SqliteDatabaseTests.cs index 1bf3be8..9c1fd5b 100644 --- a/YKanBan.Tests/Storage/SqliteDatabaseTests.cs +++ b/YKanBan.Tests/Storage/SqliteDatabaseTests.cs @@ -1,4 +1,5 @@ using Microsoft.Data.Sqlite; + using YKanBan.Storage; using YKanBan.Storage.Workspace; using YKanBan.Tests.TestUtilities; @@ -10,11 +11,9 @@ namespace YKanBan.Tests.Storage; /// user_version-driven migration runner. /// [TestClass] -public class SqliteDatabaseTests -{ +public class SqliteDatabaseTests { [TestMethod] - public void OpenAppliesMandatoryPragmas() - { + public void OpenAppliesMandatoryPragmas() { using var directory = new TempDirectory(); string databasePath = Path.Combine(directory.FullPath, "pragmas.db"); @@ -25,8 +24,7 @@ public class SqliteDatabaseTests } [TestMethod] - public void OpenSetsUserVersionToLatestMigration() - { + public void OpenSetsUserVersionToLatestMigration() { using var directory = new TempDirectory(); string databasePath = Path.Combine(directory.FullPath, "version.db"); @@ -35,13 +33,11 @@ public class SqliteDatabaseTests } [TestMethod] - public void ReopenKeepsWalJournalMode() - { + public void ReopenKeepsWalJournalMode() { using var directory = new TempDirectory(); string databasePath = Path.Combine(directory.FullPath, "wal.db"); - using (SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations)) - { + using (SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations)) { } // WAL mode is stored in the database header, so a fresh connection keeps it. @@ -50,14 +46,12 @@ public class SqliteDatabaseTests } [TestMethod] - public void OpenRejectsNewerStoredSchemaVersion() - { + public void OpenRejectsNewerStoredSchemaVersion() { using var directory = new TempDirectory(); string databasePath = Path.Combine(directory.FullPath, "newer.db"); long newerVersion = WorkspaceSchema.CurrentVersion + 1; - using (SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations)) - { + using (SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations)) { SqliteTestHelper.Exec(connection, $"PRAGMA user_version={newerVersion};"); } @@ -68,8 +62,7 @@ public class SqliteDatabaseTests } [TestMethod] - public void OpenRejectsNonContiguousMigrationList() - { + public void OpenRejectsNonContiguousMigrationList() { using var directory = new TempDirectory(); string databasePath = Path.Combine(directory.FullPath, "gap.db"); diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceInitializerTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceInitializerTests.cs index 515cd7b..b2e45ff 100644 --- a/YKanBan.Tests/Storage/Workspace/WorkspaceInitializerTests.cs +++ b/YKanBan.Tests/Storage/Workspace/WorkspaceInitializerTests.cs @@ -1,4 +1,5 @@ using Microsoft.Data.Sqlite; + using YKanBan.Storage; using YKanBan.Storage.Workspace; using YKanBan.Tests.TestUtilities; @@ -11,11 +12,9 @@ namespace YKanBan.Tests.Storage.Workspace; /// content. /// [TestClass] -public class WorkspaceInitializerTests -{ +public class WorkspaceInitializerTests { [TestMethod] - public void IsWorkspaceReflectsInitializationState() - { + public void IsWorkspaceReflectsInitializationState() { using var directory = new TempDirectory(); Assert.IsFalse(WorkspaceInitializer.IsWorkspace(directory.FullPath)); @@ -24,8 +23,7 @@ public class WorkspaceInitializerTests } [TestMethod] - public void InitializeCreatesStructureAndMigratedEmptyDatabase() - { + public void InitializeCreatesStructureAndMigratedEmptyDatabase() { using var directory = new TempDirectory(); WorkspaceInitializer.Initialize(directory.FullPath); @@ -43,8 +41,7 @@ public class WorkspaceInitializerTests } [TestMethod] - public void InitializeMissingFolderThrows() - { + public void InitializeMissingFolderThrows() { using var directory = new TempDirectory(); string missing = Path.Combine(directory.FullPath, "not-created"); @@ -54,8 +51,7 @@ public class WorkspaceInitializerTests } [TestMethod] - public void InitializeTwiceThrows() - { + public void InitializeTwiceThrows() { using var directory = new TempDirectory(); WorkspaceInitializer.Initialize(directory.FullPath); diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceLockTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceLockTests.cs index 04d4b41..e804b60 100644 --- a/YKanBan.Tests/Storage/Workspace/WorkspaceLockTests.cs +++ b/YKanBan.Tests/Storage/Workspace/WorkspaceLockTests.cs @@ -8,11 +8,9 @@ namespace YKanBan.Tests.Storage.Workspace; /// Tests for exclusive lock acquisition and release. /// [TestClass] -public class WorkspaceLockTests -{ +public class WorkspaceLockTests { [TestMethod] - public void AcquireThenDisposeThenAcquireAgainSucceeds() - { + public void AcquireThenDisposeThenAcquireAgainSucceeds() { using var directory = new TempDirectory(); WorkspaceInitializer.Initialize(directory.FullPath); @@ -22,8 +20,7 @@ public class WorkspaceLockTests } [TestMethod] - public void SecondAcquireWhileHeldThrows() - { + public void SecondAcquireWhileHeldThrows() { using var directory = new TempDirectory(); WorkspaceInitializer.Initialize(directory.FullPath); @@ -34,21 +31,18 @@ public class WorkspaceLockTests } [TestMethod] - public void AcquireWithoutYkanbanThrowsNotInitialized() - { + public void AcquireWithoutYkanbanThrowsNotInitialized() { using var directory = new TempDirectory(); Assert.ThrowsExactly( () => WorkspaceLock.Acquire(directory.FullPath)); } [TestMethod] - public void LockFileIsEmptyAfterRelease() - { + public void LockFileIsEmptyAfterRelease() { using var directory = new TempDirectory(); WorkspaceInitializer.Initialize(directory.FullPath); - using (WorkspaceLock.Acquire(directory.FullPath)) - { + using (WorkspaceLock.Acquire(directory.FullPath)) { } // The lock file survives release but carries no content; only the OS handle matters. diff --git a/YKanBan.Tests/Storage/Workspace/WorkspacePresetTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspacePresetTests.cs index eff247b..9c7a192 100644 --- a/YKanBan.Tests/Storage/Workspace/WorkspacePresetTests.cs +++ b/YKanBan.Tests/Storage/Workspace/WorkspacePresetTests.cs @@ -1,5 +1,7 @@ using System.Globalization; + using Microsoft.Data.Sqlite; + using YKanBan.Storage.Workspace; using YKanBan.Tests.TestUtilities; @@ -12,14 +14,11 @@ namespace YKanBan.Tests.Storage.Workspace; /// [TestClass] [DoNotParallelize] -public class WorkspacePresetTests -{ +public class WorkspacePresetTests { [TestMethod] - public void AddPresetColumnsStoresEnglishTextUnderEnglishCulture() - { + public void AddPresetColumnsStoresEnglishTextUnderEnglishCulture() { CultureInfo? original = Resources.Culture; - try - { + try { Resources.Culture = new CultureInfo("en"); using var directory = new TempDirectory(); @@ -43,19 +42,15 @@ public class WorkspacePresetTests Resources.PresetColumn_Done_Description, }, SqliteTestHelper.GetColumnDescriptions(connection)); - } - finally - { + } finally { Resources.Culture = original; } } [TestMethod] - public void AddPresetColumnsStoresChineseTextUnderChineseCulture() - { + public void AddPresetColumnsStoresChineseTextUnderChineseCulture() { CultureInfo? original = Resources.Culture; - try - { + try { Resources.Culture = new CultureInfo("zh-Hans"); using var directory = new TempDirectory(); @@ -74,19 +69,15 @@ public class WorkspacePresetTests // Chinese resources must resolve to different text than English. Assert.AreEqual("待办", Resources.PresetColumn_Todo); Assert.AreEqual("已完成的任务。", Resources.PresetColumn_Done_Description); - } - finally - { + } finally { Resources.Culture = original; } } [TestMethod] - public void AddPresetTagsStoresEightTagsUnderEnglishCulture() - { + public void AddPresetTagsStoresEightTagsUnderEnglishCulture() { CultureInfo? original = Resources.Culture; - try - { + try { Resources.Culture = new CultureInfo("en"); using var directory = new TempDirectory(); @@ -100,19 +91,15 @@ public class WorkspacePresetTests Assert.AreEqual(("bug", "#d73a4a", "Something isn't working"), tags[0]); Assert.AreEqual(("documentation", "#0075ca", "Improvements or additions to documentation"), tags[1]); Assert.AreEqual(("wontfix", "#ffffff", "This will not be worked on"), tags[7]); - } - finally - { + } finally { Resources.Culture = original; } } [TestMethod] - public void AddPresetTagsStoresChineseNamesUnderChineseCulture() - { + public void AddPresetTagsStoresChineseNamesUnderChineseCulture() { CultureInfo? original = Resources.Culture; - try - { + try { Resources.Culture = new CultureInfo("zh-Hans"); using var directory = new TempDirectory(); @@ -125,19 +112,15 @@ public class WorkspacePresetTests Assert.AreEqual(8, tags.Count); Assert.AreEqual("缺陷", tags[0].Name); Assert.AreEqual("功能没有正常工作", tags[0].Description); - } - finally - { + } finally { Resources.Culture = original; } } [TestMethod] - public void PresetContentIsDataAndIgnoresLaterCultureChanges() - { + public void PresetContentIsDataAndIgnoresLaterCultureChanges() { CultureInfo? original = Resources.Culture; - try - { + try { Resources.Culture = new CultureInfo("en"); using var directory = new TempDirectory(); @@ -150,9 +133,7 @@ public class WorkspacePresetTests using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath); Assert.AreEqual("To Do", SqliteTestHelper.GetColumnTitles(connection)[0]); Assert.AreEqual("bug", SqliteTestHelper.GetTags(connection)[0].Name); - } - finally - { + } finally { Resources.Culture = original; } } diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs index d5d4bd4..fc89a6c 100644 --- a/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs +++ b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs @@ -1,4 +1,5 @@ using Microsoft.Data.Sqlite; + using YKanBan.Models; using YKanBan.Search; using YKanBan.Storage.Workspace; @@ -11,16 +12,14 @@ namespace YKanBan.Tests.Storage.Workspace; /// transaction atomicity and the timestamp refresh rules. /// [TestClass] -public class WorkspaceRepositoryTests -{ +public class WorkspaceRepositoryTests { /// /// Owns a workspace with fixed-timestamp fixture data (one column, one /// card, two tags linked to the card) plus the repository under test. /// Fixture timestamps are small literal values so that any real write, /// which stamps the current Unix time, is observable. /// - private sealed class RepositoryFixture : IDisposable - { + private sealed class RepositoryFixture : IDisposable { public TempDirectory Directory { get; } = new(); public WorkspaceRepository Repository { get; } @@ -33,12 +32,10 @@ public class WorkspaceRepositoryTests public long UiTagId { get; } = 2; - public RepositoryFixture() - { + public RepositoryFixture() { WorkspaceInitializer.Initialize(Directory.FullPath); - using (SqliteConnection connection = SqliteTestHelper.OpenWorkspace(Directory.FullPath)) - { + using (SqliteConnection connection = SqliteTestHelper.OpenWorkspace(Directory.FullPath)) { SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, description, created_at, updated_at) VALUES ('Col', '', 100, 100);"); SqliteTestHelper.Exec(connection, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (1, 'T', 'C', 100, 100);"); SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color, description) VALUES ('bug', '#ff0000', '');"); @@ -50,16 +47,14 @@ public class WorkspaceRepositoryTests Repository = new WorkspaceRepository(Directory.FullPath); } - public void Dispose() - { + public void Dispose() { Repository.Dispose(); Directory.Dispose(); } } [TestMethod] - public void GetColumnsReturnsTheSeededColumn() - { + public void GetColumnsReturnsTheSeededColumn() { using var fixture = new RepositoryFixture(); ColumnModel column = fixture.Repository.GetColumn(fixture.ColumnId)!; Assert.AreEqual("Col", column.Title); @@ -67,8 +62,7 @@ public class WorkspaceRepositoryTests } [TestMethod] - public void AddColumnSetsEqualTimestamps() - { + public void AddColumnSetsEqualTimestamps() { using var fixture = new RepositoryFixture(); ColumnModel column = fixture.Repository.AddColumn("New", "desc"); Assert.IsTrue(column.CreatedAt > 0L); @@ -76,8 +70,7 @@ public class WorkspaceRepositoryTests } [TestMethod] - public void UpdateColumnWithoutChangeWritesNothing() - { + public void UpdateColumnWithoutChangeWritesNothing() { using var fixture = new RepositoryFixture(); ColumnModel result = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col", ""); @@ -87,8 +80,7 @@ public class WorkspaceRepositoryTests } [TestMethod] - public void UpdateColumnChangeRefreshesTimestamp() - { + public void UpdateColumnChangeRefreshesTimestamp() { using var fixture = new RepositoryFixture(); ColumnModel updated = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col2", "desc"); @@ -98,8 +90,7 @@ public class WorkspaceRepositoryTests } [TestMethod] - public void DeleteColumnCascadesToCardsAndTagLinks() - { + public void DeleteColumnCascadesToCardsAndTagLinks() { using var fixture = new RepositoryFixture(); Assert.IsTrue(fixture.Repository.DeleteColumn(fixture.ColumnId)); @@ -112,8 +103,7 @@ public class WorkspaceRepositoryTests } [TestMethod] - public void DeleteCardRemovesTagLinksButKeepsTags() - { + public void DeleteCardRemovesTagLinksButKeepsTags() { using var fixture = new RepositoryFixture(); Assert.IsTrue(fixture.Repository.DeleteCard(fixture.CardId)); @@ -125,8 +115,7 @@ public class WorkspaceRepositoryTests } [TestMethod] - public void DeleteTagDetachesFromCardsButKeepsCards() - { + public void DeleteTagDetachesFromCardsButKeepsCards() { using var fixture = new RepositoryFixture(); Assert.IsTrue(fixture.Repository.DeleteTag(fixture.BugTagId)); @@ -138,8 +127,7 @@ public class WorkspaceRepositoryTests } [TestMethod] - public void UpdateCardWithoutEffectiveChangeWritesNothing() - { + public void UpdateCardWithoutEffectiveChangeWritesNothing() { using var fixture = new RepositoryFixture(); CardModel result = fixture.Repository.UpdateCard( @@ -149,8 +137,7 @@ public class WorkspaceRepositoryTests } [TestMethod] - public void UpdateCardTitleChangeRefreshesTimestamp() - { + public void UpdateCardTitleChangeRefreshesTimestamp() { using var fixture = new RepositoryFixture(); CardModel updated = fixture.Repository.UpdateCard( @@ -161,8 +148,7 @@ public class WorkspaceRepositoryTests } [TestMethod] - public void UpdateCardTagSetOnlyChangeStillRefreshesTimestamp() - { + public void UpdateCardTagSetOnlyChangeStillRefreshesTimestamp() { using var fixture = new RepositoryFixture(); CardModel updated = fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.BugTagId]); @@ -172,8 +158,7 @@ public class WorkspaceRepositoryTests } [TestMethod] - public void UpdateCardWithUnknownTagRollsBackAtomically() - { + public void UpdateCardWithUnknownTagRollsBackAtomically() { using var fixture = new RepositoryFixture(); // Tag 999 does not exist: the FK fires mid-transaction and the whole edit must roll back. @@ -188,8 +173,7 @@ public class WorkspaceRepositoryTests } [TestMethod] - public void MoveCardUpdatesColumnWithoutRefreshingTimestamp() - { + public void MoveCardUpdatesColumnWithoutRefreshingTimestamp() { using var fixture = new RepositoryFixture(); long target = fixture.Repository.AddColumn("Target", "").Id; @@ -202,8 +186,7 @@ public class WorkspaceRepositoryTests } [TestMethod] - public void MoveCardToCurrentColumnIsNoOp() - { + public void MoveCardToCurrentColumnIsNoOp() { using var fixture = new RepositoryFixture(); Assert.IsFalse(fixture.Repository.MoveCard(fixture.CardId, fixture.ColumnId)); @@ -212,8 +195,7 @@ public class WorkspaceRepositoryTests } [TestMethod] - public void AddCardCreatesWithEqualTimestampsAndLinkedTags() - { + public void AddCardCreatesWithEqualTimestampsAndLinkedTags() { using var fixture = new RepositoryFixture(); CardModel card = fixture.Repository.AddCard(fixture.ColumnId, "N", "body", [fixture.UiTagId]); @@ -225,8 +207,7 @@ public class WorkspaceRepositoryTests } [TestMethod] - public void UpdateTagWithoutChangeWritesNothing() - { + public void UpdateTagWithoutChangeWritesNothing() { using var fixture = new RepositoryFixture(); TagModel result = fixture.Repository.UpdateTag( @@ -237,8 +218,7 @@ public class WorkspaceRepositoryTests } [TestMethod] - public void UpdateTagChangeWrites() - { + public void UpdateTagChangeWrites() { using var fixture = new RepositoryFixture(); TagModel updated = fixture.Repository.UpdateTag( @@ -251,16 +231,14 @@ public class WorkspaceRepositoryTests } [TestMethod] - public void AddTagWithDuplicateNameIsRejected() - { + public void AddTagWithDuplicateNameIsRejected() { using var fixture = new RepositoryFixture(); Assert.ThrowsExactly(() => fixture.Repository.AddTag("bug", RgbColor.Parse("#123456"), "")); } [TestMethod] - public void GetCardIdsMatchingRunsTheCompiledSearch() - { + public void GetCardIdsMatchingRunsTheCompiledSearch() { using var fixture = new RepositoryFixture(); CompiledSearch compiled = SearchSqlCompiler.Compile(new SearchNode.FreeText("T")); @@ -270,8 +248,7 @@ public class WorkspaceRepositoryTests } [TestMethod] - public void GetTagsWithUsageCountsAssignments() - { + public void GetTagsWithUsageCountsAssignments() { using var fixture = new RepositoryFixture(); IReadOnlyList<(TagModel Tag, long UsageCount)> before = fixture.Repository.GetTagsWithUsage(); diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs index 7e2d5b1..43bbbb1 100644 --- a/YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs +++ b/YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs @@ -1,4 +1,5 @@ using Microsoft.Data.Sqlite; + using YKanBan.Storage; using YKanBan.Storage.Workspace; using YKanBan.Tests.TestUtilities; @@ -10,19 +11,16 @@ namespace YKanBan.Tests.Storage.Workspace; /// and that the cascading delete chains work as specified. /// [TestClass] -public class WorkspaceSchemaTests -{ +public class WorkspaceSchemaTests { /// /// Owns a temp directory plus a fresh workspace-schema database for a single test. /// - private sealed class FreshDatabase : IDisposable - { + private sealed class FreshDatabase : IDisposable { public TempDirectory Directory { get; } = new(); public SqliteConnection Connection { get; } - public FreshDatabase() - { + public FreshDatabase() { Connection = SqliteDatabase.Open( Path.Combine(Directory.FullPath, "ws.db"), WorkspaceSchema.Migrations); } @@ -30,24 +28,21 @@ public class WorkspaceSchemaTests /// /// Closes the connection before removing the temporary directory. /// - public void Dispose() - { + public void Dispose() { Connection.Dispose(); Directory.Dispose(); } } [TestMethod] - public void EmptyColumnTitleIsRejected() - { + public void EmptyColumnTitleIsRejected() { using var db = new FreshDatabase(); Assert.ThrowsExactly(() => SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('', 1, 1);")); } [TestMethod] - public void WhitespaceColumnTitleIsAccepted() - { + public void WhitespaceColumnTitleIsAccepted() { using var db = new FreshDatabase(); // No trimming: a single space is a non-empty title as far as the DDL is concerned. int affected = SqliteTestHelper.Exec( @@ -56,8 +51,7 @@ public class WorkspaceSchemaTests } [TestMethod] - public void DuplicateColumnTitleIsRejected() - { + public void DuplicateColumnTitleIsRejected() { using var db = new FreshDatabase(); SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);"); Assert.ThrowsExactly(() => @@ -65,8 +59,7 @@ public class WorkspaceSchemaTests } [TestMethod] - public void ColumnTitlesCompareCaseSensitively() - { + public void ColumnTitlesCompareCaseSensitively() { using var db = new FreshDatabase(); // BINARY collation: 'Foo' and 'foo' are distinct titles. SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);"); @@ -75,28 +68,24 @@ public class WorkspaceSchemaTests } [TestMethod] - public void EmptyTagNameIsRejected() - { + public void EmptyTagNameIsRejected() { using var db = new FreshDatabase(); Assert.ThrowsExactly(() => SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('', '#112233');")); } [TestMethod] - public void TagNamesAcceptSpacesEmojiAndReservedWords() - { + public void TagNamesAcceptSpacesEmojiAndReservedWords() { using var db = new FreshDatabase(); // Tag names are fully open: no charset or length rules, no trimming. - foreach (string name in new[] { " ", "a b", "🏷️", "AND", "tag:with:colons" }) - { + foreach (string name in new[] { " ", "a b", "🏷️", "AND", "tag:with:colons" }) { SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ($n, '#112233');", ("$n", name)); } Assert.AreEqual(5L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM tags;")); } [TestMethod] - public void DuplicateTagNameIsRejected() - { + public void DuplicateTagNameIsRejected() { using var db = new FreshDatabase(); SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('bug', '#112233');"); Assert.ThrowsExactly(() => @@ -107,8 +96,7 @@ public class WorkspaceSchemaTests [DataRow("#000000")] [DataRow("#FFFFFF")] [DataRow("#a1B2c3")] - public void ValidTagColorsAreAccepted(string color) - { + public void ValidTagColorsAreAccepted(string color) { using var db = new FreshDatabase(); int affected = SqliteTestHelper.Exec( db.Connection, "INSERT INTO tags (name, color) VALUES ('x', $c);", ("$c", color)); @@ -124,16 +112,14 @@ public class WorkspaceSchemaTests [DataRow(" #112233")] [DataRow("#112233 ")] [DataRow("")] - public void InvalidTagColorsAreRejected(string color) - { + public void InvalidTagColorsAreRejected(string color) { using var db = new FreshDatabase(); Assert.ThrowsExactly(() => SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('x', $c);", ("$c", color))); } [TestMethod] - public void CardRequiresExistingColumn() - { + public void CardRequiresExistingColumn() { using var db = new FreshDatabase(); // foreign_keys=ON makes the FK enforceable; this is the DDL contract behind the cascades. Assert.ThrowsExactly(() => @@ -141,8 +127,7 @@ public class WorkspaceSchemaTests } [TestMethod] - public void CardDefaultsToEmptyTitleAndContent() - { + public void CardDefaultsToEmptyTitleAndContent() { using var db = new FreshDatabase(); SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('c', 1, 1);"); SqliteTestHelper.Exec(db.Connection, "INSERT INTO cards (column_id, created_at, updated_at) VALUES (1, 1, 1);"); @@ -153,8 +138,7 @@ public class WorkspaceSchemaTests } [TestMethod] - public void CardTagDuplicateAssignmentIsRejected() - { + public void CardTagDuplicateAssignmentIsRejected() { using var db = new FreshDatabase(); SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('c', 1, 1);"); SqliteTestHelper.Exec(db.Connection, "INSERT INTO cards (column_id, created_at, updated_at) VALUES (1, 1, 1);"); @@ -167,8 +151,7 @@ public class WorkspaceSchemaTests } [TestMethod] - public void DeletingColumnCascadesToCardsAndCardTags() - { + public void DeletingColumnCascadesToCardsAndCardTags() { using var db = new FreshDatabase(); InsertBoardRow(db.Connection); @@ -182,8 +165,7 @@ public class WorkspaceSchemaTests } [TestMethod] - public void DeletingCardCascadesToCardTagsOnly() - { + public void DeletingCardCascadesToCardTagsOnly() { using var db = new FreshDatabase(); InsertBoardRow(db.Connection); @@ -196,8 +178,7 @@ public class WorkspaceSchemaTests } [TestMethod] - public void DeletingTagRemovesAssignmentsButKeepsCards() - { + public void DeletingTagRemovesAssignmentsButKeepsCards() { using var db = new FreshDatabase(); InsertBoardRow(db.Connection); @@ -214,8 +195,7 @@ public class WorkspaceSchemaTests /// Inserts one column, one card, one tag and their assignment, ready for a cascade test. /// /// An open workspace-schema connection. - private static void InsertBoardRow(SqliteConnection connection) - { + private static void InsertBoardRow(SqliteConnection connection) { SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('c', 1, 1);"); SqliteTestHelper.Exec(connection, "INSERT INTO cards (column_id, created_at, updated_at) VALUES (1, 1, 1);"); SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color) VALUES ('bug', '#112233');"); diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceSessionTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceSessionTests.cs index 7bade97..e24c618 100644 --- a/YKanBan.Tests/Storage/Workspace/WorkspaceSessionTests.cs +++ b/YKanBan.Tests/Storage/Workspace/WorkspaceSessionTests.cs @@ -10,11 +10,9 @@ namespace YKanBan.Tests.Storage.Workspace; /// the whole session and the repository is usable through it. /// [TestClass] -public class WorkspaceSessionTests -{ +public class WorkspaceSessionTests { [TestMethod] - public void OpenHoldsLockUntilDisposed() - { + public void OpenHoldsLockUntilDisposed() { using var directory = new TempDirectory(); WorkspaceInitializer.Initialize(directory.FullPath); @@ -25,8 +23,7 @@ public class WorkspaceSessionTests } [TestMethod] - public void DisposeReleasesLockForReopen() - { + public void DisposeReleasesLockForReopen() { using var directory = new TempDirectory(); WorkspaceInitializer.Initialize(directory.FullPath); @@ -37,8 +34,7 @@ public class WorkspaceSessionTests } [TestMethod] - public void OpenUninitializedFolderThrowsNotInitialized() - { + public void OpenUninitializedFolderThrowsNotInitialized() { using var directory = new TempDirectory(); Assert.ThrowsExactly( @@ -46,8 +42,7 @@ public class WorkspaceSessionTests } [TestMethod] - public void RepositoryIsUsableThroughSession() - { + public void RepositoryIsUsableThroughSession() { using var directory = new TempDirectory(); WorkspaceInitializer.Initialize(directory.FullPath); diff --git a/YKanBan.Tests/TestUtilities/SqliteTestHelper.cs b/YKanBan.Tests/TestUtilities/SqliteTestHelper.cs index 72db4dc..3cef2e8 100644 --- a/YKanBan.Tests/TestUtilities/SqliteTestHelper.cs +++ b/YKanBan.Tests/TestUtilities/SqliteTestHelper.cs @@ -1,4 +1,5 @@ using Microsoft.Data.Sqlite; + using YKanBan.Storage; using YKanBan.Storage.Workspace; @@ -8,8 +9,7 @@ namespace YKanBan.Tests.TestUtilities; /// Shared helpers for the storage tests: small SQL conveniences plus opening a /// workspace database with the real migration list. /// -public static class SqliteTestHelper -{ +public static class SqliteTestHelper { /// /// Executes a non-query SQL statement and returns the affected row count. /// @@ -17,12 +17,10 @@ public static class SqliteTestHelper /// The statement to execute. /// Name/value pairs bound as SQL parameters. /// The number of affected rows. - public static int Exec(SqliteConnection connection, string sql, params (string Name, object? Value)[] parameters) - { + public static int Exec(SqliteConnection connection, string sql, params (string Name, object? Value)[] parameters) { using var command = connection.CreateCommand(); command.CommandText = sql; - foreach ((string name, object? value) in parameters) - { + foreach ((string name, object? value) in parameters) { command.Parameters.AddWithValue(name, value); } return command.ExecuteNonQuery(); @@ -34,8 +32,7 @@ public static class SqliteTestHelper /// An open database connection. /// The query to execute. /// The scalar result cast to a string. - public static string ScalarString(SqliteConnection connection, string sql) - { + public static string ScalarString(SqliteConnection connection, string sql) { using var command = connection.CreateCommand(); command.CommandText = sql; return (string)command.ExecuteScalar()!; @@ -47,8 +44,7 @@ public static class SqliteTestHelper /// An open database connection. /// The query to execute. /// The scalar result cast to a long. - public static long ScalarLong(SqliteConnection connection, string sql) - { + public static long ScalarLong(SqliteConnection connection, string sql) { using var command = connection.CreateCommand(); command.CommandText = sql; return (long)command.ExecuteScalar()!; @@ -60,8 +56,7 @@ public static class SqliteTestHelper /// An open database connection. /// The table name to look for. /// when the table exists. - public static bool TableExists(SqliteConnection connection, string tableName) - { + public static bool TableExists(SqliteConnection connection, string tableName) { using var command = connection.CreateCommand(); command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=$name;"; command.Parameters.AddWithValue("$name", tableName); @@ -73,15 +68,13 @@ public static class SqliteTestHelper /// /// An open database connection. /// The column titles in id order. - public static List GetColumnTitles(SqliteConnection connection) - { + public static List GetColumnTitles(SqliteConnection connection) { using var command = connection.CreateCommand(); command.CommandText = "SELECT title FROM columns ORDER BY id ASC;"; using var reader = command.ExecuteReader(); var titles = new List(); - while (reader.Read()) - { + while (reader.Read()) { titles.Add(reader.GetString(0)); } return titles; @@ -92,15 +85,13 @@ public static class SqliteTestHelper /// /// An open database connection. /// The column descriptions in id order. - public static List GetColumnDescriptions(SqliteConnection connection) - { + public static List GetColumnDescriptions(SqliteConnection connection) { using var command = connection.CreateCommand(); command.CommandText = "SELECT description FROM columns ORDER BY id ASC;"; using var reader = command.ExecuteReader(); var descriptions = new List(); - while (reader.Read()) - { + while (reader.Read()) { descriptions.Add(reader.GetString(0)); } return descriptions; @@ -111,15 +102,13 @@ public static class SqliteTestHelper /// /// An open database connection. /// The tags in id order. - public static List<(string Name, string Color, string Description)> GetTags(SqliteConnection connection) - { + public static List<(string Name, string Color, string Description)> GetTags(SqliteConnection connection) { using var command = connection.CreateCommand(); command.CommandText = "SELECT name, color, description FROM tags ORDER BY id ASC;"; using var reader = command.ExecuteReader(); var tags = new List<(string, string, string)>(); - while (reader.Read()) - { + while (reader.Read()) { tags.Add((reader.GetString(0), reader.GetString(1), reader.GetString(2))); } return tags; @@ -130,8 +119,7 @@ public static class SqliteTestHelper /// /// The workspace folder path. /// An open, migrated connection. - public static SqliteConnection OpenWorkspace(string folderPath) - { + public static SqliteConnection OpenWorkspace(string folderPath) { return SqliteDatabase.Open(WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations); } } diff --git a/YKanBan.Tests/TestUtilities/TempDirectory.cs b/YKanBan.Tests/TestUtilities/TempDirectory.cs index fe5b79c..6971a83 100644 --- a/YKanBan.Tests/TestUtilities/TempDirectory.cs +++ b/YKanBan.Tests/TestUtilities/TempDirectory.cs @@ -5,15 +5,13 @@ namespace YKanBan.Tests.TestUtilities; /// disposal. The removal is retried once because SQLite WAL sidecar files can /// linger for a moment after the connections close. /// -public sealed class TempDirectory : IDisposable -{ +public sealed class TempDirectory : IDisposable { /// /// Gets the unique directory path managed by this instance. /// public string FullPath { get; } - public TempDirectory() - { + public TempDirectory() { FullPath = Path.Combine(Path.GetTempPath(), "ykanban-tests-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(FullPath); } @@ -21,26 +19,19 @@ public sealed class TempDirectory : IDisposable /// /// Deletes the directory tree, retrying once after a short delay. /// - public void Dispose() - { - try - { + public void Dispose() { + try { // SAFETY: Recursive directory delete is safe: FullPath is the // read-only, GUID-named directory this instance created under the // OS temp folder, so it can never target user data or the temp root. Directory.Delete(FullPath, recursive: true); - } - catch (IOException) - { + } catch (IOException) { Thread.Sleep(100); - try - { + try { // SAFETY: Retry of the same recursive delete, still confined to // this instance's own temp directory. Directory.Delete(FullPath, recursive: true); - } - catch (IOException) - { + } catch (IOException) { // Leftovers in the OS temp folder are harmless; never fail a test over cleanup. } } diff --git a/YKanBan/App.axaml.cs b/YKanBan/App.axaml.cs index 1977aec..8bffc75 100644 --- a/YKanBan/App.axaml.cs +++ b/YKanBan/App.axaml.cs @@ -1,20 +1,22 @@ using System.Globalization; + using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Data.Core.Plugins; using Avalonia.Markup.Xaml; using Avalonia.Media; using Avalonia.Styling; + using YKanBan.Services; using YKanBan.Storage.AppData; using YKanBan.ViewModels; using YKanBan.Views; + using Resx = YKanBan.Resources; namespace YKanBan; -public partial class App : Application -{ +public partial class App : Application { /// /// Gets the process-wide composition root, created at startup. /// @@ -26,15 +28,12 @@ public partial class App : Application /// public static string StartupLanguage { get; private set; } = AppConfig.DefaultLanguage; - public override void Initialize() - { + public override void Initialize() { AvaloniaXamlLoader.Load(this); } - public override void OnFrameworkInitializationCompleted() - { - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { + public override void OnFrameworkInitializationCompleted() { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { DisableAvaloniaDataAnnotationValidation(); // Startup order: apply the configured culture, its RTL flow direction @@ -46,8 +45,7 @@ public partial class App : Application ApplyTheme(Services.Config.Theme); var viewModel = new MainWindowViewModel(Services, desktop.Args ?? []); - desktop.MainWindow = new MainWindow - { + desktop.MainWindow = new MainWindow { DataContext = viewModel, // RTL detection drives the whole window flow direction; the two @@ -67,14 +65,10 @@ public partial class App : Application /// /// The language code from app.json. /// The resolved culture. - public static CultureInfo ResolveCulture(string language) - { - try - { + public static CultureInfo ResolveCulture(string language) { + try { return new CultureInfo(language); - } - catch (CultureNotFoundException) - { + } catch (CultureNotFoundException) { return new CultureInfo(AppConfig.DefaultLanguage); } } @@ -84,28 +78,23 @@ public partial class App : Application /// inherits the operating system preference. /// /// The theme selection. - public static void ApplyTheme(ThemeOption option) - { - if (Current is null) - { + public static void ApplyTheme(ThemeOption option) { + if (Current is null) { return; } - Current.RequestedThemeVariant = option switch - { + Current.RequestedThemeVariant = option switch { ThemeOption.Light => ThemeVariant.Light, ThemeOption.Dark => ThemeVariant.Dark, _ => ThemeVariant.Default, }; } - private static void DisableAvaloniaDataAnnotationValidation() - { + private static void DisableAvaloniaDataAnnotationValidation() { var dataValidationPluginsToRemove = BindingPlugins.DataValidators.OfType().ToArray(); - foreach (var plugin in dataValidationPluginsToRemove) - { + foreach (var plugin in dataValidationPluginsToRemove) { BindingPlugins.DataValidators.Remove(plugin); } } diff --git a/YKanBan/Export/MarkdownExporter.cs b/YKanBan/Export/MarkdownExporter.cs index e126545..5576cb0 100644 --- a/YKanBan/Export/MarkdownExporter.cs +++ b/YKanBan/Export/MarkdownExporter.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Text; + using YKanBan.Models; namespace YKanBan.Export; @@ -11,8 +12,7 @@ namespace YKanBan.Export; /// timestamps use the shared minute-precision pattern. The output is meant for /// reading only — there is no re-import. /// -public static class MarkdownExporter -{ +public static class MarkdownExporter { /// /// Renders the whole board to Markdown. /// @@ -27,8 +27,7 @@ public static class MarkdownExporter IReadOnlyList columns, IReadOnlyList cards, IReadOnlyList<(TagModel Tag, long UsageCount)> tags, - DateTimeOffset exportedAt) - { + DateTimeOffset exportedAt) { CultureInfo culture = Resources.Culture ?? CultureInfo.CurrentUICulture; var builder = new StringBuilder(); @@ -40,18 +39,15 @@ public static class MarkdownExporter builder.AppendLine(); // Board body: every column in id order, empty ones included. - foreach (ColumnModel column in columns) - { + foreach (ColumnModel column in columns) { IReadOnlyList cardsInColumn = cards.Where(card => card.ColumnId == column.Id).ToArray(); builder.AppendLine(string.Format(culture, Resources.Export_ColumnHeading, column.Title, cardsInColumn.Count)); - if (column.Description.Length > 0) - { + if (column.Description.Length > 0) { builder.AppendLine(SingleLine(column.Description)); } builder.AppendLine(); - foreach (CardModel card in cardsInColumn) - { + foreach (CardModel card in cardsInColumn) { AppendCard(builder, card, culture); } } @@ -67,8 +63,7 @@ public static class MarkdownExporter /// Workspace display name. /// Export moment. /// The file name. - public static string BuildFileName(string workspaceName, DateTimeOffset exportedAt) - { + public static string BuildFileName(string workspaceName, DateTimeOffset exportedAt) { CultureInfo culture = Resources.Culture ?? CultureInfo.CurrentUICulture; return string.Format(culture, Resources.Export_FileNamePattern, workspaceName, exportedAt); } @@ -79,20 +74,17 @@ public static class MarkdownExporter /// The output builder. /// The card to render. /// The UI culture. - private static void AppendCard(StringBuilder builder, CardModel card, CultureInfo culture) - { + private static void AppendCard(StringBuilder builder, CardModel card, CultureInfo culture) { // Heading: "#id title"; an untitled card shows only the id. builder.AppendLine(card.Title.Length == 0 ? $"### #{card.Id}" : $"### #{card.Id} {SingleLine(card.Title)}"); - if (card.Content.Length > 0) - { + if (card.Content.Length > 0) { builder.AppendLine(card.Content); } - if (card.Tags.Count > 0) - { + if (card.Tags.Count > 0) { string names = string.Join(" ", card.Tags.Select(tag => $"`{tag.Name}`")); builder.AppendLine(string.Format(culture, Resources.Export_TagsLine, names)); } @@ -112,13 +104,11 @@ public static class MarkdownExporter private static void AppendTagTable( StringBuilder builder, IReadOnlyList<(TagModel Tag, long UsageCount)> tags, - CultureInfo culture) - { + CultureInfo culture) { builder.AppendLine(Resources.Export_TagsSummaryHeading); builder.AppendLine(Resources.Export_TagsTableHeader); builder.AppendLine("|---|---|---|---|"); - foreach ((TagModel tag, long usage) in tags) - { + foreach ((TagModel tag, long usage) in tags) { // '|' and line breaks would break the table row; neutralize them. builder.AppendLine($"| {Cell(tag.Name)} | {tag.Color} | {Cell(tag.Description)} | {usage} |"); } diff --git a/YKanBan/Launching/LaunchArguments.cs b/YKanBan/Launching/LaunchArguments.cs index e96d3e6..488d96c 100644 --- a/YKanBan/Launching/LaunchArguments.cs +++ b/YKanBan/Launching/LaunchArguments.cs @@ -8,8 +8,7 @@ namespace YKanBan.Launching; /// more than one argument, or a blank argument is rejected as an argument /// error. The path itself is not checked for existence here. /// -public static class LaunchArguments -{ +public static class LaunchArguments { /// /// Tries to parse command-line arguments following the /// ykanban <path> contract. @@ -17,10 +16,8 @@ public static class LaunchArguments /// The raw arguments, excluding the executable name. /// The parsed workspace path on success, otherwise . /// when exactly one non-blank path was supplied. - public static bool TryParse(IReadOnlyList arguments, [NotNullWhen(true)] out string? folderPath) - { - if (arguments.Count == 1 && !string.IsNullOrWhiteSpace(arguments[0])) - { + public static bool TryParse(IReadOnlyList arguments, [NotNullWhen(true)] out string? folderPath) { + if (arguments.Count == 1 && !string.IsNullOrWhiteSpace(arguments[0])) { folderPath = arguments[0]; return true; } diff --git a/YKanBan/Models/RgbColor.cs b/YKanBan/Models/RgbColor.cs index b070fc3..e3ec758 100644 --- a/YKanBan/Models/RgbColor.cs +++ b/YKanBan/Models/RgbColor.cs @@ -10,8 +10,7 @@ namespace YKanBan.Models; /// always produces the canonical lowercase form (for example "#ff0a00"). It is /// not specific to tags and may be reused anywhere a color value is needed. /// -public readonly record struct RgbColor(byte R, byte G, byte B) -{ +public readonly record struct RgbColor(byte R, byte G, byte B) { /// /// Parses a "#RRGGBB" text with case-insensitive hex digits. /// @@ -29,17 +28,14 @@ public readonly record struct RgbColor(byte R, byte G, byte B) /// The text to parse. /// The parsed color on success, otherwise the default value. /// when the text is exactly #RRGGBB. - public static bool TryParse(string? text, out RgbColor color) - { + public static bool TryParse(string? text, out RgbColor color) { color = default; - if (text is not { Length: 7 } || text[0] != '#') - { + if (text is not { Length: 7 } || text[0] != '#') { return false; } // Two hex digits per component: R, G, B. - if (TryHexPair(text, 1, out byte r) && TryHexPair(text, 3, out byte g) && TryHexPair(text, 5, out byte b)) - { + if (TryHexPair(text, 1, out byte r) && TryHexPair(text, 3, out byte g) && TryHexPair(text, 5, out byte b)) { color = new RgbColor(r, g, b); return true; } @@ -59,13 +55,11 @@ public readonly record struct RgbColor(byte R, byte G, byte B) /// Index of the first of the two digits. /// The parsed byte value. /// when both characters are hex digits. - private static bool TryHexPair(string text, int start, out byte value) - { + private static bool TryHexPair(string text, int start, out byte value) { value = 0; int high = HexDigit(text[start]); int low = HexDigit(text[start + 1]); - if (high < 0 || low < 0) - { + if (high < 0 || low < 0) { return false; } value = (byte)((high << 4) | low); @@ -77,8 +71,7 @@ public readonly record struct RgbColor(byte R, byte G, byte B) /// /// The character to convert. /// The digit value, or -1 for any other character. - private static int HexDigit(char character) => character switch - { + private static int HexDigit(char character) => character switch { >= '0' and <= '9' => character - '0', >= 'a' and <= 'f' => character - 'a' + 10, >= 'A' and <= 'F' => character - 'A' + 10, diff --git a/YKanBan/Program.cs b/YKanBan/Program.cs index 8a1170b..e606f36 100644 --- a/YKanBan/Program.cs +++ b/YKanBan/Program.cs @@ -2,14 +2,12 @@ namespace YKanBan; -sealed class Program -{ +sealed class Program { // Initialization code. Don't use any Avalonia, third-party APIs or any // SynchronizationContext-reliant code before AppMain is called: things aren't initialized // yet and stuff might break. [STAThread] - public static void Main(string[] args) - { + public static void Main(string[] args) { BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); // The lifetime has ended: persist the configuration once, then release services. diff --git a/YKanBan/Search/SearchLexer.cs b/YKanBan/Search/SearchLexer.cs index 0da560b..a80d576 100644 --- a/YKanBan/Search/SearchLexer.cs +++ b/YKanBan/Search/SearchLexer.cs @@ -5,8 +5,7 @@ namespace YKanBan.Search; /// /// Lexical tokens of the search syntax. /// -public abstract record SearchToken -{ +public abstract record SearchToken { /// /// A bare word; qualifier detection happens later from the text. /// @@ -39,14 +38,17 @@ public abstract record SearchToken /// unterminated phrase is rejected. Any input not covered by the token rules /// is rejected rather than silently ignored. /// -public static class SearchLexer -{ - /// A valid phrase escape sequence: \" or \\. +public static class SearchLexer { + /// + /// A valid phrase escape sequence: \" or \\. + /// private static readonly Parser PhraseEscape = Parser.Try(Parser.String("\\\"").Select(_ => '"')) .Or(Parser.String("\\\\").Select(_ => '\\')); - /// One phrase character: an escape or any character except a quote or backslash. + /// + /// One phrase character: an escape or any character except a quote or backslash. + /// private static readonly Parser PhraseCharacter = PhraseEscape.Or(Parser.Token(character => character != '"' && character != '\\')); @@ -90,14 +92,10 @@ public static class SearchLexer /// The raw search text. /// The token stream. /// The text contains an invalid escape or an unterminated phrase. - public static IReadOnlyList Tokenize(string input) - { - try - { + public static IReadOnlyList Tokenize(string input) { + try { return Lexer.ParseOrThrow(input); - } - catch (ParseException exception) - { + } catch (ParseException exception) { throw new SearchSyntaxException(exception.Message, exception); } } diff --git a/YKanBan/Search/SearchNodes.cs b/YKanBan/Search/SearchNodes.cs index 5609615..eb56fbf 100644 --- a/YKanBan/Search/SearchNodes.cs +++ b/YKanBan/Search/SearchNodes.cs @@ -10,8 +10,7 @@ namespace YKanBan.Search; /// Root of the search query AST. Search keywords are fixed English grammar /// and are never localized. /// -public abstract record SearchNode -{ +public abstract record SearchNode { /// /// Disjunction: matches when any term matches (OR). /// @@ -59,11 +58,14 @@ public abstract record SearchNode /// /// The card fields a atom can scope to. /// -public enum SearchField -{ - /// The card title. +public enum SearchField { + /// + /// The card title. + /// Title, - /// The card content body. + /// + /// The card content body. + /// Content, } diff --git a/YKanBan/Search/SearchQueryParser.cs b/YKanBan/Search/SearchQueryParser.cs index 47b8054..d9efbc7 100644 --- a/YKanBan/Search/SearchQueryParser.cs +++ b/YKanBan/Search/SearchQueryParser.cs @@ -1,4 +1,5 @@ using System.Globalization; + using Pidgin; namespace YKanBan.Search; @@ -14,9 +15,10 @@ namespace YKanBan.Search; /// input returns null and means "show everything". Search keywords and /// qualifier keys are fixed English grammar and are never localized. /// -public static class SearchQueryParser -{ - /// The recognized qualifier keys; add new entries here to extend the grammar. +public static class SearchQueryParser { + /// + /// The recognized qualifier keys; add new entries here to extend the grammar. + /// private static readonly (string Key, QualifierKind Kind)[] QualifierTable = [ ("tag", QualifierKind.Tag), @@ -40,12 +42,16 @@ public static class SearchQueryParser private static readonly Parser CloseParen = Parser.Token(token => token is SearchToken.CloseParen); - /// The text of a phrase token. + /// + /// The text of a phrase token. + /// private static readonly Parser PhraseText = Parser.Token(token => token is SearchToken.Phrase) .Select(token => ((SearchToken.Phrase)token).Text); - /// The text of a word token, operator keywords excluded (they never start an operand). + /// + /// The text of a word token, operator keywords excluded (they never start an operand). + /// private static readonly Parser NonKeywordWord = Parser.Token(token => token is SearchToken.Word word && word.Text != "AND" && word.Text != "OR") .Select(token => ((SearchToken.Word)token).Text); @@ -90,7 +96,9 @@ public static class SearchQueryParser private static readonly Parser Query = OrExpr.Before(Parser.End); - /// Defers the OrExpr field read to parse time (recursion cycle breaker). + /// + /// Defers the OrExpr field read to parse time (recursion cycle breaker). + /// /// The OR-expression parser. private static Parser OrExprDeferred() => OrExpr; @@ -102,20 +110,15 @@ public static class SearchQueryParser /// The raw search text. /// The parsed query, or for blank input (meaning: show everything). /// The text violates the grammar. - public static SearchNode? Parse(string text) - { - if (string.IsNullOrWhiteSpace(text)) - { + public static SearchNode? Parse(string text) { + if (string.IsNullOrWhiteSpace(text)) { return null; } - try - { + try { List tokens = SearchLexer.Tokenize(text).ToList(); return Query.ParseOrThrow(tokens); - } - catch (ParseException exception) - { + } catch (ParseException exception) { throw new SearchSyntaxException(exception.Message, exception); } } @@ -128,21 +131,18 @@ public static class SearchQueryParser /// /// The word token text. /// The parser producing the operand node. - private static Parser QualifierFrom(string word) - { + private static Parser QualifierFrom(string word) { int colonIndex = word.IndexOf(':'); // Only a leading run of letters/digits/underscores before ':' can be a qualifier key. - if (colonIndex <= 0 || !IsQualifierKeyCandidate(word.AsSpan(0, colonIndex))) - { + if (colonIndex <= 0 || !IsQualifierKeyCandidate(word.AsSpan(0, colonIndex))) { return Parser.Return(new SearchNode.FreeText(word)); } string key = word[..colonIndex]; string value = word[(colonIndex + 1)..]; - if (!TryLookupQualifier(key, out QualifierKind kind)) - { + if (!TryLookupQualifier(key, out QualifierKind kind)) { return Parser.Fail($"Unknown qualifier key '{key}'."); } @@ -169,8 +169,7 @@ public static class SearchQueryParser /// The qualifier kind. /// The qualifier value. /// The atom node, or for an invalid id value. - private static SearchNode? AtomFrom(QualifierKind kind, string value) => kind switch - { + private static SearchNode? AtomFrom(QualifierKind kind, string value) => kind switch { QualifierKind.Tag => new SearchNode.TagName(value), QualifierKind.Title => new SearchNode.FieldText(SearchField.Title, value), QualifierKind.Content => new SearchNode.FieldText(SearchField.Content, value), @@ -186,12 +185,9 @@ public static class SearchQueryParser /// The key text before the colon. /// The matching qualifier kind. /// when the key is known. - private static bool TryLookupQualifier(string key, out QualifierKind kind) - { - foreach ((string candidate, QualifierKind candidateKind) in QualifierTable) - { - if (string.Equals(candidate, key, StringComparison.Ordinal)) - { + private static bool TryLookupQualifier(string key, out QualifierKind kind) { + foreach ((string candidate, QualifierKind candidateKind) in QualifierTable) { + if (string.Equals(candidate, key, StringComparison.Ordinal)) { kind = candidateKind; return true; } @@ -207,12 +203,9 @@ public static class SearchQueryParser /// /// The candidate key text. /// when every character is a word character. - private static bool IsQualifierKeyCandidate(ReadOnlySpan text) - { - foreach (char character in text) - { - if (!(char.IsLetterOrDigit(character) || character == '_')) - { + private static bool IsQualifierKeyCandidate(ReadOnlySpan text) { + foreach (char character in text) { + if (!(char.IsLetterOrDigit(character) || character == '_')) { return false; } } @@ -226,8 +219,7 @@ public static class SearchQueryParser /// The first operand. /// The remaining operands. /// The single operand, or an node. - private static SearchNode FoldAnd(SearchNode first, IEnumerable rest) - { + private static SearchNode FoldAnd(SearchNode first, IEnumerable rest) { List terms = [first, .. rest]; return terms.Count == 1 ? terms[0] : new SearchNode.And(terms); } @@ -237,17 +229,17 @@ public static class SearchQueryParser /// /// The operands. /// The single operand, or a node. - private static SearchNode FoldOr(IEnumerable terms) - { + private static SearchNode FoldOr(IEnumerable terms) { List list = terms.ToList(); return list.Count == 1 ? list[0] : new SearchNode.Or(list); } #endregion - /// The recognized qualifier kinds. - private enum QualifierKind - { + /// + /// The recognized qualifier kinds. + /// + private enum QualifierKind { Tag, Title, Content, diff --git a/YKanBan/Search/SearchSqlCompiler.cs b/YKanBan/Search/SearchSqlCompiler.cs index 95530fe..a9692cb 100644 --- a/YKanBan/Search/SearchSqlCompiler.cs +++ b/YKanBan/Search/SearchSqlCompiler.cs @@ -15,15 +15,13 @@ public sealed record CompiledSearch(string Predicate, IReadOnlyList<(string Name /// wildcards and the escape character in user text are escaped so they match /// literally. /// -public static class SearchSqlCompiler -{ +public static class SearchSqlCompiler { /// /// Compiles the query into a SQL predicate. /// /// The parsed search AST. /// The compiled predicate and its parameters. - public static CompiledSearch Compile(SearchNode query) - { + public static CompiledSearch Compile(SearchNode query) { var parameters = new List<(string Name, object Value)>(); string predicate = Build(query, parameters); return new CompiledSearch(predicate, parameters); @@ -35,49 +33,42 @@ public static class SearchSqlCompiler /// The AST node to compile. /// The parameter accumulator. /// The SQL boolean expression for the node. - private static string Build(SearchNode node, List<(string Name, object Value)> parameters) - { - switch (node) - { + private static string Build(SearchNode node, List<(string Name, object Value)> parameters) { + switch (node) { case SearchNode.And and: return "(" + string.Join(" AND ", and.Terms.Select(term => Build(term, parameters))) + ")"; case SearchNode.Or or: return "(" + string.Join(" OR ", or.Terms.Select(term => Build(term, parameters))) + ")"; - case SearchNode.FreeText freeText: - { - // A bare word matches a substring of the title OR the content. - string titleParam = AddParameter(parameters, "%" + EscapeLike(freeText.Text) + "%"); - string contentParam = AddParameter(parameters, "%" + EscapeLike(freeText.Text) + "%"); - return $"(c.title LIKE {titleParam} ESCAPE '\\' OR c.content LIKE {contentParam} ESCAPE '\\')"; - } + case SearchNode.FreeText freeText: { + // A bare word matches a substring of the title OR the content. + string titleParam = AddParameter(parameters, "%" + EscapeLike(freeText.Text) + "%"); + string contentParam = AddParameter(parameters, "%" + EscapeLike(freeText.Text) + "%"); + return $"(c.title LIKE {titleParam} ESCAPE '\\' OR c.content LIKE {contentParam} ESCAPE '\\')"; + } - case SearchNode.FieldText field: - { - string column = field.Field == SearchField.Title ? "c.title" : "c.content"; - string param = AddParameter(parameters, "%" + EscapeLike(field.Text) + "%"); - return $"{column} LIKE {param} ESCAPE '\\'"; - } + case SearchNode.FieldText field: { + string column = field.Field == SearchField.Title ? "c.title" : "c.content"; + string param = AddParameter(parameters, "%" + EscapeLike(field.Text) + "%"); + return $"{column} LIKE {param} ESCAPE '\\'"; + } - case SearchNode.TagName tag: - { - string param = AddParameter(parameters, tag.Name); - return "EXISTS (SELECT 1 FROM card_tags ct JOIN tags t ON t.id = ct.tag_id " + - $"WHERE ct.card_id = c.id AND t.name COLLATE NOCASE = {param})"; - } + case SearchNode.TagName tag: { + string param = AddParameter(parameters, tag.Name); + return "EXISTS (SELECT 1 FROM card_tags ct JOIN tags t ON t.id = ct.tag_id " + + $"WHERE ct.card_id = c.id AND t.name COLLATE NOCASE = {param})"; + } - case SearchNode.ColumnTitle column: - { - string param = AddParameter(parameters, column.Title); - return $"c.column_id IN (SELECT id FROM columns WHERE title COLLATE NOCASE = {param})"; - } + case SearchNode.ColumnTitle column: { + string param = AddParameter(parameters, column.Title); + return $"c.column_id IN (SELECT id FROM columns WHERE title COLLATE NOCASE = {param})"; + } - case SearchNode.CardId id: - { - string param = AddParameter(parameters, id.Id); - return $"c.id = {param}"; - } + case SearchNode.CardId id: { + string param = AddParameter(parameters, id.Id); + return $"c.id = {param}"; + } default: throw new InvalidOperationException($"Unsupported search node: {node.GetType().Name}"); @@ -90,8 +81,7 @@ public static class SearchSqlCompiler /// The parameter accumulator. /// The value to bind. /// The placeholder text. - private static string AddParameter(List<(string Name, object Value)> parameters, object value) - { + private static string AddParameter(List<(string Name, object Value)> parameters, object value) { string name = "$p" + parameters.Count.ToString(System.Globalization.CultureInfo.InvariantCulture); parameters.Add((name, value)); return name; diff --git a/YKanBan/Search/SearchSyntaxException.cs b/YKanBan/Search/SearchSyntaxException.cs index 483d89a..e27591c 100644 --- a/YKanBan/Search/SearchSyntaxException.cs +++ b/YKanBan/Search/SearchSyntaxException.cs @@ -6,15 +6,13 @@ namespace YKanBan.Search; /// search error instead of coupling to Pidgin types. The message is never /// localized. /// -public sealed class SearchSyntaxException : Exception -{ +public sealed class SearchSyntaxException : Exception { /// /// Initializes the exception with a descriptive English message. /// /// The English diagnostic message. /// The underlying parse failure. public SearchSyntaxException(string message, Exception innerException) - : base(message, innerException) - { + : base(message, innerException) { } } diff --git a/YKanBan/Services/AppServices.cs b/YKanBan/Services/AppServices.cs index ac6b593..e9c1bb2 100644 --- a/YKanBan/Services/AppServices.cs +++ b/YKanBan/Services/AppServices.cs @@ -8,13 +8,11 @@ namespace YKanBan.Services; /// the single open workspace session. The configuration is persisted once at /// shutdown; the session (lock plus connection) lives until disposal. /// -public sealed class AppServices : IDisposable -{ +public sealed class AppServices : IDisposable { /// /// Loads the configuration and prepares the service graph. /// - public AppServices() - { + public AppServices() { ConfigStore = new AppConfigStore(AppDataPaths.AppConfigJsonPath); Config = ConfigStore.Load(); } @@ -42,8 +40,7 @@ public sealed class AppServices : IDisposable /// The open session. /// The folder has no .ykanban structure. /// Another instance already holds the lock. - public WorkspaceSession OpenWorkspace(string folderPath) - { + public WorkspaceSession OpenWorkspace(string folderPath) { Session = WorkspaceSession.Open(folderPath); return Session; } diff --git a/YKanBan/Storage/AppData/AppConfig.cs b/YKanBan/Storage/AppData/AppConfig.cs index 347360f..e2226ca 100644 --- a/YKanBan/Storage/AppData/AppConfig.cs +++ b/YKanBan/Storage/AppData/AppConfig.cs @@ -1,4 +1,5 @@ using System.Runtime.Serialization; + using Newtonsoft.Json; namespace YKanBan.Storage.AppData; @@ -7,37 +8,52 @@ namespace YKanBan.Storage.AppData; /// Display ordering options, used for both cards and columns. Except for the id /// order, every option falls back to id ascending as the final tie-breaker. /// -public enum SortOption -{ - /// Primary key ascending. +public enum SortOption { + /// + /// Primary key ascending. + /// [EnumMember(Value = "id-asc")] IdAscending, - /// Primary key descending. + /// + /// Primary key descending. + /// [EnumMember(Value = "id-desc")] IdDescending, - /// Creation time ascending. + /// + /// Creation time ascending. + /// [EnumMember(Value = "created-asc")] CreatedAscending, - /// Creation time descending. + /// + /// Creation time descending. + /// [EnumMember(Value = "created-desc")] CreatedDescending, - /// Last modification time ascending. + /// + /// Last modification time ascending. + /// [EnumMember(Value = "updated-asc")] UpdatedAscending, - /// Last modification time descending. + /// + /// Last modification time descending. + /// [EnumMember(Value = "updated-desc")] UpdatedDescending, - /// Title by code points (BINARY collation) ascending. + /// + /// Title by code points (BINARY collation) ascending. + /// [EnumMember(Value = "title-asc")] TitleAscending, - /// Title by code points (BINARY collation) descending. + /// + /// Title by code points (BINARY collation) descending. + /// [EnumMember(Value = "title-desc")] TitleDescending, } @@ -45,21 +61,28 @@ public enum SortOption /// /// Column width presets. uses a user-defined pixel width. /// -public enum ColumnWidthPreset -{ - /// Narrow preset. +public enum ColumnWidthPreset { + /// + /// Narrow preset. + /// [EnumMember(Value = "narrow")] Narrow, - /// Standard preset (320 px, the default). + /// + /// Standard preset (320 px, the default). + /// [EnumMember(Value = "standard")] Standard, - /// Wide preset. + /// + /// Wide preset. + /// [EnumMember(Value = "wide")] Wide, - /// User-defined pixel width (160–720). + /// + /// User-defined pixel width (160–720). + /// [EnumMember(Value = "custom")] Custom, } @@ -67,17 +90,22 @@ public enum ColumnWidthPreset /// /// UI theme selection. /// -public enum ThemeOption -{ - /// Always light. +public enum ThemeOption { + /// + /// Always light. + /// [EnumMember(Value = "light")] Light, - /// Always dark. + /// + /// Always dark. + /// [EnumMember(Value = "dark")] Dark, - /// Follow the operating system setting (default). + /// + /// Follow the operating system setting (default). + /// [EnumMember(Value = "follow-system")] FollowSystem, } @@ -85,8 +113,7 @@ public enum ThemeOption /// /// Board column width settings, applied globally. /// -public sealed class ColumnWidthSettings -{ +public sealed class ColumnWidthSettings { /// /// Gets or sets the active width preset. /// @@ -105,8 +132,7 @@ public sealed class ColumnWidthSettings /// Everything that controls how the board is displayed: the column width and /// the card and column ordering. /// -public sealed class DisplaySettings -{ +public sealed class DisplaySettings { /// /// Gets or sets the board column width settings. /// @@ -129,8 +155,7 @@ public sealed class DisplaySettings /// /// Confirmation-dialog toggles for the destructive actions. /// -public sealed class ConfirmationSettings -{ +public sealed class ConfirmationSettings { /// /// Gets or sets whether deleting a card asks for confirmation. /// @@ -162,8 +187,7 @@ public sealed class ConfirmationSettings /// and ). /// The version field is the migration hook for future format changes. /// -public sealed class AppConfig -{ +public sealed class AppConfig { /// /// Current configuration format version. /// diff --git a/YKanBan/Storage/AppData/AppConfigStore.cs b/YKanBan/Storage/AppData/AppConfigStore.cs index 0ae201c..bc7e521 100644 --- a/YKanBan/Storage/AppData/AppConfigStore.cs +++ b/YKanBan/Storage/AppData/AppConfigStore.cs @@ -9,10 +9,8 @@ namespace YKanBan.Storage.AppData; /// used. Saving writes the file directly (no atomic replace) — losing the last /// session's settings to a crash is an accepted trade-off. /// -public sealed class AppConfigStore -{ - private static readonly JsonSerializerSettings SerializerSettings = new() - { +public sealed class AppConfigStore { + private static readonly JsonSerializerSettings SerializerSettings = new() { Formatting = Formatting.Indented, NullValueHandling = NullValueHandling.Ignore, @@ -37,21 +35,17 @@ public sealed class AppConfigStore /// /// The loaded configuration, or defaults when none could be read. /// The file exists but cannot be read. - public AppConfig Load() - { + public AppConfig Load() { // Missing file is a normal first-run state: run with defaults. - if (!File.Exists(FilePath)) - { + if (!File.Exists(FilePath)) { return new AppConfig(); } string json = File.ReadAllText(FilePath); - try - { + try { // A JSON null literal deserializes to null; treat it like defaults. AppConfig? config = JsonConvert.DeserializeObject(json, SerializerSettings); - if (config is null) - { + if (config is null) { return new AppConfig(); } @@ -59,9 +53,7 @@ public sealed class AppConfigStore config.Display ??= new DisplaySettings(); config.Confirmations ??= new ConfirmationSettings(); return config; - } - catch (JsonException) - { + } catch (JsonException) { // Corrupt: keep a copy for inspection, then continue with defaults. File.Copy(FilePath, FilePath + ".bak", overwrite: true); return new AppConfig(); @@ -72,12 +64,10 @@ public sealed class AppConfigStore /// Writes the configuration directly to disk (no atomic replace). /// /// The configuration to serialize. - public void Save(AppConfig config) - { + public void Save(AppConfig config) { // Create the parent folder on demand; normally it already exists. string? directory = Path.GetDirectoryName(FilePath); - if (!string.IsNullOrEmpty(directory)) - { + if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } diff --git a/YKanBan/Storage/AppData/AppDataPaths.cs b/YKanBan/Storage/AppData/AppDataPaths.cs index 40cdd1e..10fb4f6 100644 --- a/YKanBan/Storage/AppData/AppDataPaths.cs +++ b/YKanBan/Storage/AppData/AppDataPaths.cs @@ -5,8 +5,7 @@ namespace YKanBan.Storage.AppData; /// (%APPDATA%/YKanBan on Windows and the equivalent directory on other /// platforms). /// -public static class AppDataPaths -{ +public static class AppDataPaths { /// /// Gets the per-user YKanBan application data folder. /// diff --git a/YKanBan/Storage/SqliteDatabase.cs b/YKanBan/Storage/SqliteDatabase.cs index 0b5d973..7b2b9a9 100644 --- a/YKanBan/Storage/SqliteDatabase.cs +++ b/YKanBan/Storage/SqliteDatabase.cs @@ -7,8 +7,7 @@ namespace YKanBan.Storage; /// the mandated PRAGMA configuration (WAL journal, NORMAL synchronous mode, /// foreign keys on) and applies incremental user_version migrations. /// -public static class SqliteDatabase -{ +public static class SqliteDatabase { /// /// Opens (creating if needed) a database file, applies the mandatory /// PRAGMAs and runs any pending migrations. @@ -16,10 +15,8 @@ public static class SqliteDatabase /// Path of the SQLite database file. /// Contiguous migration list ordered by version, starting at 1. /// An open connection with all PRAGMAs applied and the schema migrated. - public static SqliteConnection Open(string databasePath, IReadOnlyList migrations) - { - var connection = new SqliteConnection(new SqliteConnectionStringBuilder - { + public static SqliteConnection Open(string databasePath, IReadOnlyList migrations) { + var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = databasePath, Mode = SqliteOpenMode.ReadWriteCreate, @@ -28,14 +25,11 @@ public static class SqliteDatabase }.ToString()); connection.Open(); - try - { + try { ApplyPragmas(connection); ApplyMigrations(connection, migrations); return connection; - } - catch - { + } catch { connection.Dispose(); throw; } @@ -46,8 +40,7 @@ public static class SqliteDatabase /// /// An open database connection. /// The stored schema version. - public static long ReadUserVersion(SqliteConnection connection) - { + public static long ReadUserVersion(SqliteConnection connection) { using var command = connection.CreateCommand(); command.CommandText = "PRAGMA user_version;"; return (long)command.ExecuteScalar()!; @@ -59,8 +52,7 @@ public static class SqliteDatabase /// enabled on every connection or the DDL cascades never fire. /// /// An open database connection. - internal static void ApplyPragmas(SqliteConnection connection) - { + internal static void ApplyPragmas(SqliteConnection connection) { // WAL plus NORMAL synchronous is the chosen durability/performance trade-off. Execute(connection, "PRAGMA journal_mode=WAL;"); Execute(connection, "PRAGMA synchronous=NORMAL;"); @@ -75,13 +67,10 @@ public static class SqliteDatabase /// Contiguous migration list ordered by version, starting at 1. /// The migration list is not contiguous from version 1. /// The database was written by a newer build. - internal static void ApplyMigrations(SqliteConnection connection, IReadOnlyList migrations) - { + internal static void ApplyMigrations(SqliteConnection connection, IReadOnlyList migrations) { // Contract check: the list must be ordered, contiguous and start at version 1. - for (int index = 0; index < migrations.Count; index++) - { - if (migrations[index].Version != index + 1) - { + for (int index = 0; index < migrations.Count; index++) { + if (migrations[index].Version != index + 1) { throw new ArgumentException("Migrations must be contiguous and start at version 1.", nameof(migrations)); } } @@ -90,16 +79,13 @@ public static class SqliteDatabase int latest = migrations.Count; // A database written by a newer build cannot be managed by this one; refuse loudly. - if (current > latest) - { + if (current > latest) { throw new SchemaVersionException(current, latest); } // Apply each pending migration atomically so a crash never leaves a half-migrated database. - foreach (SchemaMigration migration in migrations) - { - if (migration.Version <= current) - { + foreach (SchemaMigration migration in migrations) { + if (migration.Version <= current) { continue; } @@ -116,8 +102,7 @@ public static class SqliteDatabase /// An open database connection. /// The SQL script to execute. /// The transaction to enlist in, when any. - private static void Execute(SqliteConnection connection, string commandText, SqliteTransaction? transaction = null) - { + private static void Execute(SqliteConnection connection, string commandText, SqliteTransaction? transaction = null) { using var command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = commandText; diff --git a/YKanBan/Storage/StorageExceptions.cs b/YKanBan/Storage/StorageExceptions.cs index ce7bfb0..bd53e05 100644 --- a/YKanBan/Storage/StorageExceptions.cs +++ b/YKanBan/Storage/StorageExceptions.cs @@ -5,24 +5,21 @@ namespace YKanBan.Storage; /// Exception messages are always English and never localized; the UI maps the /// exception type to a localized resource key instead. /// -public abstract class YKanBanException : Exception -{ +public abstract class YKanBanException : Exception { /// /// Initializes the exception with an English diagnostic message. /// /// The English diagnostic message. /// The underlying cause, when any. protected YKanBanException(string message, Exception? innerException = null) - : base(message, innerException) - { + : base(message, innerException) { } } /// /// Signals that the folder passed on the command line does not exist on disk. /// -public sealed class WorkspaceDirectoryMissingException : YKanBanException -{ +public sealed class WorkspaceDirectoryMissingException : YKanBanException { /// /// Gets the folder path that was expected to exist. /// @@ -33,8 +30,7 @@ public sealed class WorkspaceDirectoryMissingException : YKanBanException /// /// The missing folder path. public WorkspaceDirectoryMissingException(string folderPath) - : base($"The folder does not exist: {folderPath}") - { + : base($"The folder does not exist: {folderPath}") { FolderPath = folderPath; } } @@ -43,8 +39,7 @@ public sealed class WorkspaceDirectoryMissingException : YKanBanException /// Signals that a folder exists but has no .ykanban structure, so it is not /// (yet) a workspace. /// -public sealed class WorkspaceNotInitializedException : YKanBanException -{ +public sealed class WorkspaceNotInitializedException : YKanBanException { /// /// Gets the folder path that lacks the .ykanban structure. /// @@ -55,8 +50,7 @@ public sealed class WorkspaceNotInitializedException : YKanBanException /// /// The uninitialized folder path. public WorkspaceNotInitializedException(string folderPath) - : base($"The folder is not a YKanBan workspace (missing .ykanban): {folderPath}") - { + : base($"The folder is not a YKanBan workspace (missing .ykanban): {folderPath}") { FolderPath = folderPath; } } @@ -65,8 +59,7 @@ public sealed class WorkspaceNotInitializedException : YKanBanException /// Signals that another instance already holds the workspace lock, so this /// process must not touch the workspace. /// -public sealed class WorkspaceLockException : YKanBanException -{ +public sealed class WorkspaceLockException : YKanBanException { /// /// Gets the lock file that could not be acquired. /// @@ -78,8 +71,7 @@ public sealed class WorkspaceLockException : YKanBanException /// The lock file that is already held. /// The underlying I/O error. public WorkspaceLockException(string lockFilePath, Exception innerException) - : base($"The workspace is locked by another instance: {lockFilePath}", innerException) - { + : base($"The workspace is locked by another instance: {lockFilePath}", innerException) { LockFilePath = lockFilePath; } } @@ -88,8 +80,7 @@ public sealed class WorkspaceLockException : YKanBanException /// Signals that the database was written by a newer build than the one /// currently running, so its schema cannot be safely managed. /// -public sealed class SchemaVersionException : YKanBanException -{ +public sealed class SchemaVersionException : YKanBanException { /// /// Gets the user_version value found in the database. /// @@ -106,8 +97,7 @@ public sealed class SchemaVersionException : YKanBanException /// The user_version read from the database. /// The newest schema version this build supports. public SchemaVersionException(long storedVersion, int supportedVersion) - : base($"Database schema version {storedVersion} is newer than the latest supported version {supportedVersion}.") - { + : base($"Database schema version {storedVersion} is newer than the latest supported version {supportedVersion}.") { StoredVersion = storedVersion; SupportedVersion = supportedVersion; } diff --git a/YKanBan/Storage/UnixTime.cs b/YKanBan/Storage/UnixTime.cs index da0e3f6..6becb12 100644 --- a/YKanBan/Storage/UnixTime.cs +++ b/YKanBan/Storage/UnixTime.cs @@ -4,8 +4,7 @@ namespace YKanBan.Storage; /// Supplies the current Unix timestamp. Every timestamp persisted by YKanBan /// is an INTEGER count of Unix seconds. /// -public static class UnixTime -{ +public static class UnixTime { /// /// Gets the current UTC time expressed as Unix seconds. /// diff --git a/YKanBan/Storage/Workspace/WorkspaceInitializer.cs b/YKanBan/Storage/Workspace/WorkspaceInitializer.cs index 25a5307..80534c4 100644 --- a/YKanBan/Storage/Workspace/WorkspaceInitializer.cs +++ b/YKanBan/Storage/Workspace/WorkspaceInitializer.cs @@ -8,8 +8,7 @@ namespace YKanBan.Storage.Workspace; /// separate concern handled by , so the /// database can be created without any rows. /// -public static class WorkspaceInitializer -{ +public static class WorkspaceInitializer { /// /// Returns whether the folder contains a .ykanban structure. Only the /// passed folder is checked; parent folders are never searched. @@ -25,16 +24,13 @@ public static class WorkspaceInitializer /// The existing folder to initialize. /// The folder does not exist on disk. /// The workspace database already exists. - public static void Initialize(string folderPath) - { - if (!Directory.Exists(folderPath)) - { + public static void Initialize(string folderPath) { + if (!Directory.Exists(folderPath)) { throw new WorkspaceDirectoryMissingException(folderPath); } string databasePath = WorkspacePaths.Database(folderPath); - if (File.Exists(databasePath)) - { + if (File.Exists(databasePath)) { throw new InvalidOperationException($"The workspace database already exists: {databasePath}"); } diff --git a/YKanBan/Storage/Workspace/WorkspaceLock.cs b/YKanBan/Storage/Workspace/WorkspaceLock.cs index 8ac3753..1f59ce6 100644 --- a/YKanBan/Storage/Workspace/WorkspaceLock.cs +++ b/YKanBan/Storage/Workspace/WorkspaceLock.cs @@ -5,8 +5,7 @@ namespace YKanBan.Storage.Workspace; /// held until disposal, so crashes and power loss release it automatically. The /// lock file is kept empty; only its exclusive OS handle carries meaning. /// -public sealed class WorkspaceLock : IDisposable -{ +public sealed class WorkspaceLock : IDisposable { private readonly FileStream _stream; /// @@ -14,8 +13,7 @@ public sealed class WorkspaceLock : IDisposable /// /// The exclusively opened lock file stream. /// Path of the lock file being held. - private WorkspaceLock(FileStream stream, string lockFilePath) - { + private WorkspaceLock(FileStream stream, string lockFilePath) { _stream = stream; LockFilePath = lockFilePath; } @@ -32,24 +30,18 @@ public sealed class WorkspaceLock : IDisposable /// The held lock; dispose it to release. /// The folder has no .ykanban structure. /// Another instance already holds the lock. - public static WorkspaceLock Acquire(string folderPath) - { + public static WorkspaceLock Acquire(string folderPath) { string lockFilePath = WorkspacePaths.LockFile(folderPath); FileStream stream; - try - { + try { // FileShare.None gives the exclusive semantics; FileMode.Create empties any stale file. // SAFETY: FileMode.Create truncates a pre-existing file, but the lock // file is empty by design and the path always comes from // WorkspacePaths.LockFile, so no user data is ever affected. stream = new FileStream(lockFilePath, FileMode.Create, FileAccess.Write, FileShare.None); - } - catch (DirectoryNotFoundException) - { + } catch (DirectoryNotFoundException) { throw new WorkspaceNotInitializedException(folderPath); - } - catch (IOException ex) - { + } catch (IOException ex) { throw new WorkspaceLockException(lockFilePath, ex); } diff --git a/YKanBan/Storage/Workspace/WorkspacePaths.cs b/YKanBan/Storage/Workspace/WorkspacePaths.cs index 97e364e..0bd00be 100644 --- a/YKanBan/Storage/Workspace/WorkspacePaths.cs +++ b/YKanBan/Storage/Workspace/WorkspacePaths.cs @@ -4,8 +4,7 @@ namespace YKanBan.Storage.Workspace; /// Well-known file and folder names inside a workspace's .ykanban structure, /// plus helpers that combine them with a workspace folder path. /// -public static class WorkspacePaths -{ +public static class WorkspacePaths { /// /// Name of the workspace root folder created inside a managed project. /// diff --git a/YKanBan/Storage/Workspace/WorkspacePreset.cs b/YKanBan/Storage/Workspace/WorkspacePreset.cs index 65c0a70..588eb90 100644 --- a/YKanBan/Storage/Workspace/WorkspacePreset.cs +++ b/YKanBan/Storage/Workspace/WorkspacePreset.cs @@ -8,16 +8,14 @@ namespace YKanBan.Storage.Workspace; /// language active when they are added; they then become ordinary data that no /// longer follows language switches. /// -public static class WorkspacePreset -{ +public static class WorkspacePreset { /// /// Inserts the three preset columns (the current language's equivalents of /// To Do / In Progress / Done) with their descriptions in a single transaction. /// /// The initialized workspace folder. /// The workspace database does not exist or a title collides with an existing column. - public static void AddPresetColumns(string folderPath) - { + public static void AddPresetColumns(string folderPath) { using SqliteConnection connection = SqliteDatabase.Open( WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations); @@ -33,8 +31,7 @@ public static class WorkspacePreset // Insert all three atomically so a partially populated workspace can never exist. using var transaction = connection.BeginTransaction(); - foreach ((string title, string description) in columns) - { + foreach ((string title, string description) in columns) { using var command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = """ @@ -55,8 +52,7 @@ public static class WorkspacePreset /// /// The initialized workspace folder. /// The workspace database does not exist or a tag name collides. - public static void AddPresetTags(string folderPath) - { + public static void AddPresetTags(string folderPath) { using SqliteConnection connection = SqliteDatabase.Open( WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations); @@ -74,8 +70,7 @@ public static class WorkspacePreset ]; using var transaction = connection.BeginTransaction(); - foreach ((string name, string color, string description) in tags) - { + foreach ((string name, string color, string description) in tags) { using var command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = """ diff --git a/YKanBan/Storage/Workspace/WorkspaceRepository.cs b/YKanBan/Storage/Workspace/WorkspaceRepository.cs index c33de3e..9ef12fe 100644 --- a/YKanBan/Storage/Workspace/WorkspaceRepository.cs +++ b/YKanBan/Storage/Workspace/WorkspaceRepository.cs @@ -1,4 +1,5 @@ using Microsoft.Data.Sqlite; + using YKanBan.Models; using YKanBan.Search; @@ -14,16 +15,14 @@ namespace YKanBan.Storage.Workspace; /// - columns.updated_at refreshes only when title or description changes; /// - a confirmation with no effective change performs no write at all. /// -public sealed class WorkspaceRepository : IDisposable -{ +public sealed class WorkspaceRepository : IDisposable { private readonly SqliteConnection _connection; /// /// Opens the repository over an initialized workspace database. /// /// The workspace folder. - public WorkspaceRepository(string folderPath) - { + public WorkspaceRepository(string folderPath) { _connection = SqliteDatabase.Open(WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations); } @@ -38,15 +37,13 @@ public sealed class WorkspaceRepository : IDisposable /// Returns all columns ordered by id ascending. /// /// All columns in id order. - public IReadOnlyList GetColumns() - { + public IReadOnlyList GetColumns() { using var command = CreateCommand( "SELECT id, title, description, created_at, updated_at FROM columns ORDER BY id ASC;"); using var reader = command.ExecuteReader(); var columns = new List(); - while (reader.Read()) - { + while (reader.Read()) { columns.Add(ReadColumn(reader)); } return columns; @@ -57,8 +54,7 @@ public sealed class WorkspaceRepository : IDisposable /// /// The column id. /// The column, or when it does not exist. - public ColumnModel? GetColumn(long columnId) - { + public ColumnModel? GetColumn(long columnId) { using var command = CreateCommand( "SELECT id, title, description, created_at, updated_at FROM columns WHERE id = $id;"); command.Parameters.AddWithValue("$id", columnId); @@ -73,8 +69,7 @@ public sealed class WorkspaceRepository : IDisposable /// The optional description. /// The created column. /// The title is empty or not unique. - public ColumnModel AddColumn(string title, string description) - { + public ColumnModel AddColumn(string title, string description) { long now = UnixTime.Now; using var transaction = _connection.BeginTransaction(); long id = ExecuteInsert(transaction, @@ -95,14 +90,12 @@ public sealed class WorkspaceRepository : IDisposable /// The updated column, or the unchanged one for a no-op. /// The column does not exist. /// The new title is empty or not unique. - public ColumnModel UpdateColumn(long columnId, string title, string description) - { + public ColumnModel UpdateColumn(long columnId, string title, string description) { ColumnModel existing = GetColumn(columnId) ?? throw new InvalidOperationException($"Column {columnId} does not exist."); // Unchanged title and description: no write, no timestamp refresh. - if (existing.Title == title && existing.Description == description) - { + if (existing.Title == title && existing.Description == description) { return existing; } @@ -133,8 +126,7 @@ public sealed class WorkspaceRepository : IDisposable /// Returns all cards with their assigned tags loaded, ordered by id ascending. /// /// All cards in id order. - public IReadOnlyList GetCards() - { + public IReadOnlyList GetCards() { using var command = CreateCommand(""" SELECT c.id, c.column_id, c.title, c.content, c.created_at, c.updated_at, t.id, t.name, t.color, t.description @@ -148,11 +140,9 @@ public sealed class WorkspaceRepository : IDisposable // Single grouped pass: one output card per distinct card id, with its tags accumulated alongside. var cards = new List(); var tagsByCard = new Dictionary>(); - while (reader.Read()) - { + while (reader.Read()) { long cardId = reader.GetInt64(0); - if (!tagsByCard.TryGetValue(cardId, out List? tags)) - { + if (!tagsByCard.TryGetValue(cardId, out List? tags)) { tags = new List(); tagsByCard[cardId] = tags; cards.Add(new CardModel( @@ -165,8 +155,7 @@ public sealed class WorkspaceRepository : IDisposable tags)); } - if (!reader.IsDBNull(6)) - { + if (!reader.IsDBNull(6)) { tags.Add(ReadTag(reader, 6)); } } @@ -178,18 +167,15 @@ public sealed class WorkspaceRepository : IDisposable /// /// The compiled search predicate and its parameters. /// The matching card ids. - public IReadOnlyCollection GetCardIdsMatching(CompiledSearch search) - { + public IReadOnlyCollection GetCardIdsMatching(CompiledSearch search) { using var command = CreateCommand($"SELECT c.id FROM cards c WHERE {search.Predicate};"); - foreach ((string name, object value) in search.Parameters) - { + foreach ((string name, object value) in search.Parameters) { command.Parameters.AddWithValue(name, value); } using var reader = command.ExecuteReader(); var ids = new HashSet(); - while (reader.Read()) - { + while (reader.Read()) { ids.Add(reader.GetInt64(0)); } return ids; @@ -200,8 +186,7 @@ public sealed class WorkspaceRepository : IDisposable /// /// The card id. /// The card, or when it does not exist. - public CardModel? GetCard(long cardId) - { + public CardModel? GetCard(long cardId) { using var command = CreateCommand(""" SELECT c.id, c.column_id, c.title, c.content, c.created_at, c.updated_at, t.id, t.name, t.color, t.description @@ -217,8 +202,7 @@ public sealed class WorkspaceRepository : IDisposable // The LEFT JOIN may repeat the card row once per tag; build it on the first row only. CardModel? card = null; var tags = new List(); - while (reader.Read()) - { + while (reader.Read()) { card ??= new CardModel( reader.GetInt64(0), reader.GetInt64(1), @@ -228,8 +212,7 @@ public sealed class WorkspaceRepository : IDisposable reader.GetInt64(5), tags); - if (!reader.IsDBNull(6)) - { + if (!reader.IsDBNull(6)) { tags.Add(ReadTag(reader, 6)); } } @@ -245,15 +228,13 @@ public sealed class WorkspaceRepository : IDisposable /// The tags to assign initially. /// The created card with tags loaded. /// The column or a tag id does not exist. - public CardModel AddCard(long columnId, string title, string content, IReadOnlyCollection tagIds) - { + public CardModel AddCard(long columnId, string title, string content, IReadOnlyCollection tagIds) { long now = UnixTime.Now; using var transaction = _connection.BeginTransaction(); long id = ExecuteInsert(transaction, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES ($columnId, $title, $content, $now, $now);", ("$columnId", columnId), ("$title", title), ("$content", content), ("$now", now)); - foreach (long tagId in tagIds.Distinct()) - { + foreach (long tagId in tagIds.Distinct()) { ExecuteNonQuery(transaction, "INSERT INTO card_tags (card_id, tag_id) VALUES ($cardId, $tagId);", ("$cardId", id), ("$tagId", tagId)); @@ -276,15 +257,13 @@ public sealed class WorkspaceRepository : IDisposable /// The updated card with tags loaded, or the unchanged one for a no-op. /// The card does not exist. /// A tag id does not exist; nothing is written. - public CardModel UpdateCard(long cardId, string title, string content, IReadOnlyCollection tagIds) - { + public CardModel UpdateCard(long cardId, string title, string content, IReadOnlyCollection tagIds) { CardModel existing = GetCard(cardId) ?? throw new InvalidOperationException($"Card {cardId} does not exist."); // No effective change in title, content or tag set: no write, no timestamp refresh. bool tagsChanged = !tagIds.Distinct().ToHashSet().SetEquals(existing.Tags.Select(tag => tag.Id)); - if (existing.Title == title && existing.Content == content && !tagsChanged) - { + if (existing.Title == title && existing.Content == content && !tagsChanged) { return existing; } @@ -297,8 +276,7 @@ public sealed class WorkspaceRepository : IDisposable // Tag assignment is rewritten wholesale; card_tags carries nothing beyond the pair. ExecuteNonQuery(transaction, "DELETE FROM card_tags WHERE card_id = $id;", ("$id", cardId)); - foreach (long tagId in tagIds.Distinct()) - { + foreach (long tagId in tagIds.Distinct()) { ExecuteNonQuery(transaction, "INSERT INTO card_tags (card_id, tag_id) VALUES ($cardId, $tagId);", ("$cardId", cardId), ("$tagId", tagId)); @@ -316,8 +294,7 @@ public sealed class WorkspaceRepository : IDisposable /// The card to move. /// The destination column. /// when the card actually moved. - public bool MoveCard(long cardId, long targetColumnId) - { + public bool MoveCard(long cardId, long targetColumnId) { using var command = CreateCommand( "UPDATE cards SET column_id = $columnId WHERE id = $id AND column_id <> $columnId;"); command.Parameters.AddWithValue("$columnId", targetColumnId); @@ -340,15 +317,13 @@ public sealed class WorkspaceRepository : IDisposable /// Returns all tags ordered by id ascending; unreferenced tags are included. /// /// All tags in id order. - public IReadOnlyList GetTags() - { + public IReadOnlyList GetTags() { using var command = CreateCommand( "SELECT id, name, color, description FROM tags ORDER BY id ASC;"); using var reader = command.ExecuteReader(); var tags = new List(); - while (reader.Read()) - { + while (reader.Read()) { tags.Add(ReadTag(reader, 0)); } return tags; @@ -358,8 +333,7 @@ public sealed class WorkspaceRepository : IDisposable /// Returns every tag together with the number of cards using it, ordered by tag id. /// /// The tag/usage pairs in tag id order. - public IReadOnlyList<(TagModel Tag, long UsageCount)> GetTagsWithUsage() - { + public IReadOnlyList<(TagModel Tag, long UsageCount)> GetTagsWithUsage() { using var command = CreateCommand(""" SELECT t.id, t.name, t.color, t.description, COUNT(ct.card_id) FROM tags t @@ -370,8 +344,7 @@ public sealed class WorkspaceRepository : IDisposable using var reader = command.ExecuteReader(); var tags = new List<(TagModel Tag, long UsageCount)>(); - while (reader.Read()) - { + while (reader.Read()) { tags.Add((ReadTag(reader, 0), reader.GetInt64(4))); } return tags; @@ -385,8 +358,7 @@ public sealed class WorkspaceRepository : IDisposable /// The optional description. /// The created tag. /// The name is empty or not unique. - public TagModel AddTag(string name, RgbColor color, string description) - { + public TagModel AddTag(string name, RgbColor color, string description) { using var transaction = _connection.BeginTransaction(); long id = ExecuteInsert(transaction, "INSERT INTO tags (name, color, description) VALUES ($name, $color, $description);", @@ -406,14 +378,12 @@ public sealed class WorkspaceRepository : IDisposable /// The updated tag, or the unchanged one for a no-op. /// The tag does not exist. /// The new name is empty or not unique. - public TagModel UpdateTag(long tagId, string name, RgbColor color, string description) - { + public TagModel UpdateTag(long tagId, string name, RgbColor color, string description) { TagModel existing = GetTag(tagId) ?? throw new InvalidOperationException($"Tag {tagId} does not exist."); // Unchanged name, color and description: no write. - if (existing.Name == name && existing.Color == color && existing.Description == description) - { + if (existing.Name == name && existing.Color == color && existing.Description == description) { return existing; } @@ -444,8 +414,7 @@ public sealed class WorkspaceRepository : IDisposable /// /// The tag id. /// The tag, or when it does not exist. - private TagModel? GetTag(long tagId) - { + private TagModel? GetTag(long tagId) { using var command = CreateCommand( "SELECT id, name, color, description FROM tags WHERE id = $id;"); command.Parameters.AddWithValue("$id", tagId); @@ -458,8 +427,7 @@ public sealed class WorkspaceRepository : IDisposable /// /// The card id. /// The assigned tags in id order. - private IReadOnlyList LoadTags(long cardId) - { + private IReadOnlyList LoadTags(long cardId) { using var command = CreateCommand(""" SELECT t.id, t.name, t.color, t.description FROM tags t @@ -471,8 +439,7 @@ public sealed class WorkspaceRepository : IDisposable using var reader = command.ExecuteReader(); var tags = new List(); - while (reader.Read()) - { + while (reader.Read()) { tags.Add(ReadTag(reader, 0)); } return tags; @@ -502,8 +469,7 @@ public sealed class WorkspaceRepository : IDisposable /// The command text. /// The transaction to enlist in, when any. /// The prepared command. - private SqliteCommand CreateCommand(string sql, SqliteTransaction? transaction = null) - { + private SqliteCommand CreateCommand(string sql, SqliteTransaction? transaction = null) { SqliteCommand command = _connection.CreateCommand(); command.Transaction = transaction; command.CommandText = sql; @@ -516,11 +482,9 @@ public sealed class WorkspaceRepository : IDisposable /// The transaction to enlist in. /// The statement to execute. /// Name/value pairs bound as SQL parameters. - private void ExecuteNonQuery(SqliteTransaction transaction, string sql, params (string Name, object? Value)[] parameters) - { + private void ExecuteNonQuery(SqliteTransaction transaction, string sql, params (string Name, object? Value)[] parameters) { using SqliteCommand command = CreateCommand(sql, transaction); - foreach ((string name, object? value) in parameters) - { + foreach ((string name, object? value) in parameters) { command.Parameters.AddWithValue(name, value); } command.ExecuteNonQuery(); @@ -533,8 +497,7 @@ public sealed class WorkspaceRepository : IDisposable /// The INSERT statement. /// Name/value pairs bound as SQL parameters. /// The rowid assigned to the inserted row. - private long ExecuteInsert(SqliteTransaction transaction, string sql, params (string Name, object? Value)[] parameters) - { + private long ExecuteInsert(SqliteTransaction transaction, string sql, params (string Name, object? Value)[] parameters) { ExecuteNonQuery(transaction, sql, parameters); using SqliteCommand rowId = CreateCommand("SELECT last_insert_rowid();", transaction); return (long)rowId.ExecuteScalar()!; @@ -546,8 +509,7 @@ public sealed class WorkspaceRepository : IDisposable /// The DELETE statement with an $id parameter. /// The id to delete. /// when a row was deleted. - private bool ExecuteDelete(string sql, long id) - { + private bool ExecuteDelete(string sql, long id) { using SqliteCommand command = CreateCommand(sql); command.Parameters.AddWithValue("$id", id); return command.ExecuteNonQuery() > 0; diff --git a/YKanBan/Storage/Workspace/WorkspaceSchema.cs b/YKanBan/Storage/Workspace/WorkspaceSchema.cs index 4e91e22..02e59ba 100644 --- a/YKanBan/Storage/Workspace/WorkspaceSchema.cs +++ b/YKanBan/Storage/Workspace/WorkspaceSchema.cs @@ -7,8 +7,7 @@ namespace YKanBan.Storage.Workspace; /// KEY) so every stored row is valid by construction, and cascading deletes /// are covered by the same DDL. /// -public static class WorkspaceSchema -{ +public static class WorkspaceSchema { /// /// Newest workspace schema version this build understands. /// diff --git a/YKanBan/Storage/Workspace/WorkspaceSession.cs b/YKanBan/Storage/Workspace/WorkspaceSession.cs index 4d11333..1a6cc70 100644 --- a/YKanBan/Storage/Workspace/WorkspaceSession.cs +++ b/YKanBan/Storage/Workspace/WorkspaceSession.cs @@ -7,8 +7,7 @@ namespace YKanBan.Storage.Workspace; /// any database work happens. Disposing releases the repository and then the /// lock. /// -public sealed class WorkspaceSession : IDisposable -{ +public sealed class WorkspaceSession : IDisposable { private readonly WorkspaceLock _workspaceLock; /// @@ -17,8 +16,7 @@ public sealed class WorkspaceSession : IDisposable /// The canonical workspace folder path. /// The exclusive lock held while this session is open. /// The data access object for this workspace. - private WorkspaceSession(string folderPath, WorkspaceLock workspaceLock, WorkspaceRepository repository) - { + private WorkspaceSession(string folderPath, WorkspaceLock workspaceLock, WorkspaceRepository repository) { FolderPath = folderPath; _workspaceLock = workspaceLock; Repository = repository; @@ -41,16 +39,12 @@ public sealed class WorkspaceSession : IDisposable /// The opened session; dispose it to release the lock and connection. /// The folder has no .ykanban structure. /// Another instance already holds the lock. - public static WorkspaceSession Open(string folderPath) - { + public static WorkspaceSession Open(string folderPath) { WorkspaceLock workspaceLock = WorkspaceLock.Acquire(folderPath); - try - { + try { var repository = new WorkspaceRepository(folderPath); return new WorkspaceSession(folderPath, workspaceLock, repository); - } - catch - { + } catch { workspaceLock.Dispose(); throw; } @@ -59,8 +53,7 @@ public sealed class WorkspaceSession : IDisposable /// /// Releases the repository connection, then the exclusive lock. /// - public void Dispose() - { + public void Dispose() { Repository.Dispose(); _workspaceLock.Dispose(); } diff --git a/YKanBan/ViewLocator.cs b/YKanBan/ViewLocator.cs index 651f07e..d1b237e 100644 --- a/YKanBan/ViewLocator.cs +++ b/YKanBan/ViewLocator.cs @@ -1,31 +1,29 @@ using System; + using Avalonia.Controls; using Avalonia.Controls.Templates; + using YKanBan.ViewModels; namespace YKanBan; -public class ViewLocator : IDataTemplate -{ +public class ViewLocator : IDataTemplate { - public Control? Build(object? param) - { + public Control? Build(object? param) { if (param is null) return null; - + var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); var type = Type.GetType(name); - if (type != null) - { + if (type != null) { return (Control)Activator.CreateInstance(type)!; } - + return new TextBlock { Text = "Not Found: " + name }; } - public bool Match(object? data) - { + public bool Match(object? data) { return data is ViewModelBase; } } diff --git a/YKanBan/ViewModels/Board/BoardViewModel.cs b/YKanBan/ViewModels/Board/BoardViewModel.cs index 88b69db..8cf166b 100644 --- a/YKanBan/ViewModels/Board/BoardViewModel.cs +++ b/YKanBan/ViewModels/Board/BoardViewModel.cs @@ -1,6 +1,8 @@ using System.Collections.ObjectModel; + using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; + using YKanBan.Models; using YKanBan.Search; using YKanBan.Services; @@ -15,8 +17,7 @@ namespace YKanBan.ViewModels.Board; /// persistence goes through the workspace repository; the board reloads from it /// after each change. /// -public sealed partial class BoardViewModel : ViewModelBase -{ +public sealed partial class BoardViewModel : ViewModelBase { private readonly AppServices _services; private readonly WorkspaceRepository _repository; @@ -28,8 +29,7 @@ public sealed partial class BoardViewModel : ViewModelBase /// /// The process composition root (configuration access). /// The open workspace session. - public BoardViewModel(AppServices services, WorkspaceSession session) - { + public BoardViewModel(AppServices services, WorkspaceSession session) { _services = services; _repository = session.Repository; Display = new DisplaySettingsViewModel(_services.Config, Reload); @@ -97,28 +97,44 @@ public sealed partial class BoardViewModel : ViewModelBase #region Interactions - /// Gets the interaction that opens the new-column editor. + /// + /// Gets the interaction that opens the new-column editor. + /// public Interaction AddColumn { get; } = new(); - /// Gets the interaction that opens the column editor. + /// + /// Gets the interaction that opens the column editor. + /// public Interaction EditColumn { get; } = new(); - /// Gets the interaction that confirms and deletes a column. + /// + /// Gets the interaction that confirms and deletes a column. + /// public Interaction DeleteColumn { get; } = new(); - /// Gets the interaction that opens the new-card editor. + /// + /// Gets the interaction that opens the new-card editor. + /// public Interaction AddCard { get; } = new(); - /// Gets the interaction that opens the card editor. + /// + /// Gets the interaction that opens the card editor. + /// public Interaction EditCard { get; } = new(); - /// Gets the interaction that confirms and deletes a card. + /// + /// Gets the interaction that confirms and deletes a card. + /// public Interaction DeleteCard { get; } = new(); - /// Gets the interaction that opens the move-card dialog. + /// + /// Gets the interaction that opens the move-card dialog. + /// public Interaction MoveCard { get; } = new(); - /// Gets the interaction that shows the search error dialog, carrying the English parser detail. + /// + /// Gets the interaction that shows the search error dialog, carrying the English parser detail. + /// public Interaction ShowSearchError { get; } = new(); #endregion @@ -136,28 +152,22 @@ public sealed partial class BoardViewModel : ViewModelBase /// expression opens the error dialog and keeps the previous results and text. /// [RelayCommand] - private async Task SearchAsync() - { - if (string.IsNullOrWhiteSpace(SearchText)) - { + private async Task SearchAsync() { + if (string.IsNullOrWhiteSpace(SearchText)) { ApplyFilter(null); return; } - try - { + try { SearchNode? query = SearchQueryParser.Parse(SearchText); - if (query is null) - { + if (query is null) { ApplyFilter(null); return; } CompiledSearch compiled = SearchSqlCompiler.Compile(query); ApplyFilter(_repository.GetCardIdsMatching(compiled)); - } - catch (SearchSyntaxException exception) - { + } catch (SearchSyntaxException exception) { // The error dialog carries the parser's non-localized English detail. await ShowSearchError.Handle(exception.Message); } @@ -167,8 +177,7 @@ public sealed partial class BoardViewModel : ViewModelBase /// Clears the search box and restores the unfiltered board. /// [RelayCommand] - private void ClearSearch() - { + private void ClearSearch() { SearchText = string.Empty; ApplyFilter(null); } @@ -177,10 +186,8 @@ public sealed partial class BoardViewModel : ViewModelBase /// Applies a new result filter (null clears it) and rebuilds the board when it changed. /// /// The new filter, or for no filter. - private void ApplyFilter(IReadOnlyCollection? matchedIds) - { - if (_matchedCardIds is null && matchedIds is null) - { + private void ApplyFilter(IReadOnlyCollection? matchedIds) { + if (_matchedCardIds is null && matchedIds is null) { return; } @@ -244,14 +251,12 @@ public sealed partial class BoardViewModel : ViewModelBase /// /// Reloads all columns and cards from the repository and rebuilds the board. /// - public void Reload() - { + public void Reload() { IReadOnlyList columns = _repository.GetColumns(); IReadOnlyList cards = _repository.GetCards(); // An active search hides non-matching cards while every column stays visible. - if (_matchedCardIds is not null) - { + if (_matchedCardIds is not null) { cards = cards.Where(card => _matchedCardIds.Contains(card.Id)).ToList(); } @@ -263,8 +268,7 @@ public sealed partial class BoardViewModel : ViewModelBase IEnumerable orderedColumns = SortColumns(columns, _services.Config.Display.ColumnSort); Columns.Clear(); - foreach (ColumnModel column in orderedColumns) - { + foreach (ColumnModel column in orderedColumns) { // Cards are sorted within their column by the selected card order. IEnumerable columnCards = cardsByColumn.TryGetValue(column.Id, out List? list) ? SortCards(list, _services.Config.Display.CardSort) @@ -280,11 +284,9 @@ public sealed partial class BoardViewModel : ViewModelBase /// /// Recomputes the global column width and pushes it to every column. /// - public void ApplyColumnWidth() - { + public void ApplyColumnWidth() { ColumnWidth = ColumnWidthPixels(_services.Config.Display.ColumnWidth); - foreach (ColumnViewModel column in Columns) - { + foreach (ColumnViewModel column in Columns) { column.Width = ColumnWidth; } } @@ -294,8 +296,7 @@ public sealed partial class BoardViewModel : ViewModelBase /// /// The configured column width. /// The width in pixels. - public static double ColumnWidthPixels(ColumnWidthSettings settings) => settings.Preset switch - { + public static double ColumnWidthPixels(ColumnWidthSettings settings) => settings.Preset switch { ColumnWidthPreset.Narrow => 240, ColumnWidthPreset.Wide => 480, ColumnWidthPreset.Custom => settings.CustomPixels, @@ -308,8 +309,7 @@ public sealed partial class BoardViewModel : ViewModelBase /// The cards to order. /// The selected order. /// The ordered cards. - private static IEnumerable SortCards(IEnumerable cards, SortOption option) => option switch - { + private static IEnumerable SortCards(IEnumerable cards, SortOption option) => option switch { SortOption.IdAscending => cards.OrderBy(card => card.Id), SortOption.IdDescending => cards.OrderByDescending(card => card.Id), SortOption.CreatedAscending => cards.OrderBy(card => card.CreatedAt).ThenBy(card => card.Id), @@ -327,8 +327,7 @@ public sealed partial class BoardViewModel : ViewModelBase /// The columns to order. /// The selected order. /// The ordered columns. - private static IEnumerable SortColumns(IEnumerable columns, SortOption option) => option switch - { + private static IEnumerable SortColumns(IEnumerable columns, SortOption option) => option switch { SortOption.IdAscending => columns.OrderBy(column => column.Id), SortOption.IdDescending => columns.OrderByDescending(column => column.Id), SortOption.CreatedAscending => columns.OrderBy(column => column.CreatedAt).ThenBy(column => column.Id), diff --git a/YKanBan/ViewModels/Board/CardViewModel.cs b/YKanBan/ViewModels/Board/CardViewModel.cs index e54863c..53ec052 100644 --- a/YKanBan/ViewModels/Board/CardViewModel.cs +++ b/YKanBan/ViewModels/Board/CardViewModel.cs @@ -1,4 +1,5 @@ using CommunityToolkit.Mvvm.Input; + using YKanBan.Models; namespace YKanBan.ViewModels.Board; @@ -9,9 +10,10 @@ namespace YKanBan.ViewModels.Board; /// truncated content preview and the colored tag badges; timestamps are not /// shown on the board. /// -public sealed partial class CardViewModel : ViewModelBase -{ - /// Maximum number of content characters shown in the card preview. +public sealed partial class CardViewModel : ViewModelBase { + /// + /// Maximum number of content characters shown in the card preview. + /// private const int PreviewLength = 200; private readonly BoardViewModel _board; @@ -21,8 +23,7 @@ public sealed partial class CardViewModel : ViewModelBase /// /// The owning board view model. /// The card model to display. - public CardViewModel(BoardViewModel board, CardModel card) - { + public CardViewModel(BoardViewModel board, CardModel card) { _board = board; Model = card; TagBadges = card.Tags.Select(tag => new TagBadgeViewModel(tag)).ToList(); @@ -71,10 +72,8 @@ public sealed partial class CardViewModel : ViewModelBase /// /// Gets the content preview, collapsed to one line and truncated. /// - public string PreviewContent - { - get - { + public string PreviewContent { + get { string singleLine = Model.Content.Replace("\r", " ").Replace("\n", " "); return singleLine.Length <= PreviewLength ? singleLine : singleLine[..PreviewLength] + "…"; } diff --git a/YKanBan/ViewModels/Board/ColumnViewModel.cs b/YKanBan/ViewModels/Board/ColumnViewModel.cs index 123bd07..cc56872 100644 --- a/YKanBan/ViewModels/Board/ColumnViewModel.cs +++ b/YKanBan/ViewModels/Board/ColumnViewModel.cs @@ -1,6 +1,8 @@ using System.Collections.ObjectModel; + using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; + using YKanBan.Models; namespace YKanBan.ViewModels.Board; @@ -9,8 +11,7 @@ namespace YKanBan.ViewModels.Board; /// One board column: a header with its title, card count and actions, plus the /// vertically stacked cards. /// -public sealed partial class ColumnViewModel : ViewModelBase -{ +public sealed partial class ColumnViewModel : ViewModelBase { private readonly BoardViewModel _board; /// @@ -19,8 +20,7 @@ public sealed partial class ColumnViewModel : ViewModelBase /// The owning board view model. /// The column model. /// The already sorted cards belonging to this column. - public ColumnViewModel(BoardViewModel board, ColumnModel column, IEnumerable cards) - { + public ColumnViewModel(BoardViewModel board, ColumnModel column, IEnumerable cards) { _board = board; Model = column; Cards = new ObservableCollection(cards); diff --git a/YKanBan/ViewModels/Board/DisplaySettingsViewModel.cs b/YKanBan/ViewModels/Board/DisplaySettingsViewModel.cs index 6eb882e..c6b42a0 100644 --- a/YKanBan/ViewModels/Board/DisplaySettingsViewModel.cs +++ b/YKanBan/ViewModels/Board/DisplaySettingsViewModel.cs @@ -1,4 +1,5 @@ using CommunityToolkit.Mvvm.ComponentModel; + using YKanBan.Storage.AppData; namespace YKanBan.ViewModels.Board; @@ -8,12 +9,15 @@ namespace YKanBan.ViewModels.Board; /// width and the separate card and column orderings. Every change writes to /// the configuration and asks the board to refresh immediately. /// -public sealed partial class DisplaySettingsViewModel : ViewModelBase -{ - /// Smallest accepted manual width. +public sealed partial class DisplaySettingsViewModel : ViewModelBase { + /// + /// Smallest accepted manual width. + /// public const int MinimumPixels = 160; - /// Largest accepted manual width. + /// + /// Largest accepted manual width. + /// public const int MaximumPixels = 720; private readonly AppConfig _config; @@ -24,8 +28,7 @@ public sealed partial class DisplaySettingsViewModel : ViewModelBase /// /// The live application configuration. /// Invoked after every change to refresh the board. - public DisplaySettingsViewModel(AppConfig config, Action refresh) - { + public DisplaySettingsViewModel(AppConfig config, Action refresh) { _config = config; _refresh = refresh; @@ -88,11 +91,9 @@ public sealed partial class DisplaySettingsViewModel : ViewModelBase /// Stores the width preset and refreshes the board. /// /// The new selection. - partial void OnSelectedPresetChanged(OptionItem? value) - { + partial void OnSelectedPresetChanged(OptionItem? value) { OnPropertyChanged(nameof(IsCustom)); - if (value is not null) - { + if (value is not null) { _config.Display.ColumnWidth.Preset = (ColumnWidthPreset)value.Value; _refresh(); } @@ -102,17 +103,14 @@ public sealed partial class DisplaySettingsViewModel : ViewModelBase /// Stores the manual width and refreshes the board when the custom preset is active. /// /// The new pixel count. - partial void OnCustomPixelsChanged(decimal? value) - { - if (value is null) - { + partial void OnCustomPixelsChanged(decimal? value) { + if (value is null) { return; } int pixels = (int)value.Value; _config.Display.ColumnWidth.CustomPixels = pixels; - if (IsCustom) - { + if (IsCustom) { _refresh(); } } @@ -121,10 +119,8 @@ public sealed partial class DisplaySettingsViewModel : ViewModelBase /// Stores the card order and refreshes the board. /// /// The new selection. - partial void OnSelectedCardSortChanged(OptionItem? value) - { - if (value is not null) - { + partial void OnSelectedCardSortChanged(OptionItem? value) { + if (value is not null) { _config.Display.CardSort = (SortOption)value.Value; _refresh(); } @@ -134,10 +130,8 @@ public sealed partial class DisplaySettingsViewModel : ViewModelBase /// Stores the column order and refreshes the board. /// /// The new selection. - partial void OnSelectedColumnSortChanged(OptionItem? value) - { - if (value is not null) - { + partial void OnSelectedColumnSortChanged(OptionItem? value) { + if (value is not null) { _config.Display.ColumnSort = (SortOption)value.Value; _refresh(); } diff --git a/YKanBan/ViewModels/Board/TagBadgeViewModel.cs b/YKanBan/ViewModels/Board/TagBadgeViewModel.cs index 2f8f06e..7a6309b 100644 --- a/YKanBan/ViewModels/Board/TagBadgeViewModel.cs +++ b/YKanBan/ViewModels/Board/TagBadgeViewModel.cs @@ -1,4 +1,5 @@ using Avalonia.Media; + using YKanBan.Models; namespace YKanBan.ViewModels.Board; @@ -8,14 +9,12 @@ namespace YKanBan.ViewModels.Board; /// background taken directly from the tag color (user data, never themed) and /// an automatically contrasting text color. /// -public sealed class TagBadgeViewModel -{ +public sealed class TagBadgeViewModel { /// /// Initializes a badge from a tag. /// /// The tag to render. - public TagBadgeViewModel(TagModel tag) - { + public TagBadgeViewModel(TagModel tag) { Tag = tag; Name = tag.Name; Color background = Color.FromRgb(tag.Color.R, tag.Color.G, tag.Color.B); @@ -54,8 +53,7 @@ public sealed class TagBadgeViewModel /// /// The badge background color. /// The contrasting text color. - private static Color ContrastingTextColor(Color background) - { + private static Color ContrastingTextColor(Color background) { double luminance = (0.299 * background.R + 0.587 * background.G + 0.114 * background.B) / 255.0; return luminance > 0.6 ? Colors.Black : Colors.White; } diff --git a/YKanBan/ViewModels/Dialogs/AboutDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/AboutDialogViewModel.cs index 48bfc53..234bc2e 100644 --- a/YKanBan/ViewModels/Dialogs/AboutDialogViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/AboutDialogViewModel.cs @@ -5,13 +5,11 @@ namespace YKanBan.ViewModels.Dialogs; /// /// The about dialog: application name, version, repository link and license. /// -public sealed class AboutDialogViewModel : ViewModelBase -{ +public sealed class AboutDialogViewModel : ViewModelBase { /// /// Initializes the view model, reading the version from the assembly. /// - public AboutDialogViewModel() - { + public AboutDialogViewModel() { string version = typeof(AboutDialogViewModel).Assembly.GetName().Version?.ToString(3) ?? "0.0.0"; Version = string.Format( Resources.Culture ?? CultureInfo.CurrentCulture, Resources.About_Version, version); diff --git a/YKanBan/ViewModels/Dialogs/AssignedTagViewModel.cs b/YKanBan/ViewModels/Dialogs/AssignedTagViewModel.cs index a640b89..f545f98 100644 --- a/YKanBan/ViewModels/Dialogs/AssignedTagViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/AssignedTagViewModel.cs @@ -1,5 +1,7 @@ using Avalonia.Media; + using CommunityToolkit.Mvvm.Input; + using YKanBan.Models; using YKanBan.ViewModels.Board; @@ -10,8 +12,7 @@ namespace YKanBan.ViewModels.Dialogs; /// remove action that only changes the in-memory assignment (the actual write /// happens on confirm). /// -public sealed partial class AssignedTagViewModel -{ +public sealed partial class AssignedTagViewModel { private readonly CardEditorViewModel _editor; private readonly TagBadgeViewModel _badge; @@ -20,8 +21,7 @@ public sealed partial class AssignedTagViewModel /// /// The owning card editor. /// The assigned tag. - public AssignedTagViewModel(CardEditorViewModel editor, TagModel tag) - { + public AssignedTagViewModel(CardEditorViewModel editor, TagModel tag) { _editor = editor; Tag = tag; _badge = new TagBadgeViewModel(tag); diff --git a/YKanBan/ViewModels/Dialogs/CardEditorViewModel.cs b/YKanBan/ViewModels/Dialogs/CardEditorViewModel.cs index e5856da..3761753 100644 --- a/YKanBan/ViewModels/Dialogs/CardEditorViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/CardEditorViewModel.cs @@ -1,8 +1,11 @@ using System.Collections.ObjectModel; using System.Globalization; + using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; + using Microsoft.Data.Sqlite; + using YKanBan.Models; using YKanBan.Storage.Workspace; @@ -14,8 +17,7 @@ namespace YKanBan.ViewModels.Dialogs; /// card and its tag assignment as one unit; a confirm with no effective change /// writes nothing; cancelling never writes. /// -public sealed partial class CardEditorViewModel : ViewModelBase -{ +public sealed partial class CardEditorViewModel : ViewModelBase { private readonly WorkspaceRepository _repository; private readonly long _columnId; private readonly CardModel? _existing; @@ -26,8 +28,7 @@ public sealed partial class CardEditorViewModel : ViewModelBase /// The workspace repository. /// The owning column (used when adding). /// The card to edit, or to add one. - public CardEditorViewModel(WorkspaceRepository repository, long columnId, CardModel? existing) - { + public CardEditorViewModel(WorkspaceRepository repository, long columnId, CardModel? existing) { _repository = repository; _columnId = columnId; _existing = existing; @@ -36,8 +37,7 @@ public sealed partial class CardEditorViewModel : ViewModelBase AssignedTags = new ObservableCollection( (existing?.Tags ?? []).Select(tag => new AssignedTagViewModel(this, tag))); - if (existing is not null) - { + if (existing is not null) { IdLine = string.Format(Culture, Resources.CardDialog_Id, $"#{existing.Id}"); CreatedLine = string.Format(Culture, Resources.CardDialog_Created, FormatTime(existing.CreatedAt)); ModifiedLine = string.Format(Culture, Resources.CardDialog_Modified, FormatTime(existing.UpdatedAt)); @@ -105,12 +105,9 @@ public sealed partial class CardEditorViewModel : ViewModelBase /// /// Gets a value indicating whether there are unsaved effective changes. /// - public bool HasChanges - { - get - { - if (_existing is null) - { + public bool HasChanges { + get { + if (_existing is null) { return Title.Length > 0 || Content.Length > 0 || AssignedTags.Count > 0; } @@ -124,11 +121,9 @@ public sealed partial class CardEditorViewModel : ViewModelBase /// Opens the nested tag picker and adds the chosen tag when it is new to the card. /// [RelayCommand] - private async Task AddTagAsync() - { + private async Task AddTagAsync() { TagModel? tag = await ShowTagPicker.Handle(Unit.Value); - if (tag is not null && AssignedTags.All(row => row.Tag.Id != tag.Id)) - { + if (tag is not null && AssignedTags.All(row => row.Tag.Id != tag.Id)) { AssignedTags.Add(new AssignedTagViewModel(this, tag)); } } @@ -137,11 +132,9 @@ public sealed partial class CardEditorViewModel : ViewModelBase /// Removes a tag from the in-memory assignment. /// /// The tag to remove. - public void RemoveTag(TagModel tag) - { + public void RemoveTag(TagModel tag) { AssignedTagViewModel? row = AssignedTags.FirstOrDefault(item => item.Tag.Id == tag.Id); - if (row is not null) - { + if (row is not null) { AssignedTags.Remove(row); } } @@ -150,32 +143,24 @@ public sealed partial class CardEditorViewModel : ViewModelBase /// Writes the card and its tags when there is an effective change. /// /// when the dialog may close. - public bool TryAccept() - { + public bool TryAccept() { ErrorMessage = null; // A confirm with no effective change writes nothing and just closes. - if (!HasChanges) - { + if (!HasChanges) { return true; } - try - { + try { IReadOnlyCollection tagIds = AssignedTags.Select(row => row.Tag.Id).ToList(); - if (_existing is null) - { + if (_existing is null) { _repository.AddCard(_columnId, Title, Content, tagIds); - } - else - { + } else { _repository.UpdateCard(_existing.Id, Title, Content, tagIds); } return true; - } - catch (SqliteException) - { + } catch (SqliteException) { ErrorMessage = Resources.CardDialog_Error; return false; } diff --git a/YKanBan/ViewModels/Dialogs/ColumnEditorViewModel.cs b/YKanBan/ViewModels/Dialogs/ColumnEditorViewModel.cs index f5d5438..9ff5b08 100644 --- a/YKanBan/ViewModels/Dialogs/ColumnEditorViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/ColumnEditorViewModel.cs @@ -1,5 +1,7 @@ using CommunityToolkit.Mvvm.ComponentModel; + using Microsoft.Data.Sqlite; + using YKanBan.Models; using YKanBan.Storage.Workspace; @@ -10,8 +12,7 @@ namespace YKanBan.ViewModels.Dialogs; /// The write happens when the dialog is accepted; a duplicate or empty title /// keeps the dialog open with an inline error. /// -public sealed partial class ColumnEditorViewModel : ViewModelBase -{ +public sealed partial class ColumnEditorViewModel : ViewModelBase { private readonly WorkspaceRepository _repository; private readonly ColumnModel? _existing; @@ -20,8 +21,7 @@ public sealed partial class ColumnEditorViewModel : ViewModelBase /// /// The workspace repository. /// The column to edit, or to add one. - public ColumnEditorViewModel(WorkspaceRepository repository, ColumnModel? existing) - { + public ColumnEditorViewModel(WorkspaceRepository repository, ColumnModel? existing) { _repository = repository; _existing = existing; title = existing?.Title ?? string.Empty; @@ -60,24 +60,17 @@ public sealed partial class ColumnEditorViewModel : ViewModelBase /// Writes the column when accepted. /// /// when the dialog may close. - public bool TryAccept() - { + public bool TryAccept() { ErrorMessage = null; - try - { - if (_existing is null) - { + try { + if (_existing is null) { _repository.AddColumn(Title, Description); - } - else - { + } else { _repository.UpdateColumn(_existing.Id, Title, Description); } return true; - } - catch (SqliteException) - { + } catch (SqliteException) { ErrorMessage = Resources.ColumnDialog_InvalidTitle; return false; } diff --git a/YKanBan/ViewModels/Dialogs/DeleteCardConfirmViewModel.cs b/YKanBan/ViewModels/Dialogs/DeleteCardConfirmViewModel.cs index dfb4d0a..97f72e5 100644 --- a/YKanBan/ViewModels/Dialogs/DeleteCardConfirmViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/DeleteCardConfirmViewModel.cs @@ -5,14 +5,12 @@ namespace YKanBan.ViewModels.Dialogs; /// /// The confirmation shown before deleting a card, naming the card id. /// -public sealed class DeleteCardConfirmViewModel : ViewModelBase -{ +public sealed class DeleteCardConfirmViewModel : ViewModelBase { /// /// Initializes the confirmation. /// /// The id of the card to delete. - public DeleteCardConfirmViewModel(long cardId) - { + public DeleteCardConfirmViewModel(long cardId) { Message = string.Format( Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Confirm_DeleteCard_Message, diff --git a/YKanBan/ViewModels/Dialogs/DeleteColumnConfirmViewModel.cs b/YKanBan/ViewModels/Dialogs/DeleteColumnConfirmViewModel.cs index ba771b5..097c0bd 100644 --- a/YKanBan/ViewModels/Dialogs/DeleteColumnConfirmViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/DeleteColumnConfirmViewModel.cs @@ -6,15 +6,13 @@ namespace YKanBan.ViewModels.Dialogs; /// The confirmation shown before deleting a column, naming the column and how /// many cards it will cascade-delete. /// -public sealed class DeleteColumnConfirmViewModel : ViewModelBase -{ +public sealed class DeleteColumnConfirmViewModel : ViewModelBase { /// /// Initializes the confirmation. /// /// The title of the column to delete. /// The number of cards in the column. - public DeleteColumnConfirmViewModel(string columnTitle, int cardCount) - { + public DeleteColumnConfirmViewModel(string columnTitle, int cardCount) { Message = string.Format( Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Confirm_DeleteColumn_Message, diff --git a/YKanBan/ViewModels/Dialogs/DeleteTagConfirmViewModel.cs b/YKanBan/ViewModels/Dialogs/DeleteTagConfirmViewModel.cs index 6047f34..5d80dbc 100644 --- a/YKanBan/ViewModels/Dialogs/DeleteTagConfirmViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/DeleteTagConfirmViewModel.cs @@ -6,15 +6,13 @@ namespace YKanBan.ViewModels.Dialogs; /// The confirmation shown before deleting a tag, naming the tag and how many /// cards it will be removed from. /// -public sealed class DeleteTagConfirmViewModel : ViewModelBase -{ +public sealed class DeleteTagConfirmViewModel : ViewModelBase { /// /// Initializes the confirmation. /// /// The name of the tag to delete. /// The number of cards using the tag. - public DeleteTagConfirmViewModel(string tagName, long usageCount) - { + public DeleteTagConfirmViewModel(string tagName, long usageCount) { Message = string.Format( Resources.Culture ?? CultureInfo.CurrentCulture, Resources.DeleteTag_Confirm_Message, diff --git a/YKanBan/ViewModels/Dialogs/DiscardEditConfirmViewModel.cs b/YKanBan/ViewModels/Dialogs/DiscardEditConfirmViewModel.cs index 247c7e3..2c6b20a 100644 --- a/YKanBan/ViewModels/Dialogs/DiscardEditConfirmViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/DiscardEditConfirmViewModel.cs @@ -3,8 +3,7 @@ namespace YKanBan.ViewModels.Dialogs; /// /// The confirmation shown before closing an editor with unsaved changes. /// -public sealed class DiscardEditConfirmViewModel : ViewModelBase -{ +public sealed class DiscardEditConfirmViewModel : ViewModelBase { /// /// Gets the localized confirmation message. /// diff --git a/YKanBan/ViewModels/Dialogs/ExportDoneViewModel.cs b/YKanBan/ViewModels/Dialogs/ExportDoneViewModel.cs index 53ef6f7..a0c73fd 100644 --- a/YKanBan/ViewModels/Dialogs/ExportDoneViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/ExportDoneViewModel.cs @@ -6,14 +6,12 @@ namespace YKanBan.ViewModels.Dialogs; /// The confirmation dialog shown after a successful Markdown export, naming the /// written file. /// -public sealed class ExportDoneViewModel : ViewModelBase -{ +public sealed class ExportDoneViewModel : ViewModelBase { /// /// Initializes the dialog with the exported file path. /// /// The path the export was written to. - public ExportDoneViewModel(string filePath) - { + public ExportDoneViewModel(string filePath) { Message = string.Format( Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Export_Done_Message, filePath); } diff --git a/YKanBan/ViewModels/Dialogs/MoveCardViewModel.cs b/YKanBan/ViewModels/Dialogs/MoveCardViewModel.cs index 1cfd6a4..6a51c78 100644 --- a/YKanBan/ViewModels/Dialogs/MoveCardViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/MoveCardViewModel.cs @@ -1,4 +1,5 @@ using CommunityToolkit.Mvvm.ComponentModel; + using YKanBan.Models; namespace YKanBan.ViewModels.Dialogs; @@ -6,15 +7,13 @@ namespace YKanBan.ViewModels.Dialogs; /// /// The move-card dialog: choose one of the other columns as the destination. /// -public sealed partial class MoveCardViewModel : ViewModelBase -{ +public sealed partial class MoveCardViewModel : ViewModelBase { /// /// Initializes the dialog with the candidate destination columns. /// /// All columns; the card's current column is excluded. /// The card's current column id. - public MoveCardViewModel(IReadOnlyList columns, long currentColumnId) - { + public MoveCardViewModel(IReadOnlyList columns, long currentColumnId) { Targets = columns .Where(column => column.Id != currentColumnId) .Select(column => new OptionItem(column.Id, column.Title)) diff --git a/YKanBan/ViewModels/Dialogs/SearchErrorViewModel.cs b/YKanBan/ViewModels/Dialogs/SearchErrorViewModel.cs index 113bcd9..0d04dcc 100644 --- a/YKanBan/ViewModels/Dialogs/SearchErrorViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/SearchErrorViewModel.cs @@ -6,14 +6,12 @@ namespace YKanBan.ViewModels.Dialogs; /// The pure-information dialog shown when the search expression is invalid. It /// wraps the parser's non-localized English detail in the localized message. /// -public sealed class SearchErrorViewModel : ViewModelBase -{ +public sealed class SearchErrorViewModel : ViewModelBase { /// /// Initializes the dialog with the parser's English detail. /// /// The non-localized parser error description. - public SearchErrorViewModel(string detail) - { + public SearchErrorViewModel(string detail) { Message = string.Format( Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Search_Error_Message, diff --git a/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs index cc83055..de2755f 100644 --- a/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs @@ -1,4 +1,5 @@ using CommunityToolkit.Mvvm.ComponentModel; + using YKanBan.Storage.AppData; namespace YKanBan.ViewModels.Dialogs; @@ -8,8 +9,7 @@ namespace YKanBan.ViewModels.Dialogs; /// confirmation-dialog toggles. Nothing is applied until the user accepts; /// cancelling or closing the window discards the changes. /// -public sealed partial class SettingsDialogViewModel : ViewModelBase -{ +public sealed partial class SettingsDialogViewModel : ViewModelBase { private readonly AppConfig _config; private readonly string _startupLanguage; @@ -18,8 +18,7 @@ public sealed partial class SettingsDialogViewModel : ViewModelBase /// /// The live application configuration. /// The language applied at startup, for the restart notice. - public SettingsDialogViewModel(AppConfig config, string startupLanguage) - { + public SettingsDialogViewModel(AppConfig config, string startupLanguage) { _config = config; _startupLanguage = startupLanguage; @@ -101,14 +100,11 @@ public sealed partial class SettingsDialogViewModel : ViewModelBase /// /// Commits the working values into the live configuration. /// - public void Accept() - { - if (SelectedLanguage is not null) - { + public void Accept() { + if (SelectedLanguage is not null) { _config.Language = (string)SelectedLanguage.Value; } - if (SelectedTheme is not null) - { + if (SelectedTheme is not null) { _config.Theme = (ThemeOption)SelectedTheme.Value; App.ApplyTheme(_config.Theme); } diff --git a/YKanBan/ViewModels/Dialogs/TagEditorViewModel.cs b/YKanBan/ViewModels/Dialogs/TagEditorViewModel.cs index 9538d82..95f7f90 100644 --- a/YKanBan/ViewModels/Dialogs/TagEditorViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/TagEditorViewModel.cs @@ -1,6 +1,9 @@ using Avalonia.Media; + using CommunityToolkit.Mvvm.ComponentModel; + using Microsoft.Data.Sqlite; + using YKanBan.Models; using YKanBan.Storage.Workspace; @@ -11,8 +14,7 @@ namespace YKanBan.ViewModels.Dialogs; /// optional description. The write happens when the dialog is accepted; a /// duplicate or empty name keeps the dialog open with an inline error. /// -public sealed partial class TagEditorViewModel : ViewModelBase -{ +public sealed partial class TagEditorViewModel : ViewModelBase { private readonly WorkspaceRepository _repository; private readonly TagModel? _existing; @@ -21,8 +23,7 @@ public sealed partial class TagEditorViewModel : ViewModelBase /// /// The workspace repository. /// The tag to edit, or to add one. - public TagEditorViewModel(WorkspaceRepository repository, TagModel? existing) - { + public TagEditorViewModel(WorkspaceRepository repository, TagModel? existing) { _repository = repository; _existing = existing; name = existing?.Name ?? string.Empty; @@ -70,26 +71,19 @@ public sealed partial class TagEditorViewModel : ViewModelBase /// Writes the tag when accepted. /// /// when the dialog may close. - public bool TryAccept() - { + public bool TryAccept() { ErrorMessage = null; var rgb = new RgbColor(Color.R, Color.G, Color.B); - try - { - if (_existing is null) - { + try { + if (_existing is null) { _repository.AddTag(Name, rgb, Description); - } - else - { + } else { _repository.UpdateTag(_existing.Id, Name, rgb, Description); } return true; - } - catch (SqliteException) - { + } catch (SqliteException) { ErrorMessage = Resources.TagEditor_InvalidName; return false; } diff --git a/YKanBan/ViewModels/Dialogs/TagPickerViewModel.cs b/YKanBan/ViewModels/Dialogs/TagPickerViewModel.cs index d288db7..d76bab3 100644 --- a/YKanBan/ViewModels/Dialogs/TagPickerViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/TagPickerViewModel.cs @@ -1,4 +1,5 @@ using CommunityToolkit.Mvvm.ComponentModel; + using YKanBan.Storage.Workspace; using YKanBan.ViewModels.Board; @@ -9,15 +10,13 @@ namespace YKanBan.ViewModels.Dialogs; /// not already assigned to the card. When no candidate remains the picker /// shows a message and cannot be confirmed. /// -public sealed partial class TagPickerViewModel : ViewModelBase -{ +public sealed partial class TagPickerViewModel : ViewModelBase { /// /// Initializes the picker with the tags not yet assigned to the card. /// /// The workspace repository. /// The tag ids already assigned to the card. - public TagPickerViewModel(WorkspaceRepository repository, IReadOnlyCollection assignedTagIds) - { + public TagPickerViewModel(WorkspaceRepository repository, IReadOnlyCollection assignedTagIds) { Targets = repository.GetTags() .Where(tag => !assignedTagIds.Contains(tag.Id)) .Select(tag => new TagBadgeViewModel(tag)) diff --git a/YKanBan/ViewModels/ErrorPages/ArgumentErrorPageViewModel.cs b/YKanBan/ViewModels/ErrorPages/ArgumentErrorPageViewModel.cs index 4007c14..fc6809c 100644 --- a/YKanBan/ViewModels/ErrorPages/ArgumentErrorPageViewModel.cs +++ b/YKanBan/ViewModels/ErrorPages/ArgumentErrorPageViewModel.cs @@ -4,6 +4,5 @@ namespace YKanBan.ViewModels.ErrorPages; /// The full-window page shown when YKanBan is started without arguments or with /// invalid arguments. It has no operations. /// -public sealed class ArgumentErrorPageViewModel : ViewModelBase -{ +public sealed class ArgumentErrorPageViewModel : ViewModelBase { } diff --git a/YKanBan/ViewModels/ErrorPages/DirectoryMissingPageViewModel.cs b/YKanBan/ViewModels/ErrorPages/DirectoryMissingPageViewModel.cs index 5eb1fd2..a07a196 100644 --- a/YKanBan/ViewModels/ErrorPages/DirectoryMissingPageViewModel.cs +++ b/YKanBan/ViewModels/ErrorPages/DirectoryMissingPageViewModel.cs @@ -6,14 +6,12 @@ namespace YKanBan.ViewModels.ErrorPages; /// The full-window page shown when the folder passed on the command line does /// not exist. It has no operations. /// -public sealed class DirectoryMissingPageViewModel : ViewModelBase -{ +public sealed class DirectoryMissingPageViewModel : ViewModelBase { /// /// Initializes the page for a missing folder. /// /// The folder path that does not exist. - public DirectoryMissingPageViewModel(string folderPath) - { + public DirectoryMissingPageViewModel(string folderPath) { FolderPath = folderPath; Message = string.Format( Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Error_DirectoryMissing_Message, folderPath); diff --git a/YKanBan/ViewModels/ErrorPages/LockConflictPageViewModel.cs b/YKanBan/ViewModels/ErrorPages/LockConflictPageViewModel.cs index 817cd69..fc20f93 100644 --- a/YKanBan/ViewModels/ErrorPages/LockConflictPageViewModel.cs +++ b/YKanBan/ViewModels/ErrorPages/LockConflictPageViewModel.cs @@ -6,14 +6,12 @@ namespace YKanBan.ViewModels.ErrorPages; /// The full-window page shown when the workspace is already locked by another /// instance. It has no operations. /// -public sealed class LockConflictPageViewModel : ViewModelBase -{ +public sealed class LockConflictPageViewModel : ViewModelBase { /// /// Initializes the page for a locked workspace. /// /// The locked workspace folder. - public LockConflictPageViewModel(string folderPath) - { + public LockConflictPageViewModel(string folderPath) { FolderPath = folderPath; Message = string.Format( Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Error_LockConflict_Message, folderPath); diff --git a/YKanBan/ViewModels/ErrorPages/NotInitializedPageViewModel.cs b/YKanBan/ViewModels/ErrorPages/NotInitializedPageViewModel.cs index 40830b9..06fe24a 100644 --- a/YKanBan/ViewModels/ErrorPages/NotInitializedPageViewModel.cs +++ b/YKanBan/ViewModels/ErrorPages/NotInitializedPageViewModel.cs @@ -1,4 +1,5 @@ using System.Globalization; + using CommunityToolkit.Mvvm.Input; namespace YKanBan.ViewModels.ErrorPages; @@ -8,8 +9,7 @@ namespace YKanBan.ViewModels.ErrorPages; /// structure. It is the only error page with an operation: initializing the /// workspace. /// -public sealed partial class NotInitializedPageViewModel : ViewModelBase -{ +public sealed partial class NotInitializedPageViewModel : ViewModelBase { private readonly Interaction _initializeWorkspace; /// @@ -17,8 +17,7 @@ public sealed partial class NotInitializedPageViewModel : ViewModelBase /// /// The uninitialized folder path. /// The interaction that performs initialization. - public NotInitializedPageViewModel(string folderPath, Interaction initializeWorkspace) - { + public NotInitializedPageViewModel(string folderPath, Interaction initializeWorkspace) { FolderPath = folderPath; _initializeWorkspace = initializeWorkspace; Message = string.Format( diff --git a/YKanBan/ViewModels/Interaction.cs b/YKanBan/ViewModels/Interaction.cs index 001fe54..9f3d245 100644 --- a/YKanBan/ViewModels/Interaction.cs +++ b/YKanBan/ViewModels/Interaction.cs @@ -3,8 +3,7 @@ namespace YKanBan.ViewModels; /// /// Input placeholder for interactions that carry no meaningful payload. /// -public readonly record struct Unit -{ +public readonly record struct Unit { /// /// Gets the single unit value. /// @@ -18,8 +17,7 @@ public readonly record struct Unit /// The view model never holds a reference to a view. /// /// The request payload. -public sealed class Interaction -{ +public sealed class Interaction { private Func? _handler; /// @@ -46,8 +44,7 @@ public sealed class Interaction /// /// The request payload. /// The handler's answer. -public sealed class Interaction -{ +public sealed class Interaction { private Func>? _handler; /// diff --git a/YKanBan/ViewModels/MainWindowViewModel.cs b/YKanBan/ViewModels/MainWindowViewModel.cs index 03f00d3..ab4db4d 100644 --- a/YKanBan/ViewModels/MainWindowViewModel.cs +++ b/YKanBan/ViewModels/MainWindowViewModel.cs @@ -1,5 +1,7 @@ using System.Diagnostics; + using CommunityToolkit.Mvvm.ComponentModel; + using YKanBan.Launching; using YKanBan.Services; using YKanBan.Storage; @@ -13,8 +15,7 @@ namespace YKanBan.ViewModels; /// normal workspace view or one of the full-window error pages, and owns the /// interactions that the view fulfils by opening dialogs. /// -public sealed partial class MainWindowViewModel : ViewModelBase -{ +public sealed partial class MainWindowViewModel : ViewModelBase { private readonly AppServices _services; /// @@ -22,8 +23,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase /// /// The process composition root. /// The command-line arguments (excluding the executable). - public MainWindowViewModel(AppServices services, IReadOnlyList arguments) - { + public MainWindowViewModel(AppServices services, IReadOnlyList arguments) { _services = services; ShowSettings = new Interaction(); ShowAbout = new Interaction(); @@ -63,30 +63,22 @@ public sealed partial class MainWindowViewModel : ViewModelBase /// Resolves the launch arguments into a content view model. /// /// The command-line arguments. - private void Resolve(IReadOnlyList arguments) - { - if (!LaunchArguments.TryParse(arguments, out string? folderPath)) - { + private void Resolve(IReadOnlyList arguments) { + if (!LaunchArguments.TryParse(arguments, out string? folderPath)) { Content = new ArgumentErrorPageViewModel(); return; } - if (!Directory.Exists(folderPath)) - { + if (!Directory.Exists(folderPath)) { Content = new DirectoryMissingPageViewModel(folderPath); return; } - try - { + try { OpenWorkspace(folderPath); - } - catch (WorkspaceNotInitializedException) - { + } catch (WorkspaceNotInitializedException) { Content = new NotInitializedPageViewModel(folderPath, InitializeWorkspace); - } - catch (WorkspaceLockException) - { + } catch (WorkspaceLockException) { Content = new LockConflictPageViewModel(folderPath); } } @@ -95,8 +87,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase /// Opens the workspace and switches the content to the normal view. /// /// The workspace folder to open. - private void OpenWorkspace(string folderPath) - { + private void OpenWorkspace(string folderPath) { WorkspaceSession session = _services.OpenWorkspace(folderPath); Content = new WorkspaceViewModel(_services, session, ShowSettings, ShowAbout); Title = $"{new DirectoryInfo(folderPath).Name} - {Resources.App_Name}"; @@ -107,22 +98,17 @@ public sealed partial class MainWindowViewModel : ViewModelBase /// columns and opens the resulting workspace. /// /// A completed task once initialization has been attempted. - private Task InitializeCurrentWorkspaceAsync() - { - if (Content is not NotInitializedPageViewModel page) - { + private Task InitializeCurrentWorkspaceAsync() { + if (Content is not NotInitializedPageViewModel page) { return Task.CompletedTask; } - try - { + try { WorkspaceInitializer.Initialize(page.FolderPath); WorkspacePreset.AddPresetColumns(page.FolderPath); WorkspacePreset.AddPresetTags(page.FolderPath); OpenWorkspace(page.FolderPath); - } - catch (Exception exception) when (exception is YKanBanException or IOException or UnauthorizedAccessException) - { + } catch (Exception exception) when (exception is YKanBanException or IOException or UnauthorizedAccessException) { // Initialization failed; the page remains so the user can retry. Debug.WriteLine(exception); } diff --git a/YKanBan/ViewModels/Tags/TagItemViewModel.cs b/YKanBan/ViewModels/Tags/TagItemViewModel.cs index 8df4930..8d3f646 100644 --- a/YKanBan/ViewModels/Tags/TagItemViewModel.cs +++ b/YKanBan/ViewModels/Tags/TagItemViewModel.cs @@ -1,5 +1,7 @@ using System.Globalization; + using CommunityToolkit.Mvvm.Input; + using YKanBan.Models; using YKanBan.ViewModels.Board; @@ -9,8 +11,7 @@ namespace YKanBan.ViewModels.Tags; /// One row of the tag manager: a color swatch, the name, the description and the /// usage count, plus edit and delete actions. /// -public sealed partial class TagItemViewModel -{ +public sealed partial class TagItemViewModel { private readonly TagsViewModel _owner; /// @@ -19,8 +20,7 @@ public sealed partial class TagItemViewModel /// The owning tag manager. /// The tag model. /// The number of cards using the tag. - public TagItemViewModel(TagsViewModel owner, TagModel tag, long usageCount) - { + public TagItemViewModel(TagsViewModel owner, TagModel tag, long usageCount) { _owner = owner; Tag = tag; Badge = new TagBadgeViewModel(tag); diff --git a/YKanBan/ViewModels/Tags/TagsViewModel.cs b/YKanBan/ViewModels/Tags/TagsViewModel.cs index cff3070..ce70928 100644 --- a/YKanBan/ViewModels/Tags/TagsViewModel.cs +++ b/YKanBan/ViewModels/Tags/TagsViewModel.cs @@ -1,5 +1,7 @@ using System.Collections.ObjectModel; + using CommunityToolkit.Mvvm.Input; + using YKanBan.Models; using YKanBan.Storage.Workspace; @@ -10,8 +12,7 @@ namespace YKanBan.ViewModels.Tags; /// re-describe and delete), showing each tag's color, name, description and /// usage count. Tag changes also refresh the board. /// -public sealed partial class TagsViewModel : ViewModelBase -{ +public sealed partial class TagsViewModel : ViewModelBase { private readonly WorkspaceRepository _repository; private readonly Action _boardChanged; @@ -20,8 +21,7 @@ public sealed partial class TagsViewModel : ViewModelBase /// /// The workspace repository. /// Invoked after a tag change so the board refreshes. - public TagsViewModel(WorkspaceRepository repository, Action boardChanged) - { + public TagsViewModel(WorkspaceRepository repository, Action boardChanged) { _repository = repository; _boardChanged = boardChanged; Reload(); @@ -47,13 +47,19 @@ public sealed partial class TagsViewModel : ViewModelBase /// public bool HasNoTags => Tags.Count == 0; - /// Gets the interaction that opens the new-tag editor. + /// + /// Gets the interaction that opens the new-tag editor. + /// public Interaction AddTag { get; } = new(); - /// Gets the interaction that opens the tag editor. + /// + /// Gets the interaction that opens the tag editor. + /// public Interaction EditTag { get; } = new(); - /// Gets the interaction that confirms and deletes a tag. + /// + /// Gets the interaction that confirms and deletes a tag. + /// public Interaction DeleteTag { get; } = new(); /// @@ -79,11 +85,9 @@ public sealed partial class TagsViewModel : ViewModelBase /// /// Reloads the tag list from the repository. /// - public void Reload() - { + public void Reload() { Tags.Clear(); - foreach ((TagModel tag, long usage) in _repository.GetTagsWithUsage()) - { + foreach ((TagModel tag, long usage) in _repository.GetTagsWithUsage()) { Tags.Add(new TagItemViewModel(this, tag, usage)); } @@ -94,8 +98,7 @@ public sealed partial class TagsViewModel : ViewModelBase /// /// Reloads the tags and refreshes the board after a tag change. /// - public void RefreshAfterChange() - { + public void RefreshAfterChange() { Reload(); _boardChanged(); } diff --git a/YKanBan/ViewModels/ViewModelBase.cs b/YKanBan/ViewModels/ViewModelBase.cs index 5b2f8b5..bdd40ea 100644 --- a/YKanBan/ViewModels/ViewModelBase.cs +++ b/YKanBan/ViewModels/ViewModelBase.cs @@ -2,6 +2,5 @@ namespace YKanBan.ViewModels; -public class ViewModelBase : ObservableObject -{ +public class ViewModelBase : ObservableObject { } diff --git a/YKanBan/ViewModels/WorkspaceViewModel.cs b/YKanBan/ViewModels/WorkspaceViewModel.cs index 2fcadab..38b573f 100644 --- a/YKanBan/ViewModels/WorkspaceViewModel.cs +++ b/YKanBan/ViewModels/WorkspaceViewModel.cs @@ -1,4 +1,5 @@ using CommunityToolkit.Mvvm.Input; + using YKanBan.Services; using YKanBan.Storage.Workspace; using YKanBan.ViewModels.Board; @@ -10,8 +11,7 @@ namespace YKanBan.ViewModels; /// The normal workspace view: the top display area (name, path and the export, /// settings and about buttons) plus the board and tags tabs. /// -public sealed partial class WorkspaceViewModel : ViewModelBase -{ +public sealed partial class WorkspaceViewModel : ViewModelBase { private readonly Interaction _showSettings; private readonly Interaction _showAbout; @@ -26,8 +26,7 @@ public sealed partial class WorkspaceViewModel : ViewModelBase AppServices services, WorkspaceSession session, Interaction showSettings, - Interaction showAbout) - { + Interaction showAbout) { Session = session; WorkspaceName = new DirectoryInfo(session.FolderPath).Name; WorkspacePath = session.FolderPath; diff --git a/YKanBan/Views/Board/BoardView.axaml.cs b/YKanBan/Views/Board/BoardView.axaml.cs index 61ca0b3..e508448 100644 --- a/YKanBan/Views/Board/BoardView.axaml.cs +++ b/YKanBan/Views/Board/BoardView.axaml.cs @@ -1,24 +1,21 @@ using Avalonia.Controls; + using YKanBan.ViewModels.Board; using YKanBan.ViewModels.Dialogs; using YKanBan.Views.Dialogs; namespace YKanBan.Views.Board; -public partial class BoardView : UserControl -{ +public partial class BoardView : UserControl { private bool _registered; - public BoardView() - { + public BoardView() { InitializeComponent(); } - protected override void OnDataContextChanged(EventArgs e) - { + protected override void OnDataContextChanged(EventArgs e) { base.OnDataContextChanged(e); - if (DataContext is BoardViewModel board && !_registered) - { + if (DataContext is BoardViewModel board && !_registered) { _registered = true; board.AddColumn.RegisterHandler(_ => EditColumnAsync(board, null)); board.EditColumn.RegisterHandler(column => EditColumnAsync(board, column)); @@ -31,47 +28,44 @@ public partial class BoardView : UserControl } } - /// Gets the window that owns this board, used as the dialog owner. + /// + /// Gets the window that owns this board, used as the dialog owner. + /// private Window? OwningWindow => TopLevel.GetTopLevel(this) as Window; - /// Shows the add/edit column dialog and reloads on success. - private async Task EditColumnAsync(BoardViewModel board, ColumnViewModel? column) - { - if (OwningWindow is not Window owner) - { + /// + /// Shows the add/edit column dialog and reloads on success. + /// + private async Task EditColumnAsync(BoardViewModel board, ColumnViewModel? column) { + if (OwningWindow is not Window owner) { return; } - var dialog = new ColumnEditorDialog - { + var dialog = new ColumnEditorDialog { DataContext = new ColumnEditorViewModel(board.Repository, column?.Model), }; - if (await dialog.ShowDialog(owner)) - { + if (await dialog.ShowDialog(owner)) { board.Reload(); } } - /// Confirms and deletes a column, then reloads. - private async Task DeleteColumnAsync(BoardViewModel board, ColumnViewModel column) - { - if (OwningWindow is not Window owner) - { + /// + /// Confirms and deletes a column, then reloads. + /// + private async Task DeleteColumnAsync(BoardViewModel board, ColumnViewModel column) { + if (OwningWindow is not Window owner) { return; } bool confirmed = true; - if (board.Config.Confirmations.DeleteColumn) - { - var dialog = new DeleteColumnConfirmDialog - { + if (board.Config.Confirmations.DeleteColumn) { + var dialog = new DeleteColumnConfirmDialog { DataContext = new DeleteColumnConfirmViewModel(column.Title, column.CardCount), }; confirmed = await dialog.ShowDialog(owner); } - if (!confirmed) - { + if (!confirmed) { return; } @@ -79,45 +73,40 @@ public partial class BoardView : UserControl board.Reload(); } - /// Shows the add/edit card dialog and reloads on success. - private async Task EditCardAsync(BoardViewModel board, ColumnViewModel? column, CardViewModel? card) - { - if (OwningWindow is not Window owner) - { + /// + /// Shows the add/edit card dialog and reloads on success. + /// + private async Task EditCardAsync(BoardViewModel board, ColumnViewModel? column, CardViewModel? card) { + if (OwningWindow is not Window owner) { return; } long columnId = card?.ColumnId ?? column!.Id; - var dialog = new CardEditorDialog - { + var dialog = new CardEditorDialog { DataContext = new CardEditorViewModel(board.Repository, columnId, card?.Model), }; - if (await dialog.ShowDialog(owner)) - { + if (await dialog.ShowDialog(owner)) { board.Reload(); } } - /// Confirms and deletes a card, then reloads. - private async Task DeleteCardAsync(BoardViewModel board, CardViewModel card) - { - if (OwningWindow is not Window owner) - { + /// + /// Confirms and deletes a card, then reloads. + /// + private async Task DeleteCardAsync(BoardViewModel board, CardViewModel card) { + if (OwningWindow is not Window owner) { return; } bool confirmed = true; - if (board.Config.Confirmations.DeleteCard) - { - var dialog = new DeleteCardConfirmDialog - { + if (board.Config.Confirmations.DeleteCard) { + var dialog = new DeleteCardConfirmDialog { DataContext = new DeleteCardConfirmViewModel(card.Id), }; confirmed = await dialog.ShowDialog(owner); } - if (!confirmed) - { + if (!confirmed) { return; } @@ -125,35 +114,32 @@ public partial class BoardView : UserControl board.Reload(); } - /// Shows the move-card dialog and reloads when a destination is chosen. - private async Task MoveCardAsync(BoardViewModel board, CardViewModel card) - { - if (OwningWindow is not Window owner) - { + /// + /// Shows the move-card dialog and reloads when a destination is chosen. + /// + private async Task MoveCardAsync(BoardViewModel board, CardViewModel card) { + if (OwningWindow is not Window owner) { return; } - var dialog = new MoveCardDialog - { + var dialog = new MoveCardDialog { DataContext = new MoveCardViewModel(board.Repository.GetColumns(), card.ColumnId), }; - if (await dialog.ShowDialog(owner) is long targetColumnId) - { + if (await dialog.ShowDialog(owner) is long targetColumnId) { board.Repository.MoveCard(card.Id, targetColumnId); board.Reload(); } } - /// Shows the search syntax error dialog. - private async Task ShowSearchErrorAsync(string detail) - { - if (OwningWindow is not Window owner) - { + /// + /// Shows the search syntax error dialog. + /// + private async Task ShowSearchErrorAsync(string detail) { + if (OwningWindow is not Window owner) { return; } - var dialog = new SearchErrorDialog - { + var dialog = new SearchErrorDialog { DataContext = new SearchErrorViewModel(detail), }; await dialog.ShowDialog(owner); diff --git a/YKanBan/Views/Board/CardView.axaml.cs b/YKanBan/Views/Board/CardView.axaml.cs index 07dcfc0..de4658b 100644 --- a/YKanBan/Views/Board/CardView.axaml.cs +++ b/YKanBan/Views/Board/CardView.axaml.cs @@ -1,25 +1,26 @@ using Avalonia.Controls; using Avalonia.Input; + using YKanBan.ViewModels.Board; namespace YKanBan.Views.Board; -public partial class CardView : UserControl -{ - public CardView() - { +public partial class CardView : UserControl { + public CardView() { InitializeComponent(); } - /// Clicking the card body opens the card editor. - private void OnCardTapped(object? sender, TappedEventArgs e) - { - if (DataContext is CardViewModel card) - { + /// + /// Clicking the card body opens the card editor. + /// + private void OnCardTapped(object? sender, TappedEventArgs e) { + if (DataContext is CardViewModel card) { card.EditCommand.Execute(null); } } - /// Stops the corner menu button's tap from bubbling to the card body. + /// + /// Stops the corner menu button's tap from bubbling to the card body. + /// private void OnMenuButtonTapped(object? sender, TappedEventArgs e) => e.Handled = true; } diff --git a/YKanBan/Views/Board/ColumnView.axaml.cs b/YKanBan/Views/Board/ColumnView.axaml.cs index 110f6a6..c679391 100644 --- a/YKanBan/Views/Board/ColumnView.axaml.cs +++ b/YKanBan/Views/Board/ColumnView.axaml.cs @@ -2,10 +2,8 @@ using Avalonia.Controls; namespace YKanBan.Views.Board; -public partial class ColumnView : UserControl -{ - public ColumnView() - { +public partial class ColumnView : UserControl { + public ColumnView() { InitializeComponent(); } } diff --git a/YKanBan/Views/Dialogs/AboutDialog.axaml.cs b/YKanBan/Views/Dialogs/AboutDialog.axaml.cs index 7fb96ea..9fd8fb1 100644 --- a/YKanBan/Views/Dialogs/AboutDialog.axaml.cs +++ b/YKanBan/Views/Dialogs/AboutDialog.axaml.cs @@ -3,13 +3,13 @@ using Avalonia.Interactivity; namespace YKanBan.Views.Dialogs; -public partial class AboutDialog : Window -{ - public AboutDialog() - { +public partial class AboutDialog : Window { + public AboutDialog() { InitializeComponent(); } - /// Closes the dialog. + /// + /// Closes the dialog. + /// private void OnCloseClick(object? sender, RoutedEventArgs e) => Close(); } diff --git a/YKanBan/Views/Dialogs/CardEditorDialog.axaml.cs b/YKanBan/Views/Dialogs/CardEditorDialog.axaml.cs index f317f1c..dd84eb6 100644 --- a/YKanBan/Views/Dialogs/CardEditorDialog.axaml.cs +++ b/YKanBan/Views/Dialogs/CardEditorDialog.axaml.cs @@ -1,40 +1,36 @@ using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Threading; + using YKanBan.Models; using YKanBan.ViewModels; using YKanBan.ViewModels.Dialogs; namespace YKanBan.Views.Dialogs; -public partial class CardEditorDialog : Window -{ +public partial class CardEditorDialog : Window { private bool _allowClose; - public CardEditorDialog() - { + public CardEditorDialog() { InitializeComponent(); } - protected override void OnDataContextChanged(EventArgs e) - { + protected override void OnDataContextChanged(EventArgs e) { base.OnDataContextChanged(e); - if (DataContext is CardEditorViewModel viewModel) - { + if (DataContext is CardEditorViewModel viewModel) { viewModel.ShowTagPicker.RegisterHandler(ShowTagPickerAsync); } } - /// Shows the nested tag picker, owned by this dialog. - private async Task ShowTagPickerAsync(Unit input) - { - if (DataContext is not CardEditorViewModel viewModel) - { + /// + /// Shows the nested tag picker, owned by this dialog. + /// + private async Task ShowTagPickerAsync(Unit input) { + if (DataContext is not CardEditorViewModel viewModel) { return null; } - var dialog = new TagPickerDialog - { + var dialog = new TagPickerDialog { DataContext = new TagPickerViewModel( viewModel.Repository, viewModel.AssignedTags.Select(row => row.Tag.Id).ToList()), @@ -42,47 +38,45 @@ public partial class CardEditorDialog : Window return await dialog.ShowDialog(this); } - /// Writes the card and closes when accepted. - private void OnOkClick(object? sender, RoutedEventArgs e) - { - if (DataContext is CardEditorViewModel viewModel && viewModel.TryAccept()) - { + /// + /// Writes the card and closes when accepted. + /// + private void OnOkClick(object? sender, RoutedEventArgs e) { + if (DataContext is CardEditorViewModel viewModel && viewModel.TryAccept()) { _allowClose = true; Close(true); } } - /// Requests a close, confirming the discard first when needed. + /// + /// Requests a close, confirming the discard first when needed. + /// private void OnCancelClick(object? sender, RoutedEventArgs e) => _ = RequestCloseAsync(); - /// Intercepts the window close button to run the discard confirmation. - protected override void OnClosing(WindowClosingEventArgs e) - { + /// + /// Intercepts the window close button to run the discard confirmation. + /// + protected override void OnClosing(WindowClosingEventArgs e) { base.OnClosing(e); - if (_allowClose) - { + if (_allowClose) { return; } - if (HasUnsavedChanges && DiscardConfirmEnabled) - { + if (HasUnsavedChanges && DiscardConfirmEnabled) { e.Cancel = true; Dispatcher.UIThread.Post(() => _ = RequestCloseAsync()); - } - else - { + } else { _allowClose = true; } } - /// Confirms the discard and closes, or stays open when declined. - private async Task RequestCloseAsync() - { - if (HasUnsavedChanges && DiscardConfirmEnabled) - { + /// + /// Confirms the discard and closes, or stays open when declined. + /// + private async Task RequestCloseAsync() { + if (HasUnsavedChanges && DiscardConfirmEnabled) { var dialog = new DiscardEditConfirmDialog { DataContext = new DiscardEditConfirmViewModel() }; - if (!await dialog.ShowDialog(this)) - { + if (!await dialog.ShowDialog(this)) { return; } } @@ -91,9 +85,13 @@ public partial class CardEditorDialog : Window Close(false); } - /// Gets whether the editor has unsaved changes. + /// + /// Gets whether the editor has unsaved changes. + /// private bool HasUnsavedChanges => DataContext is CardEditorViewModel viewModel && viewModel.HasChanges; - /// Gets whether the discard confirmation setting is enabled. + /// + /// Gets whether the discard confirmation setting is enabled. + /// private static bool DiscardConfirmEnabled => App.Services?.Config.Confirmations.DiscardEdit == true; } diff --git a/YKanBan/Views/Dialogs/ColumnEditorDialog.axaml.cs b/YKanBan/Views/Dialogs/ColumnEditorDialog.axaml.cs index 315bd60..f1d9e4d 100644 --- a/YKanBan/Views/Dialogs/ColumnEditorDialog.axaml.cs +++ b/YKanBan/Views/Dialogs/ColumnEditorDialog.axaml.cs @@ -1,25 +1,26 @@ using Avalonia.Controls; using Avalonia.Interactivity; + using YKanBan.ViewModels.Dialogs; namespace YKanBan.Views.Dialogs; -public partial class ColumnEditorDialog : Window -{ - public ColumnEditorDialog() - { +public partial class ColumnEditorDialog : Window { + public ColumnEditorDialog() { InitializeComponent(); } - /// Writes the column and closes when accepted. - private void OnOkClick(object? sender, RoutedEventArgs e) - { - if (DataContext is ColumnEditorViewModel viewModel && viewModel.TryAccept()) - { + /// + /// Writes the column and closes when accepted. + /// + private void OnOkClick(object? sender, RoutedEventArgs e) { + if (DataContext is ColumnEditorViewModel viewModel && viewModel.TryAccept()) { Close(true); } } - /// Closes without writing. + /// + /// Closes without writing. + /// private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false); } diff --git a/YKanBan/Views/Dialogs/DeleteCardConfirmDialog.axaml.cs b/YKanBan/Views/Dialogs/DeleteCardConfirmDialog.axaml.cs index a49ea73..b76e52b 100644 --- a/YKanBan/Views/Dialogs/DeleteCardConfirmDialog.axaml.cs +++ b/YKanBan/Views/Dialogs/DeleteCardConfirmDialog.axaml.cs @@ -3,16 +3,18 @@ using Avalonia.Interactivity; namespace YKanBan.Views.Dialogs; -public partial class DeleteCardConfirmDialog : Window -{ - public DeleteCardConfirmDialog() - { +public partial class DeleteCardConfirmDialog : Window { + public DeleteCardConfirmDialog() { InitializeComponent(); } - /// Confirms the deletion. + /// + /// Confirms the deletion. + /// private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true); - /// Cancels the deletion. + /// + /// Cancels the deletion. + /// private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false); } diff --git a/YKanBan/Views/Dialogs/DeleteColumnConfirmDialog.axaml.cs b/YKanBan/Views/Dialogs/DeleteColumnConfirmDialog.axaml.cs index 4fea518..80accf1 100644 --- a/YKanBan/Views/Dialogs/DeleteColumnConfirmDialog.axaml.cs +++ b/YKanBan/Views/Dialogs/DeleteColumnConfirmDialog.axaml.cs @@ -3,16 +3,18 @@ using Avalonia.Interactivity; namespace YKanBan.Views.Dialogs; -public partial class DeleteColumnConfirmDialog : Window -{ - public DeleteColumnConfirmDialog() - { +public partial class DeleteColumnConfirmDialog : Window { + public DeleteColumnConfirmDialog() { InitializeComponent(); } - /// Confirms the deletion. + /// + /// Confirms the deletion. + /// private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true); - /// Cancels the deletion. + /// + /// Cancels the deletion. + /// private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false); } diff --git a/YKanBan/Views/Dialogs/DeleteTagConfirmDialog.axaml.cs b/YKanBan/Views/Dialogs/DeleteTagConfirmDialog.axaml.cs index 5f756e2..4f7ffa1 100644 --- a/YKanBan/Views/Dialogs/DeleteTagConfirmDialog.axaml.cs +++ b/YKanBan/Views/Dialogs/DeleteTagConfirmDialog.axaml.cs @@ -3,16 +3,18 @@ using Avalonia.Interactivity; namespace YKanBan.Views.Dialogs; -public partial class DeleteTagConfirmDialog : Window -{ - public DeleteTagConfirmDialog() - { +public partial class DeleteTagConfirmDialog : Window { + public DeleteTagConfirmDialog() { InitializeComponent(); } - /// Confirms the deletion. + /// + /// Confirms the deletion. + /// private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true); - /// Cancels the deletion. + /// + /// Cancels the deletion. + /// private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false); } diff --git a/YKanBan/Views/Dialogs/DiscardEditConfirmDialog.axaml.cs b/YKanBan/Views/Dialogs/DiscardEditConfirmDialog.axaml.cs index caa6a36..a82f0ac 100644 --- a/YKanBan/Views/Dialogs/DiscardEditConfirmDialog.axaml.cs +++ b/YKanBan/Views/Dialogs/DiscardEditConfirmDialog.axaml.cs @@ -3,16 +3,18 @@ using Avalonia.Interactivity; namespace YKanBan.Views.Dialogs; -public partial class DiscardEditConfirmDialog : Window -{ - public DiscardEditConfirmDialog() - { +public partial class DiscardEditConfirmDialog : Window { + public DiscardEditConfirmDialog() { InitializeComponent(); } - /// Confirms discarding the changes. + /// + /// Confirms discarding the changes. + /// private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true); - /// Keeps editing. + /// + /// Keeps editing. + /// private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false); } diff --git a/YKanBan/Views/Dialogs/ExportDoneDialog.axaml.cs b/YKanBan/Views/Dialogs/ExportDoneDialog.axaml.cs index 87655ec..50ca3d1 100644 --- a/YKanBan/Views/Dialogs/ExportDoneDialog.axaml.cs +++ b/YKanBan/Views/Dialogs/ExportDoneDialog.axaml.cs @@ -3,13 +3,13 @@ using Avalonia.Interactivity; namespace YKanBan.Views.Dialogs; -public partial class ExportDoneDialog : Window -{ - public ExportDoneDialog() - { +public partial class ExportDoneDialog : Window { + public ExportDoneDialog() { InitializeComponent(); } - /// Closes the dialog. + /// + /// Closes the dialog. + /// private void OnOkClick(object? sender, RoutedEventArgs e) => Close(); } diff --git a/YKanBan/Views/Dialogs/MoveCardDialog.axaml.cs b/YKanBan/Views/Dialogs/MoveCardDialog.axaml.cs index ad68989..490802c 100644 --- a/YKanBan/Views/Dialogs/MoveCardDialog.axaml.cs +++ b/YKanBan/Views/Dialogs/MoveCardDialog.axaml.cs @@ -1,25 +1,26 @@ using Avalonia.Controls; using Avalonia.Interactivity; + using YKanBan.ViewModels.Dialogs; namespace YKanBan.Views.Dialogs; -public partial class MoveCardDialog : Window -{ - public MoveCardDialog() - { +public partial class MoveCardDialog : Window { + public MoveCardDialog() { InitializeComponent(); } - /// Closes with the selected destination column id. - private void OnOkClick(object? sender, RoutedEventArgs e) - { - if (DataContext is MoveCardViewModel viewModel && viewModel.SelectedTarget is not null) - { + /// + /// Closes with the selected destination column id. + /// + private void OnOkClick(object? sender, RoutedEventArgs e) { + if (DataContext is MoveCardViewModel viewModel && viewModel.SelectedTarget is not null) { Close((long)viewModel.SelectedTarget.Value); } } - /// Closes without moving. + /// + /// Closes without moving. + /// private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(null); } diff --git a/YKanBan/Views/Dialogs/SearchErrorDialog.axaml.cs b/YKanBan/Views/Dialogs/SearchErrorDialog.axaml.cs index 3ccbdb5..c13d5c0 100644 --- a/YKanBan/Views/Dialogs/SearchErrorDialog.axaml.cs +++ b/YKanBan/Views/Dialogs/SearchErrorDialog.axaml.cs @@ -3,13 +3,13 @@ using Avalonia.Interactivity; namespace YKanBan.Views.Dialogs; -public partial class SearchErrorDialog : Window -{ - public SearchErrorDialog() - { +public partial class SearchErrorDialog : Window { + public SearchErrorDialog() { InitializeComponent(); } - /// Closes the dialog. + /// + /// Closes the dialog. + /// private void OnOkClick(object? sender, RoutedEventArgs e) => Close(); } diff --git a/YKanBan/Views/Dialogs/SettingsDialog.axaml.cs b/YKanBan/Views/Dialogs/SettingsDialog.axaml.cs index ba0cac9..cf42918 100644 --- a/YKanBan/Views/Dialogs/SettingsDialog.axaml.cs +++ b/YKanBan/Views/Dialogs/SettingsDialog.axaml.cs @@ -1,23 +1,25 @@ using Avalonia.Controls; using Avalonia.Interactivity; + using YKanBan.ViewModels.Dialogs; namespace YKanBan.Views.Dialogs; -public partial class SettingsDialog : Window -{ - public SettingsDialog() - { +public partial class SettingsDialog : Window { + public SettingsDialog() { InitializeComponent(); } - /// Applies the working settings and closes with a positive result. - private void OnOkClick(object? sender, RoutedEventArgs e) - { + /// + /// Applies the working settings and closes with a positive result. + /// + private void OnOkClick(object? sender, RoutedEventArgs e) { (DataContext as SettingsDialogViewModel)?.Accept(); Close(true); } - /// Closes without applying anything. + /// + /// Closes without applying anything. + /// private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false); } diff --git a/YKanBan/Views/Dialogs/TagEditorDialog.axaml.cs b/YKanBan/Views/Dialogs/TagEditorDialog.axaml.cs index 3f9f8da..a459821 100644 --- a/YKanBan/Views/Dialogs/TagEditorDialog.axaml.cs +++ b/YKanBan/Views/Dialogs/TagEditorDialog.axaml.cs @@ -1,25 +1,26 @@ using Avalonia.Controls; using Avalonia.Interactivity; + using YKanBan.ViewModels.Dialogs; namespace YKanBan.Views.Dialogs; -public partial class TagEditorDialog : Window -{ - public TagEditorDialog() - { +public partial class TagEditorDialog : Window { + public TagEditorDialog() { InitializeComponent(); } - /// Writes the tag and closes when accepted. - private void OnOkClick(object? sender, RoutedEventArgs e) - { - if (DataContext is TagEditorViewModel viewModel && viewModel.TryAccept()) - { + /// + /// Writes the tag and closes when accepted. + /// + private void OnOkClick(object? sender, RoutedEventArgs e) { + if (DataContext is TagEditorViewModel viewModel && viewModel.TryAccept()) { Close(true); } } - /// Closes without writing. + /// + /// Closes without writing. + /// private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false); } diff --git a/YKanBan/Views/Dialogs/TagPickerDialog.axaml.cs b/YKanBan/Views/Dialogs/TagPickerDialog.axaml.cs index c0343dc..42be563 100644 --- a/YKanBan/Views/Dialogs/TagPickerDialog.axaml.cs +++ b/YKanBan/Views/Dialogs/TagPickerDialog.axaml.cs @@ -1,25 +1,26 @@ using Avalonia.Controls; using Avalonia.Interactivity; + using YKanBan.ViewModels.Dialogs; namespace YKanBan.Views.Dialogs; -public partial class TagPickerDialog : Window -{ - public TagPickerDialog() - { +public partial class TagPickerDialog : Window { + public TagPickerDialog() { InitializeComponent(); } - /// Closes with the selected tag. - private void OnOkClick(object? sender, RoutedEventArgs e) - { - if (DataContext is TagPickerViewModel viewModel && viewModel.SelectedTarget is { } badge) - { + /// + /// Closes with the selected tag. + /// + private void OnOkClick(object? sender, RoutedEventArgs e) { + if (DataContext is TagPickerViewModel viewModel && viewModel.SelectedTarget is { } badge) { Close(badge.Tag); } } - /// Closes without picking a tag. + /// + /// Closes without picking a tag. + /// private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(null); } diff --git a/YKanBan/Views/ErrorPages/ArgumentErrorPageView.axaml.cs b/YKanBan/Views/ErrorPages/ArgumentErrorPageView.axaml.cs index 2dc50a8..2fbed49 100644 --- a/YKanBan/Views/ErrorPages/ArgumentErrorPageView.axaml.cs +++ b/YKanBan/Views/ErrorPages/ArgumentErrorPageView.axaml.cs @@ -2,10 +2,8 @@ using Avalonia.Controls; namespace YKanBan.Views.ErrorPages; -public partial class ArgumentErrorPageView : UserControl -{ - public ArgumentErrorPageView() - { +public partial class ArgumentErrorPageView : UserControl { + public ArgumentErrorPageView() { InitializeComponent(); } } diff --git a/YKanBan/Views/ErrorPages/DirectoryMissingPageView.axaml.cs b/YKanBan/Views/ErrorPages/DirectoryMissingPageView.axaml.cs index aa4c9f0..a18f359 100644 --- a/YKanBan/Views/ErrorPages/DirectoryMissingPageView.axaml.cs +++ b/YKanBan/Views/ErrorPages/DirectoryMissingPageView.axaml.cs @@ -2,10 +2,8 @@ using Avalonia.Controls; namespace YKanBan.Views.ErrorPages; -public partial class DirectoryMissingPageView : UserControl -{ - public DirectoryMissingPageView() - { +public partial class DirectoryMissingPageView : UserControl { + public DirectoryMissingPageView() { InitializeComponent(); } } diff --git a/YKanBan/Views/ErrorPages/LockConflictPageView.axaml.cs b/YKanBan/Views/ErrorPages/LockConflictPageView.axaml.cs index 0905d8e..11d3de8 100644 --- a/YKanBan/Views/ErrorPages/LockConflictPageView.axaml.cs +++ b/YKanBan/Views/ErrorPages/LockConflictPageView.axaml.cs @@ -2,10 +2,8 @@ using Avalonia.Controls; namespace YKanBan.Views.ErrorPages; -public partial class LockConflictPageView : UserControl -{ - public LockConflictPageView() - { +public partial class LockConflictPageView : UserControl { + public LockConflictPageView() { InitializeComponent(); } } diff --git a/YKanBan/Views/ErrorPages/NotInitializedPageView.axaml.cs b/YKanBan/Views/ErrorPages/NotInitializedPageView.axaml.cs index 0c90457..acc93af 100644 --- a/YKanBan/Views/ErrorPages/NotInitializedPageView.axaml.cs +++ b/YKanBan/Views/ErrorPages/NotInitializedPageView.axaml.cs @@ -2,10 +2,8 @@ using Avalonia.Controls; namespace YKanBan.Views.ErrorPages; -public partial class NotInitializedPageView : UserControl -{ - public NotInitializedPageView() - { +public partial class NotInitializedPageView : UserControl { + public NotInitializedPageView() { InitializeComponent(); } } diff --git a/YKanBan/Views/MainWindow.axaml.cs b/YKanBan/Views/MainWindow.axaml.cs index 6cbaf8f..29730ec 100644 --- a/YKanBan/Views/MainWindow.axaml.cs +++ b/YKanBan/Views/MainWindow.axaml.cs @@ -1,50 +1,46 @@ using Avalonia.Controls; + using YKanBan.ViewModels; using YKanBan.ViewModels.Dialogs; using YKanBan.Views.Dialogs; namespace YKanBan.Views; -public partial class MainWindow : Window -{ +public partial class MainWindow : Window { private bool _interactionsRegistered; - public MainWindow() - { + public MainWindow() { InitializeComponent(); } - protected override void OnDataContextChanged(EventArgs e) - { + protected override void OnDataContextChanged(EventArgs e) { base.OnDataContextChanged(e); - if (DataContext is MainWindowViewModel viewModel && !_interactionsRegistered) - { + if (DataContext is MainWindowViewModel viewModel && !_interactionsRegistered) { _interactionsRegistered = true; viewModel.ShowSettings.RegisterHandler(ShowSettingsAsync); viewModel.ShowAbout.RegisterHandler(ShowAboutAsync); } } - /// Shows the settings dialog centered on this window. - private async Task ShowSettingsAsync(Unit input) - { - if (App.Services is null) - { + /// + /// Shows the settings dialog centered on this window. + /// + private async Task ShowSettingsAsync(Unit input) { + if (App.Services is null) { return; } - var dialog = new SettingsDialog - { + var dialog = new SettingsDialog { DataContext = new SettingsDialogViewModel(App.Services.Config, App.StartupLanguage), }; await dialog.ShowDialog(this); } - /// Shows the about dialog centered on this window. - private async Task ShowAboutAsync(Unit input) - { - var dialog = new AboutDialog - { + /// + /// Shows the about dialog centered on this window. + /// + private async Task ShowAboutAsync(Unit input) { + var dialog = new AboutDialog { DataContext = new AboutDialogViewModel(), }; await dialog.ShowDialog(this); diff --git a/YKanBan/Views/Tags/TagsView.axaml.cs b/YKanBan/Views/Tags/TagsView.axaml.cs index ca7cc76..d51d415 100644 --- a/YKanBan/Views/Tags/TagsView.axaml.cs +++ b/YKanBan/Views/Tags/TagsView.axaml.cs @@ -1,24 +1,21 @@ using Avalonia.Controls; + using YKanBan.ViewModels.Dialogs; using YKanBan.ViewModels.Tags; using YKanBan.Views.Dialogs; namespace YKanBan.Views.Tags; -public partial class TagsView : UserControl -{ +public partial class TagsView : UserControl { private bool _registered; - public TagsView() - { + public TagsView() { InitializeComponent(); } - protected override void OnDataContextChanged(EventArgs e) - { + protected override void OnDataContextChanged(EventArgs e) { base.OnDataContextChanged(e); - if (DataContext is TagsViewModel tags && !_registered) - { + if (DataContext is TagsViewModel tags && !_registered) { _registered = true; tags.AddTag.RegisterHandler(_ => EditTagAsync(tags, null)); tags.EditTag.RegisterHandler(item => EditTagAsync(tags, item)); @@ -26,47 +23,44 @@ public partial class TagsView : UserControl } } - /// Gets the window that owns this view, used as the dialog owner. + /// + /// Gets the window that owns this view, used as the dialog owner. + /// private Window? OwningWindow => TopLevel.GetTopLevel(this) as Window; - /// Shows the add/edit tag dialog and refreshes on success. - private async Task EditTagAsync(TagsViewModel tags, TagItemViewModel? item) - { - if (OwningWindow is not Window owner) - { + /// + /// Shows the add/edit tag dialog and refreshes on success. + /// + private async Task EditTagAsync(TagsViewModel tags, TagItemViewModel? item) { + if (OwningWindow is not Window owner) { return; } - var dialog = new TagEditorDialog - { + var dialog = new TagEditorDialog { DataContext = new TagEditorViewModel(tags.Repository, item?.Tag), }; - if (await dialog.ShowDialog(owner)) - { + if (await dialog.ShowDialog(owner)) { tags.RefreshAfterChange(); } } - /// Confirms and deletes a tag, then refreshes. - private async Task DeleteTagAsync(TagsViewModel tags, TagItemViewModel item) - { - if (OwningWindow is not Window owner) - { + /// + /// Confirms and deletes a tag, then refreshes. + /// + private async Task DeleteTagAsync(TagsViewModel tags, TagItemViewModel item) { + if (OwningWindow is not Window owner) { return; } bool confirmed = true; - if (App.Services?.Config.Confirmations.DeleteTag == true) - { - var dialog = new DeleteTagConfirmDialog - { + if (App.Services?.Config.Confirmations.DeleteTag == true) { + var dialog = new DeleteTagConfirmDialog { DataContext = new DeleteTagConfirmViewModel(item.Name, item.UsageCount), }; confirmed = await dialog.ShowDialog(owner); } - if (!confirmed) - { + if (!confirmed) { return; } diff --git a/YKanBan/Views/WorkspaceView.axaml.cs b/YKanBan/Views/WorkspaceView.axaml.cs index 17f126c..9b99113 100644 --- a/YKanBan/Views/WorkspaceView.axaml.cs +++ b/YKanBan/Views/WorkspaceView.axaml.cs @@ -1,55 +1,51 @@ using System.Text; + using Avalonia.Controls; using Avalonia.Platform.Storage; + using YKanBan.Export; using YKanBan.ViewModels; using YKanBan.ViewModels.Dialogs; using YKanBan.Views.Dialogs; + using Resx = YKanBan.Resources; namespace YKanBan.Views; -public partial class WorkspaceView : UserControl -{ +public partial class WorkspaceView : UserControl { private bool _registered; - public WorkspaceView() - { + public WorkspaceView() { InitializeComponent(); } - protected override void OnDataContextChanged(EventArgs e) - { + protected override void OnDataContextChanged(EventArgs e) { base.OnDataContextChanged(e); - if (DataContext is WorkspaceViewModel viewModel && !_registered) - { + if (DataContext is WorkspaceViewModel viewModel && !_registered) { _registered = true; viewModel.Export.RegisterHandler(ExportAsync); } } - /// Prompts for a file and writes the Markdown board export to it. - private async Task ExportAsync(Unit input) - { - if (DataContext is not WorkspaceViewModel viewModel) - { + /// + /// Prompts for a file and writes the Markdown board export to it. + /// + private async Task ExportAsync(Unit input) { + if (DataContext is not WorkspaceViewModel viewModel) { return; } - if (TopLevel.GetTopLevel(this)?.StorageProvider is not { } storage) - { + if (TopLevel.GetTopLevel(this)?.StorageProvider is not { } storage) { return; } DateTimeOffset exportedAt = DateTimeOffset.Now; - IStorageFile? file = await storage.SaveFilePickerAsync(new FilePickerSaveOptions - { + IStorageFile? file = await storage.SaveFilePickerAsync(new FilePickerSaveOptions { Title = Resx.Main_Export, SuggestedFileName = MarkdownExporter.BuildFileName(viewModel.WorkspaceName, exportedAt), DefaultExtension = "md", FileTypeChoices = [new FilePickerFileType("Markdown") { Patterns = ["*.md"] }], }); - if (file is null) - { + if (file is null) { return; } @@ -62,15 +58,12 @@ public partial class WorkspaceView : UserControl exportedAt); await using (Stream stream = await file.OpenWriteAsync()) - await using (var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))) - { + await using (var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))) { await writer.WriteAsync(markdown); } - if (TopLevel.GetTopLevel(this) is Window owner) - { - var dialog = new ExportDoneDialog - { + if (TopLevel.GetTopLevel(this) is Window owner) { + var dialog = new ExportDoneDialog { DataContext = new ExportDoneViewModel(file.TryGetLocalPath() ?? file.Name), }; await dialog.ShowDialog(owner);