Skip to content

Commit f297fca

Browse files
committed
Advanced search
This commit adds a new advanced search functionality with a proper parser, so that we can base a new dynamic saved search functionality on this instead of the currently existing hard coded quick filters.
1 parent 8290681 commit f297fca

25 files changed

Lines changed: 4521 additions & 33 deletions

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ gem "kaminari"
2424
gem "nokogiri"
2525
gem "csv"
2626
gem "redcarpet"
27+
gem "parslet"
2728

2829
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
2930
gem "bcrypt", "~> 3.1"

Gemfile.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ GEM
268268
parser (3.3.10.0)
269269
ast (~> 2.4.1)
270270
racc
271+
parslet (2.0.0)
271272
pg (1.6.2)
272273
pg (1.6.2-aarch64-linux)
273274
pg (1.6.2-aarch64-linux-musl)
@@ -500,6 +501,7 @@ DEPENDENCIES
500501
omniauth
501502
omniauth-google-oauth2
502503
omniauth-rails_csrf_protection
504+
parslet
503505
pg (~> 1.1)
504506
pghero
505507
propshaft

app/assets/stylesheets/components/sidebar.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,3 +282,22 @@
282282
.sidebar .commitfest-committers li + li {
283283
margin-top: var(--spacing-1);
284284
}
285+
286+
.sidebar .search-help-link {
287+
margin-top: var(--spacing-3);
288+
text-align: center;
289+
}
290+
291+
.sidebar .search-help-link a {
292+
color: var(--color-text-secondary);
293+
font-size: var(--font-size-sm);
294+
text-decoration: none;
295+
display: inline-flex;
296+
align-items: center;
297+
gap: var(--spacing-1);
298+
transition: color var(--transition-fast);
299+
}
300+
301+
.sidebar .search-help-link a:hover {
302+
color: var(--color-text-link);
303+
}

app/assets/stylesheets/components/topics.css

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,3 +730,75 @@ a.topic-icon {
730730
background: var(--color-primary-50);
731731
border-color: var(--color-primary-200);
732732
}
733+
734+
/* Search errors and warnings */
735+
.search-error {
736+
background: var(--color-danger-soft);
737+
border: var(--border-width) solid var(--color-danger);
738+
border-radius: var(--border-radius-lg);
739+
padding: var(--spacing-5);
740+
margin-bottom: var(--spacing-5);
741+
display: flex;
742+
gap: var(--spacing-4);
743+
align-items: flex-start;
744+
}
745+
746+
.search-error-icon {
747+
color: var(--color-danger);
748+
font-size: var(--font-size-xl);
749+
flex-shrink: 0;
750+
}
751+
752+
.search-error-content h3 {
753+
margin: 0 0 var(--spacing-2) 0;
754+
color: var(--color-danger);
755+
font-size: var(--font-size-lg);
756+
font-weight: var(--font-weight-semibold);
757+
}
758+
759+
.search-error-content p {
760+
margin: 0 0 var(--spacing-2) 0;
761+
color: var(--color-text-primary);
762+
}
763+
764+
.search-error-help {
765+
font-size: var(--font-size-sm);
766+
}
767+
768+
.search-error-help a {
769+
color: var(--color-danger);
770+
text-decoration: underline;
771+
font-weight: var(--font-weight-medium);
772+
}
773+
774+
.search-warnings {
775+
background: var(--color-warning-bg);
776+
border: var(--border-width) solid var(--color-warning);
777+
border-radius: var(--border-radius-lg);
778+
padding: var(--spacing-4);
779+
margin-bottom: var(--spacing-4);
780+
}
781+
782+
.search-warnings-header {
783+
display: flex;
784+
align-items: center;
785+
gap: var(--spacing-2);
786+
color: var(--color-warning-text);
787+
font-weight: var(--font-weight-medium);
788+
margin-bottom: var(--spacing-2);
789+
}
790+
791+
.search-warnings-header i {
792+
font-size: var(--font-size-lg);
793+
}
794+
795+
.search-warnings-list {
796+
margin: 0;
797+
padding-left: var(--spacing-6);
798+
color: var(--color-warning-text);
799+
font-size: var(--font-size-sm);
800+
}
801+
802+
.search-warnings-list li + li {
803+
margin-top: var(--spacing-1);
804+
}

app/controllers/help_controller.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ class HelpController < ApplicationController
44
layout "help"
55

66
PAGES = {
7+
"search" => "Advanced Search Guide",
78
"hackorum-patch" => "Applying Patches with hackorum-patch",
8-
"account-linking" => "Account Linking & Multiple Emails"
9+
"account-linking" => "Account Linking & Multiple Emails",
910
}.freeze
1011

1112
def index

app/controllers/topics_controller.rb

Lines changed: 63 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,34 @@ def search
202202
return
203203
end
204204

205-
load_cached_search_results
205+
@search_warnings = []
206+
207+
begin
208+
# Parse the search query
209+
parser = Search::QueryParser.new
210+
ast = parser.parse(@search_query)
211+
212+
# Validate and collect warnings
213+
validator = Search::QueryValidator.new(ast)
214+
validated = validator.validate
215+
@search_warnings += validated.warnings
216+
217+
# Build the ActiveRecord query
218+
builder = Search::QueryBuilder.new(ast: validated.ast, user: current_user)
219+
result = builder.build
220+
@search_warnings += result.warnings
221+
222+
# Load results
223+
load_search_results(result.relation)
224+
rescue Parslet::ParseFailed => e
225+
@search_error = format_parse_error(e)
226+
@topics = []
227+
end
206228

207229
preload_topic_participants
208230
preload_commitfest_summaries
209231
preload_participation_flags if user_signed_in?
232+
load_visible_tags if user_signed_in?
210233

211234
respond_to do |format|
212235
format.html
@@ -857,41 +880,19 @@ def load_star_state
857880
@is_starred = TopicStar.exists?(user: current_user, topic: @topic)
858881
end
859882

860-
def load_cached_search_results
883+
SEARCH_PAGE_SIZE = 1000
884+
885+
def load_search_results(base_relation)
861886
@viewing_since = viewing_since_param
862887
longpage = params[:longpage].to_i
863-
cache = SearchResultCache.new(query: @search_query, scope: "title_body", viewing_since: @viewing_since, longpage: longpage)
864-
865-
result = cache.fetch do |limit, offset|
866-
build_search_query(@search_query)
867-
.joins(:messages)
868-
.where(messages: { created_at: ..@viewing_since })
869-
.group('topics.id')
870-
.select('topics.id, topics.creator_id, MAX(messages.created_at) as last_activity')
871-
.order('MAX(messages.created_at) DESC, topics.id DESC')
872-
.limit(limit)
873-
.offset(offset)
874-
.load
875-
end
876888

877-
entries = result[:entries] || []
889+
entries = execute_search_query(base_relation, longpage)
878890
sliced = slice_cached_entries(entries, params[:cursor])
879891

880-
if sliced[:entries].empty? && entries.size >= SearchResultCache::LONGPAGE_SIZE
892+
# Handle pagination to next longpage if needed
893+
if sliced[:entries].empty? && entries.size >= SEARCH_PAGE_SIZE
881894
longpage += 1
882-
cache = SearchResultCache.new(query: @search_query, scope: "title_body", viewing_since: @viewing_since, longpage: longpage)
883-
next_result = cache.fetch do |limit, offset|
884-
build_search_query(@search_query)
885-
.joins(:messages)
886-
.where(messages: { created_at: ..@viewing_since })
887-
.group('topics.id')
888-
.select('topics.id, topics.creator_id, MAX(messages.created_at) as last_activity')
889-
.order('MAX(messages.created_at) DESC, topics.id DESC')
890-
.limit(limit)
891-
.offset(offset)
892-
.load
893-
end
894-
entries = next_result[:entries] || []
895+
entries = execute_search_query(base_relation, longpage)
895896
sliced = slice_cached_entries(entries, params[:cursor])
896897
end
897898

@@ -901,6 +902,38 @@ def load_cached_search_results
901902
@new_topics_count = 0
902903
end
903904

905+
def execute_search_query(base_relation, longpage)
906+
results = base_relation
907+
.joins(:messages)
908+
.where(messages: { created_at: ..@viewing_since })
909+
.group("topics.id")
910+
.select("topics.id, topics.creator_id, MAX(messages.created_at) as last_activity")
911+
.order("MAX(messages.created_at) DESC, topics.id DESC")
912+
.limit(SEARCH_PAGE_SIZE)
913+
.offset(SEARCH_PAGE_SIZE * longpage)
914+
.load
915+
916+
results.map do |row|
917+
{
918+
id: row.id,
919+
last_activity: row.try(:last_activity)&.to_time || row.try(:created_at)&.to_time
920+
}
921+
end
922+
end
923+
924+
def format_parse_error(error)
925+
# Extract user-friendly error message from Parslet error
926+
cause = error.parse_failure_cause
927+
if cause
928+
line = cause.pos.line_and_column.first rescue 1
929+
"Syntax error at position #{line}: #{cause.message}"
930+
else
931+
"Invalid search syntax"
932+
end
933+
rescue StandardError
934+
"Invalid search syntax"
935+
end
936+
904937
def preload_commitfest_summaries
905938
topic_ids = @topics.map(&:id)
906939
@commitfest_summaries = Topic.commitfest_summaries(topic_ids)

app/services/search/date_parser.rb

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# frozen_string_literal: true
2+
3+
module Search
4+
# Parses date strings for search queries.
5+
# Supports absolute dates (2024-01-01, 2024-01, 2024) and relative dates (today, yesterday, 7d, 2w, 3m, 1y)
6+
class DateParser
7+
RELATIVE_PATTERNS = {
8+
/\Atoday\z/i => -> { Time.current.beginning_of_day },
9+
/\Ayesterday\z/i => -> { 1.day.ago.beginning_of_day },
10+
/\A(\d+)d\z/i => ->(n) { n.to_i.days.ago },
11+
/\A(\d+)w\z/i => ->(n) { n.to_i.weeks.ago },
12+
/\A(\d+)m\z/i => ->(n) { (n.to_i * 30).days.ago },
13+
/\A(\d+)y\z/i => ->(n) { (n.to_i * 365).days.ago }
14+
}.freeze
15+
16+
def initialize(value)
17+
@value = value.to_s.strip
18+
end
19+
20+
def parse
21+
return nil if @value.blank?
22+
23+
parse_relative || parse_absolute
24+
end
25+
26+
def valid?
27+
parse.present?
28+
end
29+
30+
private
31+
32+
def parse_relative
33+
RELATIVE_PATTERNS.each do |pattern, handler|
34+
match = @value.match(pattern)
35+
next unless match
36+
37+
if match.captures.empty?
38+
return handler.call
39+
else
40+
return handler.call(match[1])
41+
end
42+
end
43+
44+
nil
45+
end
46+
47+
def parse_absolute
48+
case @value
49+
when /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/
50+
# Full ISO timestamp: 2024-01-15T10:30:00
51+
Time.zone.parse(@value)
52+
when /\A(\d{4})-(\d{2})-(\d{2})\z/
53+
# Full date: 2024-01-01
54+
Time.zone.parse("#{@value} 00:00:00")
55+
when /\A(\d{4})-(\d{2})\z/
56+
# Month only: 2024-01
57+
Time.zone.parse("#{@value}-01 00:00:00")
58+
when /\A(\d{4})\z/
59+
# Year only: 2024
60+
Time.zone.parse("#{@value}-01-01 00:00:00")
61+
else
62+
# Try a generic parse as last resort
63+
Time.zone.parse(@value)
64+
end
65+
rescue ArgumentError, TypeError
66+
nil
67+
end
68+
end
69+
end

0 commit comments

Comments
 (0)