├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── exe └── retscli ├── lib ├── retscli.rb └── retscli │ ├── display_adapter.rb │ ├── shell.rb │ ├── shell_commands.rb │ └── version.rb ├── retscli.gemspec └── test ├── display_adapter_test.rb ├── fixtures.rb ├── shell_test.rb └── test_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0.0 4 | - 2.1.8 5 | - 2.2.4 6 | - 2.3.0 7 | before_install: gem install bundler -v 1.11 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.2.0 2 | - Ability to search resources 3 | - Better error handling 4 | - Debug mode 5 | - Ability to login inside console incase session becomes invalid 6 | - Fix user agent password flag 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in retscli.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | retscli (0.2.0) 5 | rets (~> 0.10) 6 | terminal-table (~> 1.5) 7 | thor (~> 0.19) 8 | tty-spinner (~> 0.2) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | domain_name (0.5.20190701) 14 | unf (>= 0.0.5, < 1.0.0) 15 | http-cookie (1.0.4) 16 | domain_name (~> 0.5) 17 | httpclient (2.8.3) 18 | mini_portile2 (2.6.1) 19 | minitest (5.14.4) 20 | nokogiri (1.12.3) 21 | mini_portile2 (~> 2.6.1) 22 | racc (~> 1.4) 23 | racc (1.5.2) 24 | rake (10.5.0) 25 | rets (0.11.2) 26 | http-cookie (~> 1.0.0) 27 | httpclient (~> 2.7) 28 | nokogiri (~> 1.5) 29 | terminal-table (1.8.0) 30 | unicode-display_width (~> 1.1, >= 1.1.1) 31 | thor (0.20.3) 32 | tty-cursor (0.7.1) 33 | tty-spinner (0.9.3) 34 | tty-cursor (~> 0.7) 35 | unf (0.1.4) 36 | unf_ext 37 | unf_ext (0.0.7.7) 38 | unicode-display_width (1.7.0) 39 | 40 | PLATFORMS 41 | ruby 42 | 43 | DEPENDENCIES 44 | minitest (~> 5.0) 45 | rake (~> 10.0) 46 | retscli! 47 | 48 | BUNDLED WITH 49 | 2.1.4 50 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Ari Summer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # retscli 2 | 3 | [![Gem Version](https://badge.fury.io/rb/retscli.svg)](https://badge.fury.io/rb/retscli) 4 | [![Build Status](https://travis-ci.org/summera/retscli.svg?branch=master)](https://travis-ci.org/summera/retscli) 5 | 6 | CLI for querying RETS servers and searching metadata. 7 | 8 | This gem is built on top of the [rets](http://github.com/estately/rets) gem, which handles the actual querying and parsing of the RETS server requests. Big thanks to the [Estately](http://www.estately.com) team for their work! 9 | 10 | ![retscli gif](https://github.com/summera/gifs/blob/master/retscli/search_metadata_cropped.gif?raw=true) 11 | ![retscli png](https://github.com/summera/gifs/blob/master/retscli/search.png?raw=true) 12 | 13 | ## Installation 14 | $ gem install retscli 15 | 16 | ## Features 17 | - Search metadata for keywords. Results are highlighted for readability. 18 | - Search resources. Results are displayed in a nicely formatted ascii table. 19 | - Open command results in your editor of choice. 20 | - Debug RETS requests and queries in debug mode. 21 | - Command results are paged using less by default. 22 | 23 | ## Usage 24 | 25 | #### CLI Commands 26 | ```bash 27 | $ retscli help 28 | Commands: 29 | retscli capabilities [LOGIN URL] # Display capabilities for rets server 30 | retscli console [LOGIN URL] # Start rets console 31 | retscli help [COMMAND] # Describe available commands or one specific command 32 | retscli validate [LOGIN URL] # Validate rets credentials 33 | ``` 34 | 35 | To see the available flags/options, run help on the command 36 | 37 | ```bash 38 | $ retscli help console 39 | Usage: 40 | retscli console [LOGIN URL] 41 | 42 | Options: 43 | -u, [--username=USERNAME] # Username 44 | -p, [--password=PASSWORD] # Password 45 | -v, [--version=VERSION] # Rets version 46 | # Default: RETS/1.7.2 47 | -a, [--agent=AGENT] # User agent 48 | -t, [--ua-password=UA_PASSWORD] # User agent password 49 | -d, [--debug], [--no-debug] # Debug mode 50 | 51 | Start rets console 52 | ``` 53 | 54 | #### Debug Mode 55 | To view requests being made to the RETS server, enable debug mode with the `-d` flag. Once enabled, helpful info will be logged to `$stdout`. This is helpful to debug errors thrown by the RETS server and to adjust queries. 56 | 57 | #### Rets Console Commands 58 | After dropping into the RETS console, you get a bunch of useful commands for searching and exploring the RETS server 59 | 60 | ```bash 61 | $ retscli console http://rets.server.com -u summera -p password 62 | 63 | summera@rets.server.com > help 64 | Commands: 65 | capabilities # Display capabilities for rets server 66 | classes [RESOURCE] # List available classes for resource 67 | help [COMMAND] # Describe available commands or one specific command 68 | login # Re-Login to RETS server. Use if session is no longer valid 69 | metadata # View metadata 70 | objects [RESOURCE] # List available objects for resource 71 | resources # List available resources 72 | search [RESOURCE] [CLASS] [QUERY] # Search resources, e.g. properties, open houses, etc. 73 | search-metadata # Search metadata tables 74 | tables [RESOURCE] [CLASS] # List available tables for class of resource 75 | timezone-offset # System timezone offset 76 | ``` 77 | 78 | Again, to see available flags/options, run help on the command. Many of the commands have an `editor` option if you feel the need to get down and dirty in your editor of choice. 79 | 80 | 81 | ```bash 82 | summera@rets.server.com > help search-metadata 83 | Usage: 84 | search-metadata 85 | 86 | Options: 87 | -r, [--resources=one two three] # Filter metadata by resources 88 | -c, [--classes=one two three] # Filter metadata by classes 89 | -e, [--editor=EDITOR] # Open search results in editor 90 | 91 | Search metadata tables 92 | ``` 93 | 94 | ## Notes 95 | - When opening output in your editor, retscli will check the `$EDITOR` environment variable. If this is not set, it falls back to nano. 96 | - Much of the output is piped through `less` by default to allow for easy paging. If you'd like to change this, set your preferred pager in the `$PAGER` environment variable. 97 | - Retscli uses the ruby readline module for the rets console. 98 | - If any arguments and/or options have spaces in them, you will need to wrap them in quotes so that retscli does not split on the spaces. 99 | 100 | ## Contributing 101 | 102 | 1. Fork it ( https://github.com/summera/retscli/fork ) 103 | 1. Create your feature branch (`git checkout -b my-new-feature`) 104 | 1. Commit your changes (`git commit -am 'Add some feature'`) 105 | 1. Run the test suite (`bundle exec rake`) 106 | 1. Push to the branch (`git push origin my-new-feature`) 107 | 1. Create a new Pull Request 108 | 109 | 110 | ## License 111 | 112 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 113 | 114 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList['test/**/*_test.rb'] 8 | end 9 | 10 | task :default => :test 11 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "retscli" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /exe/retscli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'retscli' 4 | 5 | Retscli::Cli.start(ARGV) 6 | -------------------------------------------------------------------------------- /lib/retscli.rb: -------------------------------------------------------------------------------- 1 | require "retscli/version" 2 | require "rets" 3 | require "thor" 4 | require "terminal-table" 5 | require "retscli/shell" 6 | require "retscli/display_adapter" 7 | 8 | module Retscli 9 | class Cli < Thor 10 | 11 | desc 'validate [LOGIN URL]', 'Validate rets credentials' 12 | method_option :username, aliases: '-u', :desc => 'Username' 13 | method_option :password, aliases: '-p', :desc => 'Password' 14 | method_option :version, aliases: '-v', :desc => 'Rets version', :default => 'RETS/1.7.2' 15 | method_option :agent, aliases: '-a', :desc => 'User agent' 16 | method_option :ua_password, aliases: '-t', :desc => 'User agent password' 17 | method_option :debug, :aliases => '-d', :desc => 'Debug mode', :type => :boolean, :default => false 18 | def validate(url) 19 | client = rets_client(url, options) 20 | 21 | begin 22 | client.login 23 | client.logout 24 | puts set_color("\u2713 Valid Credentials", :green) 25 | true 26 | rescue => e 27 | puts set_color("\u2717 Invalid Credential\n#{e.message}", :red) 28 | false 29 | end 30 | end 31 | 32 | desc 'capabilities [LOGIN URL]', 'Display capabilities for rets server' 33 | method_option :username, aliases: '-u', :desc => 'Username' 34 | method_option :password, aliases: '-p', :desc => 'Password' 35 | method_option :version, aliases: '-v', :desc => 'Rets version', :default => 'RETS/1.7.2' 36 | method_option :agent, aliases: '-a', :desc => 'User agent' 37 | method_option :ua_password, aliases: '-t', :desc => 'User agent password' 38 | method_option :debug, :aliases => '-d', :desc => 'Debug mode', :type => :boolean, :default => false 39 | def capabilities(url) 40 | client = rets_client(url, options) 41 | 42 | begin 43 | client.login 44 | display_adapter = Retscli::DisplayAdapter.new(client) 45 | display_adapter.page(display_adapter.capabilities) 46 | client.logout 47 | rescue => e 48 | puts set_color("#{e.message}", :red) 49 | end 50 | end 51 | 52 | desc 'console [LOGIN URL]', 'Start rets console' 53 | method_option :username, aliases: '-u', :desc => 'Username' 54 | method_option :password, aliases: '-p', :desc => 'Password' 55 | method_option :version, aliases: '-v', :desc => 'Rets version', :default => 'RETS/1.7.2' 56 | method_option :agent, aliases: '-a', :desc => 'User agent' 57 | method_option :ua_password, aliases: '-t', :desc => 'User agent password' 58 | method_option :debug, :aliases => '-d', :desc => 'Debug mode', :type => :boolean, :default => false 59 | def console(url) 60 | client = rets_client(url, options) 61 | Retscli::Shell.new(client).start 62 | end 63 | 64 | no_commands do 65 | def rets_client(url, params={}) 66 | logger = options[:debug] ? Logger.new($stdout) : nil 67 | 68 | ::Rets::Client.new({ 69 | :login_url => url, 70 | :username => params[:username], 71 | :password => params[:password], 72 | :version => params[:version], 73 | :agent => params[:agent], 74 | :ua_password => params[:ua_password], 75 | :logger => logger 76 | }) 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/retscli/display_adapter.rb: -------------------------------------------------------------------------------- 1 | require "terminal-table" 2 | 3 | module Retscli 4 | class DisplayAdapter 5 | NO_RESULTS = 'No Results'.freeze 6 | EMPTY_VALUE = ''.freeze 7 | 8 | def initialize(client) 9 | @client = client 10 | @colorer = ::Thor::Shell::Color.new 11 | end 12 | 13 | def login 14 | @client.login 15 | set_color("\u2713 Logged in", :green) 16 | end 17 | 18 | def capabilities 19 | Terminal::Table.new(:rows => @client.capabilities.to_a) 20 | end 21 | 22 | def resources 23 | retrieve_metadata.tree.map do |key, resource| 24 | render_resource(resource) 25 | end.join("\n") 26 | end 27 | 28 | def classes(resource) 29 | resource_tree = retrieve_metadata.tree[resource.downcase] 30 | 31 | if resource_tree 32 | resource_tree.rets_classes.map do |klass| 33 | render_class(klass) 34 | end.join("\n") 35 | else 36 | set_color("#{resource} resource does not exist", :red) 37 | end 38 | end 39 | 40 | def objects(resource) 41 | resource_tree = retrieve_metadata.tree[resource.downcase] 42 | 43 | if resource_tree 44 | resource_tree.rets_objects.map do |object| 45 | render_object(object) 46 | end.join("\n") 47 | else 48 | set_color("#{resource} resource does not exist", :red) 49 | end 50 | end 51 | 52 | def tables(resource, klass) 53 | resource_tree = retrieve_metadata.tree[resource.downcase] 54 | return set_color("#{resource} resource does not exist", :red) unless resource_tree 55 | 56 | resource_class = resource_tree.rets_classes.detect { |rc| rc.name.downcase == klass.downcase } 57 | return set_color("#{klass} class does not exist", :red) unless resource_class 58 | 59 | resource_class.tables.map do |table| 60 | render_table(table) 61 | end.join("\n") 62 | end 63 | 64 | def timezone_offset 65 | offset = retrieve_metadata 66 | .metadata_types[:system] 67 | .first 68 | .fragment 69 | .xpath('SYSTEM') 70 | .attribute('TimeZoneOffset') 71 | .to_s 72 | 73 | if offset.empty? 74 | set_color("No offset specified", :red) 75 | else 76 | offset 77 | end 78 | end 79 | 80 | def metadata 81 | build_tree 82 | end 83 | 84 | def search_metadata(search, options={}) 85 | options = { :resources => [], :classes => [] , :color => true }.merge(options) 86 | search_results = '' 87 | resources = options[:resources].map!{ |res| res.downcase } 88 | classes = options[:classes].map!{ |klass| klass.downcase } 89 | 90 | 91 | retrieve_metadata.tree.each do |key, res| 92 | next if !resources.empty? && !resources.include?(res.id.downcase) 93 | match_found_for_resouce = false 94 | 95 | res.rets_classes.each do |klass| 96 | next if !classes.empty? && !classes.include?(klass.name.downcase) 97 | match_found_for_class = false 98 | 99 | klass.tables.each do |table| 100 | if match = search_table(table, search) 101 | search_results << render_resource(res) << "\n" unless match_found_for_resouce 102 | search_results << tab_over(render_class(klass), 1) << "\n" unless match_found_for_class 103 | 104 | rendered_table = tab_over(render_table(table), 2) 105 | search_results << rendered_table.gsub!(match.regexp) do |sub| 106 | options[:color] ? set_color(sub, :red, :on_white) : sub 107 | end << "\n" 108 | 109 | match_found_for_resouce = true 110 | match_found_for_class = true 111 | end 112 | end 113 | end 114 | end 115 | 116 | search_results 117 | end 118 | 119 | def search(resource, klass, query, options={}) 120 | select = options[:select] ? options[:select].join(',') : '' 121 | count = options[:count] ? 2 : 0 122 | 123 | results = @client.find( 124 | :all, 125 | search_type: resource, 126 | class: klass, 127 | query: query, 128 | select: select, 129 | limit: options[:limit], 130 | offset: options[:offset], 131 | count: count, 132 | format: options[:format], 133 | no_records_not_an_error: true 134 | ) 135 | 136 | if results.is_a?(Integer) 137 | resource_table([{ 'count' => results }]) 138 | else 139 | resource_table(results) 140 | end 141 | end 142 | 143 | def page(output) 144 | begin 145 | pager = ENV['PAGER'] || 'less' 146 | IO.popen(pager, 'w') { |f| f.puts(output) } 147 | rescue Errno::EPIPE 148 | end 149 | end 150 | 151 | def open_in_editor(text, editor='editor') 152 | editor = editor == 'editor' ? (ENV['EDITOR'] || 'nano') : editor 153 | open_tempfile_with_content(editor, text) 154 | end 155 | 156 | private 157 | def resource_table(results=[]) 158 | term_table = Terminal::Table.new 159 | if results.empty? 160 | term_table.rows = [[NO_RESULTS]] 161 | else 162 | term_table.headings = results.first.keys 163 | term_table.rows = results.map{ |result| result.values.map!{ |value| value.to_s.empty? ? EMPTY_VALUE : value } } 164 | end 165 | 166 | term_table 167 | end 168 | 169 | def open_tempfile_with_content(editor, initial_content) 170 | temp_file do |f| 171 | f.puts(initial_content) 172 | f.flush 173 | f.close(false) 174 | system(editor, f.path) 175 | File.read(f.path) 176 | end 177 | end 178 | 179 | def temp_file(ext='.txt') 180 | file = Tempfile.new(['retscli', ext]) 181 | yield file 182 | ensure 183 | file.close(true) if file 184 | end 185 | 186 | def retrieve_metadata 187 | @client.metadata 188 | end 189 | 190 | def search_table(table, search) 191 | regex = /#{search}/i 192 | table.table_fragment['LongName'].match(regex) || 193 | table.table_fragment['SystemName'].match(regex) || 194 | table.table_fragment['ShortName'].match(regex) || 195 | table.table_fragment['StandardName'].match(regex) 196 | end 197 | 198 | def build_tree 199 | tree = '' 200 | retrieve_metadata.tree.each do |name, resource| 201 | tree << render_resource(resource) 202 | 203 | resource.rets_classes.each do |klass| 204 | tree << "\n" 205 | tree << tab_over(render_class(klass), 1) 206 | 207 | klass.tables.each do |table| 208 | tree << "\n" 209 | tree << tab_over(render_table(table), 2) 210 | end 211 | end 212 | 213 | resource.rets_objects.each do |object| 214 | tree << "\n" 215 | tree << tab_over(render_object(object), 1) 216 | end 217 | 218 | tree << "\n" 219 | end 220 | 221 | tree 222 | end 223 | 224 | def render_resource(resource) 225 | "Resource: #{resource.id} (Key Field: #{resource.key_field})" 226 | end 227 | 228 | def render_class(klass) 229 | "Class: #{klass.name}\n"\ 230 | " Visible Name: #{klass.visible_name}\n"\ 231 | " Description : #{klass.description}" 232 | end 233 | 234 | def render_object(object) 235 | "Object: #{object.name}\n"\ 236 | " MimeType: #{object.mime_type}\n"\ 237 | " Description: #{object.description}" 238 | end 239 | 240 | def render_table(table) 241 | types = false 242 | if table.is_a?(::Rets::Metadata::LookupTable) 243 | header = "LookupTable: #{table.name}" 244 | types = true 245 | elsif table.is_a?(::Rets::Metadata::MultiLookupTable) 246 | header = "MultiLookupTable: #{table.name}" 247 | types = true 248 | else 249 | header = "Table: #{table.name}" 250 | end 251 | 252 | base = "#{header}\n"\ 253 | " Resource: #{table.resource_id}\n"\ 254 | " ShortName: #{table.table_fragment["ShortName"] }\n"\ 255 | " LongName: #{table.long_name }\n"\ 256 | " StandardName: #{table.table_fragment["StandardName"] }\n"\ 257 | " Units: #{table.table_fragment["Units"] }\n"\ 258 | " Searchable: #{table.table_fragment["Searchable"] }\n"\ 259 | " Required: #{table.table_fragment['Required']}" 260 | 261 | if types 262 | base << "\n Types:" 263 | table.lookup_types.each do |type| 264 | base << "\n #{render_lookup_type(type)}" 265 | end 266 | end 267 | 268 | base 269 | end 270 | 271 | def render_lookup_type(type) 272 | "#{type.long_value} -> #{type.value}" 273 | end 274 | 275 | def tab_over(str, num_tabs) 276 | tab_width = " " 277 | str.prepend(tab_width*num_tabs).gsub!("\n", "\n#{tab_width*num_tabs}") 278 | end 279 | 280 | def set_color(text, *args) 281 | @colorer.set_color(text, *args) 282 | end 283 | end 284 | end 285 | -------------------------------------------------------------------------------- /lib/retscli/shell.rb: -------------------------------------------------------------------------------- 1 | require "readline" 2 | require "csv" 3 | require_relative "shell_commands" 4 | require 'tty-spinner' 5 | 6 | module Retscli 7 | class Shell 8 | EXIT_COMMANDS = ['quit', 'exit'].freeze 9 | 10 | def initialize(client) 11 | @stty_save = `stty -g`.chomp 12 | setup_readline_autocomplete 13 | @client = client 14 | @client.login 15 | @display_adapter = Retscli::DisplayAdapter.new(client) 16 | @colorer = ::Thor::Shell::Color.new 17 | retrieve_metadata 18 | end 19 | 20 | def start 21 | while line = readline_with_hist_management 22 | if EXIT_COMMANDS.include?(line) 23 | close 24 | else 25 | execute_shell_command(line) 26 | end 27 | end 28 | end 29 | 30 | # NOTE: this should probably be private, but making it public allowed 31 | # for easier testing without having to deal with mocking readline. Can we 32 | # find a better way? 33 | def execute_shell_command(line, out=$stdout) 34 | begin 35 | Retscli::ShellCommands.start(split_line(line), :display_adapter => @display_adapter) 36 | rescue => e 37 | out.puts @colorer.set_color(e.message, :red) 38 | end 39 | end 40 | 41 | private 42 | # Split line, immitating ARGV behavior where it splits on spaces 43 | # except when quoted 44 | def split_line(line) 45 | CSV.parse_line(line, :col_sep => ' ') 46 | end 47 | 48 | def retrieve_metadata 49 | spinner = TTY::Spinner.new("Retrieving metadata #{@colorer.set_color(':spinner', :yellow)} ...", format: :box_bounce) 50 | spinner.start 51 | 52 | begin 53 | @client.metadata 54 | rescue => e 55 | puts "Error retrieving metadata. #{e.message}" 56 | ensure 57 | spinner.stop(@colorer.set_color('done', :green)) 58 | puts '' 59 | end 60 | end 61 | 62 | def setup_readline_autocomplete 63 | comp = proc { |s| Retscli::ShellCommands.command_list.grep( /^#{Regexp.escape(s)}/ ) } 64 | Readline.completion_append_character = " " 65 | Readline.completion_proc = comp 66 | end 67 | 68 | def readline_with_hist_management 69 | line = Readline.readline(prompt, true) 70 | return nil if line.nil? 71 | 72 | if line =~ /^\s*$/ || Readline::HISTORY.to_a[-2] == line 73 | Readline::HISTORY.pop 74 | end 75 | 76 | line 77 | end 78 | 79 | def prompt 80 | @prompt ||= 81 | begin 82 | uri = URI.parse(@client.login_url) 83 | @colorer.set_color("#{@client.options[:username]}@#{uri.host} > ", :blue) 84 | end 85 | end 86 | 87 | def close 88 | system('stty', @stty_save) 89 | 90 | begin 91 | @client.logout 92 | rescue => e 93 | puts @colorer.set_color(e.message, :red) 94 | end 95 | 96 | exit 97 | end 98 | end 99 | end 100 | 101 | # HACK: because HTTPClient warnings 102 | # are super annoying 103 | class HTTPClient 104 | module Util 105 | def warning(msg); end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/retscli/shell_commands.rb: -------------------------------------------------------------------------------- 1 | require "thor" 2 | 3 | module Retscli 4 | class ShellCommands < Thor 5 | def initialize(args, options, config) 6 | super 7 | @display_adapter = config[:display_adapter] 8 | end 9 | 10 | # since we are using thor for shell commands we want to remove executable 11 | # name from command help menu 12 | def self.banner(command, namespace = nil, subcommand = false) 13 | super.gsub("#{basename} ", "") 14 | end 15 | 16 | def self.command_list 17 | all_commands.keys.map{ |command| command.gsub('_', '-') } 18 | end 19 | 20 | desc 'login', 'Re-Login to RETS server. Use if session is no longer valid' 21 | def login 22 | puts @display_adapter.login 23 | end 24 | 25 | desc 'capabilities', 'Display capabilities for rets server' 26 | def capabilities 27 | @display_adapter.page(@display_adapter.capabilities) 28 | end 29 | 30 | desc 'resources', 'List available resources' 31 | method_option :editor, :aliases => '-e', :desc => 'Open resources in editor' 32 | def resources 33 | if options[:editor] 34 | @display_adapter.open_in_editor(@display_adapter.resources, options[:editor]) 35 | else 36 | @display_adapter.page(@display_adapter.resources) 37 | end 38 | end 39 | 40 | desc 'classes [RESOURCE]', 'List available classes for resource' 41 | method_option :editor, :aliases => '-e', :desc => 'Open classes in editor' 42 | def classes(resource) 43 | if options[:editor] 44 | @display_adapter.open_in_editor(@display_adapter.classes(resource), options[:editor]) 45 | else 46 | @display_adapter.page(@display_adapter.classes(resource)) 47 | end 48 | end 49 | 50 | desc 'tables [RESOURCE] [CLASS]', 'List available tables for class of resource' 51 | method_option :editor, :aliases => '-e', :desc => 'Open tables in editor' 52 | def tables(resource, klass) 53 | if options[:editor] 54 | @display_adapter.open_in_editor(@display_adapter.tables(resource, klass), options[:editor]) 55 | else 56 | @display_adapter.page(@display_adapter.tables(resource, klass)) 57 | end 58 | end 59 | 60 | desc 'objects [RESOURCE]', 'List available objects for resource' 61 | method_option :editor, :aliases => '-e', :desc => 'Open objects in editor' 62 | def objects(resource) 63 | if options[:editor] 64 | @display_adapter.open_in_editor(@display_adapter.objects(resource), options[:editor]) 65 | else 66 | @display_adapter.page(@display_adapter.objects(resource)) 67 | end 68 | end 69 | 70 | desc 'timezone-offset', 'System timezone offset' 71 | def timezone_offset 72 | puts @display_adapter.timezone_offset 73 | end 74 | 75 | map 'timezone-offset' => :timezone_offset 76 | 77 | desc 'metadata', 'View metadata' 78 | method_option :editor, :aliases => '-e', :desc => 'Open metadata in editor' 79 | def metadata 80 | if options[:editor] 81 | @display_adapter.open_in_editor(@display_adapter.metadata, options[:editor]) 82 | else 83 | @display_adapter.page(@display_adapter.metadata) 84 | end 85 | end 86 | 87 | desc 'search-metadata', 'Search metadata tables' 88 | method_option :resources, :aliases => '-r', :desc => 'Filter metadata by resources', :type => :array, :default => [] 89 | method_option :classes, :aliases => '-c', :desc => 'Filter metadata by classes', :type => :array, :default => [] 90 | method_option :editor, :aliases => '-e', :desc => 'Open search results in editor' 91 | def search_metadata(search) 92 | search_options = { 93 | :classes => options[:classes], 94 | :resources => options[:resources] 95 | } 96 | 97 | if options[:editor] 98 | @display_adapter.open_in_editor(@display_adapter.search_metadata(search, search_options.merge(:color => false)), options[:editor]) 99 | else 100 | @display_adapter.page(@display_adapter.search_metadata(search, search_options)) 101 | end 102 | end 103 | 104 | map 'search-metadata' => :search_metadata 105 | 106 | desc 'search [RESOURCE] [CLASS] [QUERY]', 'Search resources, e.g. properties, open houses, etc.' 107 | method_option :limit, :aliases => '-l', :desc => 'limit', :type => :numeric, :default => 20 108 | method_option :offset, :aliases => '-o', :desc => 'Offset', :type => :numeric 109 | method_option :count, :aliases => '-c', :desc => 'Return result count', :type => :boolean, :default => false 110 | method_option :select, :aliases => '-s', :desc => 'Select specific fields from records', :type => :array, :default => [] 111 | method_option :format, :aliases => '-f', :desc => 'Rets data return format', :enum => ['COMPACT', 'COMPACT-DECODED', 'STANDARD-XML'], :default => 'COMPACT-DECODED' 112 | def search(resource, klass, query) 113 | search_options = { 114 | :limit => options[:limit], 115 | :offset => options[:offset], 116 | :count => options[:count], 117 | :select => options[:select], 118 | :format => options[:format] 119 | } 120 | 121 | results = @display_adapter.search(resource, klass, query, search_options) 122 | @display_adapter.page(results) 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /lib/retscli/version.rb: -------------------------------------------------------------------------------- 1 | module Retscli 2 | VERSION = "0.2.0" 3 | end 4 | -------------------------------------------------------------------------------- /retscli.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'retscli/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "retscli" 8 | spec.version = Retscli::VERSION 9 | spec.authors = ["Ari Summer"] 10 | spec.email = ["aribsummer@gmail.com"] 11 | 12 | spec.summary = "CLI for querying RETS servers and searching metadata" 13 | spec.description = "CLI for querying RETS servers and searching metadata" 14 | spec.homepage = "http://github.com/summera/retscli" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_dependency "rets", "~> 0.10" 23 | spec.add_dependency "thor", "~> 0.19" 24 | spec.add_dependency "terminal-table", "~> 1.5" 25 | spec.add_dependency "tty-spinner", "~> 0.2" 26 | spec.add_development_dependency "rake", "~> 10.0" 27 | spec.add_development_dependency "minitest", "~> 5.0" 28 | end 29 | -------------------------------------------------------------------------------- /test/display_adapter_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe Retscli::DisplayAdapter do 4 | let(:dummy_client) do 5 | Class.new do 6 | def login; end 7 | 8 | def logout; end 9 | 10 | def find(quantity, params={}) 11 | if params[:count] == 2 12 | 2 13 | elsif params[:query] == '(ListingID=0+)' 14 | [{ 15 | 'heading1' => 'value1', 16 | 'heading2' => 'value2' 17 | }, { 18 | 'heading1' => 'value1', 19 | 'heading2' => 'value2' 20 | }] 21 | elsif params[:query] == '(ListingID=100+)' 22 | [{ 23 | 'heading1' => 'value1', 24 | 'heading2' => '' 25 | }] 26 | else 27 | [] 28 | end 29 | end 30 | 31 | def metadata 32 | Rets::Metadata::Root.new(nil, RETS_METADATA) 33 | end 34 | end.new 35 | end 36 | 37 | subject { Retscli::DisplayAdapter.new(dummy_client) } 38 | 39 | describe '#resources' do 40 | it 'renders resources' do 41 | resources = "Resource: Agent (Key Field: rets_agt_id)"\ 42 | "\nResource: Office (Key Field: DO_OFFICE_ID)"\ 43 | "\nResource: Property (Key Field: MST_MLS_NUMBER)"\ 44 | "\nResource: OpenHouse (Key Field: rets_oh_id)" 45 | 46 | assert_equal resources, subject.resources 47 | end 48 | end 49 | 50 | describe '#classes' do 51 | let (:classes) do 52 | "Class: Resd"\ 53 | "\n Visible Name: Residential"\ 54 | "\n Description : Residential"\ 55 | "\nClass: ResLand"\ 56 | "\n Visible Name: Residential_Land"\ 57 | "\n Description : Residential_Land"\ 58 | "\nClass: CmmlSales"\ 59 | "\n Visible Name: Commercial_Sales"\ 60 | "\n Description : Commercial_Sales"\ 61 | "\nClass: CmmlLand"\ 62 | "\n Visible Name: Commercial_Land"\ 63 | "\n Description : Commercial_Land"\ 64 | "\nClass: ResLse"\ 65 | "\n Visible Name: Residential_Lease"\ 66 | "\n Description : Residential_Lease"\ 67 | "\nClass: CmmlLse"\ 68 | "\n Visible Name: Commercial_Lease"\ 69 | "\n Description : Commercial_Lease" 70 | end 71 | 72 | it 'renders classes' do 73 | assert_equal classes, subject.classes('Property') 74 | end 75 | 76 | it 'is case independent' do 77 | assert_equal classes, subject.classes('proPeRty') 78 | end 79 | 80 | it 'returns resource does not exist message' do 81 | assert_equal "\e[31mDoesNotExist resource does not exist\e[0m", subject.classes('DoesNotExist') 82 | end 83 | end 84 | 85 | describe '#objects' do 86 | it 'renders objects' do 87 | objects = "Object: image"\ 88 | "\n MimeType: "\ 89 | "\n Description: Property_Photo" 90 | 91 | assert_equal objects, subject.objects('property') 92 | end 93 | end 94 | 95 | describe '#tables' do 96 | it 'renders open house lookup table' do 97 | tables = "LookupTable: rets_oh_activity_type"\ 98 | "\n Resource: OpenHouse"\ 99 | "\n ShortName: ActType"\ 100 | "\n LongName: Activity Type"\ 101 | "\n StandardName: "\ 102 | "\n Units: "\ 103 | "\n Searchable: 1"\ 104 | "\n Required: "\ 105 | "\n Types:"\ 106 | "\n Open -> 0"\ 107 | "\n Private -> 1" 108 | 109 | assert_equal tables, subject.tables('OpenHouse', 'OpenHouse') 110 | end 111 | 112 | it 'renders property tables for CmmlLand' do 113 | tables = "Table: Address"\ 114 | "\n Resource: Property"\ 115 | "\n ShortName: Str Nm"\ 116 | "\n LongName: Street Name"\ 117 | "\n StandardName: StreetName"\ 118 | "\n Units: "\ 119 | "\n Searchable: 1"\ 120 | "\n Required: " 121 | 122 | assert_equal tables, subject.tables('Property', 'CmmlLand') 123 | end 124 | 125 | it 'returns resource does not exist message' do 126 | assert_equal "\e[31mDoesNotExist resource does not exist\e[0m", subject.tables('DoesNotExist', '') 127 | end 128 | 129 | it 'returns class does not exist message' do 130 | assert_equal "\e[31mDoesNotExist class does not exist\e[0m", subject.tables('Property', 'DoesNotExist') 131 | end 132 | end 133 | 134 | describe '#timezone_offset' do 135 | it 'returns rets server timezone offset' do 136 | assert_equal "-07:00", subject.timezone_offset 137 | end 138 | 139 | describe 'without specified offset' do 140 | let(:dummy_client) do 141 | Class.new do 142 | def login; end 143 | 144 | def logout; end 145 | 146 | def metadata 147 | meta_with_no_tz = {} 148 | meta_with_no_tz['SYSTEM'] = RETS_METADATA['SYSTEM'].dup 149 | meta_with_no_tz['SYSTEM'].gsub!('TimeZoneOffset="-07:00"', '') 150 | Rets::Metadata::Root.new(nil, meta_with_no_tz) 151 | end 152 | end.new 153 | end 154 | 155 | it 'returns offset not specified message' do 156 | assert_equal "\e[31mNo offset specified\e[0m", subject.timezone_offset 157 | end 158 | end 159 | end 160 | 161 | describe '#metadata' do 162 | it 'renders full metadata' do 163 | assert_equal RENDERED_METDATA, subject.metadata 164 | end 165 | end 166 | 167 | describe "#search_metadata" do 168 | it 'searches long name of metadata tables' do 169 | result = "Resource: OpenHouse (Key Field: rets_oh_id)"\ 170 | "\n Class: OpenHouse"\ 171 | "\n Visible Name: OpenHouse"\ 172 | "\n Description : Open House"\ 173 | "\n LookupTable: rets_oh_\e[31m\e[47mactivity\e[0m_type"\ 174 | "\n Resource: OpenHouse"\ 175 | "\n ShortName: ActType"\ 176 | "\n LongName: \e[31m\e[47mActivity\e[0m Type"\ 177 | "\n StandardName: "\ 178 | "\n Units: "\ 179 | "\n Searchable: 1"\ 180 | "\n Required: "\ 181 | "\n Types:"\ 182 | "\n Open -> 0"\ 183 | "\n Private -> 1\n" 184 | 185 | assert_equal result, subject.search_metadata('activity') 186 | end 187 | 188 | it 'searches system name of metadata tables' do 189 | result = "Resource: Property (Key Field: MST_MLS_NUMBER)"\ 190 | "\n Class: CmmlLand"\ 191 | "\n Visible Name: Commercial_Land"\ 192 | "\n Description : Commercial_Land"\ 193 | "\n Table: \e[31m\e[47mAddress\e[0m"\ 194 | "\n Resource: Property"\ 195 | "\n ShortName: Str Nm"\ 196 | "\n LongName: Street Name"\ 197 | "\n StandardName: StreetName"\ 198 | "\n Units: "\ 199 | "\n Searchable: 1"\ 200 | "\n Required: \n" 201 | 202 | assert_equal result, subject.search_metadata('address') 203 | end 204 | 205 | it 'searches short name of metadata tables' do 206 | result = "Resource: Property (Key Field: MST_MLS_NUMBER)"\ 207 | "\n Class: CmmlLand"\ 208 | "\n Visible Name: Commercial_Land"\ 209 | "\n Description : Commercial_Land"\ 210 | "\n Table: Address"\ 211 | "\n Resource: Property"\ 212 | "\n ShortName: \e[31m\e[47mStr\e[0m Nm"\ 213 | "\n LongName: \e[31m\e[47mStr\e[0meet Name"\ 214 | "\n StandardName: \e[31m\e[47mStr\e[0meetName"\ 215 | "\n Units: "\ 216 | "\n Searchable: 1"\ 217 | "\n Required: \n" 218 | 219 | assert_equal result, subject.search_metadata('str') 220 | end 221 | 222 | it 'searches standard name of metdata tables' do 223 | result = "Resource: Property (Key Field: MST_MLS_NUMBER)"\ 224 | "\n Class: CmmlLand"\ 225 | "\n Visible Name: Commercial_Land"\ 226 | "\n Description : Commercial_Land"\ 227 | "\n Table: Address"\ 228 | "\n Resource: Property"\ 229 | "\n ShortName: Str Nm"\ 230 | "\n LongName: Street Name"\ 231 | "\n StandardName: \e[31m\e[47mStreetName\e[0m"\ 232 | "\n Units: "\ 233 | "\n Searchable: 1"\ 234 | "\n Required: \n" 235 | 236 | assert_equal result, subject.search_metadata('streetname') 237 | end 238 | 239 | describe 'with resource filter' do 240 | it 'returns no results' do 241 | assert_equal '', subject.search_metadata('streetname', :resources => ['openhouse']) 242 | end 243 | 244 | it 'returns results from filtered resources' do 245 | result = "Resource: Property (Key Field: MST_MLS_NUMBER)"\ 246 | "\n Class: CmmlLand"\ 247 | "\n Visible Name: Commercial_Land"\ 248 | "\n Description : Commercial_Land"\ 249 | "\n Table: Address"\ 250 | "\n Resource: Property"\ 251 | "\n ShortName: Str Nm"\ 252 | "\n LongName: Street Name"\ 253 | "\n StandardName: \e[31m\e[47mStreetName\e[0m"\ 254 | "\n Units: "\ 255 | "\n Searchable: 1"\ 256 | "\n Required: \n" 257 | 258 | assert_equal result, subject.search_metadata('streetname', :resources => ['property']) 259 | end 260 | end 261 | 262 | describe 'with class filter' do 263 | it 'returns no results' do 264 | assert_equal '', subject.search_metadata('streetname', :classes => ['resland']) 265 | end 266 | 267 | it 'returns results from filtered classes' do 268 | result = "Resource: Property (Key Field: MST_MLS_NUMBER)"\ 269 | "\n Class: CmmlLand"\ 270 | "\n Visible Name: Commercial_Land"\ 271 | "\n Description : Commercial_Land"\ 272 | "\n Table: Address"\ 273 | "\n Resource: Property"\ 274 | "\n ShortName: Str Nm"\ 275 | "\n LongName: Street Name"\ 276 | "\n StandardName: \e[31m\e[47mStreetName\e[0m"\ 277 | "\n Units: "\ 278 | "\n Searchable: 1"\ 279 | "\n Required: \n" 280 | 281 | assert_equal result, subject.search_metadata('streetname', :classes => ['cmmlland']) 282 | end 283 | end 284 | 285 | describe 'with resource and class filters' do 286 | it 'returns no results when resource matches but class does not' do 287 | assert_equal '', subject.search_metadata('streetname', :resources => ['property'], :classes => ['cool']) 288 | end 289 | 290 | it 'returns no results when class matches but resource does not' do 291 | assert_equal '', subject.search_metadata('streetname', :resources => ['awesome'], :classes => ['cmmlland']) 292 | end 293 | 294 | it 'returns results from filtered resources and classes' do 295 | result = "Resource: Property (Key Field: MST_MLS_NUMBER)"\ 296 | "\n Class: CmmlLand"\ 297 | "\n Visible Name: Commercial_Land"\ 298 | "\n Description : Commercial_Land"\ 299 | "\n Table: Address"\ 300 | "\n Resource: Property"\ 301 | "\n ShortName: Str Nm"\ 302 | "\n LongName: Street Name"\ 303 | "\n StandardName: \e[31m\e[47mStreetName\e[0m"\ 304 | "\n Units: "\ 305 | "\n Searchable: 1"\ 306 | "\n Required: \n" 307 | 308 | assert_equal result, subject.search_metadata('streetname', :resources => ['property'], :classes => ['cmmlland']) 309 | end 310 | end 311 | end 312 | 313 | describe '#search' do 314 | it 'returns results table with correct headings' do 315 | result = subject.search('property', 'res', '(ListingID=0+)') 316 | headings = result.headings.map{ |row| row.cells.map{ |cell| cell.value } } 317 | assert_equal [['heading1', 'heading2']], headings 318 | end 319 | 320 | it 'returns results table with correct values' do 321 | result = subject.search('property', 'res', '(ListingID=0+)') 322 | values = result.rows.map{ |row| row.cells.map{ |cell| cell.value } } 323 | assert_equal [['value1', 'value2'],['value1', 'value2']], values 324 | end 325 | 326 | it 'returns table with no results' do 327 | result = subject.search('property', 'res', '') 328 | values = result.rows.map{ |row| row.cells.map{ |cell| cell.value } } 329 | assert_equal [[Retscli::DisplayAdapter::NO_RESULTS]], values 330 | end 331 | 332 | it 'returns table with number of results' do 333 | result = subject.search('property', 'res', '(ListingID=0+)', :count => true) 334 | values = result.rows.map{ |row| row.cells.map{ |cell| cell.value } } 335 | assert_equal [[2]], values 336 | end 337 | 338 | it 'replaces empty strings with empty value' do 339 | result = subject.search('property', 'res', '(ListingID=100+)') 340 | values = result.rows.map{ |row| row.cells.map{ |cell| cell.value } } 341 | assert_equal [['value1', Retscli::DisplayAdapter::EMPTY_VALUE]], values 342 | end 343 | end 344 | 345 | describe '#page' do 346 | it 'uses PAGER environment variable' do 347 | ENV['PAGER'] = 'less' 348 | mock = MiniTest::Mock.new 349 | mock.expect(:call, nil, ['less', 'w']) 350 | 351 | IO.stub(:popen, mock, StringIO.new) do 352 | subject.page('') 353 | end 354 | 355 | mock.verify 356 | end 357 | end 358 | 359 | describe '#open_in_editor' do 360 | it 'uses EDITOR environment variable by default' do 361 | ENV['EDITOR'] = 'vim' 362 | mock = MiniTest::Mock.new 363 | mock.expect(:call, nil, ['vim', 'text']) 364 | 365 | subject.stub(:open_tempfile_with_content, mock) do 366 | subject.open_in_editor('text') 367 | end 368 | 369 | mock.verify 370 | end 371 | 372 | it 'uses nano when EDITOR environment variable is not set' do 373 | ENV.delete('EDITOR') 374 | mock = MiniTest::Mock.new 375 | mock.expect(:call, nil, ['nano', 'text']) 376 | 377 | subject.stub(:open_tempfile_with_content, mock) do 378 | subject.open_in_editor('text') 379 | end 380 | 381 | mock.verify 382 | end 383 | end 384 | end 385 | 386 | -------------------------------------------------------------------------------- /test/fixtures.rb: -------------------------------------------------------------------------------- 1 | RETS_METADATA = { 2 | "SYSTEM"=>"\r\n \r\n \r\n TEST BOARD\r\n \r\n", 3 | "RESOURCE"=>"\r\n \r\n \tResourceID\tStandardName\tVisibleName\tDescription\tKeyField\tClassCount\tClassVersion\tClassDate\tObjectVersion\tObjectDate\tSearchHelpVersion\tSearchHelpDate\tEditMaskVersion\tEditMaskDate\tLookupVersion\tLookupDate\tUpdateHelpVersion\tUpdateHelpDate\tValidationExpressionVersion\tValidationExpressionDate\tValidationLookupVersion\tValidationLookupDate\tValidationExternalVersion\tValidationExternalDate\t\r\n \tAgent\tAgent\tAgent\tAgent\trets_agt_id\t1\t01.00.00000\t2012-07-16T23:33:24\t01.00.00000\t2015-06-24T23:11:25\t\t\t\t\t01.00.00002\t2015-10-26T23:19:19\t\t\t\t\t\t\t\t\t\r\n \tOffice\tOffice\tOffice\tOffice\tDO_OFFICE_ID\t1\t01.00.00000\t2012-07-16T23:33:36\t\t\t\t\t\t\t01.00.00002\t2015-10-26T23:19:43\t\t\t\t\t\t\t\t\t\r\n \tProperty\tProperty\tProperty\tProperty\tMST_MLS_NUMBER\t6\t01.00.00006\t2015-09-26T17:48:29\t01.00.00000\t2012-07-17T00:30:03\t\t\t\t\t01.00.00113\t2015-12-18T02:08:50\t\t\t\t\t\t\t\t\t\r\n \tOpenHouse\tOpenHouse\tOpenHouse\tOpenHouse\trets_oh_id\t1\t01.00.00000\t2015-09-26T17:48:04\t\t\t\t\t\t\t01.00.00000\t2015-10-26T23:19:57\t\t\t\t\t\t\t\t\t\r\n \r\n", 4 | "CLASS"=>"\r\n \r\n \tClassName\tStandardName\tVisibleName\tDescription\tTableVersion\tTableDate\tUpdateVersion\tUpdateDate\tClassTimeStamp\tDeletedFlagField\tDeletedFlagValue\t\r\n \tAgent\tAgent\tAgent\tAgent\t01.00.00026\t2015-12-15T20:28:58\t\t\t\t\t\t\r\n \r\n \r\n \tClassName\tStandardName\tVisibleName\tDescription\tTableVersion\tTableDate\tUpdateVersion\tUpdateDate\tClassTimeStamp\tDeletedFlagField\tDeletedFlagValue\t\r\n \tOffice\tOffice\tOffice\tOffice\t01.00.00021\t2015-10-26T23:22:54\t\t\t\t\t\t\r\n \r\n \r\n \tClassName\tStandardName\tVisibleName\tDescription\tTableVersion\tTableDate\tUpdateVersion\tUpdateDate\tClassTimeStamp\tDeletedFlagField\tDeletedFlagValue\t\r\n \tResd\tResidentialProperty\tResidential\tResidential\t01.00.00288\t2015-12-18T02:11:22\t\t\t\t\t\t\r\n \tResLand\tLotsAndLand\tResidential_Land\tResidential_Land\t01.00.00218\t2015-12-18T02:11:54\t\t\t\t\t\t\r\n \tCmmlSales\tCommonInterest\tCommercial_Sales\tCommercial_Sales\t01.00.00245\t2015-12-18T02:10:49\t\t\t\t\t\t\r\n \tCmmlLand\tLotsAndLand\tCommercial_Land\tCommercial_Land\t01.00.00239\t2015-12-18T02:09:47\t\t\t\t\t\t\r\n \tResLse\tResidentialProperty\tResidential_Lease\tResidential_Lease\t01.00.00273\t2015-12-18T02:12:24\t\t\t\t\t\t\r\n \tCmmlLse\tCommonInterest\tCommercial_Lease\tCommercial_Lease\t01.00.00219\t2015-12-18T02:10:18\t\t\t\t\t\t\r\n \r\n \r\n \tClassName\tStandardName\tVisibleName\tDescription\tTableVersion\tTableDate\tUpdateVersion\tUpdateDate\tClassTimeStamp\tDeletedFlagField\tDeletedFlagValue\t\r\n \tOpenHouse\tOpenHouse\tOpenHouse\tOpen House\t01.00.00009\t2015-10-26T23:23:03\t\t\t\t\t\t\r\n \r\n", 5 | "TABLE"=>"\r\n \r\n \tMetadataEntryID\tSystemName\tStandardName\tLongName\tDBName\tShortName\tMaximumLength\tDataType\tPrecision\tSearchable\tInterpretation\tAlignment\tUseSeparator\tEditMaskID\tLookupName\tMaxSelect\tUnits\tIndex\tMinimum\tMaximum\tDefault\tRequired\tSearchHelpID\tUnique\tModTimeStampName\tForeignKeyName\tForeignField\tKeyQuery\tKeySelect\tInKeyIndex\t\r\n \td7c4c89fe41c44b88c9ef9b2f51b2d71\tAddress\tStreetName\tStreet Name\tStrNm\tStr Nm\t50\tCharacter\t\t1\t\t\t\t\t\t\t\t\t\t\t\t\t\t0\t\t\t\t\t\t1\t\r\n \r\n \r\n \tMetadataEntryID\tSystemName\tStandardName\tLongName\tDBName\tShortName\tMaximumLength\tDataType\tPrecision\tSearchable\tInterpretation\tAlignment\tUseSeparator\tEditMaskID\tLookupName\tMaxSelect\tUnits\tIndex\tMinimum\tMaximum\tDefault\tRequired\tSearchHelpID\tUnique\tModTimeStampName\tForeignKeyName\tForeignField\tKeyQuery\tKeySelect\tInKeyIndex\t\r\n \t75dd7c37bb9149229cdfc517fba445e1\trets_oh_activity_type\t\tActivity Type\tActType\tActType\t4\tCharacter\t\t1\tLookup\t\t\t\tOHActivityType\t1\t\t\t\t\t\t\t\t0\t\t\t\t\t\t1\t\r\n \r\n", 6 | "LOOKUP"=>"\r\n \r\n \tMetadataEntryID\tLookupName\tVisibleName\tLookupTypeVersion\tLookupTypeDate\t\r\n \t6756719706b2460aa04a3170a0fa2f24\tOHActivityType\tOHActivityType\t1.00.000\t2015-10-26T23:19:57\t\r\n \r\n", 7 | "LOOKUP_TYPE"=>"\r\n \r\n \tMetadataEntryID\tLongValue\tShortValue\tValue\t\r\n \t7859ccdcd754426980379a26bd840cf4\tOpen\tOpen\t0\t\r\n \t0c0e8922e2b544609d9e3bdf9bdfee09\tPrivate\tPrivate\t1\t\r\n \r\n", 8 | "OBJECT"=>"\r\n \r\n \tMetadataEntryID\tObjectType\tMimeType\tVisibleName\tDescription\tObjectTimeStamp\tObjectCount\t\r\n \tbfce80be2f0b400d852119e9323f5ef6\tPhoto\timage/jpeg\timage\tAgent Photo\trets_photo_timestamp\trets_photo_count\t\r\n \r\n \r\n \tMetadataEntryID\tObjectType\tMimeType\tVisibleName\tDescription\tObjectTimeStamp\tObjectCount\t\r\n \t4de7ad1fb4e1460784a497f318aa9198\tPhoto\timage/jpeg\timage\tProperty_Photo\trets_photo_timestamp\trets_photo_count\t\r\n \r\n" 9 | } 10 | 11 | RENDERED_METDATA = "Resource: Agent (Key Field: rets_agt_id)"\ 12 | "\n Class: Agent"\ 13 | "\n Visible Name: Agent"\ 14 | "\n Description : Agent"\ 15 | "\n Object: image"\ 16 | "\n MimeType: "\ 17 | "\n Description: Agent Photo"\ 18 | "\nResource: Office (Key Field: DO_OFFICE_ID)"\ 19 | "\n Class: Office"\ 20 | "\n Visible Name: Office"\ 21 | "\n Description : Office"\ 22 | "\nResource: Property (Key Field: MST_MLS_NUMBER)"\ 23 | "\n Class: Resd"\ 24 | "\n Visible Name: Residential"\ 25 | "\n Description : Residential"\ 26 | "\n Class: ResLand"\ 27 | "\n Visible Name: Residential_Land"\ 28 | "\n Description : Residential_Land"\ 29 | "\n Class: CmmlSales"\ 30 | "\n Visible Name: Commercial_Sales"\ 31 | "\n Description : Commercial_Sales"\ 32 | "\n Class: CmmlLand"\ 33 | "\n Visible Name: Commercial_Land"\ 34 | "\n Description : Commercial_Land"\ 35 | "\n Table: Address"\ 36 | "\n Resource: Property"\ 37 | "\n ShortName: Str Nm"\ 38 | "\n LongName: Street Name"\ 39 | "\n StandardName: StreetName"\ 40 | "\n Units: "\ 41 | "\n Searchable: 1"\ 42 | "\n Required: "\ 43 | "\n Class: ResLse"\ 44 | "\n Visible Name: Residential_Lease"\ 45 | "\n Description : Residential_Lease"\ 46 | "\n Class: CmmlLse"\ 47 | "\n Visible Name: Commercial_Lease"\ 48 | "\n Description : Commercial_Lease"\ 49 | "\n Object: image"\ 50 | "\n MimeType: "\ 51 | "\n Description: Property_Photo"\ 52 | "\nResource: OpenHouse (Key Field: rets_oh_id)"\ 53 | "\n Class: OpenHouse"\ 54 | "\n Visible Name: OpenHouse"\ 55 | "\n Description : Open House"\ 56 | "\n LookupTable: rets_oh_activity_type"\ 57 | "\n Resource: OpenHouse"\ 58 | "\n ShortName: ActType"\ 59 | "\n LongName: Activity Type"\ 60 | "\n StandardName: "\ 61 | "\n Units: "\ 62 | "\n Searchable: 1"\ 63 | "\n Required: "\ 64 | "\n Types:"\ 65 | "\n Open -> 0"\ 66 | "\n Private -> 1\n" 67 | -------------------------------------------------------------------------------- /test/shell_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe Retscli::Shell do 4 | let(:dummy_client) do 5 | Class.new do 6 | def login; end 7 | 8 | def logout; end 9 | 10 | def metadata 11 | Rets::Metadata::Root.new(nil, RETS_METADATA) 12 | end 13 | end.new 14 | end 15 | 16 | subject do 17 | class DummyShell < Retscli::Shell 18 | def retrieve_metadata 19 | @client.metadata 20 | end 21 | end 22 | 23 | DummyShell.new(dummy_client) 24 | end 25 | 26 | describe '#execute_shell_command' do 27 | it 'does not split quoted text' do 28 | mock = MiniTest::Mock.new 29 | mock.expect(:call, '', [['search-metadata', 'this is my quoted search'], Hash]) 30 | 31 | Retscli::ShellCommands.stub(:start, mock) do 32 | subject.execute_shell_command('search-metadata "this is my quoted search"') 33 | end 34 | 35 | mock.verify 36 | end 37 | 38 | it 'splits on command flags' do 39 | mock = MiniTest::Mock.new 40 | mock.expect(:call, '', [['search-metadata', 'quoted search', 'with', '-f', 'flags', '-and', 'another'], Hash]) 41 | 42 | Retscli::ShellCommands.stub(:start, mock) do 43 | subject.execute_shell_command('search-metadata "quoted search" with -f flags -and another') 44 | end 45 | 46 | mock.verify 47 | end 48 | 49 | it 'does not raise error' do 50 | raise_proc = Proc.new { raise StandardError.new('Some Error') } 51 | Retscli::ShellCommands.stub(:start, raise_proc) do 52 | subject.execute_shell_command('search property res (ListingID=0+)', StringIO.new) 53 | end 54 | end 55 | 56 | it 'prints error to IO' do 57 | error = StandardError.new('Some Error') 58 | raise_proc = Proc.new { raise error } 59 | io = StringIO.new 60 | Retscli::ShellCommands.stub(:start, raise_proc) do 61 | subject.execute_shell_command('search property res (ListingID=0+)', io) 62 | 63 | end 64 | 65 | assert_match(/.*#{error.message}.*/, io.string) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'retscli' 3 | 4 | require 'minitest/autorun' 5 | require_relative 'fixtures' 6 | --------------------------------------------------------------------------------