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
56 changes: 56 additions & 0 deletions app/components/searchable_component.html.slim
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
- unless resource.search_model_name == 'talk'
span class="card-list-item is-#{resource.search_model_name}"
.card-list-item__inner
- if resource.class == User
.card-list-item__user
= link_to resource_user, class: 'card-list-item__user-link' do
span.a-user-role class="is-#{resource_user.primary_role}"
= image_tag resource_user.avatar_url, class: 'card-list-item__user-icon a-user-icon'
- else
.card-list-item__label
.card-list-item__label-inner.is-sm
= simple_format(resource.search_label)

.card-list-item__rows
.card-list-item__row
.card-list-item-title
- if resource.try(:wip)
.a-list-item-badge.is-wip
span WIP
- elsif resource.search_model_name == 'user'
.a-list-item-badge.is-searchable
span ユーザー
- elsif resource.search_model_name == 'comment'
.a-list-item-badge.is-searchable
span コメント
- elsif resource.search_model_name.in?(%w[answer correct_answer])
.a-list-item-badge.is-searchable
span コメント
.card-list-item-title__title
= link_to search_title_text, resource.search_url, class: 'card-list-item-title__link a-text-link'

.card-list-item__row
.card-list-item__summary
p = sanitize(helpers.formatted_search_summary(resource, word))

.card-list-item__row
.card-list-item-meta
.card-list-item-meta__items
- if resource_user && resource.search_model_name != 'user'
.card-list-item-meta__item
.card-list-item-meta__user
= link_to resource_user, class: 'card-list-item-meta__icon-link' do
span.a-user-role class="is-#{resource_user.primary_role}"
= image_tag resource_user.avatar_url, class: 'card-list-item-meta__icon a-user-icon'
= link_to resource_user.login_name, resource_user, class: 'a-user-name'
.card-list-item-meta__item
time.a-meta datetime=resource.updated_at.iso8601 pubdate='pubdate'
= l(resource.updated_at)
.card-list-item-meta__item
.a-meta
= comment_meta_info
= answer_meta_info
- if helpers.current_user&.admin? && resource.search_model_name == 'user'
- if talk.present?
.card-list-item-meta__item
= link_to '相談部屋', talk_path(talk), class: 'a-text-link'
46 changes: 46 additions & 0 deletions app/components/searchable_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# frozen_string_literal: true

class SearchableComponent < ViewComponent::Base
def initialize(resource:, users:, word:, talks:)
@resource = resource
@users = users
@word = word
@talks = talks
end

def resource_user
@resource_user ||= users[resource.search_user_id]
end

def talk
@talk ||= talks[resource_user&.id]
end

def comment_meta_info
return unless resource.search_model_name == 'comment'

safe_join([
'(',
link_to(resource.search_commentable_user&.login_name, "/users/#{resource.search_commentable_user&.id}", class: 'a-user-name'),
" #{resource.search_commentable_type})"
], ' ')
end

def answer_meta_info
return unless resource.search_model_name.in?(%w[answer correct_answer])

safe_join([
'(',
link_to(resource.search_commentable_user&.login_name, "/users/#{resource.search_commentable_user&.id}", class: 'a-user-name'),
' Q&A)'
], ' ')
end

def search_title_text
resource.search_title.presence || resource.try(:login_name)
end

private

attr_reader :resource, :users, :word, :talks
end
11 changes: 11 additions & 0 deletions app/components/searchables_component.html.slim
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
- if searchables.any?
.container.is-md
.card-list.a-card
- searchables.each do |resource|
= render SearchableComponent.new(resource: resource, users: users, word: word, talks: talks)
- else
.o-empty-message
.o-empty-message__icon
i.fa-regular.fa-sad-tear
p.o-empty-message__text
| #{word} に一致する情報は見つかりませんでした。
14 changes: 14 additions & 0 deletions app/components/searchables_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

class SearchablesComponent < ViewComponent::Base
def initialize(searchables:, users:, word:, talks:)
@searchables = searchables
@users = users
@word = word
@talks = talks
end

private

attr_reader :searchables, :users, :word, :talks
end
16 changes: 11 additions & 5 deletions app/controllers/searchables_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@ def index
@word = params[:word].to_s
@document_type = params[:document_type]&.to_sym || :all

searchables = Searcher.search(word: @word, only_me: params[:only_me], document_type: @document_type, current_user:)
@searchables = Kaminari.paginate_array(searchables.uniq).page(params[:page]).per(PER_PAGE)
searcher = Searcher.new(
keyword: params[:word],
document_type: params[:document_type],
only_me: params[:only_me].present?,
current_user:
)
results = searcher.search
@searchables = Kaminari.paginate_array(results).page(params[:page]).per(PER_PAGE)

user_ids = @searchables.map(&:user_id).compact.uniq
@users_by_id = User.where(id: user_ids).index_by(&:id)
@talks_by_user_id = Talk.where(user_id: user_ids).index_by(&:user_id)
user_ids = @searchables.map(&:search_user_id).compact.uniq
@users = User.where(id: user_ids).index_by(&:id)
@talks = Talk.where(user_id: user_ids).index_by(&:user_id)
end
end
15 changes: 9 additions & 6 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@ def index

target_users = fetch_target_users

@users = target_users
.page(params[:page]).per(PAGER_NUMBER)
.preload(:avatar_attachment, :course, :taggings)
.order(updated_at: :desc)

if params[:search_word]
search_user = SearchUser.new(word: params[:search_word], users: @users, target: @target)
search_user = SearchUser.new(word: params[:search_word], users: target_users, target: @target)
@users = search_user.search
.page(params[:page]).per(PAGER_NUMBER)
.preload(:avatar_attachment, :course, :taggings)
.order(updated_at: :desc)
else
@users = target_users
.page(params[:page]).per(PAGER_NUMBER)
.preload(:avatar_attachment, :course, :taggings)
.order(updated_at: :desc)
end

@random_tags = User.tags.sample(20)
Expand Down
149 changes: 68 additions & 81 deletions app/helpers/search_helper.rb
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
# frozen_string_literal: true

module SearchHelper
include ActionView::Helpers::SanitizeHelper
include ERB::Util
include MarkdownHelper
include PolicyHelper

EXTRACTING_CHARACTERS = 50

def searchable_summary(comment, word = '')
return '' if comment.nil?

return process_special_case(comment, word) if comment.is_a?(String) && comment.include?('|') && !comment.include?('```')

summary = md2plain_text(comment)
find_match_in_text(summary, word)
end

# コメントや回答の検索結果に対して実際のドキュメントを返す
# コメントの場合:コメント先のオブジェクト(日報、提出物など)
# 回答の場合:関連する質問、その他の場合:リソース自体
def matched_document(searchable)
if searchable.instance_of?(Comment)
searchable.commentable_type.constantize.find(searchable.commentable_id)
Expand All @@ -25,99 +21,90 @@ def matched_document(searchable)
end
end

def searchable_url(searchable)
case searchable
when Comment
"#{Rails.application.routes.url_helpers.polymorphic_path(searchable.commentable)}#comment_#{searchable.id}"
when CorrectAnswer, Answer
Rails.application.routes.url_helpers.question_path(searchable.question, anchor: "answer_#{searchable.id}")
else
helper_method = "#{searchable.class.name.underscore}_path"
Rails.application.routes.url_helpers.send(helper_method, searchable)
end
# 検索可能リソースから権限チェック付きでコンテンツを抽出・フィルタリング
# 提出物コメントのプラクティス修了要件などの特殊ケースを処理
# convert_markdown: trueの場合Markdown変換済みテキスト、falseの場合生コンテンツを返す
def filtered_message(searchable, convert_markdown: true)
content = extract_filtered_content(searchable)
convert_markdown ? md2plain_text(content) : content
end

def filtered_message(searchable)
if searchable.is_a?(Comment) && searchable.commentable_type == 'Product'
commentable = searchable.commentable
return '該当プラクティスを修了するまで他の人の提出物へのコメントは見れません。' unless policy(commentable).show? || commentable.practice.open_product?
# リソースコンテンツからキーワードマッチをハイライトしたテキスト要約を生成
# 権限に基づいてコンテンツをフィルタリングし、関連するテキストスニペットを抽出
# テーブル形式などの特殊ケースも処理
def search_summary(resource, keyword)
content = filtered_message(resource, convert_markdown: false)
return '' if content.nil? || content.blank?

return md2plain_text(searchable.body)
end
return process_special_case(content, keyword) if content.is_a?(String) && content.include?('|') && !content.include?('```')

description_or_body = searchable.try(:description) || searchable.try(:body) || ''
md2plain_text(description_or_body)
plain_content = md2plain_text(content)
result = find_match_in_text(plain_content, keyword)
result || ''
end

def created_user(searchable)
if searchable.is_a?(SearchResult)
User.find_by(id: searchable.user_id)
else
searchable.respond_to?(:user) ? searchable.user : nil
end
# キーワードハイライト付きのHTML形式検索要約を返す
# マッチした単語をスタイリング用のCSSクラス付き<strong>タグで囲む
def formatted_search_summary(resource, keyword)
summary_text = search_summary(resource, keyword)
highlight_word(summary_text, keyword)
end

def extract_user_id_match(result, word)
user_id = word.delete_prefix('user:')
return match_by_user_object(result, user_id) if result.respond_to?(:user) && result.user.present?

match_by_last_updated_user_id(result, user_id)
end
private

def match_by_user_object(result, user_id)
result.user&.login_name&.casecmp?(user_id)
end
# 権限フィルタリング付きでリソースから生コンテンツを抽出する内部メソッド
def extract_filtered_content(resource)
if resource.is_a?(Comment) && resource.commentable_type == 'Product'
commentable = resource.commentable
return '該当プラクティスを修了するまで他の人の提出物へのコメントは見れません。' unless policy(commentable).show? || commentable.practice.open_product?

def match_by_last_updated_user_id(result, user_id)
return false unless result.respond_to?(:last_updated_user_id) && result.last_updated_user_id.present?
return resource.description
end

user = User.find_by(id: result.last_updated_user_id)
user&.login_name&.casecmp?(user_id)
# 基本的なコンテンツ抽出はSearchableのメソッドを使用
resource.search_content
end

def visible_to_user?(searchable, current_user)
case searchable
when Talk
current_user.admin? || searchable.user_id == current_user.id
when Comment
if searchable.commentable.is_a?(Talk)
current_user.admin? || searchable.commentable.user_id == current_user.id
else
true
end
when User, Practice, Page, Event, RegularEvent, Announcement, Report, Product, Question, Answer
true
else
false
end
end
# マッチしたキーワードをハイライト用のHTML <strong>タグで囲む
# セキュリティのためHTMLエスケープと出力のサニタイズを実行
def highlight_word(text, word)
return text unless text.present? && word.present?

def delete_private_comment!(searchables)
searchables.reject do |searchable|
searchable.instance_of?(Comment) && searchable.commentable.class.in?([Talk, Inquiry, CorporateTrainingInquiry])
escaped_text = ERB::Util.html_escape(text)
words = Searcher.split_keywords(word)
highlighted_text = words.reduce(escaped_text) do |text_fragment, w|
text_fragment.gsub(/(#{Regexp.escape(w)})/i, '<strong class="matched_word">\1</strong>')
end
end

def search_model_name(type)
return nil if type == :all
ActionController::Base.helpers.sanitize(highlighted_text, tags: %w[strong], attributes: %w[class])
end

type.to_s.camelize.singularize
# パイプ文字を含むテーブルコンテンツなどの特殊フォーマットケースを処理
# 処理前にMarkdownをプレーンテキストに変換
def process_special_case(comment, word)
summary = md2plain_text(comment)
find_match_in_text(summary, word)
end

def filter_by_keywords(results, words)
return results if words.empty?
# テキスト内でキーワードマッチを検索し、周辺のコンテキストを抽出
# 最初のキーワードマッチを中心としたテキストスニペットを返す
def find_match_in_text(text, word)
return text[0, EXTRACTING_CHARACTERS * 2] if word.blank?

(results || []).select { |result| words.all? { |word| result_matches_keyword?(result, word) } }
.sort_by(&:updated_at)
.reverse
end
words = Searcher.split_keywords(word)
first_match_position = nil

def result_matches_keyword?(result, word)
return extract_user_id_match(result, word) if word.match?(/^user:/)
words.each do |w|
position = text.downcase.index(w.downcase)
first_match_position = position if position && (first_match_position.nil? || position < first_match_position)
end

word_downcase = word.downcase
[result.try(:title), result.try(:body), result.try(:description)]
.compact
.any? { |field| field.downcase.include?(word_downcase) }
if first_match_position
start_pos = [0, first_match_position - EXTRACTING_CHARACTERS].max
end_pos = [text.length, first_match_position + EXTRACTING_CHARACTERS].min
text[start_pos...end_pos].strip
else
text[0, EXTRACTING_CHARACTERS * 2]
end
end
end
1 change: 0 additions & 1 deletion app/models/announcement.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ class Announcement < ApplicationRecord
include Reactionable
include WithAvatar
include Watchable
include SearchHelper

enum target: {
all: 0,
Expand Down
11 changes: 10 additions & 1 deletion app/models/answer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ class Answer < ApplicationRecord
include Reactionable
include Searchable
include Mentioner
include SearchHelper

belongs_to :user, touch: true
belongs_to :question, touch: false
Expand All @@ -28,4 +27,14 @@ def path
def certain_period_has_passed?
created_at.since(1.week).to_date == Date.current
end

def search_title
question&.title || 'Q&A回答'
end

def search_url
return Rails.application.routes.url_helpers.questions_path unless question.presence

Rails.application.routes.url_helpers.question_path(question, anchor: "answer_#{id}")
end
end
Loading