├── .gitignore ├── LICENSE ├── README.md ├── Rakefile ├── bin └── so ├── lib ├── stackoverflow.rb └── stackoverflow │ └── xml2sql.rb ├── so ├── stackoverflow.gemspec └── test └── test_stackoverflow.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.swp 3 | .pydevproject 4 | .project 5 | data/ 6 | *.gem 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoviaque/stack-overflow-command-line/5fe340f4cd753c98d131262331281eb7fb2e4e38/LICENSE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Stack Overflow - Command Line Tool 2 | ================================== 3 | 4 | Allows to query Stack Overflow's questions & answers from the command line. It can either be used in "online" mode, where the StackOverflow API is queried, or offline, by downloading the latest dump released by StackOverflow. 5 | 6 | Install 7 | ======= 8 | 9 | $ sudo gem install stackoverflow 10 | 11 | If you plan on using the offline mode, download the data (be patient, several GB to get!): 12 | 13 | $ so --update 14 | 15 | Use 16 | === 17 | 18 | $ so git revert 19 | 20 | [1] (+45) GIT revert to previous commit... how? 21 | [2] (+30) Git, Revert to a commit by SHA hash? 22 | [3] (+28) How to revert a "git rm -r ."? 23 | [4] (+26) Revert multiple git commits 24 | [5] (+22) Eclipse git checkout (aka, revert) 25 | [6] (+9) git revert changes to a file in a commit 26 | [7] (+8) Revert a range of commits in git 27 | [8] (+8) Git revert last commit in heroku 28 | [9] (+8) Git revert local commit 29 | [10] (+6) Revert a Git Submodule pointer 30 | [11] (+5) Git revert merge to specific parent 31 | [12] (+5) Git force revert to HEAD~7 32 | [13] (+5) Git merge, then revert, then revert the revert 33 | [14] (+4) hg equivalant of git revert 34 | [15] (+3) git revert in Egit 35 | [16] (+3) Revert back to a specific commit in git, build, then revert to the latest changes 36 | [17] (+3) git revert back to certain commit 37 | [18] (+2) git how to revert to specific revision 38 | [19] (+2) Git: Revert to previous commit status 39 | [20] (+2) Git Revert, Checkout and Reset for Dummies 40 | [21] (+1) Git cancel a revert 41 | [22] (+1) git revert and git checkout 42 | [23] (0) undo revert in git or tortoisegit 43 | [24] (0) How to apply a git revert? 44 | [25] (0) Git Revert Error Message? 45 | 46 | $ so git revert 2 47 | 48 | [...Shows the question & answers for #2: "Git, Revert to a commit by SHA hash?"...] 49 | 50 | Offline mode: 51 | 52 | $ so --update 53 | $ so --offline git revert 54 | $ so --offline git revert 2 55 | 56 | License 57 | ======= 58 | 59 | * Data (c) [Stack Overflow][], [CC-BY-SA 3.0][] 60 | * Code (c) [Xavier Antoviaque][], [AGPLv3][] 61 | 62 | [Stack Overflow]: http://stackoverflow.com/ 63 | [Xavier Antoviaque]: http://antoviaque.org/ 64 | [CC-BY-SA 3.0]: http://creativecommons.org/licenses/by-sa/3.0/ 65 | [AGPLv3]: http://www.gnu.org/licenses/agpl-3.0.html 66 | 67 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/testtask' 2 | 3 | Rake::TestTask.new do |t| 4 | t.libs << 'test' 5 | end 6 | 7 | desc "Run tests" 8 | task :default => :test 9 | 10 | -------------------------------------------------------------------------------- /bin/so: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'stackoverflow' 4 | 5 | StackOverflow::Command.new.run 6 | 7 | -------------------------------------------------------------------------------- /lib/stackoverflow.rb: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2012 Xavier Antoviaque 2 | # 3 | # This software's license gives you freedom; you can copy, convey, 4 | # propagate, redistribute and/or modify this program under the terms of 5 | # the GNU Affero General Public License (AGPL) as published by the Free 6 | # Software Foundation (FSF), either version 3 of the License, or (at your 7 | # option) any later version of the AGPL published by the FSF. 8 | # 9 | # This program is distributed in the hope that it will be useful, but 10 | # WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 12 | # General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program in a file in the toplevel directory called 16 | # "AGPLv3". If not, see . 17 | # 18 | 19 | require 'rubygems' 20 | require 'open-uri' 21 | require 'net/http' 22 | require 'json' 23 | require 'nokogiri' 24 | require 'terminal-table/import' 25 | require 'sqlite3' 26 | require 'optparse' 27 | 28 | module StackOverflow 29 | class API 30 | def search(search_string) 31 | search_string = URI::encode(search_string) 32 | api_get("/2.0/similar?order=desc&sort=votes&title=#{search_string}&site=stackoverflow&filter=!9Tk5iz1Gf") 33 | end 34 | 35 | def get_question(question_id) 36 | api_get("/2.0/questions/#{question_id}?order=desc&sort=activity&site=stackoverflow&filter=!9Tk5izFWA") 37 | end 38 | 39 | def get_answers(question_id) 40 | api_get("/2.0/questions/#{question_id}/answers?order=desc&sort=votes&site=stackoverflow&filter=!9Tk6JYC_e") 41 | end 42 | 43 | private 44 | 45 | def api_get(path) 46 | url = "https://api.stackexchange.com" + path 47 | u = URI.parse(url) 48 | Net::HTTP.start(u.host, u.port, :use_ssl => true) do |http| 49 | response = http.get(u.request_uri) 50 | return JSON(response.body)['items'] 51 | end 52 | end 53 | end 54 | 55 | class DB 56 | def initialize 57 | @db = SQLite3::Database.new(File.join(Dir.home, ".stackoverflow/stackoverflow.db")) 58 | @db_idx = SQLite3::Database.new(File.join(Dir.home, ".stackoverflow/stackoverflow_idx.db")) 59 | end 60 | 61 | def db_error_catching 62 | begin 63 | yield 64 | rescue SQLite3::SQLException => e 65 | puts "******************" 66 | puts "** DATABASE ERROR. Did you run 'so --update'? (If you did, remove the ~/.stackoverflow directory and run 'so --update' again)" 67 | puts "******************\n\n" 68 | puts 'Details of the error:' 69 | puts e.message 70 | puts e.backtrace 71 | end 72 | end 73 | 74 | def search(search_string) 75 | # Search on titles in the small index DB, to get the ids faster 76 | sql = "SELECT id FROM questions WHERE " 77 | sub_sql = [] 78 | for search_term in search_string.split(' ') 79 | sub_sql << "title LIKE '%#{search_term}%'" 80 | end 81 | sql += sub_sql.join(' AND ') 82 | 83 | questions_ids = [] 84 | db_error_catching do 85 | @db_idx.execute(sql) do |row| 86 | questions_ids << row[0] 87 | end 88 | end 89 | return [] if questions_ids.length < 1 90 | 91 | # Then retreive details from the main (large) DB 92 | sql = "SELECT id, score, body, title FROM posts WHERE " 93 | sub_sql = [] 94 | for question_id in questions_ids 95 | sub_sql << "id=#{question_id}" 96 | end 97 | sql += sub_sql.join(' OR ') 98 | sql += ' ORDER BY score DESC LIMIT 0,25' 99 | 100 | questions = [] 101 | db_error_catching do 102 | @db.execute(sql) do |row| 103 | questions << { 'id' => row[0], 104 | 'score' => row[1], 105 | 'body' => row[2], 106 | 'title' => row[3], 107 | 'link' => '', 108 | 'answers' => get_answers(row[0]) } 109 | end 110 | end 111 | questions 112 | end 113 | 114 | def get_answers(question_id) 115 | # Search on parent ids in the small index DB, to get the ids faster 116 | sql = "SELECT id FROM answers WHERE parent_id=#{question_id}" 117 | answers_ids = [] 118 | db_error_catching do 119 | @db_idx.execute(sql) do |row| 120 | answers_ids << row[0] 121 | end 122 | end 123 | return [] if answers_ids.length < 1 124 | 125 | # Then retreive details from the main (large) DB 126 | sql = "SELECT id, score, body FROM posts WHERE " 127 | sub_sql = [] 128 | for answer_id in answers_ids 129 | sub_sql << "id=#{answer_id}" 130 | end 131 | sql += sub_sql.join(' OR ') 132 | sql += ' ORDER BY score DESC' 133 | 134 | answers = [] 135 | db_error_catching do 136 | @db.execute(sql) do |row| 137 | answers << { 'id' => row[0], 138 | 'score' => row[1], 139 | 'body' => row[2] } 140 | end 141 | end 142 | answers 143 | end 144 | end 145 | 146 | class DBUpdater 147 | def initialize 148 | @dir_path = File.join(Dir.home, ".stackoverflow") 149 | @db_version_path = File.join(@dir_path, "db_version") 150 | 151 | @remote_hostname = "dl.dropbox.com" 152 | @remote_path = "/u/31130894/" 153 | end 154 | 155 | def check_local_dir 156 | Dir.mkdir(@dir_path) if not directory_exists?(@dir_path) 157 | end 158 | 159 | def get_db_version 160 | db_version = 0 161 | if file_exists?(@db_version_path) 162 | File.open(@db_version_path, 'r') { |f| db_version = f.read().to_i } 163 | end 164 | db_version 165 | end 166 | 167 | def set_db_version(version_nb) 168 | File.open(@db_version_path, 'w+') { |f| f.write(version_nb.to_s) } 169 | end 170 | 171 | def get_remote_db_version 172 | remote_db_version = 0 173 | Net::HTTP.start(@remote_hostname) do |http| 174 | resp = http.get(File.join(@remote_path, "db_version")) 175 | resp.body.to_i 176 | end 177 | end 178 | 179 | def can_resume?(remote_db_version) 180 | can_resume = false 181 | last_download_db_version_flagpath = File.join(@dir_path, ".last_download_db_version") 182 | if file_exists?(last_download_db_version_flagpath) 183 | File.open(last_download_db_version_flagpath, 'r') do |f| 184 | can_resume = true if remote_db_version == f.read().to_i 185 | end 186 | end 187 | File.open(last_download_db_version_flagpath, 'w+') { |f| f.write(remote_db_version.to_s) } 188 | can_resume 189 | end 190 | 191 | def update 192 | check_local_dir 193 | remote_db_version = get_remote_db_version 194 | db_version = get_db_version 195 | can_resume = can_resume?(remote_db_version) 196 | if db_version < remote_db_version 197 | puts "Database update found!" 198 | puts "Updating from version #{db_version} to version #{remote_db_version} (several GB to download - this can take a while)..." 199 | download_all(can_resume) 200 | set_db_version(remote_db_version) 201 | end 202 | puts "The database is up to date (version #{get_db_version})." 203 | end 204 | 205 | def wget_available? 206 | available = false 207 | ENV['PATH'].split(':').each {|folder| available = true if File.exists?(folder+'/wget')} 208 | available 209 | end 210 | 211 | def download_all(can_resume) 212 | files_names = ["stackoverflow_idx.db.gz", "stackoverflow.db.gz"] 213 | 214 | for file_name in files_names 215 | file_path = File.join(@dir_path, file_name) 216 | File.delete(file_path) if file_exists?(file_path) and not can_resume 217 | 218 | puts "Downloading #{file_path}..." 219 | if wget_available? 220 | wget file_name 221 | else 222 | puts "Warning: 'wget' utility unavailable. Install it to be able to resume failed downloads." 223 | internal_download file_name 224 | end 225 | 226 | puts "Unpacking #{file_path}..." 227 | gunzip_file file_name 228 | end 229 | end 230 | 231 | def wget(file_name) 232 | download_url = "http://#{@remote_hostname}" + File.join(@remote_path, file_name) 233 | `wget -P #{@dir_path} -c #{download_url}` 234 | end 235 | 236 | def internal_download(file_name) 237 | Net::HTTP.start(@remote_hostname) do |http| 238 | f = open(File.join(@dir_path, file_name), "wb") 239 | begin 240 | http.request_get(File.join(@remote_path, file_name)) do |resp| 241 | resp.read_body do |segment| 242 | f.write(segment) 243 | end 244 | end 245 | ensure 246 | f.close() 247 | end 248 | end 249 | end 250 | 251 | def gunzip_file(file_name) 252 | gz_file_path = File.join(@dir_path, file_name) 253 | z = Zlib::Inflate.new(16+Zlib::MAX_WBITS) 254 | 255 | File.open(gz_file_path) do |f| 256 | File.open(gz_file_path[0...-3], "w") do |w| 257 | f.each do |str| 258 | w << z.inflate(str) 259 | end 260 | end 261 | end 262 | z.finish 263 | z.close 264 | File.delete(gz_file_path) 265 | end 266 | 267 | def directory_exists?(path) 268 | return false if not File.exist?(path) or not File.directory?(path) 269 | true 270 | end 271 | 272 | def file_exists?(path) 273 | return false if not File.exist?(path) or not File.file?(path) 274 | true 275 | end 276 | 277 | end 278 | 279 | class Formatter 280 | def questions_list(questions) 281 | nb = 1 282 | 283 | table = Terminal::Table.new do |t| 284 | questions.each do |question| 285 | if question['score'] 286 | score = question['score'] > 0 ? "+#{question['score']}" : question['score'] 287 | else 288 | score = 0 289 | end 290 | t << ["[#{nb}]", "(#{score})", question['title'][0..60]] 291 | nb += 1 292 | end 293 | t.style = {:padding_left => 2, :border_x => " ", :border_i => " ", :border_y => " "} 294 | end 295 | puts table 296 | end 297 | 298 | def question_viewer(question) 299 | answers = question['answers'] 300 | nb = 1 301 | 302 | man = ".TH STACKOVERFLOW \"1\" \"\" \"Stack Overflow\" \"#{question['title']}\"\n" 303 | man += ".SH QUESTION\n#{html2text(question['body'])}\n" 304 | 305 | answers.each do |answer| 306 | text = html2text(answer['body']) 307 | man += ".SH ANSWER [#{nb}] (+#{answer['score']})\n" 308 | man += "#{text}\n" 309 | nb += 1 310 | end 311 | 312 | tmp_file_path = "/tmp/.stack_overflow.#{question['id']}" 313 | File.open(tmp_file_path, 'w+') { |f| f.write(man) } 314 | system "man #{tmp_file_path}" 315 | end 316 | 317 | def html2text(html) 318 | doc = Nokogiri::HTML(html) 319 | doc.css('body').text.squeeze(" ").squeeze("\n").gsub(/[\n]+/, "\n\n") 320 | end 321 | 322 | def wordwrap(str, columns=80) 323 | str.gsub(/\t/, " ").gsub(/.{1,#{ columns }}(?:\s|\Z)/) do 324 | ($& + 5.chr).gsub(/\n\005/, "\n").gsub(/\005/, "\n") 325 | end 326 | end 327 | end 328 | 329 | class Command 330 | def run 331 | options = {:run => true} 332 | OptionParser.new do |opts| 333 | opts.banner = "** Usage: so [options] []" 334 | opts.on("-h", "--help", "Help") do |v| 335 | help 336 | options[:run] = false 337 | end 338 | opts.on("-u", "--update", "Update local database") do |v| 339 | DBUpdater.new.update 340 | options[:run] = false 341 | end 342 | opts.on("-o", "--offline", "Offline mode") do |v| 343 | options[:offline] = true 344 | end 345 | end.parse! 346 | 347 | if ARGV.length < 1 348 | help 349 | options[:run] = false 350 | end 351 | 352 | if options[:run] 353 | # last argument is integer when user is specifing a question_nb from the results 354 | question_nb = nil 355 | if ARGV[-1] =~ /^[0-9]+$/ 356 | question_nb = ARGV.pop.to_i 357 | end 358 | 359 | search_string = ARGV.join(' ') 360 | search(search_string, question_nb, options) 361 | end 362 | end 363 | 364 | def help 365 | puts "** Usage: so [options] []" 366 | puts " so --update\n\n" 367 | puts "Arguments:" 368 | puts "\t : Search Stack Overflow for a combination of words" 369 | puts "\t : (Optional) Display the question with this #id from the search results\n\n" 370 | puts "Options:" 371 | puts "\t-o --offline : Query the local database instead of the online StackOverflow API (offline mode)" 372 | puts "\t-u --update : Download or update the local database of StackOverflow answers (7GB+)\n\n" 373 | end 374 | 375 | def search(search_string, question_nb, options) 376 | if options['offline'] 377 | api = DB.new 378 | else 379 | api = API.new 380 | end 381 | 382 | questions = api.search(search_string) 383 | if !questions or questions.length == 0 384 | puts "No record found - Try a less specific (or sometimes, more specific) query" 385 | return 386 | end 387 | 388 | if !question_nb 389 | Formatter.new.questions_list(questions) 390 | else 391 | question = questions[question_nb.to_i - 1] 392 | Formatter.new.question_viewer(question) 393 | end 394 | end 395 | end 396 | end 397 | 398 | -------------------------------------------------------------------------------- /lib/stackoverflow/xml2sql.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # 3 | # Copyright (C) 2012 Xavier Antoviaque 4 | # 5 | # This software's license gives you freedom; you can copy, convey, 6 | # propagate, redistribute and/or modify this program under the terms of 7 | # the GNU Affero General Public License (AGPL) as published by the Free 8 | # Software Foundation (FSF), either version 3 of the License, or (at your 9 | # option) any later version of the AGPL published by the FSF. 10 | # 11 | # This program is distributed in the hope that it will be useful, but 12 | # WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 14 | # General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU Affero General Public License 17 | # along with this program in a file in the toplevel directory called 18 | # "AGPLv3". If not, see . 19 | # 20 | 21 | require 'xml' 22 | require 'sqlite3' 23 | 24 | db = SQLite3::Database.new("stackoverflow.db") 25 | db_idx = SQLite3::Database.new("stackoverflow_idx.db") 26 | 27 | db.execute(" 28 | CREATE TABLE posts ( 29 | id INTEGER PRIMARY KEY, 30 | post_type_id INTEGER, 31 | parent_id INTEGER, 32 | accepted_answer_id INTEGER, 33 | score INTEGER, 34 | body TEXT, 35 | title VARCHAR(255) 36 | ); 37 | CREATE INDEX post_type_id_idx ON posts(post_type_id); 38 | CREATE INDEX parent_id_idx ON posts(parent_id); 39 | CREATE INDEX title_idx ON posts(title); ") 40 | 41 | db_idx.execute(" 42 | CREATE TABLE questions ( 43 | id INTEGER PRIMARY KEY, 44 | title VARCHAR(255) 45 | ); 46 | CREATE INDEX title_idx ON questions(title); ") 47 | db_idx.execute(" 48 | CREATE TABLE answers ( 49 | id INTEGER PRIMARY KEY, 50 | parent_id INTEGER 51 | ); 52 | CREATE INDEX parent_id_idx ON answers(parent_id);") 53 | 54 | reader = XML::Reader.file "posts.xml" 55 | ins = db.prepare('INSERT INTO posts VALUES (?, ?, ?, ?, ?, ?, ?)') 56 | ins_question = db_idx.prepare('INSERT INTO questions VALUES (?, ?)') 57 | ins_answer = db_idx.prepare('INSERT INTO answers VALUES (?, ?)') 58 | 59 | while reader.read 60 | if reader.node_type == XML::Reader::TYPE_ELEMENT && reader.name == 'row' 61 | post = {} 62 | while reader.move_to_next_attribute == 1 63 | post[reader.name] = reader.value 64 | end 65 | ins.execute(post['Id'], 66 | post['PostTypeId'], 67 | post['ParentId'], 68 | post['AcceptedAnswerId'], 69 | post['Score'], 70 | post['Body'], 71 | post['Title']) 72 | 73 | if post['PostTypeId'] == '1' 74 | ins_question.execute(post['Id'], 75 | post['Title']) 76 | else if post['PostTypeId'] == '2' 77 | ins_answer.execute(post['Id'], 78 | post['ParentId']) 79 | end end 80 | end 81 | end 82 | 83 | -------------------------------------------------------------------------------- /so: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ruby -Ilib ./bin/so $* 4 | 5 | -------------------------------------------------------------------------------- /stackoverflow.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'stackoverflow' 3 | s.version = '0.1.5' 4 | s.date = '2012-03-26' 5 | s.summary = "Query StackOverflow from the command line (offline/online modes)" 6 | s.description = "Allows to query Stack Overflow's questions & answers from the command line. It can either be used in 'online' mode, where the StackOverflow API is queried, or offline, by downloading the latest dump released by StackOverflow." 7 | s.authors = ["Xavier Antoviaque"] 8 | s.email = 'xavier@antoviaque.org' 9 | s.files = ["lib/stackoverflow.rb"] 10 | s.homepage = 'https://github.com/antoviaque/stack-overflow-command-line' 11 | 12 | s.executables << 'so' 13 | 14 | s.add_dependency('json', '>= 1.6.5') 15 | s.add_dependency('libxml-ruby', '>= 2.3.2') 16 | s.add_dependency('nokogiri', '>= 1.5.2') 17 | s.add_dependency('sqlite3', '>= 1.3.5') 18 | s.add_dependency('terminal-table', '>= 1.4.5') 19 | end 20 | -------------------------------------------------------------------------------- /test/test_stackoverflow.rb: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2012 Xavier Antoviaque 2 | # 3 | # This software's license gives you freedom; you can copy, convey, 4 | # propagate, redistribute and/or modify this program under the terms of 5 | # the GNU Affero General Public License (AGPL) as published by the Free 6 | # Software Foundation (FSF), either version 3 of the License, or (at your 7 | # option) any later version of the AGPL published by the FSF. 8 | # 9 | # This program is distributed in the hope that it will be useful, but 10 | # WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 12 | # General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program in a file in the toplevel directory called 16 | # "AGPLv3". If not, see . 17 | # 18 | 19 | require 'test/unit' 20 | require 'stackoverflow' 21 | require 'stringio' 22 | 23 | class StackOverflowTest < Test::Unit::TestCase 24 | 25 | def test_arguments_empty 26 | ARGV.clear 27 | output = capture_stdout { StackOverflow::Command.new.run } 28 | assert_match /^\*\* Usage: so/, output 29 | end 30 | 31 | def test_arguments_help 32 | ARGV.clear 33 | ARGV << '-h' 34 | output = capture_stdout { StackOverflow::Command.new.run } 35 | assert_match /^\*\* Usage: so/, output 36 | 37 | ARGV.clear 38 | ARGV << '--help' 39 | output = capture_stdout { StackOverflow::Command.new.run } 40 | assert_match /^\*\* Usage: so/, output 41 | end 42 | 43 | def test_arguments_unknown 44 | ARGV.clear 45 | ARGV << '-sdkjflksd' 46 | raised_exception = false 47 | begin 48 | StackOverflow::Command.new.run 49 | rescue OptionParser::InvalidOption => e 50 | assert_match /invalid option: -sdkjflksd/, e.message 51 | raised_exception = true 52 | end 53 | assert_equal raised_exception, true 54 | end 55 | 56 | def capture_stdout 57 | previous_stdout, $stdout = $stdout, StringIO.new 58 | yield 59 | $stdout.string 60 | ensure 61 | $stdout = previous_stdout 62 | end 63 | 64 | end 65 | --------------------------------------------------------------------------------