style: expand one-line summaries and run dotnet format solution-wide
This commit is contained in:
1 parent
9ea551b600
commit
8b7e50a955
95 files changed
+970
-1424
No files matched your search
+1
-1
@@ -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}]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} |");
|
||||
}
|
||||
|
||||
@@ -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 <path></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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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] + "…";
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,5 @@
|
||||
|
||||
namespace YKanBan.ViewModels;
|
||||
|
||||
public class ViewModelBase : ObservableObject
|
||||
{
|
||||
public class ViewModelBase : ObservableObject {
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user