diff --git a/.claude/hooks/brakeman-check.sh b/.claude/hooks/brakeman-check.sh new file mode 100755 index 000000000..16ed99ecd --- /dev/null +++ b/.claude/hooks/brakeman-check.sh @@ -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 diff --git a/.claude/hooks/rubocop-autofix.sh b/.claude/hooks/rubocop-autofix.sh new file mode 100755 index 000000000..7e725cf46 --- /dev/null +++ b/.claude/hooks/rubocop-autofix.sh @@ -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 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..bdc470d83 --- /dev/null +++ b/.claude/settings.json @@ -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" + } + ] + } + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ea632f510..91208c351 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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:*)" ] } } diff --git a/app/models/concerns/punctuation_strippable.rb b/app/models/concerns/punctuation_strippable.rb new file mode 100644 index 000000000..4c48b8322 --- /dev/null +++ b/app/models/concerns/punctuation_strippable.rb @@ -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 diff --git a/app/models/workshop.rb b/app/models/workshop.rb index 106eb5975..5efe579b3 100644 --- a/app/models/workshop.rb +++ b/app/models/workshop.rb @@ -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 @@ -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( @@ -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 }%") } scope :windows_type_ids, ->(windows_type_ids) { where(windows_type_id: windows_type_ids) } scope :with_bookmarks_count, -> do left_joins(:bookmarks) diff --git a/app/services/workshop_search_service.rb b/app/services/workshop_search_service.rb index 86371c369..be4a12f3b 100644 --- a/app/services/workshop_search_service.rb +++ b/app/services/workshop_search_service.rb @@ -1,5 +1,6 @@ class WorkshopSearchService include ActionPolicy::Behaviour + include PunctuationStrippable authorize :user attr_reader :params, :user, :admin @@ -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]) 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 --- @@ -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 diff --git a/db/migrate/20260215122713_add_polymorphic_index_to_bookmarks.rb b/db/migrate/20260215122713_add_polymorphic_index_to_bookmarks.rb new file mode 100644 index 000000000..5d0ce0aae --- /dev/null +++ b/db/migrate/20260215122713_add_polymorphic_index_to_bookmarks.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 68b9fc3c4..ed01949ff 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 @@ -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 @@ -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 diff --git a/spec/services/workshop_search_service_spec.rb b/spec/services/workshop_search_service_spec.rb index 471503612..80a9afade 100644 --- a/spec/services/workshop_search_service_spec.rb +++ b/spec/services/workshop_search_service_spec.rb @@ -130,5 +130,227 @@ expect(service.sort).to eq('created') end end + + context "punctuation-ignoring search" do + let!(:workshop_with_hyphen) do + create(:workshop, :published, title: "Hello - Goodbye", year: 2025, month: 3) + end + let!(:workshop_without_hyphen) do + create(:workshop, :published, title: "Hello Goodbye", year: 2025, month: 4) + end + let!(:workshop_with_ampersand) do + create(:workshop, :published, title: "Arts & Crafts", year: 2025, month: 5) + end + let!(:workshop_with_period) do + create(:workshop, :published, title: "Dr. Workshop", year: 2025, month: 6) + end + let!(:workshop_with_em_dash) do + create(:workshop, :published, title: "Hello—Goodbye", year: 2025, month: 7) + end + let!(:workshop_with_en_dash) do + create(:workshop, :published, title: "Hello–Goodbye", year: 2025, month: 8) + end + let!(:workshop_with_quotes) do + create(:workshop, :published, title: "The 'Best' Workshop", year: 2025, month: 9) + end + + context "title search" do + it "finds workshops with hyphens when searching without hyphens" do + service = WorkshopSearchService.new({ title: 'Hello Goodbye' }, user: user).call + workshops = service.workshops + + expect(workshops).to include(workshop_with_hyphen) + expect(workshops).to include(workshop_without_hyphen) + end + + it "finds workshops without hyphens when searching with hyphens" do + service = WorkshopSearchService.new({ title: 'Hello - Goodbye' }, user: user).call + workshops = service.workshops + + expect(workshops).to include(workshop_with_hyphen) + expect(workshops).to include(workshop_without_hyphen) + end + + it "finds workshops with multiple hyphens when searching with different hyphen patterns" do + workshop_multi_hyphen = create(:workshop, :published, title: "Hello -- Goodbye", year: 2025, month: 10) + + service = WorkshopSearchService.new({ title: 'Hello Goodbye' }, user: user).call + workshops = service.workshops + + expect(workshops).to include(workshop_multi_hyphen) + end + + it "finds workshops with ampersands when searching without ampersands" do + service = WorkshopSearchService.new({ title: 'Arts Crafts' }, user: user).call + workshops = service.workshops + + expect(workshops).to include(workshop_with_ampersand) + end + + it "finds workshops with ampersands when searching with 'and'" do + service = WorkshopSearchService.new({ title: 'Arts and Crafts' }, user: user).call + expect(service.workshops).to include(workshop_with_ampersand) + end + + it "finds workshops with 'and' when searching with ampersand" do + workshop = create(:workshop, :published, title: "Arts and Crafts", year: 2025, month: 10) + + service = WorkshopSearchService.new({ title: 'Arts & Crafts' }, user: user).call + expect(service.workshops).to include(workshop) + end + + it "finds workshops with 'w/' when searching with 'with'" do + workshop = create(:workshop, :published, title: "Painting w/ Kids", year: 2025, month: 10) + + service = WorkshopSearchService.new({ title: 'Painting with Kids' }, user: user).call + expect(service.workshops).to include(workshop) + end + + it "finds workshops with 'with' when searching with 'w/'" do + workshop = create(:workshop, :published, title: "Painting with Kids", year: 2025, month: 10) + + service = WorkshopSearchService.new({ title: 'Painting w/ Kids' }, user: user).call + expect(service.workshops).to include(workshop) + end + + it "finds workshops with periods when searching without periods" do + service = WorkshopSearchService.new({ title: 'Dr Workshop' }, user: user).call + workshops = service.workshops + + expect(workshops).to include(workshop_with_period) + end + + it "finds workshops with em dashes when searching without them" do + service = WorkshopSearchService.new({ title: 'HelloGoodbye' }, user: user).call + workshops = service.workshops + + expect(workshops).to include(workshop_with_em_dash) + end + + it "finds workshops with en dashes when searching without them" do + service = WorkshopSearchService.new({ title: 'HelloGoodbye' }, user: user).call + workshops = service.workshops + + expect(workshops).to include(workshop_with_en_dash) + end + + it "finds workshops with quotes when searching without quotes" do + service = WorkshopSearchService.new({ title: 'The Best Workshop' }, user: user).call + workshops = service.workshops + + expect(workshops).to include(workshop_with_quotes) + end + + it "finds workshops with slashes when searching without slashes" do + workshop = create(:workshop, :published, title: "Art/Music", year: 2025, month: 10) + + service = WorkshopSearchService.new({ title: 'Art Music' }, user: user).call + expect(service.workshops).to include(workshop) + end + + it "finds workshops with colons when searching without colons" do + workshop = create(:workshop, :published, title: "Introduction: The Basics", year: 2025, month: 10) + + service = WorkshopSearchService.new({ title: 'Introduction The Basics' }, user: user).call + expect(service.workshops).to include(workshop) + end + + it "finds workshops with plus signs when searching without them" do + workshop = create(:workshop, :published, title: "Arts + Crafts", year: 2025, month: 10) + + service = WorkshopSearchService.new({ title: 'Arts Crafts' }, user: user).call + expect(service.workshops).to include(workshop) + end + + it "finds workshops with exclamation marks when searching without them" do + workshop = create(:workshop, :published, title: "Create!", year: 2025, month: 10) + + service = WorkshopSearchService.new({ title: 'Create' }, user: user).call + expect(service.workshops).to include(workshop) + end + + it "finds workshops with question marks when searching without them" do + workshop = create(:workshop, :published, title: "What is Dance?", year: 2025, month: 10) + + service = WorkshopSearchService.new({ title: 'What is Dance' }, user: user).call + expect(service.workshops).to include(workshop) + end + + it "finds workshops with commas when searching without them" do + workshop = create(:workshop, :published, title: "Draw, Paint, Create", year: 2025, month: 10) + + service = WorkshopSearchService.new({ title: 'Draw Paint Create' }, user: user).call + expect(service.workshops).to include(workshop) + end + + it "finds workshops with parentheses when searching without them" do + workshop = create(:workshop, :published, title: "Weaving (Advanced)", year: 2025, month: 10) + + service = WorkshopSearchService.new({ title: 'Weaving Advanced' }, user: user).call + expect(service.workshops).to include(workshop) + end + + it "finds workshops with ellipsis when searching without it" do + workshop = create(:workshop, :published, title: "Creating…Discovering", year: 2025, month: 10) + + service = WorkshopSearchService.new({ title: 'CreatingDiscovering' }, user: user).call + expect(service.workshops).to include(workshop) + end + + it "finds all variants of hyphenated/spaced/joined words" do + hyphenated = create(:workshop, :published, title: "Self-Care Workshop", year: 2025, month: 10) + spaced = create(:workshop, :published, title: "Self Care Workshop", year: 2025, month: 11) + joined = create(:workshop, :published, title: "Selfcare Workshop", year: 2025, month: 12) + + # Searching with spaces finds all three + service = WorkshopSearchService.new({ title: 'Self Care' }, user: user).call + expect(service.workshops).to include(hyphenated, spaced, joined) + + # Searching with hyphen finds all three + service = WorkshopSearchService.new({ title: 'Self-Care' }, user: user).call + expect(service.workshops).to include(hyphenated, spaced, joined) + + # Searching joined finds all three + service = WorkshopSearchService.new({ title: 'SelfCare' }, user: user).call + expect(service.workshops).to include(hyphenated, spaced, joined) + end + end + + context "query search" do + let!(:workshop_with_hyphen_content) do + create(:workshop, :published, title: "Test Workshop", rhino_objective: "Learn about self-care", year: 2025, month: 11) + end + let!(:workshop_without_hyphen_content) do + create(:workshop, :published, title: "Another Workshop", rhino_objective: "Learn about selfcare", year: 2025, month: 12) + end + let!(:workshop_with_ampersand_content) do + create(:workshop, :published, title: "Third Workshop", rhino_objective: "Arts & crafts therapy", year: 2026, month: 1) + end + + it "finds all variants of hyphenated/spaced/joined content" do + # "selfcare" finds both hyphenated and joined + service = WorkshopSearchService.new({ query: 'selfcare' }, user: user).call + expect(service.workshops).to include(workshop_with_hyphen_content) + expect(service.workshops).to include(workshop_without_hyphen_content) + + # "self-care" finds both + service = WorkshopSearchService.new({ query: 'self-care' }, user: user).call + expect(service.workshops).to include(workshop_with_hyphen_content) + expect(service.workshops).to include(workshop_without_hyphen_content) + + # "self care" finds both + service = WorkshopSearchService.new({ query: 'self care' }, user: user).call + expect(service.workshops).to include(workshop_with_hyphen_content) + expect(service.workshops).to include(workshop_without_hyphen_content) + end + + it "finds workshops with ampersands in content when searching without ampersands" do + service = WorkshopSearchService.new({ query: 'Arts crafts' }, user: user).call + workshops = service.workshops + + expect(workshops).to include(workshop_with_ampersand_content) + end + end + end end end