diff --git a/.gitignore b/.gitignore index f8e2b67..292859e 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ pickle-email-*.html # Ignore Byebug command history file. .byebug_history + +# Ignore Sphinx generadet config and database +/config/*.sphinx.conf +/db/sphinx diff --git a/Gemfile b/Gemfile index 69d3754..3e7d13b 100644 --- a/Gemfile +++ b/Gemfile @@ -35,6 +35,9 @@ gem 'sidekiq' gem 'sinatra', require: false gem 'whenever' +gem 'mysql2' # Don't worry, it's for Sphinx only! +gem 'thinking-sphinx' + # gem 'unicorn' gem 'thin' diff --git a/Gemfile.lock b/Gemfile.lock index 888a8c9..3c3fb5c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -164,9 +164,12 @@ GEM hashie (3.4.6) http_parser.rb (0.6.0) i18n (0.7.0) + innertube (1.1.0) jbuilder (2.6.0) activesupport (>= 3.0.0, < 5.1) multi_json (~> 1.2) + joiner (0.3.4) + activerecord (>= 4.1.0) jquery-rails (4.2.1) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) @@ -196,6 +199,7 @@ GEM rack-contrib (~> 1.1) railties (>= 3.0.0, < 5.1.0) method_source (0.8.2) + middleware (0.1.0) mime-types (3.1) mime-types-data (~> 3.2015) mime-types-data (3.2016.0521) @@ -205,6 +209,7 @@ GEM multi_json (1.12.1) multi_xml (0.5.5) multipart-post (2.0.0) + mysql2 (0.4.4) nenv (0.3.0) nokogiri (1.6.8) mini_portile2 (~> 2.1.0) @@ -238,7 +243,7 @@ GEM orm_adapter (0.5.0) parser (2.3.1.4) ast (~> 2.2) - pg (0.18.4) + pg (0.19.0) pkg-config (1.1.7) powerpack (0.1.1) private_pub (1.0.3) @@ -309,6 +314,7 @@ GEM require_all (1.3.3) responders (2.3.0) railties (>= 4.2.0, < 5.1) + riddle (1.5.12) rspec (3.5.0) rspec-core (~> 3.5.0) rspec-expectations (~> 3.5.0) @@ -391,6 +397,13 @@ GEM daemons (~> 1.0, >= 1.0.9) eventmachine (~> 1.0, >= 1.0.4) rack (>= 1, < 3) + thinking-sphinx (3.2.0) + activerecord (>= 3.1.0) + builder (>= 2.1.2) + innertube (>= 1.0.2) + joiner (>= 0.2.0) + middleware (>= 0.1.0) + riddle (>= 1.5.11) thor (0.19.1) thread_safe (0.3.5) tilt (2.0.5) @@ -446,6 +459,7 @@ DEPENDENCIES launchy letter_opener meta_request + mysql2 oj oj_mimic_json omniauth @@ -477,6 +491,7 @@ DEPENDENCIES sprockets (= 3.6.3) test_after_commit thin + thinking-sphinx turbolinks uglifier (>= 1.3.0) web-console (~> 2.0) diff --git a/app/assets/stylesheets/search.scss b/app/assets/stylesheets/search.scss new file mode 100644 index 0000000..0ba8a5a --- /dev/null +++ b/app/assets/stylesheets/search.scss @@ -0,0 +1,7 @@ +#global-search { + select { + border-radius:0; + border-left:none; + -webkit-appearance:none; + } +} diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb new file mode 100644 index 0000000..688d98c --- /dev/null +++ b/app/controllers/search_controller.rb @@ -0,0 +1,19 @@ +class SearchController < ApplicationController + before_filter :get_search_params + + def search + @found = SearchService.search(type: @search_type, query: @search_query) + render :no_results if @found.empty? + end + + private + + def get_search_params + @search_query = params[:q] + @search_type = params[:t].to_s + unless @search_type.blank? || SearchService::ALLOWED_TYPES.include?(@search_type) + @search_type = nil + render nothing: true, status: :not_implemented + end + end +end diff --git a/app/indices/answers_index.rb b/app/indices/answers_index.rb new file mode 100644 index 0000000..1e55cdd --- /dev/null +++ b/app/indices/answers_index.rb @@ -0,0 +1,3 @@ +ThinkingSphinx::Index.define :answer, with: :active_record, delta: true do + indexes body +end diff --git a/app/indices/comments_index.rb b/app/indices/comments_index.rb new file mode 100644 index 0000000..de4668f --- /dev/null +++ b/app/indices/comments_index.rb @@ -0,0 +1,3 @@ +ThinkingSphinx::Index.define :comment, with: :active_record, delta: true do + indexes body +end diff --git a/app/indices/questions_index.rb b/app/indices/questions_index.rb new file mode 100644 index 0000000..3b156d1 --- /dev/null +++ b/app/indices/questions_index.rb @@ -0,0 +1,4 @@ +ThinkingSphinx::Index.define :question, with: :active_record, delta: true do + indexes topic, sortable: true + indexes body +end diff --git a/app/indices/users_index.rb b/app/indices/users_index.rb new file mode 100644 index 0000000..8dccf1b --- /dev/null +++ b/app/indices/users_index.rb @@ -0,0 +1,3 @@ +ThinkingSphinx::Index.define :user, with: :active_record, delta: true do + indexes email +end diff --git a/app/models/answer.rb b/app/models/answer.rb index 2a20c4b..d93cb72 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -11,6 +11,8 @@ class Answer < ActiveRecord::Base validates :user_id, :question_id, presence: true validates :body, presence: true, length: (20..50_000) + default_scope { order({starred: :desc}, :created_at) } + after_commit :invoke_subscriptions_delivery, on: :create def star! diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 21cee8b..1fac4ce 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -4,4 +4,6 @@ class Attachment < ActiveRecord::Base mount_uploader :file, FileUploader validates :file, presence: true + + default_scope { order(:created_at) } end diff --git a/app/models/comment.rb b/app/models/comment.rb index 6908394..3b5bd2c 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -4,4 +4,6 @@ class Comment < ActiveRecord::Base validates :user_id, presence: true validates :body, presence: true, length: (2..2_000) + + default_scope { order(:created_at) } end diff --git a/app/models/question.rb b/app/models/question.rb index b0cd9cd..57b0532 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -13,6 +13,8 @@ class Question < ActiveRecord::Base validates :topic, presence: true, length: (10..200) validates :body, presence: true, length: (20..50_000) + default_scope { order(:created_at) } + after_create :subscribe_author! private diff --git a/app/services/search_service.rb b/app/services/search_service.rb new file mode 100644 index 0000000..82c28e3 --- /dev/null +++ b/app/services/search_service.rb @@ -0,0 +1,17 @@ +class SearchService + ALLOWED_TYPES = %w(question answer comment user) + + class << self + def search(params) + query = ThinkingSphinx::Query.escape(params[:query] || '') + type = params[:type] || '' + klass(type)&.search(query) + end + + def klass(type) + return ThinkingSphinx if type == '' + return nil unless ALLOWED_TYPES.include?(type) + type.classify.constantize + end + end +end diff --git a/app/views/comments/_comment.slim b/app/views/comments/_comment.slim index 39dfa2c..e41e630 100644 --- a/app/views/comments/_comment.slim +++ b/app/views/comments/_comment.slim @@ -1,4 +1,4 @@ -p +p id="comment-#{comment.id}" = comment.body span.little style="color:gray" ' diff --git a/app/views/search/_answer.slim b/app/views/search/_answer.slim new file mode 100644 index 0000000..633ffc6 --- /dev/null +++ b/app/views/search/_answer.slim @@ -0,0 +1,7 @@ +p + ' Answer to the Question + i + = link_to "\"#{entry.question.topic}\"", entry.question + | : +p.lead + = link_to entry.body, question_path(entry.question, anchor: "answer-#{entry.id}") \ No newline at end of file diff --git a/app/views/search/_comment.slim b/app/views/search/_comment.slim new file mode 100644 index 0000000..49100e6 --- /dev/null +++ b/app/views/search/_comment.slim @@ -0,0 +1,13 @@ +- commentable = entry.commentable +- question = commentable.kind_of?(Question) ? commentable : commentable.question +p + ' Comment to the + => commentable.class.name + i + - if commentable.kind_of?(Question) + = link_to "\"#{commentable.topic}\"", commentable + - else + = link_to "\"#{commentable.body}\"", question_path(question, anchor: "answer-#{commentable.id}") + | : +p.lead + = link_to entry.body, question_path(question, anchor: "comment-#{entry.id}") diff --git a/app/views/search/_question.slim b/app/views/search/_question.slim new file mode 100644 index 0000000..ada8976 --- /dev/null +++ b/app/views/search/_question.slim @@ -0,0 +1,4 @@ +p + ' Question: +p.lead + = link_to entry.topic, entry \ No newline at end of file diff --git a/app/views/search/_user.slim b/app/views/search/_user.slim new file mode 100644 index 0000000..05dde72 --- /dev/null +++ b/app/views/search/_user.slim @@ -0,0 +1,4 @@ +p + ' User with email: +p.lead + = link_to entry.email, "mailto:#{entry.email}" \ No newline at end of file diff --git a/app/views/search/no_results.slim b/app/views/search/no_results.slim new file mode 100644 index 0000000..f29ad41 --- /dev/null +++ b/app/views/search/no_results.slim @@ -0,0 +1,4 @@ +h2 Search returns no results + +.well style="margin-top:40px" + p We sorry but try to search with another query! diff --git a/app/views/search/search.slim b/app/views/search/search.slim new file mode 100644 index 0000000..7e8b924 --- /dev/null +++ b/app/views/search/search.slim @@ -0,0 +1,13 @@ +h2 Search Results + +h4 #{pluralize(@found.count, 'entry')} found: + +- @found.each_with_index do |entry, cnt| + .well + .row + .col-xs-1 + p.lead + = cnt + 1 + | . + .col-xs-11 + = render entry.class.name.downcase, entry: entry diff --git a/app/views/shared/_navbar.slim b/app/views/shared/_navbar.slim index c426909..08c9643 100644 --- a/app/views/shared/_navbar.slim +++ b/app/views/shared/_navbar.slim @@ -6,14 +6,14 @@ nav.navbar.navbar-default role="navigation" span.icon-bar = link_to root_path, class: 'navbar-brand' = image_tag 'logo.png', size: '24x24', alt: 'Deep Recursion', class: 'pull-left' - |   Deep Recursion + |   D/R #navbar-collapse.collapse.navbar-collapse ul.nav.navbar-nav - = navbar_item 'Questions List', questions_path + = navbar_item 'Questions', questions_path - if policy(Question).new? = navbar_item 'Ask a Question', new_question_path - if current_user&.admin? - = navbar_item 'Applications', oauth_applications_path + = navbar_item 'Apps', oauth_applications_path ul.nav.navbar-nav.navbar-right li - if user_signed_in? @@ -27,3 +27,10 @@ nav.navbar.navbar-default role="navigation" = navbar_item new_user_session_path => glyph 'log-in' | Sign In + = form_tag search_path, method: :get, class: 'navbar-form navbar-right' + .form-group + .input-group#global-search + = search_field_tag :q, @search_query, class: 'form-control', size: 30, placeholder: 'Search for...' + span.input-group-btn + = select_tag :t, options_for_select({questions: :question, answers: :answer, comments: :comment, users: :user}, @search_type), prompt: 'anything ▾', class: 'form-control' + button.btn.btn-default type="submit" Go! diff --git a/config/application.rb b/config/application.rb index 3eda4ec..361e34f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -17,6 +17,8 @@ module DeepRecursion class Application < Rails::Application + config.autoload_paths << Rails.root.join('services') + # Use the responders controller from the responders gem config.app_generators.scaffold_controller :responders_controller diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index bc19a1e..beb4e87 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -114,7 +114,7 @@ # access will be blocked just in the third day. Default is 0.days, meaning # the user cannot access the website without confirming their account. # config.allow_unconfirmed_access_for = 2.days - config.allow_unconfirmed_access_for = 2.days if Rails.env.test? + # config.allow_unconfirmed_access_for = 2.days if Rails.env.test? # A period that the user is allowed to confirm their account before their # token becomes invalid. For example, if set to 3.days, the user can confirm diff --git a/config/routes.rb b/config/routes.rb index d114f96..4f69500 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,6 +12,8 @@ devise_for :users, controllers: {omniauth_callbacks: :omniauth_callbacks} match 'account/confirm_email', via: [:get, :patch] + get :search, to: 'search#search' + concern :rateable do member do post :rate_inc diff --git a/config/schedule.rb b/config/schedule.rb index b8a02b2..2825520 100644 --- a/config/schedule.rb +++ b/config/schedule.rb @@ -1,3 +1,7 @@ every 1.day, at: '9:00' do runner 'MailDigestJob.perform_later' end + +every 1.hour, at: 42 do + rake 'ts:rebuild' +end diff --git a/config/thinking_sphinx.yml b/config/thinking_sphinx.yml new file mode 100644 index 0000000..339e1b5 --- /dev/null +++ b/config/thinking_sphinx.yml @@ -0,0 +1,2 @@ +test: + mysql41: 9307 diff --git a/db/migrate/20160924001243_create_sphinx_deltas.rb b/db/migrate/20160924001243_create_sphinx_deltas.rb new file mode 100644 index 0000000..b9d924b --- /dev/null +++ b/db/migrate/20160924001243_create_sphinx_deltas.rb @@ -0,0 +1,8 @@ +class CreateSphinxDeltas < ActiveRecord::Migration + def up + add_column :questions, :delta, :boolean, default: true, null: false + add_column :answers, :delta, :boolean, default: true, null: false + add_column :comments, :delta, :boolean, default: true, null: false + add_column :users, :delta, :boolean, default: true, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index a76f79f..5f0fc3c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20160921133756) do +ActiveRecord::Schema.define(version: 20160924001243) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -24,6 +24,7 @@ t.datetime "updated_at", null: false t.integer "user_id" t.boolean "starred", default: false, null: false + t.boolean "delta", default: true, null: false end add_index "answers", ["question_id"], name: "index_answers_on_question_id", using: :btree @@ -43,9 +44,10 @@ t.integer "user_id" t.integer "commentable_id" t.string "commentable_type" - t.text "body", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.text "body", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "delta", default: true, null: false end create_table "identities", force: :cascade do |t| @@ -101,12 +103,13 @@ add_index "oauth_applications", ["uid"], name: "index_oauth_applications_on_uid", unique: true, using: :btree create_table "questions", force: :cascade do |t| - t.string "topic", limit: 200, null: false - t.text "body", null: false - t.integer "rating", default: 0, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "topic", limit: 200, null: false + t.text "body", null: false + t.integer "rating", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.integer "user_id" + t.boolean "delta", default: true, null: false end add_index "questions", ["user_id"], name: "index_questions_on_user_id", using: :btree @@ -133,14 +136,14 @@ add_index "subscriptions", ["user_id", "question_id"], name: "index_subscriptions_on_user_id_and_question_id", unique: true, using: :btree create_table "users", force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "email", default: "", null: false - t.string "encrypted_password", default: "", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "email", default: "", null: false + t.string "encrypted_password", default: "", null: false t.string "reset_password_token" t.datetime "reset_password_sent_at" t.datetime "remember_created_at" - t.integer "sign_in_count", default: 0, null: false + t.integer "sign_in_count", default: 0, null: false t.datetime "current_sign_in_at" t.datetime "last_sign_in_at" t.inet "current_sign_in_ip" @@ -150,6 +153,7 @@ t.datetime "confirmation_sent_at" t.string "unconfirmed_email" t.boolean "admin" + t.boolean "delta", default: true, null: false end add_index "users", ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true, using: :btree diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb new file mode 100644 index 0000000..8fa86e9 --- /dev/null +++ b/spec/controllers/search_controller_spec.rb @@ -0,0 +1,68 @@ +require 'rails_helper' + +shared_examples :sphinx_search_controller_with_params do |entity, attribute, search_type, validity = true| + if entity == :empty + let!(:object) { '' } + attribute = :to_s + else + let!(:object) { create(entity) } + end + + context "search by #{entity.to_s.capitalize}.#{attribute}" do + if validity + it 'calls SearchService' do + expect(SearchService).to receive(:klass).with(search_type). + and_return(search_type.empty? ? ThinkingSphinx : object.class) + expect(SearchService).to receive(:search). + with(type: search_type, query: object.send(attribute)).and_call_original + get :search, q: object.send(attribute), t: search_type + end + else + it 'doesn\'t call SearchService' do + expect(SearchService).to_not receive(:klass) + expect(SearchService).to_not receive(:search) + get :search, q: object.send(attribute), t: search_type + end + end + end + + context 'execute search on empty database' do + before { get :search, q: object.send(attribute), t: search_type } + subject { response } + + it "sets @found to #{validity ? 'empty Array' : 'nil'}" do + get :search, q: object.send(attribute), t: search_type + expect(assigns(:found)).to eq validity ? [] : nil + end + + it { is_expected.to have_http_status validity ? :success : :not_implemented } + + it("renders #{validity ? ':no_results' : 'nothing'}") do + is_expected.to render_template validity ? :no_results : nil + end + end +end + +shared_examples :sphinx_search_controller do |entity, attribute = :body| + context 'with type in query' do + include_examples :sphinx_search_controller_with_params, entity, attribute, entity.to_s + end + + context 'without type in query' do + include_examples :sphinx_search_controller_with_params, entity, attribute, '' + end +end + +describe SearchController do + describe 'GET #search' do + include_examples :sphinx_search_controller, :question, :topic + include_examples :sphinx_search_controller, :question + include_examples :sphinx_search_controller, :answer + include_examples :sphinx_search_controller, :comment + include_examples :sphinx_search_controller, :user, :email + + context 'search with invalid search type' do + include_examples :sphinx_search_controller_with_params, :empty, :empty, 'INVALID', false + end + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 964f557..e60eee9 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -6,6 +6,6 @@ factory :user do email password '12345678' - password_confirmation '12345678' + confirmed_at {Time.now} end end diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb new file mode 100644 index 0000000..62ab33f --- /dev/null +++ b/spec/features/search_spec.rb @@ -0,0 +1,100 @@ +require 'features_helper' + +shared_examples :sphinx_search_feature do |attribute = :body| + given!(:junk_questions) { create_list(:question, 2) } + given!(:junk_answers) { create_list(:answer, 2) } + given!(:junk_comments) { create_list(:comment, 2) } + given!(:junk_users) { create_list(:user, 2) } + + let!(:text) { object.send(attribute) } + + before { visit new_question_path } + + scenario 'search with type in query' do + within '#global-search' do + fill_in 'q', with: text + select object.class.name.downcase, from: 't' + click_button 'Go!' + end + + expect(current_path).to eq '/search' + + expect(page).to have_content text + (junk_questions.map(&:topic) + + junk_answers.map(&:body) + + junk_comments.map(&:body) + + junk_users.map(&:email)).each do |junk| + expect(page).to_not have_content junk + end + end + + scenario 'search without type in query' do + within '#global-search' do + fill_in 'q', with: text + click_button 'Go!' + end + + expect(current_path).to eq '/search' + expect(page).to have_content text + end + + scenario 'search unsearchable' do + within '#global-search' do + fill_in 'q', with: 'unsearchablestring' + click_button 'Go!' + end + + expect(current_path).to eq '/search' + expect(page).to_not have_content text + end +end + +feature 'Search Questions', %q( + To get information I need + As a Guest + I want to search information in Questions +), :sphinx do + given!(:object) { create(:question) } + + include_examples :sphinx_search_feature, :topic +end + +feature 'Search Answers', %q( + To get information I need + As a Guest + I want to search information in Answers +), :sphinx do + given!(:object) { create(:answer) } + + include_examples :sphinx_search_feature +end + +feature 'Search Answers Comments', %q( + To get information I need + As a Guest + I want to search information in Answers Comments +), :sphinx do + given!(:object) { create(:comment, commentable: create(:answer)) } + + include_examples :sphinx_search_feature +end + +feature 'Search Questions Comments', %q( + To get information I need + As a Guest + I want to search information in Questions Comments +), :sphinx do + given!(:object) { create(:comment, commentable: create(:question)) } + + include_examples :sphinx_search_feature +end + +feature 'Search Users', %q( + To get information I need + As a Guest + I want to search information in Answers +), :sphinx do + given!(:object) { create(:user) } + + include_examples :sphinx_search_feature, :email +end diff --git a/spec/features/sign_up_spec.rb b/spec/features/sign_up_spec.rb index 6ef1c15..978eee0 100644 --- a/spec/features/sign_up_spec.rb +++ b/spec/features/sign_up_spec.rb @@ -14,6 +14,8 @@ fill_in 'Password confirmation', with: password click_on 'Sign up' - expect(page.find('.alert')).to have_content 'Welcome! You have signed up successfully.' + expect(page.find('.alert')).to have_content Devise.allow_unconfirmed_access_for > 0 ? + 'Welcome! You have signed up successfully.' : + 'A message with a confirmation link has been sent to your email address.' end end diff --git a/spec/features/star_answer_spec.rb b/spec/features/star_answer_spec.rb index eb9ec6d..2db44bc 100644 --- a/spec/features/star_answer_spec.rb +++ b/spec/features/star_answer_spec.rb @@ -41,8 +41,10 @@ second_answer = create(:answer, question: question, user: user) visit question_path(question) within("#answer-#{second_answer.id}") { click_on 'Star' } - within "#answers" do - expect(page.first('div')[:id]).to eq "answer-#{second_answer.id}" + sleep 0.1 + expect(page).to have_content second_answer.body + within '#answers' do + expect(page.first('div')).to have_content second_answer.body end end diff --git a/spec/features_helper.rb b/spec/features_helper.rb index 3d0faf9..23120a0 100644 --- a/spec/features_helper.rb +++ b/spec/features_helper.rb @@ -31,6 +31,10 @@ DatabaseCleaner.strategy = :truncation end + config.before(:each, :sphinx) do + DatabaseCleaner.strategy = :truncation + end + config.before(:each) do DatabaseCleaner.start end diff --git a/spec/support/sphinx.rb b/spec/support/sphinx.rb new file mode 100644 index 0000000..64d905b --- /dev/null +++ b/spec/support/sphinx.rb @@ -0,0 +1,23 @@ +module SphinxHelpers + def index + ThinkingSphinx::Test.index + sleep 0.1 until index_finished? + end + + def index_finished? + Dir[Rails.root.join(ThinkingSphinx::Test.config.indices_location, '*.{new,tmp}*')].empty? + end +end + +RSpec.configure do |config| + config.include SphinxHelpers, type: :feature + + config.before(:suite) do + ThinkingSphinx::Test.init + ThinkingSphinx::Test.start_with_autostop + end + + config.before(:each, :sphinx) do + index + end +end