├── test ├── files │ ├── have-null.txt │ ├── japanese.txt │ ├── one-page.odt │ ├── one-page.pdf │ └── encrypted.zip ├── application_system_test_case.rb ├── functional │ └── fts_query_expansions_controller_test.rb ├── unit │ └── full_text_search │ │ ├── issue_query_any_searchable_test.rb │ │ ├── project_test.rb │ │ ├── changeset_test.rb │ │ ├── journal_test.rb │ │ ├── plugin_wiki_extensions_tag_searchable_test.rb │ │ ├── query_expansion_synchronizer_test.rb │ │ ├── issue_test.rb │ │ ├── change_git_test.rb │ │ ├── similar_search_issue_test.rb │ │ └── change_subversion_test.rb ├── system │ └── full_text_search │ │ └── search_test.rb └── test_helper.rb ├── app ├── views │ ├── fts_query_expand │ │ └── index.html.erb │ ├── fts_query_expansions │ │ ├── _form.html.erb │ │ ├── edit.html.erb │ │ ├── new.html.erb │ │ ├── show.html.erb │ │ └── index.html.erb │ ├── search │ │ └── index.api.rsb │ ├── issues │ │ └── full_text_search │ │ │ └── _view_issues_show_description_bottom.html.erb │ └── settings │ │ └── _full_text_search.html.erb ├── models │ ├── full_text_search │ │ ├── issue_content.rb │ │ ├── query_expansion_request.rb │ │ ├── tag_type.rb │ │ ├── type.rb │ │ ├── tag.rb │ │ └── target.rb │ └── fts_query_expansion.rb ├── jobs │ └── full_text_search │ │ ├── extract_text_job.rb │ │ ├── upsert_target_job.rb │ │ └── update_issue_content_job.rb ├── controllers │ ├── fts_query_expand_controller.rb │ └── fts_query_expansions_controller.rb └── types │ └── full_text_search │ └── mroonga_integer_array_type.rb ├── assets ├── stylesheets │ ├── similar_issues.css │ ├── fontawesome-free-5.8.2-web │ │ ├── webfonts │ │ │ ├── fa-solid-900.eot │ │ │ ├── fa-solid-900.ttf │ │ │ ├── fa-brands-400.eot │ │ │ ├── fa-brands-400.ttf │ │ │ ├── fa-brands-400.woff │ │ │ ├── fa-regular-400.eot │ │ │ ├── fa-regular-400.ttf │ │ │ ├── fa-solid-900.woff │ │ │ ├── fa-solid-900.woff2 │ │ │ ├── fa-brands-400.woff2 │ │ │ ├── fa-regular-400.woff │ │ │ └── fa-regular-400.woff2 │ │ ├── css │ │ │ ├── brands.min.css │ │ │ ├── solid.min.css │ │ │ ├── regular.min.css │ │ │ ├── brands.css │ │ │ ├── solid.css │ │ │ ├── regular.css │ │ │ └── svg-with-js.min.css │ │ └── LICENSE.txt │ ├── score.css │ ├── query_expansions.css │ └── search.css └── javascripts │ └── query_expansions.js ├── db └── migrate │ ├── 20190226093842_extend_content_size.rb │ ├── 20170810052427_add_index_to_issue_contents.rb │ ├── 20170630075027_load_comments_from_changesets.rb │ ├── 20170630075028_add_index_to_searcher_records.rb │ ├── 20170630063757_copy_records_to_searcher_records.rb │ ├── 20190226093843_add_missing_indexes_to_searcher_records.rb │ ├── 20190603060948_create_fts_types.rb │ ├── 20190603061110_create_fts_tag_types.rb │ ├── 20170630063557_enable_pgroonga.rb │ ├── 20190728022920_mroonga_add_source_type_id_index_to_fts_targets.rb │ ├── 20190603061445_create_fts_tags.rb │ ├── 20190925074645_add_name_index_to_fts_tags.rb │ ├── 20190807085000_create_fts_query_expansions.rb │ ├── 20170810045914_create_issue_contents.rb │ ├── 20170630063657_create_searcher_records.rb │ ├── 20190603061606_create_fts_targets.rb │ ├── 20250225000050_remove_contents_from_issue_contents.rb │ ├── 20190603054615_drop_searcher_records.rb │ └── 20231210061330_add_registered_at_to_fts_targets_with_index.rb ├── .github ├── dependabot.yml └── workflows │ └── release.yml ├── config ├── initializers │ ├── chupa_text.rb │ └── schema_format.rb ├── routes.rb ├── database.yml.example.5.1.postgresql ├── database.yml.example.6.0.postgresql ├── database.yml.example.6.1.postgresql ├── database.yml.example.master.postgresql ├── database.yml.example.5.1.mysql ├── database.yml.example.6.0.mysql ├── database.yml.example.6.1.mysql ├── database.yml.example.master.mysql └── locales │ ├── ja.yml │ └── en.yml ├── bin ├── analize-log ├── report-statistics ├── filter-similar-words └── dump-mysql ├── dev ├── run-test.sh ├── run-postgresql.sh ├── run-mysql.sh ├── setup.sh └── initialize-redmine.sh ├── Gemfile ├── lib ├── full_text_search.rb ├── full_text_search │ ├── condition_builder.rb │ ├── hooks │ │ ├── issue_query_any_searchable.rb │ │ ├── issues_show_description_bottom_hook.rb │ │ ├── search_index_options_content_bottom_hook.rb │ │ ├── settings_helper.rb │ │ ├── search_helper.rb │ │ ├── similar_issues_helper.rb │ │ └── controller_search_index.rb │ ├── issue_synchronizable.rb │ ├── journal_synchronizable.rb │ ├── attachment_synchronizable.rb │ ├── custom_field_callbacks.rb │ ├── plugin_wiki_extensions_tag_searchable.rb │ ├── migration.rb │ ├── document_mapper.rb │ ├── news_mapper.rb │ ├── scm_adapter_cat_io.rb │ ├── project_mapper.rb │ ├── resolver.rb │ ├── similar_searcher.rb │ ├── message_mapper.rb │ ├── text_extractor.rb │ ├── repository_entry.rb │ ├── wiki_page_mapper.rb │ ├── changeset_mapper.rb │ ├── tracer.rb │ ├── similar_searcher │ │ ├── pgroonga.rb │ │ └── mroonga.rb │ ├── markup_parser.rb │ ├── issue_mapper.rb │ ├── custom_value_mapper.rb │ ├── query_expansion_synchronizer.rb │ ├── settings.rb │ ├── journal_mapper.rb │ ├── similar_words_filter.rb │ ├── mroonga.rb │ ├── pgroonga.rb │ ├── scm_adapter_all_file_entries.rb │ └── attachment_mapper.rb └── tasks │ └── full_text_search.rake ├── .dir-locals.el ├── .gitignore ├── LICENSE └── init.rb /test/files/have-null.txt: -------------------------------------------------------------------------------- 1 | A B 2 | -------------------------------------------------------------------------------- /test/files/japanese.txt: -------------------------------------------------------------------------------- 1 | こんにちは 2 | -------------------------------------------------------------------------------- /app/views/fts_query_expand/index.html.erb: -------------------------------------------------------------------------------- 1 |
<%= @expanded %>
2 | -------------------------------------------------------------------------------- /assets/stylesheets/similar_issues.css: -------------------------------------------------------------------------------- 1 | .similar-issues table.list { 2 | border: 0; 3 | } 4 | -------------------------------------------------------------------------------- /test/files/one-page.odt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clear-code/redmine_full_text_search/HEAD/test/files/one-page.odt -------------------------------------------------------------------------------- /test/files/one-page.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clear-code/redmine_full_text_search/HEAD/test/files/one-page.pdf -------------------------------------------------------------------------------- /test/files/encrypted.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clear-code/redmine_full_text_search/HEAD/test/files/encrypted.zip -------------------------------------------------------------------------------- /app/models/full_text_search/issue_content.rb: -------------------------------------------------------------------------------- 1 | module FullTextSearch 2 | class IssueContent < ApplicationRecord 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /db/migrate/20190226093842_extend_content_size.rb: -------------------------------------------------------------------------------- 1 | class ExtendContentSize < ActiveRecord::Migration[5.2] 2 | def change 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /db/migrate/20170810052427_add_index_to_issue_contents.rb: -------------------------------------------------------------------------------- 1 | class AddIndexToIssueContents < ActiveRecord::Migration[4.2] 2 | def change 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /db/migrate/20170630075027_load_comments_from_changesets.rb: -------------------------------------------------------------------------------- 1 | class LoadCommentsFromChangesets < ActiveRecord::Migration[4.2] 2 | def change 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /db/migrate/20170630075028_add_index_to_searcher_records.rb: -------------------------------------------------------------------------------- 1 | class AddIndexToSearcherRecords < ActiveRecord::Migration[4.2] 2 | def change 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /db/migrate/20170630063757_copy_records_to_searcher_records.rb: -------------------------------------------------------------------------------- 1 | class CopyRecordsToSearcherRecords < ActiveRecord::Migration[4.2] 2 | def change 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /config/initializers/chupa_text.rb: -------------------------------------------------------------------------------- 1 | ChupaText.logger = Rails.logger 2 | ChupaText::Decomposers.enable_all_gems 3 | ChupaText::Decomposers.load 4 | ChupaText::Configuration.default 5 | -------------------------------------------------------------------------------- /db/migrate/20190226093843_add_missing_indexes_to_searcher_records.rb: -------------------------------------------------------------------------------- 1 | class AddMissingIndexesToSearcherRecords < ActiveRecord::Migration[5.2] 2 | def change 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/application_system_test_case.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../test_helper', __FILE__) 2 | require File.expand_path('../../../../test/application_system_test_case', __FILE__) 3 | -------------------------------------------------------------------------------- /bin/analize-log: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../lib/full_text_search/log_analyzer" 4 | 5 | analyzer = FullTextSearch::LogAnalyzer.new 6 | exit(analyzer.run(ARGV)) 7 | -------------------------------------------------------------------------------- /dev/run-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -exu 4 | 5 | env \ 6 | PSQLRC=/tmp/nonexistent \ 7 | RAILS_ENV=test \ 8 | ${RUBY:-ruby} bin/rails test "plugins/*/test/**/*_test.rb" "$@" 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "chupa-text", ">= 1.3.3" 4 | gem "groonga-client", ">= 0.6.1" 5 | gem "tty-progressbar" 6 | 7 | group :test do 8 | gem "webrick" 9 | end 10 | -------------------------------------------------------------------------------- /assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-solid-900.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clear-code/redmine_full_text_search/HEAD/assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-solid-900.eot -------------------------------------------------------------------------------- /assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clear-code/redmine_full_text_search/HEAD/assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-brands-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clear-code/redmine_full_text_search/HEAD/assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-brands-400.eot -------------------------------------------------------------------------------- /assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clear-code/redmine_full_text_search/HEAD/assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-brands-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clear-code/redmine_full_text_search/HEAD/assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-brands-400.woff -------------------------------------------------------------------------------- /assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-regular-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clear-code/redmine_full_text_search/HEAD/assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-regular-400.eot -------------------------------------------------------------------------------- /assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clear-code/redmine_full_text_search/HEAD/assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-solid-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clear-code/redmine_full_text_search/HEAD/assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-solid-900.woff -------------------------------------------------------------------------------- /assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clear-code/redmine_full_text_search/HEAD/assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | scope :full_text_search do 2 | resources :fts_query_expansions, path: "query_expansions" 3 | get "query_expand", 4 | to: "fts_query_expand#index", 5 | as: :fts_query_expand 6 | end 7 | -------------------------------------------------------------------------------- /assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clear-code/redmine_full_text_search/HEAD/assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-regular-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clear-code/redmine_full_text_search/HEAD/assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-regular-400.woff -------------------------------------------------------------------------------- /assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clear-code/redmine_full_text_search/HEAD/assets/stylesheets/fontawesome-free-5.8.2-web/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /lib/full_text_search.rb: -------------------------------------------------------------------------------- 1 | module FullTextSearch 2 | class << self 3 | def resolver 4 | @resolver ||= Resolver.new 5 | end 6 | 7 | def attach 8 | resolver 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/full_text_search/condition_builder.rb: -------------------------------------------------------------------------------- 1 | module FullTextSearch 2 | module ConditionBuilder 3 | def build_condition(operator, *conditions) 4 | operator = " #{operator} " 5 | "(#{conditions.compact.join(operator)})" 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/views/fts_query_expansions/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= error_messages_for "query_expansion" %> 2 | 3 |<%= f.text_field :source, :required => true, :size => 60 %>
5 |<%= f.text_field :destination, :required => true, :size => 60 %>
6 |22 | <%= l(:label_similar_issues) %> 23 | <% if fts_display_score? %> 24 | (<%= duration %>ms) 25 | <% end %> 26 |
27 | <%= similar_issues %> 28 |2 | <%= label_tag("settings[display_score]", 3 | l(:label_full_text_search_display_score)) %> 4 | <%= check_box("settings", 5 | "display_score", 6 | checked: settings.display_score?) %> 7 |
8 | 9 |10 | <%= label_tag("settings[display_similar_issues]", 11 | l(:label_full_text_search_display_similar_issues)) %> 12 | <%= check_box("settings", 13 | "display_similar_issues", 14 | checked: settings.display_similar_issues?) %> 15 |
16 | 17 |18 | <%= label_tag("settings[enable_tracking]", 19 | l(:label_full_text_search_enable_tracking)) %> 20 | <%= check_box("settings", 21 | "enable_tracking", 22 | checked: settings.enable_tracking?) %> 23 |
24 | 25 |26 | <%= label_tag("settings[attachment_max_text_size_in_mb]", 27 | l(:label_full_text_search_attachment_max_text_size)) %> 28 | <%= text_field_tag("settings[attachment_max_text_size_in_mb]", 29 | settings.attachment_max_text_size_in_mb, 30 | size: 6) %> 31 | <%= l(:"number.human.storage_units.units.mb") %> 32 |
33 | 34 |35 | <%= label_tag("settings[text_extraction_timeout]", 36 | l(:label_full_text_search_text_extraction_timeout)) %> 37 | <%= text_field_tag("settings[text_extraction_timeout]", 38 | settings.text_extraction_timeout, 39 | size: 6) %> 40 |
41 | 42 |43 | <%= label_tag("settings[external_command_max_memory_in_mb]", 44 | l(:label_full_text_search_external_command_max_memory)) %> 45 | <%= text_field_tag("settings[external_command_max_memory_in_mb]", 46 | settings.external_command_max_memory_in_mb, 47 | size: 6) %> 48 | <%= l(:"number.human.storage_units.units.mb") %> 49 |
50 | 51 |52 | <%= label_tag("settings[server_url]", 53 | l(:label_full_text_search_server_url)) %> 54 | <%= text_field_tag("settings[server_url]", 55 | settings.server_url) %> 56 |
57 | -------------------------------------------------------------------------------- /test/unit/full_text_search/changeset_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | module FullTextSearch 4 | class ChangesetTest < ActiveSupport::TestCase 5 | include PrettyInspectable 6 | include NullValues 7 | include TimeValue 8 | 9 | fixtures :enabled_modules 10 | fixtures :projects 11 | fixtures :repositories 12 | fixtures :users 13 | fixtures :roles 14 | 15 | def test_save 16 | changeset = Changeset.generate! do |generating_changeset| 17 | generating_changeset.comments = "Fix a memory leak\n\nThis is critical." 18 | generating_changeset.committer = User.find(2).login 19 | end 20 | changeset.reload 21 | targets = Target.where(source_id: changeset.id, 22 | source_type_id: Type.changeset.id) 23 | assert_equal([ 24 | { 25 | "project_id" => changeset.repository.project_id, 26 | "source_id" => changeset.id, 27 | "source_type_id" => Type.changeset.id, 28 | "last_modified_at" => changeset.committed_on, 29 | "registered_at" => changeset.committed_on, 30 | "is_private" => null_boolean, 31 | "title" => "Fix a memory leak", 32 | "content" => "This is critical.", 33 | "custom_field_id" => null_number, 34 | "container_id" => null_number, 35 | "container_type_id" => null_number, 36 | "tag_ids" => [ 37 | Tag.user(changeset.user.id).id, 38 | ], 39 | }, 40 | ], 41 | targets.collect {|target| target.attributes.except("id")}) 42 | end 43 | 44 | def test_destroy 45 | changeset = Changeset.generate! 46 | targets = Target.where(source_id: changeset.id, 47 | source_type_id: Type.changeset.id) 48 | assert_equal(1, targets.size) 49 | changeset.destroy! 50 | assert_equal([], targets.reload.to_a) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/unit/full_text_search/journal_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | module FullTextSearch 4 | class JournalTest < ActiveSupport::TestCase 5 | include PrettyInspectable 6 | include NullValues 7 | 8 | fixtures :enumerations 9 | fixtures :issue_statuses 10 | fixtures :issues 11 | fixtures :projects 12 | fixtures :projects_trackers 13 | fixtures :trackers 14 | fixtures :users 15 | 16 | def test_save 17 | journal = Journal.generate!(notes: "Hello!") 18 | journal.reload 19 | targets = Target.where(source_id: journal.id, 20 | source_type_id: Type.journal.id) 21 | issue = journal.journalized 22 | last_modified_at = journal.updated_on 23 | assert_equal([ 24 | { 25 | "project_id" => issue.project_id, 26 | "source_id" => journal.id, 27 | "source_type_id" => Type.journal.id, 28 | "last_modified_at" => last_modified_at, 29 | "registered_at" => journal.created_on, 30 | "title" => null_string, 31 | "tag_ids" => [ 32 | Tag.user(journal.user_id).id, 33 | Tag.tracker(issue.tracker_id).id, 34 | Tag.issue_status(issue.status_id).id, 35 | ], 36 | "is_private" => journal.private_notes, 37 | "content" => journal.notes, 38 | "custom_field_id" => null_number, 39 | "container_id" => issue.id, 40 | "container_type_id" => Type.issue.id, 41 | } 42 | ], 43 | targets.collect {|target| target.attributes.except("id")}) 44 | end 45 | 46 | def test_destroy 47 | journal = Journal.generate! 48 | targets = Target.where(source_id: journal.id, 49 | source_type_id: Type.journal.id) 50 | assert_equal(1, targets.size) 51 | journal.destroy! 52 | assert_equal([], targets.reload.to_a) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/full_text_search/tracer.rb: -------------------------------------------------------------------------------- 1 | module FullTextSearch 2 | class Tracer 3 | def initialize(tag) 4 | @tag = tag 5 | @start_memory_usage = compute_memory_usage 6 | @start_time = Time.now 7 | end 8 | 9 | def trace(log_level, label, data, error: nil) 10 | Rails.logger.__send__(log_level) do 11 | format_message(label, data, error) 12 | end 13 | end 14 | 15 | private 16 | def compute_memory_usage 17 | status_path = "/proc/self/status" 18 | if File.exist?(status_path) 19 | File.open(status_path) do |status| 20 | status.each_line do |line| 21 | case line 22 | when /\AVmRSS:\s+(\d+) kB/ 23 | return Integer($1, 10) * 1024 24 | end 25 | end 26 | end 27 | end 28 | 0 29 | end 30 | 31 | def format_message(label, data, error) 32 | message = "[full-text-search]#{@tag} #{label}" 33 | data.each do |data_label, data_value| 34 | message << ": #{data_label}: <#{data_value}>" 35 | end 36 | elapsed_time = Time.now - @start_time 37 | message << ": elapsed time: <#{format_elapsed_time(elapsed_time)}>" 38 | memory_usage = compute_memory_usage 39 | if memory_usage > 0 40 | message << ": memory usage: <#{format_memory_usage(memory_usage)}>" 41 | memory_usage_diff = memory_usage - @start_memory_usage 42 | message << 43 | ": memory usage diff: <#{format_memory_usage(memory_usage_diff)}>" 44 | end 45 | if error 46 | message << ": #{error.class}: #{error.message}\n" 47 | message << error.backtrace.join("\n") 48 | end 49 | message 50 | end 51 | 52 | def format_elapsed_time(elapsed_time) 53 | if elapsed_time < 1 54 | "%.2fms" % (elapsed_time * 1000) 55 | elsif elapsed_time < 60 56 | "%.2fs" % elapsed_time 57 | elsif elapsed_time < (60 * 60) 58 | "%.2fm" % (elapsed_time / 60) 59 | else 60 | "%.2fh" % (elapsed_time / 60 / 60) 61 | end 62 | end 63 | 64 | def format_memory_usage(memory_usage) 65 | if memory_usage < (1024.0 * 1024.0 * 1024.0) 66 | "%.2fMiB" % (memory_usage / 1024.0 / 1024.0) 67 | else 68 | "%.2fGiB" % (memory_usage / 1024.0 / 1024.0 / 1024.0) 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/full_text_search/similar_searcher/pgroonga.rb: -------------------------------------------------------------------------------- 1 | module FullTextSearch 2 | module SimilarSearcher 3 | module Pgroonga 4 | def self.included(base) 5 | base.include(InstanceMethods) 6 | base.include(FullTextSearch::ConditionBuilder) 7 | base.class_eval do 8 | attr_accessor :similarity_score 9 | end 10 | end 11 | 12 | module InstanceMethods 13 | def similar_issues(user: User.current, project_ids: [], limit: 5) 14 | sql = <<-SQL.strip_heredoc 15 | select pgroonga_command( 16 | 'select', 17 | ARRAY[ 18 | 'table', pgroonga_table_name('#{similar_issues_index_name}'), 19 | 'output_columns', 'issue_id, _score', 20 | 'filter', '(content *S ' || pgroonga_escape(:content) || ') && issue_id != :id' || ' && #{filter_condition(user, project_ids)}', 21 | 'limit', ':limit', 22 | 'sort_keys', '-_score' 23 | ] 24 | )::json 25 | SQL 26 | response = nil 27 | ActiveSupport::Notifications.instrument("groonga.similar.search", sql: sql) do 28 | response = self.class.connection.select_value(ActiveRecord::Base.send(:sanitize_sql_array, [sql, content: similar_content, id: id, limit: limit])) 29 | end 30 | command = Groonga::Command.find("select").new("select", {}) 31 | r = Groonga::Client::Response.parse(command, response) 32 | if r.success? 33 | issue_scores = r.records.map do |row| 34 | [row["issue_id"], row["_score"]] 35 | end.to_h 36 | logger.debug(r.records) 37 | similar_issues = Issue.where(id: issue_scores.keys).all 38 | similar_issues.each do |s| 39 | s.similarity_score = issue_scores[s.id] 40 | end 41 | similar_issues.sort_by {|s| - s.similarity_score } 42 | else 43 | if Rails.env.production? 44 | logger.warn(r.message) 45 | [] 46 | else 47 | raise r.message 48 | end 49 | end 50 | end 51 | 52 | def similar_issues_index_name 53 | "index_issue_contents_pgroonga" 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /app/models/full_text_search/tag.rb: -------------------------------------------------------------------------------- 1 | module FullTextSearch 2 | class Tag < ApplicationRecord 3 | self.table_name = :fts_tags 4 | belongs_to :type, class_name: "FullTextSearch::TagType" 5 | 6 | case connection_db_config.adapter 7 | when "postgresql" 8 | include Pgroonga 9 | when "mysql2" 10 | include Mroonga 11 | end 12 | 13 | class << self 14 | def extension(ext) 15 | type = TagType.extension 16 | find_or_create_by(type_id: type.id, 17 | name: ext.downcase) 18 | end 19 | 20 | def identifier(id) 21 | type = TagType.identifier 22 | find_or_create_by(type_id: type.id, 23 | name: id) 24 | end 25 | 26 | def issue_status(issue_status_id) 27 | type = TagType.issue_status 28 | find_or_create_by(type_id: type.id, 29 | name: issue_status_id.to_s) 30 | end 31 | 32 | def text_extraction(status) 33 | type = TagType.text_extraction 34 | find_or_create_by(type_id: type.id, 35 | name: status) 36 | end 37 | 38 | def text_extraction_error 39 | text_extraction("error") 40 | end 41 | 42 | def text_extraction_yet 43 | text_extraction("yet") 44 | end 45 | 46 | def text_extraction_ids 47 | type = TagType.text_extraction 48 | where(type_id: type.id).select(:id).collect(&:id) 49 | end 50 | 51 | def tracker(tracker_id) 52 | type = TagType.tracker 53 | find_or_create_by(type_id: type.id, 54 | name: tracker_id.to_s) 55 | end 56 | 57 | def user(user_id) 58 | type = TagType.user 59 | find_or_create_by(type_id: type.id, 60 | name: user_id.to_s) 61 | end 62 | 63 | def label(label) 64 | type = TagType.label 65 | find_or_create_by(type_id: type.id, 66 | name: label) 67 | end 68 | end 69 | 70 | def value 71 | @value ||= compute_value 72 | end 73 | 74 | private 75 | def compute_value 76 | case type_id 77 | when TagType.issue_status.id 78 | IssueStatus.find(name.to_i) 79 | when TagType.tracker.id 80 | Tracker.find(name.to_i) 81 | when TagType.user.id 82 | User.find(name.to_i) 83 | else 84 | name 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /app/jobs/full_text_search/update_issue_content_job.rb: -------------------------------------------------------------------------------- 1 | module FullTextSearch 2 | class UpdateIssueContentJob < ActiveJob::Base 3 | queue_as :full_text_search 4 | queue_with_priority 15 5 | 6 | discard_on ActiveRecord::RecordNotFound 7 | 8 | def perform(record_class_name, record_id, action, options={}) 9 | record_class = record_class_name.constantize 10 | 11 | case action 12 | when "commit" 13 | record = record_class.find(record_id) 14 | case record 15 | when Issue 16 | content = FullTextSearch::IssueContent 17 | .find_or_initialize_by(issue_id: record.id) 18 | content.project_id = record.project_id 19 | content.subject = record.subject 20 | content.content = create_content(record.id) 21 | content.status_id = record.status_id 22 | content.save! 23 | when Journal 24 | issue_id = record.journalized_id 25 | FullTextSearch::IssueContent 26 | .where(issue_id: issue_id) 27 | .update_all(content: create_content(issue_id)) 28 | end 29 | when "destroy" 30 | case record_class_name 31 | when "Issue" 32 | FullTextSearch::IssueContent.where(issue_id: record_id).destroy_all 33 | when "Journal" 34 | issue_id = options[:issue_id] 35 | FullTextSearch::IssueContent 36 | .where(issue_id: issue_id) 37 | .update_all(content: create_content(issue_id, excludes: [record_id])) 38 | when "Attachment" 39 | issue_id = options[:issue_id] 40 | FullTextSearch::IssueContent 41 | .where(issue_id: issue_id) 42 | .update_all(content: create_content(issue_id)) 43 | end 44 | end 45 | end 46 | 47 | private 48 | def create_content(issue_id, excludes: []) 49 | issue = Issue.eager_load(:journals).find(issue_id) 50 | content = [issue.subject, issue.description] 51 | notes = issue.journals 52 | .reject {|j| j.notes.blank? || excludes.include?(j.id) } 53 | .sort_by(&:id) 54 | .map(&:notes) 55 | content.concat(notes) 56 | issue.attachments.order(:id).each do |attachment| 57 | content << attachment.filename if attachment.filename.present? 58 | content << attachment.description if attachment.description.present? 59 | end 60 | content.join("\n") 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/unit/full_text_search/plugin_wiki_extensions_tag_searchable_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | module FullTextSearch 4 | class PluginWikiExtensionsTagSearchableText < ActiveSupport::TestCase 5 | fixtures :projects 6 | fixtures :wikis 7 | 8 | def setup 9 | unless defined?(WikiExtensionsTagRelation) 10 | skip 'redmine_wiki_extensions is not installed' 11 | end 12 | 13 | project = Project.find(1) 14 | 3.times do |i| 15 | WikiExtensionsTag.create( 16 | name: "tag-#{i}", 17 | project_id: project.id, 18 | ) 19 | end 20 | 21 | @page = WikiPage.new( 22 | wiki: project.wiki, 23 | title: 'PluginWikiExtensionsTagRelationTest' 24 | ) 25 | content = WikiContent.new(page: @page) 26 | @page.save_with_content(content) 27 | end 28 | 29 | def set_tags(*names) 30 | wiki_ext_tags = WikiExtensionsTag. 31 | where(name: names). 32 | collect{ |tag| [tag.id, tag.name] }. 33 | to_h 34 | @page.set_tags(wiki_ext_tags) 35 | @page.reload 36 | end 37 | 38 | def test_set_tag 39 | set_tags('tag-0', 'tag-2') 40 | target = Target.find_by(source_id: @page.id, 41 | source_type_id: Type.wiki_page.id) 42 | assert_equal( 43 | [ 44 | Tag.label('tag-0').id, 45 | Tag.label('tag-2').id 46 | ].sort, 47 | target.tag_ids.sort 48 | ) 49 | end 50 | 51 | def test_add_tag 52 | # First set tag-0 and tag-2 53 | set_tags('tag-0', 'tag-2') 54 | # Add 'tag-1' 55 | set_tags('tag-0', 'tag-1', 'tag-2') 56 | 57 | target = Target.find_by(source_id: @page.id, 58 | source_type_id: Type.wiki_page.id) 59 | assert_equal( 60 | [ 61 | Tag.label('tag-0').id, 62 | Tag.label('tag-1').id, 63 | Tag.label('tag-2').id 64 | ].sort, 65 | target.tag_ids.sort 66 | ) 67 | end 68 | 69 | def test_remove_tag 70 | # First set tag-0 and tag-2 71 | set_tags('tag-0', 'tag-2') 72 | # Remove 'tag-0' 73 | set_tags('tag-2') 74 | 75 | target = Target.find_by(source_id: @page.id, 76 | source_type_id: Type.wiki_page.id) 77 | assert_equal( 78 | [Tag.label('tag-2').id], 79 | target.tag_ids 80 | ) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/full_text_search/markup_parser.rb: -------------------------------------------------------------------------------- 1 | module FullTextSearch 2 | class MarkupParser 3 | include ERB::Util 4 | include ActionView::Helpers 5 | include Rails.application.routes.url_helpers 6 | include ApplicationHelper 7 | 8 | def initialize(project) 9 | @project = project 10 | assign_controller(WelcomeController.new) 11 | controller.request = ActionDispatch::Request.new({}) 12 | end 13 | 14 | def parse(object, attribute, options={}) 15 | html = with_user(User.admin.first) do 16 | textilizable(object, attribute, options) 17 | end 18 | return ["", []] unless html.present? 19 | 20 | document = Document.new 21 | # We need .to_s for old Nokogiri. We can remove the .to_s after we drop support for Redmine < 6.0. 22 | # Redmine 6.0 requires Nokogiri 1.18.3 or later. 23 | parser = Nokogiri::HTML::SAX::Parser.new(document, html.encoding.to_s) 24 | parser.parse(html) 25 | [document.text.strip, document.tag_ids] 26 | end 27 | 28 | private 29 | def with_user(user) 30 | current_user = User.current 31 | begin 32 | User.current = user 33 | yield 34 | ensure 35 | User.current = current_user 36 | end 37 | end 38 | 39 | class Document < Nokogiri::XML::SAX::Document 40 | attr_reader :text 41 | attr_reader :tag_ids 42 | def initialize 43 | @text = +"" 44 | @tag_ids = [] 45 | 46 | @tag_stack = [] 47 | @attributes_stack =[] 48 | @in_body = false 49 | end 50 | 51 | def start_element(name, attributes=[]) 52 | @tag_stack.push(name) 53 | @attributes_stack.push(attributes) 54 | unless @in_body 55 | @in_body = (@tag_stack == ["html", "body"]) 56 | return 57 | end 58 | end 59 | 60 | def end_element(name) 61 | @attributes_stack.pop 62 | @tag_stack.pop 63 | return unless @in_body 64 | 65 | if name == "body" and @tag_stack == ["html"] 66 | @in_body = false 67 | return 68 | end 69 | end 70 | 71 | def characters(text) 72 | @text << text if in_target_text? 73 | end 74 | 75 | private 76 | def in_target_text? 77 | return false unless @in_body 78 | 79 | @attributes_stack.last.each do |name, value| 80 | case name 81 | when "class" 82 | return false if value.split(/\s+/).include?("wiki-anchor") 83 | end 84 | end 85 | true 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/full_text_search/hooks/controller_search_index.rb: -------------------------------------------------------------------------------- 1 | module FullTextSearch 2 | module Hooks 3 | module ControllerSearchIndex 4 | def index 5 | @search_request = Request.new(query_params) 6 | @search_request.user = User.current 7 | @search_request.project = @project 8 | 9 | page = (params[:page].presence || 1).to_i 10 | case params[:format] 11 | when 'xml', 'json' 12 | offset, limit = api_offset_and_limit 13 | else 14 | limit = Setting.search_results_per_page.to_i 15 | limit = 10 if limit == 0 16 | offset = (page - 1) * limit 17 | end 18 | @search_request.offset = offset 19 | @search_request.limit = limit 20 | 21 | # quick jump to an issue 22 | if (m = @search_request.query.match(/^#?(\d+)$/)) && 23 | (issue = Issue.visible.find_by_id(m[1].to_i)) 24 | redirect_to issue_path(issue) 25 | return 26 | end 27 | 28 | searcher = Searcher.new(@search_request) 29 | @result_set = searcher.search 30 | context = @search_request.to_params 31 | context = context.merge("user_id" => @search_request.user.id, 32 | "project_id" => @search_request.project&.id, 33 | "n_hits" => @result_set.n_hits, 34 | "total_n_hits" => @result_set.total_n_hits, 35 | "elapsed_time" => @result_set.elapsed_time, 36 | "timestamp" => Time.zone.now.iso8601) 37 | log = "[full-text-search][search] #{context.to_json}" 38 | Rails.logger.info(log) 39 | @result_pages = Redmine::Pagination::Paginator.new(@result_set.n_hits, 40 | @search_request.limit, 41 | params["page"]) 42 | 43 | respond_to do |format| 44 | format.html { render layout: false if request.xhr? } 45 | format.api { render layout: false } 46 | end 47 | end 48 | 49 | private 50 | def query_params 51 | permitted_names = [ 52 | :search_id, 53 | :q, 54 | :scope, 55 | :all_words, 56 | :titles_only, 57 | :attachments, 58 | :open_issues, 59 | :format, 60 | :order_target, 61 | :order_type, 62 | :options, 63 | ] 64 | Redmine::Search.available_search_types.each do |type| 65 | permitted_names << type.to_sym 66 | end 67 | params.permit(*permitted_names, 68 | tags: []) 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/full_text_search/issue_mapper.rb: -------------------------------------------------------------------------------- 1 | module FullTextSearch 2 | class IssueMapper < Mapper 3 | class << self 4 | def redmine_mapper_class 5 | RedmineIssueMapper 6 | end 7 | 8 | def fts_mapper_class 9 | FtsIssueMapper 10 | end 11 | end 12 | end 13 | resolver.register(Issue, IssueMapper) 14 | 15 | class RedmineIssueMapper < RedmineMapper 16 | def upsert_fts_target(options={}) 17 | fts_target = find_fts_target 18 | fts_target.source_id = @record.id 19 | fts_target.source_type_id = Type[@record.class].id 20 | fts_target.project_id = @record.project_id 21 | tag_ids = [] 22 | tag_ids << Tag.tracker(@record.tracker_id).id if @record.tracker_id 23 | fts_target.title = @record.subject 24 | parser = MarkupParser.new(@record.project) 25 | content_text, content_tag_ids = parser.parse(@record, :description) 26 | fts_target.content = content_text 27 | tag_ids.concat(content_tag_ids) 28 | tag_ids << Tag.user(@record.author_id).id if @record.author_id 29 | fts_target.is_private = @record.is_private 30 | tag_ids << Tag.issue_status(@record.status_id).id if @record.status_id 31 | fts_target.tag_ids = tag_ids 32 | fts_target.last_modified_at = @record.updated_on 33 | fts_target.registered_at = @record.created_on 34 | fts_target.save! 35 | 36 | @record.journals.each do |journal| 37 | redmine_mapper = JournalMapper.redmine_mapper(journal) 38 | # We don't insert a new FTS target here to avoid an unique 39 | # constraint error when a new journal is added. The following 40 | # jobs are enqueued when a new journal is added: 41 | # 42 | # 1. UpsertTargetJob(IssueMapper) 43 | # 2. UpsertTargetJob(JournalMapper) 44 | # 45 | # If both of jobs are executed in parallel, both of job 1. and 46 | # job 2. may try adding new FTS target for the same 47 | # journal. In the case, one of them is failed by an unique 48 | # constraint error. If we don't create a new FTS target in 49 | # here (job 1.), we can avoid the error. 50 | next unless redmine_mapper.find_fts_target.persisted? 51 | redmine_mapper.upsert_fts_target(options) 52 | end 53 | # @record.custom_values 54 | end 55 | end 56 | 57 | class FtsIssueMapper < FtsMapper 58 | def type 59 | issue = redmine_record 60 | if issue.closed? 61 | "issue-closed" 62 | else 63 | "issue" 64 | end 65 | end 66 | 67 | def title_prefix 68 | issue = redmine_record 69 | "#{issue.tracker.name} \##{@record.source_id} (#{issue.status}): " 70 | end 71 | 72 | def url 73 | { 74 | controller: "issues", 75 | action: "show", 76 | id: @record.source_id, 77 | } 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/unit/full_text_search/query_expansion_synchronizer_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | module FullTextSearch 4 | class QueryExpansionSynchronizerTest < ActiveSupport::TestCase 5 | include PrettyInspectable 6 | 7 | def setup 8 | FtsQueryExpansion.destroy_all 9 | end 10 | 11 | def synchronize(input) 12 | synchronizer = QueryExpansionSynchronizer.new(input) 13 | synchronizer.synchronize 14 | end 15 | 16 | def test_json_array 17 | synchronize(StringIO.new(+<<-JSON)) 18 | [ 19 | ["Groonga", "Groonga"], 20 | ["Groonga", "Senna"] 21 | ] 22 | JSON 23 | assert_equal([ 24 | FtsQueryExpansion.find_by(source: "Groonga", 25 | destination: "Groonga"), 26 | FtsQueryExpansion.find_by(source: "Groonga", 27 | destination: "Senna"), 28 | ], 29 | FtsQueryExpansion.order(:id)) 30 | end 31 | 32 | def test_json_object 33 | synchronize(StringIO.new(+<<-JSON)) 34 | [ 35 | {"source": "Groonga", "destination": "Groonga"}, 36 | {"source": "Groonga", "destination": "Senna"} 37 | ] 38 | JSON 39 | assert_equal([ 40 | FtsQueryExpansion.find_by(source: "Groonga", 41 | destination: "Groonga"), 42 | FtsQueryExpansion.find_by(source: "Groonga", 43 | destination: "Senna"), 44 | ], 45 | FtsQueryExpansion.order(:id)) 46 | end 47 | 48 | def test_csv 49 | synchronize(StringIO.new(+<<-CSV)) 50 | Groonga,Groonga 51 | Groonga,Senna 52 | CSV 53 | assert_equal([ 54 | FtsQueryExpansion.find_by(source: "Groonga", 55 | destination: "Groonga"), 56 | FtsQueryExpansion.find_by(source: "Groonga", 57 | destination: "Senna"), 58 | ], 59 | FtsQueryExpansion.order(:id)) 60 | end 61 | 62 | def test_remove_untouched_entries 63 | FtsQueryExpansion.create!(source: "Groonga", 64 | destination: "Groonga", 65 | updated_at: Time.current - 2) 66 | FtsQueryExpansion.create!(source: "Groonga", 67 | destination: "Senna", 68 | updated_at: Time.current - 2) 69 | synchronize(StringIO.new(+<<-CSV)) 70 | Groonga,Groonga 71 | CSV 72 | assert_equal([ 73 | FtsQueryExpansion.find_by(source: "Groonga", 74 | destination: "Groonga"), 75 | ], 76 | FtsQueryExpansion.all) 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/full_text_search/custom_value_mapper.rb: -------------------------------------------------------------------------------- 1 | module FullTextSearch 2 | class CustomValueMapper < Mapper 3 | class << self 4 | def redmine_mapper_class 5 | RedmineCustomValueMapper 6 | end 7 | 8 | def fts_mapper_class 9 | FtsCustomValueMapper 10 | end 11 | end 12 | end 13 | resolver.register(CustomValue, CustomValueMapper) 14 | 15 | class RedmineCustomValueMapper < RedmineMapper 16 | class << self 17 | def with_project(redmine_class) 18 | redmine_class 19 | .joins(<<-JOIN) 20 | LEFT OUTER JOIN issues 21 | ON customized_type = 'Issue' AND issues.id = customized_id 22 | JOIN 23 | .joins(<<-JOIN) 24 | JOIN projects 25 | ON (customized_type = 'Project' AND projects.id = customized_id) OR 26 | (customized_type = 'Issue' AND projects.id = issues.project_id) 27 | JOIN 28 | end 29 | 30 | def not_mapped(redmine_class, options) 31 | super 32 | .joins(:custom_field) 33 | .where("custom_fields.searchable") 34 | end 35 | end 36 | 37 | def upsert_fts_target(options={}) 38 | fts_target = find_fts_target 39 | 40 | unless @record.custom_field.searchable? 41 | fts_target.destroy! if fts_target.persisted? 42 | return 43 | end 44 | 45 | customized = @record.customized 46 | unless customized 47 | fts_target.destroy! if fts_target.persisted? 48 | return 49 | end 50 | 51 | # searchable CustomValue belongs to issue or project 52 | fts_target.source_id = @record.id 53 | fts_target.source_type_id = Type[@record.class].id 54 | fts_target.content = @record.value 55 | fts_target.custom_field_id = @record.custom_field_id 56 | case @record.customized_type 57 | when "Issue" 58 | fts_target.project_id = customized.project_id 59 | fts_target.is_private = customized.is_private 60 | when "Project" 61 | fts_target.project_id = customized.id 62 | else 63 | fts_target.destroy! if fts_target.persisted? 64 | return 65 | end 66 | fts_target.container_id = customized.id 67 | fts_target.container_type_id = Type[customized].id 68 | # TODO: This may not be updated when issue or project is updated. 69 | fts_target.last_modified_at = customized.updated_on 70 | fts_target.registered_at = customized.created_on 71 | fts_target.save! 72 | end 73 | end 74 | 75 | class FtsCustomValueMapper < FtsMapper 76 | def type 77 | redmine_record.customized.event_type 78 | end 79 | 80 | def title 81 | redmine_record.customized.event_title 82 | end 83 | 84 | def description 85 | redmine_record.customized.event_description 86 | end 87 | 88 | def url 89 | redmine_record.customized.event_url 90 | end 91 | 92 | def id 93 | redmine_record.customized_id 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/full_text_search/query_expansion_synchronizer.rb: -------------------------------------------------------------------------------- 1 | require "csv" 2 | require "json" 3 | 4 | module FullTextSearch 5 | class QueryExpansionSynchronizer 6 | def initialize(input) 7 | @input = input 8 | end 9 | 10 | def synchronize 11 | start = Time.current.change(usec: 0) 12 | each_record do |record| 13 | attributes = { 14 | source: record["source"], 15 | destination: record["destination"], 16 | } 17 | query_expansion = FtsQueryExpansion.find_or_initialize_by(attributes) 18 | query_expansion.updated_at = Time.current 19 | unless query_expansion.save 20 | Rails.logger.warn("#{log_tag} failed to save:") 21 | query_expansion.errors.full_messages.each do |message| 22 | Rails.logger.warn("#{log_tag} #{message}") 23 | end 24 | end 25 | end 26 | not_updated = FtsQueryExpansion.where("updated_at < ?", start) 27 | not_updated.destroy_all 28 | end 29 | 30 | private 31 | BOM_CODE_POINT = 0xfeff 32 | def open_input 33 | if @input.respond_to?(:getc) 34 | yield(@input) 35 | else 36 | File.open(@input) do |input| 37 | yield(input) 38 | end 39 | end 40 | end 41 | 42 | def each_record 43 | open_input do |input| 44 | skip_bom(input) 45 | case detect_format(input) 46 | when :json 47 | JSON.parse(input.read).each do |row| 48 | if row.is_a?(Array) 49 | record = { 50 | "source" => row[0], 51 | "destination" => row[1], 52 | } 53 | else 54 | record = row 55 | end 56 | yield(record) 57 | end 58 | else 59 | csv = CSV.new(input) 60 | csv.each do |row| 61 | record = { 62 | "source" => row[0], 63 | "destination" => row[1], 64 | } 65 | yield(record) 66 | end 67 | end 68 | end 69 | end 70 | 71 | def skip_bom(input) 72 | first_character = input.getc 73 | unless first_character.codepoints[0] == BOM_CODE_POINT 74 | input.ungetc(first_character) 75 | end 76 | end 77 | 78 | def detect_format(input) 79 | if input.respond_to?(:path) 80 | case input.path 81 | when /\.json\z/i 82 | :json 83 | when /\.csv\z/i 84 | :csv 85 | else 86 | nil 87 | end 88 | else 89 | first_character = input.getc 90 | input.ungetc(first_character) 91 | case first_character 92 | when "[" 93 | :json 94 | else 95 | nil 96 | end 97 | end 98 | end 99 | 100 | def log_tag 101 | "[full-text-search][query-expansion][synchronize]" 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/full_text_search/settings.rb: -------------------------------------------------------------------------------- 1 | module FullTextSearch 2 | module SettingsObjectize 3 | def plugin_full_text_search 4 | Settings.new(super) 5 | end 6 | end 7 | 8 | class Settings 9 | TRUE_VALUE = "1" 10 | FALSE_VALUE = "0" 11 | 12 | def initialize(raw) 13 | @raw = raw || {} 14 | end 15 | 16 | def display_score? 17 | @raw["display_score"] == TRUE_VALUE 18 | end 19 | 20 | def display_similar_issues? 21 | @raw["display_similar_issues"] == TRUE_VALUE 22 | end 23 | 24 | def enable_tracking? 25 | @raw["enable_tracking"] != FALSE_VALUE 26 | end 27 | 28 | DEFAULT_ATTACHMENT_MAX_TEXT_SIZE_IN_MB = 4 29 | def attachment_max_text_size_in_mb 30 | size = @raw.fetch("attachment_max_text_size_in_mb", 31 | DEFAULT_ATTACHMENT_MAX_TEXT_SIZE_IN_MB) 32 | begin 33 | Float(size) 34 | rescue ArgumentError 35 | DEFAULT_ATTACHMENT_MAX_TEXT_SIZE_IN_MB 36 | end 37 | end 38 | 39 | def attachment_max_text_size 40 | size = (attachment_max_text_size_in_mb * 1.megabytes).floor 41 | if Redmine::Database.mysql? 42 | connection = ActiveRecord::Base.connection 43 | result = connection.exec_query("SELECT @@max_allowed_packet", 44 | "max allowed package") 45 | max_allowed_packet = result[0]["@@max_allowed_packet"] 46 | [size, (max_allowed_packet * 0.7).floor].min 47 | else 48 | size 49 | end 50 | end 51 | 52 | DEFAULT_TEXT_EXTRACTION_TIMEOUT = 180 53 | def text_extraction_timeout 54 | timeout = @raw.fetch("text_extraction_timeout", 55 | DEFAULT_TEXT_EXTRACTION_TIMEOUT) 56 | begin 57 | Float(timeout) 58 | rescue ArgumentError 59 | DEFAULT_TEXT_EXTRACTION_TIMEOUT 60 | end 61 | end 62 | 63 | external_command_max_memory_in_mb = 1024 64 | if File.readable?("/proc/meminfo") 65 | File.open("/proc/meminfo") do |meminfo| 66 | meminfo.each_line do |line| 67 | case line 68 | when /\AMemTotal:\s+(\d+) kB/ 69 | total_memory = Integer($1, 10) 70 | external_command_max_memory_in_mb = 71 | ((total_memory / 4) / 1024.0).round 72 | break 73 | end 74 | end 75 | end 76 | end 77 | DEFAULT_EXTERNAL_COMMAND_MAX_MEMORY_IN_MB = external_command_max_memory_in_mb 78 | def external_command_max_memory_in_mb 79 | size = @raw.fetch("external_command_max_memory_in_mb", 80 | DEFAULT_EXTERNAL_COMMAND_MAX_MEMORY_IN_MB) 81 | begin 82 | Float(size) 83 | rescue ArgumentError 84 | DEFAULT_EXTERNAL_COMMAND_MAX_MEMORY_IN_MB 85 | end 86 | end 87 | 88 | def external_command_max_memory 89 | (external_command_max_memory_in_mb * 1.megabytes).floor 90 | end 91 | 92 | def server_url 93 | @raw["server_url"].presence 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/full_text_search/journal_mapper.rb: -------------------------------------------------------------------------------- 1 | module FullTextSearch 2 | class JournalMapper < Mapper 3 | class << self 4 | def redmine_mapper_class 5 | RedmineJournalMapper 6 | end 7 | 8 | def fts_mapper_class 9 | FtsJournalMapper 10 | end 11 | end 12 | end 13 | resolver.register(Journal, JournalMapper) 14 | 15 | class RedmineJournalMapper < RedmineMapper 16 | class << self 17 | def with_project(redmine_class) 18 | redmine_class 19 | .joins(<<-JOIN) 20 | JOIN issues 21 | ON journalized_type = 'Issue' AND issues.id = journalized_id 22 | JOIN 23 | .joins(<<-JOIN) 24 | JOIN projects 25 | ON projects.id = issues.project_id 26 | JOIN 27 | end 28 | end 29 | 30 | def upsert_fts_target(options={}) 31 | # journal belongs to an issue for now. 32 | issue = @record.journalized 33 | return if issue.nil? 34 | return unless issue.is_a?(Issue) 35 | 36 | fts_target = find_fts_target 37 | fts_target.source_id = @record.id 38 | fts_target.source_type_id = Type[@record.class].id 39 | fts_target.project_id = issue.project_id 40 | fts_target.container_id = issue.id 41 | fts_target.container_type_id = Type.issue.id 42 | tag_ids = [] 43 | parser = MarkupParser.new(issue.project) 44 | content_text, content_tag_ids = parser.parse(@record, :notes) 45 | fts_target.content = content_text 46 | tag_ids.concat(content_tag_ids) 47 | tag_ids << Tag.user(@record.user_id).id if @record.user_id 48 | fts_target.is_private = (issue.is_private or @record.private_notes) 49 | tag_ids << Tag.tracker(issue.tracker_id).id if issue.tracker_id 50 | tag_ids << Tag.issue_status(issue.status_id).id if issue.status_id 51 | fts_target.tag_ids = tag_ids 52 | fts_target.last_modified_at = @record.updated_on 53 | fts_target.registered_at = @record.created_on 54 | fts_target.save! 55 | end 56 | end 57 | 58 | class FtsJournalMapper < FtsMapper 59 | def type 60 | journal = redmine_record 61 | new_status = journal.new_status 62 | if new_status 63 | if new_status.is_closed? 64 | "issue-closed" 65 | else 66 | "issue-edit" 67 | end 68 | else 69 | "issue-note" 70 | end 71 | end 72 | 73 | def title_prefix 74 | journal = redmine_record 75 | issue = journal.issue 76 | prefix = "#{issue.tracker.name} " 77 | prefix << "\##{issue.id}\#change-#{journal.id} " 78 | prefix << "(#{issue.status}): " 79 | prefix 80 | end 81 | 82 | def title 83 | journal = redmine_record 84 | issue = journal.issue 85 | "#{title_prefix}#{issue.subject}#{title_suffix}" 86 | end 87 | 88 | def url 89 | journal = redmine_record 90 | { 91 | controller: "issues", 92 | action: "show", 93 | id: journal.issue.id, 94 | anchor: "change-#{journal.id}", 95 | } 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/full_text_search/similar_searcher/mroonga.rb: -------------------------------------------------------------------------------- 1 | module FullTextSearch 2 | module SimilarSearcher 3 | module Mroonga 4 | def self.included(base) 5 | base.include(InstanceMethods) 6 | base.include(FullTextSearch::ConditionBuilder) 7 | base.class_eval do 8 | attr_accessor :similarity_score 9 | end 10 | end 11 | 12 | module InstanceMethods 13 | def similar_issues(user: User.current, project_ids: [], limit: 5) 14 | # NOTE: The sanitize_sql_array method in the MySQL adapter is not 15 | # schema-aware. It quotes numeric parameters as strings to prevent 16 | # query manipulation attacks. However, if numeric parameters like :id 17 | # and :limit are not explicitly converted to strings beforehand, this 18 | # can lead to a syntax error. 19 | # 20 | # For example, without the explicit conversion, the following error is 21 | # caused 22 | # 23 | # ...'limit', ''5'', 'sor' at line 5 24 | # 25 | # To prevent such syntax errors, we explicitly convert numeric 26 | # parameters to strings before passing them to sanitize_sql_array. 27 | sql = <<-SQL.strip_heredoc 28 | select mroonga_command( 29 | 'select', 30 | 'table', 'issue_contents', 31 | 'output_columns', 'issue_id, _score', 32 | 'filter', CONCAT('(content *S "', mroonga_escape(:content), '") && issue_id != ', :id, ' && #{filter_condition(user, project_ids)}'), 33 | 'limit', :limit, 34 | 'sort_keys', '-_score' 35 | ) 36 | SQL 37 | r = nil 38 | ActiveSupport::Notifications.instrument("groonga.similar.search", sql: sql) do 39 | r = self.class.connection.select_value(ActiveRecord::Base.send(:sanitize_sql_array, [sql, content: similar_content, id: id.to_s, limit: limit.to_s])) 40 | end 41 | # NOTE: Hack to use Groonga::Client::Response.parse 42 | # Raise Mysql2::Error if error occurred 43 | body = JSON.parse(r) 44 | header = [0, 0, 0] 45 | response = [header, body].to_json 46 | command = Groonga::Command.find("select").new("select", {}) 47 | r = Groonga::Client::Response.parse(command, response) 48 | issue_scores = r.records.map do |row| 49 | [row["issue_id"], row["_score"]] 50 | end.to_h 51 | logger.debug(r.records) 52 | similar_issues = Issue.where(id: issue_scores.keys).all 53 | similar_issues.each do |s| 54 | s.similarity_score = issue_scores[s.id] 55 | end 56 | similar_issues.sort_by {|s| - s.similarity_score } 57 | rescue => ex 58 | if Rails.env.production? 59 | logger.warn(ex.class => ex.message) 60 | [] 61 | else 62 | raise 63 | end 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/tasks/full_text_search.rake: -------------------------------------------------------------------------------- 1 | # -*- ruby -*- 2 | 3 | namespace :full_text_search do 4 | desc "Tag" 5 | task :tag => :environment do 6 | plugin = Redmine::Plugin.find(:full_text_search) 7 | version = plugin.version 8 | cd(plugin.directory) do 9 | sh("git", "tag", 10 | "-a", "v#{version}", 11 | "-m", "#{version} has been released!!!") 12 | sh("git", "push", "--tags") 13 | end 14 | end 15 | 16 | desc "Release" 17 | task :release => :tag 18 | 19 | desc "Truncate" 20 | task :truncate => :environment do 21 | FullTextSearch::Target.truncate 22 | end 23 | 24 | wait_queue = lambda do 25 | queue_adapter = ActiveJob::Base.queue_adapter 26 | case queue_adapter 27 | when ActiveJob::QueueAdapters::AsyncAdapter 28 | queue_adapter.shutdown 29 | end 30 | end 31 | 32 | run_batch = lambda do |&block| 33 | upsert = ENV["UPSERT"] || "immediate" 34 | extract_text = ENV["EXTRACT_TEXT"] || "immediate" 35 | project = ENV["PROJECT"] 36 | type = ENV["TYPE"] 37 | batch_runner = FullTextSearch::BatchRunner.new(show_progress: true) 38 | block.call(batch_runner, 39 | project: project, 40 | type: type, 41 | upsert: upsert.to_sym, 42 | extract_text: extract_text.to_sym) 43 | wait_queue.call 44 | end 45 | 46 | desc "Synchronize" 47 | task :synchronize => :environment do 48 | run_batch.call do |batch_runner, **options| 49 | batch_runner.synchronize(**options) 50 | end 51 | end 52 | 53 | namespace :repository do 54 | desc "Synchronize only repository data" 55 | task :synchronize => :environment do 56 | run_batch.call do |batch_runner, **options| 57 | batch_runner.synchronize_repositories(**options) 58 | end 59 | end 60 | end 61 | 62 | namespace :similar_issues do 63 | desc "Synchronize similar issues data" 64 | task :synchronize => :environment do 65 | run_batch.call do |batch_runner, **options| 66 | batch_runner.synchronize_similar_issues(**options) 67 | end 68 | end 69 | end 70 | 71 | namespace :target do 72 | desc "Reload targets" 73 | task :reload => :environment do 74 | run_batch.call do |batch_runner, **options| 75 | batch_runner.reload_fts_targets(**options) 76 | end 77 | end 78 | end 79 | 80 | namespace :text do 81 | desc "Extract texts" 82 | task :extract => :environment do 83 | options = {} 84 | id = ENV["ID"] 85 | options[:ids] = [Integer(id, 10)] if id.present? 86 | batch_runner = FullTextSearch::BatchRunner.new(show_progress: true) 87 | batch_runner.extract_text(**options) 88 | wait_queue.call 89 | end 90 | end 91 | 92 | namespace :query_expansion do 93 | desc "Synchronize query expansion data" 94 | task :synchronize => :environment do 95 | input = ENV["INPUT"] || $stdin 96 | synchronizer = FullTextSearch::QueryExpansionSynchronizer.new(input) 97 | synchronizer.synchronize 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /db/migrate/20170630063657_create_searcher_records.rb: -------------------------------------------------------------------------------- 1 | # For auto load 2 | FullTextSearch::Migration 3 | 4 | class CreateSearcherRecords < ActiveRecord::Migration[4.2] 5 | def change 6 | return if reverting? and !table_exists?(:searcher_records) 7 | 8 | if Redmine::Database.mysql? 9 | options = "ENGINE=Mroonga" 10 | else 11 | options = nil 12 | end 13 | create_table :searcher_records, options: options do |t| 14 | # common 15 | t.integer :project_id, null: false 16 | t.string :project_name, null: false # for searching by project_name 17 | t.integer :original_id, null: false 18 | t.string :original_type, null: false 19 | t.timestamp :original_created_on 20 | t.timestamp :original_updated_on 21 | 22 | # projects 23 | t.string :name 24 | t.text :description 25 | t.string :identifier 26 | t.integer :status 27 | 28 | # news 29 | t.string :title 30 | t.string :summary 31 | # t.text :description 32 | 33 | # issues 34 | t.string :subject 35 | # t.text :description 36 | t.integer :author_id 37 | t.boolean :is_private 38 | t.integer :status_id 39 | t.integer :tracker_id 40 | t.integer :issue_id 41 | 42 | # documents 43 | # t.string :title 44 | # t.text :description 45 | 46 | # changesets 47 | t.text :comments 48 | t.text :short_comments 49 | t.text :long_comments 50 | 51 | # messages 52 | # t.string :subject 53 | t.text :content 54 | 55 | # journals 56 | t.text :notes 57 | # t.integer :user_id # => author_id 58 | t.boolean :private_notes 59 | # t.integer :status_id 60 | 61 | # wiki_pages 62 | # t.string :title 63 | t.text :text # wiki_contents.text w/ latest version 64 | 65 | # custom_value 66 | t.text :value 67 | t.integer :custom_field_id 68 | 69 | # attachments 70 | t.integer :container_id 71 | t.string :container_type 72 | t.string :filename 73 | # t.text :description 74 | 75 | t.index([:original_id, :original_type], unique: true) 76 | 77 | columns = [ 78 | :original_type, 79 | :project_name, 80 | :name, 81 | :identifier, 82 | :description, 83 | :title, 84 | :summary, 85 | :subject, 86 | :comments, 87 | :content, 88 | :notes, 89 | :text, 90 | :value, 91 | :container_type, 92 | :filename, 93 | ] 94 | if Redmine::Database.mysql? 95 | columns.each do |column| 96 | t.index column, type: "fulltext" 97 | end 98 | t.index :original_type, 99 | name: "index_searcher_records_on_original_type_perfect_matching" 100 | t.index :project_id 101 | t.index :issue_id 102 | else 103 | t.index [:id] + columns, 104 | name: "index_searcher_records_pgroonga", 105 | using: "PGroonga" 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /db/migrate/20190603061606_create_fts_targets.rb: -------------------------------------------------------------------------------- 1 | # For auto load 2 | FullTextSearch::Migration 3 | 4 | class CreateFtsTargets < ActiveRecord::Migration[5.2] 5 | def comparable_version(version) 6 | version.split(".").collect {|component| Integer(component, 10)} 7 | end 8 | 9 | def change 10 | return if reverting? and !table_exists?(:fts_targets) 11 | 12 | if Redmine::Database.mysql? 13 | mroonga_version = connection.select_rows(<<-SQL)[0][1] 14 | SHOW VARIABLES LIKE 'mroonga_version'; 15 | SQL 16 | required_mroonga_version = "9.03" 17 | if (comparable_version(mroonga_version) <=> 18 | comparable_version(required_mroonga_version)) < 0 19 | message = "Mroonga #{required_mroonga_version} or later is required: " + 20 | mroonga_version 21 | raise message 22 | end 23 | options = "ENGINE=Mroonga DEFAULT CHARSET=utf8mb4" 24 | else 25 | options = nil 26 | end 27 | create_table :fts_targets, options: options do |t| 28 | t.integer :source_id, null: false 29 | t.integer :source_type_id, null: false 30 | t.integer :project_id, null: false 31 | t.integer :container_id 32 | t.integer :container_type_id 33 | t.integer :custom_field_id 34 | t.boolean :is_private 35 | t.timestamp :last_modified_at 36 | t.text :title 37 | if Redmine::Database.mysql? 38 | t.text :content, 39 | limit: 16.megabytes, 40 | comment: "FLAGS 'COLUMN_SCALAR|COMPRESS_ZSTD'" 41 | else 42 | t.text :content 43 | end 44 | if Redmine::Database.mysql? 45 | t.text :tag_ids, 46 | comment: "FLAGS 'COLUMN_VECTOR', GROONGA_TYPE 'Int64'" 47 | else 48 | t.integer :tag_ids, array: true 49 | end 50 | t.index [:source_id, :source_type_id], unique: true 51 | if Redmine::Database.mysql? 52 | t.index :project_id 53 | t.index :container_id 54 | t.index :container_type_id 55 | t.index :custom_field_id 56 | t.index :is_private 57 | t.index :last_modified_at 58 | t.index :title, 59 | type: "fulltext", 60 | comment: "NORMALIZER 'NormalizerNFKC121'" 61 | t.index :content, 62 | type: "fulltext", 63 | comment: "NORMALIZER 'NormalizerNFKC121', INDEX_FLAGS 'WITH_POSITION|INDEX_LARGE'" 64 | t.index :tag_ids, 65 | type: "fulltext", 66 | comment: "LEXICON 'fts_tags', INDEX_FLAGS ''" 67 | t.index :source_type_id 68 | else 69 | t.index [:id, 70 | :source_id, 71 | :source_type_id, 72 | :project_id, 73 | :container_id, 74 | :container_type_id, 75 | :custom_field_id, 76 | :is_private, 77 | :last_modified_at, 78 | :title, 79 | :content, 80 | :tag_ids], 81 | using: "PGroonga", 82 | with: "normalizer = 'NormalizerNFKC121'", 83 | name: "fts_targets_index_pgroonga" 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/full_text_search/similar_words_filter.rb: -------------------------------------------------------------------------------- 1 | module FullTextSearch 2 | class SimilarWordsFilter 3 | attr_accessor :cosine_threshold 4 | attr_accessor :engine 5 | attr_accessor :sentence_piece_space 6 | def initialize 7 | @cosine_threshold = 0.99 8 | @engine = nil 9 | @sentence_piece_space = "▁" 10 | end 11 | 12 | def run(records) 13 | expansions = {} 14 | records.each do |record| 15 | source = normalize_word(record["source"]) 16 | destination = normalize_word(record["destination"]) 17 | next unless target?(source, destination, record) 18 | (expansions[source] ||= []) << destination 19 | (expansions[destination] ||= []) << source 20 | end 21 | generate_records(expansions) 22 | end 23 | 24 | private 25 | def normalize_word(word) 26 | word. 27 | gsub(@sentence_piece_space, " "). 28 | unicode_normalize(:nfkc). 29 | downcase. 30 | strip 31 | end 32 | 33 | def ignore_character_only?(word) 34 | /\A[\p{Number}\p{Punctuation}\p{Symbol}]*\z/.match?(word) 35 | end 36 | 37 | def multibyte_word?(word) 38 | not word.ascii_only? 39 | end 40 | 41 | def sub_word?(word1, word2) 42 | word1.include?(word2) or word2.include?(word1) 43 | end 44 | 45 | JAPANESE_PARTICLES = [ 46 | "が", 47 | "で", 48 | "と", 49 | "に", 50 | "の", 51 | "は", 52 | "も", 53 | "を", 54 | ] 55 | def with_japanese_particle?(word) 56 | JAPANESE_PARTICLES.any? do |particle| 57 | word.start_with?(particle) or word.end_with?(particle) 58 | end 59 | end 60 | 61 | def target?(source, destination, record) 62 | return false if source.include?(" ") 63 | return false if destination.include?(" ") 64 | return false if source.size == 1 65 | return false if destination.size == 1 66 | return false if ignore_character_only?(source) 67 | return false if ignore_character_only?(destination) 68 | if multibyte_word?(source) or multibyte_word?(destination) 69 | return false if sub_word?(source, destination) 70 | end 71 | 72 | # TODO: Very heuristic. Remove me. 73 | return false if with_japanese_particle?(source) 74 | return false if with_japanese_particle?(destination) 75 | 76 | cosine = record["cosine"] 77 | if cosine and cosine < @cosine_threshold 78 | return false 79 | end 80 | 81 | if @engine and record["engine"] != @engine 82 | return false 83 | end 84 | 85 | true 86 | end 87 | 88 | def generate_records(expansions) 89 | records = [] 90 | expansions.keys.sort.each do |source| 91 | destinations = expansions[source] 92 | destinations = ([source] + destinations).uniq.sort 93 | next if destinations.size == 1 94 | destinations.each do |destination| 95 | record = { 96 | "source" => source, 97 | "destination" => destination, 98 | } 99 | records << record 100 | end 101 | end 102 | records 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /test/system/full_text_search/search_test.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require File.expand_path("../../../application_system_test_case", __FILE__) 3 | rescue => error 4 | if error.class.name == "Webdrivers::VersionError" 5 | puts("Webdrivers < 5.3.0 doesn't work. " + 6 | "See also: https://github.com/titusfortner/webdrivers/pull/251") 7 | puts("#{error.class}: #{error}") 8 | return 9 | else 10 | raise 11 | end 12 | end 13 | 14 | module FullTextSearch 15 | class SearchTest < ApplicationSystemTestCase 16 | include PrettyInspectable 17 | 18 | fixtures :attachments 19 | fixtures :boards 20 | fixtures :changesets 21 | fixtures :custom_fields 22 | fixtures :custom_fields_projects 23 | fixtures :custom_fields_trackers 24 | fixtures :custom_values 25 | fixtures :documents 26 | fixtures :enabled_modules 27 | fixtures :enumerations 28 | fixtures :issue_statuses 29 | fixtures :issues 30 | fixtures :journals 31 | fixtures :member_roles 32 | fixtures :messages 33 | fixtures :news 34 | fixtures :projects 35 | fixtures :projects_trackers 36 | fixtures :repositories 37 | fixtures :roles 38 | fixtures :trackers 39 | fixtures :users 40 | fixtures :wiki_contents 41 | fixtures :wiki_pages 42 | fixtures :wikis 43 | 44 | setup do 45 | if Object.const_defined?(:Webdrivers) 46 | if Gem::Version.new(Webdrivers::VERSION) < Gem::Version.new("5.3.0") 47 | skip("Webdrivers < 5.3.0 doesn't work. " + 48 | "See also: https://github.com/titusfortner/webdrivers/pull/251") 49 | end 50 | end 51 | 52 | Target.destroy_all 53 | batch_runner = BatchRunner.new(show_progress: false) 54 | batch_runner.synchronize 55 | log_user("jsmith", "jsmith") 56 | end 57 | 58 | def test_keep_search_target 59 | visit(search_url) 60 | click_on("search-target-wiki-pages") 61 | fill_in("search-input", with: "cookbook") 62 | click_on("search-submit") 63 | within("#search-result") do 64 | within("#search-result-content") do 65 | within("#search-source-types") do 66 | find_link("search-target-wiki-pages", 67 | class: "selected") 68 | end 69 | end 70 | end 71 | end 72 | 73 | def test_no_pagination 74 | subproject1 = Project.find("subproject1") 75 | visit(url_for(controller: "search", 76 | action: "index", 77 | id: subproject1.identifier)) 78 | click_on("search-target-issues") 79 | within("#search-results") do 80 | assert_equal(2, all("li").size) 81 | end 82 | within(".pagination") do 83 | assert_equal([], all("li").to_a) 84 | end 85 | end 86 | 87 | def test_pagination 88 | visit(search_url) 89 | click_on("search-target-issues") 90 | within("#search-results") do 91 | assert_equal(10, all("li").size) 92 | end 93 | within(".pagination") do 94 | assert_equal("1", find(".current").text) 95 | find(".next a").click 96 | end 97 | within("#search-results") do 98 | assert_equal(10, all("li").size) 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /db/migrate/20250225000050_remove_contents_from_issue_contents.rb: -------------------------------------------------------------------------------- 1 | # For auto load 2 | FullTextSearch::Migration 3 | 4 | class RemoveContentsFromIssueContents < ActiveRecord::Migration[6.1] 5 | if Redmine::Database.mysql? 6 | include FullTextSearch::Mroonga 7 | else 8 | include FullTextSearch::Pgroonga 9 | end 10 | 11 | def up 12 | return unless table_exists?(:issue_contents) 13 | 14 | if Redmine::Database.mysql? 15 | remove_index :issue_contents, :contents 16 | else 17 | remove_index :issue_contents, name: "index_issue_contents_pgroonga" 18 | end 19 | remove_column :issue_contents, :contents, :text 20 | remove_column :issue_contents, :is_private, :boolean 21 | content_limit = Redmine::Database.mysql? ? 16.megabytes : nil 22 | add_column :issue_contents, :content, :text, limit: content_limit 23 | 24 | # TODO: Replace 'TokenMecab' with a multilingual morphological based tokenizer 25 | # when available. See also: groonga/groonga#1941. 26 | if Redmine::Database.mysql? 27 | add_index :issue_contents, 28 | :content, 29 | type: "fulltext", 30 | comment: "TOKENIZER 'TokenMecab'" 31 | else 32 | add_index :issue_contents, 33 | [:id, 34 | :project_id, 35 | :issue_id, 36 | :subject, 37 | :content, 38 | :status_id], 39 | name: "index_issue_contents_pgroonga", 40 | using: "PGroonga", 41 | with: [ 42 | "tokenizer = 'TokenMecab'", 43 | "normalizer = '#{normalizer}'", 44 | ].join(", ") 45 | end 46 | end 47 | 48 | def down 49 | return unless table_exists?(:issue_contents) 50 | 51 | if Redmine::Database.mysql? 52 | remove_index :issue_contents, :content 53 | else 54 | remove_index :issue_contents, name: "index_issue_contents_pgroonga" 55 | end 56 | remove_column :issue_contents, :content, :text 57 | contents_limit = Redmine::Database.mysql? ? 16.megabytes : nil 58 | add_column :issue_contents, :contents, :text, limit: contents_limit 59 | add_column :issue_contents, :is_private, :boolean 60 | if Redmine::Database.mysql? 61 | add_index :issue_contents, 62 | :contents, 63 | type: "fulltext", 64 | comment: "TOKENIZER 'TokenMecab'" 65 | else 66 | add_index :issue_contents, 67 | [:id, 68 | :project_id, 69 | :issue_id, 70 | :subject, 71 | :contents, 72 | :status_id, 73 | :is_private], 74 | name: "index_issue_contents_pgroonga", 75 | using: "PGroonga", 76 | with: [ 77 | "tokenizer = 'TokenMecab'", 78 | "normalizer = '#{normalizer}'", 79 | ].join(", ") 80 | end 81 | end 82 | 83 | private 84 | 85 | def normalizer 86 | version = Gem::Version.new(self.class.groonga_version) 87 | if version >= Gem::Version.new("14.1.3") 88 | "NormalizerNFKC" 89 | elsif version >= Gem::Version.new("13.0.0") 90 | "NormalizerNFKC150" 91 | elsif version >= Gem::Version.new("10.0.9") 92 | "NormalizerNFKC130" 93 | else 94 | "NormalizerNFKC121" 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /db/migrate/20190603054615_drop_searcher_records.rb: -------------------------------------------------------------------------------- 1 | class DropSearcherRecords < ActiveRecord::Migration[5.2] 2 | def change 3 | reversible do |d| 4 | d.up do 5 | drop_table :searcher_records 6 | end 7 | d.down do 8 | create_table :searcher_records do |t| 9 | if Redmine::Database.mysql? 10 | t.integer :project_id, null: false 11 | t.string :project_name, null: false # for searching by project_name 12 | t.integer :original_id, null: false 13 | t.string :original_type, null: false 14 | t.timestamp :original_created_on 15 | t.timestamp :original_updated_on 16 | 17 | # projects 18 | t.string :name 19 | t.text :description 20 | t.string :identifier 21 | t.integer :status 22 | 23 | # news 24 | t.string :title 25 | t.string :summary 26 | # t.text :description 27 | 28 | # issues 29 | t.string :subject 30 | # t.text :description 31 | t.integer :author_id 32 | t.boolean :is_private 33 | t.integer :status_id 34 | t.integer :tracker_id 35 | t.integer :issue_id 36 | 37 | # documents 38 | # t.string :title 39 | # t.text :description 40 | 41 | # changesets 42 | t.text :comments 43 | t.text :short_comments 44 | t.text :long_comments 45 | 46 | # messages 47 | # t.string :subject 48 | t.text :content 49 | 50 | # journals 51 | t.text :notes 52 | # t.integer :user_id # => author_id 53 | t.boolean :private_notes 54 | # t.integer :status_id 55 | 56 | # wiki_pages 57 | # t.string :title 58 | t.text :text # wiki_contents.text w/ latest version 59 | 60 | # custom_value 61 | t.text :value 62 | t.integer :custom_field_id 63 | 64 | # attachments 65 | t.integer :container_id 66 | t.string :container_type 67 | t.string :filename 68 | # t.text :description 69 | 70 | t.index :original_type, type: "fulltext" 71 | t.index :project_name, type: "fulltext" 72 | t.index :name, type: "fulltext" 73 | t.index :identifier, type: "fulltext" 74 | t.index :description, type: "fulltext" 75 | t.index :title, type: "fulltext" 76 | t.index :summary, type: "fulltext" 77 | t.index :subject, type: "fulltext" 78 | t.index :comments, type: "fulltext" 79 | t.index :content, type: "fulltext" 80 | t.index :notes, type: "fulltext" 81 | t.index :text, type: "fulltext" 82 | t.index :value, type: "fulltext" 83 | t.index :container_type, type: "fulltext" 84 | t.index :filename, type: "fulltext" 85 | t.index :original_type, name: "index_searcher_records_on_original_type_perfect_matching" 86 | t.index :project_id 87 | t.index :issue_id 88 | t.index :short_comments, type: "fulltext" 89 | t.index :long_comments, type: "fulltext" 90 | elsif Redmine::Database.postgresql? 91 | t.index :id, name: "index_searcher_records_pgroonga" 92 | end 93 | end 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/full_text_search/mroonga.rb: -------------------------------------------------------------------------------- 1 | module FullTextSearch 2 | module Mroonga 3 | extend ActiveSupport::Concern 4 | 5 | module ClassMethods 6 | def select(command) 7 | sql = build_sql(command) 8 | now = Time.zone.now.to_f 9 | raw_response = connection.select_value(sql) 10 | elapsed_time = Time.zone.now.to_f - now 11 | response_class = Groonga::Client::Response.find(command.command_name) 12 | header = [0, now, elapsed_time] 13 | body = JSON.parse(raw_response) 14 | response = response_class.new(command, header, body) 15 | response.raw = "[#{header.to_json}, #{raw_response}]" 16 | response 17 | end 18 | 19 | def full_text_search(column, query) 20 | where("MATCH (#{connection.quote_column_name(column)}) " + 21 | "AGAINST (? IN BOOLEAN MODE)", 22 | query) 23 | end 24 | 25 | def build_expand_query_sql_part(query) 26 | [ 27 | "mroonga_query_expand(?, ?, ?, ?) AS query", 28 | [ 29 | table_name, 30 | source_column_name, 31 | destination_column_name, 32 | query, 33 | ], 34 | ] 35 | end 36 | 37 | def time_offset 38 | @time_offset ||= compute_time_offset 39 | end 40 | 41 | def mroonga_version 42 | connection.select_rows(<<-SQL)[0][1] 43 | SHOW VARIABLES LIKE 'mroonga_version'; 44 | SQL 45 | end 46 | 47 | def groonga_version 48 | connection.select_rows(<<-SQL)[0][1] 49 | SHOW VARIABLES LIKE 'mroonga_libgroonga_version'; 50 | SQL 51 | end 52 | 53 | def mroonga_vector_load_is_supported? 54 | Gem::Version.new(groonga_version) >= Gem::Version.new("9.0.5") 55 | end 56 | 57 | def multiple_column_unique_key_update_is_supported? 58 | Gem::Version.new(mroonga_version) >= Gem::Version.new("9.05") 59 | end 60 | 61 | private 62 | def build_sql(command) 63 | arguments = [command.command_name] 64 | placeholders = ["?"] 65 | command["table"] = table_name 66 | command.arguments.each do |name, value| 67 | next if value.blank? 68 | placeholders << "?" 69 | arguments << name 70 | if name == :query 71 | expand_query_sql_part = 72 | FtsQueryExpansion.build_expand_query_sql_part(value) 73 | placeholders << expand_query_sql_part[0] 74 | arguments.concat(expand_query_sql_part[1]) 75 | else 76 | placeholders << "?" 77 | arguments << value 78 | end 79 | end 80 | sql_template = <<-SELECT 81 | SELECT mroonga_command(#{placeholders.join(", ")}) 82 | SELECT 83 | sanitize_sql([sql_template, *arguments]) 84 | end 85 | 86 | def compute_time_offset 87 | mysql_system_time_zone = connection.select_rows(<<-SQL)[0][1] 88 | SHOW VARIABLES LIKE 'system_time_zone' 89 | SQL 90 | mysql_system_utc_offset = 0 91 | TZInfo::Timezone.all.each do |timezone| 92 | period = timezone.current_period 93 | if period.abbreviation.to_s == mysql_system_time_zone 94 | mysql_system_utc_offset = period.utc_offset 95 | break 96 | end 97 | end 98 | mysql_system_utc_offset - Time.now.utc_offset 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/full_text_search/pgroonga.rb: -------------------------------------------------------------------------------- 1 | module FullTextSearch 2 | module Pgroonga 3 | extend ActiveSupport::Concern 4 | 5 | module ClassMethods 6 | def select(command) 7 | sql = build_sql(command) 8 | raw_response = connection.select_value(sql) 9 | Groonga::Client::Response.parse(command, raw_response) 10 | end 11 | 12 | def full_text_search(column, query) 13 | where("#{connection.quote_column_name(column)} &@~ ?", 14 | query) 15 | end 16 | 17 | def build_expand_query_sql_part(query) 18 | [ 19 | "pgroonga_query_expand(?, ?, ?, ?)", 20 | [ 21 | table_name, 22 | source_column_name, 23 | destination_column_name, 24 | query, 25 | ], 26 | ] 27 | end 28 | 29 | def time_offset 30 | @time_offset ||= compute_time_offset 31 | end 32 | 33 | def groonga_version 34 | # Ensure loading PGroonga 35 | connection.select_rows(<<-SQL) 36 | SELECT pgroonga_command('status'); 37 | SQL 38 | connection.select_rows(<<-SQL)[0][0] 39 | SHOW pgroonga.libgroonga_version; 40 | SQL 41 | end 42 | 43 | def multiple_column_unique_key_update_is_supported? 44 | true 45 | end 46 | 47 | private 48 | def build_sql(command) 49 | arguments = [] 50 | placeholders = [] 51 | command["table"] = "pgroonga_table_name('#{pgroonga_index_name}')" 52 | if command["filter"].present? 53 | command["filter"] += " && pgroonga_tuple_is_alive(ctid)" 54 | else 55 | command["filter"] = "pgroonga_tuple_is_alive(ctid)" 56 | end 57 | command.arguments.each do |name, value| 58 | next if value.blank? 59 | next if name == :table 60 | placeholders << "?" 61 | arguments << name 62 | if name == :query 63 | expand_query_sql_part = 64 | FtsQueryExpansion.build_expand_query_sql_part(value) 65 | placeholders << expand_query_sql_part[0] 66 | arguments.concat(expand_query_sql_part[1]) 67 | else 68 | placeholders << "?" 69 | arguments << value 70 | end 71 | end 72 | sql_template = <<-SELECT 73 | SELECT pgroonga_command(?, 74 | ARRAY[ 75 | 'table', #{command["table"]}, 76 | #{placeholders.join(", ")} 77 | ] 78 | ) 79 | SELECT 80 | sanitize_sql([sql_template, 81 | command.command_name, 82 | *arguments]) 83 | end 84 | 85 | def compute_time_offset 86 | utc_offset = connection.select_value(<<-SQL) 87 | SELECT utc_offset 88 | FROM pg_timezone_names 89 | WHERE name = current_setting('timezone') 90 | SQL 91 | case utc_offset 92 | when /\A(-)?(\d+):(\d+):(\d+)\z/ 93 | minus = $1 94 | hours = Integer($2, 10) 95 | minutes = Integer($3, 10) 96 | seconds = Integer($4, 10) 97 | offset = (hours * 60 * 60) + (minutes * 60) + seconds 98 | offset = -offset if minus == "-" 99 | offset - Time.now.utc_offset 100 | when /\A[-+]?PT/ 101 | duration = ActiveSupport::Duration.parse(utc_offset) 102 | duration.in_seconds - Time.now.utc_offset 103 | else 104 | raise "Invalid time offset value: #{utc_offset.inspect}" 105 | end 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /db/migrate/20231210061330_add_registered_at_to_fts_targets_with_index.rb: -------------------------------------------------------------------------------- 1 | # For auto load 2 | FullTextSearch::Migration 3 | 4 | class AddRegisteredAtToFtsTargetsWithIndex < ActiveRecord::Migration[5.2] 5 | def up 6 | return if !table_exists?(:fts_targets) 7 | 8 | add_column :fts_targets, :registered_at, :timestamp 9 | 10 | ActiveRecord::Base.transaction do 11 | update_registered_at_using_last_modified_at 12 | update_registered_at_using_created_on 13 | end 14 | 15 | if Redmine::Database.mysql? 16 | add_index :fts_targets, :registered_at 17 | else 18 | remove_index :fts_targets, name: "fts_targets_index_pgroonga" 19 | add_index :fts_targets, 20 | [:id, 21 | :source_id, 22 | :source_type_id, 23 | :project_id, 24 | :container_id, 25 | :container_type_id, 26 | :custom_field_id, 27 | :is_private, 28 | :last_modified_at, 29 | :registered_at, 30 | :title, 31 | :content, 32 | :tag_ids], 33 | using: "PGroonga", 34 | with: "normalizer = 'NormalizerNFKC121'", 35 | name: "fts_targets_index_pgroonga" 36 | end 37 | end 38 | 39 | def down 40 | return if !table_exists?(:fts_targets) 41 | 42 | if Redmine::Database.mysql? 43 | remove_index :fts_targets, :registered_at 44 | else 45 | remove_index :fts_targets, name: "fts_targets_index_pgroonga" 46 | add_index :fts_targets, 47 | [:id, 48 | :source_id, 49 | :source_type_id, 50 | :project_id, 51 | :container_id, 52 | :container_type_id, 53 | :custom_field_id, 54 | :is_private, 55 | :last_modified_at, 56 | :title, 57 | :content, 58 | :tag_ids], 59 | using: "PGroonga", 60 | with: "normalizer = 'NormalizerNFKC121'", 61 | name: "fts_targets_index_pgroonga" 62 | end 63 | 64 | remove_column :fts_targets, :registered_at 65 | end 66 | 67 | private 68 | 69 | class FtsTarget < ActiveRecord::Base 70 | end 71 | 72 | class FtsType < ActiveRecord::Base 73 | end 74 | 75 | class Issue < ActiveRecord::Base 76 | end 77 | 78 | class Message < ActiveRecord::Base 79 | end 80 | 81 | class Project < ActiveRecord::Base 82 | end 83 | 84 | class WikiPage < ActiveRecord::Base 85 | end 86 | 87 | def update_registered_at_using_last_modified_at 88 | type_names = ['Attachment', 'Change', 'Changeset', 'CustomValue', 'Document', 'Journal', 'News'] 89 | 90 | FtsTarget.where(source_type_id: FtsType.where(name: type_names).select(:id)) 91 | .update_all(registered_at: FtsTarget.arel_table[:last_modified_at]) 92 | end 93 | 94 | def update_registered_at_using_created_on 95 | types = [Issue, Message, Project, WikiPage] 96 | types.each do |type| 97 | subquery_for_created_on = type.where(type.arel_table[:id].eq(FtsTarget.arel_table[:source_id])) 98 | .select(:created_on) 99 | .to_sql 100 | FtsTarget.where(source_type_id: FtsType.where(name: type.name.demodulize).select(:id)) 101 | .update_all(registered_at: Arel.sql("(#{subquery_for_created_on})")) 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /test/unit/full_text_search/issue_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | module FullTextSearch 4 | class IssueTest < ActiveSupport::TestCase 5 | include PrettyInspectable 6 | include NullValues 7 | 8 | fixtures :custom_fields 9 | fixtures :custom_fields_projects 10 | fixtures :custom_fields_trackers 11 | fixtures :custom_values 12 | fixtures :enumerations 13 | fixtures :issue_statuses 14 | fixtures :projects 15 | fixtures :projects_trackers 16 | fixtures :trackers 17 | fixtures :users 18 | 19 | def test_save 20 | issue = Issue.generate! 21 | issue.reload 22 | targets = Target.where(source_id: issue.id, 23 | source_type_id: Type.issue.id) 24 | assert_equal([ 25 | { 26 | "project_id" => issue.project_id, 27 | "source_id" => issue.id, 28 | "source_type_id" => Type.issue.id, 29 | "last_modified_at" => issue.updated_on, 30 | "registered_at" => issue.created_on, 31 | "title" => issue.subject, 32 | "content" => issue.description || "", 33 | "tag_ids" => [ 34 | Tag.tracker(issue.tracker_id).id, 35 | Tag.user(issue.author_id).id, 36 | Tag.issue_status(issue.status_id).id, 37 | ], 38 | "is_private" => issue.is_private, 39 | "custom_field_id" => null_number, 40 | "container_id" => null_number, 41 | "container_type_id" => null_number, 42 | } 43 | ], 44 | targets.collect {|target| target.attributes.except("id")}) 45 | end 46 | 47 | def test_save_journal_status_and_tracker 48 | issue = Issue.generate!( 49 | status: IssueStatus.find_by_name("New"), 50 | tracker: Tracker.find_by_name("Bug") 51 | ) 52 | journal = issue.journals.create!(notes: "comment") 53 | issue.status = IssueStatus.find_by_name("Closed") 54 | issue.tracker = Tracker.find_by_name("Support request") 55 | issue.save! 56 | 57 | journal_targets = Target.where(source_id: journal.id, 58 | source_type_id: Type.journal.id) 59 | assert_equal([ 60 | [ 61 | Tag.user(journal.user_id), 62 | Tag.tracker(issue.tracker_id), 63 | Tag.issue_status(issue.status_id), 64 | ].sort_by(&:id), 65 | ], 66 | journal_targets.collect {|target| target.tags.sort_by(&:id)}) 67 | end 68 | 69 | def test_destroy 70 | searchable_custom_field = custom_fields(:custom_fields_002) 71 | issue = Issue.generate! do |i| 72 | i.custom_fields = [ 73 | { 74 | "id" => searchable_custom_field.id.to_s, 75 | "value" => "Hello", 76 | }, 77 | ] 78 | end 79 | issue_targets = Target.where(source_id: issue.id, 80 | source_type_id: Type.issue.id) 81 | custom_value_targets = Target.where(container_id: issue.id, 82 | source_type_id: Type.custom_value.id) 83 | assert_equal([1, 1], 84 | [issue_targets.size, custom_value_targets.size]) 85 | issue.destroy! 86 | assert_equal([[], []], 87 | [issue_targets.to_a, custom_value_targets.to_a]) 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /test/unit/full_text_search/change_git_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../../test_helper", __FILE__) 2 | 3 | module FullTextSearch 4 | class ChangeGitTest < ActiveSupport::TestCase 5 | include PrettyInspectable 6 | include NullValues 7 | include TimeValue 8 | 9 | fixtures :enabled_modules 10 | fixtures :projects 11 | fixtures :repositories 12 | fixtures :users 13 | fixtures :roles 14 | 15 | def setup 16 | unless Target.multiple_column_unique_key_update_is_supported? 17 | skip("Need Mroonga 9.05 or later") 18 | end 19 | @project = Project.find(3) 20 | end 21 | 22 | def test_fetch_changesets 23 | url = self.class.repository_path("git") 24 | repository = Repository::Git.create(:project => @project, 25 | :url => url) 26 | repository.fetch_changesets 27 | records = Target. 28 | where(container_id: repository.id, 29 | container_type_id: Type.repository.id). 30 | order(source_id: :asc) 31 | first_change = Change.find_by!(path: "images/edit.png") 32 | last_change = Change.where(path: "issue-8857/test01.txt").last 33 | expected_titles = [ 34 | "images/edit.png", 35 | "copied_README", 36 | "renamed_test.txt", 37 | "sources/watchers_controller.rb", 38 | "this_is_a_really_long_and_verbose_directory_name/this_is_because_of_a_simple_reason/it_is_testing_the_ability_of_redmine_to_use_really_long_path_names/These_names_exceed_255_chars_in_total/That_is_the_single_reason_why_we_have_this_directory_here/But_there_might_also_be_additonal_reasons/And_then_there_is_not_even_somthing_funny_in_here.txt", 39 | "filemane with spaces.txt", 40 | " filename with a leading space.txt ", 41 | "new_file.txt", 42 | "latin-1/test00.txt", 43 | "README", 44 | "latin-1-dir/make-latin-1-file.rb", 45 | "issue-8857/test00.txt", 46 | "issue-8857/test01.txt", 47 | ] 48 | expected_first_change = { 49 | "project_id" => @project.id, 50 | "source_id" => first_change.id, 51 | "source_type_id" => Type.change.id, 52 | "last_modified_at" => parse_time("2007-12-14T09:24:01Z"), 53 | "registered_at" => parse_time("2007-12-14T09:24:01Z"), 54 | "container_id" => repository.id, 55 | "container_type_id" => Type.repository.id, 56 | "title" => "images/edit.png", 57 | "content" => "", 58 | "custom_field_id" => null_number, 59 | "is_private" => null_boolean, 60 | "tag_ids" => [Tag.extension("png").id], 61 | } 62 | expected_last_change = { 63 | "project_id" => @project.id, 64 | "source_id" => last_change.id, 65 | "source_type_id" => Type.change.id, 66 | "last_modified_at" => parse_time("2011-01-01T03:00:00Z"), 67 | "registered_at" => parse_time("2011-01-01T03:00:00Z"), 68 | "container_id" => repository.id, 69 | "container_type_id" => Type.repository.id, 70 | "custom_field_id" => null_number, 71 | "title" => "issue-8857/test01.txt", 72 | "content" => <<-CONTENT, 73 | test 74 | test 75 | CONTENT 76 | "is_private" => null_boolean, 77 | "tag_ids" => [Tag.extension("txt").id], 78 | } 79 | assert_equal([ 80 | expected_titles, 81 | expected_first_change, 82 | expected_last_change, 83 | ], 84 | [ 85 | records.collect(&:title), 86 | records.first.attributes.except("id"), 87 | records.last.attributes.except("id"), 88 | ]) 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /app/views/fts_query_expansions/index.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :header_tags do %> 2 | <%= javascript_include_tag "query_expansions", plugin: "full_text_search" %> 3 | <%= stylesheet_link_tag "query_expansions", plugin: "full_text_search" %> 4 | <% fontawesome_prefix = "fontawesome-free-5.8.2-web" %> 5 | <%= stylesheet_link_tag "#{fontawesome_prefix}/css/all.css", 6 | plugin: "full_text_search" %> 7 | <% end %> 8 | 9 |<%= l(:label_no_data) %>
66 | <% else %> 67 || 83 | | ||||||
|---|---|---|---|---|---|---|
| <%= expansion.source %> | 89 |<%= expansion.destination %> | 90 |<%= format_time(expansion.created_at) %> | 91 |<%= format_time(expansion.updated_at) %> | 92 |<%= link_to(l(:button_view), expansion) %> | 93 |<%= link_to(l(:button_edit), 94 | edit_fts_query_expansion_path(expansion), 95 | class: "icon icon-edit") %> | 96 |<%= delete_link(expansion) %> | 97 |