feat: add search parser, sql compiler and markdown export
This commit is contained in:
1 parent
941e22c7ff
commit
22a0f61cb5
12 files changed
+1510
No files matched your search
@@ -25,4 +25,34 @@
|
||||
<data name="PresetColumn_Done" xml:space="preserve">
|
||||
<value>Done</value>
|
||||
</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>
|
||||
@@ -25,4 +25,34 @@
|
||||
<data name="PresetColumn_Done" xml:space="preserve">
|
||||
<value>已完成</value>
|
||||
</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>
|
||||
@@ -0,0 +1,148 @@
|
||||
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>
|
||||
/// <returns>The Markdown document.</returns>
|
||||
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, for example
|
||||
/// "MyRepo-export-20260930-142537.md".
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
CultureInfo culture = Resources.Culture ?? CultureInfo.CurrentUICulture;
|
||||
return string.Format(culture, Resources.Export_FileNamePattern, workspaceName, exportedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends one card block: heading, body, tags line and timestamps line.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
// 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>
|
||||
/// <param name="builder">The output builder.</param>
|
||||
/// <param name="tags">The tags and their usage counts.</param>
|
||||
/// <param name="culture">The UI culture.</param>
|
||||
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>
|
||||
/// <param name="text">The raw cell text.</param>
|
||||
/// <returns>The escaped single-line text.</returns>
|
||||
private static string Cell(string text) => SingleLine(text).Replace("|", "\\|");
|
||||
|
||||
/// <summary>
|
||||
/// Collapses line breaks so the text stays on one Markdown line.
|
||||
/// </summary>
|
||||
/// <param name="text">The raw text.</param>
|
||||
/// <returns>The text without line breaks.</returns>
|
||||
private static string SingleLine(string text) => text.Replace("\r", string.Empty).Replace("\n", " ");
|
||||
|
||||
/// <summary>
|
||||
/// Converts stored Unix seconds to a local-time <see cref="DateTimeOffset"/>.
|
||||
/// </summary>
|
||||
/// <param name="unixSeconds">The stored timestamp.</param>
|
||||
/// <returns>The local time.</returns>
|
||||
private static DateTimeOffset ToLocalTime(long unixSeconds) =>
|
||||
DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToLocalTime();
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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>
|
||||
/// <param name="Text">The word text.</param>
|
||||
public sealed record Word(string Text) : SearchToken;
|
||||
|
||||
/// <summary>
|
||||
/// A quoted phrase with its escapes already resolved.
|
||||
/// </summary>
|
||||
/// <param name="Text">The unescaped phrase content.</param>
|
||||
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 and parentheses; whitespace only separates tokens.
|
||||
///
|
||||
/// The lexer is strict: inside a phrase only <c>\"</c> and <c>\\</c> are valid
|
||||
/// escapes, a bare backslash or an unknown escape is rejected, and an
|
||||
/// 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>
|
||||
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>
|
||||
private static readonly Parser<char, char> PhraseCharacter =
|
||||
PhraseEscape.Or(Parser<char>.Token(character => character != '"' && character != '\\'));
|
||||
|
||||
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(character =>
|
||||
!char.IsWhiteSpace(character) && character != '(' && character != ')' && character != '"')
|
||||
.AtLeastOnce()
|
||||
.Select(chars => (SearchToken)new SearchToken.Word(new string(chars.ToArray())));
|
||||
|
||||
private static readonly Parser<char, SearchToken> TokenParser =
|
||||
Parser.SkipWhitespaces.Then(
|
||||
PhraseParser
|
||||
.Or(OpenParenParser)
|
||||
.Or(CloseParenParser)
|
||||
.Or(WordParser));
|
||||
|
||||
// Try(...) keeps a failed token non-consuming so Many stops gracefully; End
|
||||
// then rejects any leftover input, which turns dangling quotes or invalid
|
||||
// escapes into hard failures instead of silently dropped text.
|
||||
private static readonly Parser<char, IReadOnlyList<SearchToken>> Lexer =
|
||||
Parser.Try(TokenParser).Many()
|
||||
.Before(Parser.SkipWhitespaces)
|
||||
.Before(Parser<char>.End)
|
||||
.Select(tokens => (IReadOnlyList<SearchToken>)tokens.ToList());
|
||||
|
||||
/// <summary>
|
||||
/// Tokenizes the search text.
|
||||
/// </summary>
|
||||
/// <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
|
||||
{
|
||||
return Lexer.ParseOrThrow(input);
|
||||
}
|
||||
catch (ParseException exception)
|
||||
{
|
||||
throw new SearchSyntaxException(exception.Message, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// 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 Or/And 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>
|
||||
/// <param name="Terms">The alternative terms.</param>
|
||||
public sealed record Or(IReadOnlyList<SearchNode> Terms) : SearchNode;
|
||||
|
||||
/// <summary>
|
||||
/// Conjunction: matches when all terms match (explicit AND or adjacency).
|
||||
/// </summary>
|
||||
/// <param name="Terms">The required terms.</param>
|
||||
public sealed record And(IReadOnlyList<SearchNode> Terms) : SearchNode;
|
||||
|
||||
/// <summary>
|
||||
/// A bare word or phrase matched as a substring of the title OR content.
|
||||
/// </summary>
|
||||
/// <param name="Text">The search text.</param>
|
||||
public sealed record FreeText(string Text) : SearchNode;
|
||||
|
||||
/// <summary>
|
||||
/// A substring match scoped to one card field.
|
||||
/// </summary>
|
||||
/// <param name="Field">The card field to match against.</param>
|
||||
/// <param name="Text">The search text.</param>
|
||||
public sealed record FieldText(SearchField Field, string Text) : SearchNode;
|
||||
|
||||
/// <summary>
|
||||
/// An exact, case-insensitive tag name match.
|
||||
/// </summary>
|
||||
/// <param name="Name">The tag name to match.</param>
|
||||
public sealed record TagName(string Name) : SearchNode;
|
||||
|
||||
/// <summary>
|
||||
/// An exact, case-insensitive column title match.
|
||||
/// </summary>
|
||||
/// <param name="Title">The column title to match.</param>
|
||||
public sealed record ColumnTitle(string Title) : SearchNode;
|
||||
|
||||
/// <summary>
|
||||
/// An exact card id match.
|
||||
/// </summary>
|
||||
/// <param name="Id">The card id to match.</param>
|
||||
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,257 @@
|
||||
using System.Globalization;
|
||||
using Pidgin;
|
||||
|
||||
namespace YKanBan.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the search grammar with Pidgin, strictly: any malformed input
|
||||
/// (unbalanced parentheses, dangling or consecutive operators, unterminated
|
||||
/// phrases, invalid escapes, qualifier keys without a value or with an
|
||||
/// unknown/non-numeric id value) 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". 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>
|
||||
private static readonly (string Key, QualifierKind Kind)[] QualifierTable =
|
||||
[
|
||||
("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(token => token is SearchToken.Word { Text: "AND" });
|
||||
|
||||
private static readonly Parser<SearchToken, SearchToken> OrKeyword =
|
||||
Parser<SearchToken>.Token(token => token is SearchToken.Word { Text: "OR" });
|
||||
|
||||
private static readonly Parser<SearchToken, SearchToken> OpenParen =
|
||||
Parser<SearchToken>.Token(token => token is SearchToken.OpenParen);
|
||||
|
||||
private static readonly Parser<SearchToken, SearchToken> CloseParen =
|
||||
Parser<SearchToken>.Token(token => token is SearchToken.CloseParen);
|
||||
|
||||
/// <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>
|
||||
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);
|
||||
|
||||
#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>
|
||||
/// <returns>The OR-expression parser.</returns>
|
||||
private static Parser<SearchToken, SearchNode> OrExprDeferred() => OrExpr;
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Parses a search box text.
|
||||
/// </summary>
|
||||
/// <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))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
List<SearchToken> tokens = SearchLexer.Tokenize(text).ToList();
|
||||
return Query.ParseOrThrow(tokens);
|
||||
}
|
||||
catch (ParseException exception)
|
||||
{
|
||||
throw new SearchSyntaxException(exception.Message, exception);
|
||||
}
|
||||
}
|
||||
|
||||
#region Atom construction
|
||||
|
||||
/// <summary>
|
||||
/// Builds the parser for one word operand: a qualifier atom, an unknown-key
|
||||
/// failure, or free text.
|
||||
/// </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)
|
||||
{
|
||||
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)))
|
||||
{
|
||||
return Parser<SearchToken>.Return<SearchNode>(new SearchNode.FreeText(word));
|
||||
}
|
||||
|
||||
string key = word[..colonIndex];
|
||||
string value = word[(colonIndex + 1)..];
|
||||
|
||||
if (!TryLookupQualifier(key, out QualifierKind kind))
|
||||
{
|
||||
return Parser<SearchToken>.Fail<SearchNode>($"Unknown qualifier key '{key}'.");
|
||||
}
|
||||
|
||||
// "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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the atom parser for a qualifier value, failing strictly on a non-numeric id.
|
||||
/// </summary>
|
||||
/// <param name="kind">The qualifier kind.</param>
|
||||
/// <param name="value">The qualifier value.</param>
|
||||
/// <returns>The parser producing the atom node.</returns>
|
||||
private static Parser<SearchToken, SearchNode> AtomParser(QualifierKind kind, string value) =>
|
||||
AtomFrom(kind, value) is { } atom
|
||||
? Parser<SearchToken>.Return(atom)
|
||||
: Parser<SearchToken>.Fail<SearchNode>($"The id qualifier expects an integer, got '{value}'.");
|
||||
|
||||
/// <summary>
|
||||
/// Builds the atom for a qualifier value; null when an id value is not an integer.
|
||||
/// </summary>
|
||||
/// <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
|
||||
{
|
||||
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>
|
||||
/// Looks up a qualifier key in the extension table with ordinal (case-sensitive) matching.
|
||||
/// </summary>
|
||||
/// <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))
|
||||
{
|
||||
kind = candidateKind;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
kind = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether the text looks like a qualifier key: a non-empty run of
|
||||
/// letters, digits or underscores.
|
||||
/// </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 == '_'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collapses an AND chain into a single node.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
{
|
||||
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>
|
||||
/// <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)
|
||||
{
|
||||
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
|
||||
{
|
||||
Tag,
|
||||
Title,
|
||||
Content,
|
||||
Id,
|
||||
Column,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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, for <c>SELECT ... FROM cards c WHERE {Predicate}</c>.</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 semantics: LIKE's default case
|
||||
/// folding for substring matches 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>
|
||||
/// <param name="query">The parsed search AST.</param>
|
||||
/// <returns>The compiled predicate and its parameters.</returns>
|
||||
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>
|
||||
/// <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)
|
||||
{
|
||||
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>
|
||||
/// <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)
|
||||
{
|
||||
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>
|
||||
/// <param name="text">The raw user text.</param>
|
||||
/// <returns>The text with <c>\</c>, <c>%</c> and <c>_</c> escaped.</returns>
|
||||
private static string EscapeLike(string text) =>
|
||||
text.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_");
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace YKanBan.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when the search text violates the search grammar. Carries the
|
||||
/// underlying parser's descriptive English message; the UI surfaces it as a
|
||||
/// search error instead of coupling to Pidgin types. The message is never
|
||||
/// localized.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user