├── 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 |
2 | В поле для поиска введите искомое слово, фразу или составьте поисковый запрос. 3 | С помощью несложного синтаксиса можно построить запрос, который найдет иммено то что нужно. 4 |
5 | 6 |электричка
11 | 12 | или его часть (начальную): 13 | 14 |электр
15 | 16 | Поиск слова или его части по умолчанию осуществляется по следующим полям: 17 | 18 |"скорый поезд"
32 | 33 |скорый поезд
38 | 39 | Для поиска с нахождением всех слов отметьте галкой переключатель "Все слова" 40 | 41 |44 | Для поиска с подстановками можно использовать знак вопроса '?' для замены одного символа, и звездочку '*' 45 | для замены множества символов. 46 |
47 | 48 |qu?ck bro*
49 | 50 |51 | Знайте, что с масками запросы могут использовать огромное количество памяти и выполняться очень долго - 52 | просто подумайте, сколько термов должно быть запрошено для строки запроса "a* b* c*". 53 |
54 | 55 |
56 | Внимание
57 | Запрос со звездочкой в начале слова (например, "*ing") является особенно тяжелым,
58 | потому что все термы в индексе должны быть проверены на совпадение.
59 |
63 | Как упоминалось выше, по умолчанию поиск осуществляется по полям title, description и notes, 64 | но есть возможность указать для поиска и другие поля: 65 |
66 | 67 |status:new
71 |title:(quick brown)
75 |author:"John Smith"
79 |journals.notes:(quick brown)
83 |_missing_:category
87 |_exists_:category
91 |96 | Можно искать задачи, проекты, новости, документы, вики странички и сообщения 97 | по содержимому приложенных файлов. Например поиск по имени файла приложения: 98 |
99 |attachments.filename:somefile.pdf
100 | Список доступных полей в приложении 101 | 102 |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 |
/.*n/
118 | 119 | Используйте осторожно! 120 | 121 |quikc~ brwn~ foks~
127 | 128 | Этот запрос использует расстояние Дамерау — Левенштейна для поиска термов с одним или двумя изменениями, 129 | где изменение - вставка, удаление или транспозиция (перестановка двух соседних символов). 130 | 131 | По умолчанию используется расстояние 2, но расстояние 1 изменение покрывает примерно 80% всех опечаток.quikc~1
135 | 136 |"fox quick"~5
145 | 146 |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 | Фигурные и квадратные скобки можно использовать вместе:count:[1..5}
178 | 179 | Для открытых диапазонов можно использовать следующий синтаксис: 180 | 181 |
182 | age:>10
183 | age:>=10
184 | age:<10
185 | age:<=10
186 |
189 | age:(>=10 AND <20)
190 | age:(+>=10 +<20)
191 |
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 |
224 | Знакомые операторы "AND", "OR" и "NOT" (также "&&", "||" и "!"), также поддерживаются. 225 | Однако разобраться в действии этих операторов может быть сложнее, чем кажется на первый взгляд. 226 | "NOT" имеет преимущество над "AND", который имеет приоритет над "OR". 227 | В то время как + и - влияют только на терм справа от оператора, 228 | "AND" и "OR" влияют на термы слева и справа. 229 |
230 | 231 |(quick OR brown) AND fox
235 |status:(active OR pending) title:(full text search)^2
236 | 237 |240 | Если вам нужно использовать в поисковом запросе символы которые являются зарезервированными, 241 | то используйте "\" для экранирования. Например для поиска "(1+1)=2" можно написать такой запрос: 242 |
243 | 244 |\(1\+1\)=2
245 | 246 |247 | Список зарезервированных символов: + - && || ! ( ) { } [ ] ^ " ~ * ? : \ / 248 |
249 | 250 |253 | Если строка запроса пуста или содержит только пробел строка запроса интерпретируется как no_docs_query и даст пустой 254 | результат. 255 |
256 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redmine Elasticsearch Plugin 2 | 3 | 4 | [](https://travis-ci.org/southbridgeio/redmine_elasticsearch) 5 | [](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 |  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 |computer
15 | 16 | or its part (from begining): 17 | 18 |comp
19 | 20 | By default search will be performed on these fields: 21 | 22 |"quick brown"
37 | 38 |fox brown bar
43 | 44 | For searching documents with all words you should enable "All words" checkbox. 45 | 46 |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 |
status:new
70 |title:(quick brown)
74 |author:"John Smith"
78 |book.\*:(quick brown)
83 |_missing_:title
87 |_exists_:title
91 |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 |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 |
/.*n/
118 | 119 | Use with caution! 120 | 121 |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 |"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 |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: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 |
194 | age:(>=10 AND <20)
195 | age:(+>=10 +<20)
196 |
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 |"john smith"^2 (foo bar)^4
209 | 210 |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 |
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 |(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 |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 |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 | --------------------------------------------------------------------------------