style: expand one-line summaries and run dotnet format solution-wide

This commit is contained in:
yyc12345 committed 2026-10-04 10:22:02 +08:00
1 parent 9ea551b600
commit 8b7e50a955
95 files changed
+970 -1424

No files matched your search

+1 -1
View File
@@ -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}]
+16 -33
View File
@@ -1,5 +1,6 @@
using System.Globalization;
using System.Text;
using YKanBan.Export;
using YKanBan.Models;
@@ -14,8 +15,7 @@ namespace YKanBan.Tests.Export;
/// </summary>
[TestClass]
[DoNotParallelize]
public class MarkdownExporterTests
{
public class MarkdownExporterTests {
private static readonly DateTimeOffset ExportedAt = new(2026, 9, 30, 14, 25, 37, TimeSpan.Zero);
/// <summary>
@@ -46,8 +46,7 @@ public class MarkdownExporterTests
/// Builds the board fixture: one working column with two cards, one empty column and two tags.
/// </summary>
/// <returns>The columns, cards and tag/usage tuples.</returns>
private static (IReadOnlyList<ColumnModel> Columns, IReadOnlyList<CardModel> Cards, IReadOnlyList<(TagModel, long)> Tags) CreateBoard()
{
private static (IReadOnlyList<ColumnModel> Columns, IReadOnlyList<CardModel> 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.
/// </summary>
/// <returns>The produced Markdown document.</returns>
private static string ExportBoard()
{
private static string ExportBoard() {
(IReadOnlyList<ColumnModel> columns, IReadOnlyList<CardModel> cards, IReadOnlyList<(TagModel, long)> tags) = CreateBoard();
return MarkdownExporter.Export("MyRepo", columns, cards, tags, ExportedAt);
}
@@ -82,8 +80,7 @@ public class MarkdownExporterTests
/// </summary>
/// <param name="culture">The UI culture.</param>
/// <returns>The expected Markdown document.</returns>
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.
/// </summary>
/// <param name="culture">The UI culture to test.</param>
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;
}
}
@@ -7,32 +7,27 @@ namespace YKanBan.Tests.Launching;
/// three branches: no argument, one valid path, and invalid input.
/// </summary>
[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);
+9 -18
View File
@@ -7,11 +7,9 @@ namespace YKanBan.Tests.Models;
/// boundary in both directions.
/// </summary>
[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<FormatException>(() => 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));
}
+2 -4
View File
@@ -7,15 +7,13 @@ namespace YKanBan.Tests;
/// application project and that the test runner can discover and execute tests.
/// </summary>
[TestClass]
public class ProjectReferenceTests
{
public class ProjectReferenceTests {
/// <summary>
/// Verifies that a type defined in the application assembly can be
/// referenced from the test project.
/// </summary>
[TestMethod]
public void ApplicationAssemblyIsReferenceable()
{
public void ApplicationAssemblyIsReferenceable() {
Assert.IsNotNull(typeof(App));
}
}
+7 -14
View File
@@ -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.
/// </summary>
public sealed class SearchNodeComparer : IEqualityComparer<SearchNode>
{
public sealed class SearchNodeComparer : IEqualityComparer<SearchNode> {
/// <summary>
/// Gets the shared comparer instance.
/// </summary>
@@ -20,19 +19,15 @@ public sealed class SearchNodeComparer : IEqualityComparer<SearchNode>
/// <param name="x">The first node.</param>
/// <param name="y">The second node.</param>
/// <returns><see langword="true"/> when the nodes are structurally equal.</returns>
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<SearchNode>
/// <param name="x">The first sequence.</param>
/// <param name="y">The second sequence.</param>
/// <returns><see langword="true"/> when both sequences match element-wise.</returns>
private static bool SequencesEqual(IReadOnlyList<SearchNode> x, IReadOnlyList<SearchNode> y)
{
if (x.Count != y.Count)
{
private static bool SequencesEqual(IReadOnlyList<SearchNode> x, IReadOnlyList<SearchNode> y) {
if (x.Count != y.Count) {
return false;
}
return x.Zip(y).All(pair => Instance.Equals(pair.First, pair.Second));
+39 -78
View File
@@ -8,15 +8,13 @@ namespace YKanBan.Tests.Search;
/// <see cref="SearchSyntaxException"/>).
/// </summary>
[TestClass]
public class SearchQueryParserTests
{
public class SearchQueryParserTests {
/// <summary>
/// Parses and asserts structural equality against the expected AST.
/// </summary>
/// <param name="expected">The expected AST.</param>
/// <param name="text">The search text.</param>
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.
/// </summary>
/// <param name="text">The search text.</param>
private static void AssertSyntaxError(string text)
{
private static void AssertSyntaxError(string text) {
Assert.ThrowsExactly<SearchSyntaxException>(() => 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<SearchSyntaxException>(
() => SearchQueryParser.Parse("a AND"));
Assert.IsFalse(string.IsNullOrEmpty(exception.Message));
+18 -34
View File
@@ -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.
/// </summary>
[TestClass]
public class SearchSqlCompilerTests
{
public class SearchSqlCompilerTests {
/// <summary>
/// Owns a workspace with a fixed card/tag fixture.
/// </summary>
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
/// </summary>
/// <param name="queryText">The search text.</param>
/// <returns>The matching card ids in id order.</returns>
public List<long> Search(string queryText)
{
public List<long> 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<long>();
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");
@@ -8,11 +8,9 @@ namespace YKanBan.Tests.Storage.AppData;
/// degradation on a missing or corrupt file.
/// </summary>
[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 },
+9 -16
View File
@@ -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.
/// </summary>
[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");
@@ -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.
/// </summary>
[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);
@@ -8,11 +8,9 @@ namespace YKanBan.Tests.Storage.Workspace;
/// Tests for exclusive lock acquisition and release.
/// </summary>
[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<WorkspaceNotInitializedException>(
() => 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.
@@ -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;
/// </summary>
[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;
}
}
@@ -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.
/// </summary>
[TestClass]
public class WorkspaceRepositoryTests
{
public class WorkspaceRepositoryTests {
/// <summary>
/// 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.
/// </summary>
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<SqliteException>(() =>
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();
@@ -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.
/// </summary>
[TestClass]
public class WorkspaceSchemaTests
{
public class WorkspaceSchemaTests {
/// <summary>
/// Owns a temp directory plus a fresh workspace-schema database for a single test.
/// </summary>
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
/// <summary>
/// Closes the connection before removing the temporary directory.
/// </summary>
public void Dispose()
{
public void Dispose() {
Connection.Dispose();
Directory.Dispose();
}
}
[TestMethod]
public void EmptyColumnTitleIsRejected()
{
public void EmptyColumnTitleIsRejected() {
using var db = new FreshDatabase();
Assert.ThrowsExactly<SqliteException>(() =>
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<SqliteException>(() =>
@@ -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<SqliteException>(() =>
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<SqliteException>(() =>
@@ -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<SqliteException>(() =>
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<SqliteException>(() =>
@@ -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.
/// </summary>
/// <param name="connection">An open workspace-schema connection.</param>
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');");
@@ -10,11 +10,9 @@ namespace YKanBan.Tests.Storage.Workspace;
/// the whole session and the repository is usable through it.
/// </summary>
[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<WorkspaceNotInitializedException>(
@@ -46,8 +42,7 @@ public class WorkspaceSessionTests
}
[TestMethod]
public void RepositoryIsUsableThroughSession()
{
public void RepositoryIsUsableThroughSession() {
using var directory = new TempDirectory();
WorkspaceInitializer.Initialize(directory.FullPath);
+14 -26
View File
@@ -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.
/// </summary>
public static class SqliteTestHelper
{
public static class SqliteTestHelper {
/// <summary>
/// Executes a non-query SQL statement and returns the affected row count.
/// </summary>
@@ -17,12 +17,10 @@ public static class SqliteTestHelper
/// <param name="sql">The statement to execute.</param>
/// <param name="parameters">Name/value pairs bound as SQL parameters.</param>
/// <returns>The number of affected rows.</returns>
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
/// <param name="connection">An open database connection.</param>
/// <param name="sql">The query to execute.</param>
/// <returns>The scalar result cast to a string.</returns>
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
/// <param name="connection">An open database connection.</param>
/// <param name="sql">The query to execute.</param>
/// <returns>The scalar result cast to a long.</returns>
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
/// <param name="connection">An open database connection.</param>
/// <param name="tableName">The table name to look for.</param>
/// <returns><see langword="true"/> when the table exists.</returns>
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
/// </summary>
/// <param name="connection">An open database connection.</param>
/// <returns>The column titles in id order.</returns>
public static List<string> GetColumnTitles(SqliteConnection connection)
{
public static List<string> 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<string>();
while (reader.Read())
{
while (reader.Read()) {
titles.Add(reader.GetString(0));
}
return titles;
@@ -92,15 +85,13 @@ public static class SqliteTestHelper
/// </summary>
/// <param name="connection">An open database connection.</param>
/// <returns>The column descriptions in id order.</returns>
public static List<string> GetColumnDescriptions(SqliteConnection connection)
{
public static List<string> 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<string>();
while (reader.Read())
{
while (reader.Read()) {
descriptions.Add(reader.GetString(0));
}
return descriptions;
@@ -111,15 +102,13 @@ public static class SqliteTestHelper
/// </summary>
/// <param name="connection">An open database connection.</param>
/// <returns>The tags in id order.</returns>
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
/// </summary>
/// <param name="folderPath">The workspace folder path.</param>
/// <returns>An open, migrated connection.</returns>
public static SqliteConnection OpenWorkspace(string folderPath)
{
public static SqliteConnection OpenWorkspace(string folderPath) {
return SqliteDatabase.Open(WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations);
}
}
+7 -16
View File
@@ -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.
/// </summary>
public sealed class TempDirectory : IDisposable
{
public sealed class TempDirectory : IDisposable {
/// <summary>
/// Gets the unique directory path managed by this instance.
/// </summary>
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
/// <summary>
/// Deletes the directory tree, retrying once after a short delay.
/// </summary>
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.
}
}
+16 -27
View File
@@ -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 {
/// <summary>
/// Gets the process-wide composition root, created at startup.
/// </summary>
@@ -26,15 +28,12 @@ public partial class App : Application
/// </summary>
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
/// </summary>
/// <param name="language">The language code from app.json.</param>
/// <returns>The resolved culture.</returns>
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.
/// </summary>
/// <param name="option">The theme selection.</param>
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<DataAnnotationsValidationPlugin>().ToArray();
foreach (var plugin in dataValidationPluginsToRemove)
{
foreach (var plugin in dataValidationPluginsToRemove) {
BindingPlugins.DataValidators.Remove(plugin);
}
}
+12 -22
View File
@@ -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.
/// </summary>
public static class MarkdownExporter
{
public static class MarkdownExporter {
/// <summary>
/// Renders the whole board to Markdown.
/// </summary>
@@ -27,8 +27,7 @@ public static class MarkdownExporter
IReadOnlyList<ColumnModel> columns,
IReadOnlyList<CardModel> 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<CardModel> 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
/// <param name="workspaceName">Workspace display name.</param>
/// <param name="exportedAt">Export moment.</param>
/// <returns>The file name.</returns>
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
/// <param name="builder">The output builder.</param>
/// <param name="card">The card to render.</param>
/// <param name="culture">The UI culture.</param>
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} |");
}
+3 -6
View File
@@ -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.
/// </summary>
public static class LaunchArguments
{
public static class LaunchArguments {
/// <summary>
/// Tries to parse command-line arguments following the
/// <c>ykanban &lt;path&gt;</c> contract.
@@ -17,10 +16,8 @@ public static class LaunchArguments
/// <param name="arguments">The raw arguments, excluding the executable name.</param>
/// <param name="folderPath">The parsed workspace path on success, otherwise <see langword="null"/>.</param>
/// <returns><see langword="true"/> when exactly one non-blank path was supplied.</returns>
public static bool TryParse(IReadOnlyList<string> arguments, [NotNullWhen(true)] out string? folderPath)
{
if (arguments.Count == 1 && !string.IsNullOrWhiteSpace(arguments[0]))
{
public static bool TryParse(IReadOnlyList<string> arguments, [NotNullWhen(true)] out string? folderPath) {
if (arguments.Count == 1 && !string.IsNullOrWhiteSpace(arguments[0])) {
folderPath = arguments[0];
return true;
}
+7 -14
View File
@@ -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.
/// </summary>
public readonly record struct RgbColor(byte R, byte G, byte B)
{
public readonly record struct RgbColor(byte R, byte G, byte B) {
/// <summary>
/// Parses a "#RRGGBB" text with case-insensitive hex digits.
/// </summary>
@@ -29,17 +28,14 @@ public readonly record struct RgbColor(byte R, byte G, byte B)
/// <param name="text">The text to parse.</param>
/// <param name="color">The parsed color on success, otherwise the default value.</param>
/// <returns><see langword="true"/> when the text is exactly #RRGGBB.</returns>
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)
/// <param name="start">Index of the first of the two digits.</param>
/// <param name="value">The parsed byte value.</param>
/// <returns><see langword="true"/> when both characters are hex digits.</returns>
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)
/// </summary>
/// <param name="character">The character to convert.</param>
/// <returns>The digit value, or -1 for any other character.</returns>
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,
+2 -4
View File
@@ -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.
+11 -13
View File
@@ -5,8 +5,7 @@ namespace YKanBan.Search;
/// <summary>
/// Lexical tokens of the search syntax.
/// </summary>
public abstract record SearchToken
{
public abstract record SearchToken {
/// <summary>
/// A bare word; qualifier detection happens later from the text.
/// </summary>
@@ -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.
/// </summary>
public static class SearchLexer
{
/// <summary>A valid phrase escape sequence: <c>\"</c> or <c>\\</c>.</summary>
public static class SearchLexer {
/// <summary>
/// A valid phrase escape sequence: <c>\"</c> or <c>\\</c>.
/// </summary>
private static readonly Parser<char, char> PhraseEscape =
Parser.Try(Parser.String("\\\"").Select(_ => '"'))
.Or(Parser.String("\\\\").Select(_ => '\\'));
/// <summary>One phrase character: an escape or any character except a quote or backslash.</summary>
/// <summary>
/// One phrase character: an escape or any character except a quote or backslash.
/// </summary>
private static readonly Parser<char, char> PhraseCharacter =
PhraseEscape.Or(Parser<char>.Token(character => character != '"' && character != '\\'));
@@ -90,14 +92,10 @@ public static class SearchLexer
/// <param name="input">The raw search text.</param>
/// <returns>The token stream.</returns>
/// <exception cref="SearchSyntaxException">The text contains an invalid escape or an unterminated phrase.</exception>
public static IReadOnlyList<SearchToken> Tokenize(string input)
{
try
{
public static IReadOnlyList<SearchToken> Tokenize(string input) {
try {
return Lexer.ParseOrThrow(input);
}
catch (ParseException exception)
{
} catch (ParseException exception) {
throw new SearchSyntaxException(exception.Message, exception);
}
}
+8 -6
View File
@@ -10,8 +10,7 @@ namespace YKanBan.Search;
/// Root of the search query AST. Search keywords are fixed English grammar
/// and are never localized.
/// </summary>
public abstract record SearchNode
{
public abstract record SearchNode {
/// <summary>
/// Disjunction: matches when any term matches (OR).
/// </summary>
@@ -59,11 +58,14 @@ public abstract record SearchNode
/// <summary>
/// The card fields a <see cref="SearchNode.FieldText"/> atom can scope to.
/// </summary>
public enum SearchField
{
/// <summary>The card title.</summary>
public enum SearchField {
/// <summary>
/// The card title.
/// </summary>
Title,
/// <summary>The card content body.</summary>
/// <summary>
/// The card content body.
/// </summary>
Content,
}
+34 -42
View File
@@ -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.
/// </summary>
public static class SearchQueryParser
{
/// <summary>The recognized qualifier keys; add new entries here to extend the grammar.</summary>
public static class SearchQueryParser {
/// <summary>
/// The recognized qualifier keys; add new entries here to extend the grammar.
/// </summary>
private static readonly (string Key, QualifierKind Kind)[] QualifierTable =
[
("tag", QualifierKind.Tag),
@@ -40,12 +42,16 @@ public static class SearchQueryParser
private static readonly Parser<SearchToken, SearchToken> CloseParen =
Parser<SearchToken>.Token(token => token is SearchToken.CloseParen);
/// <summary>The text of a phrase token.</summary>
/// <summary>
/// The text of a phrase token.
/// </summary>
private static readonly Parser<SearchToken, string> PhraseText =
Parser<SearchToken>.Token(token => token is SearchToken.Phrase)
.Select(token => ((SearchToken.Phrase)token).Text);
/// <summary>The text of a word token, operator keywords excluded (they never start an operand).</summary>
/// <summary>
/// The text of a word token, operator keywords excluded (they never start an operand).
/// </summary>
private static readonly Parser<SearchToken, string> NonKeywordWord =
Parser<SearchToken>.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<SearchToken, SearchNode> Query =
OrExpr.Before(Parser<SearchToken>.End);
/// <summary>Defers the OrExpr field read to parse time (recursion cycle breaker).</summary>
/// <summary>
/// Defers the OrExpr field read to parse time (recursion cycle breaker).
/// </summary>
/// <returns>The OR-expression parser.</returns>
private static Parser<SearchToken, SearchNode> OrExprDeferred() => OrExpr;
@@ -102,20 +110,15 @@ public static class SearchQueryParser
/// <param name="text">The raw search text.</param>
/// <returns>The parsed query, or <see langword="null"/> for blank input (meaning: show everything).</returns>
/// <exception cref="SearchSyntaxException">The text violates the grammar.</exception>
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<SearchToken> 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
/// </summary>
/// <param name="word">The word token text.</param>
/// <returns>The parser producing the operand node.</returns>
private static Parser<SearchToken, SearchNode> QualifierFrom(string word)
{
private static Parser<SearchToken, SearchNode> 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<SearchToken>.Return<SearchNode>(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<SearchToken>.Fail<SearchNode>($"Unknown qualifier key '{key}'.");
}
@@ -169,8 +169,7 @@ public static class SearchQueryParser
/// <param name="kind">The qualifier kind.</param>
/// <param name="value">The qualifier value.</param>
/// <returns>The atom node, or <see langword="null"/> for an invalid id value.</returns>
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
/// <param name="key">The key text before the colon.</param>
/// <param name="kind">The matching qualifier kind.</param>
/// <returns><see langword="true"/> when the key is known.</returns>
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
/// </summary>
/// <param name="text">The candidate key text.</param>
/// <returns><see langword="true"/> when every character is a word character.</returns>
private static bool IsQualifierKeyCandidate(ReadOnlySpan<char> text)
{
foreach (char character in text)
{
if (!(char.IsLetterOrDigit(character) || character == '_'))
{
private static bool IsQualifierKeyCandidate(ReadOnlySpan<char> text) {
foreach (char character in text) {
if (!(char.IsLetterOrDigit(character) || character == '_')) {
return false;
}
}
@@ -226,8 +219,7 @@ public static class SearchQueryParser
/// <param name="first">The first operand.</param>
/// <param name="rest">The remaining operands.</param>
/// <returns>The single operand, or an <see cref="SearchNode.And"/> node.</returns>
private static SearchNode FoldAnd(SearchNode first, IEnumerable<SearchNode> rest)
{
private static SearchNode FoldAnd(SearchNode first, IEnumerable<SearchNode> rest) {
List<SearchNode> terms = [first, .. rest];
return terms.Count == 1 ? terms[0] : new SearchNode.And(terms);
}
@@ -237,17 +229,17 @@ public static class SearchQueryParser
/// </summary>
/// <param name="terms">The operands.</param>
/// <returns>The single operand, or a <see cref="SearchNode.Or"/> node.</returns>
private static SearchNode FoldOr(IEnumerable<SearchNode> terms)
{
private static SearchNode FoldOr(IEnumerable<SearchNode> terms) {
List<SearchNode> list = terms.ToList();
return list.Count == 1 ? list[0] : new SearchNode.Or(list);
}
#endregion
/// <summary>The recognized qualifier kinds.</summary>
private enum QualifierKind
{
/// <summary>
/// The recognized qualifier kinds.
/// </summary>
private enum QualifierKind {
Tag,
Title,
Content,
+29 -39
View File
@@ -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.
/// </summary>
public static class SearchSqlCompiler
{
public static class SearchSqlCompiler {
/// <summary>
/// Compiles the query into a SQL predicate.
/// </summary>
/// <param name="query">The parsed search AST.</param>
/// <returns>The compiled predicate and its parameters.</returns>
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
/// <param name="node">The AST node to compile.</param>
/// <param name="parameters">The parameter accumulator.</param>
/// <returns>The SQL boolean expression for the node.</returns>
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
/// <param name="parameters">The parameter accumulator.</param>
/// <param name="value">The value to bind.</param>
/// <returns>The placeholder text.</returns>
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;
+2 -4
View File
@@ -6,15 +6,13 @@ namespace YKanBan.Search;
/// search error instead of coupling to Pidgin types. The message is never
/// localized.
/// </summary>
public sealed class SearchSyntaxException : Exception
{
public sealed class SearchSyntaxException : Exception {
/// <summary>
/// Initializes the exception with a descriptive English message.
/// </summary>
/// <param name="message">The English diagnostic message.</param>
/// <param name="innerException">The underlying parse failure.</param>
public SearchSyntaxException(string message, Exception innerException)
: base(message, innerException)
{
: base(message, innerException) {
}
}
+3 -6
View File
@@ -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.
/// </summary>
public sealed class AppServices : IDisposable
{
public sealed class AppServices : IDisposable {
/// <summary>
/// Loads the configuration and prepares the service graph.
/// </summary>
public AppServices()
{
public AppServices() {
ConfigStore = new AppConfigStore(AppDataPaths.AppConfigJsonPath);
Config = ConfigStore.Load();
}
@@ -42,8 +40,7 @@ public sealed class AppServices : IDisposable
/// <returns>The open session.</returns>
/// <exception cref="YKanBan.Storage.WorkspaceNotInitializedException">The folder has no .ykanban structure.</exception>
/// <exception cref="YKanBan.Storage.WorkspaceLockException">Another instance already holds the lock.</exception>
public WorkspaceSession OpenWorkspace(string folderPath)
{
public WorkspaceSession OpenWorkspace(string folderPath) {
Session = WorkspaceSession.Open(folderPath);
return Session;
}
+53 -29
View File
@@ -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.
/// </summary>
public enum SortOption
{
/// <summary>Primary key ascending.</summary>
public enum SortOption {
/// <summary>
/// Primary key ascending.
/// </summary>
[EnumMember(Value = "id-asc")]
IdAscending,
/// <summary>Primary key descending.</summary>
/// <summary>
/// Primary key descending.
/// </summary>
[EnumMember(Value = "id-desc")]
IdDescending,
/// <summary>Creation time ascending.</summary>
/// <summary>
/// Creation time ascending.
/// </summary>
[EnumMember(Value = "created-asc")]
CreatedAscending,
/// <summary>Creation time descending.</summary>
/// <summary>
/// Creation time descending.
/// </summary>
[EnumMember(Value = "created-desc")]
CreatedDescending,
/// <summary>Last modification time ascending.</summary>
/// <summary>
/// Last modification time ascending.
/// </summary>
[EnumMember(Value = "updated-asc")]
UpdatedAscending,
/// <summary>Last modification time descending.</summary>
/// <summary>
/// Last modification time descending.
/// </summary>
[EnumMember(Value = "updated-desc")]
UpdatedDescending,
/// <summary>Title by code points (BINARY collation) ascending.</summary>
/// <summary>
/// Title by code points (BINARY collation) ascending.
/// </summary>
[EnumMember(Value = "title-asc")]
TitleAscending,
/// <summary>Title by code points (BINARY collation) descending.</summary>
/// <summary>
/// Title by code points (BINARY collation) descending.
/// </summary>
[EnumMember(Value = "title-desc")]
TitleDescending,
}
@@ -45,21 +61,28 @@ public enum SortOption
/// <summary>
/// Column width presets. <see cref="Custom"/> uses a user-defined pixel width.
/// </summary>
public enum ColumnWidthPreset
{
/// <summary>Narrow preset.</summary>
public enum ColumnWidthPreset {
/// <summary>
/// Narrow preset.
/// </summary>
[EnumMember(Value = "narrow")]
Narrow,
/// <summary>Standard preset (320 px, the default).</summary>
/// <summary>
/// Standard preset (320 px, the default).
/// </summary>
[EnumMember(Value = "standard")]
Standard,
/// <summary>Wide preset.</summary>
/// <summary>
/// Wide preset.
/// </summary>
[EnumMember(Value = "wide")]
Wide,
/// <summary>User-defined pixel width (160–720).</summary>
/// <summary>
/// User-defined pixel width (160–720).
/// </summary>
[EnumMember(Value = "custom")]
Custom,
}
@@ -67,17 +90,22 @@ public enum ColumnWidthPreset
/// <summary>
/// UI theme selection.
/// </summary>
public enum ThemeOption
{
/// <summary>Always light.</summary>
public enum ThemeOption {
/// <summary>
/// Always light.
/// </summary>
[EnumMember(Value = "light")]
Light,
/// <summary>Always dark.</summary>
/// <summary>
/// Always dark.
/// </summary>
[EnumMember(Value = "dark")]
Dark,
/// <summary>Follow the operating system setting (default).</summary>
/// <summary>
/// Follow the operating system setting (default).
/// </summary>
[EnumMember(Value = "follow-system")]
FollowSystem,
}
@@ -85,8 +113,7 @@ public enum ThemeOption
/// <summary>
/// Board column width settings, applied globally.
/// </summary>
public sealed class ColumnWidthSettings
{
public sealed class ColumnWidthSettings {
/// <summary>
/// Gets or sets the active width preset.
/// </summary>
@@ -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.
/// </summary>
public sealed class DisplaySettings
{
public sealed class DisplaySettings {
/// <summary>
/// Gets or sets the board column width settings.
/// </summary>
@@ -129,8 +155,7 @@ public sealed class DisplaySettings
/// <summary>
/// Confirmation-dialog toggles for the destructive actions.
/// </summary>
public sealed class ConfirmationSettings
{
public sealed class ConfirmationSettings {
/// <summary>
/// Gets or sets whether deleting a card asks for confirmation.
/// </summary>
@@ -162,8 +187,7 @@ public sealed class ConfirmationSettings
/// <see cref="JsonPropertyAttribute"/> and <see cref="EnumMemberAttribute"/>).
/// The <c>version</c> field is the migration hook for future format changes.
/// </summary>
public sealed class AppConfig
{
public sealed class AppConfig {
/// <summary>
/// Current configuration format version.
/// </summary>
+9 -19
View File
@@ -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.
/// </summary>
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
/// </summary>
/// <returns>The loaded configuration, or defaults when none could be read.</returns>
/// <exception cref="IOException">The file exists but cannot be read.</exception>
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<AppConfig>(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).
/// </summary>
/// <param name="config">The configuration to serialize.</param>
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);
}
+1 -2
View File
@@ -5,8 +5,7 @@ namespace YKanBan.Storage.AppData;
/// (%APPDATA%/YKanBan on Windows and the equivalent directory on other
/// platforms).
/// </summary>
public static class AppDataPaths
{
public static class AppDataPaths {
/// <summary>
/// Gets the per-user YKanBan application data folder.
/// </summary>
+14 -29
View File
@@ -7,8 +7,7 @@ namespace YKanBan.Storage;
/// the mandated PRAGMA configuration (WAL journal, NORMAL synchronous mode,
/// foreign keys on) and applies incremental <c>user_version</c> migrations.
/// </summary>
public static class SqliteDatabase
{
public static class SqliteDatabase {
/// <summary>
/// Opens (creating if needed) a database file, applies the mandatory
/// PRAGMAs and runs any pending migrations.
@@ -16,10 +15,8 @@ public static class SqliteDatabase
/// <param name="databasePath">Path of the SQLite database file.</param>
/// <param name="migrations">Contiguous migration list ordered by version, starting at 1.</param>
/// <returns>An open connection with all PRAGMAs applied and the schema migrated.</returns>
public static SqliteConnection Open(string databasePath, IReadOnlyList<SchemaMigration> migrations)
{
var connection = new SqliteConnection(new SqliteConnectionStringBuilder
{
public static SqliteConnection Open(string databasePath, IReadOnlyList<SchemaMigration> 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
/// </summary>
/// <param name="connection">An open database connection.</param>
/// <returns>The stored schema version.</returns>
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.
/// </summary>
/// <param name="connection">An open database connection.</param>
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
/// <param name="migrations">Contiguous migration list ordered by version, starting at 1.</param>
/// <exception cref="ArgumentException">The migration list is not contiguous from version 1.</exception>
/// <exception cref="SchemaVersionException">The database was written by a newer build.</exception>
internal static void ApplyMigrations(SqliteConnection connection, IReadOnlyList<SchemaMigration> migrations)
{
internal static void ApplyMigrations(SqliteConnection connection, IReadOnlyList<SchemaMigration> 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
/// <param name="connection">An open database connection.</param>
/// <param name="commandText">The SQL script to execute.</param>
/// <param name="transaction">The transaction to enlist in, when any.</param>
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;
+10 -20
View File
@@ -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.
/// </summary>
public abstract class YKanBanException : Exception
{
public abstract class YKanBanException : Exception {
/// <summary>
/// Initializes the exception with an English diagnostic message.
/// </summary>
/// <param name="message">The English diagnostic message.</param>
/// <param name="innerException">The underlying cause, when any.</param>
protected YKanBanException(string message, Exception? innerException = null)
: base(message, innerException)
{
: base(message, innerException) {
}
}
/// <summary>
/// Signals that the folder passed on the command line does not exist on disk.
/// </summary>
public sealed class WorkspaceDirectoryMissingException : YKanBanException
{
public sealed class WorkspaceDirectoryMissingException : YKanBanException {
/// <summary>
/// Gets the folder path that was expected to exist.
/// </summary>
@@ -33,8 +30,7 @@ public sealed class WorkspaceDirectoryMissingException : YKanBanException
/// </summary>
/// <param name="folderPath">The missing folder path.</param>
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.
/// </summary>
public sealed class WorkspaceNotInitializedException : YKanBanException
{
public sealed class WorkspaceNotInitializedException : YKanBanException {
/// <summary>
/// Gets the folder path that lacks the .ykanban structure.
/// </summary>
@@ -55,8 +50,7 @@ public sealed class WorkspaceNotInitializedException : YKanBanException
/// </summary>
/// <param name="folderPath">The uninitialized folder path.</param>
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.
/// </summary>
public sealed class WorkspaceLockException : YKanBanException
{
public sealed class WorkspaceLockException : YKanBanException {
/// <summary>
/// Gets the lock file that could not be acquired.
/// </summary>
@@ -78,8 +71,7 @@ public sealed class WorkspaceLockException : YKanBanException
/// <param name="lockFilePath">The lock file that is already held.</param>
/// <param name="innerException">The underlying I/O error.</param>
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.
/// </summary>
public sealed class SchemaVersionException : YKanBanException
{
public sealed class SchemaVersionException : YKanBanException {
/// <summary>
/// Gets the user_version value found in the database.
/// </summary>
@@ -106,8 +97,7 @@ public sealed class SchemaVersionException : YKanBanException
/// <param name="storedVersion">The user_version read from the database.</param>
/// <param name="supportedVersion">The newest schema version this build supports.</param>
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;
}
+1 -2
View File
@@ -4,8 +4,7 @@ namespace YKanBan.Storage;
/// Supplies the current Unix timestamp. Every timestamp persisted by YKanBan
/// is an INTEGER count of Unix seconds.
/// </summary>
public static class UnixTime
{
public static class UnixTime {
/// <summary>
/// Gets the current UTC time expressed as Unix seconds.
/// </summary>
@@ -8,8 +8,7 @@ namespace YKanBan.Storage.Workspace;
/// separate concern handled by <see cref="WorkspacePreset"/>, so the
/// database can be created without any rows.
/// </summary>
public static class WorkspaceInitializer
{
public static class WorkspaceInitializer {
/// <summary>
/// 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
/// <param name="folderPath">The existing folder to initialize.</param>
/// <exception cref="WorkspaceDirectoryMissingException">The folder does not exist on disk.</exception>
/// <exception cref="InvalidOperationException">The workspace database already exists.</exception>
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}");
}
+6 -14
View File
@@ -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.
/// </summary>
public sealed class WorkspaceLock : IDisposable
{
public sealed class WorkspaceLock : IDisposable {
private readonly FileStream _stream;
/// <summary>
@@ -14,8 +13,7 @@ public sealed class WorkspaceLock : IDisposable
/// </summary>
/// <param name="stream">The exclusively opened lock file stream.</param>
/// <param name="lockFilePath">Path of the lock file being held.</param>
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
/// <returns>The held lock; dispose it to release.</returns>
/// <exception cref="WorkspaceNotInitializedException">The folder has no .ykanban structure.</exception>
/// <exception cref="WorkspaceLockException">Another instance already holds the lock.</exception>
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);
}
+1 -2
View File
@@ -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.
/// </summary>
public static class WorkspacePaths
{
public static class WorkspacePaths {
/// <summary>
/// Name of the workspace root folder created inside a managed project.
/// </summary>
+5 -10
View File
@@ -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.
/// </summary>
public static class WorkspacePreset
{
public static class WorkspacePreset {
/// <summary>
/// Inserts the three preset columns (the current language's equivalents of
/// To Do / In Progress / Done) with their descriptions in a single transaction.
/// </summary>
/// <param name="folderPath">The initialized workspace folder.</param>
/// <exception cref="SqliteException">The workspace database does not exist or a title collides with an existing column.</exception>
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
/// </summary>
/// <param name="folderPath">The initialized workspace folder.</param>
/// <exception cref="SqliteException">The workspace database does not exist or a tag name collides.</exception>
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 = """
@@ -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.
/// </summary>
public sealed class WorkspaceRepository : IDisposable
{
public sealed class WorkspaceRepository : IDisposable {
private readonly SqliteConnection _connection;
/// <summary>
/// Opens the repository over an initialized workspace database.
/// </summary>
/// <param name="folderPath">The workspace folder.</param>
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.
/// </summary>
/// <returns>All columns in id order.</returns>
public IReadOnlyList<ColumnModel> GetColumns()
{
public IReadOnlyList<ColumnModel> 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<ColumnModel>();
while (reader.Read())
{
while (reader.Read()) {
columns.Add(ReadColumn(reader));
}
return columns;
@@ -57,8 +54,7 @@ public sealed class WorkspaceRepository : IDisposable
/// </summary>
/// <param name="columnId">The column id.</param>
/// <returns>The column, or <see langword="null"/> when it does not exist.</returns>
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
/// <param name="description">The optional description.</param>
/// <returns>The created column.</returns>
/// <exception cref="SqliteException">The title is empty or not unique.</exception>
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
/// <returns>The updated column, or the unchanged one for a no-op.</returns>
/// <exception cref="InvalidOperationException">The column does not exist.</exception>
/// <exception cref="SqliteException">The new title is empty or not unique.</exception>
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.
/// </summary>
/// <returns>All cards in id order.</returns>
public IReadOnlyList<CardModel> GetCards()
{
public IReadOnlyList<CardModel> 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<CardModel>();
var tagsByCard = new Dictionary<long, List<TagModel>>();
while (reader.Read())
{
while (reader.Read()) {
long cardId = reader.GetInt64(0);
if (!tagsByCard.TryGetValue(cardId, out List<TagModel>? tags))
{
if (!tagsByCard.TryGetValue(cardId, out List<TagModel>? tags)) {
tags = new List<TagModel>();
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
/// </summary>
/// <param name="search">The compiled search predicate and its parameters.</param>
/// <returns>The matching card ids.</returns>
public IReadOnlyCollection<long> GetCardIdsMatching(CompiledSearch search)
{
public IReadOnlyCollection<long> 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<long>();
while (reader.Read())
{
while (reader.Read()) {
ids.Add(reader.GetInt64(0));
}
return ids;
@@ -200,8 +186,7 @@ public sealed class WorkspaceRepository : IDisposable
/// </summary>
/// <param name="cardId">The card id.</param>
/// <returns>The card, or <see langword="null"/> when it does not exist.</returns>
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<TagModel>();
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
/// <param name="tagIds">The tags to assign initially.</param>
/// <returns>The created card with tags loaded.</returns>
/// <exception cref="SqliteException">The column or a tag id does not exist.</exception>
public CardModel AddCard(long columnId, string title, string content, IReadOnlyCollection<long> tagIds)
{
public CardModel AddCard(long columnId, string title, string content, IReadOnlyCollection<long> 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
/// <returns>The updated card with tags loaded, or the unchanged one for a no-op.</returns>
/// <exception cref="InvalidOperationException">The card does not exist.</exception>
/// <exception cref="SqliteException">A tag id does not exist; nothing is written.</exception>
public CardModel UpdateCard(long cardId, string title, string content, IReadOnlyCollection<long> tagIds)
{
public CardModel UpdateCard(long cardId, string title, string content, IReadOnlyCollection<long> 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
/// <param name="cardId">The card to move.</param>
/// <param name="targetColumnId">The destination column.</param>
/// <returns><see langword="true"/> when the card actually moved.</returns>
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.
/// </summary>
/// <returns>All tags in id order.</returns>
public IReadOnlyList<TagModel> GetTags()
{
public IReadOnlyList<TagModel> 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<TagModel>();
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.
/// </summary>
/// <returns>The tag/usage pairs in tag id order.</returns>
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
/// <param name="description">The optional description.</param>
/// <returns>The created tag.</returns>
/// <exception cref="SqliteException">The name is empty or not unique.</exception>
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
/// <returns>The updated tag, or the unchanged one for a no-op.</returns>
/// <exception cref="InvalidOperationException">The tag does not exist.</exception>
/// <exception cref="SqliteException">The new name is empty or not unique.</exception>
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
/// </summary>
/// <param name="tagId">The tag id.</param>
/// <returns>The tag, or <see langword="null"/> when it does not exist.</returns>
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
/// </summary>
/// <param name="cardId">The card id.</param>
/// <returns>The assigned tags in id order.</returns>
private IReadOnlyList<TagModel> LoadTags(long cardId)
{
private IReadOnlyList<TagModel> 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<TagModel>();
while (reader.Read())
{
while (reader.Read()) {
tags.Add(ReadTag(reader, 0));
}
return tags;
@@ -502,8 +469,7 @@ public sealed class WorkspaceRepository : IDisposable
/// <param name="sql">The command text.</param>
/// <param name="transaction">The transaction to enlist in, when any.</param>
/// <returns>The prepared command.</returns>
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
/// <param name="transaction">The transaction to enlist in.</param>
/// <param name="sql">The statement to execute.</param>
/// <param name="parameters">Name/value pairs bound as SQL parameters.</param>
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
/// <param name="sql">The INSERT statement.</param>
/// <param name="parameters">Name/value pairs bound as SQL parameters.</param>
/// <returns>The rowid assigned to the inserted row.</returns>
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
/// <param name="sql">The DELETE statement with an <c>$id</c> parameter.</param>
/// <param name="id">The id to delete.</param>
/// <returns><see langword="true"/> when a row was deleted.</returns>
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;
+1 -2
View File
@@ -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.
/// </summary>
public static class WorkspaceSchema
{
public static class WorkspaceSchema {
/// <summary>
/// Newest workspace schema version this build understands.
/// </summary>
+6 -13
View File
@@ -7,8 +7,7 @@ namespace YKanBan.Storage.Workspace;
/// any database work happens. Disposing releases the repository and then the
/// lock.
/// </summary>
public sealed class WorkspaceSession : IDisposable
{
public sealed class WorkspaceSession : IDisposable {
private readonly WorkspaceLock _workspaceLock;
/// <summary>
@@ -17,8 +16,7 @@ public sealed class WorkspaceSession : IDisposable
/// <param name="folderPath">The canonical workspace folder path.</param>
/// <param name="workspaceLock">The exclusive lock held while this session is open.</param>
/// <param name="repository">The data access object for this workspace.</param>
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
/// <returns>The opened session; dispose it to release the lock and connection.</returns>
/// <exception cref="WorkspaceNotInitializedException">The folder has no .ykanban structure.</exception>
/// <exception cref="WorkspaceLockException">Another instance already holds the lock.</exception>
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
/// <summary>
/// Releases the repository connection, then the exclusive lock.
/// </summary>
public void Dispose()
{
public void Dispose() {
Repository.Dispose();
_workspaceLock.Dispose();
}
+8 -10
View File
@@ -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;
}
}
+44 -45
View File
@@ -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.
/// </summary>
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
/// </summary>
/// <param name="services">The process composition root (configuration access).</param>
/// <param name="session">The open workspace session.</param>
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
/// <summary>Gets the interaction that opens the new-column editor.</summary>
/// <summary>
/// Gets the interaction that opens the new-column editor.
/// </summary>
public Interaction<Unit> AddColumn { get; } = new();
/// <summary>Gets the interaction that opens the column editor.</summary>
/// <summary>
/// Gets the interaction that opens the column editor.
/// </summary>
public Interaction<ColumnViewModel> EditColumn { get; } = new();
/// <summary>Gets the interaction that confirms and deletes a column.</summary>
/// <summary>
/// Gets the interaction that confirms and deletes a column.
/// </summary>
public Interaction<ColumnViewModel> DeleteColumn { get; } = new();
/// <summary>Gets the interaction that opens the new-card editor.</summary>
/// <summary>
/// Gets the interaction that opens the new-card editor.
/// </summary>
public Interaction<ColumnViewModel> AddCard { get; } = new();
/// <summary>Gets the interaction that opens the card editor.</summary>
/// <summary>
/// Gets the interaction that opens the card editor.
/// </summary>
public Interaction<CardViewModel> EditCard { get; } = new();
/// <summary>Gets the interaction that confirms and deletes a card.</summary>
/// <summary>
/// Gets the interaction that confirms and deletes a card.
/// </summary>
public Interaction<CardViewModel> DeleteCard { get; } = new();
/// <summary>Gets the interaction that opens the move-card dialog.</summary>
/// <summary>
/// Gets the interaction that opens the move-card dialog.
/// </summary>
public Interaction<CardViewModel> MoveCard { get; } = new();
/// <summary>Gets the interaction that shows the search error dialog, carrying the English parser detail.</summary>
/// <summary>
/// Gets the interaction that shows the search error dialog, carrying the English parser detail.
/// </summary>
public Interaction<string> 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.
/// </summary>
[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.
/// </summary>
[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.
/// </summary>
/// <param name="matchedIds">The new filter, or <see langword="null"/> for no filter.</param>
private void ApplyFilter(IReadOnlyCollection<long>? matchedIds)
{
if (_matchedCardIds is null && matchedIds is null)
{
private void ApplyFilter(IReadOnlyCollection<long>? matchedIds) {
if (_matchedCardIds is null && matchedIds is null) {
return;
}
@@ -244,14 +251,12 @@ public sealed partial class BoardViewModel : ViewModelBase
/// <summary>
/// Reloads all columns and cards from the repository and rebuilds the board.
/// </summary>
public void Reload()
{
public void Reload() {
IReadOnlyList<ColumnModel> columns = _repository.GetColumns();
IReadOnlyList<CardModel> 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<ColumnModel> 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<CardModel> columnCards = cardsByColumn.TryGetValue(column.Id, out List<CardModel>? list)
? SortCards(list, _services.Config.Display.CardSort)
@@ -280,11 +284,9 @@ public sealed partial class BoardViewModel : ViewModelBase
/// <summary>
/// Recomputes the global column width and pushes it to every column.
/// </summary>
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
/// </summary>
/// <param name="settings">The configured column width.</param>
/// <returns>The width in pixels.</returns>
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
/// <param name="cards">The cards to order.</param>
/// <param name="option">The selected order.</param>
/// <returns>The ordered cards.</returns>
private static IEnumerable<CardModel> SortCards(IEnumerable<CardModel> cards, SortOption option) => option switch
{
private static IEnumerable<CardModel> SortCards(IEnumerable<CardModel> 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
/// <param name="columns">The columns to order.</param>
/// <param name="option">The selected order.</param>
/// <returns>The ordered columns.</returns>
private static IEnumerable<ColumnModel> SortColumns(IEnumerable<ColumnModel> columns, SortOption option) => option switch
{
private static IEnumerable<ColumnModel> SortColumns(IEnumerable<ColumnModel> 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),
+8 -9
View File
@@ -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.
/// </summary>
public sealed partial class CardViewModel : ViewModelBase
{
/// <summary>Maximum number of content characters shown in the card preview.</summary>
public sealed partial class CardViewModel : ViewModelBase {
/// <summary>
/// Maximum number of content characters shown in the card preview.
/// </summary>
private const int PreviewLength = 200;
private readonly BoardViewModel _board;
@@ -21,8 +23,7 @@ public sealed partial class CardViewModel : ViewModelBase
/// </summary>
/// <param name="board">The owning board view model.</param>
/// <param name="card">The card model to display.</param>
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
/// <summary>
/// Gets the content preview, collapsed to one line and truncated.
/// </summary>
public string PreviewContent
{
get
{
public string PreviewContent {
get {
string singleLine = Model.Content.Replace("\r", " ").Replace("\n", " ");
return singleLine.Length <= PreviewLength ? singleLine : singleLine[..PreviewLength] + "…";
}
+4 -4
View File
@@ -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.
/// </summary>
public sealed partial class ColumnViewModel : ViewModelBase
{
public sealed partial class ColumnViewModel : ViewModelBase {
private readonly BoardViewModel _board;
/// <summary>
@@ -19,8 +20,7 @@ public sealed partial class ColumnViewModel : ViewModelBase
/// <param name="board">The owning board view model.</param>
/// <param name="column">The column model.</param>
/// <param name="cards">The already sorted cards belonging to this column.</param>
public ColumnViewModel(BoardViewModel board, ColumnModel column, IEnumerable<CardViewModel> cards)
{
public ColumnViewModel(BoardViewModel board, ColumnModel column, IEnumerable<CardViewModel> cards) {
_board = board;
Model = column;
Cards = new ObservableCollection<CardViewModel>(cards);
@@ -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.
/// </summary>
public sealed partial class DisplaySettingsViewModel : ViewModelBase
{
/// <summary>Smallest accepted manual width.</summary>
public sealed partial class DisplaySettingsViewModel : ViewModelBase {
/// <summary>
/// Smallest accepted manual width.
/// </summary>
public const int MinimumPixels = 160;
/// <summary>Largest accepted manual width.</summary>
/// <summary>
/// Largest accepted manual width.
/// </summary>
public const int MaximumPixels = 720;
private readonly AppConfig _config;
@@ -24,8 +28,7 @@ public sealed partial class DisplaySettingsViewModel : ViewModelBase
/// </summary>
/// <param name="config">The live application configuration.</param>
/// <param name="refresh">Invoked after every change to refresh the board.</param>
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.
/// </summary>
/// <param name="value">The new selection.</param>
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.
/// </summary>
/// <param name="value">The new pixel count.</param>
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.
/// </summary>
/// <param name="value">The new selection.</param>
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.
/// </summary>
/// <param name="value">The new selection.</param>
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();
}
@@ -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.
/// </summary>
public sealed class TagBadgeViewModel
{
public sealed class TagBadgeViewModel {
/// <summary>
/// Initializes a badge from a tag.
/// </summary>
/// <param name="tag">The tag to render.</param>
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
/// </summary>
/// <param name="background">The badge background color.</param>
/// <returns>The contrasting text color.</returns>
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;
}
@@ -5,13 +5,11 @@ namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The about dialog: application name, version, repository link and license.
/// </summary>
public sealed class AboutDialogViewModel : ViewModelBase
{
public sealed class AboutDialogViewModel : ViewModelBase {
/// <summary>
/// Initializes the view model, reading the version from the assembly.
/// </summary>
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);
@@ -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).
/// </summary>
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
/// </summary>
/// <param name="editor">The owning card editor.</param>
/// <param name="tag">The assigned tag.</param>
public AssignedTagViewModel(CardEditorViewModel editor, TagModel tag)
{
public AssignedTagViewModel(CardEditorViewModel editor, TagModel tag) {
_editor = editor;
Tag = tag;
_badge = new TagBadgeViewModel(tag);
@@ -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.
/// </summary>
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
/// <param name="repository">The workspace repository.</param>
/// <param name="columnId">The owning column (used when adding).</param>
/// <param name="existing">The card to edit, or <see langword="null"/> to add one.</param>
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<AssignedTagViewModel>(
(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
/// <summary>
/// Gets a value indicating whether there are unsaved effective changes.
/// </summary>
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.
/// </summary>
[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.
/// </summary>
/// <param name="tag">The tag to remove.</param>
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.
/// </summary>
/// <returns><see langword="true"/> when the dialog may close.</returns>
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<long> 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;
}
@@ -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.
/// </summary>
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
/// </summary>
/// <param name="repository">The workspace repository.</param>
/// <param name="existing">The column to edit, or <see langword="null"/> to add one.</param>
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.
/// </summary>
/// <returns><see langword="true"/> when the dialog may close.</returns>
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;
}
@@ -5,14 +5,12 @@ namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The confirmation shown before deleting a card, naming the card id.
/// </summary>
public sealed class DeleteCardConfirmViewModel : ViewModelBase
{
public sealed class DeleteCardConfirmViewModel : ViewModelBase {
/// <summary>
/// Initializes the confirmation.
/// </summary>
/// <param name="cardId">The id of the card to delete.</param>
public DeleteCardConfirmViewModel(long cardId)
{
public DeleteCardConfirmViewModel(long cardId) {
Message = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture,
Resources.Confirm_DeleteCard_Message,
@@ -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.
/// </summary>
public sealed class DeleteColumnConfirmViewModel : ViewModelBase
{
public sealed class DeleteColumnConfirmViewModel : ViewModelBase {
/// <summary>
/// Initializes the confirmation.
/// </summary>
/// <param name="columnTitle">The title of the column to delete.</param>
/// <param name="cardCount">The number of cards in the column.</param>
public DeleteColumnConfirmViewModel(string columnTitle, int cardCount)
{
public DeleteColumnConfirmViewModel(string columnTitle, int cardCount) {
Message = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture,
Resources.Confirm_DeleteColumn_Message,
@@ -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.
/// </summary>
public sealed class DeleteTagConfirmViewModel : ViewModelBase
{
public sealed class DeleteTagConfirmViewModel : ViewModelBase {
/// <summary>
/// Initializes the confirmation.
/// </summary>
/// <param name="tagName">The name of the tag to delete.</param>
/// <param name="usageCount">The number of cards using the tag.</param>
public DeleteTagConfirmViewModel(string tagName, long usageCount)
{
public DeleteTagConfirmViewModel(string tagName, long usageCount) {
Message = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture,
Resources.DeleteTag_Confirm_Message,
@@ -3,8 +3,7 @@ namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The confirmation shown before closing an editor with unsaved changes.
/// </summary>
public sealed class DiscardEditConfirmViewModel : ViewModelBase
{
public sealed class DiscardEditConfirmViewModel : ViewModelBase {
/// <summary>
/// Gets the localized confirmation message.
/// </summary>
@@ -6,14 +6,12 @@ namespace YKanBan.ViewModels.Dialogs;
/// The confirmation dialog shown after a successful Markdown export, naming the
/// written file.
/// </summary>
public sealed class ExportDoneViewModel : ViewModelBase
{
public sealed class ExportDoneViewModel : ViewModelBase {
/// <summary>
/// Initializes the dialog with the exported file path.
/// </summary>
/// <param name="filePath">The path the export was written to.</param>
public ExportDoneViewModel(string filePath)
{
public ExportDoneViewModel(string filePath) {
Message = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Export_Done_Message, filePath);
}
@@ -1,4 +1,5 @@
using CommunityToolkit.Mvvm.ComponentModel;
using YKanBan.Models;
namespace YKanBan.ViewModels.Dialogs;
@@ -6,15 +7,13 @@ namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The move-card dialog: choose one of the other columns as the destination.
/// </summary>
public sealed partial class MoveCardViewModel : ViewModelBase
{
public sealed partial class MoveCardViewModel : ViewModelBase {
/// <summary>
/// Initializes the dialog with the candidate destination columns.
/// </summary>
/// <param name="columns">All columns; the card's current column is excluded.</param>
/// <param name="currentColumnId">The card's current column id.</param>
public MoveCardViewModel(IReadOnlyList<ColumnModel> columns, long currentColumnId)
{
public MoveCardViewModel(IReadOnlyList<ColumnModel> columns, long currentColumnId) {
Targets = columns
.Where(column => column.Id != currentColumnId)
.Select(column => new OptionItem(column.Id, column.Title))
@@ -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.
/// </summary>
public sealed class SearchErrorViewModel : ViewModelBase
{
public sealed class SearchErrorViewModel : ViewModelBase {
/// <summary>
/// Initializes the dialog with the parser's English detail.
/// </summary>
/// <param name="detail">The non-localized parser error description.</param>
public SearchErrorViewModel(string detail)
{
public SearchErrorViewModel(string detail) {
Message = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture,
Resources.Search_Error_Message,
@@ -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.
/// </summary>
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
/// </summary>
/// <param name="config">The live application configuration.</param>
/// <param name="startupLanguage">The language applied at startup, for the restart notice.</param>
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
/// <summary>
/// Commits the working values into the live configuration.
/// </summary>
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);
}
@@ -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.
/// </summary>
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
/// </summary>
/// <param name="repository">The workspace repository.</param>
/// <param name="existing">The tag to edit, or <see langword="null"/> to add one.</param>
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.
/// </summary>
/// <returns><see langword="true"/> when the dialog may close.</returns>
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;
}
@@ -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.
/// </summary>
public sealed partial class TagPickerViewModel : ViewModelBase
{
public sealed partial class TagPickerViewModel : ViewModelBase {
/// <summary>
/// Initializes the picker with the tags not yet assigned to the card.
/// </summary>
/// <param name="repository">The workspace repository.</param>
/// <param name="assignedTagIds">The tag ids already assigned to the card.</param>
public TagPickerViewModel(WorkspaceRepository repository, IReadOnlyCollection<long> assignedTagIds)
{
public TagPickerViewModel(WorkspaceRepository repository, IReadOnlyCollection<long> assignedTagIds) {
Targets = repository.GetTags()
.Where(tag => !assignedTagIds.Contains(tag.Id))
.Select(tag => new TagBadgeViewModel(tag))
@@ -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.
/// </summary>
public sealed class ArgumentErrorPageViewModel : ViewModelBase
{
public sealed class ArgumentErrorPageViewModel : ViewModelBase {
}
@@ -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.
/// </summary>
public sealed class DirectoryMissingPageViewModel : ViewModelBase
{
public sealed class DirectoryMissingPageViewModel : ViewModelBase {
/// <summary>
/// Initializes the page for a missing folder.
/// </summary>
/// <param name="folderPath">The folder path that does not exist.</param>
public DirectoryMissingPageViewModel(string folderPath)
{
public DirectoryMissingPageViewModel(string folderPath) {
FolderPath = folderPath;
Message = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Error_DirectoryMissing_Message, folderPath);
@@ -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.
/// </summary>
public sealed class LockConflictPageViewModel : ViewModelBase
{
public sealed class LockConflictPageViewModel : ViewModelBase {
/// <summary>
/// Initializes the page for a locked workspace.
/// </summary>
/// <param name="folderPath">The locked workspace folder.</param>
public LockConflictPageViewModel(string folderPath)
{
public LockConflictPageViewModel(string folderPath) {
FolderPath = folderPath;
Message = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Error_LockConflict_Message, folderPath);
@@ -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.
/// </summary>
public sealed partial class NotInitializedPageViewModel : ViewModelBase
{
public sealed partial class NotInitializedPageViewModel : ViewModelBase {
private readonly Interaction<Unit> _initializeWorkspace;
/// <summary>
@@ -17,8 +17,7 @@ public sealed partial class NotInitializedPageViewModel : ViewModelBase
/// </summary>
/// <param name="folderPath">The uninitialized folder path.</param>
/// <param name="initializeWorkspace">The interaction that performs initialization.</param>
public NotInitializedPageViewModel(string folderPath, Interaction<Unit> initializeWorkspace)
{
public NotInitializedPageViewModel(string folderPath, Interaction<Unit> initializeWorkspace) {
FolderPath = folderPath;
_initializeWorkspace = initializeWorkspace;
Message = string.Format(
+3 -6
View File
@@ -3,8 +3,7 @@ namespace YKanBan.ViewModels;
/// <summary>
/// Input placeholder for interactions that carry no meaningful payload.
/// </summary>
public readonly record struct Unit
{
public readonly record struct Unit {
/// <summary>
/// Gets the single unit value.
/// </summary>
@@ -18,8 +17,7 @@ public readonly record struct Unit
/// The view model never holds a reference to a view.
/// </summary>
/// <typeparam name="TInput">The request payload.</typeparam>
public sealed class Interaction<TInput>
{
public sealed class Interaction<TInput> {
private Func<TInput, Task>? _handler;
/// <summary>
@@ -46,8 +44,7 @@ public sealed class Interaction<TInput>
/// </summary>
/// <typeparam name="TInput">The request payload.</typeparam>
/// <typeparam name="TOutput">The handler's answer.</typeparam>
public sealed class Interaction<TInput, TOutput>
{
public sealed class Interaction<TInput, TOutput> {
private Func<TInput, Task<TOutput>>? _handler;
/// <summary>
+15 -29
View File
@@ -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.
/// </summary>
public sealed partial class MainWindowViewModel : ViewModelBase
{
public sealed partial class MainWindowViewModel : ViewModelBase {
private readonly AppServices _services;
/// <summary>
@@ -22,8 +23,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
/// </summary>
/// <param name="services">The process composition root.</param>
/// <param name="arguments">The command-line arguments (excluding the executable).</param>
public MainWindowViewModel(AppServices services, IReadOnlyList<string> arguments)
{
public MainWindowViewModel(AppServices services, IReadOnlyList<string> arguments) {
_services = services;
ShowSettings = new Interaction<Unit>();
ShowAbout = new Interaction<Unit>();
@@ -63,30 +63,22 @@ public sealed partial class MainWindowViewModel : ViewModelBase
/// Resolves the launch arguments into a content view model.
/// </summary>
/// <param name="arguments">The command-line arguments.</param>
private void Resolve(IReadOnlyList<string> arguments)
{
if (!LaunchArguments.TryParse(arguments, out string? folderPath))
{
private void Resolve(IReadOnlyList<string> 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.
/// </summary>
/// <param name="folderPath">The workspace folder to open.</param>
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.
/// </summary>
/// <returns>A completed task once initialization has been attempted.</returns>
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);
}
+4 -4
View File
@@ -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.
/// </summary>
public sealed partial class TagItemViewModel
{
public sealed partial class TagItemViewModel {
private readonly TagsViewModel _owner;
/// <summary>
@@ -19,8 +20,7 @@ public sealed partial class TagItemViewModel
/// <param name="owner">The owning tag manager.</param>
/// <param name="tag">The tag model.</param>
/// <param name="usageCount">The number of cards using the tag.</param>
public TagItemViewModel(TagsViewModel owner, TagModel tag, long usageCount)
{
public TagItemViewModel(TagsViewModel owner, TagModel tag, long usageCount) {
_owner = owner;
Tag = tag;
Badge = new TagBadgeViewModel(tag);
+16 -13
View File
@@ -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.
/// </summary>
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
/// </summary>
/// <param name="repository">The workspace repository.</param>
/// <param name="boardChanged">Invoked after a tag change so the board refreshes.</param>
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
/// </summary>
public bool HasNoTags => Tags.Count == 0;
/// <summary>Gets the interaction that opens the new-tag editor.</summary>
/// <summary>
/// Gets the interaction that opens the new-tag editor.
/// </summary>
public Interaction<Unit> AddTag { get; } = new();
/// <summary>Gets the interaction that opens the tag editor.</summary>
/// <summary>
/// Gets the interaction that opens the tag editor.
/// </summary>
public Interaction<TagItemViewModel> EditTag { get; } = new();
/// <summary>Gets the interaction that confirms and deletes a tag.</summary>
/// <summary>
/// Gets the interaction that confirms and deletes a tag.
/// </summary>
public Interaction<TagItemViewModel> DeleteTag { get; } = new();
/// <summary>
@@ -79,11 +85,9 @@ public sealed partial class TagsViewModel : ViewModelBase
/// <summary>
/// Reloads the tag list from the repository.
/// </summary>
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
/// <summary>
/// Reloads the tags and refreshes the board after a tag change.
/// </summary>
public void RefreshAfterChange()
{
public void RefreshAfterChange() {
Reload();
_boardChanged();
}
+1 -2
View File
@@ -2,6 +2,5 @@
namespace YKanBan.ViewModels;
public class ViewModelBase : ObservableObject
{
public class ViewModelBase : ObservableObject {
}
+3 -4
View File
@@ -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.
/// </summary>
public sealed partial class WorkspaceViewModel : ViewModelBase
{
public sealed partial class WorkspaceViewModel : ViewModelBase {
private readonly Interaction<Unit> _showSettings;
private readonly Interaction<Unit> _showAbout;
@@ -26,8 +26,7 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
AppServices services,
WorkspaceSession session,
Interaction<Unit> showSettings,
Interaction<Unit> showAbout)
{
Interaction<Unit> showAbout) {
Session = session;
WorkspaceName = new DirectoryInfo(session.FolderPath).Name;
WorkspacePath = session.FolderPath;
+51 -65
View File
@@ -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
}
}
/// <summary>Gets the window that owns this board, used as the dialog owner.</summary>
/// <summary>
/// Gets the window that owns this board, used as the dialog owner.
/// </summary>
private Window? OwningWindow => TopLevel.GetTopLevel(this) as Window;
/// <summary>Shows the add/edit column dialog and reloads on success.</summary>
private async Task EditColumnAsync(BoardViewModel board, ColumnViewModel? column)
{
if (OwningWindow is not Window owner)
{
/// <summary>
/// Shows the add/edit column dialog and reloads on success.
/// </summary>
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<bool>(owner))
{
if (await dialog.ShowDialog<bool>(owner)) {
board.Reload();
}
}
/// <summary>Confirms and deletes a column, then reloads.</summary>
private async Task DeleteColumnAsync(BoardViewModel board, ColumnViewModel column)
{
if (OwningWindow is not Window owner)
{
/// <summary>
/// Confirms and deletes a column, then reloads.
/// </summary>
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<bool>(owner);
}
if (!confirmed)
{
if (!confirmed) {
return;
}
@@ -79,45 +73,40 @@ public partial class BoardView : UserControl
board.Reload();
}
/// <summary>Shows the add/edit card dialog and reloads on success.</summary>
private async Task EditCardAsync(BoardViewModel board, ColumnViewModel? column, CardViewModel? card)
{
if (OwningWindow is not Window owner)
{
/// <summary>
/// Shows the add/edit card dialog and reloads on success.
/// </summary>
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<bool>(owner))
{
if (await dialog.ShowDialog<bool>(owner)) {
board.Reload();
}
}
/// <summary>Confirms and deletes a card, then reloads.</summary>
private async Task DeleteCardAsync(BoardViewModel board, CardViewModel card)
{
if (OwningWindow is not Window owner)
{
/// <summary>
/// Confirms and deletes a card, then reloads.
/// </summary>
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<bool>(owner);
}
if (!confirmed)
{
if (!confirmed) {
return;
}
@@ -125,35 +114,32 @@ public partial class BoardView : UserControl
board.Reload();
}
/// <summary>Shows the move-card dialog and reloads when a destination is chosen.</summary>
private async Task MoveCardAsync(BoardViewModel board, CardViewModel card)
{
if (OwningWindow is not Window owner)
{
/// <summary>
/// Shows the move-card dialog and reloads when a destination is chosen.
/// </summary>
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<long?>(owner) is long targetColumnId)
{
if (await dialog.ShowDialog<long?>(owner) is long targetColumnId) {
board.Repository.MoveCard(card.Id, targetColumnId);
board.Reload();
}
}
/// <summary>Shows the search syntax error dialog.</summary>
private async Task ShowSearchErrorAsync(string detail)
{
if (OwningWindow is not Window owner)
{
/// <summary>
/// Shows the search syntax error dialog.
/// </summary>
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);
+11 -10
View File
@@ -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();
}
/// <summary>Clicking the card body opens the card editor.</summary>
private void OnCardTapped(object? sender, TappedEventArgs e)
{
if (DataContext is CardViewModel card)
{
/// <summary>
/// Clicking the card body opens the card editor.
/// </summary>
private void OnCardTapped(object? sender, TappedEventArgs e) {
if (DataContext is CardViewModel card) {
card.EditCommand.Execute(null);
}
}
/// <summary>Stops the corner menu button's tap from bubbling to the card body.</summary>
/// <summary>
/// Stops the corner menu button's tap from bubbling to the card body.
/// </summary>
private void OnMenuButtonTapped(object? sender, TappedEventArgs e) => e.Handled = true;
}
+2 -4
View File
@@ -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();
}
}
+5 -5
View File
@@ -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();
}
/// <summary>Closes the dialog.</summary>
/// <summary>
/// Closes the dialog.
/// </summary>
private void OnCloseClick(object? sender, RoutedEventArgs e) => Close();
}
+38 -40
View File
@@ -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);
}
}
/// <summary>Shows the nested tag picker, owned by this dialog.</summary>
private async Task<TagModel?> ShowTagPickerAsync(Unit input)
{
if (DataContext is not CardEditorViewModel viewModel)
{
/// <summary>
/// Shows the nested tag picker, owned by this dialog.
/// </summary>
private async Task<TagModel?> 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<TagModel?>(this);
}
/// <summary>Writes the card and closes when accepted.</summary>
private void OnOkClick(object? sender, RoutedEventArgs e)
{
if (DataContext is CardEditorViewModel viewModel && viewModel.TryAccept())
{
/// <summary>
/// Writes the card and closes when accepted.
/// </summary>
private void OnOkClick(object? sender, RoutedEventArgs e) {
if (DataContext is CardEditorViewModel viewModel && viewModel.TryAccept()) {
_allowClose = true;
Close(true);
}
}
/// <summary>Requests a close, confirming the discard first when needed.</summary>
/// <summary>
/// Requests a close, confirming the discard first when needed.
/// </summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => _ = RequestCloseAsync();
/// <summary>Intercepts the window close button to run the discard confirmation.</summary>
protected override void OnClosing(WindowClosingEventArgs e)
{
/// <summary>
/// Intercepts the window close button to run the discard confirmation.
/// </summary>
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;
}
}
/// <summary>Confirms the discard and closes, or stays open when declined.</summary>
private async Task RequestCloseAsync()
{
if (HasUnsavedChanges && DiscardConfirmEnabled)
{
/// <summary>
/// Confirms the discard and closes, or stays open when declined.
/// </summary>
private async Task RequestCloseAsync() {
if (HasUnsavedChanges && DiscardConfirmEnabled) {
var dialog = new DiscardEditConfirmDialog { DataContext = new DiscardEditConfirmViewModel() };
if (!await dialog.ShowDialog<bool>(this))
{
if (!await dialog.ShowDialog<bool>(this)) {
return;
}
}
@@ -91,9 +85,13 @@ public partial class CardEditorDialog : Window
Close(false);
}
/// <summary>Gets whether the editor has unsaved changes.</summary>
/// <summary>
/// Gets whether the editor has unsaved changes.
/// </summary>
private bool HasUnsavedChanges => DataContext is CardEditorViewModel viewModel && viewModel.HasChanges;
/// <summary>Gets whether the discard confirmation setting is enabled.</summary>
/// <summary>
/// Gets whether the discard confirmation setting is enabled.
/// </summary>
private static bool DiscardConfirmEnabled => App.Services?.Config.Confirmations.DiscardEdit == true;
}
@@ -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();
}
/// <summary>Writes the column and closes when accepted.</summary>
private void OnOkClick(object? sender, RoutedEventArgs e)
{
if (DataContext is ColumnEditorViewModel viewModel && viewModel.TryAccept())
{
/// <summary>
/// Writes the column and closes when accepted.
/// </summary>
private void OnOkClick(object? sender, RoutedEventArgs e) {
if (DataContext is ColumnEditorViewModel viewModel && viewModel.TryAccept()) {
Close(true);
}
}
/// <summary>Closes without writing.</summary>
/// <summary>
/// Closes without writing.
/// </summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -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();
}
/// <summary>Confirms the deletion.</summary>
/// <summary>
/// Confirms the deletion.
/// </summary>
private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true);
/// <summary>Cancels the deletion.</summary>
/// <summary>
/// Cancels the deletion.
/// </summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -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();
}
/// <summary>Confirms the deletion.</summary>
/// <summary>
/// Confirms the deletion.
/// </summary>
private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true);
/// <summary>Cancels the deletion.</summary>
/// <summary>
/// Cancels the deletion.
/// </summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -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();
}
/// <summary>Confirms the deletion.</summary>
/// <summary>
/// Confirms the deletion.
/// </summary>
private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true);
/// <summary>Cancels the deletion.</summary>
/// <summary>
/// Cancels the deletion.
/// </summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -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();
}
/// <summary>Confirms discarding the changes.</summary>
/// <summary>
/// Confirms discarding the changes.
/// </summary>
private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true);
/// <summary>Keeps editing.</summary>
/// <summary>
/// Keeps editing.
/// </summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -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();
}
/// <summary>Closes the dialog.</summary>
/// <summary>
/// Closes the dialog.
/// </summary>
private void OnOkClick(object? sender, RoutedEventArgs e) => Close();
}
+11 -10
View File
@@ -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();
}
/// <summary>Closes with the selected destination column id.</summary>
private void OnOkClick(object? sender, RoutedEventArgs e)
{
if (DataContext is MoveCardViewModel viewModel && viewModel.SelectedTarget is not null)
{
/// <summary>
/// Closes with the selected destination column id.
/// </summary>
private void OnOkClick(object? sender, RoutedEventArgs e) {
if (DataContext is MoveCardViewModel viewModel && viewModel.SelectedTarget is not null) {
Close((long)viewModel.SelectedTarget.Value);
}
}
/// <summary>Closes without moving.</summary>
/// <summary>
/// Closes without moving.
/// </summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(null);
}
@@ -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();
}
/// <summary>Closes the dialog.</summary>
/// <summary>
/// Closes the dialog.
/// </summary>
private void OnOkClick(object? sender, RoutedEventArgs e) => Close();
}
+10 -8
View File
@@ -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();
}
/// <summary>Applies the working settings and closes with a positive result.</summary>
private void OnOkClick(object? sender, RoutedEventArgs e)
{
/// <summary>
/// Applies the working settings and closes with a positive result.
/// </summary>
private void OnOkClick(object? sender, RoutedEventArgs e) {
(DataContext as SettingsDialogViewModel)?.Accept();
Close(true);
}
/// <summary>Closes without applying anything.</summary>
/// <summary>
/// Closes without applying anything.
/// </summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
}
+11 -10
View File
@@ -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();
}
/// <summary>Writes the tag and closes when accepted.</summary>
private void OnOkClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TagEditorViewModel viewModel && viewModel.TryAccept())
{
/// <summary>
/// Writes the tag and closes when accepted.
/// </summary>
private void OnOkClick(object? sender, RoutedEventArgs e) {
if (DataContext is TagEditorViewModel viewModel && viewModel.TryAccept()) {
Close(true);
}
}
/// <summary>Closes without writing.</summary>
/// <summary>
/// Closes without writing.
/// </summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
}
+11 -10
View File
@@ -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();
}
/// <summary>Closes with the selected tag.</summary>
private void OnOkClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TagPickerViewModel viewModel && viewModel.SelectedTarget is { } badge)
{
/// <summary>
/// Closes with the selected tag.
/// </summary>
private void OnOkClick(object? sender, RoutedEventArgs e) {
if (DataContext is TagPickerViewModel viewModel && viewModel.SelectedTarget is { } badge) {
Close(badge.Tag);
}
}
/// <summary>Closes without picking a tag.</summary>
/// <summary>
/// Closes without picking a tag.
/// </summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(null);
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
+16 -20
View File
@@ -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);
}
}
/// <summary>Shows the settings dialog centered on this window.</summary>
private async Task ShowSettingsAsync(Unit input)
{
if (App.Services is null)
{
/// <summary>
/// Shows the settings dialog centered on this window.
/// </summary>
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);
}
/// <summary>Shows the about dialog centered on this window.</summary>
private async Task ShowAboutAsync(Unit input)
{
var dialog = new AboutDialog
{
/// <summary>
/// Shows the about dialog centered on this window.
/// </summary>
private async Task ShowAboutAsync(Unit input) {
var dialog = new AboutDialog {
DataContext = new AboutDialogViewModel(),
};
await dialog.ShowDialog(this);
+23 -29
View File
@@ -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
}
}
/// <summary>Gets the window that owns this view, used as the dialog owner.</summary>
/// <summary>
/// Gets the window that owns this view, used as the dialog owner.
/// </summary>
private Window? OwningWindow => TopLevel.GetTopLevel(this) as Window;
/// <summary>Shows the add/edit tag dialog and refreshes on success.</summary>
private async Task EditTagAsync(TagsViewModel tags, TagItemViewModel? item)
{
if (OwningWindow is not Window owner)
{
/// <summary>
/// Shows the add/edit tag dialog and refreshes on success.
/// </summary>
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<bool>(owner))
{
if (await dialog.ShowDialog<bool>(owner)) {
tags.RefreshAfterChange();
}
}
/// <summary>Confirms and deletes a tag, then refreshes.</summary>
private async Task DeleteTagAsync(TagsViewModel tags, TagItemViewModel item)
{
if (OwningWindow is not Window owner)
{
/// <summary>
/// Confirms and deletes a tag, then refreshes.
/// </summary>
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<bool>(owner);
}
if (!confirmed)
{
if (!confirmed) {
return;
}
+18 -25
View File
@@ -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);
}
}
/// <summary>Prompts for a file and writes the Markdown board export to it.</summary>
private async Task ExportAsync(Unit input)
{
if (DataContext is not WorkspaceViewModel viewModel)
{
/// <summary>
/// Prompts for a file and writes the Markdown board export to it.
/// </summary>
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);