├── lib ├── workers.rb ├── redmine_elasticsearch │ ├── refinements │ │ └── string.rb │ ├── patches │ │ ├── response_results_patch.rb │ │ ├── redmine_search_patch.rb │ │ └── search_controller_patch.rb │ ├── search_result.rb │ ├── serializer_service.rb │ └── indexer_service.rb ├── tasks │ ├── test.rake │ └── index.rake ├── redmine_elasticsearch.rb └── workers │ └── indexer.rb ├── doc ├── elasticsearch_1.PNG └── elasticsearch_1.png ├── config ├── routes.rb ├── database-postgresql-travis.yml └── locales │ ├── en.yml │ └── ru.yml ├── app ├── serializers │ ├── journal_serializer.rb │ ├── changeset_serializer.rb │ ├── document_serializer.rb │ ├── parent_project_serializer.rb │ ├── contact_serializer.rb │ ├── news_serializer.rb │ ├── wiki_page_serializer.rb │ ├── message_serializer.rb │ ├── project_serializer.rb │ ├── kb_article_serializer.rb │ ├── base_serializer.rb │ ├── issue_serializer.rb │ └── attachment_serializer.rb ├── controllers │ └── elasticsearch_controller.rb ├── elastic │ ├── news_search.rb │ ├── contact_search.rb │ ├── message_search.rb │ ├── document_search.rb │ ├── kb_article_search.rb │ ├── changeset_search.rb │ ├── wiki_page_search.rb │ ├── project_search.rb │ ├── issue_search.rb │ └── application_search.rb ├── views │ └── elasticsearch │ │ ├── _available_fields_toc.html.erb │ │ ├── _quick_reference_toc.html.erb │ │ ├── search_syntax.html.erb │ │ ├── _available_fields.html.erb │ │ ├── _quick_reference.ru.html.erb │ │ └── _quick_reference.html.erb └── models │ └── parent_project.rb ├── Gemfile ├── test ├── test_helper.rb ├── unit │ └── redmine_elasticsearch │ │ └── serializer_service_test.rb └── integration │ └── redmine_elasticsearch │ ├── indexer_service_test.rb │ ├── search_test.rb │ └── api_search_test.rb ├── .travis.yml ├── travis.sh ├── init.rb ├── README.md └── LICENSE /lib/workers.rb: -------------------------------------------------------------------------------- 1 | module Workers 2 | end 3 | -------------------------------------------------------------------------------- /doc/elasticsearch_1.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/southbridgeio/redmine_elasticsearch/HEAD/doc/elasticsearch_1.PNG -------------------------------------------------------------------------------- /doc/elasticsearch_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/southbridgeio/redmine_elasticsearch/HEAD/doc/elasticsearch_1.png -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | RedmineApp::Application.routes.draw do 2 | get 'help/search_syntax', to: 'elasticsearch#search_syntax' 3 | end 4 | -------------------------------------------------------------------------------- /app/serializers/journal_serializer.rb: -------------------------------------------------------------------------------- 1 | class JournalSerializer < ActiveModel::Serializer 2 | self.root = false 3 | 4 | attributes :id, :notes 5 | end 6 | -------------------------------------------------------------------------------- /app/controllers/elasticsearch_controller.rb: -------------------------------------------------------------------------------- 1 | class ElasticsearchController < ApplicationController 2 | layout false 3 | 4 | def search_syntax 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /config/database-postgresql-travis.yml: -------------------------------------------------------------------------------- 1 | # http://about.travis-ci.org/docs/user/database-setup/#PostgreSQL 2 | test: 3 | adapter: postgresql 4 | database: redmine 5 | username: postgres 6 | -------------------------------------------------------------------------------- /app/serializers/changeset_serializer.rb: -------------------------------------------------------------------------------- 1 | class ChangesetSerializer < BaseSerializer 2 | attributes :project_id, :revision, :committer, :committed_on, :comments 3 | 4 | def project_id 5 | object.repository.try(:project_id) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | gem 'elasticsearch-model', '~> 5.0' 2 | gem 'active_model_serializers', '~> 0.7.0' 3 | gem 'kaminari' 4 | gem 'ansi' 5 | gem 'faraday-patron' 6 | 7 | group :development do 8 | gem 'pry-byebug' 9 | end 10 | 11 | group :test do 12 | gem 'elasticsearch-extensions' 13 | end 14 | -------------------------------------------------------------------------------- /app/serializers/document_serializer.rb: -------------------------------------------------------------------------------- 1 | class DocumentSerializer < BaseSerializer 2 | attributes :project_id, :title, :description, :created_on, :category 3 | 4 | has_many :attachments, serializer: AttachmentSerializer 5 | 6 | def category 7 | object.category.try(:name) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/serializers/parent_project_serializer.rb: -------------------------------------------------------------------------------- 1 | class ParentProjectSerializer < ActiveModel::Serializer 2 | self.root = false 3 | 4 | attributes :id, 5 | :is_public, 6 | :status_id, 7 | :enabled_module_names 8 | 9 | def status_id 10 | object.status 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/serializers/contact_serializer.rb: -------------------------------------------------------------------------------- 1 | class ContactSerializer < BaseSerializer 2 | attributes :project_id, 3 | :name, :company, :phone, 4 | :email, :created_on, :updated_on 5 | 6 | def project_id 7 | object.project.try(:id) 8 | end 9 | 10 | def _parent 11 | project_id 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/redmine_elasticsearch/refinements/string.rb: -------------------------------------------------------------------------------- 1 | module RedmineElasticsearch 2 | module Refinements::String 3 | ESCAPED_CHARS = %w[\\ /].freeze 4 | 5 | refine ::String do 6 | def sanitize 7 | dup.tap { |s| ESCAPED_CHARS.each { |char| s.gsub!(char, "\\#{char}") } } 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/serializers/news_serializer.rb: -------------------------------------------------------------------------------- 1 | class NewsSerializer < BaseSerializer 2 | attributes :project_id, 3 | :title, :summary, :description, 4 | :created_on, 5 | :author, 6 | :comments_count 7 | 8 | has_many :attachments, serializer: AttachmentSerializer 9 | 10 | def author 11 | object.author.try(:name) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/serializers/wiki_page_serializer.rb: -------------------------------------------------------------------------------- 1 | class WikiPageSerializer < BaseSerializer 2 | attributes :project_id, 3 | :title, :text, 4 | :created_on, :updated_on 5 | 6 | has_many :attachments, serializer: AttachmentSerializer 7 | 8 | def project_id 9 | object.wiki.try(:project_id) 10 | end 11 | 12 | def author 13 | nil 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/elastic/news_search.rb: -------------------------------------------------------------------------------- 1 | module NewsSearch 2 | extend ActiveSupport::Concern 3 | 4 | module ClassMethods 5 | 6 | def allowed_to_search_query(user, options = {}) 7 | options = options.merge( 8 | permission: :view_news, 9 | type: 'news' 10 | ) 11 | ParentProject.allowed_to_search_query(user, options) 12 | end 13 | 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $VERBOSE = nil 2 | 3 | require File.expand_path(File.dirname(__FILE__) + '../../../../test/test_helper') 4 | 5 | # Perform all operations without Resque while testing 6 | require 'workers/indexer' 7 | module Workers 8 | class Indexer 9 | class << self 10 | def perform_async(options) 11 | new.perform(options) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/elastic/contact_search.rb: -------------------------------------------------------------------------------- 1 | module ContactSearch 2 | extend ActiveSupport::Concern 3 | 4 | module ClassMethods 5 | def allowed_to_search_query(user, options = {}) 6 | options = options.merge( 7 | permission: :view_contacts, 8 | type: 'contact' 9 | ) 10 | 11 | ParentProject.allowed_to_search_query(user, options) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/elastic/message_search.rb: -------------------------------------------------------------------------------- 1 | module MessageSearch 2 | extend ActiveSupport::Concern 3 | 4 | module ClassMethods 5 | 6 | def allowed_to_search_query(user, options = {}) 7 | options = options.merge( 8 | permission: :view_messages, 9 | type: 'message' 10 | ) 11 | ParentProject.allowed_to_search_query(user, options) 12 | end 13 | 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/elastic/document_search.rb: -------------------------------------------------------------------------------- 1 | module DocumentSearch 2 | extend ActiveSupport::Concern 3 | 4 | module ClassMethods 5 | 6 | def allowed_to_search_query(user, options = {}) 7 | options = options.merge( 8 | permission: :view_documents, 9 | type: 'document' 10 | ) 11 | ParentProject.allowed_to_search_query(user, options) 12 | end 13 | 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/elastic/kb_article_search.rb: -------------------------------------------------------------------------------- 1 | module KbArticleSearch 2 | extend ActiveSupport::Concern 3 | 4 | module ClassMethods 5 | def allowed_to_search_query(user, options = {}) 6 | options = options.merge( 7 | permission: :view_kb_articles, 8 | type: 'kb_article' 9 | ) 10 | 11 | ParentProject.allowed_to_search_query(user, options) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/elastic/changeset_search.rb: -------------------------------------------------------------------------------- 1 | module ChangesetSearch 2 | extend ActiveSupport::Concern 3 | 4 | module ClassMethods 5 | 6 | def allowed_to_search_query(user, options = {}) 7 | options = options.merge( 8 | permission: :view_changesets, 9 | type: 'changeset' 10 | ) 11 | ParentProject.allowed_to_search_query(user, options) 12 | end 13 | 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/redmine_elasticsearch/patches/response_results_patch.rb: -------------------------------------------------------------------------------- 1 | # Patch for customize Elasticsearch::Model::Response::Results 2 | module RedmineElasticsearch 3 | module Patches 4 | module ResponseResultsPatch 5 | 6 | # Returns the customized {Results} collection 7 | def results 8 | response.response['hits']['hits'].map { |hit| ::RedmineElasticsearch::SearchResult.new(hit) } 9 | end 10 | 11 | end 12 | end 13 | end -------------------------------------------------------------------------------- /app/serializers/message_serializer.rb: -------------------------------------------------------------------------------- 1 | class MessageSerializer < BaseSerializer 2 | attributes :project_id, 3 | :subject, :content, 4 | :author, 5 | :created_on, 6 | :updated_on, 7 | :replies_count 8 | 9 | has_many :attachments, serializer: AttachmentSerializer 10 | 11 | def project_id 12 | object.board.try(:project_id) 13 | end 14 | 15 | def author 16 | object.author.try(:name) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/redmine_elasticsearch/search_result.rb: -------------------------------------------------------------------------------- 1 | module RedmineElasticsearch 2 | class SearchResult < Elasticsearch::Model::Response::Result 3 | 4 | def project 5 | @project ||= Project.find_by_id(project_id) 6 | end 7 | 8 | # Adding event attributes aliases 9 | %w(datetime title description author type url).each do |attr| 10 | src = <<-END_SRC 11 | def event_#{attr}(*args) 12 | #{attr} 13 | end 14 | END_SRC 15 | class_eval src, __FILE__, __LINE__ 16 | end 17 | 18 | end 19 | end -------------------------------------------------------------------------------- /app/elastic/wiki_page_search.rb: -------------------------------------------------------------------------------- 1 | module WikiPageSearch 2 | extend ActiveSupport::Concern 3 | 4 | module ClassMethods 5 | 6 | def allowed_to_search_query(user, options = {}) 7 | options = options.merge( 8 | permission: :view_wiki_pages, 9 | type: 'wiki_page' 10 | ) 11 | ParentProject.allowed_to_search_query(user, options) 12 | end 13 | 14 | def searching_scope 15 | return super unless Redmine::Plugin.installed?(:redmine_wiki_encryptor) 16 | super.where(not_index: false) 17 | end 18 | 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/elastic/project_search.rb: -------------------------------------------------------------------------------- 1 | module ProjectSearch 2 | extend ActiveSupport::Concern 3 | 4 | def async_update_index 5 | project = ParentProject.find_by(id: id) 6 | Workers::Indexer.defer(project) if project 7 | Workers::Indexer.defer(self) 8 | end 9 | 10 | module ClassMethods 11 | 12 | def allowed_to_search_query(user, options = {}) 13 | options = options.merge( 14 | permission: :view_project, 15 | type: 'project' 16 | ) 17 | ParentProject.allowed_to_search_query(user, options) 18 | end 19 | 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/serializers/project_serializer.rb: -------------------------------------------------------------------------------- 1 | class ProjectSerializer < BaseSerializer 2 | attributes :name, :description, 3 | :homepage, :identifier, 4 | :created_on, 5 | :updated_on, 6 | :is_public, 7 | :custom_field_values, 8 | :project_id 9 | 10 | has_many :attachments, serializer: AttachmentSerializer 11 | 12 | def custom_field_values 13 | fields = object.custom_field_values.find_all { |cfv| cfv.custom_field.searchable? } 14 | fields.map(&:to_s) 15 | end 16 | 17 | def _parent 18 | object.id 19 | end 20 | 21 | def project_id 22 | object.id 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/serializers/kb_article_serializer.rb: -------------------------------------------------------------------------------- 1 | class KbArticleSerializer < BaseSerializer 2 | attributes :project_id, 3 | :title, :summary, :content, 4 | :created_on, :updated_on, 5 | :tag, :category 6 | 7 | has_many :attachments, serializer: AttachmentSerializer 8 | 9 | def _parent 10 | object.try(:project_id) 11 | end 12 | 13 | def tag 14 | object.tags.last.try(:name) 15 | end 16 | 17 | def category 18 | object.category.try(:title) 19 | end 20 | 21 | def created_on 22 | object.try(:created_at) 23 | end 24 | 25 | def updated_on 26 | object.try(:updated_at) 27 | end 28 | end -------------------------------------------------------------------------------- /app/serializers/base_serializer.rb: -------------------------------------------------------------------------------- 1 | class BaseSerializer < ActiveModel::Serializer 2 | self.root = false 3 | 4 | attributes :id, :_parent, :datetime, :title, :description, :author, :url, :type 5 | 6 | def _parent 7 | project_id if respond_to? :project_id 8 | end 9 | 10 | %w(datetime title description).each do |attr| 11 | class_eval "def #{attr}() object.event_#{attr} end" 12 | end 13 | 14 | def type 15 | object.class.document_type 16 | end 17 | 18 | def author 19 | object.event_author && object.event_author.to_s 20 | end 21 | 22 | def url 23 | url_for object.event_url(default_url_options) 24 | rescue 25 | nil 26 | end 27 | 28 | def default_url_options 29 | { host: Setting.host_name, protocol: Setting.protocol } 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/views/elasticsearch/_available_fields_toc.html.erb: -------------------------------------------------------------------------------- 1 |

<%= l('es.available_fields') %>

2 | 14 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | search_request_failed: Search request to full text search engine failed. 3 | search_connection_refused: Connection to full text search engine refused. 4 | 5 | label_attachment_content: Attachment content 6 | label_search_quick_reference: Search Quick Reference 7 | label_how_to_search: How to search 8 | 9 | field_digest: MD5 digest 10 | 11 | es: 12 | word: Word 13 | phrase: Phrase 14 | many_words: Many words 15 | wildcards: Wildcards 16 | field: Search by field 17 | regular_expression: Regular expression 18 | fuzziness: Fuzziness 19 | proximity_searches: Proximity searches 20 | ranges: Ranges 21 | boosting: Boosting 22 | boolean_operators: Boolean operators 23 | grouping: Grouping 24 | reserved_characters: Reserved characters 25 | empty_query: Empty query 26 | available_fields: Available search fields 27 | attachments: Search by attachments 28 | 29 | views: 30 | pagination: 31 | first: "« First" 32 | last: "Last »" 33 | previous: "‹ Prev" 34 | next: "Next ›" 35 | truncate: "..." 36 | -------------------------------------------------------------------------------- /config/locales/ru.yml: -------------------------------------------------------------------------------- 1 | ru: 2 | search_request_failed: Поисковый запрос к сервису полнотекстового поиска не выполнен. 3 | search_connection_refused: Не удается подключиться к сервису полнотекстового поиска. 4 | 5 | label_attachment_content: Содержимое файла 6 | label_search_quick_reference: Краткая справка по поиску 7 | label_how_to_search: Как искать? 8 | 9 | field_digest: MD5 дайджест 10 | 11 | es: 12 | word: Слово 13 | phrase: Фраза 14 | many_words: Несколько слов 15 | wildcards: Подстановки 16 | field: Поиск по определенному полю 17 | regular_expression: Регулярное выражения 18 | fuzziness: Нечеткий поиск 19 | proximity_searches: Похожесть 20 | ranges: Диапазон 21 | boosting: Поднятие 22 | boolean_operators: Булевы операторы 23 | grouping: Группировка 24 | reserved_characters: Зарезервированные символы 25 | empty_query: Пустой запрос 26 | available_fields: Доступные для поиска поля 27 | attachments: Поиск по вложениям 28 | 29 | views: 30 | pagination: 31 | first: "« Первая" 32 | last: "Последняя »" 33 | previous: "‹ Назад" 34 | next: "Вперед ›" 35 | truncate: "..." 36 | -------------------------------------------------------------------------------- /app/views/elasticsearch/_quick_reference_toc.html.erb: -------------------------------------------------------------------------------- 1 |

<%= l(:label_how_to_search) %>

2 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | services: 4 | - postgresql 5 | - redis-server 6 | - elasticsearch 7 | 8 | rvm: 9 | - 2.3.6 10 | - 2.5.1 11 | - 2.7.2 12 | 13 | env: 14 | - REDMINE_VER=3.4-stable DB=postgresql 15 | - REDMINE_VER=5.1-stable DB=postgresql 16 | 17 | sudo: true 18 | addons: 19 | apt: 20 | packages: 21 | - oracle-java8-set-default 22 | 23 | before_install: 24 | - sudo service elasticsearch stop 25 | - export ES_HOME=/usr/share/elasticsearch 26 | - curl -O https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-5.6.16.deb 27 | - sudo dpkg -i --force-confnew elasticsearch-5.6.16.deb 28 | - sudo $ES_HOME/bin/elasticsearch-plugin install http://dl.bintray.com/content/imotov/elasticsearch-plugins/org/elasticsearch/elasticsearch-analysis-morphology/5.6.16/elasticsearch-analysis-morphology-5.6.16.zip 29 | - yes | sudo $ES_HOME/bin/elasticsearch-plugin install ingest-attachment 30 | - sudo service elasticsearch start 31 | - sleep 5 32 | 33 | install: "echo skip bundle install" 34 | 35 | script: 36 | - export TESTSPACE=`pwd`/testspace 37 | - export NAME_OF_PLUGIN=redmine_elasticsearch 38 | - export PATH_TO_PLUGIN=`pwd` 39 | - export PATH_TO_REDMINE=$TESTSPACE/redmine 40 | - mkdir $TESTSPACE 41 | - bash -x ./travis.sh 42 | -------------------------------------------------------------------------------- /lib/tasks/test.rake: -------------------------------------------------------------------------------- 1 | if Rails.env.test? 2 | require 'elasticsearch/extensions/test/cluster' 3 | 4 | namespace :redmine_elasticsearch do 5 | desc 'Starting ES test cluster' 6 | task :start_test_cluster do 7 | if ENV['START_TEST_CLUSTER'] 8 | 9 | if Elasticsearch::Extensions::Test::Cluster.running? RedmineElasticsearch.client_options 10 | puts 'Stopping elasticsearch test cluster' 11 | Elasticsearch::Extensions::Test::Cluster.stop(RedmineElasticsearch.client_options) 12 | end 13 | 14 | puts 'Running the elasticsearch test cluster...' 15 | # Use TEST_CLUSTER_COMMAND to setup elasticsearch run command 16 | Elasticsearch::Extensions::Test::Cluster.start RedmineElasticsearch.client_options 17 | 18 | # Stop test cluster after test 19 | Rake::Task['redmine:plugins:test:integration'].enhance do 20 | Rake::Task['redmine_elasticsearch:stop_test_cluster'].invoke 21 | end 22 | end 23 | end 24 | 25 | desc 'Stopping ES test cluster' 26 | task :stop_test_cluster do 27 | puts 'Stopping elasticsearch test cluster' 28 | Elasticsearch::Extensions::Test::Cluster.stop(RedmineElasticsearch.client_options) 29 | end 30 | end 31 | 32 | # Start test ES cluster for integration tests 33 | task 'redmine:plugins:test:integration' => 'redmine_elasticsearch:start_test_cluster' 34 | end 35 | -------------------------------------------------------------------------------- /app/views/elasticsearch/search_syntax.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Search Quick Reference 5 | 6 | 43 | 44 | 45 | 46 | 47 |

<%= l(:label_search_quick_reference) %>

48 | 49 | <%= render 'quick_reference_toc' %> 50 | <%= render 'quick_reference' %> 51 | 52 | <%= render 'available_fields_toc' %> 53 | <%= render 'available_fields' %> 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /app/serializers/issue_serializer.rb: -------------------------------------------------------------------------------- 1 | class IssueSerializer < BaseSerializer 2 | attributes :project_id, 3 | :subject, :description, 4 | :created_on, :updated_on, :closed_on, 5 | :author, 6 | :author_id, 7 | :assigned_to, 8 | :assigned_to_id, 9 | :category, 10 | :status, 11 | :done_ratio, 12 | :custom_field_values, :cfv, 13 | :is_private, 14 | :private, 15 | :priority, 16 | :fixed_version, 17 | :due_date, 18 | :is_closed, 19 | :closed 20 | 21 | has_many :journals, serializer: JournalSerializer 22 | has_many :attachments, serializer: AttachmentSerializer 23 | 24 | def author 25 | object.author.try(:name) 26 | end 27 | 28 | def assigned_to 29 | object.assigned_to.try(:name) 30 | end 31 | 32 | def category 33 | object.category.try(:name) 34 | end 35 | 36 | def status 37 | object.status.try(:name) 38 | end 39 | 40 | def custom_field_values 41 | fields = object.custom_field_values.find_all { |cfv| cfv.custom_field.searchable? } 42 | fields.map(&:to_s) 43 | end 44 | 45 | alias_method :cfv, :custom_field_values 46 | 47 | def priority 48 | object.priority.try(:name) 49 | end 50 | 51 | def fixed_version 52 | object.fixed_version.try(:name) 53 | end 54 | 55 | def private 56 | object.is_private? 57 | end 58 | 59 | alias_method :is_private, :private 60 | 61 | def closed 62 | object.closed? 63 | end 64 | 65 | alias_method :is_closed, :closed 66 | end 67 | -------------------------------------------------------------------------------- /test/unit/redmine_elasticsearch/serializer_service_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | 3 | class RedmineElasticsearch::SerializerServiceTest < ActiveSupport::TestCase 4 | 5 | def setup 6 | # clear cache 7 | RedmineElasticsearch::SerializerService.class_eval do 8 | @serializers = nil 9 | end 10 | end 11 | 12 | def test_get_explicit_serializer 13 | issue = Issue.new 14 | serializer = RedmineElasticsearch::SerializerService.serializer(issue) 15 | assert_kind_of IssueSerializer, serializer 16 | end 17 | 18 | def test_serializer_has_additional_attributes 19 | Rails.configuration.stubs(:additional_index_properties).returns( 20 | issues: { 21 | foo: { type: 'string' }, 22 | bar: { 23 | properties: { 24 | name: { type: 'string' } 25 | } 26 | } 27 | } 28 | ) 29 | issue = Issue.new 30 | serializer = RedmineElasticsearch::SerializerService.serializer(issue) 31 | assert serializer.respond_to?(:foo) 32 | assert serializer.respond_to?(:bar) 33 | ensure 34 | Rails.configuration.stubs(:additional_index_properties).returns({}) 35 | end 36 | 37 | def test_get_implicit_serializer 38 | object = IssueStatus.new 39 | serializer = RedmineElasticsearch::SerializerService.serializer(object) 40 | assert_kind_of BaseSerializer, serializer 41 | end 42 | 43 | def test_create_serializer_with_custom_klass 44 | object = Project.new 45 | serializer = RedmineElasticsearch::SerializerService.serializer(object, ParentProjectSerializer) 46 | assert_kind_of ParentProjectSerializer, serializer 47 | end 48 | 49 | end 50 | -------------------------------------------------------------------------------- /travis.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ ! "$TESTSPACE" = /* ]] || 6 | [[ ! "$PATH_TO_REDMINE" = /* ]] || 7 | [[ ! "$REDMINE_VER" = * ]] || 8 | [[ ! "$NAME_OF_PLUGIN" = * ]] || 9 | [[ ! "$PATH_TO_PLUGIN" = /* ]]; 10 | then 11 | echo "You should set"\ 12 | " TESTSPACE, PATH_TO_REDMINE, REDMINE_VER"\ 13 | " NAME_OF_PLUGIN, PATH_TO_PLUGIN"\ 14 | " environment variables" 15 | echo "You set:"\ 16 | "$TESTSPACE"\ 17 | "$PATH_TO_REDMINE"\ 18 | "$REDMINE_VER"\ 19 | "$NAME_OF_PLUGIN"\ 20 | "$PATH_TO_PLUGIN" 21 | exit 1; 22 | fi 23 | 24 | export RAILS_ENV=test 25 | 26 | export REDMINE_GIT_REPO=git://github.com/redmine/redmine.git 27 | export REDMINE_GIT_TAG=$REDMINE_VER 28 | export BUNDLE_GEMFILE=$PATH_TO_REDMINE/Gemfile 29 | 30 | # checkout redmine 31 | git clone $REDMINE_GIT_REPO $PATH_TO_REDMINE 32 | cd $PATH_TO_REDMINE 33 | if [ ! "$REDMINE_GIT_TAG" = "master" ]; 34 | then 35 | git checkout -b $REDMINE_GIT_TAG origin/$REDMINE_GIT_TAG 36 | fi 37 | 38 | # create a link to the backlogs plugin 39 | ln -sf $PATH_TO_PLUGIN plugins/$NAME_OF_PLUGIN 40 | 41 | git clone git://github.com/centosadmin/redmine_sidekiq.git $PATH_TO_REDMINE/plugins/redmine_sidekiq 42 | 43 | cp $PATH_TO_PLUGIN/config/database-$DB-travis.yml $PATH_TO_REDMINE/config/database.yml 44 | 45 | # install gems 46 | bundle install 47 | 48 | bundle exec rake db:create 49 | 50 | # run redmine database migrations 51 | bundle exec rake db:migrate 52 | 53 | # run plugin database migrations 54 | bundle exec rake redmine:plugins:migrate 55 | 56 | bundle exec rake db:structure:dump 57 | 58 | # run tests 59 | bundle exec rake redmine:plugins:test NAME=$NAME_OF_PLUGIN 60 | -------------------------------------------------------------------------------- /lib/redmine_elasticsearch.rb: -------------------------------------------------------------------------------- 1 | require 'elasticsearch' 2 | require 'elasticsearch/model' 3 | 4 | module RedmineElasticsearch 5 | INDEX_NAME = "#{Rails.application.class.module_parent_name.downcase}_#{Rails.env}" 6 | BATCH_SIZE_FOR_IMPORT = 300 7 | 8 | def type2class_name(type) 9 | type.to_s.underscore.classify 10 | end 11 | 12 | def type2class(type) 13 | self.type2class_name(type).constantize 14 | end 15 | 16 | def search_klasses 17 | Redmine::Search.available_search_types.map { |type| type2class(type) } 18 | end 19 | 20 | def apply_patch(patch, *targets) 21 | targets = Array(targets).flatten 22 | targets.each do |target| 23 | unless target.included_modules.include? patch 24 | target.send :prepend, patch 25 | end 26 | end 27 | end 28 | 29 | def additional_index_properties(document_type) 30 | @additional_index_properties = {} 31 | @additional_index_properties[document_type] ||= begin 32 | Rails.configuration.respond_to?(:additional_index_properties) ? 33 | Rails.configuration.additional_index_properties.fetch(document_type, {}) : {} 34 | end 35 | end 36 | 37 | def client(cache: true) 38 | if cache 39 | @client ||= Elasticsearch::Client.new client_options 40 | else 41 | @client = Elasticsearch::Client.new client_options 42 | end 43 | end 44 | 45 | def client_options 46 | @client_options ||= 47 | (Redmine::Configuration['elasticsearch'] || { request_timeout: 180 }).symbolize_keys 48 | end 49 | 50 | # Refresh the index and to make the changes (creates, updates, deletes) searchable. 51 | def refresh_indices 52 | client.indices.refresh 53 | end 54 | 55 | extend self 56 | end 57 | -------------------------------------------------------------------------------- /app/elastic/issue_search.rb: -------------------------------------------------------------------------------- 1 | module IssueSearch 2 | extend ActiveSupport::Concern 3 | 4 | module ClassMethods 5 | 6 | def allowed_to_search_query(user, options = {}) 7 | options = options.merge( 8 | permission: :view_issues, 9 | type: 'issue' 10 | ) 11 | ParentProject.allowed_to_search_query(user, options) do |role, user| 12 | if user.logged? 13 | case role.issues_visibility 14 | when 'all' 15 | nil 16 | when 'default' 17 | user_ids = [user.id] + user.groups.map(&:id) 18 | { 19 | bool: { 20 | should: [ 21 | { term: { is_private: { value: false } } }, 22 | { term: { author_id: { value: user.id } } }, 23 | { terms: { assigned_to_id: user_ids } }, 24 | ], 25 | minimum_should_match: 1 26 | } 27 | } 28 | when 'own' 29 | user_ids = [user.id] + user.groups.map(&:id) 30 | { 31 | bool: { 32 | should: [ 33 | { term: { author_id: { value: user.id } } }, 34 | { terms: { assigned_to_id: user_ids } }, 35 | ], 36 | minimum_should_match: 1 37 | } 38 | } 39 | else 40 | { term: { id: { value: 0 } } } 41 | end 42 | else 43 | { term: { is_private: { value: false } } } 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/workers/indexer.rb: -------------------------------------------------------------------------------- 1 | module Workers 2 | class Indexer 3 | include Sidekiq::Worker 4 | 5 | class IndexError < StandardError 6 | end 7 | 8 | class << self 9 | def defer(object_or_class) 10 | if object_or_class.is_a? Class 11 | params = { type: object_or_class.document_type } 12 | perform_async(params) 13 | elsif object_or_class.id? 14 | params = { 15 | id: object_or_class.id, 16 | type: object_or_class.class.document_type 17 | } 18 | perform_async(params) 19 | end 20 | rescue StandardError => e 21 | Rails.logger.debug "INDEXER: #{e.class} => #{e.message}" 22 | raise 23 | end 24 | end 25 | 26 | def perform(options) 27 | id, type = options.with_indifferent_access[:id], options.with_indifferent_access[:type] 28 | id.nil? ? update_class_index(type) : update_instance_index(type, id) 29 | end 30 | 31 | private 32 | 33 | def update_class_index(type) 34 | klass = RedmineElasticsearch.type2class(type) 35 | klass.update_index 36 | rescue Errno::ECONNREFUSED => e 37 | raise IndexError, e, e.backtrace 38 | end 39 | 40 | def update_instance_index(type, id) 41 | klass = RedmineElasticsearch.type2class(type) 42 | document = klass.find id 43 | document.update_index 44 | rescue ActiveRecord::RecordNotFound 45 | begin 46 | klass.remove_from_index id 47 | rescue Elasticsearch::Transport::Transport::Errors::NotFound 48 | # do nothing 49 | end 50 | rescue Elasticsearch::Transport::Transport::Errors::NotFound 51 | # do nothing 52 | rescue Errno::ECONNREFUSED => e 53 | raise IndexError, e, e.backtrace 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require 'redmine' 2 | 3 | register_after_redmine_initialize_proc = 4 | if Redmine::VERSION::MAJOR >= 5 5 | Rails.application.config.public_method(:after_initialize) 6 | else 7 | reloader = defined?(ActiveSupport::Reloader) ? ActiveSupport::Reloader : ActionDispatch::Reloader 8 | reloader.public_method(:to_prepare) 9 | end 10 | register_after_redmine_initialize_proc.call do 11 | paths = '/lib/redmine_elasticsearch/{patches/*_patch}.rb' 12 | 13 | Dir.glob(File.dirname(__FILE__) + paths).each do |file| 14 | require_dependency file 15 | end 16 | 17 | RedmineElasticsearch.apply_patch RedmineElasticsearch::Patches::RedmineSearchPatch, Redmine::Search 18 | RedmineElasticsearch.apply_patch RedmineElasticsearch::Patches::SearchControllerPatch, SearchController 19 | RedmineElasticsearch.apply_patch RedmineElasticsearch::Patches::ResponseResultsPatch, Elasticsearch::Model::Response::Results 20 | # Using plugin's configured client in all models 21 | Elasticsearch::Model.client = RedmineElasticsearch.client 22 | end 23 | 24 | paths = Dir.glob("#{Rails.application.config.root}/plugins/redmine_elasticsearch/{lib,app/models,app/controllers}") 25 | 26 | Rails.application.config.eager_load_paths += paths 27 | Rails.application.config.autoload_paths += paths 28 | ActiveSupport::Dependencies.autoload_paths += paths 29 | 30 | Redmine::Plugin.register :redmine_elasticsearch do 31 | name 'Redmine Elasticsearch Plugin' 32 | description 'This plugin integrates the Elasticsearch full-text search engine into Redmine.' 33 | author 'Restream' 34 | version '0.2.1' 35 | url 'https://github.com/Restream/redmine_elasticsearch' 36 | 37 | requires_redmine version_or_higher: '2.1' 38 | end 39 | 40 | require './plugins/redmine_elasticsearch/lib/redmine_elasticsearch' 41 | -------------------------------------------------------------------------------- /lib/redmine_elasticsearch/patches/redmine_search_patch.rb: -------------------------------------------------------------------------------- 1 | require 'redmine/search' 2 | 3 | module RedmineElasticsearch 4 | module Patches 5 | module RedmineSearchPatch 6 | 7 | def self.prepended(base) 8 | base.class_eval do 9 | 10 | # watching for changing size of available_search_types 11 | class << available_search_types 12 | Array.instance_methods(false).each do |meth| 13 | old = instance_method(meth) 14 | define_method(meth) do |*args, &block| 15 | old_size = size 16 | old.bind(self).call(*args, &block) 17 | Redmine::Search.update_search_methods if old_size != size 18 | end if [:add, :<<].include?(meth) 19 | end 20 | end 21 | 22 | extend ClassMethods 23 | 24 | update_search_methods 25 | end 26 | end 27 | 28 | module ClassMethods 29 | 30 | # Registers a search provider 31 | def register(search_type, options={}) 32 | include_search_methods(search_type) 33 | super 34 | end 35 | 36 | def update_search_methods 37 | available_search_types.each { |search_type| include_search_methods(search_type) } if available_search_types 38 | end 39 | 40 | private 41 | 42 | def include_search_methods(search_type) 43 | search_klass = RedmineElasticsearch.type2class(search_type) 44 | include_methods(search_klass, ::ApplicationSearch) 45 | explicit_search_methods = detect_search_methods(search_type) 46 | include_methods(search_klass, explicit_search_methods) if explicit_search_methods 47 | end 48 | 49 | def detect_search_methods(search_type) 50 | "::#{RedmineElasticsearch.type2class_name(search_type)}Search".safe_constantize 51 | end 52 | 53 | def include_methods(klass, methods) 54 | klass.send(:include, methods) unless klass.included_modules.include?(methods) 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /app/serializers/attachment_serializer.rb: -------------------------------------------------------------------------------- 1 | class AttachmentSerializer < ActiveModel::Serializer 2 | self.root = false 3 | 4 | # todo: move max_size and supported_mime_patterns and unsupported phrase to plugin configuration 5 | 6 | MAX_SIZE = 5.megabytes 7 | 8 | SUPPORTED_EXTENSIONS = %w{ 9 | .doc .docx .htm .html .json .ods .odt .pdf .ppt .pptx .rb .rtf .sh .sql .txt .xls .xlsx .xml .yaml .yml 10 | } 11 | 12 | SUPPORTED_MIME_PATTERNS = %w{ 13 | application\/json 14 | application\/msword 15 | application\/pdf 16 | application\/vnd.ms-excel 17 | application\/vnd.ms-powerpoint 18 | application\/vnd.ms-publisher 19 | application\/vnd.oasis.opendocument.spreadsheet 20 | application\/vnd.oasis.opendocument.text 21 | application\/vnd.openxmlformats-officedocument 22 | application\/vnd.openxmlformats-officedocument 23 | application\/vnd.openxmlformats-officedocument 24 | application\/x-javascript 25 | application\/x-ruby 26 | application\/x-sh 27 | application\/x-shellscript 28 | application\/x-yaml 29 | application\/xml 30 | message\/rfc822 31 | text\/ 32 | } 33 | 34 | UNSUPPORTED = 'unsupported' 35 | 36 | attributes :created_on, 37 | :filename, 38 | :description, 39 | :author, 40 | :filesize, 41 | :digest, 42 | :downloads, 43 | :author_id, 44 | :content_type, 45 | :file 46 | 47 | def author 48 | object.author && object.author.to_s 49 | end 50 | 51 | def file 52 | content = supported? ? File.read(object.diskfile) : UNSUPPORTED 53 | Base64.encode64(content) 54 | end 55 | 56 | private 57 | 58 | def supported? 59 | object.filesize > 0 && 60 | object.filesize < MAX_SIZE && 61 | (extension_supported? || content_type_supported?) && 62 | object.readable? 63 | end 64 | 65 | def extension_supported? 66 | SUPPORTED_EXTENSIONS.include?($1) if object.filename =~ /(\.[^\.]+)$/ 67 | end 68 | 69 | def content_type_supported? 70 | SUPPORTED_MIME_PATTERNS.any? { |pattern| object.content_type =~ Regexp.new(pattern, true) } 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/integration/redmine_elasticsearch/indexer_service_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | 3 | class RedmineElasticsearch::IndexerServiceTest < Redmine::IntegrationTest 4 | fixtures :projects, 5 | :users, 6 | :email_addresses, 7 | :roles, 8 | :members, 9 | :member_roles, 10 | :issues, 11 | :issue_statuses, 12 | :versions, 13 | :trackers, 14 | :projects_trackers, 15 | :issue_categories, 16 | :enabled_modules, 17 | :enumerations, 18 | :news, 19 | :documents, 20 | :changesets, 21 | :repositories, 22 | :wikis, 23 | :wiki_pages, 24 | :messages, 25 | :boards 26 | 27 | def setup 28 | if RedmineElasticsearch.client.indices.exists? index: RedmineElasticsearch::INDEX_NAME 29 | RedmineElasticsearch.client.indices.delete index: RedmineElasticsearch::INDEX_NAME 30 | end 31 | end 32 | 33 | def test_recreate_index 34 | refute RedmineElasticsearch.client.indices.exists? index: RedmineElasticsearch::INDEX_NAME 35 | assert_nothing_raised do 36 | RedmineElasticsearch::IndexerService.recreate_index 37 | end 38 | assert RedmineElasticsearch.client.indices.exists? index: RedmineElasticsearch::INDEX_NAME 39 | end 40 | 41 | def test_reindex_all 42 | refute RedmineElasticsearch.client.indices.exists? index: RedmineElasticsearch::INDEX_NAME 43 | assert_nothing_raised do 44 | RedmineElasticsearch::IndexerService.reindex_all 45 | end 46 | assert RedmineElasticsearch.client.indices.exists? index: RedmineElasticsearch::INDEX_NAME 47 | refute Issue.all.empty? 48 | found_records = Elasticsearch::Model.search('*',[Issue]).results.total 49 | assert_equal Issue.count, found_records 50 | 51 | Redmine::Search.available_search_types.each do |type| 52 | klass = RedmineElasticsearch.type2class(type) 53 | refute klass.all.empty?, "#{ klass.to_s } should contains some records for test" 54 | found_records = Elasticsearch::Model.search('*',[klass]).results.total 55 | assert_equal klass.count, found_records, "Search all in #{ klass.to_s } should returns #{ klass.count } results." 56 | end 57 | end 58 | 59 | end -------------------------------------------------------------------------------- /lib/tasks/index.rake: -------------------------------------------------------------------------------- 1 | require 'ansi/progressbar' 2 | 3 | namespace :redmine_elasticsearch do 4 | 5 | desc 'Recreate index' 6 | task :recreate_index => :logged do 7 | puts 'Recreate index for all available search types' 8 | RedmineElasticsearch::IndexerService.recreate_index 9 | puts 'Done recreating index.' 10 | end 11 | 12 | desc 'Recreate index and reindex all available search types (BATCH_SIZE env variable is optional)' 13 | task :reindex_all => :logged do 14 | 15 | puts 'Recreating index and updating mapping...' 16 | RedmineElasticsearch::IndexerService.recreate_index 17 | 18 | puts "Available search types: [#{Redmine::Search.available_search_types.join(', ')}]" 19 | 20 | # Errors counter 21 | errors = 0 22 | 23 | # Reindex project tree first 24 | errors += reindex_project_tree 25 | 26 | # Reindex all searchable types 27 | Redmine::Search.available_search_types.each do |search_type| 28 | reindex_document_type search_type 29 | end 30 | 31 | puts 'Refresh index for allowing searching right after reindex...' 32 | RedmineElasticsearch.client.indices.refresh 33 | 34 | puts "Done reindex all. Errors: #{errors}" 35 | end 36 | 37 | desc 'Reindex search type (NAME env variable is required, BATCH_SIZE is optional)' 38 | task :reindex => :logged do 39 | search_type = ENV['NAME'] 40 | raise 'Specify search type in NAME env variable' if search_type.blank? 41 | 42 | errors = 0 43 | 44 | # Reindex project tree 45 | reindex_project_tree if search_type == 'projects' 46 | 47 | # Reindex document 48 | errors += reindex_document_type search_type 49 | 50 | puts 'Refresh index for allowing searching right after reindex...' 51 | RedmineElasticsearch.client.indices.refresh 52 | 53 | puts "Done. Errors: #{errors}" 54 | end 55 | 56 | task :logged => :environment do 57 | logger = Logger.new(STDOUT) 58 | logger.level = Logger::WARN 59 | ActiveRecord::Base.logger = logger 60 | end 61 | 62 | def batch_size 63 | ENV['BATCH_SIZE'].to_i if ENV['BATCH_SIZE'].present? 64 | end 65 | 66 | def reindex_project_tree 67 | puts "\nCounting projects..." 68 | estimated_records = ParentProject.count 69 | puts "#{estimated_records} will be imported." 70 | bar = ANSI::ProgressBar.new("Project tree", estimated_records) 71 | bar.flush 72 | errors = ParentProject.import batch_size: batch_size do |imported_records| 73 | bar.inc imported_records 74 | end 75 | bar.halt 76 | puts "Done reindex project tree. Errors: #{errors}" 77 | errors 78 | end 79 | 80 | def reindex_document_type(search_type) 81 | puts "\nCounting estimated records for #{search_type}..." 82 | estimated_records = RedmineElasticsearch::IndexerService.count_estimated_records(search_type) 83 | puts "#{estimated_records} will be imported." 84 | bar = ANSI::ProgressBar.new("#{search_type}", estimated_records) 85 | bar.flush 86 | errors = RedmineElasticsearch::IndexerService.reindex(search_type, batch_size: batch_size) do |imported_records| 87 | bar.set imported_records 88 | end 89 | bar.halt 90 | puts "Done reindex #{search_type}. Errors: #{errors}" 91 | errors 92 | end 93 | 94 | end 95 | -------------------------------------------------------------------------------- /app/elastic/application_search.rb: -------------------------------------------------------------------------------- 1 | module ApplicationSearch 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | include Elasticsearch::Model 6 | 7 | index_name RedmineElasticsearch::INDEX_NAME 8 | 9 | after_commit :async_update_index 10 | end 11 | 12 | def to_indexed_json 13 | RedmineElasticsearch::SerializerService.serialize_to_json(self) 14 | end 15 | 16 | def async_update_index 17 | Workers::Indexer.defer(self) 18 | end 19 | 20 | module ClassMethods 21 | 22 | def index_mapping 23 | { 24 | document_type => { 25 | _parent: { type: 'parent_project' } 26 | } 27 | } 28 | end 29 | 30 | def additional_index_mappings 31 | return {} unless Rails.configuration.respond_to?(:additional_index_properties) 32 | Rails.configuration.additional_index_properties[self.name.tableize.to_sym] || {} 33 | end 34 | 35 | # Update mapping for document type 36 | def update_mapping 37 | __elasticsearch__.client.indices.put_mapping( 38 | index: index_name, 39 | type: document_type, 40 | body: index_mapping 41 | ) 42 | end 43 | 44 | def allowed_to_search_query(user, options = {}) 45 | options = options.merge( 46 | permission: :view_project, 47 | type: document_type 48 | ) 49 | ParentProject.allowed_to_search_query(user, options) 50 | end 51 | 52 | def searching_scope 53 | all 54 | end 55 | 56 | # Import all records to elastic 57 | # @return [Integer] errors count 58 | def import(options = {}, &block) 59 | # Batch size for bulk operations 60 | batch_size = options[:batch_size] || RedmineElasticsearch::BATCH_SIZE_FOR_IMPORT 61 | 62 | # Document type 63 | type = options.fetch(:type, document_type) 64 | 65 | # Imported records counter 66 | imported = 0 67 | 68 | # Errors counter 69 | errors = 0 70 | 71 | searching_scope.find_in_batches(batch_size: batch_size) do |items| 72 | response = __elasticsearch__.client.bulk( 73 | index: index_name, 74 | type: type, 75 | body: items.map do |item| 76 | data = item.to_indexed_json 77 | parent = data.delete :_parent 78 | { index: { _id: item.id, _parent: parent, data: data } } 79 | end 80 | ) 81 | imported += items.length 82 | errors += response['items'].map { |k, v| k.values.first['error'] }.compact.length 83 | 84 | # Call block with imported records count in batch 85 | yield(imported) if block_given? 86 | end 87 | errors 88 | end 89 | 90 | def remove_from_index(id) 91 | __elasticsearch__.client.delete index: index_name, type: document_type, id: id, routing: id 92 | end 93 | end 94 | 95 | def update_index 96 | relation = self.class.searching_scope.where(id: id) 97 | 98 | if relation.size.zero? 99 | begin 100 | self.class.remove_from_index(id) 101 | return 102 | rescue Elasticsearch::Transport::Transport::Errors::NotFound 103 | return 104 | end 105 | end 106 | 107 | relation.import 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /test/integration/redmine_elasticsearch/search_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | 3 | class RedmineElasticsearch::SearchTest < Redmine::IntegrationTest 4 | fixtures :projects, 5 | :users, 6 | :email_addresses, 7 | :roles, 8 | :members, 9 | :member_roles, 10 | :issues, 11 | :issue_statuses, 12 | :versions, 13 | :trackers, 14 | :projects_trackers, 15 | :issue_categories, 16 | :enabled_modules, 17 | :enumerations, 18 | :news, 19 | :documents, 20 | :changesets, 21 | :repositories, 22 | :wikis, 23 | :wiki_pages, 24 | :messages, 25 | :boards 26 | 27 | def setup 28 | RedmineElasticsearch::IndexerService.reindex_all 29 | end 30 | 31 | test 'only allowed issues will found' do 32 | assert_search_only_allowed_items( 33 | query: ->(user) { Issue.allowed_to_search_query(user) }, 34 | expected: ->(user) { Issue.visible(user) } 35 | ) 36 | end 37 | 38 | test 'only allowed news will found' do 39 | assert_search_only_allowed_items( 40 | query: ->(user) { News.allowed_to_search_query(user) }, 41 | expected: ->(user) { News.visible(user) } 42 | ) 43 | end 44 | 45 | test 'only allowed documents will found' do 46 | assert_search_only_allowed_items( 47 | query: ->(user) { Document.allowed_to_search_query(user) }, 48 | expected: ->(user) { Document.visible(user) } 49 | ) 50 | end 51 | 52 | test 'only allowed changesets will found' do 53 | assert_search_only_allowed_items( 54 | query: ->(user) { Changeset.allowed_to_search_query(user) }, 55 | expected: ->(user) { Changeset.visible(user) } 56 | ) 57 | end 58 | 59 | test 'only allowed wiki_pages will found' do 60 | assert_search_only_allowed_items( 61 | query: ->(user) { WikiPage.allowed_to_search_query(user) }, 62 | expected: ->(user) { WikiPage.all.to_a.select { |wiki_page| wiki_page.visible?(user) } } 63 | ) 64 | end 65 | 66 | test 'only allowed messages will found' do 67 | assert_search_only_allowed_items( 68 | query: ->(user) { Message.allowed_to_search_query(user) }, 69 | expected: ->(user) { Message.visible(user) } 70 | ) 71 | end 72 | 73 | test 'only allowed projects will found' do 74 | assert_search_only_allowed_items( 75 | query: ->(user) { Project.allowed_to_search_query(user) }, 76 | expected: ->(user) { Project.visible(user) } 77 | ) 78 | end 79 | 80 | test 'search all issues without errors' do 81 | get '/search', { q: '*' }, credentials('admin') 82 | 83 | assert_response :success 84 | end 85 | 86 | private 87 | 88 | def assert_search_only_allowed_items(query:, expected:) 89 | # For each user 90 | ([User.anonymous] + User.all).each do |user| 91 | expected_items = expected.call(user) 92 | payload = { 93 | size: expected_items.length + 10, # allow to return more results as expected 94 | query: query.call(user) 95 | } 96 | found = Elasticsearch::Model.search(payload).to_a 97 | found_ids = found.map { |item| item._id.to_i }.sort 98 | expected_ids = expected_items.map(&:id).sort 99 | assert_equal expected_ids, found_ids, "Found ids are not the same as expected ids for user '#{user.name}'" 100 | end 101 | end 102 | 103 | end 104 | -------------------------------------------------------------------------------- /lib/redmine_elasticsearch/serializer_service.rb: -------------------------------------------------------------------------------- 1 | class RedmineElasticsearch::SerializerService 2 | class << self 3 | 4 | # Serialize instance of searchable klass as json 5 | # @param [Object] object instance of searchable klass 6 | # @param [Class] serializer_klass class that will be used to construct serializer 7 | # 8 | def serialize_to_json(object, serializer_klass = nil) 9 | serializer(object, serializer_klass).as_json 10 | end 11 | 12 | # Get serializer for specific object 13 | # @param [Object] object instance of searchable klass 14 | # @param [Class] serializer_klass class that will be used to construct serializer 15 | # 16 | def serializer(object, serializer_klass = nil) 17 | object_type = object.class.name.tableize.to_sym 18 | build_serializer_klass(object_type, serializer_klass).new(object) 19 | end 20 | 21 | private 22 | 23 | # Build serializer. Construct new class and add properties from additional config 24 | # @param [String] object_type document type 25 | # @param [Class] serializer_klass class that will be used to construct serializer 26 | # 27 | def build_serializer_klass(object_type, serializer_klass = nil) 28 | parent = serializer_klass || 29 | "#{RedmineElasticsearch.type2class_name(object_type)}Serializer".safe_constantize || 30 | BaseSerializer 31 | serializer_klass = Class.new(parent) 32 | additional_props = additional_index_properties(object_type) 33 | add_additional_properties(serializer_klass, additional_props) if additional_props 34 | serializer_klass 35 | end 36 | 37 | # Get additional options that should be added to indexed json by serializer 38 | # @param [String] object_type document type 39 | # 40 | # 41 | # Example of additional properties in config/additional_environment.rb: 42 | # 43 | # config.additional_index_properties = { 44 | # issues: { 45 | # tags: { type: 'string' } 46 | # } 47 | # } 48 | # 49 | def additional_index_properties(object_type) 50 | RedmineElasticsearch::additional_index_properties(object_type) 51 | end 52 | 53 | # Add additional properties to serializer 54 | # @param [Class] serializer_klass serailizer class to extend 55 | # @param [Hash] additional_props properties which extends serializer 56 | # 57 | def add_additional_properties(serializer_klass, additional_props) 58 | additional_props.each do |key, value| 59 | props = value[:properties] 60 | if props.nil? 61 | add_attribute_to_serializer(serializer_klass, key) 62 | else 63 | add_association_to_serializer(serializer_klass, key, props) 64 | end 65 | end 66 | end 67 | 68 | # Add attribute to serializer 69 | # @param [Class] serializer_klass serailizer class to extend 70 | # @param [String] name attribute name 71 | # 72 | def add_attribute_to_serializer(serializer_klass, name) 73 | serializer_klass.send :attribute, name 74 | end 75 | 76 | # Add association to serializer 77 | # @param [Class] serializer_klass serailizer class to extend 78 | # @param [String] name association name 79 | # @param [Hash] options additional attributes for associations 80 | # 81 | def add_association_to_serializer(serializer_klass, name, options) 82 | props_serializer_klass = Class.new(BaseSerializer) 83 | add_additional_properties(props_serializer_klass, options) 84 | serializer_klass.send :has_many, name, serializer: props_serializer_klass 85 | end 86 | 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /test/integration/redmine_elasticsearch/api_search_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | 3 | # Just as in redmine search test 4 | class RedmineElasticsearch::ApiSearchTest < Redmine::ApiTest::Base 5 | fixtures :projects, 6 | :users, 7 | :email_addresses, 8 | :roles, 9 | :members, 10 | :member_roles, 11 | :issues, 12 | :journals, 13 | :journal_details, 14 | :issue_statuses, 15 | :versions, 16 | :trackers, 17 | :projects_trackers, 18 | :issue_categories, 19 | :enabled_modules, 20 | :enumerations, 21 | :news, 22 | :documents, 23 | :changesets, 24 | :repositories, 25 | :wikis, 26 | :wiki_pages, 27 | :messages, 28 | :boards 29 | 30 | def setup 31 | RedmineElasticsearch::IndexerService.reindex_all 32 | end 33 | 34 | test "GET /search.xml should return xml content" do 35 | get '/search.xml' 36 | 37 | assert_response :success 38 | assert_equal 'application/xml', @response.content_type 39 | end 40 | 41 | test "GET /search.json should return json content" do 42 | get '/search.json' 43 | 44 | assert_response :success 45 | assert_equal 'application/json', @response.content_type 46 | 47 | json = ActiveSupport::JSON.decode(response.body) 48 | assert_kind_of Hash, json 49 | assert_kind_of Array, json['results'] 50 | end 51 | 52 | test "GET /search.xml without query strings should return empty results" do 53 | get '/search.xml', :q => '', :all_words => '' 54 | 55 | assert_response :success 56 | assert_equal 0, assigns(:results).size 57 | end 58 | 59 | test "GET /search.xml with query strings should return results" do 60 | get '/search.xml', :q => 'recipe subproject commit', :all_words => '' 61 | 62 | assert_response :success 63 | assert_not_empty(assigns(:results)) 64 | 65 | assert_select 'results[type=array]' do 66 | assert_select 'result', :count => assigns(:results).count 67 | assigns(:results).size.times.each do |i| 68 | assert_select 'result' do 69 | assert_select 'id', :text => assigns(:results)[i].id.to_s 70 | assert_select 'title', :text => assigns(:results)[i].event_title 71 | assert_select 'type', :text => assigns(:results)[i].event_type 72 | assert_select 'url', :text => url_for(assigns(:results)[i].event_url(:only_path => false)) 73 | assert_select 'description', :text => assigns(:results)[i].event_description 74 | assert_select 'datetime' 75 | end 76 | end 77 | end 78 | end 79 | 80 | test "GET /search.json should paginate" do 81 | issue = (0..10).map {Issue.generate! :subject => 'search'}.reverse.map(&:id) 82 | RedmineElasticsearch.refresh_indices 83 | 84 | get '/search.json', :q => 'search', :limit => 4 85 | json = ActiveSupport::JSON.decode(response.body) 86 | assert_equal 11, json['total_count'] 87 | assert_equal 0, json['offset'] 88 | assert_equal 4, json['limit'] 89 | assert_equal issue[0..3], json['results'].map {|r| r['id'].to_i } 90 | 91 | get '/search.json', :q => 'search', :offset => 8, :limit => 4 92 | json = ActiveSupport::JSON.decode(response.body) 93 | assert_equal 11, json['total_count'] 94 | assert_equal 8, json['offset'] 95 | assert_equal 4, json['limit'] 96 | assert_equal issue[8..10], json['results'].map {|r| r['id'].to_i } 97 | end 98 | 99 | test "search should find text in journal" do 100 | # Will search for note -> "A comment with inline image: !picture.jpg! and a reference to #1 and r2." 101 | get '/search.json', q: 'reference', issues: true 102 | json = ActiveSupport::JSON.decode(response.body) 103 | assert_equal 1, json['total_count'] 104 | assert_equal [2], json['results'].map {|r| r['id'].to_i } 105 | end 106 | 107 | end 108 | -------------------------------------------------------------------------------- /app/models/parent_project.rb: -------------------------------------------------------------------------------- 1 | # Parent project used for index and query issues, news, projects and etc with parent. 2 | class ParentProject < Project 3 | include ApplicationSearch 4 | 5 | index_name RedmineElasticsearch::INDEX_NAME 6 | 7 | class << self 8 | 9 | # Import all projects to 'parent_project' document type. 10 | # 'parent_project' is a project tree for all other items. 11 | def import(options={}, &block) 12 | # Batch size for bulk operations 13 | batch_size = options[:batch_size] || RedmineElasticsearch::BATCH_SIZE_FOR_IMPORT 14 | 15 | # Imported records counter 16 | imported = 0 17 | 18 | # Errors counter 19 | errors = 0 20 | 21 | find_in_batches(batch_size: batch_size) do |items| 22 | response = __elasticsearch__.client.bulk( 23 | index: index_name, 24 | type: document_type, 25 | body: items.map do |item| 26 | data = item.to_indexed_json 27 | { index: { _id: item.id, data: data } } 28 | end 29 | ) 30 | imported += items.length 31 | errors += response['items'].map { |k, v| k.values.first['error'] }.compact.length 32 | 33 | # Call block with imported records count in batch 34 | yield(imported) if block_given? 35 | end 36 | errors 37 | end 38 | 39 | def searching_scope 40 | self.where(nil) 41 | end 42 | 43 | def allowed_to_search_query(user, options = {}) 44 | permission = options[:permission] || :search_project 45 | perm = Redmine::AccessControl.permission(permission) 46 | 47 | must_queries = [] 48 | 49 | # If the permission belongs to a project module, make sure the module is enabled 50 | if perm && perm.project_module 51 | must_queries << { 52 | has_parent: { 53 | parent_type: 'parent_project', 54 | query: { 55 | term: { 56 | 'enabled_module_names.keyword' => { value: perm.project_module } 57 | } 58 | } 59 | } 60 | } 61 | end 62 | 63 | must_queries << { term: { _type: options[:type] } } if options[:type].present? 64 | 65 | unless user.admin? 66 | statement_by_role = {} 67 | role = user.logged? ? Role.non_member : Role.anonymous 68 | hide_public_projects = user.pref[:hide_public_projects] == '1' 69 | if role.allowed_to?(permission) && !hide_public_projects 70 | statement_by_role[role] = { 71 | has_parent: { 72 | parent_type: 'parent_project', 73 | query: { term: { is_public: { value: true } } } 74 | } 75 | } 76 | end 77 | if user.logged? 78 | user.projects_by_role.each do |role, projects| 79 | if role.allowed_to?(permission) && projects.any? 80 | statement_by_role[role] = { 81 | has_parent: { 82 | parent_type: 'parent_project', 83 | query: { ids: { values: projects.collect(&:id) } } 84 | } 85 | } 86 | end 87 | end 88 | end 89 | if statement_by_role.empty? 90 | must_queries = [{ term: { id: { value: 0 } } }] 91 | else 92 | if block_given? 93 | statement_by_role.each do |role, statement| 94 | block_statement = yield(role, user) 95 | if block_statement.present? 96 | statement_by_role[role] = { 97 | bool: { 98 | must: [statement, block_statement] 99 | } 100 | } 101 | end 102 | end 103 | end 104 | must_queries << { bool: { should: statement_by_role.values, minimum_should_match: 1 } } 105 | end 106 | end 107 | { 108 | bool: { 109 | must: must_queries 110 | } 111 | } 112 | end 113 | 114 | end 115 | 116 | end 117 | -------------------------------------------------------------------------------- /lib/redmine_elasticsearch/indexer_service.rb: -------------------------------------------------------------------------------- 1 | module RedmineElasticsearch 2 | 3 | class IndexerError < StandardError 4 | end 5 | 6 | class IndexerService 7 | 8 | class << self 9 | def recreate_index 10 | delete_index if index_exists? 11 | create_index 12 | update_mapping 13 | RedmineElasticsearch.refresh_indices 14 | end 15 | 16 | # Recreate index and mapping and then import documents 17 | # @return [Integer] errors count 18 | # 19 | def reindex_all(options = {}, &block) 20 | 21 | # Errors counter 22 | errors = 0 23 | 24 | # Delete and create indexes 25 | recreate_index 26 | 27 | # Importing parent project first 28 | ParentProject.import 29 | 30 | # Import records from all searchable classes 31 | RedmineElasticsearch.search_klasses.each do |search_klass| 32 | errors += search_klass.import options, &block 33 | end 34 | 35 | # Refresh index for allowing searching right after reindex 36 | RedmineElasticsearch.client.indices.refresh 37 | 38 | errors 39 | end 40 | 41 | # Reindex only given search type 42 | def reindex(search_type, options = {}, &block) 43 | search_klass = find_search_klass(search_type) 44 | create_index unless index_exists? 45 | search_klass.update_mapping 46 | 47 | # Import records from given searchable class 48 | errors = search_klass.import options do |imported_records| 49 | yield(imported_records) if block_given? 50 | end 51 | 52 | errors 53 | end 54 | 55 | def count_estimated_records(search_type = nil) 56 | search_klass = search_type && find_search_klass(search_type) 57 | search_klass ? 58 | search_klass.searching_scope.count : 59 | RedmineElasticsearch.search_klasses.inject(0) { |sum, klass| sum + klass.searching_scope.count } 60 | end 61 | 62 | protected 63 | 64 | def logger 65 | ActiveRecord::Base.logger 66 | end 67 | 68 | def update_mapping 69 | RedmineElasticsearch.search_klasses.each { |search_klass| search_klass.update_mapping } 70 | end 71 | 72 | def index_exists? 73 | RedmineElasticsearch.client.indices.exists? index: RedmineElasticsearch::INDEX_NAME 74 | end 75 | 76 | def create_index 77 | RedmineElasticsearch.client.indices.create( 78 | index: RedmineElasticsearch::INDEX_NAME, 79 | body: { 80 | settings: { 81 | index: { 82 | number_of_shards: 1, 83 | number_of_replicas: 0 84 | }, 85 | analysis: { 86 | analyzer: { 87 | default: { 88 | type: 'custom', 89 | tokenizer: 'standard', 90 | filter: %w(lowercase main_ngrams russian_morphology english_morphology main_stopwords) 91 | }, 92 | default_search: { 93 | type: 'custom', 94 | tokenizer: 'standard', 95 | filter: %w(lowercase russian_morphology english_morphology main_stopwords) 96 | }, 97 | }, 98 | filter: { 99 | main_stopwords: { 100 | type: 'stop', 101 | stopwords: %w(а без более бы был была были было быть в вам вас весь во вот все всего всех вы где да даже для до его ее если есть еще же за здесь и из или им их к как ко когда кто ли либо мне может мы на надо наш не него нее нет ни них но ну о об однако он она они оно от очень по под при с со так также такой там те тем то того тоже той только том ты у уже хотя чего чей чем что чтобы чье чья эта эти это я a an and are as at be but by for if in into is it no not of on or such that the their then there these they this to was will with) 102 | }, 103 | main_ngrams: { 104 | type: 'edgeNGram', 105 | min_gram: 1, 106 | max_gram: 20 107 | } 108 | } 109 | } 110 | }, 111 | mappings: { 112 | _default_: { 113 | properties: { 114 | type: { type: 'keyword' }, 115 | title: { type: 'text' }, 116 | description: { type: 'text' }, 117 | datetime: { type: 'date' }, 118 | url: { type: 'text', index: 'not_analyzed' } 119 | } 120 | } 121 | } 122 | } 123 | ) 124 | end 125 | 126 | def delete_index 127 | RedmineElasticsearch.client.indices.delete index: RedmineElasticsearch::INDEX_NAME 128 | end 129 | 130 | def find_search_klass(search_type) 131 | validate_search_type(search_type) 132 | RedmineElasticsearch.type2class(search_type) 133 | end 134 | 135 | def validate_search_type(search_type) 136 | unless Redmine::Search.available_search_types.include?(search_type) 137 | raise IndexError.new("Wrong search type [#{search_type}]. Available search types are #{Redmine::Search.available_search_types}") 138 | end 139 | end 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /app/views/elasticsearch/_available_fields.html.erb: -------------------------------------------------------------------------------- 1 |

<%= l(:label_issue_plural) %>

2 | 22 | 23 |

<%= l(:label_project_plural) %>

24 | 36 | 37 |

<%= l(:label_news) %>

38 | 47 | 48 |

<%= l(:label_document_plural) %>

49 | 57 | 58 |

<%= l(:label_changeset_plural) %>

59 | 67 | 68 |

<%= l(:label_wiki_page_plural) %>

69 | 77 | 78 |

<%= l(:label_message_plural) %>

79 | 88 | 89 |

<%= l(:label_attachment_plural) %>

90 | 100 | 101 |

<%= l(:label_kb_article_plural) %>

102 | 113 | 114 |

<%= l(:label_contact_plural) %>

115 | 126 | -------------------------------------------------------------------------------- /app/views/elasticsearch/_quick_reference.ru.html.erb: -------------------------------------------------------------------------------- 1 |

2 | В поле для поиска введите искомое слово, фразу или составьте поисковый запрос. 3 | С помощью несложного синтаксиса можно построить запрос, который найдет иммено то что нужно. 4 |

5 | 6 |

<%= l('es.word') %>

7 | 8 | Для поиска можно ввести искомое слово полностью 9 | 10 |

электричка

11 | 12 | или его часть (начальную): 13 | 14 |

электр

15 | 16 | Поиск слова или его части по умолчанию осуществляется по следующим полям: 17 | 18 | 23 | 24 | Если отметить галочку "Искать только в названиях", 25 | то поиск будет осуществлятся только по полю title(<%= l(:field_title) %>). 26 | 27 |

<%= l('es.phrase') %>

28 | 29 | Для поиска фразы возьмите её в двойные ковычки: 30 | 31 |

"скорый поезд"

32 | 33 |

<%= l('es.many_words') %>

34 | 35 | Для поиска нескольких слов введите их через пробел 36 | 37 |

скорый поезд

38 | 39 | Для поиска с нахождением всех слов отметьте галкой переключатель "Все слова" 40 | 41 |

<%= l('es.wildcards') %>

42 | 43 |

44 | Для поиска с подстановками можно использовать знак вопроса '?' для замены одного символа, и звездочку '*' 45 | для замены множества символов. 46 |

47 | 48 |

qu?ck bro*

49 | 50 |

51 | Знайте, что с масками запросы могут использовать огромное количество памяти и выполняться очень долго - 52 | просто подумайте, сколько термов должно быть запрошено для строки запроса "a* b* c*". 53 |

54 | 55 |

56 | Внимание
57 | Запрос со звездочкой в начале слова (например, "*ing") является особенно тяжелым, 58 | потому что все термы в индексе должны быть проверены на совпадение. 59 |

60 | 61 |

<%= l('es.field') %>

62 |

63 | Как упоминалось выше, по умолчанию поиск осуществляется по полям title, description и notes, 64 | но есть возможность указать для поиска и другие поля: 65 |

66 | 67 | 93 | 94 |

<%= l('es.attachments') %>

95 |

96 | Можно искать задачи, проекты, новости, документы, вики странички и сообщения 97 | по содержимому приложенных файлов. Например поиск по имени файла приложения: 98 |

99 |

attachments.filename:somefile.pdf

100 | Список доступных полей в приложении 101 | 102 |

<%= l('es.regular_expression') %>

103 | 104 | можно использовать регулярные выражения: 105 | 106 |

author:/joh?n(ath[oa]n)/

107 | 108 | Синтаксис поддерживаемых регулярных выражений подробно описан 109 | здесь. 110 | 111 |

112 | Внимание
113 | A query string such as the following would force Elasticsearch to visit every term in the index: 114 | Подобный запрос приведет к перебору всех термов в индексе. 115 |

116 | 117 |

/.*n/

118 | 119 | Используйте осторожно! 120 | 121 |

<%= l('es.fuzziness') %>

122 | 123 | Мы можем захотеть найти слова которые похожи, а не четко соответствуют нашему запросу. 124 | Для этого можно использовать оператор "~": 125 | 126 |

quikc~ brwn~ foks~

127 | 128 | Этот запрос использует расстояние Дамерау — Левенштейна для поиска термов с одним или двумя изменениями, 129 | где изменение - вставка, удаление или транспозиция (перестановка двух соседних символов). 130 | 131 | По умолчанию используется расстояние 2, но расстояние 1 изменение покрывает примерно 80% всех опечаток.
132 | Расстояние 1 можно указать так: 133 | 134 |

quikc~1

135 | 136 |

<%= l('es.proximity_searches') %>

137 | 138 | В то время как запрос для поиска фразы (например, "John Smith") приведет к поиску всех слов в том же порядке, 139 | запрос "похоже" позволяет искать фразу с указанными словами расположенными в другом порядке. 140 | Так же как для нечетких запросов можно указать максимальное 141 |   расстояние изменения символов в слове, для запросов "похоже" можно 142 | указать максимальное расстояние изменения слов в фразе: 143 | 144 |

"fox quick"~5

145 | 146 |

<%= l('es.ranges') %>

147 | 148 |

149 | Диапозоны могут быть указаны для полей с датами, цифрами и строками. 150 | Диапазон с включением граничных значений указывается с помощью квадратных скобок [min TO max], 151 | а диапазон не включающий граничные значения указывается с помощью фигурных скобок {min TO max}. 152 |

153 | 154 | Весь 2013 год: 155 | 156 |

datetime:[2013-01-01 TO 2013-12-31]

157 | 158 | Числа от 1 до 5 159 | 160 |

count:[1 TO 5]

161 | 162 | Тэги alpha и omega, исключая alpha и omega: 163 | 164 |

tags:{alpha TO omega}

165 | 166 | Числа от 10 167 | 168 |

count:[10 TO *]

169 | 170 | Даты до 2014 171 | 172 |

datetime:{* TO 2012-01-01}

173 | 174 | Фигурные и квадратные скобки можно использовать вместе:
175 | Числа от 1 до 5, но не включая 5 176 | 177 |

count:[1..5}

178 | 179 | Для открытых диапазонов можно использовать следующий синтаксис: 180 | 181 |

182 | age:>10
183 | age:>=10
184 | age:<10
185 | age:<=10 186 |

187 | 188 |

189 | age:(>=10 AND <20)
190 | age:(+>=10 +<20) 191 |

192 | 193 |

<%= l('es.boosting') %>

194 | 195 | #todo 196 | 197 |

<%= l('es.boolean_operators') %>

198 | 199 |

200 | По умолчанию, все термы не являются обязательными, пока находится хоть один терм. 201 |     Поиск "foo bar baz" найдет любой документ, который содержит один или более из foo bar baz. 202 |     Есть логические операторы, которые можно использовать в самой строке запроса, 203 | чтобы обеспечить больший контроль. 204 |

205 |

206 | The preferred operators are + (this term must be present) and - (this term must not be present). 207 | All other terms are optional. For example, this query: 208 | 209 | Предпочтительные операторы это "+" (этот терм должен присутствовать) и "-" (этот терм не должен присутствовать). 210 | Все остальные условия не являются обязательными. Например, этот запрос: 211 |

212 | 213 |

quick brown +fox -news

214 | 215 |

216 | указывает что:
217 | 218 | fox должен присутствовать
219 | news не должен присутствовать
220 | quick и brown опциональные — их присутствие возможно 221 |

222 | 223 |

224 | Знакомые операторы "AND", "OR" и "NOT" (также "&&", "||" и "!"), также поддерживаются. 225 | Однако разобраться в действии этих операторов может быть сложнее, чем кажется на первый взгляд. 226 |     "NOT" имеет преимущество над "AND", который имеет приоритет над "OR". 227 | В то время как + и - влияют только на терм справа от оператора, 228 |     "AND" и "OR" влияют на термы слева и справа. 229 |

230 | 231 |

<%= l('es.grouping') %>

232 | Несколько термов или выражений могут быть сгруппированы вместе с помощью круглых скобок для формирования подзапросов: 233 | 234 |

(quick OR brown) AND fox

235 |

status:(active OR pending) title:(full text search)^2

236 | 237 |

<%= l('es.reserved_characters') %>

238 | 239 |

240 | Если вам нужно использовать в поисковом запросе символы которые являются зарезервированными, 241 | то используйте "\" для экранирования. Например для поиска "(1+1)=2" можно написать такой запрос: 242 |

243 | 244 |

\(1\+1\)=2

245 | 246 |

247 | Список зарезервированных символов: + - && || ! ( ) { } [ ] ^ " ~ * ? : \ / 248 |

249 | 250 |

<%= l('es.empty_query') %>

251 | 252 |

253 | Если строка запроса пуста или содержит только пробел строка запроса интерпретируется как no_docs_query и даст пустой 254 | результат. 255 |

256 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redmine Elasticsearch Plugin 2 | 3 | 4 | [![Build Status](https://travis-ci.org/southbridgeio/redmine_elasticsearch.png?branch=master)](https://travis-ci.org/southbridgeio/redmine_elasticsearch) 5 | [![Code Climate](https://codeclimate.com/github/southbridgeio/redmine_elasticsearch.png)](https://codeclimate.com/github/southbridgeio/redmine_elasticsearch) 6 | 7 | This plugin integrates the Elasticsearch® full-text search engine into Redmine. 8 | 9 | Elasticsearch is a trademark of Elasticsearch BV, registered in the U.S. and in other countries. 10 | 11 | Our post at Medium about this plugin: https://medium.com/southbridge-io/fast-full-text-search-in-redmine-23a4b2bb0aea#.dyf614wqk 12 | 13 | 14 | ## Compatibility 15 | 16 | This plugin version is compatible only with Redmine 3.x and later. 17 | All tests are performed with Elasticsearch 5 version. 18 | Work with other versions of Elasticsearch is possible but not guarantied. 19 | 20 | ## Installation 21 | 22 | 1. This plugin requires [Redmine Sidekiq Plugin](https://github.com/southbridgeio/redmine_sidekiq). 23 | 24 | 2. Download and install [Elasticsearch](http://www.elasticsearch.org/overview/elkdownloads/). 25 | 26 | 3. Install other required plugins: 27 | 28 | * [Morphological Analysis Plugin for ElasticSearch](https://github.com/imotov/elasticsearch-analysis-morphology) 29 | 30 | * [Mapper Attachments Type for Elasticsearch](https://github.com/elasticsearch/elasticsearch-mapper-attachments) 31 | 32 | 4. To install Redmine Elasticsearch Plugin, 33 | 34 | * Download the .ZIP archive, extract files and copy the plugin directory into #{REDMINE_ROOT}/plugins. 35 | 36 | Or 37 | 38 | * Change you current directory to your Redmine root directory: 39 | 40 | cd {REDMINE_ROOT} 41 | 42 | Copy the plugin from GitHub using the following commands: 43 | 44 | git clone https://github.com/southbridgeio/redmine_elasticsearch.git plugins/redmine_elasticsearch 45 | 46 | 5. Install the required gems: 47 | 48 | bundle install 49 | 50 | 6. Reindex all documents using the following command: 51 | 52 | cd {REDMINE_ROOT} 53 | bundle exec rake redmine_elasticsearch:reindex_all RAILS_ENV=production 54 | 55 | 7. Restart Redmine 56 | 57 | Now you should be able to see the plugin in **Administration > Plugins**. 58 | 59 | ## Configuration 60 | 61 | By default, only regular fields are indexed. To index custom fields, you should add them to **config/additional_environment.rb**. For example, to enable indexing of issue tags, add the following code: 62 | 63 | config.additional_index_properties = { 64 | issues: { 65 | tags: { type: 'string' } 66 | } 67 | } 68 | 69 | For change connection options just add some to config/configuration.yml. Here an example: 70 | 71 | default: 72 | elasticsearch: 73 | log: true 74 | request_timeout: 180 75 | host: '127.0.0.1' 76 | port: 9200 77 | 78 | [Full list of available options.](https://github.com/elastic/elasticsearch-ruby/blob/master/elasticsearch-transport/lib/elasticsearch/transport/client.rb#L34) 79 | 80 | ## Usage 81 | 82 | The plugin enables full-text search capabilities in Redmine. 83 | 84 | Search is performed using a query string, which is parsed into a series of terms and operators. A term can be a single word (*another* or *issue*) or a phrase (*another issue*). Operators allow you to customize your search. 85 | 86 | For more information about the query string syntax, see [Elasticsearch Reference]( http://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#query-string-syntax). 87 | 88 | The search results are counted and displayed according to the current user permissions. 89 | 90 | You can search for one word by typing the word or its initial part in the **Search** box. If you type several words, the search results will show pages that contain at least one of these words. To search for all words, enable the **All words** check box. If you want to search for the exact phrase, surround it by double quotes (*"another issue"*). 91 | ![search options](doc/elasticsearch_1.png) 92 | 93 | By default, search is performed in the following fields: 94 | 95 | * Subject or Title 96 | * Description 97 | * Custom fields values 98 | * Notes (only for issues) 99 | 100 | If you enable the **Search titles only** check box, search will be performed only in the **Subject** / **Title** field. 101 | 102 | To perform search in other fields, you can specify the field name and its value in the query string in the following format: `field:value`. 103 | 104 | The table below lists the fields that can be searched, and the corresponding Redmine field names. Alternative field names are preceded with a tilde ('~'). 105 | 106 | ### Issues 107 | 108 | | Query string field | Redmine field name | 109 | |--------------------|------------------- | 110 | | subject ~ title | Subject | 111 | | description | Description | 112 | | author | Author | 113 | | category | Category | 114 | | created_on ~ datetime | Created | 115 | | updated_on | Updated | 116 | | closed_on | Closed | 117 | | due_date | Due date | 118 | | assigned_to | Assignee | 119 | | status | Status | 120 | | priority | Priority | 121 | | done_ratio | % Done | 122 | | custom_field_values ~ cfv | Custom fields | 123 | | fixed_version ~ version | Target version | 124 | | is_private ~ private | Private | 125 | | is_closed ~ closed | Issue closed | 126 | | journals.notes | Notes | 127 | | url | URL | 128 | 129 | *Note that 'subject ~ title' means that you can use 'subject' or 'title' in a query* 130 | 131 | For example this query will search issues with done_ratio from 0 to 50 and due_date before April 2015: 132 | 133 | done_ratio:[0 50] AND due_date:[* 2015-04] 134 | 135 | ### Projects 136 | 137 | | Query string field | Redmine field name | 138 | |--------------------|------------------- | 139 | | name ~ title | Name | 140 | | description | Description | 141 | | author | Author | 142 | | created_on ~ datetime | Created | 143 | | updated_on | Updated | 144 | | homepage | Homepage | 145 | | due_date | Due date | 146 | | url | URL | 147 | | identifier | Identifier | 148 | | custom_field_values ~ cfv | Custom fields | 149 | | is_public ~ public | Public | 150 | 151 | ### Changesets 152 | 153 | | Query string field | Redmine field name | 154 | |--------------------|------------------- | 155 | | title | Title | 156 | | comments | Comment | 157 | | committer ~ author | Author | 158 | | committed_on ~ datetime | Created | 159 | | url | URL | 160 | | revision | Revision | 161 | 162 | ### News 163 | 164 | | Query string field | Redmine field name | 165 | |--------------------|------------------- | 166 | | title | Title | 167 | | description | Description | 168 | | author | Author | 169 | | created_on ~ datetime | Created | 170 | | url | URL | 171 | | summary | Summary | 172 | | comments_count | Comments | 173 | 174 | ### Messages 175 | 176 | | Query string field | Redmine field name | 177 | |--------------------|------------------- | 178 | | subject ~ title | Subject | 179 | | content ~ description | Content | 180 | | author | Author | 181 | | created_on ~ datetime | Created | 182 | | updated_on | Updated | 183 | | replies_count | Replies| 184 | | url | URL | 185 | 186 | ### Wiki pages 187 | 188 | | Query string field | Redmine field name | 189 | |--------------------|------------------- | 190 | | title | Title | 191 | | text ~ description | Text | 192 | | author | Author | 193 | | created_on ~ datetime | Created | 194 | | updated_on | Updated | 195 | | url | URL | 196 | 197 | ### Documents 198 | 199 | | Query string field | Redmine field name | 200 | |--------------------|------------------- | 201 | | title | Title | 202 | | description | Description | 203 | | author | Author | 204 | | created_on ~ datetime | Created | 205 | | url | URL | 206 | | category | Category | 207 | 208 | ### Files 209 | 210 | | Query string field | Redmine field name | 211 | |--------------------|------------------- | 212 | | attachments.created_on | Created | 213 | | attachments.filename | Format | 214 | | attachments.description | Description | 215 | | attachments.author | Author | 216 | | attachments.filesize | Size | 217 | | attachments.digest | MD5 digest | 218 | | attachments.downloads | D/L | 219 | | attachments.file | Attachment content | 220 | 221 | ### Knowledgebase 222 | 223 | | Query string field | Redmine field name | 224 | |--------------------|------------------- | 225 | | title | Title | 226 | | summary | Summary | 227 | | content ~ description | Text | 228 | | category | Category | 229 | | tag | Tag | 230 | | author | Author | 231 | | created_on ~ datetime | Created | 232 | | updated_on | Updated | 233 | | url | URL | 234 | 235 | ### Contacts 236 | 237 | | Query string field | Redmine field name | 238 | |--------------------|------------------- | 239 | | name | Name | 240 | | description | Text | 241 | | company | Company | 242 | | phone | Phone | 243 | | email | Email | 244 | | author | Author | 245 | | created_on ~ datetime | Created | 246 | | updated_on | Updated | 247 | | url | URL | 248 | 249 | You can search for issues, projects, news, documents, wiki pages and messages by attachments. For example, to limit the search scope to containers with the **somefile.pdf** attachment filename, use the following syntax: 250 | 251 | attachments.filename:somefile.pdf 252 | 253 | ## Testing 254 | 255 | bundle exec rake redmine:plugins:test RAILS_ENV=test NAME=redmine_elasticsearch START_TEST_CLUSTER=true TEST_CLUSTER_COMMAND={PATH_TO_ELASTICSEARCH} 256 | 257 | ## License 258 | 259 | Copyright (c) 2018 Restream, Southbridge 260 | 261 | Licensed under the Apache License, Version 2.0 (the "License"); 262 | you may not use this file except in compliance with the License. 263 | You may obtain a copy of the License at 264 | 265 | http://www.apache.org/licenses/LICENSE-2.0 266 | 267 | Unless required by applicable law or agreed to in writing, software 268 | distributed under the License is distributed on an "AS IS" BASIS, 269 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 270 | See the License for the specific language governing permissions and 271 | limitations under the License. 272 | -------------------------------------------------------------------------------- /lib/redmine_elasticsearch/patches/search_controller_patch.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'search_controller' 2 | 3 | module RedmineElasticsearch 4 | module Patches 5 | module SearchControllerPatch 6 | using Refinements::String 7 | 8 | def index 9 | get_variables_from_params 10 | 11 | # quick jump to an issue 12 | if issue = detect_issue_in_question(@question) 13 | redirect_to issue_path(issue) 14 | return 15 | end 16 | 17 | # First searching with advanced query with parsing it on elasticsearch side. 18 | # If it fails then use match query. 19 | # http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-match-query.html#_comparison_to_query_string_field 20 | # The match family of queries does not go through a "query parsing" process. 21 | # It does not support field name prefixes, wildcard characters, or other "advance" features. 22 | # For this reason, chances of it failing are very small / non existent, 23 | # and it provides an excellent behavior when it comes to just analyze and 24 | # run that text as a query behavior (which is usually what a text search box does). 25 | search_options = { 26 | scope: @scope, 27 | q: @question.sanitize, 28 | titles_only: @titles_only, 29 | search_attachments: @search_attachments, 30 | all_words: @all_words, 31 | page: @page, 32 | size: @limit, 33 | from: @offset, 34 | projects: @projects_to_search 35 | } 36 | begin 37 | search_options[:search_type] = :query_string 38 | @results = perform_search(search_options) 39 | rescue => e 40 | logger.debug e 41 | search_options[:search_type] = :match 42 | @results = perform_search(search_options) 43 | end 44 | @search_type = search_options[:search_type] 45 | @result_count = @results.total 46 | @result_count_by_type = get_results_by_type_from_search_results(@results) 47 | 48 | @result_pages = Redmine::Pagination::Paginator.new @result_count, @limit, @page 49 | @offset ||= @result_pages.offset 50 | 51 | respond_to do |format| 52 | format.html { render :layout => false if request.xhr? } 53 | format.api { @results ||= []; render :layout => false } 54 | end 55 | rescue Faraday::ConnectionFailed, Errno::ECONNREFUSED => e 56 | logger.error e 57 | render_error message: :search_connection_refused, status: 503 58 | rescue => e 59 | logger.error e 60 | render_error message: :search_request_failed, status: 503 61 | end 62 | 63 | private 64 | 65 | def get_variables_from_params 66 | @question = params[:q] || '' 67 | @question.strip! 68 | @all_words = params[:all_words] ? params[:all_words].present? : true 69 | @titles_only = params[:titles_only] ? params[:titles_only].present? : false 70 | @projects_to_search = get_projects_from_params 71 | @object_types = allowed_object_types(@projects_to_search) 72 | @scope = filter_object_types_from_params(@object_types) 73 | @search_attachments = params[:attachments].presence || '0' 74 | @open_issues = params[:open_issues] ? params[:open_issues].present? : false 75 | 76 | @page = [params[:page].to_i, 1].max 77 | case params[:format] 78 | when 'xml', 'json' 79 | @offset, @limit = api_offset_and_limit 80 | else 81 | @limit = Setting.search_results_per_page.to_i 82 | @limit = 10 if @limit == 0 83 | @offset = (@page - 1) * @limit 84 | end 85 | 86 | # extract tokens from the question 87 | # eg. hello "bye bye" => ["hello", "bye bye"] 88 | @tokens = @question.scan(%r{((\s|^)"[\s\w]+"(\s|$)|\S+)}).collect { |m| m.first.gsub(%r{(^\s*"\s*|\s*"\s*$)}, '') } 89 | # tokens must be at least 2 characters long 90 | @tokens = @tokens.uniq.select { |w| w.length > 1 } 91 | end 92 | 93 | def detect_issue_in_question(question) 94 | (m = question.match(/^#?(\d+)$/)) && Issue.visible.find_by_id(m[1].to_i) 95 | end 96 | 97 | def get_projects_from_params 98 | case params[:scope] 99 | when 'all' 100 | nil 101 | when 'my_projects' 102 | User.current.projects 103 | when 'subprojects' 104 | @project ? (@project.self_and_descendants.active.all) : nil 105 | else 106 | @project 107 | end 108 | end 109 | 110 | def allowed_object_types(projects_to_search) 111 | object_types = Redmine::Search.available_search_types.dup 112 | if projects_to_search.is_a? Project 113 | # don't search projects 114 | object_types.delete('projects') 115 | # only show what the user is allowed to view 116 | object_types = object_types.select { |o| User.current.allowed_to?("view_#{o}".to_sym, projects_to_search) } 117 | end 118 | object_types 119 | end 120 | 121 | def filter_object_types_from_params(object_types) 122 | scope = object_types.select { |t| params[t] } 123 | scope = object_types if scope.empty? 124 | scope 125 | end 126 | 127 | def perform_search(options = {}) 128 | #todo: refactor this 129 | project_ids = options[:projects] ? [options[:projects]].flatten.compact.map(&:id) : nil 130 | 131 | common_must = [] 132 | 133 | search_fields = get_search_fields( 134 | titles_only: options[:titles_only], 135 | search_attachments: options[:search_attachments] 136 | ) 137 | search_operator = options[:all_words] ? 'AND' : 'OR' 138 | common_must << get_main_query(options, search_fields, search_operator) 139 | 140 | document_types = options[:scope].map(&:singularize) 141 | common_must << { terms: { _type: document_types } } 142 | 143 | if project_ids 144 | common_must << { 145 | has_parent: { 146 | parent_type: 'parent_project', 147 | query: { ids: { values: project_ids } } 148 | } 149 | } 150 | end 151 | 152 | common_must_not = [] 153 | 154 | common_must_not << { 155 | has_parent: { 156 | parent_type: 'parent_project', 157 | query: { term: { status_id: { value: Project::STATUS_ARCHIVED } } } 158 | } 159 | } 160 | 161 | # Search only open issues if such option is selected 162 | common_must_not << { term: { is_closed: { value: true } } } if @open_issues 163 | 164 | common_should = [] 165 | 166 | document_types.each do |search_type| 167 | search_klass = RedmineElasticsearch.type2class(search_type) 168 | type_query = search_klass.allowed_to_search_query(User.current) 169 | common_should << type_query if type_query 170 | end 171 | 172 | payload = { 173 | query: { 174 | bool: { 175 | must: common_must, 176 | must_not: common_must_not, 177 | should: common_should, 178 | minimum_should_match: 1 179 | } 180 | }, 181 | sort: [ 182 | { datetime: { order: 'desc' } }, 183 | :_score 184 | ], 185 | aggs: { 186 | event_types: { 187 | terms: { 188 | field: 'type' 189 | } 190 | } 191 | } 192 | } 193 | 194 | search_options = { 195 | size: options[:size], 196 | from: options[:from] 197 | }.merge payload 198 | 199 | search = Elasticsearch::Model.search search_options, [], index: RedmineElasticsearch::INDEX_NAME 200 | @query_curl ||= [] 201 | search.results 202 | end 203 | 204 | # Get list of searchable fields regardles of searching options: 'titles_only', 'search_attachments' 205 | def get_search_fields(titles_only:, search_attachments:) 206 | search_fields = titles_only ? 207 | %w(title) : 208 | %w(title description journals.notes custom_field_values) 209 | 210 | search_attachment_fields = titles_only ? 211 | %w(attachments.title) : 212 | %w(attachments.title attachments.file attachments.filename attachments.description) 213 | 214 | case search_attachments 215 | when '1' 216 | search_fields + search_attachment_fields 217 | when 'only' 218 | search_attachment_fields 219 | else 220 | search_fields 221 | end 222 | end 223 | 224 | def get_main_query(options, search_fields, search_operator) 225 | case options[:search_type] 226 | when :query_string 227 | { 228 | query_string: { 229 | query: options[:q], 230 | default_operator: search_operator, 231 | fields: search_fields, 232 | use_dis_max: true 233 | } 234 | } 235 | when :match 236 | { 237 | multi_match: { 238 | query: options[:q], 239 | operator: search_operator, 240 | fields: search_fields, 241 | use_dis_max: true 242 | } 243 | } 244 | else 245 | raise "Unknown search_type: #{options[:search_type].inspect}" 246 | end 247 | end 248 | 249 | def get_results_by_type_from_search_results(results) 250 | results_by_type = Hash.new { |h, k| h[k] = 0 } 251 | unless results.empty? 252 | results.response.aggregations.event_types.buckets.each do |facet| 253 | results_by_type[facet['key']] = facet['doc_count'] 254 | end 255 | end 256 | results_by_type 257 | end 258 | end 259 | end 260 | end 261 | -------------------------------------------------------------------------------- /app/views/elasticsearch/_quick_reference.html.erb: -------------------------------------------------------------------------------- 1 |

2 | The query string “mini-language” is used by the Query String Query and by the q query string parameter in the search 3 | API. 4 | The query string is parsed into a series of terms and operators. A term can be a single word — quick or brown —  5 | or a phrase, surrounded by double quotes — "quick brown" — which searches for all the words in the phrase, in the same 6 | order. 7 | Operators allow you to customize the search — the available options are explained below. 8 |

9 | 10 |

<%= l('es.word') %>

11 | 12 | For searching a word just type a whole word: 13 | 14 |

computer

15 | 16 | or its part (from begining): 17 | 18 |

comp

19 | 20 | By default search will be performed on these fields: 21 | 22 | 27 | 28 | If you enable checkbox "Search titles only" then search 29 | will be performed only fo field title(<%= l(:field_title) %>). 30 | 31 |

<%= l('es.phrase') %>

32 | 33 | Phrase, surrounded by double quotes — "quick brown" — will search 34 | for all the words in the phrase, in the same order. 35 | 36 |

"quick brown"

37 | 38 |

<%= l('es.many_words') %>

39 | 40 | For searching one of words: 41 | 42 |

fox brown bar

43 | 44 | For searching documents with all words you should enable "All words" checkbox. 45 | 46 |

<%= l('es.wildcards') %>

47 | 48 | Wildcard searches can be run on individual terms, using '?' to replace a single character, and '*' 49 | to replace zero or more characters: 50 | 51 |

qu?ck bro*

52 | 53 | Be aware that wildcard queries can use an enormous amount of memory and perform very badly — just think how many terms 54 | need to be queried to match the query string "a* b* c*". 55 | 56 |

57 | Warning
58 | Allowing a wildcard at the beginning of a word (eg "*ing") is particularly heavy, because all terms in the index need 59 | to be examined, just in case they match. 60 |

61 | 62 |

<%= l('es.field') %>

63 | As mentioned above, the default_fields is searched for the search terms, 64 | but it is possible to specify other fields in the query syntax: 65 | 66 | 93 | 94 |

<%= l('es.attachments') %>

95 |

96 | You can search issues, projects, news, documents, wiki_pages and messages by attachments. 97 | Here an example for searching container with attachment filename "somefile.pdf": 98 |

99 |

attachments.filename:somefile.pdf

100 | List of attachment fields 101 | 102 |

<%= l('es.regular_expression') %>

103 | 104 | Regular expression patterns can be embedded in the query string by wrapping them in forward-slashes ("/"): 105 | 106 |

author:/joh?n(ath[oa]n)/

107 | 108 | The supported regular expression syntax is explained in 109 | Regular 110 | expression syntax. 111 | 112 |

113 | Warning
114 | A query string such as the following would force Elasticsearch to visit every term in the index: 115 |

116 | 117 |

/.*n/

118 | 119 | Use with caution! 120 | 121 |

<%= l('es.fuzziness') %>

122 | 123 | We can search for terms that are similar to, but not exactly like our search terms, using the “fuzzy” operator: 124 | 125 |

quikc~ brwn~ foks~

126 | 127 | This uses the Damerau-Levenshtein distance to find all terms with a maximum of two changes, 128 | where a change is the insertion, deletion or substitution of a single character, 129 | or transposition of two adjacent characters. 130 | 131 | The default edit distance is 2, but an edit distance of 1 should be sufficient to catch 80% of all human misspellings. 132 | It can be specified as: 133 | 134 |

quikc~1

135 | 136 |

<%= l('es.proximity_searches') %>

137 | 138 | While a phrase query (eg "john smith") expects all of the terms in exactly the same order, a proximity query allows the 139 | specified words to be further apart or in a different order. In the same way that fuzzy queries can specify a maximum 140 | edit distance for characters in a word, a proximity search allows us to specify a maximum edit distance 141 | of words in a phrase: 142 | 143 |

"fox quick"~5

144 | 145 | The closer the text in a field is to the original order specified in the query string, the more relevant that document 146 | is considered to be. When compared to the above example query, the phrase "quick fox" would be considered more 147 | relevant than "quick brown fox". 148 | 149 |

<%= l('es.ranges') %>

150 | 151 |

152 | Ranges can be specified for date, numeric or string fields. Inclusive ranges are specified with square 153 | brackets [min TO max] and exclusive ranges with curly brackets {min TO max}. 154 |

155 | 156 | All days in 2013: 157 | 158 |

datetime:[2013-01-01 TO 2013-12-31]

159 | 160 | Numbers 1..5 161 | 162 |

count:[1 TO 5]

163 | 164 | Tags between alpha and omega, excluding alpha and omega: 165 | 166 |

tags:{alpha TO omega}

167 | 168 | Numbers from 10 upwards 169 | 170 |

count:[10 TO *]

171 | 172 | Dates before 2012 173 | 174 |

datetime:{* TO 2012-01-01}

175 | 176 | Curly and square brackets can be combined:
177 | Numbers from 1 up to but not including 5 178 | 179 |

count:[1..5}

180 | 181 | Ranges with one side unbounded can use the following syntax: 182 | 183 |

184 | age:>10
185 | age:>=10
186 | age:<10
187 | age:<=10 188 |

189 | 190 | Note 191 | To combine an upper and lower bound with the simplified syntax, you would need to join two clauses with an AND operator: 192 | 193 |

194 | age:(>=10 AND <20)
195 | age:(+>=10 +<20) 196 |

197 | 198 |

<%= l('es.boosting') %>

199 | Use the boost operator ^ to make one term more relevant than another. 200 | For instance, if we want to find all documents about foxes, but we are especially interested in quick foxes: 201 | 202 |

quick^2 fox

203 | 204 | The default boost value is 1, but can be any positive floating point number. Boosts between 0 and 1 reduce relevance. 205 |
206 | 207 | Boosts can also be applied to phrases or to groups: 208 |

"john smith"^2 (foo bar)^4

209 | 210 |

<%= l('es.boolean_operators') %>

211 |

212 | By default, all terms are optional, as long as one term matches. 213 | A search for foo bar baz will find any document that contains one or more of foo or bar or baz. 214 | We have already discussed the default_operator above which allows you to force all terms to be required, 215 | but there are also boolean operators which can be used in the query string itself to provide more control. 216 |

217 |

218 | The preferred operators are + (this term must be present) and - (this term must not be present). 219 | All other terms are optional. For example, this query: 220 |

221 | 222 |

quick brown +fox -news

223 | 224 |

225 | states that:
226 | 227 | fox must be present
228 | news must not be present
229 | quick and brown are optional — their presence increases the relevance 230 |

231 | 232 |

233 | The familiar operators AND, OR and NOT (also written &&, || and !) are also supported. 234 | However, the effects of these operators can be more complicated than is obvious at first glance. 235 | NOT takes precedence over AND, which takes precedence over OR. 236 | While the + and - only affect the term to the right of the operator, 237 | AND and OR can affect the terms to the left and right. 238 |

239 | 240 |

<%= l('es.grouping') %>

241 | Multiple terms or clauses can be grouped together with parentheses, to form sub-queries: 242 | 243 |

(quick OR brown) AND fox

244 | 245 | Groups can be used to target a particular field, or to boost the result of a sub-query: 246 | 247 |

status:(active OR pending) title:(full text search)^2

248 | 249 |

<%= l('es.reserved_characters') %>

250 | 251 |

252 | If you need to use any of the characters which function as operators in your query itself (and not as operators), 253 | then you should escape them with a leading backslash. For instance, to search for (1+1)=2, you would need 254 | to write your query as \(1\+1\)=2. 255 |

256 | 257 |

258 | The reserved characters are: + - && || ! ( ) { } [ ] ^ " ~ * ? : \ / 259 |

260 | 261 |

262 | Failing to escape these special characters correctly could lead to a syntax error which prevents your query from 263 | running. 264 |

265 | 266 |

<%= l('es.empty_query') %>

267 | 268 |

269 | If the query string is empty or only contains whitespaces the query string is interpreted as a no_docs_query 270 | and will yield an empty result set. 271 |

272 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2017 Restream, 2018 Southbridge 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------