feat(m3): search and export
- Pidgin lexer: words, escaped phrases (strict closing), parens - Full-Pidgin token-level parser, strict: malformed input throws SearchSyntaxException (BREAKING: lenient degradation removed from the plan; M7 must surface the error, M8 USAGE.md documents strict behavior) - AST with reference-equality collection terms; structural comparison lives in the test-side SearchNodeComparer (YYC MARK note records the record-equality pitfall) - SQL compiler: parameterized predicate over cards alias, LIKE escaping, COLLATE NOCASE exact matches, tag EXISTS / column IN subqueries - Markdown exporter: ResX-driven templates, per-language export time AND card time patterns, kebab i18n file names, table cell escaping - New RgbColor value type (byte components, strict #RRGGBB parse, canonical lowercase text) wired through TagModel and repository - 57 new tests (grammar branches, strict errors, SQL end-to-end, en/zh export snapshots, color conversions)
This commit is contained in:
1 parent
e2bb0298b1
commit
b5614ff59b
11 files changed
+1195
No files matched your search
@@ -0,0 +1,171 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using YKanBan.Export;
|
||||||
|
using YKanBan.Models;
|
||||||
|
|
||||||
|
namespace YKanBan.Tests.Export;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the Markdown exporter. Mutates the global Resources.Culture,
|
||||||
|
/// therefore marked DoNotParallelize. Asserts real localized output for
|
||||||
|
/// both supported languages.
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
[DoNotParallelize]
|
||||||
|
public class MarkdownExporterTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset ExportedAt = new(2026, 9, 30, 14, 25, 37, TimeSpan.Zero);
|
||||||
|
|
||||||
|
/// <summary>A local wall-clock moment, matching the exporter's conversion path.</summary>
|
||||||
|
private static DateTimeOffset Local(int year, int month, int day, int hour, int minute) =>
|
||||||
|
new DateTimeOffset(new DateTime(year, month, day, hour, minute, 0)).ToLocalTime();
|
||||||
|
|
||||||
|
/// <summary>Unix seconds for a local wall-clock time.</summary>
|
||||||
|
private static long UnixTimeOf(int year, int month, int day, int hour, int minute) =>
|
||||||
|
Local(year, month, day, hour, minute).ToUnixTimeSeconds();
|
||||||
|
|
||||||
|
/// <summary>Builds the board fixture: one working column with two cards, one empty column, two tags.</summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
var columns = new List<ColumnModel>
|
||||||
|
{
|
||||||
|
new(1, "In Progress", "The work column", 100, 100),
|
||||||
|
new(2, "Empty", string.Empty, 100, 100),
|
||||||
|
};
|
||||||
|
var bug = new TagModel(1, "bug", RgbColor.Parse("#ff0000"), string.Empty);
|
||||||
|
var ui = new TagModel(2, "ui", RgbColor.Parse("#00ff00"), "Interface work");
|
||||||
|
var cards = new List<CardModel>
|
||||||
|
{
|
||||||
|
new(61, 1, "Fix login", "Body text", created, updated, [bug, ui]),
|
||||||
|
new(62, 1, string.Empty, string.Empty, created, updated, []),
|
||||||
|
};
|
||||||
|
var tags = new List<(TagModel, long)> { (bug, 1), (ui, 1) };
|
||||||
|
return (columns, cards, tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Export(CultureInfo culture)
|
||||||
|
{
|
||||||
|
CultureInfo? original = Resources.Culture;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Resources.Culture = culture;
|
||||||
|
(IReadOnlyList<ColumnModel> columns, IReadOnlyList<CardModel> cards, IReadOnlyList<(TagModel, long)> tags) = CreateBoard();
|
||||||
|
return MarkdownExporter.Export("MyRepo", columns, cards, tags, ExportedAt);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Resources.Culture = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalize(string text) => text.Replace("\r\n", "\n");
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void EnglishOutputMatchesTemplate()
|
||||||
|
{
|
||||||
|
string created = Local(2026, 9, 1, 10, 0).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||||||
|
string updated = Local(2026, 9, 28, 18, 30).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
string expected = Normalize($"""
|
||||||
|
# MyRepo
|
||||||
|
> Exported at: 2026-09-30 14:25:37
|
||||||
|
|
||||||
|
## Column: In Progress (2)
|
||||||
|
The work column
|
||||||
|
|
||||||
|
### #61 Fix login
|
||||||
|
Body text
|
||||||
|
- Tags: `bug` `ui`
|
||||||
|
- Created: {created} Modified: {updated}
|
||||||
|
|
||||||
|
### #62
|
||||||
|
- Created: {created} Modified: {updated}
|
||||||
|
|
||||||
|
## Column: Empty (0)
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
| Name | Color | Description | Usage |
|
||||||
|
|---|---|---|---|
|
||||||
|
| bug | #ff0000 | | 1 |
|
||||||
|
| ui | #00ff00 | Interface work | 1 |
|
||||||
|
""");
|
||||||
|
|
||||||
|
Assert.AreEqual(expected + "\n", Normalize(Export(new CultureInfo("en"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ChineseOutputMatchesTemplate()
|
||||||
|
{
|
||||||
|
var chinese = new CultureInfo("zh-Hans");
|
||||||
|
string created = Local(2026, 9, 1, 10, 0).ToString("yyyy年M月d日 HH:mm", chinese);
|
||||||
|
string updated = Local(2026, 9, 28, 18, 30).ToString("yyyy年M月d日 HH:mm", chinese);
|
||||||
|
|
||||||
|
string expected = Normalize($"""
|
||||||
|
# MyRepo
|
||||||
|
> 导出时间:2026年9月30日 14:25
|
||||||
|
|
||||||
|
## 列:In Progress(2)
|
||||||
|
The work column
|
||||||
|
|
||||||
|
### #61 Fix login
|
||||||
|
Body text
|
||||||
|
- 标签:`bug` `ui`
|
||||||
|
- 创建:{created} 修改:{updated}
|
||||||
|
|
||||||
|
### #62
|
||||||
|
- 创建:{created} 修改:{updated}
|
||||||
|
|
||||||
|
## 列:Empty(0)
|
||||||
|
|
||||||
|
## 标签总表
|
||||||
|
| 名称 | 颜色 | 描述 | 使用数 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| bug | #ff0000 | | 1 |
|
||||||
|
| ui | #00ff00 | Interface work | 1 |
|
||||||
|
""");
|
||||||
|
|
||||||
|
Assert.AreEqual(expected + "\n", Normalize(Export(new CultureInfo("zh-Hans"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void FileNameFollowsLanguage()
|
||||||
|
{
|
||||||
|
CultureInfo? original = Resources.Culture;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Resources.Culture = new CultureInfo("en");
|
||||||
|
Assert.AreEqual("MyRepo-export-20260930-142537.md", MarkdownExporter.BuildFileName("MyRepo", ExportedAt));
|
||||||
|
|
||||||
|
Resources.Culture = new CultureInfo("zh-Hans");
|
||||||
|
Assert.AreEqual("MyRepo-导出-20260930-142537.md", MarkdownExporter.BuildFileName("MyRepo", ExportedAt));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Resources.Culture = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void TagTableCellsEscapePipesAndLineBreaks()
|
||||||
|
{
|
||||||
|
CultureInfo? original = Resources.Culture;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Resources.Culture = new CultureInfo("en");
|
||||||
|
var tag = new TagModel(1, "a|b", RgbColor.Parse("#112233"), "first\nsecond");
|
||||||
|
string markdown = MarkdownExporter.Export("W",
|
||||||
|
[new ColumnModel(1, "C", string.Empty, 1, 1)],
|
||||||
|
[],
|
||||||
|
[(tag, 0L)],
|
||||||
|
ExportedAt);
|
||||||
|
|
||||||
|
StringAssert.Contains(markdown, "| a\\|b | #112233 | first second | 0 |");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Resources.Culture = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using YKanBan.Search;
|
||||||
|
|
||||||
|
namespace YKanBan.Tests.Search;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Structural comparer for search AST nodes, used by assertions only. The
|
||||||
|
/// AST 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>
|
||||||
|
{
|
||||||
|
/// <summary>Shared comparer instance.</summary>
|
||||||
|
public static SearchNodeComparer Instance { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Determines structural equality of two nodes.</summary>
|
||||||
|
public bool Equals(SearchNode? x, SearchNode? y)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(x, y))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (x is null || y is null || x.GetType() != y.GetType())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
(SearchNode.FieldText a, SearchNode.FieldText b) => a.Field == b.Field && a.Text == b.Text,
|
||||||
|
(SearchNode.TagName a, SearchNode.TagName b) => a.Name == b.Name,
|
||||||
|
(SearchNode.ColumnTitle a, SearchNode.ColumnTitle b) => a.Title == b.Title,
|
||||||
|
(SearchNode.CardId a, SearchNode.CardId b) => a.Id == b.Id,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Not supported: the comparer exists for assertions, not hashing.</summary>
|
||||||
|
public int GetHashCode(SearchNode obj) =>
|
||||||
|
throw new NotSupportedException("Structural comparison for assertions only; hashing is not supported.");
|
||||||
|
|
||||||
|
/// <summary>Order-sensitive element-wise comparison.</summary>
|
||||||
|
private static bool SequencesEqual(IReadOnlyList<SearchNode> x, IReadOnlyList<SearchNode> y)
|
||||||
|
{
|
||||||
|
if (x.Count != y.Count)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Enumerable.Zip(x, y, (a, b) => (a, b)).All(pair => Instance.Equals(pair.a, pair.b));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
using YKanBan.Search;
|
||||||
|
|
||||||
|
namespace YKanBan.Tests.Search;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the search parser: the full grammar branches plus the strict
|
||||||
|
/// failure behavior (malformed input always throws SearchSyntaxException).
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
public class SearchQueryParserTests
|
||||||
|
{
|
||||||
|
/// <summary>Parses and asserts structural equality against the expected AST.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Asserts that the input violates the grammar.</summary>
|
||||||
|
private static void AssertSyntaxError(string text)
|
||||||
|
{
|
||||||
|
Assert.ThrowsExactly<SearchSyntaxException>(() => SearchQueryParser.Parse(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void BlankInputReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.IsNull(SearchQueryParser.Parse(string.Empty));
|
||||||
|
Assert.IsNull(SearchQueryParser.Parse(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void SingleWordIsFreeText()
|
||||||
|
{
|
||||||
|
AssertParsesTo(new SearchNode.FreeText("login"), "login");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void AdjacentWordsAreImplicitAnd()
|
||||||
|
{
|
||||||
|
AssertParsesTo(
|
||||||
|
new SearchNode.And([new SearchNode.FreeText("fix"), new SearchNode.FreeText("login")]),
|
||||||
|
"fix login");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ExplicitAndKeyword()
|
||||||
|
{
|
||||||
|
AssertParsesTo(
|
||||||
|
new SearchNode.And([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
|
||||||
|
"a AND b");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void OrKeyword()
|
||||||
|
{
|
||||||
|
AssertParsesTo(
|
||||||
|
new SearchNode.Or([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
|
||||||
|
"a OR b");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ImplicitAndBindsTighterThanOr()
|
||||||
|
{
|
||||||
|
AssertParsesTo(
|
||||||
|
new SearchNode.Or([
|
||||||
|
new SearchNode.And([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
|
||||||
|
new SearchNode.FreeText("c")]),
|
||||||
|
"a b OR c");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ParenthesesForceGrouping()
|
||||||
|
{
|
||||||
|
AssertParsesTo(
|
||||||
|
new SearchNode.And([
|
||||||
|
new SearchNode.FreeText("a"),
|
||||||
|
new SearchNode.Or([new SearchNode.FreeText("b"), new SearchNode.FreeText("c")])]),
|
||||||
|
"a (b OR c)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void NestedParenthesesCollapse()
|
||||||
|
{
|
||||||
|
AssertParsesTo(
|
||||||
|
new SearchNode.Or([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]),
|
||||||
|
"((a OR b))");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void LowercaseKeywordsArePlainWords()
|
||||||
|
{
|
||||||
|
AssertParsesTo(
|
||||||
|
new SearchNode.And([
|
||||||
|
new SearchNode.FreeText("a"),
|
||||||
|
new SearchNode.FreeText("and"),
|
||||||
|
new SearchNode.FreeText("or"),
|
||||||
|
new SearchNode.FreeText("b")]),
|
||||||
|
"a and or b");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void PhraseIsFreeText()
|
||||||
|
{
|
||||||
|
AssertParsesTo(new SearchNode.FreeText("hello world"), "\"hello world\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void PhraseSupportsEscapes()
|
||||||
|
{
|
||||||
|
// Escaped quote, escaped backslash, and a lone backslash kept literally.
|
||||||
|
AssertParsesTo(new SearchNode.FreeText("a\"b"), "\"a\\\"b\"");
|
||||||
|
AssertParsesTo(new SearchNode.FreeText("a\\b"), "\"a\\\\b\"");
|
||||||
|
AssertParsesTo(new SearchNode.FreeText("C:\\x"), "\"C:\\x\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void TagQualifier()
|
||||||
|
{
|
||||||
|
AssertParsesTo(new SearchNode.TagName("bug"), "tag:bug");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void TagQualifierAcceptsPhraseValue()
|
||||||
|
{
|
||||||
|
AssertParsesTo(new SearchNode.TagName("a b"), "tag:\"a b\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
AssertParsesTo(new SearchNode.ColumnTitle("To Do"), "column:\"To Do\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void IdQualifierParsesNumber()
|
||||||
|
{
|
||||||
|
AssertParsesTo(new SearchNode.CardId(42), "id:42");
|
||||||
|
AssertParsesTo(new SearchNode.CardId(12), "id:\"12\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void QualifierKeysAreCaseSensitive()
|
||||||
|
{
|
||||||
|
AssertParsesTo(new SearchNode.FreeText("Tag:x"), "Tag:x");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void UnterminatedPhraseIsAnError()
|
||||||
|
{
|
||||||
|
AssertSyntaxError("\"abc def");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void UnbalancedOpenParenIsAnError()
|
||||||
|
{
|
||||||
|
AssertSyntaxError("(a");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void StrayCloseParenIsAnError()
|
||||||
|
{
|
||||||
|
AssertSyntaxError("a)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void EmptyParensAreAnError()
|
||||||
|
{
|
||||||
|
AssertSyntaxError("()");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void DanglingOrIsAnError()
|
||||||
|
{
|
||||||
|
AssertSyntaxError("a OR");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void DanglingAndIsAnError()
|
||||||
|
{
|
||||||
|
AssertSyntaxError("a AND");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void LeadingAndIsAnError()
|
||||||
|
{
|
||||||
|
AssertSyntaxError("AND a");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void OperatorAtOperandPositionIsAnError()
|
||||||
|
{
|
||||||
|
AssertSyntaxError("a AND OR b");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void QualifierWithoutValueIsAnError()
|
||||||
|
{
|
||||||
|
AssertSyntaxError("tag:");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void NonNumericIdIsAnError()
|
||||||
|
{
|
||||||
|
AssertSyntaxError("id:12x");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void SyntaxErrorCarriesDescriptiveMessage()
|
||||||
|
{
|
||||||
|
SearchSyntaxException ex = Assert.ThrowsExactly<SearchSyntaxException>(() => SearchQueryParser.Parse("a AND"));
|
||||||
|
Assert.IsFalse(string.IsNullOrEmpty(ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using YKanBan.Search;
|
||||||
|
using YKanBan.Storage;
|
||||||
|
using YKanBan.Storage.Workspace;
|
||||||
|
using YKanBan.Tests.TestUtilities;
|
||||||
|
|
||||||
|
namespace YKanBan.Tests.Search;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the query-to-SQL compiler, executed end-to-end against a
|
||||||
|
/// fixture workspace database.
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
public class SearchSqlCompilerTests
|
||||||
|
{
|
||||||
|
/// <summary>Owns a workspace with a fixed card/tag fixture.</summary>
|
||||||
|
private sealed class SearchFixture : IDisposable
|
||||||
|
{
|
||||||
|
public TempDirectory Directory { get; } = new();
|
||||||
|
|
||||||
|
public SqliteConnection Connection { get; }
|
||||||
|
|
||||||
|
public SearchFixture()
|
||||||
|
{
|
||||||
|
WorkspaceInitializer.Initialize(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);");
|
||||||
|
|
||||||
|
// Cards: 1 titled, 2 title-or-content matches, 3 untitled, 4 wildcard chars.
|
||||||
|
SqliteTestHelper.Exec(setup, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (1, 'Fix login', 'Cannot sign in', 1, 1);");
|
||||||
|
SqliteTestHelper.Exec(setup, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (2, 'Green apple', 'the apple pie', 1, 1);");
|
||||||
|
SqliteTestHelper.Exec(setup, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (2, '', 'banana split', 1, 1);");
|
||||||
|
SqliteTestHelper.Exec(setup, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (1, '100% done', 'a_b test', 1, 1);");
|
||||||
|
|
||||||
|
// Tags: mixed case names plus one containing spaces.
|
||||||
|
SqliteTestHelper.Exec(setup, "INSERT INTO tags (name, color, description) VALUES ('Bug', '#ff0000', '');");
|
||||||
|
SqliteTestHelper.Exec(setup, "INSERT INTO tags (name, color, description) VALUES ('UI', '#00ff00', '');");
|
||||||
|
SqliteTestHelper.Exec(setup, "INSERT INTO tags (name, color, description) VALUES ('release notes', '#0000ff', '');");
|
||||||
|
SqliteTestHelper.Exec(setup, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 1);");
|
||||||
|
SqliteTestHelper.Exec(setup, "INSERT INTO card_tags (card_id, tag_id) VALUES (2, 2);");
|
||||||
|
SqliteTestHelper.Exec(setup, "INSERT INTO card_tags (card_id, tag_id) VALUES (3, 2);");
|
||||||
|
SqliteTestHelper.Exec(setup, "INSERT INTO card_tags (card_id, tag_id) VALUES (4, 3);");
|
||||||
|
}
|
||||||
|
|
||||||
|
Connection = SqliteTestHelper.OpenWorkspace(Directory.FullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs a query and returns the ids of the matching cards.</summary>
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
command.Parameters.AddWithValue(name, value);
|
||||||
|
}
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
|
||||||
|
var ids = new List<long>();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
ids.Add(reader.GetInt64(0));
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Connection.Dispose();
|
||||||
|
Directory.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void FreeTextMatchesTitleOrContentCaseInsensitively()
|
||||||
|
{
|
||||||
|
using var fixture = new SearchFixture();
|
||||||
|
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("apple"));
|
||||||
|
CollectionAssert.AreEqual(new[] { 1L }, fixture.Search("login"));
|
||||||
|
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("APPLE"));
|
||||||
|
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("pie"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void FieldQualifiersScopeToTheirField()
|
||||||
|
{
|
||||||
|
using var fixture = new SearchFixture();
|
||||||
|
CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("title:apple"));
|
||||||
|
CollectionAssert.AreEqual(new[] { 3L }, fixture.Search("content:banana"));
|
||||||
|
Assert.AreEqual(0, fixture.Search("title:pie").Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
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"));
|
||||||
|
CollectionAssert.AreEqual(new[] { 4L }, fixture.Search("tag:\"release notes\""));
|
||||||
|
|
||||||
|
// Exact match: prefixes and substrings do not hit.
|
||||||
|
Assert.AreEqual(0, fixture.Search("tag:bu").Count);
|
||||||
|
Assert.AreEqual(0, fixture.Search("tag:release").Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
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\""));
|
||||||
|
|
||||||
|
// Not a prefix or substring match.
|
||||||
|
Assert.AreEqual(0, fixture.Search("column:To").Count);
|
||||||
|
Assert.AreEqual(0, fixture.Search("column:progress").Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
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"));
|
||||||
|
Assert.AreEqual(0, fixture.Search("apple AND id:1").Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ParenthesizedGrouping()
|
||||||
|
{
|
||||||
|
using var fixture = new SearchFixture();
|
||||||
|
|
||||||
|
// (apple OR banana) hits cards 2,3; intersected with column "In Progress" (2,3).
|
||||||
|
CollectionAssert.AreEqual(new[] { 2L, 3L }, fixture.Search("(apple OR banana) AND column:\"In Progress\""));
|
||||||
|
|
||||||
|
// ...while column "To Do" holds cards 1,4 — the intersection is empty.
|
||||||
|
Assert.AreEqual(0, fixture.Search("(apple OR banana) AND column:\"To Do\"").Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
CompiledSearch compiled = SearchSqlCompiler.Compile(new SearchNode.FreeText("x"));
|
||||||
|
|
||||||
|
StringAssert.Contains(compiled.Predicate, "$p0");
|
||||||
|
StringAssert.Contains(compiled.Predicate, "$p1");
|
||||||
|
Assert.AreEqual(2, compiled.Parameters.Count);
|
||||||
|
Assert.AreEqual("%x%", compiled.Parameters[0].Value);
|
||||||
|
Assert.AreEqual("%x%", compiled.Parameters[1].Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,4 +21,34 @@
|
|||||||
<data name="PresetColumn_Done" xml:space="preserve">
|
<data name="PresetColumn_Done" xml:space="preserve">
|
||||||
<value>Done</value>
|
<value>Done</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Export_FileNamePattern" xml:space="preserve">
|
||||||
|
<value>{0}-export-{1:yyyyMMdd-HHmmss}.md</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_TimePattern" xml:space="preserve">
|
||||||
|
<value>yyyy-MM-dd HH:mm:ss</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_CardTimePattern" xml:space="preserve">
|
||||||
|
<value>yyyy-MM-dd HH:mm</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_WorkspaceHeading" xml:space="preserve">
|
||||||
|
<value># {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_ExportedAtLine" xml:space="preserve">
|
||||||
|
<value>> Exported at: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_ColumnHeading" xml:space="preserve">
|
||||||
|
<value>## Column: {0} ({1})</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_TagsLine" xml:space="preserve">
|
||||||
|
<value>- Tags: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_CreatedModifiedLine" xml:space="preserve">
|
||||||
|
<value>- Created: {0} Modified: {1}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_TagsSummaryHeading" xml:space="preserve">
|
||||||
|
<value>## Tags</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_TagsTableHeader" xml:space="preserve">
|
||||||
|
<value>| Name | Color | Description | Usage |</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -21,4 +21,34 @@
|
|||||||
<data name="PresetColumn_Done" xml:space="preserve">
|
<data name="PresetColumn_Done" xml:space="preserve">
|
||||||
<value>已完成</value>
|
<value>已完成</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Export_FileNamePattern" xml:space="preserve">
|
||||||
|
<value>{0}-导出-{1:yyyyMMdd-HHmmss}.md</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_TimePattern" xml:space="preserve">
|
||||||
|
<value>yyyy年M月d日 HH:mm</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_CardTimePattern" xml:space="preserve">
|
||||||
|
<value>yyyy年M月d日 HH:mm</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_WorkspaceHeading" xml:space="preserve">
|
||||||
|
<value># {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_ExportedAtLine" xml:space="preserve">
|
||||||
|
<value>> 导出时间:{0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_ColumnHeading" xml:space="preserve">
|
||||||
|
<value>## 列:{0}({1})</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_TagsLine" xml:space="preserve">
|
||||||
|
<value>- 标签:{0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_CreatedModifiedLine" xml:space="preserve">
|
||||||
|
<value>- 创建:{0} 修改:{1}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_TagsSummaryHeading" xml:space="preserve">
|
||||||
|
<value>## 标签总表</value>
|
||||||
|
</data>
|
||||||
|
<data name="Export_TagsTableHeader" xml:space="preserve">
|
||||||
|
<value>| 名称 | 颜色 | 描述 | 使用数 |</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using YKanBan.Models;
|
||||||
|
|
||||||
|
namespace YKanBan.Export;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates the human-readable Markdown snapshot of a whole board. All
|
||||||
|
/// template text and time formats come from the ResX resources in the
|
||||||
|
/// current UI language; the export timestamp uses the per-language pattern,
|
||||||
|
/// card timestamps use the shared minute-precision pattern. The output is
|
||||||
|
/// meant for reading only — there is no re-import.
|
||||||
|
/// </summary>
|
||||||
|
public static class MarkdownExporter
|
||||||
|
{
|
||||||
|
/// <summary>Renders the whole board to Markdown.</summary>
|
||||||
|
/// <param name="workspaceName">Workspace display name (the folder name).</param>
|
||||||
|
/// <param name="columns">All columns, in id order; empty columns are kept.</param>
|
||||||
|
/// <param name="cards">All cards; grouped by column, id order within a column.</param>
|
||||||
|
/// <param name="tags">All tags with usage counts, in id order.</param>
|
||||||
|
/// <param name="exportedAt">Export moment in local time.</param>
|
||||||
|
public static string Export(
|
||||||
|
string workspaceName,
|
||||||
|
IReadOnlyList<ColumnModel> columns,
|
||||||
|
IReadOnlyList<CardModel> cards,
|
||||||
|
IReadOnlyList<(TagModel Tag, long UsageCount)> tags,
|
||||||
|
DateTimeOffset exportedAt)
|
||||||
|
{
|
||||||
|
CultureInfo culture = Resources.Culture ?? CultureInfo.CurrentUICulture;
|
||||||
|
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
|
||||||
|
// Header: workspace name and the export timestamp in the UI language's format.
|
||||||
|
builder.AppendLine(string.Format(culture, Resources.Export_WorkspaceHeading, workspaceName));
|
||||||
|
builder.AppendLine(string.Format(
|
||||||
|
culture, Resources.Export_ExportedAtLine, exportedAt.ToString(Resources.Export_TimePattern, culture)));
|
||||||
|
builder.AppendLine();
|
||||||
|
|
||||||
|
// Board body: every column in id order, empty ones included.
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
builder.AppendLine(SingleLine(column.Description));
|
||||||
|
}
|
||||||
|
builder.AppendLine();
|
||||||
|
|
||||||
|
foreach (CardModel card in cardsInColumn)
|
||||||
|
{
|
||||||
|
AppendCard(builder, card, culture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AppendTagTable(builder, tags, culture);
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the default export file name in the UI language, e.g. "MyRepo-export-20260930-142537.md".</summary>
|
||||||
|
public static string BuildFileName(string workspaceName, DateTimeOffset exportedAt)
|
||||||
|
{
|
||||||
|
CultureInfo culture = Resources.Culture ?? CultureInfo.CurrentUICulture;
|
||||||
|
return string.Format(culture, Resources.Export_FileNamePattern, workspaceName, exportedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Appends one card block: heading, body, tags line, timestamps line.</summary>
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
builder.AppendLine(card.Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.Tags.Count > 0)
|
||||||
|
{
|
||||||
|
string names = string.Join(" ", card.Tags.Select(tag => $"`{tag.Name}`"));
|
||||||
|
builder.AppendLine(string.Format(culture, Resources.Export_TagsLine, names));
|
||||||
|
}
|
||||||
|
|
||||||
|
string created = ToLocalTime(card.CreatedAt).ToString(Resources.Export_CardTimePattern, culture);
|
||||||
|
string updated = ToLocalTime(card.UpdatedAt).ToString(Resources.Export_CardTimePattern, culture);
|
||||||
|
builder.AppendLine(string.Format(culture, Resources.Export_CreatedModifiedLine, created, updated));
|
||||||
|
builder.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Appends the tag summary table with usage counts; tags with zero usage are included.</summary>
|
||||||
|
private static void AppendTagTable(
|
||||||
|
StringBuilder builder,
|
||||||
|
IReadOnlyList<(TagModel Tag, long UsageCount)> tags,
|
||||||
|
CultureInfo culture)
|
||||||
|
{
|
||||||
|
builder.AppendLine(Resources.Export_TagsSummaryHeading);
|
||||||
|
builder.AppendLine(Resources.Export_TagsTableHeader);
|
||||||
|
builder.AppendLine("|---|---|---|---|");
|
||||||
|
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} |");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Formats a table cell: single line with escaped pipe characters.</summary>
|
||||||
|
private static string Cell(string text) => SingleLine(text).Replace("|", "\\|");
|
||||||
|
|
||||||
|
/// <summary>Collapses line breaks so the text stays on one Markdown line.</summary>
|
||||||
|
private static string SingleLine(string text) => text.Replace("\r", string.Empty).Replace("\n", " ");
|
||||||
|
|
||||||
|
/// <summary>Converts stored Unix seconds to a local-time DateTimeOffset.</summary>
|
||||||
|
private static DateTimeOffset ToLocalTime(long unixSeconds) =>
|
||||||
|
DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToLocalTime();
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using Pidgin;
|
||||||
|
|
||||||
|
namespace YKanBan.Search;
|
||||||
|
|
||||||
|
/// <summary>Lexical tokens of the search syntax.</summary>
|
||||||
|
public abstract record SearchToken
|
||||||
|
{
|
||||||
|
/// <summary>A bare word; qualifier detection happens later from the text.</summary>
|
||||||
|
public sealed record Word(string Text) : SearchToken;
|
||||||
|
|
||||||
|
/// <summary>A quoted phrase with escapes already resolved.</summary>
|
||||||
|
public sealed record Phrase(string Text) : SearchToken;
|
||||||
|
|
||||||
|
/// <summary>An opening parenthesis.</summary>
|
||||||
|
public sealed record OpenParen : SearchToken;
|
||||||
|
|
||||||
|
/// <summary>A closing parenthesis.</summary>
|
||||||
|
public sealed record CloseParen : SearchToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pidgin-based lexer for the search syntax. Splits the input into words,
|
||||||
|
/// quoted phrases (with \" and \\ escapes) and parentheses; whitespace only
|
||||||
|
/// separates tokens. The lexer is total: it never rejects input — anything
|
||||||
|
/// the dedicated rules do not cover falls through to the word rule, and an
|
||||||
|
/// unterminated phrase simply extends to the end of the input.
|
||||||
|
/// </summary>
|
||||||
|
public static class SearchLexer
|
||||||
|
{
|
||||||
|
private static readonly Parser<char, char> PhraseCharacter =
|
||||||
|
Parser.Try(Parser.String("\\\"").Select(_ => '"'))
|
||||||
|
.Or(Parser.Try(Parser.String("\\\\").Select(_ => '\\')))
|
||||||
|
.Or(Parser.AnyCharExcept('"'));
|
||||||
|
|
||||||
|
private static readonly Parser<char, SearchToken> PhraseParser =
|
||||||
|
from openingQuote in Parser.Char('"')
|
||||||
|
from content in PhraseCharacter.Many()
|
||||||
|
from closingQuote in Parser.Char('"')
|
||||||
|
select (SearchToken)new SearchToken.Phrase(new string(content.ToArray()));
|
||||||
|
|
||||||
|
private static readonly Parser<char, SearchToken> OpenParenParser =
|
||||||
|
Parser.Char('(').Select(_ => (SearchToken)new SearchToken.OpenParen());
|
||||||
|
|
||||||
|
private static readonly Parser<char, SearchToken> CloseParenParser =
|
||||||
|
Parser.Char(')').Select(_ => (SearchToken)new SearchToken.CloseParen());
|
||||||
|
|
||||||
|
private static readonly Parser<char, SearchToken> WordParser =
|
||||||
|
Parser<char>.Token(c => !char.IsWhiteSpace(c) && c != '(' && c != ')' && c != '"')
|
||||||
|
.AtLeastOnce()
|
||||||
|
.Select(chars => (SearchToken)new SearchToken.Word(new string(chars.ToArray())));
|
||||||
|
|
||||||
|
private static readonly Parser<char, SearchToken> AnyCharacterAsWord =
|
||||||
|
Parser<char>.Any.Select(c => (SearchToken)new SearchToken.Word(c.ToString()));
|
||||||
|
|
||||||
|
private static readonly Parser<char, SearchToken> TokenParser =
|
||||||
|
Parser.SkipWhitespaces.Then(
|
||||||
|
PhraseParser
|
||||||
|
.Or(OpenParenParser)
|
||||||
|
.Or(CloseParenParser)
|
||||||
|
.Or(WordParser)
|
||||||
|
.Or(AnyCharacterAsWord));
|
||||||
|
|
||||||
|
private static readonly Parser<char, IReadOnlyList<SearchToken>> Lexer =
|
||||||
|
// Try(...) keeps a trailing-whitespace failure non-consuming so Many stops
|
||||||
|
// gracefully; the trailing Before(...) then swallows the leftover whitespace.
|
||||||
|
Parser.Try(TokenParser).Many()
|
||||||
|
.Before(Parser.SkipWhitespaces)
|
||||||
|
.Select(tokens => (IReadOnlyList<SearchToken>)tokens.ToList());
|
||||||
|
|
||||||
|
/// <summary>Tokenizes the search text; total over any input.</summary>
|
||||||
|
public static IReadOnlyList<SearchToken> Tokenize(string input) =>
|
||||||
|
Lexer.ParseOrThrow(input);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// YYC MARK: C# records synthesize equality by running EqualityComparer<T>.Default
|
||||||
|
// on each positional property. For collection properties (IReadOnlyList<>,
|
||||||
|
// ImmutableArray<>, arrays, ...) that comparer falls back to REFERENCE equality,
|
||||||
|
// so the And/Or nodes below deliberately do NOT compare structurally. The only
|
||||||
|
// structural comparison lives in the test helper (SearchNodeComparer); production
|
||||||
|
// code never compares AST nodes.
|
||||||
|
namespace YKanBan.Search;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Root of the search query AST. Search keywords are fixed English grammar
|
||||||
|
/// and are never localized.
|
||||||
|
/// </summary>
|
||||||
|
public abstract record SearchNode
|
||||||
|
{
|
||||||
|
/// <summary>Disjunction: matches when any term matches (OR).</summary>
|
||||||
|
public sealed record Or(IReadOnlyList<SearchNode> Terms) : SearchNode;
|
||||||
|
|
||||||
|
/// <summary>Conjunction: matches when all terms match (explicit AND or adjacency).</summary>
|
||||||
|
public sealed record And(IReadOnlyList<SearchNode> Terms) : SearchNode;
|
||||||
|
|
||||||
|
/// <summary>A bare word or phrase matched as a substring of the title OR content.</summary>
|
||||||
|
public sealed record FreeText(string Text) : SearchNode;
|
||||||
|
|
||||||
|
/// <summary>A substring match scoped to one card field.</summary>
|
||||||
|
public sealed record FieldText(SearchField Field, string Text) : SearchNode;
|
||||||
|
|
||||||
|
/// <summary>An exact, case-insensitive tag name match.</summary>
|
||||||
|
public sealed record TagName(string Name) : SearchNode;
|
||||||
|
|
||||||
|
/// <summary>An exact, case-insensitive column title match.</summary>
|
||||||
|
public sealed record ColumnTitle(string Title) : SearchNode;
|
||||||
|
|
||||||
|
/// <summary>An exact card id match.</summary>
|
||||||
|
public sealed record CardId(long Id) : SearchNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The card fields a <see cref="SearchNode.FieldText"/> atom can scope to.</summary>
|
||||||
|
public enum SearchField
|
||||||
|
{
|
||||||
|
/// <summary>The card title.</summary>
|
||||||
|
Title,
|
||||||
|
|
||||||
|
/// <summary>The card content body.</summary>
|
||||||
|
Content,
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Pidgin;
|
||||||
|
|
||||||
|
namespace YKanBan.Search;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when the search text violates the search grammar. Carries the
|
||||||
|
/// underlying parser's descriptive message (including the error position);
|
||||||
|
/// the UI surfaces it as a search error instead of coupling to Pidgin types.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchSyntaxException : Exception
|
||||||
|
{
|
||||||
|
/// <summary>Creates an exception wrapping the original parse failure.</summary>
|
||||||
|
public SearchSyntaxException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the search grammar with Pidgin, strictly: any malformed input
|
||||||
|
/// (unbalanced parentheses, dangling operators, unterminated phrases,
|
||||||
|
/// qualifier keys without a value, non-numeric id values) raises
|
||||||
|
/// <see cref="SearchSyntaxException"/>. Only uppercase AND/OR are operators
|
||||||
|
/// (lowercase ones are plain words); AND — explicit or by adjacency — binds
|
||||||
|
/// tighter than OR; parentheses group. Blank input returns null and means
|
||||||
|
/// "show everything".
|
||||||
|
/// </summary>
|
||||||
|
public static class SearchQueryParser
|
||||||
|
{
|
||||||
|
/// <summary>Recognized qualifier kinds; extend <see cref="Qualifiers"/> to add new keys.</summary>
|
||||||
|
private enum QualifierKind
|
||||||
|
{
|
||||||
|
Tag,
|
||||||
|
Title,
|
||||||
|
Content,
|
||||||
|
Id,
|
||||||
|
Column,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The qualifier key table ("key:" prefixes); reserved for future keys like created:.</summary>
|
||||||
|
private static readonly (string Key, QualifierKind Kind)[] Qualifiers =
|
||||||
|
[
|
||||||
|
("tag", QualifierKind.Tag),
|
||||||
|
("title", QualifierKind.Title),
|
||||||
|
("content", QualifierKind.Content),
|
||||||
|
("id", QualifierKind.Id),
|
||||||
|
("column", QualifierKind.Column),
|
||||||
|
];
|
||||||
|
|
||||||
|
#region Token primitives
|
||||||
|
|
||||||
|
private static readonly Parser<SearchToken, SearchToken> AndKeyword =
|
||||||
|
Parser<SearchToken>.Token(new SearchToken.Word("AND"));
|
||||||
|
|
||||||
|
private static readonly Parser<SearchToken, SearchToken> OrKeyword =
|
||||||
|
Parser<SearchToken>.Token(new SearchToken.Word("OR"));
|
||||||
|
|
||||||
|
private static readonly Parser<SearchToken, SearchToken> OpenParen =
|
||||||
|
Parser<SearchToken>.Token((SearchToken)new SearchToken.OpenParen());
|
||||||
|
|
||||||
|
private static readonly Parser<SearchToken, SearchToken> CloseParen =
|
||||||
|
Parser<SearchToken>.Token((SearchToken)new SearchToken.CloseParen());
|
||||||
|
|
||||||
|
/// <summary>The text of a phrase token.</summary>
|
||||||
|
private static readonly Parser<SearchToken, string> PhraseText =
|
||||||
|
Parser<SearchToken>.Token(t => t is SearchToken.Phrase)
|
||||||
|
.Select(t => ((SearchToken.Phrase)t).Text);
|
||||||
|
|
||||||
|
/// <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(t => t is SearchToken.Word w && w.Text != "AND" && w.Text != "OR")
|
||||||
|
.Select(t => ((SearchToken.Word)t).Text);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Grammar
|
||||||
|
|
||||||
|
// A bare phrase matched against title or content.
|
||||||
|
private static readonly Parser<SearchToken, SearchNode> FreePhrase =
|
||||||
|
PhraseText.Select(text => (SearchNode)new SearchNode.FreeText(text));
|
||||||
|
|
||||||
|
// A bare word: a "key:value" qualifier when the prefix matches, otherwise free text.
|
||||||
|
private static readonly Parser<SearchToken, SearchNode> WordAtom =
|
||||||
|
NonKeywordWord.Bind(QualifierFrom);
|
||||||
|
|
||||||
|
// '(' orExpr ')' — the deferred method call breaks the
|
||||||
|
// Parenthesized → OrExpr → AndExpr → Operand → Parenthesized recursion cycle.
|
||||||
|
private static readonly Parser<SearchToken, SearchNode> Parenthesized =
|
||||||
|
OpenParen.Then(_ => OrExprDeferred()).Before(CloseParen);
|
||||||
|
|
||||||
|
// operand := '(' orExpr ')' | qualifier-or-word | phrase
|
||||||
|
private static readonly Parser<SearchToken, SearchNode> Operand =
|
||||||
|
Parenthesized.Or(WordAtom).Or(FreePhrase);
|
||||||
|
|
||||||
|
// One andExpr operand together with its optional explicit AND keyword.
|
||||||
|
private static readonly Parser<SearchToken, SearchNode> AndPrefixedOperand =
|
||||||
|
AndKeyword.Optional().Then(Operand);
|
||||||
|
|
||||||
|
// andExpr := operand (AND? operand)*
|
||||||
|
private static readonly Parser<SearchToken, SearchNode> AndExpr =
|
||||||
|
Operand.Bind(first =>
|
||||||
|
AndPrefixedOperand.Many()
|
||||||
|
.Select(rest => FoldAnd(first, rest)));
|
||||||
|
|
||||||
|
// orExpr := andExpr (OR andExpr)+
|
||||||
|
private static readonly Parser<SearchToken, SearchNode> OrExpr =
|
||||||
|
AndExpr.SeparatedAtLeastOnce(OrKeyword)
|
||||||
|
.Select(FoldOr);
|
||||||
|
|
||||||
|
// The full query: orExpr consuming the whole token stream.
|
||||||
|
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>
|
||||||
|
private static Parser<SearchToken, SearchNode> OrExprDeferred() => OrExpr;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
/// <summary>Parses a search box text; returns null for blank input (meaning: show everything).</summary>
|
||||||
|
/// <exception cref="SearchSyntaxException">The text violates the grammar.</exception>
|
||||||
|
public static SearchNode? Parse(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
List<SearchToken> tokens = SearchLexer.Tokenize(text).ToList();
|
||||||
|
return Query.ParseOrThrow(tokens);
|
||||||
|
}
|
||||||
|
catch (ParseException ex)
|
||||||
|
{
|
||||||
|
throw new SearchSyntaxException(ex.Message, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the parser for one word operand: qualifier atom or free text.</summary>
|
||||||
|
private static Parser<SearchToken, SearchNode> QualifierFrom(string word)
|
||||||
|
{
|
||||||
|
foreach ((string key, QualifierKind kind) in Qualifiers)
|
||||||
|
{
|
||||||
|
// Qualifier keys are case-sensitive lowercase grammar tokens.
|
||||||
|
if (!word.StartsWith(key + ":", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
string value = word[(key.Length + 1)..];
|
||||||
|
|
||||||
|
// "key:" without an inline value takes the value from the following phrase, e.g. tag:"a b".
|
||||||
|
return value.Length == 0
|
||||||
|
? PhraseText.Bind(text => AtomParser(kind, text))
|
||||||
|
: AtomParser(kind, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No qualifier prefix: a plain free-text word.
|
||||||
|
return Parser<SearchToken>.Return<SearchNode>(new SearchNode.FreeText(word));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the atom parser for a qualifier value, failing strictly on a non-numeric id.</summary>
|
||||||
|
private static Parser<SearchToken, SearchNode> AtomParser(QualifierKind kind, string value)
|
||||||
|
{
|
||||||
|
return AtomFrom(kind, value) is { } atom
|
||||||
|
? Parser<SearchToken>.Return(atom)
|
||||||
|
: Parser<SearchToken>.Fail<SearchNode>($"id: expects an integer, got '{value}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the atom for a qualifier value; null when an id value is not an integer.</summary>
|
||||||
|
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),
|
||||||
|
QualifierKind.Column => new SearchNode.ColumnTitle(value),
|
||||||
|
QualifierKind.Id when long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long id) => new SearchNode.CardId(id),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Collapses an AND chain into a single node.</summary>
|
||||||
|
private static SearchNode FoldAnd(SearchNode first, IEnumerable<SearchNode> rest)
|
||||||
|
{
|
||||||
|
List<SearchNode> terms = [first, .. rest];
|
||||||
|
return terms.Count == 1 ? terms[0] : new SearchNode.And(terms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Collapses an OR chain into a single node.</summary>
|
||||||
|
private static SearchNode FoldOr(IEnumerable<SearchNode> terms)
|
||||||
|
{
|
||||||
|
List<SearchNode> list = terms.ToList();
|
||||||
|
return list.Count == 1 ? list[0] : new SearchNode.Or(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
namespace YKanBan.Search;
|
||||||
|
|
||||||
|
/// <summary>A compiled search predicate: a SQL fragment over the "c" (cards) alias plus its bound parameters.</summary>
|
||||||
|
/// <param name="Predicate">SQL boolean expression; intended for "SELECT ... FROM cards c WHERE {Predicate}".</param>
|
||||||
|
/// <param name="Parameters">Named parameters referenced by the predicate, in order.</param>
|
||||||
|
public sealed record CompiledSearch(string Predicate, IReadOnlyList<(string Name, object Value)> Parameters);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compiles a parsed search AST into a parameterized SQL predicate.
|
||||||
|
/// Matching is case-insensitive with SQLite's built-in ASCII semantics:
|
||||||
|
/// LIKE's default case folding plus COLLATE NOCASE for exact matches.
|
||||||
|
/// LIKE wildcards and the escape character in user text are escaped so they
|
||||||
|
/// match literally.
|
||||||
|
/// </summary>
|
||||||
|
public static class SearchSqlCompiler
|
||||||
|
{
|
||||||
|
/// <summary>Compiles the query into a SQL predicate.</summary>
|
||||||
|
public static CompiledSearch Compile(SearchNode query)
|
||||||
|
{
|
||||||
|
var parameters = new List<(string Name, object Value)>();
|
||||||
|
string predicate = Build(query, parameters);
|
||||||
|
return new CompiledSearch(predicate, parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Recursively builds the SQL expression for one node.</summary>
|
||||||
|
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.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.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}";
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException($"Unsupported search node: {node.GetType().Name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Binds a value under a fresh name and returns the SQL placeholder.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Escapes LIKE wildcards and the escape character inside user text.</summary>
|
||||||
|
private static string EscapeLike(string text) =>
|
||||||
|
text.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_");
|
||||||
|
}
|
||||||
Reference in new issue
Block a user