[parsing] Migrate SearchQuery parser to C#
This commit is contained in:
parent
553c0cf7ab
commit
8d9856fd62
11 changed files with 448 additions and 407 deletions
|
@ -12,10 +12,10 @@ WORKDIR /src
|
||||||
ARG BUILDPLATFORM
|
ARG BUILDPLATFORM
|
||||||
ARG AOT=false
|
ARG AOT=false
|
||||||
|
|
||||||
# copy csproj/fsproj & nuget config, then restore as distinct layers
|
# copy csproj files & nuget config, then restore as distinct layers
|
||||||
COPY NuGet.Config /src
|
COPY NuGet.Config /src
|
||||||
COPY Iceshrimp.Backend/*.csproj /src/Iceshrimp.Backend/
|
COPY Iceshrimp.Backend/*.csproj /src/Iceshrimp.Backend/
|
||||||
COPY Iceshrimp.Parsing/*.fsproj /src/Iceshrimp.Parsing/
|
COPY Iceshrimp.Parsing/*.csproj /src/Iceshrimp.Parsing/
|
||||||
COPY Iceshrimp.Frontend/*.csproj /src/Iceshrimp.Frontend/
|
COPY Iceshrimp.Frontend/*.csproj /src/Iceshrimp.Frontend/
|
||||||
COPY Iceshrimp.Shared/*.csproj /src/Iceshrimp.Shared/
|
COPY Iceshrimp.Shared/*.csproj /src/Iceshrimp.Shared/
|
||||||
COPY Iceshrimp.Build/*.csproj /src/Iceshrimp.Build/
|
COPY Iceshrimp.Build/*.csproj /src/Iceshrimp.Build/
|
||||||
|
|
|
@ -7,7 +7,6 @@ using Iceshrimp.Backend.Core.Database.Tables;
|
||||||
using Iceshrimp.Backend.Core.Helpers;
|
using Iceshrimp.Backend.Core.Helpers;
|
||||||
using Iceshrimp.Parsing;
|
using Iceshrimp.Parsing;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using static Iceshrimp.Parsing.SearchQueryFilters;
|
|
||||||
|
|
||||||
namespace Iceshrimp.Backend.Core.Extensions;
|
namespace Iceshrimp.Backend.Core.Extensions;
|
||||||
|
|
||||||
|
@ -17,7 +16,7 @@ public static class QueryableFtsExtensions
|
||||||
this IQueryable<Note> query, string input, User user, Config.InstanceSection config, DatabaseContext db
|
this IQueryable<Note> query, string input, User user, Config.InstanceSection config, DatabaseContext db
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var parsed = SearchQuery.parse(input);
|
var parsed = SearchQueryParser.Parse(input);
|
||||||
var caseSensitivity = parsed.OfType<CaseFilter>().LastOrDefault()?.Value ?? CaseFilterType.Insensitive;
|
var caseSensitivity = parsed.OfType<CaseFilter>().LastOrDefault()?.Value ?? CaseFilterType.Insensitive;
|
||||||
var matchType = parsed.OfType<MatchFilter>().LastOrDefault()?.Value ?? MatchFilterType.Substring;
|
var matchType = parsed.OfType<MatchFilter>().LastOrDefault()?.Value ?? MatchFilterType.Substring;
|
||||||
|
|
||||||
|
@ -106,8 +105,8 @@ public static class QueryableFtsExtensions
|
||||||
|
|
||||||
private static IQueryable<Note> ApplyReplyFilter(
|
private static IQueryable<Note> ApplyReplyFilter(
|
||||||
this IQueryable<Note> query, ReplyFilter filter, Config.InstanceSection config, DatabaseContext db
|
this IQueryable<Note> query, ReplyFilter filter, Config.InstanceSection config, DatabaseContext db
|
||||||
) => query.Where(p => p.Reply != null &&
|
) => query.Where(p => p.Reply != null
|
||||||
p.Reply.User.UserSubqueryMatches(filter.Value, filter.Negated, config, db));
|
&& p.Reply.User.UserSubqueryMatches(filter.Value, filter.Negated, config, db));
|
||||||
|
|
||||||
private static IQueryable<Note> ApplyInFilter(
|
private static IQueryable<Note> ApplyInFilter(
|
||||||
this IQueryable<Note> query, InFilter filter, User user, DatabaseContext db
|
this IQueryable<Note> query, InFilter filter, User user, DatabaseContext db
|
||||||
|
@ -115,10 +114,10 @@ public static class QueryableFtsExtensions
|
||||||
{
|
{
|
||||||
return filter.Value switch
|
return filter.Value switch
|
||||||
{
|
{
|
||||||
{ IsLikes: true } => query.ApplyInLikesFilter(user, filter.Negated, db),
|
InFilterType.Likes => query.ApplyInLikesFilter(user, filter.Negated, db),
|
||||||
{ IsBookmarks: true } => query.ApplyInBookmarksFilter(user, filter.Negated, db),
|
InFilterType.Bookmarks => query.ApplyInBookmarksFilter(user, filter.Negated, db),
|
||||||
{ IsReactions: true } => query.ApplyInReactionsFilter(user, filter.Negated, db),
|
InFilterType.Reactions => query.ApplyInReactionsFilter(user, filter.Negated, db),
|
||||||
{ IsInteractions: true } => query.ApplyInInteractionsFilter(user, filter.Negated, db),
|
InFilterType.Interactions => query.ApplyInInteractionsFilter(user, filter.Negated, db),
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(filter), filter.Value, null)
|
_ => throw new ArgumentOutOfRangeException(nameof(filter), filter.Value, null)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -155,10 +154,10 @@ public static class QueryableFtsExtensions
|
||||||
{
|
{
|
||||||
return filter.Value switch
|
return filter.Value switch
|
||||||
{
|
{
|
||||||
{ IsFollowers: true } => query.ApplyFollowersFilter(user, filter.Negated),
|
MiscFilterType.Followers => query.ApplyFollowersFilter(user, filter.Negated),
|
||||||
{ IsFollowing: true } => query.ApplyFollowingFilter(user, filter.Negated),
|
MiscFilterType.Following => query.ApplyFollowingFilter(user, filter.Negated),
|
||||||
{ IsRenotes: true } => query.ApplyBoostsFilter(filter.Negated),
|
MiscFilterType.Renotes => query.ApplyBoostsFilter(filter.Negated),
|
||||||
{ IsReplies: true } => query.ApplyRepliesFilter(filter.Negated),
|
MiscFilterType.Replies => query.ApplyRepliesFilter(filter.Negated),
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(filter))
|
_ => throw new ArgumentOutOfRangeException(nameof(filter))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -182,25 +181,25 @@ public static class QueryableFtsExtensions
|
||||||
|
|
||||||
private static IQueryable<Note> ApplyRegularAttachmentFilter(this IQueryable<Note> query, AttachmentFilter filter)
|
private static IQueryable<Note> ApplyRegularAttachmentFilter(this IQueryable<Note> query, AttachmentFilter filter)
|
||||||
{
|
{
|
||||||
if (filter.Value.IsMedia)
|
if (filter.Value is AttachmentFilterType.Media)
|
||||||
return query.Where(p => p.AttachedFileTypes.Count != 0);
|
return query.Where(p => p.AttachedFileTypes.Count != 0);
|
||||||
if (filter.Value.IsPoll)
|
if (filter.Value is AttachmentFilterType.Poll)
|
||||||
return query.Where(p => p.HasPoll);
|
return query.Where(p => p.HasPoll);
|
||||||
|
|
||||||
if (filter.Value.IsImage || filter.Value.IsVideo || filter.Value.IsAudio)
|
if (filter.Value is AttachmentFilterType.Image or AttachmentFilterType.Video or AttachmentFilterType.Audio)
|
||||||
{
|
{
|
||||||
return query.Where(p => p.AttachedFileTypes.Count != 0 &&
|
return query.Where(p => p.AttachedFileTypes.Count != 0
|
||||||
EF.Functions.ILike(p.RawAttachments, GetAttachmentILikeQuery(filter.Value)));
|
&& EF.Functions.ILike(p.RawAttachments, GetAttachmentILikeQuery(filter.Value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.Value.IsFile)
|
if (filter.Value is AttachmentFilterType.File)
|
||||||
{
|
{
|
||||||
return query.Where(p => p.AttachedFileTypes.Count != 0 &&
|
return query.Where(p => p.AttachedFileTypes.Count != 0
|
||||||
(!EF.Functions.ILike(p.RawAttachments,
|
&& (!EF.Functions.ILike(p.RawAttachments,
|
||||||
GetAttachmentILikeQuery(AttachmentFilterType.Image)) ||
|
GetAttachmentILikeQuery(AttachmentFilterType.Image))
|
||||||
!EF.Functions.ILike(p.RawAttachments,
|
|| !EF.Functions.ILike(p.RawAttachments,
|
||||||
GetAttachmentILikeQuery(AttachmentFilterType.Video)) ||
|
GetAttachmentILikeQuery(AttachmentFilterType.Video))
|
||||||
!EF.Functions.ILike(p.RawAttachments,
|
|| !EF.Functions.ILike(p.RawAttachments,
|
||||||
GetAttachmentILikeQuery(AttachmentFilterType.Audio))));
|
GetAttachmentILikeQuery(AttachmentFilterType.Audio))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -209,20 +208,20 @@ public static class QueryableFtsExtensions
|
||||||
|
|
||||||
private static IQueryable<Note> ApplyNegatedAttachmentFilter(this IQueryable<Note> query, AttachmentFilter filter)
|
private static IQueryable<Note> ApplyNegatedAttachmentFilter(this IQueryable<Note> query, AttachmentFilter filter)
|
||||||
{
|
{
|
||||||
if (filter.Value.IsMedia)
|
if (filter.Value is AttachmentFilterType.Media)
|
||||||
return query.Where(p => p.AttachedFileTypes.Count == 0);
|
return query.Where(p => p.AttachedFileTypes.Count == 0);
|
||||||
if (filter.Value.IsPoll)
|
if (filter.Value is AttachmentFilterType.Poll)
|
||||||
return query.Where(p => !p.HasPoll);
|
return query.Where(p => !p.HasPoll);
|
||||||
if (filter.Value.IsImage || filter.Value.IsVideo || filter.Value.IsAudio)
|
if (filter.Value is AttachmentFilterType.Image or AttachmentFilterType.Video or AttachmentFilterType.Audio)
|
||||||
return query.Where(p => !EF.Functions.ILike(p.RawAttachments, GetAttachmentILikeQuery(filter.Value)));
|
return query.Where(p => !EF.Functions.ILike(p.RawAttachments, GetAttachmentILikeQuery(filter.Value)));
|
||||||
|
|
||||||
if (filter.Value.IsFile)
|
if (filter.Value is AttachmentFilterType.File)
|
||||||
{
|
{
|
||||||
return query.Where(p => EF.Functions
|
return query.Where(p => EF.Functions
|
||||||
.ILike(p.RawAttachments, GetAttachmentILikeQuery(AttachmentFilterType.Image)) ||
|
.ILike(p.RawAttachments, GetAttachmentILikeQuery(AttachmentFilterType.Image))
|
||||||
EF.Functions
|
|| EF.Functions
|
||||||
.ILike(p.RawAttachments, GetAttachmentILikeQuery(AttachmentFilterType.Video)) ||
|
.ILike(p.RawAttachments, GetAttachmentILikeQuery(AttachmentFilterType.Video))
|
||||||
EF.Functions
|
|| EF.Functions
|
||||||
.ILike(p.RawAttachments, GetAttachmentILikeQuery(AttachmentFilterType.Audio)));
|
.ILike(p.RawAttachments, GetAttachmentILikeQuery(AttachmentFilterType.Audio)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -235,9 +234,9 @@ public static class QueryableFtsExtensions
|
||||||
{
|
{
|
||||||
return filter switch
|
return filter switch
|
||||||
{
|
{
|
||||||
{ IsImage: true } => "%image/%",
|
AttachmentFilterType.Image => "%image/%",
|
||||||
{ IsVideo: true } => "%video/%",
|
AttachmentFilterType.Video => "%video/%",
|
||||||
{ IsAudio: true } => "%audio/%",
|
AttachmentFilterType.Audio => "%audio/%",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(filter), filter, null)
|
_ => throw new ArgumentOutOfRangeException(nameof(filter), filter, null)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -264,8 +263,8 @@ public static class QueryableFtsExtensions
|
||||||
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
|
||||||
Justification = "Projectable chain must have consistent visibility")]
|
Justification = "Projectable chain must have consistent visibility")]
|
||||||
internal static IQueryable<User> UserSubquery((string username, string? host) filter, DatabaseContext db) =>
|
internal static IQueryable<User> UserSubquery((string username, string? host) filter, DatabaseContext db) =>
|
||||||
db.Users.Where(p => p.UsernameLower == filter.username &&
|
db.Users.Where(p => p.UsernameLower == filter.username
|
||||||
p.Host == (filter.host != null ? filter.host.ToPunycodeLower() : null));
|
&& p.Host == (filter.host != null ? filter.host.ToPunycodeLower() : null));
|
||||||
|
|
||||||
[Projectable]
|
[Projectable]
|
||||||
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
|
||||||
|
@ -275,34 +274,34 @@ public static class QueryableFtsExtensions
|
||||||
) => matchType.Equals(MatchFilterType.Substring)
|
) => matchType.Equals(MatchFilterType.Substring)
|
||||||
? caseSensitivity.Equals(CaseFilterType.Sensitive)
|
? caseSensitivity.Equals(CaseFilterType.Sensitive)
|
||||||
? negated
|
? negated
|
||||||
? !EF.Functions.Like(note.Text!, "%" + query + "%", @"\") &&
|
? !EF.Functions.Like(note.Text!, "%" + query + "%", @"\")
|
||||||
!EF.Functions.Like(note.Cw!, "%" + query + "%", @"\") &&
|
&& !EF.Functions.Like(note.Cw!, "%" + query + "%", @"\")
|
||||||
!EF.Functions.Like(note.CombinedAltText!, "%" + query + "%", @"\")
|
&& !EF.Functions.Like(note.CombinedAltText!, "%" + query + "%", @"\")
|
||||||
: EF.Functions.Like(note.Text!, "%" + query + "%", @"\") ||
|
: EF.Functions.Like(note.Text!, "%" + query + "%", @"\")
|
||||||
EF.Functions.Like(note.Cw!, "%" + query + "%", @"\") ||
|
|| EF.Functions.Like(note.Cw!, "%" + query + "%", @"\")
|
||||||
EF.Functions.Like(note.CombinedAltText!, "%" + query + "%", @"\")
|
|| EF.Functions.Like(note.CombinedAltText!, "%" + query + "%", @"\")
|
||||||
: negated
|
: negated
|
||||||
? !EF.Functions.ILike(note.Text!, "%" + query + "%", @"\") &&
|
? !EF.Functions.ILike(note.Text!, "%" + query + "%", @"\")
|
||||||
!EF.Functions.ILike(note.Cw!, "%" + query + "%", @"\") &&
|
&& !EF.Functions.ILike(note.Cw!, "%" + query + "%", @"\")
|
||||||
!EF.Functions.ILike(note.CombinedAltText!, "%" + query + "%", @"\")
|
&& !EF.Functions.ILike(note.CombinedAltText!, "%" + query + "%", @"\")
|
||||||
: EF.Functions.ILike(note.Text!, "%" + query + "%", @"\") ||
|
: EF.Functions.ILike(note.Text!, "%" + query + "%", @"\")
|
||||||
EF.Functions.ILike(note.Cw!, "%" + query + "%", @"\") ||
|
|| EF.Functions.ILike(note.Cw!, "%" + query + "%", @"\")
|
||||||
EF.Functions.ILike(note.CombinedAltText!, "%" + query + "%", @"\")
|
|| EF.Functions.ILike(note.CombinedAltText!, "%" + query + "%", @"\")
|
||||||
: caseSensitivity.Equals(CaseFilterType.Sensitive)
|
: caseSensitivity.Equals(CaseFilterType.Sensitive)
|
||||||
? negated
|
? negated
|
||||||
? !Regex.IsMatch(note.Text!, "\\y" + query + "\\y") &&
|
? !Regex.IsMatch(note.Text!, "\\y" + query + "\\y")
|
||||||
!Regex.IsMatch(note.Cw!, "\\y" + query + "\\y") &&
|
&& !Regex.IsMatch(note.Cw!, "\\y" + query + "\\y")
|
||||||
!Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y")
|
&& !Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y")
|
||||||
: Regex.IsMatch(note.Text!, "\\y" + query + "\\y") ||
|
: Regex.IsMatch(note.Text!, "\\y" + query + "\\y")
|
||||||
Regex.IsMatch(note.Cw!, "\\y" + query + "\\y") ||
|
|| Regex.IsMatch(note.Cw!, "\\y" + query + "\\y")
|
||||||
Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y")
|
|| Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y")
|
||||||
: negated
|
: negated
|
||||||
? !Regex.IsMatch(note.Text!, "\\y" + query + "\\y", RegexOptions.IgnoreCase) &&
|
? !Regex.IsMatch(note.Text!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
|
||||||
!Regex.IsMatch(note.Cw!, "\\y" + query + "\\y", RegexOptions.IgnoreCase) &&
|
&& !Regex.IsMatch(note.Cw!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
|
||||||
!Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
|
&& !Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
|
||||||
: Regex.IsMatch(note.Text!, "\\y" + query + "\\y", RegexOptions.IgnoreCase) ||
|
: Regex.IsMatch(note.Text!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
|
||||||
Regex.IsMatch(note.Cw!, "\\y" + query + "\\y", RegexOptions.IgnoreCase) ||
|
|| Regex.IsMatch(note.Cw!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
|
||||||
Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y", RegexOptions.IgnoreCase);
|
|| Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y", RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
[Projectable]
|
[Projectable]
|
||||||
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Iceshrimp.Build\Iceshrimp.Build.csproj" PrivateAssets="all" Private="false" />
|
<ProjectReference Include="..\Iceshrimp.Build\Iceshrimp.Build.csproj" PrivateAssets="all" Private="false" />
|
||||||
<ProjectReference Include="..\Iceshrimp.Frontend\Iceshrimp.Frontend.csproj" />
|
<ProjectReference Include="..\Iceshrimp.Frontend\Iceshrimp.Frontend.csproj" />
|
||||||
<ProjectReference Include="..\Iceshrimp.Parsing\Iceshrimp.Parsing.fsproj" />
|
<ProjectReference Include="..\Iceshrimp.Parsing\Iceshrimp.Parsing.csproj" />
|
||||||
<ProjectReference Include="..\Iceshrimp.Shared\Iceshrimp.Shared.csproj" />
|
<ProjectReference Include="..\Iceshrimp.Shared\Iceshrimp.Shared.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
@ -4,8 +4,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Iceshrimp.Backend", "Iceshr
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Iceshrimp.Tests", "Iceshrimp.Tests\Iceshrimp.Tests.csproj", "{0C93C33B-3D68-41DE-8BD6-2C19EB1C95F7}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Iceshrimp.Tests", "Iceshrimp.Tests\Iceshrimp.Tests.csproj", "{0C93C33B-3D68-41DE-8BD6-2C19EB1C95F7}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Iceshrimp.Parsing", "Iceshrimp.Parsing\Iceshrimp.Parsing.fsproj", "{665B7CCA-6B5B-44DC-9CDB-D070622476C2}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Iceshrimp.Frontend", "Iceshrimp.Frontend\Iceshrimp.Frontend.csproj", "{8BAF3DEB-19A7-4044-A3F3-75C8B9B51863}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Iceshrimp.Frontend", "Iceshrimp.Frontend\Iceshrimp.Frontend.csproj", "{8BAF3DEB-19A7-4044-A3F3-75C8B9B51863}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Iceshrimp.Shared", "Iceshrimp.Shared\Iceshrimp.Shared.csproj", "{25E8E423-D2F7-437B-8E9B-5277BA5CE3CD}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Iceshrimp.Shared", "Iceshrimp.Shared\Iceshrimp.Shared.csproj", "{25E8E423-D2F7-437B-8E9B-5277BA5CE3CD}"
|
||||||
|
@ -61,6 +59,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docker", "docker", "{B14D01
|
||||||
.docker\ci-env-dotnet9.Dockerfile = .docker\ci-env-dotnet9.Dockerfile
|
.docker\ci-env-dotnet9.Dockerfile = .docker\ci-env-dotnet9.Dockerfile
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Iceshrimp.Parsing", "Iceshrimp.Parsing\Iceshrimp.Parsing.csproj", "{6BB21937-A781-4D2A-B64A-19E985870B38}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
@ -75,10 +75,6 @@ Global
|
||||||
{0C93C33B-3D68-41DE-8BD6-2C19EB1C95F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{0C93C33B-3D68-41DE-8BD6-2C19EB1C95F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{0C93C33B-3D68-41DE-8BD6-2C19EB1C95F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{0C93C33B-3D68-41DE-8BD6-2C19EB1C95F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{0C93C33B-3D68-41DE-8BD6-2C19EB1C95F7}.Release|Any CPU.Build.0 = Release|Any CPU
|
{0C93C33B-3D68-41DE-8BD6-2C19EB1C95F7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{665B7CCA-6B5B-44DC-9CDB-D070622476C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{665B7CCA-6B5B-44DC-9CDB-D070622476C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{665B7CCA-6B5B-44DC-9CDB-D070622476C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{665B7CCA-6B5B-44DC-9CDB-D070622476C2}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{8BAF3DEB-19A7-4044-A3F3-75C8B9B51863}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{8BAF3DEB-19A7-4044-A3F3-75C8B9B51863}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{8BAF3DEB-19A7-4044-A3F3-75C8B9B51863}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{8BAF3DEB-19A7-4044-A3F3-75C8B9B51863}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{8BAF3DEB-19A7-4044-A3F3-75C8B9B51863}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{8BAF3DEB-19A7-4044-A3F3-75C8B9B51863}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
@ -91,6 +87,10 @@ Global
|
||||||
{B2598946-03CA-4C6B-8E3E-7F2AC77021E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{B2598946-03CA-4C6B-8E3E-7F2AC77021E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{B2598946-03CA-4C6B-8E3E-7F2AC77021E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{B2598946-03CA-4C6B-8E3E-7F2AC77021E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{B2598946-03CA-4C6B-8E3E-7F2AC77021E5}.Release|Any CPU.Build.0 = Release|Any CPU
|
{B2598946-03CA-4C6B-8E3E-7F2AC77021E5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{6BB21937-A781-4D2A-B64A-19E985870B38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{6BB21937-A781-4D2A-B64A-19E985870B38}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{6BB21937-A781-4D2A-B64A-19E985870B38}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{6BB21937-A781-4D2A-B64A-19E985870B38}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{12AC2DB4-4817-4F73-B541-20568AC51685} = {2000A25C-AF38-47BC-9432-D1278C12010B}
|
{12AC2DB4-4817-4F73-B541-20568AC51685} = {2000A25C-AF38-47BC-9432-D1278C12010B}
|
||||||
|
|
1
Iceshrimp.Parsing/.gitattributes
vendored
1
Iceshrimp.Parsing/.gitattributes
vendored
|
@ -1 +0,0 @@
|
||||||
*.fs linguist-language=F#
|
|
9
Iceshrimp.Parsing/Iceshrimp.Parsing.csproj
Normal file
9
Iceshrimp.Parsing/Iceshrimp.Parsing.csproj
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
|
@ -1,12 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Compile Include="*.fs" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="FParsec" Version="1.1.1" />
|
|
||||||
<PackageReference Update="FSharp.Core" Version="9.0.100" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
|
@ -1,264 +0,0 @@
|
||||||
namespace Iceshrimp.Parsing
|
|
||||||
|
|
||||||
open System
|
|
||||||
open FParsec
|
|
||||||
|
|
||||||
module SearchQueryFilters =
|
|
||||||
type Filter() = class end
|
|
||||||
|
|
||||||
type WordFilter(neg: bool, value: string) =
|
|
||||||
inherit Filter()
|
|
||||||
member val Negated = neg
|
|
||||||
member val Value = value
|
|
||||||
|
|
||||||
type CwFilter(neg: bool, value: string) =
|
|
||||||
inherit Filter()
|
|
||||||
member val Negated = neg
|
|
||||||
member val Value = value
|
|
||||||
|
|
||||||
type MultiWordFilter(values: string list) =
|
|
||||||
inherit Filter()
|
|
||||||
member val Values = values
|
|
||||||
|
|
||||||
type FromFilter(neg: bool, value: string) =
|
|
||||||
inherit Filter()
|
|
||||||
member val Negated = neg
|
|
||||||
member val Value = value
|
|
||||||
|
|
||||||
type MentionFilter(neg: bool, value: string) =
|
|
||||||
inherit Filter()
|
|
||||||
member val Negated = neg
|
|
||||||
member val Value = value
|
|
||||||
|
|
||||||
type ReplyFilter(neg: bool, value: string) =
|
|
||||||
inherit Filter()
|
|
||||||
member val Negated = neg
|
|
||||||
member val Value = value
|
|
||||||
|
|
||||||
type InstanceFilter(neg: bool, value: string) =
|
|
||||||
inherit Filter()
|
|
||||||
member val Negated = neg
|
|
||||||
member val Value = value
|
|
||||||
|
|
||||||
type MiscFilterType =
|
|
||||||
| Followers
|
|
||||||
| Following
|
|
||||||
| Replies
|
|
||||||
| Renotes
|
|
||||||
|
|
||||||
type MiscFilter(neg: bool, value: string) =
|
|
||||||
inherit Filter()
|
|
||||||
member val Negated = neg
|
|
||||||
|
|
||||||
member val Value =
|
|
||||||
match value with
|
|
||||||
| "followers" -> Followers
|
|
||||||
| "following" -> Following
|
|
||||||
| "replies" -> Replies
|
|
||||||
| "reply" -> Replies
|
|
||||||
| "boosts" -> Renotes
|
|
||||||
| "boost" -> Renotes
|
|
||||||
| "renote" -> Renotes
|
|
||||||
| "renotes" -> Renotes
|
|
||||||
| _ -> failwith $"Invalid type: {value}"
|
|
||||||
|
|
||||||
type InFilterType =
|
|
||||||
| Bookmarks
|
|
||||||
| Likes
|
|
||||||
| Reactions
|
|
||||||
| Interactions
|
|
||||||
|
|
||||||
type InFilter(neg: bool, value: string) =
|
|
||||||
inherit Filter()
|
|
||||||
member val Negated = neg
|
|
||||||
|
|
||||||
member val Value =
|
|
||||||
match value with
|
|
||||||
| "bookmarks" -> Bookmarks
|
|
||||||
| "likes" -> Likes
|
|
||||||
| "favorites" -> Likes
|
|
||||||
| "favourites" -> Likes
|
|
||||||
| "reactions" -> Reactions
|
|
||||||
| "interactions" -> Interactions
|
|
||||||
| _ -> failwith $"Invalid type: {value}"
|
|
||||||
|
|
||||||
|
|
||||||
type AttachmentFilterType =
|
|
||||||
| Media
|
|
||||||
| Image
|
|
||||||
| Video
|
|
||||||
| Audio
|
|
||||||
| File
|
|
||||||
| Poll
|
|
||||||
|
|
||||||
type AttachmentFilter(neg: bool, value: string) =
|
|
||||||
inherit Filter()
|
|
||||||
member val Negated = neg
|
|
||||||
|
|
||||||
member val Value =
|
|
||||||
match value with
|
|
||||||
| "any" -> Media
|
|
||||||
| "media" -> Media
|
|
||||||
| "image" -> Image
|
|
||||||
| "video" -> Video
|
|
||||||
| "audio" -> Audio
|
|
||||||
| "file" -> File
|
|
||||||
| "poll" -> Poll
|
|
||||||
| _ -> failwith $"Invalid type: {value}"
|
|
||||||
|
|
||||||
type AfterFilter(d: DateOnly) =
|
|
||||||
inherit Filter()
|
|
||||||
member val Value = d
|
|
||||||
|
|
||||||
type BeforeFilter(d: DateOnly) =
|
|
||||||
inherit Filter()
|
|
||||||
member val Value = d
|
|
||||||
|
|
||||||
type CaseFilterType =
|
|
||||||
| Sensitive
|
|
||||||
| Insensitive
|
|
||||||
|
|
||||||
type CaseFilter(v: string) =
|
|
||||||
inherit Filter()
|
|
||||||
|
|
||||||
member val Value =
|
|
||||||
match v with
|
|
||||||
| "sensitive" -> Sensitive
|
|
||||||
| "insensitive" -> Insensitive
|
|
||||||
| _ -> failwith $"Invalid type: {v}"
|
|
||||||
|
|
||||||
type MatchFilterType =
|
|
||||||
| Words
|
|
||||||
| Substring
|
|
||||||
|
|
||||||
type MatchFilter(v: string) =
|
|
||||||
inherit Filter()
|
|
||||||
|
|
||||||
member val Value =
|
|
||||||
match v with
|
|
||||||
| "word" -> Words
|
|
||||||
| "words" -> Words
|
|
||||||
| "substr" -> Substring
|
|
||||||
| "substring" -> Substring
|
|
||||||
| _ -> failwith $"Invalid type: {v}"
|
|
||||||
|
|
||||||
module private SearchQueryParser =
|
|
||||||
open SearchQueryFilters
|
|
||||||
|
|
||||||
// Abstractions
|
|
||||||
let str s = pstring s
|
|
||||||
let tokenEnd = (skipChar ' ' <|> eof)
|
|
||||||
let token = anyChar |> manyCharsTill <| tokenEnd
|
|
||||||
let orTokenEnd = (skipChar ' ' <|> lookAhead (skipChar ')') <|> eof)
|
|
||||||
let orToken = spaces >>. anyChar |> manyCharsTill <| orTokenEnd
|
|
||||||
let key s = str s .>>? pchar ':'
|
|
||||||
let strEnd s = str s .>>? tokenEnd
|
|
||||||
let anyStr s = choice (s |> Seq.map strEnd)
|
|
||||||
let anyKey k = choice (k |> Seq.map key)
|
|
||||||
let seqAttempt s = s |> Seq.map attempt
|
|
||||||
let neg = opt <| pchar '-'
|
|
||||||
let negFilter k = pipe2 neg (anyKey k >>. token)
|
|
||||||
let negKeyFilter k v = pipe2 neg (anyKey k >>. anyStr v)
|
|
||||||
let keyFilter k v = anyKey k >>. anyStr v
|
|
||||||
let strSepByOr = sepBy orToken (str "OR ")
|
|
||||||
|
|
||||||
let parseDate (s: string) =
|
|
||||||
match DateOnly.TryParseExact(s, "O") with
|
|
||||||
| true, result -> preturn result
|
|
||||||
| false, _ -> fail $"Invalid date: {s}"
|
|
||||||
|
|
||||||
let dateFilter k = anyKey k >>. token >>= parseDate
|
|
||||||
|
|
||||||
// Filters
|
|
||||||
let wordFilter = pipe2 neg token <| fun a b -> WordFilter(a.IsSome, b) :> Filter
|
|
||||||
|
|
||||||
let cwFilter = negFilter [ "cw" ] <| fun n v -> CwFilter(n.IsSome, v) :> Filter
|
|
||||||
|
|
||||||
let multiWordFilter =
|
|
||||||
skipChar '(' >>. strSepByOr .>> skipChar ')'
|
|
||||||
|>> fun v -> MultiWordFilter(v) :> Filter
|
|
||||||
|
|
||||||
let literalStringFilter =
|
|
||||||
skipChar '"' >>. manyCharsTill anyChar (skipChar '"')
|
|
||||||
|>> fun v -> WordFilter(false, v) :> Filter
|
|
||||||
|
|
||||||
let fromFilter =
|
|
||||||
negFilter [ "from"; "author"; "by"; "user" ]
|
|
||||||
<| fun n v -> FromFilter(n.IsSome, v) :> Filter
|
|
||||||
|
|
||||||
let mentionFilter =
|
|
||||||
negFilter [ "mention"; "mentions"; "mentioning" ]
|
|
||||||
<| fun n v -> MentionFilter(n.IsSome, v) :> Filter
|
|
||||||
|
|
||||||
let replyFilter =
|
|
||||||
negFilter [ "reply"; "replying"; "to" ]
|
|
||||||
<| fun n v -> ReplyFilter(n.IsSome, v) :> Filter
|
|
||||||
|
|
||||||
let instanceFilter =
|
|
||||||
negFilter [ "instance"; "domain"; "host" ]
|
|
||||||
<| fun n v -> InstanceFilter(n.IsSome, v) :> Filter
|
|
||||||
|
|
||||||
let miscFilter =
|
|
||||||
negKeyFilter
|
|
||||||
[ "filter" ]
|
|
||||||
[ "followers"
|
|
||||||
"following"
|
|
||||||
"replies"
|
|
||||||
"reply"
|
|
||||||
"renote"
|
|
||||||
"renotes"
|
|
||||||
"boosts"
|
|
||||||
"boost" ]
|
|
||||||
<| fun n v -> MiscFilter(n.IsSome, v) :> Filter
|
|
||||||
|
|
||||||
let inFilter =
|
|
||||||
negKeyFilter [ "in" ] [ "bookmarks"; "favorites"; "favourites"; "reactions"; "likes"; "interactions" ]
|
|
||||||
<| fun n v -> InFilter(n.IsSome, v) :> Filter
|
|
||||||
|
|
||||||
let attachmentFilter =
|
|
||||||
negKeyFilter [ "has"; "attachment"; "attached" ] [ "any"; "media"; "image"; "video"; "audio"; "file"; "poll" ]
|
|
||||||
<| fun n v -> AttachmentFilter(n.IsSome, v) :> Filter
|
|
||||||
|
|
||||||
let afterFilter =
|
|
||||||
dateFilter [ "after"; "since" ] |>> fun v -> AfterFilter(v) :> Filter
|
|
||||||
|
|
||||||
let beforeFilter =
|
|
||||||
dateFilter [ "before"; "until" ] |>> fun v -> BeforeFilter(v) :> Filter
|
|
||||||
|
|
||||||
let caseFilter =
|
|
||||||
keyFilter [ "case" ] [ "sensitive"; "insensitive" ]
|
|
||||||
|>> fun v -> CaseFilter(v) :> Filter
|
|
||||||
|
|
||||||
let matchFilter =
|
|
||||||
keyFilter [ "match" ] [ "words"; "word"; "substr"; "substring" ]
|
|
||||||
|>> fun v -> MatchFilter(v) :> Filter
|
|
||||||
|
|
||||||
// Filter collection
|
|
||||||
let filterSeq =
|
|
||||||
[ literalStringFilter
|
|
||||||
fromFilter
|
|
||||||
mentionFilter
|
|
||||||
replyFilter
|
|
||||||
instanceFilter
|
|
||||||
miscFilter
|
|
||||||
inFilter
|
|
||||||
attachmentFilter
|
|
||||||
afterFilter
|
|
||||||
beforeFilter
|
|
||||||
caseFilter
|
|
||||||
matchFilter
|
|
||||||
cwFilter
|
|
||||||
multiWordFilter
|
|
||||||
wordFilter ]
|
|
||||||
|
|
||||||
// Final parse commands
|
|
||||||
let filters = choice <| seqAttempt filterSeq
|
|
||||||
let parse = manyTill (spaces >>. filters .>> spaces) eof
|
|
||||||
|
|
||||||
module SearchQuery =
|
|
||||||
open SearchQueryParser
|
|
||||||
|
|
||||||
let parse str =
|
|
||||||
match run parse str with
|
|
||||||
| Success(result, _, _) -> result
|
|
||||||
| Failure(s, _, _) -> failwith $"Failed to parse query: {s}"
|
|
190
Iceshrimp.Parsing/SearchQueryFilters.cs
Normal file
190
Iceshrimp.Parsing/SearchQueryFilters.cs
Normal file
|
@ -0,0 +1,190 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace Iceshrimp.Parsing;
|
||||||
|
|
||||||
|
public interface ISearchQueryFilter;
|
||||||
|
|
||||||
|
public record WordFilter(bool Negated, string Value) : ISearchQueryFilter;
|
||||||
|
|
||||||
|
public record CwFilter(bool Negated, string Value) : ISearchQueryFilter;
|
||||||
|
|
||||||
|
public record MultiWordFilter(bool Negated, string[] Values) : ISearchQueryFilter;
|
||||||
|
|
||||||
|
public record FromFilter(bool Negated, string Value) : ISearchQueryFilter;
|
||||||
|
|
||||||
|
public record MentionFilter(bool Negated, string Value) : ISearchQueryFilter;
|
||||||
|
|
||||||
|
public record ReplyFilter(bool Negated, string Value) : ISearchQueryFilter;
|
||||||
|
|
||||||
|
public record InstanceFilter(bool Negated, string Value) : ISearchQueryFilter;
|
||||||
|
|
||||||
|
public enum MiscFilterType
|
||||||
|
{
|
||||||
|
Followers,
|
||||||
|
Following,
|
||||||
|
Replies,
|
||||||
|
Renotes
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MiscFilter(bool Negated, MiscFilterType Value) : ISearchQueryFilter
|
||||||
|
{
|
||||||
|
public static bool TryParse(bool negated, ReadOnlySpan<char> value, [NotNullWhen(true)] out MiscFilter? result)
|
||||||
|
{
|
||||||
|
MiscFilterType? type = value switch
|
||||||
|
{
|
||||||
|
"followers" => MiscFilterType.Followers,
|
||||||
|
"following" => MiscFilterType.Following,
|
||||||
|
"replies" => MiscFilterType.Replies,
|
||||||
|
"reply" => MiscFilterType.Replies,
|
||||||
|
"renote" => MiscFilterType.Renotes,
|
||||||
|
"renotes" => MiscFilterType.Renotes,
|
||||||
|
"boosts" => MiscFilterType.Renotes,
|
||||||
|
"boost" => MiscFilterType.Renotes,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!type.HasValue)
|
||||||
|
{
|
||||||
|
result = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = new MiscFilter(negated, type.Value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum InFilterType
|
||||||
|
{
|
||||||
|
Bookmarks,
|
||||||
|
Likes,
|
||||||
|
Reactions,
|
||||||
|
Interactions
|
||||||
|
}
|
||||||
|
|
||||||
|
public record InFilter(bool Negated, InFilterType Value) : ISearchQueryFilter
|
||||||
|
{
|
||||||
|
public static bool TryParse(bool negated, ReadOnlySpan<char> value, [NotNullWhen(true)] out InFilter? result)
|
||||||
|
{
|
||||||
|
InFilterType? type = value switch
|
||||||
|
{
|
||||||
|
"bookmarks" => InFilterType.Bookmarks,
|
||||||
|
"likes" => InFilterType.Likes,
|
||||||
|
"favorites" => InFilterType.Likes,
|
||||||
|
"favourites" => InFilterType.Likes,
|
||||||
|
"reactions" => InFilterType.Reactions,
|
||||||
|
"interactions" => InFilterType.Interactions,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!type.HasValue)
|
||||||
|
{
|
||||||
|
result = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = new InFilter(negated, type.Value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AttachmentFilterType
|
||||||
|
{
|
||||||
|
Media,
|
||||||
|
Image,
|
||||||
|
Video,
|
||||||
|
Audio,
|
||||||
|
File,
|
||||||
|
Poll
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AttachmentFilter(bool Negated, AttachmentFilterType Value) : ISearchQueryFilter
|
||||||
|
{
|
||||||
|
public static bool TryParse(
|
||||||
|
bool negated, ReadOnlySpan<char> value, [NotNullWhen(true)] out AttachmentFilter? result
|
||||||
|
)
|
||||||
|
{
|
||||||
|
AttachmentFilterType? type = value switch
|
||||||
|
{
|
||||||
|
"any" => AttachmentFilterType.Media,
|
||||||
|
"media" => AttachmentFilterType.Media,
|
||||||
|
"image" => AttachmentFilterType.Image,
|
||||||
|
"video" => AttachmentFilterType.Video,
|
||||||
|
"audio" => AttachmentFilterType.Audio,
|
||||||
|
"file" => AttachmentFilterType.File,
|
||||||
|
"poll" => AttachmentFilterType.Poll,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!type.HasValue)
|
||||||
|
{
|
||||||
|
result = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = new AttachmentFilter(negated, type.Value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AfterFilter(DateOnly Value) : ISearchQueryFilter;
|
||||||
|
|
||||||
|
public record BeforeFilter(DateOnly Value) : ISearchQueryFilter;
|
||||||
|
|
||||||
|
public enum CaseFilterType
|
||||||
|
{
|
||||||
|
Sensitive,
|
||||||
|
Insensitive
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CaseFilter(CaseFilterType Value) : ISearchQueryFilter
|
||||||
|
{
|
||||||
|
public static bool TryParse(ReadOnlySpan<char> value, [NotNullWhen(true)] out CaseFilter? result)
|
||||||
|
{
|
||||||
|
CaseFilterType? type = value switch
|
||||||
|
{
|
||||||
|
"sensitive" => CaseFilterType.Sensitive,
|
||||||
|
"insensitive" => CaseFilterType.Insensitive,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!type.HasValue)
|
||||||
|
{
|
||||||
|
result = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = new CaseFilter(type.Value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MatchFilterType
|
||||||
|
{
|
||||||
|
Words,
|
||||||
|
Substring
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MatchFilter(MatchFilterType Value) : ISearchQueryFilter
|
||||||
|
{
|
||||||
|
public static bool TryParse(ReadOnlySpan<char> value, [NotNullWhen(true)] out MatchFilter? result)
|
||||||
|
{
|
||||||
|
MatchFilterType? type = value switch
|
||||||
|
{
|
||||||
|
"words" => MatchFilterType.Words,
|
||||||
|
"word" => MatchFilterType.Words,
|
||||||
|
"substring" => MatchFilterType.Substring,
|
||||||
|
"substr" => MatchFilterType.Substring,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!type.HasValue)
|
||||||
|
{
|
||||||
|
result = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = new MatchFilter(type.Value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
102
Iceshrimp.Parsing/SearchQueryParser.cs
Normal file
102
Iceshrimp.Parsing/SearchQueryParser.cs
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
namespace Iceshrimp.Parsing;
|
||||||
|
|
||||||
|
public static class SearchQueryParser
|
||||||
|
{
|
||||||
|
public static List<ISearchQueryFilter> Parse(ReadOnlySpan<char> input)
|
||||||
|
{
|
||||||
|
var results = new List<ISearchQueryFilter>();
|
||||||
|
|
||||||
|
input = input.Trim();
|
||||||
|
if (input.Length == 0) return [];
|
||||||
|
|
||||||
|
int pos = 0;
|
||||||
|
while (pos < input.Length)
|
||||||
|
{
|
||||||
|
var oldPos = pos;
|
||||||
|
var res = ParseToken(input, ref pos);
|
||||||
|
if (res == null) return results;
|
||||||
|
if (pos <= oldPos) throw new Exception("Infinite loop detected!");
|
||||||
|
results.Add(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ISearchQueryFilter? ParseToken(ReadOnlySpan<char> input, ref int pos)
|
||||||
|
{
|
||||||
|
while (input[pos] == ' ')
|
||||||
|
{
|
||||||
|
pos++;
|
||||||
|
if (pos >= input.Length) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var negated = false;
|
||||||
|
if (input[pos] == '-' && input.Length > pos + 1)
|
||||||
|
{
|
||||||
|
negated = true;
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input[pos] == '"' && input.Length > pos + 2)
|
||||||
|
{
|
||||||
|
var closingQuote = pos + 1 + input[(pos + 1)..].IndexOf('"');
|
||||||
|
if (closingQuote != -1)
|
||||||
|
{
|
||||||
|
var literalRes = new WordFilter(negated, input[++pos..closingQuote].ToString());
|
||||||
|
pos = closingQuote + 1;
|
||||||
|
return literalRes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input[pos] == '(' && input.Length > pos + 2)
|
||||||
|
{
|
||||||
|
var closingParen = pos + 1 + input[(pos + 1)..].IndexOf(')');
|
||||||
|
if (closingParen != -1)
|
||||||
|
{
|
||||||
|
var items = input[++pos..closingParen].ToString().Split(" OR ").Select(p => p.Trim()).ToArray();
|
||||||
|
var literalRes = new MultiWordFilter(negated, items);
|
||||||
|
if (items.Length > 0)
|
||||||
|
{
|
||||||
|
pos = closingParen + 1;
|
||||||
|
return literalRes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var end = input[pos..].IndexOf(' ');
|
||||||
|
if (end == -1)
|
||||||
|
end = input.Length;
|
||||||
|
else
|
||||||
|
end += pos;
|
||||||
|
|
||||||
|
var splitIdx = input[pos..end].IndexOf(':');
|
||||||
|
var keyRange = splitIdx < 1 ? ..0 : pos..(pos + splitIdx);
|
||||||
|
var key = splitIdx < 1 ? ReadOnlySpan<char>.Empty : input[keyRange];
|
||||||
|
var value = splitIdx < 1 ? input : input[(keyRange.End.Value + 1)..end];
|
||||||
|
|
||||||
|
ISearchQueryFilter res = key switch
|
||||||
|
{
|
||||||
|
"cw" => new CwFilter(negated, value.ToString()),
|
||||||
|
"from" or "author" or "by" or "user" => new FromFilter(negated, value.ToString()),
|
||||||
|
"mention" or "mentions" or "mentioning" => new MentionFilter(negated, value.ToString()),
|
||||||
|
"reply" or "replying" or "to" => new ReplyFilter(negated, value.ToString()),
|
||||||
|
"instance" or "domain" or "host" => new InstanceFilter(negated, value.ToString()),
|
||||||
|
|
||||||
|
"filter" when MiscFilter.TryParse(negated, value, out var parsed) => parsed,
|
||||||
|
"in" when InFilter.TryParse(negated, value, out var parsed) => parsed,
|
||||||
|
"has" or "attachment" or "attached" when AttachmentFilter.TryParse(negated, value, out var parsed)
|
||||||
|
=> parsed,
|
||||||
|
|
||||||
|
"case" when CaseFilter.TryParse(value, out var parsed) => parsed,
|
||||||
|
"match" when MatchFilter.TryParse(value, out var parsed) => parsed,
|
||||||
|
|
||||||
|
"after" or "since" when DateOnly.TryParse(value, out var date) => new AfterFilter(date),
|
||||||
|
"before" or "until" when DateOnly.TryParse(value, out var date) => new BeforeFilter(date),
|
||||||
|
|
||||||
|
_ => new WordFilter(negated, input[pos..end].ToString())
|
||||||
|
};
|
||||||
|
|
||||||
|
pos = end;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,15 +1,14 @@
|
||||||
using Iceshrimp.Parsing;
|
using Iceshrimp.Parsing;
|
||||||
using static Iceshrimp.Parsing.SearchQueryFilters;
|
|
||||||
|
|
||||||
namespace Iceshrimp.Tests.Parsing;
|
namespace Iceshrimp.Tests.Parsing;
|
||||||
|
|
||||||
[TestClass]
|
[TestClass]
|
||||||
public class SearchQueryTests
|
public class SearchQueryTests
|
||||||
{
|
{
|
||||||
private static List<Filter> GetCandidatesByUsername(IEnumerable<string> candidates) =>
|
private static List<ISearchQueryFilter> GetCandidatesByUsername(IEnumerable<string> candidates) =>
|
||||||
candidates.Select(p => $"{p}:username").SelectMany(SearchQuery.parse).ToList();
|
candidates.Select(p => $"{p}:username").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
|
||||||
|
|
||||||
private static void Validate(ICollection<Filter> results, object expectedResult, int count)
|
private static void Validate(ICollection<ISearchQueryFilter> results, object expectedResult, int count)
|
||||||
{
|
{
|
||||||
results.Count.Should().Be(count);
|
results.Count.Should().Be(count);
|
||||||
foreach (var res in results) res.Should().BeEquivalentTo(expectedResult);
|
foreach (var res in results) res.Should().BeEquivalentTo(expectedResult);
|
||||||
|
@ -20,7 +19,7 @@ public class SearchQueryTests
|
||||||
[DataRow(true)]
|
[DataRow(true)]
|
||||||
public void TestParseCw(bool negated)
|
public void TestParseCw(bool negated)
|
||||||
{
|
{
|
||||||
var result = SearchQuery.parse(negated ? "-cw:meta" : "cw:meta").ToList();
|
var result = SearchQueryParser.Parse(negated ? "-cw:meta" : "cw:meta").ToList();
|
||||||
var expectedResult = new CwFilter(negated, "meta");
|
var expectedResult = new CwFilter(negated, "meta");
|
||||||
Validate(result, expectedResult, 1);
|
Validate(result, expectedResult, 1);
|
||||||
}
|
}
|
||||||
|
@ -37,6 +36,17 @@ public class SearchQueryTests
|
||||||
Validate(results, expectedResult, candidates.Count);
|
Validate(results, expectedResult, candidates.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
[DataRow(false)]
|
||||||
|
[DataRow(true)]
|
||||||
|
public void TestParseInvalid(bool negated)
|
||||||
|
{
|
||||||
|
var prefix = negated ? "-" : "";
|
||||||
|
//SearchQueryParser.Parse($"{prefix}from:");
|
||||||
|
//SearchQueryParser.Parse($"{prefix}:");
|
||||||
|
SearchQueryParser.Parse($"{prefix}asd {prefix}:");
|
||||||
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
[DataRow(false)]
|
[DataRow(false)]
|
||||||
[DataRow(true)]
|
[DataRow(true)]
|
||||||
|
@ -68,7 +78,7 @@ public class SearchQueryTests
|
||||||
{
|
{
|
||||||
List<string> candidates = ["instance", "domain", "host"];
|
List<string> candidates = ["instance", "domain", "host"];
|
||||||
if (negated) candidates = candidates.Select(p => "-" + p).ToList();
|
if (negated) candidates = candidates.Select(p => "-" + p).ToList();
|
||||||
var results = candidates.Select(p => $"{p}:instance.tld").SelectMany(SearchQuery.parse).ToList();
|
var results = candidates.Select(p => $"{p}:instance.tld").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
|
||||||
var expectedResult = new InstanceFilter(negated, "instance.tld");
|
var expectedResult = new InstanceFilter(negated, "instance.tld");
|
||||||
Validate(results, expectedResult, candidates.Count);
|
Validate(results, expectedResult, candidates.Count);
|
||||||
}
|
}
|
||||||
|
@ -77,7 +87,7 @@ public class SearchQueryTests
|
||||||
public void TestParseAfter()
|
public void TestParseAfter()
|
||||||
{
|
{
|
||||||
List<string> candidates = ["after", "since"];
|
List<string> candidates = ["after", "since"];
|
||||||
var results = candidates.Select(p => $"{p}:2024-03-01").SelectMany(SearchQuery.parse).ToList();
|
var results = candidates.Select(p => $"{p}:2024-03-01").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
|
||||||
var expectedResult = new AfterFilter(DateOnly.ParseExact("2024-03-01", "O"));
|
var expectedResult = new AfterFilter(DateOnly.ParseExact("2024-03-01", "O"));
|
||||||
Validate(results, expectedResult, candidates.Count);
|
Validate(results, expectedResult, candidates.Count);
|
||||||
}
|
}
|
||||||
|
@ -86,7 +96,7 @@ public class SearchQueryTests
|
||||||
public void TestParseBefore()
|
public void TestParseBefore()
|
||||||
{
|
{
|
||||||
List<string> candidates = ["before", "until"];
|
List<string> candidates = ["before", "until"];
|
||||||
var results = candidates.Select(p => $"{p}:2024-03-01").SelectMany(SearchQuery.parse).ToList();
|
var results = candidates.Select(p => $"{p}:2024-03-01").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
|
||||||
var expectedResult = new BeforeFilter(DateOnly.ParseExact("2024-03-01", "O"));
|
var expectedResult = new BeforeFilter(DateOnly.ParseExact("2024-03-01", "O"));
|
||||||
Validate(results, expectedResult, candidates.Count);
|
Validate(results, expectedResult, candidates.Count);
|
||||||
}
|
}
|
||||||
|
@ -100,16 +110,18 @@ public class SearchQueryTests
|
||||||
if (negated) keyCandidates = keyCandidates.Select(p => "-" + p).ToList();
|
if (negated) keyCandidates = keyCandidates.Select(p => "-" + p).ToList();
|
||||||
List<string> candidates = ["any", "media", "image", "video", "audio", "file", "poll"];
|
List<string> candidates = ["any", "media", "image", "video", "audio", "file", "poll"];
|
||||||
var results =
|
var results =
|
||||||
keyCandidates.Select(k => candidates.Select(v => $"{k}:{v}").SelectMany(SearchQuery.parse).ToList());
|
keyCandidates.Select(k => candidates.Select(v => $"{k}:{v}")
|
||||||
List<Filter> expectedResults =
|
.SelectMany(p => SearchQueryParser.Parse(p))
|
||||||
|
.ToList());
|
||||||
|
List<ISearchQueryFilter> expectedResults =
|
||||||
[
|
[
|
||||||
new AttachmentFilter(negated, "any"),
|
new AttachmentFilter(negated, AttachmentFilterType.Media),
|
||||||
new AttachmentFilter(negated, "media"),
|
new AttachmentFilter(negated, AttachmentFilterType.Media),
|
||||||
new AttachmentFilter(negated, "image"),
|
new AttachmentFilter(negated, AttachmentFilterType.Image),
|
||||||
new AttachmentFilter(negated, "video"),
|
new AttachmentFilter(negated, AttachmentFilterType.Video),
|
||||||
new AttachmentFilter(negated, "audio"),
|
new AttachmentFilter(negated, AttachmentFilterType.Audio),
|
||||||
new AttachmentFilter(negated, "file"),
|
new AttachmentFilter(negated, AttachmentFilterType.File),
|
||||||
new AttachmentFilter(negated, "poll")
|
new AttachmentFilter(negated, AttachmentFilterType.Poll)
|
||||||
];
|
];
|
||||||
results.Should()
|
results.Should()
|
||||||
.HaveCount(keyCandidates.Count)
|
.HaveCount(keyCandidates.Count)
|
||||||
|
@ -121,8 +133,11 @@ public class SearchQueryTests
|
||||||
{
|
{
|
||||||
const string key = "case";
|
const string key = "case";
|
||||||
List<string> candidates = ["sensitive", "insensitive"];
|
List<string> candidates = ["sensitive", "insensitive"];
|
||||||
var results = candidates.Select(v => $"{key}:{v}").SelectMany(SearchQuery.parse).ToList();
|
var results = candidates.Select(v => $"{key}:{v}").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
|
||||||
List<Filter> expectedResults = [new CaseFilter("sensitive"), new CaseFilter("insensitive")];
|
List<ISearchQueryFilter> expectedResults =
|
||||||
|
[
|
||||||
|
new CaseFilter(CaseFilterType.Sensitive), new CaseFilter(CaseFilterType.Insensitive)
|
||||||
|
];
|
||||||
results.Should()
|
results.Should()
|
||||||
.HaveCount(expectedResults.Count)
|
.HaveCount(expectedResults.Count)
|
||||||
.And.BeEquivalentTo(expectedResults, opts => opts.RespectingRuntimeTypes());
|
.And.BeEquivalentTo(expectedResults, opts => opts.RespectingRuntimeTypes());
|
||||||
|
@ -133,10 +148,13 @@ public class SearchQueryTests
|
||||||
{
|
{
|
||||||
const string key = "match";
|
const string key = "match";
|
||||||
List<string> candidates = ["words", "word", "substr", "substring"];
|
List<string> candidates = ["words", "word", "substr", "substring"];
|
||||||
var results = candidates.Select(v => $"{key}:{v}").SelectMany(SearchQuery.parse).ToList();
|
var results = candidates.Select(v => $"{key}:{v}").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
|
||||||
List<Filter> expectedResults =
|
List<ISearchQueryFilter> expectedResults =
|
||||||
[
|
[
|
||||||
new MatchFilter("words"), new MatchFilter("words"), new MatchFilter("substr"), new MatchFilter("substr")
|
new MatchFilter(MatchFilterType.Words),
|
||||||
|
new MatchFilter(MatchFilterType.Words),
|
||||||
|
new MatchFilter(MatchFilterType.Substring),
|
||||||
|
new MatchFilter(MatchFilterType.Substring)
|
||||||
];
|
];
|
||||||
results.Should()
|
results.Should()
|
||||||
.HaveCount(expectedResults.Count)
|
.HaveCount(expectedResults.Count)
|
||||||
|
@ -150,15 +168,15 @@ public class SearchQueryTests
|
||||||
{
|
{
|
||||||
var key = negated ? "-in" : "in";
|
var key = negated ? "-in" : "in";
|
||||||
List<string> candidates = ["bookmarks", "likes", "favorites", "favourites", "reactions", "interactions"];
|
List<string> candidates = ["bookmarks", "likes", "favorites", "favourites", "reactions", "interactions"];
|
||||||
var results = candidates.Select(v => $"{key}:{v}").SelectMany(SearchQuery.parse).ToList();
|
var results = candidates.Select(v => $"{key}:{v}").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
|
||||||
List<Filter> expectedResults =
|
List<ISearchQueryFilter> expectedResults =
|
||||||
[
|
[
|
||||||
new InFilter(negated, "bookmarks"),
|
new InFilter(negated, InFilterType.Bookmarks),
|
||||||
new InFilter(negated, "likes"),
|
new InFilter(negated, InFilterType.Likes),
|
||||||
new InFilter(negated, "likes"),
|
new InFilter(negated, InFilterType.Likes),
|
||||||
new InFilter(negated, "likes"),
|
new InFilter(negated, InFilterType.Likes),
|
||||||
new InFilter(negated, "reactions"),
|
new InFilter(negated, InFilterType.Reactions),
|
||||||
new InFilter(negated, "interactions")
|
new InFilter(negated, InFilterType.Interactions)
|
||||||
];
|
];
|
||||||
results.Should()
|
results.Should()
|
||||||
.HaveCount(expectedResults.Count)
|
.HaveCount(expectedResults.Count)
|
||||||
|
@ -175,17 +193,17 @@ public class SearchQueryTests
|
||||||
[
|
[
|
||||||
"followers", "following", "replies", "reply", "renote", "renotes", "boosts", "boost"
|
"followers", "following", "replies", "reply", "renote", "renotes", "boosts", "boost"
|
||||||
];
|
];
|
||||||
var results = candidates.Select(v => $"{key}:{v}").SelectMany(SearchQuery.parse).ToList();
|
var results = candidates.Select(v => $"{key}:{v}").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
|
||||||
List<Filter> expectedResults =
|
List<ISearchQueryFilter> expectedResults =
|
||||||
[
|
[
|
||||||
new MiscFilter(negated, "followers"),
|
new MiscFilter(negated, MiscFilterType.Followers),
|
||||||
new MiscFilter(negated, "following"),
|
new MiscFilter(negated, MiscFilterType.Following),
|
||||||
new MiscFilter(negated, "replies"),
|
new MiscFilter(negated, MiscFilterType.Replies),
|
||||||
new MiscFilter(negated, "replies"),
|
new MiscFilter(negated, MiscFilterType.Replies),
|
||||||
new MiscFilter(negated, "renotes"),
|
new MiscFilter(negated, MiscFilterType.Renotes),
|
||||||
new MiscFilter(negated, "renotes"),
|
new MiscFilter(negated, MiscFilterType.Renotes),
|
||||||
new MiscFilter(negated, "renotes"),
|
new MiscFilter(negated, MiscFilterType.Renotes),
|
||||||
new MiscFilter(negated, "renotes")
|
new MiscFilter(negated, MiscFilterType.Renotes)
|
||||||
];
|
];
|
||||||
results.Should()
|
results.Should()
|
||||||
.HaveCount(expectedResults.Count)
|
.HaveCount(expectedResults.Count)
|
||||||
|
@ -199,8 +217,8 @@ public class SearchQueryTests
|
||||||
{
|
{
|
||||||
List<string> candidates = ["test", "word", "since:2023-10-10invalid", "in:bookmarkstypo"];
|
List<string> candidates = ["test", "word", "since:2023-10-10invalid", "in:bookmarkstypo"];
|
||||||
if (negated) candidates = candidates.Select(p => "-" + p).ToList();
|
if (negated) candidates = candidates.Select(p => "-" + p).ToList();
|
||||||
var results = candidates.Select(v => $"{v}").SelectMany(SearchQuery.parse).ToList();
|
var results = candidates.Select(v => $"{v}").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
|
||||||
List<Filter> expectedResults =
|
List<ISearchQueryFilter> expectedResults =
|
||||||
[
|
[
|
||||||
new WordFilter(negated, "test"),
|
new WordFilter(negated, "test"),
|
||||||
new WordFilter(negated, "word"),
|
new WordFilter(negated, "word"),
|
||||||
|
@ -216,7 +234,7 @@ public class SearchQueryTests
|
||||||
public void TestParseMultiWord()
|
public void TestParseMultiWord()
|
||||||
{
|
{
|
||||||
const string input = "(word OR word2 OR word3)";
|
const string input = "(word OR word2 OR word3)";
|
||||||
var results = SearchQuery.parse(input).ToList();
|
var results = SearchQueryParser.Parse(input).ToList();
|
||||||
results.Should().HaveCount(1);
|
results.Should().HaveCount(1);
|
||||||
results[0].Should().BeOfType<MultiWordFilter>();
|
results[0].Should().BeOfType<MultiWordFilter>();
|
||||||
((MultiWordFilter)results[0]).Values.ToList().Should().BeEquivalentTo(["word", "word2", "word3"]);
|
((MultiWordFilter)results[0]).Values.ToList().Should().BeEquivalentTo(["word", "word2", "word3"]);
|
||||||
|
@ -226,7 +244,7 @@ public class SearchQueryTests
|
||||||
public void TestParseLiteralString()
|
public void TestParseLiteralString()
|
||||||
{
|
{
|
||||||
const string input = "\"literal string with spaces $# and has:image before:2023-10-10 other things\"";
|
const string input = "\"literal string with spaces $# and has:image before:2023-10-10 other things\"";
|
||||||
var results = SearchQuery.parse(input).ToList();
|
var results = SearchQueryParser.Parse(input).ToList();
|
||||||
results.Should().HaveCount(1);
|
results.Should().HaveCount(1);
|
||||||
results[0].Should().BeOfType<WordFilter>();
|
results[0].Should().BeOfType<WordFilter>();
|
||||||
((WordFilter)results[0]).Value.Should()
|
((WordFilter)results[0]).Value.Should()
|
||||||
|
|
Loading…
Add table
Reference in a new issue