Skip to content
Merged
14 changes: 14 additions & 0 deletions .claude/hooks/brakeman-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ ! "$FILE_PATH" =~ \.rb$|\.erb$ ]]; then
exit 0
fi
# Get path relative to project root
REL_PATH="${FILE_PATH#$CLAUDE_PROJECT_DIR/}"
OUTPUT=$(bundle exec brakeman --only-files "$REL_PATH" --no-pager --format text --quiet --no-summary 2>/dev/null)
if [ -n "$OUTPUT" ] && ! echo "$OUTPUT" | grep -q "No warnings found"; then
echo "Brakeman warnings in $REL_PATH:" >&2
echo "$OUTPUT" >&2
fi
exit 0
8 changes: 8 additions & 0 deletions .claude/hooks/rubocop-autofix.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ ! "$FILE_PATH" =~ \.rb$ ]]; then
exit 0
fi
bundle exec rubocop -A --format quiet "$FILE_PATH" 2>/dev/null
exit 0
19 changes: 19 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/rubocop-autofix.sh"
},
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/brakeman-check.sh"
}
]
}
]
}
}
7 changes: 6 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@
"Bash(git -C /Users/maebeale/programming/awbw branch --show-current)",
"Bash(bundle exec rails routes:*)",
"Bash(bundle exec rubocop:*)",
"Bash(git -C /Users/maebeale/programming/awbw diff --name-only main...HEAD)"
"Bash(git -C /Users/maebeale/programming/awbw diff --name-only main...HEAD)",
"Bash(git apply:*)",
"Bash(git checkout:*)",
"Bash(mysql -u root:*)",
"Bash(chmod:*)",
"Bash(bundle exec brakeman:*)"
]
}
}
72 changes: 72 additions & 0 deletions app/models/concerns/punctuation_strippable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

module PunctuationStrippable
extend ActiveSupport::Concern

PUNCTUATION_CHARS_SQL = [
"'-'", # hyphen
"'&'", # ampersand
"'.'", # period
"0xE28094", # em dash —
"0xE28093", # en dash –
"'\"'", # double quote "
"\"'\"", # single quote '
"0xE28098", # left single curly quote '
"0xE28099", # right single curly quote '
"0xE2809C", # left double curly quote "
"0xE2809D", # right double curly quote "
"'/'", # slash
"':'", # colon
"'+'", # plus
"'!'", # exclamation
"CHAR(63)", # question mark ?
"','", # comma
"'('", # open paren
"')'", # close paren
"0xE280A6" # ellipsis …
].freeze

PUNCTUATION_REGEX = /[-&.—–'"''""\/:+!?,()…]/

module ClassMethods
# "spaced" — punctuation → space, collapse multiple spaces.
# Matches "self care" to "self-care".
def strip_punctuation_sql_spaced(field_name)
result = normalize_synonyms_sql(field_name)
PUNCTUATION_CHARS_SQL.each { |c| result = "REPLACE(#{result}, #{c}, ' ')" }
3.times { result = "REPLACE(#{result}, ' ', ' ')" }
result
end

# "spaceless" — punctuation AND spaces → removed entirely.
# Matches "selfcare" to "self-care" and to "self care".
def strip_punctuation_sql_spaceless(field_name)
result = normalize_synonyms_sql(field_name)
PUNCTUATION_CHARS_SQL.each { |c| result = "REPLACE(#{result}, #{c}, '')" }
result = "REPLACE(#{result}, ' ', '')"
result
end

def strip_punctuation_spaced(text)
normalize_synonyms(text).gsub(PUNCTUATION_REGEX, " ").gsub(/\s+/, " ")
end

def strip_punctuation_spaceless(text)
normalize_synonyms(text).gsub(PUNCTUATION_REGEX, "").gsub(/\s+/, "")
end

private

# Normalize synonyms so both sides strip the same way.
# " and " → " & " (then & gets stripped as punctuation)
# "w/" → "with" (then / gets stripped, leaving "with" on both sides)
def normalize_synonyms_sql(field_name)
result = "REPLACE(#{field_name}, ' and ', ' & ')"
"REPLACE(#{result}, 'w/', 'with')"
end

def normalize_synonyms(text)
text.to_s.gsub(/ and /i, " & ").gsub(/w\//i, "with")
end
end
end
12 changes: 10 additions & 2 deletions app/models/workshop.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class Workshop < ApplicationRecord
include Featureable, Publishable, TagFilterable, Trendable, WindowsTypeFilterable, RichTextSearchable
include PunctuationStrippable
include Rails.application.routes.url_helpers
include ActionText::Attachable
include ActiveModel::Dirty
Expand Down Expand Up @@ -139,7 +140,15 @@ def self.mentionable_rich_text_fields
# See Featureable, Publishable, TagFilterable, Trendable, WindowsTypeFilterable, RichTextSearchable
scope :created_by_id, ->(created_by_id) { where(user_id: created_by_id) }
scope :legacy, -> { where(legacy: true) }
scope :title, ->(title) { where("workshops.title like ?", "%#{ title }%") }
scope :title, ->(title) {
spaced = "%#{ActiveRecord::Base.sanitize_sql_like(strip_punctuation_spaced(title))}%"
spaceless = "%#{ActiveRecord::Base.sanitize_sql_like(strip_punctuation_spaceless(title))}%"

spaced_expr = Arel::Nodes::SqlLiteral.new(strip_punctuation_sql_spaced("workshops.title"))
spaceless_expr = Arel::Nodes::SqlLiteral.new(strip_punctuation_sql_spaceless("workshops.title"))

where(spaced_expr.matches(spaced).or(spaceless_expr.matches(spaceless)))
}
scope :order_by_date, ->(sort_order = "asc") do
order(Arel.sql(<<~SQL.squish))
COALESCE(
Expand All @@ -151,7 +160,6 @@ def self.mentionable_rich_text_fields
) #{sort_order == "asc" ? "ASC" : "DESC"}
SQL
end
scope :title, ->(title) { where("workshops.title like ?", "%#{ title }%") }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove dupe

scope :windows_type_ids, ->(windows_type_ids) { where(windows_type_id: windows_type_ids) }
scope :with_bookmarks_count, -> do
left_joins(:bookmarks)
Expand Down
41 changes: 29 additions & 12 deletions app/services/workshop_search_service.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class WorkshopSearchService
include ActionPolicy::Behaviour
include PunctuationStrippable
authorize :user

attr_reader :params, :user, :admin
Expand Down Expand Up @@ -127,22 +128,34 @@ def filter_by_tag_names

def filter_by_title
return unless params[:title].present?
@workshops = @workshops.search("title:#{params[:title]}")
@workshops = @workshops.title(params[:title])
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stop using SearchCop for title search so we can instead apply this stripped chars version of searching.

end

def filter_by_query
return unless params[:query].present?

results = @workshops.search(params[:query]) # Use the SearchCop search scope directly on the relation
spaced = ActiveRecord::Base.sanitize_sql_like(
self.class.strip_punctuation_spaced(params[:query]).strip
)
spaceless = ActiveRecord::Base.sanitize_sql_like(
self.class.strip_punctuation_spaceless(params[:query]).strip
)
return if spaced.blank?

# Match against spaced variant (punctuation → space) and spaceless variant (punctuation + spaces removed)
fields = %w[workshops.title workshops.full_name action_text_rich_texts.plain_text_body]
conditions = fields.flat_map do |field|
[
"#{self.class.strip_punctuation_sql_spaced(field)} LIKE :spaced",
"#{self.class.strip_punctuation_sql_spaceless(field)} LIKE :spaceless"
]
end.join(" OR ")

# If SearchCop returned an Array (e.g., because of scoring), convert back to Relation
if results.is_a?(Array)
ordered_ids = results.map(&:id)
@workshops = Workshop.where(id: ordered_ids)
.order(Arel.sql("FIELD(id, #{ordered_ids.join(',')})"))
else
@workshops = results
end
@workshops = @workshops
.joins("LEFT JOIN action_text_rich_texts ON action_text_rich_texts.record_id = workshops.id " \
"AND action_text_rich_texts.record_type = 'Workshop'")
.where(conditions, spaced: "%#{spaced}%", spaceless: "%#{spaceless}%")
.distinct
end

# --- Search methods ---
Expand All @@ -151,13 +164,17 @@ def search_by_author_name(workshops, author_name)
return workshops if author_name.blank?

sanitized = author_name.strip.gsub(/\s+/, "")
workshops.left_outer_joins(:user)
workshops.left_outer_joins(user: :person)
.where(
"LOWER(REPLACE(workshops.full_name, ' ', '')) LIKE :name
OR LOWER(REPLACE(CONCAT(users.first_name, users.last_name), ' ', '')) LIKE :name
OR LOWER(REPLACE(CONCAT(users.last_name, users.first_name), ' ', '')) LIKE :name
OR LOWER(REPLACE(users.first_name, ' ', '')) LIKE :name
OR LOWER(REPLACE(users.last_name, ' ', '')) LIKE :name",
OR LOWER(REPLACE(users.last_name, ' ', '')) LIKE :name
OR LOWER(REPLACE(CONCAT(people.first_name, people.last_name), ' ', '')) LIKE :name
OR LOWER(REPLACE(CONCAT(people.last_name, people.first_name), ' ', '')) LIKE :name
OR LOWER(REPLACE(people.first_name, ' ', '')) LIKE :name
OR LOWER(REPLACE(people.last_name, ' ', '')) LIKE :name",
name: "%#{sanitized}%"
)
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class AddPolymorphicIndexToBookmarks < ActiveRecord::Migration[7.2]
def change
add_index :bookmarks, [ :bookmarkable_type, :bookmarkable_id ],
name: "index_bookmarks_on_bookmarkable"
end
end
5 changes: 4 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.1].define(version: 2026_02_14_230901) do
ActiveRecord::Schema[8.1].define(version: 2026_02_15_122713) do
create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.bigint "action_text_rich_text_id", null: false
t.datetime "created_at", null: false
Expand Down Expand Up @@ -216,6 +216,7 @@
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.integer "user_id"
t.index ["bookmarkable_type", "bookmarkable_id"], name: "index_bookmarks_on_bookmarkable"
t.index ["user_id"], name: "index_bookmarks_on_user_id"
end

Expand Down Expand Up @@ -245,9 +246,11 @@

create_table "category_types", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.string "display_text"
t.string "legacy_id"
t.string "name"
t.boolean "published", default: false
t.boolean "story_specific", default: false
t.datetime "updated_at", precision: nil, null: false
end

Expand Down
Loading