Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ gem "kaminari"
gem "nokogiri"
gem "csv"
gem "redcarpet"
gem "parslet"

# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
gem "bcrypt", "~> 3.1"
Expand Down
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ GEM
parser (3.3.10.0)
ast (~> 2.4.1)
racc
parslet (2.0.0)
pg (1.6.2)
pg (1.6.2-aarch64-linux)
pg (1.6.2-aarch64-linux-musl)
Expand Down Expand Up @@ -500,6 +501,7 @@ DEPENDENCIES
omniauth
omniauth-google-oauth2
omniauth-rails_csrf_protection
parslet
pg (~> 1.1)
pghero
propshaft
Expand Down
19 changes: 19 additions & 0 deletions app/assets/stylesheets/components/sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -282,3 +282,22 @@
.sidebar .commitfest-committers li + li {
margin-top: var(--spacing-1);
}

.sidebar .search-help-link {
margin-top: var(--spacing-3);
text-align: center;
}

.sidebar .search-help-link a {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
transition: color var(--transition-fast);
}

.sidebar .search-help-link a:hover {
color: var(--color-text-link);
}
72 changes: 72 additions & 0 deletions app/assets/stylesheets/components/topics.css
Original file line number Diff line number Diff line change
Expand Up @@ -730,3 +730,75 @@ a.topic-icon {
background: var(--color-primary-50);
border-color: var(--color-primary-200);
}

/* Search errors and warnings */
.search-error {
background: var(--color-danger-soft);
border: var(--border-width) solid var(--color-danger);
border-radius: var(--border-radius-lg);
padding: var(--spacing-5);
margin-bottom: var(--spacing-5);
display: flex;
gap: var(--spacing-4);
align-items: flex-start;
}

.search-error-icon {
color: var(--color-danger);
font-size: var(--font-size-xl);
flex-shrink: 0;
}

.search-error-content h3 {
margin: 0 0 var(--spacing-2) 0;
color: var(--color-danger);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
}

.search-error-content p {
margin: 0 0 var(--spacing-2) 0;
color: var(--color-text-primary);
}

.search-error-help {
font-size: var(--font-size-sm);
}

.search-error-help a {
color: var(--color-danger);
text-decoration: underline;
font-weight: var(--font-weight-medium);
}

.search-warnings {
background: var(--color-warning-bg);
border: var(--border-width) solid var(--color-warning);
border-radius: var(--border-radius-lg);
padding: var(--spacing-4);
margin-bottom: var(--spacing-4);
}

.search-warnings-header {
display: flex;
align-items: center;
gap: var(--spacing-2);
color: var(--color-warning-text);
font-weight: var(--font-weight-medium);
margin-bottom: var(--spacing-2);
}

.search-warnings-header i {
font-size: var(--font-size-lg);
}

.search-warnings-list {
margin: 0;
padding-left: var(--spacing-6);
color: var(--color-warning-text);
font-size: var(--font-size-sm);
}

.search-warnings-list li + li {
margin-top: var(--spacing-1);
}
3 changes: 2 additions & 1 deletion app/controllers/help_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ class HelpController < ApplicationController
layout "help"

PAGES = {
"search" => "Advanced Search Guide",
"hackorum-patch" => "Applying Patches with hackorum-patch",
"account-linking" => "Account Linking & Multiple Emails"
"account-linking" => "Account Linking & Multiple Emails",
}.freeze

def index
Expand Down
93 changes: 63 additions & 30 deletions app/controllers/topics_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -202,11 +202,34 @@ def search
return
end

load_cached_search_results
@search_warnings = []

begin
# Parse the search query
parser = Search::QueryParser.new
ast = parser.parse(@search_query)

# Validate and collect warnings
validator = Search::QueryValidator.new(ast)
validated = validator.validate
@search_warnings += validated.warnings

# Build the ActiveRecord query
builder = Search::QueryBuilder.new(ast: validated.ast, user: current_user)
result = builder.build
@search_warnings += result.warnings

# Load results
load_search_results(result.relation)
rescue Parslet::ParseFailed => e
@search_error = format_parse_error(e)
@topics = []
end

preload_topic_participants
preload_commitfest_summaries
preload_participation_flags if user_signed_in?
load_visible_tags if user_signed_in?

respond_to do |format|
format.html
Expand Down Expand Up @@ -857,41 +880,19 @@ def load_star_state
@is_starred = TopicStar.exists?(user: current_user, topic: @topic)
end

def load_cached_search_results
SEARCH_PAGE_SIZE = 1000

def load_search_results(base_relation)
@viewing_since = viewing_since_param
longpage = params[:longpage].to_i
cache = SearchResultCache.new(query: @search_query, scope: "title_body", viewing_since: @viewing_since, longpage: longpage)

result = cache.fetch do |limit, offset|
build_search_query(@search_query)
.joins(:messages)
.where(messages: { created_at: ..@viewing_since })
.group('topics.id')
.select('topics.id, topics.creator_id, MAX(messages.created_at) as last_activity')
.order('MAX(messages.created_at) DESC, topics.id DESC')
.limit(limit)
.offset(offset)
.load
end

entries = result[:entries] || []
entries = execute_search_query(base_relation, longpage)
sliced = slice_cached_entries(entries, params[:cursor])

if sliced[:entries].empty? && entries.size >= SearchResultCache::LONGPAGE_SIZE
# Handle pagination to next longpage if needed
if sliced[:entries].empty? && entries.size >= SEARCH_PAGE_SIZE
longpage += 1
cache = SearchResultCache.new(query: @search_query, scope: "title_body", viewing_since: @viewing_since, longpage: longpage)
next_result = cache.fetch do |limit, offset|
build_search_query(@search_query)
.joins(:messages)
.where(messages: { created_at: ..@viewing_since })
.group('topics.id')
.select('topics.id, topics.creator_id, MAX(messages.created_at) as last_activity')
.order('MAX(messages.created_at) DESC, topics.id DESC')
.limit(limit)
.offset(offset)
.load
end
entries = next_result[:entries] || []
entries = execute_search_query(base_relation, longpage)
sliced = slice_cached_entries(entries, params[:cursor])
end

Expand All @@ -901,6 +902,38 @@ def load_cached_search_results
@new_topics_count = 0
end

def execute_search_query(base_relation, longpage)
results = base_relation
.joins(:messages)
.where(messages: { created_at: ..@viewing_since })
.group("topics.id")
.select("topics.id, topics.creator_id, MAX(messages.created_at) as last_activity")
.order("MAX(messages.created_at) DESC, topics.id DESC")
.limit(SEARCH_PAGE_SIZE)
.offset(SEARCH_PAGE_SIZE * longpage)
.load

results.map do |row|
{
id: row.id,
last_activity: row.try(:last_activity)&.to_time || row.try(:created_at)&.to_time
}
end
end

def format_parse_error(error)
# Extract user-friendly error message from Parslet error
cause = error.parse_failure_cause
if cause
line = cause.pos.line_and_column.first rescue 1
"Syntax error at position #{line}: #{cause.message}"
else
"Invalid search syntax"
end
rescue StandardError
"Invalid search syntax"
end

def preload_commitfest_summaries
topic_ids = @topics.map(&:id)
@commitfest_summaries = Topic.commitfest_summaries(topic_ids)
Expand Down
69 changes: 69 additions & 0 deletions app/services/search/date_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

module Search
# Parses date strings for search queries.
# Supports absolute dates (2024-01-01, 2024-01, 2024) and relative dates (today, yesterday, 7d, 2w, 3m, 1y)
class DateParser
RELATIVE_PATTERNS = {
/\Atoday\z/i => -> { Time.current.beginning_of_day },
/\Ayesterday\z/i => -> { 1.day.ago.beginning_of_day },
/\A(\d+)d\z/i => ->(n) { n.to_i.days.ago },
/\A(\d+)w\z/i => ->(n) { n.to_i.weeks.ago },
/\A(\d+)m\z/i => ->(n) { (n.to_i * 30).days.ago },
/\A(\d+)y\z/i => ->(n) { (n.to_i * 365).days.ago }
}.freeze

def initialize(value)
@value = value.to_s.strip
end

def parse
return nil if @value.blank?

parse_relative || parse_absolute
end

def valid?
parse.present?
end

private

def parse_relative
RELATIVE_PATTERNS.each do |pattern, handler|
match = @value.match(pattern)
next unless match

if match.captures.empty?
return handler.call
else
return handler.call(match[1])
end
end

nil
end

def parse_absolute
case @value
when /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/
# Full ISO timestamp: 2024-01-15T10:30:00
Time.zone.parse(@value)
when /\A(\d{4})-(\d{2})-(\d{2})\z/
# Full date: 2024-01-01
Time.zone.parse("#{@value} 00:00:00")
when /\A(\d{4})-(\d{2})\z/
# Month only: 2024-01
Time.zone.parse("#{@value}-01 00:00:00")
when /\A(\d{4})\z/
# Year only: 2024
Time.zone.parse("#{@value}-01-01 00:00:00")
else
# Try a generic parse as last resort
Time.zone.parse(@value)
end
rescue ArgumentError, TypeError
nil
end
end
end
Loading