pincerna 1.1.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (102) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +7 -0
  3. data/.rubocop.yml +77 -0
  4. data/.travis-gemfile +17 -0
  5. data/.travis.yml +7 -0
  6. data/.yardopts +1 -0
  7. data/CHANGELOG.md +3 -0
  8. data/Gemfile +23 -0
  9. data/LICENSE.md +21 -0
  10. data/README.md +211 -0
  11. data/Rakefile +45 -0
  12. data/bin/pincernad +56 -0
  13. data/docs/Pincerna.html +130 -0
  14. data/docs/Pincerna/Base.html +3051 -0
  15. data/docs/Pincerna/Bookmark.html +523 -0
  16. data/docs/Pincerna/Cache.html +767 -0
  17. data/docs/Pincerna/ChromeBookmark.html +308 -0
  18. data/docs/Pincerna/CurrencyConversion.html +589 -0
  19. data/docs/Pincerna/FirefoxBookmark.html +328 -0
  20. data/docs/Pincerna/Ip.html +1017 -0
  21. data/docs/Pincerna/Map.html +399 -0
  22. data/docs/Pincerna/SafariBookmark.html +308 -0
  23. data/docs/Pincerna/Server.html +673 -0
  24. data/docs/Pincerna/Translation.html +517 -0
  25. data/docs/Pincerna/UnitConversion.html +1042 -0
  26. data/docs/Pincerna/Version.html +189 -0
  27. data/docs/Pincerna/Vpn.html +561 -0
  28. data/docs/Pincerna/Weather.html +837 -0
  29. data/docs/_index.html +298 -0
  30. data/docs/class_list.html +54 -0
  31. data/docs/css/common.css +1 -0
  32. data/docs/css/full_list.css +57 -0
  33. data/docs/css/style.css +338 -0
  34. data/docs/file.README.html +327 -0
  35. data/docs/file_list.html +56 -0
  36. data/docs/frames.html +28 -0
  37. data/docs/index.html +327 -0
  38. data/docs/js/app.js +214 -0
  39. data/docs/js/full_list.js +178 -0
  40. data/docs/js/jquery.js +4 -0
  41. data/docs/method_list.html +389 -0
  42. data/docs/top-level-namespace.html +112 -0
  43. data/icon.png +0 -0
  44. data/images/chrome.png +0 -0
  45. data/images/currency.png +0 -0
  46. data/images/firefox.png +0 -0
  47. data/images/map.png +0 -0
  48. data/images/network.png +0 -0
  49. data/images/safari.png +0 -0
  50. data/images/translate.png +0 -0
  51. data/images/unit.png +0 -0
  52. data/images/vpn.png +0 -0
  53. data/images/weather.png +0 -0
  54. data/info.plist +961 -0
  55. data/it.cowtech.pincernad.plist +19 -0
  56. data/lib/pincerna.rb +30 -0
  57. data/lib/pincerna/base.rb +258 -0
  58. data/lib/pincerna/bookmark.rb +80 -0
  59. data/lib/pincerna/cache.rb +61 -0
  60. data/lib/pincerna/chrome_bookmark.rb +40 -0
  61. data/lib/pincerna/currency_conversion.rb +134 -0
  62. data/lib/pincerna/firefox_bookmark.rb +92 -0
  63. data/lib/pincerna/ip.rb +135 -0
  64. data/lib/pincerna/map.rb +30 -0
  65. data/lib/pincerna/safari_bookmark.rb +40 -0
  66. data/lib/pincerna/server.rb +85 -0
  67. data/lib/pincerna/translation.rb +67 -0
  68. data/lib/pincerna/unit_conversion.rb +120 -0
  69. data/lib/pincerna/version.rb +24 -0
  70. data/lib/pincerna/vpn.rb +74 -0
  71. data/lib/pincerna/weather.rb +188 -0
  72. data/pincerna.alfredworkflow +0 -0
  73. data/pincerna.gemspec +36 -0
  74. data/pincerna.sh +9 -0
  75. data/spec/cassettes/Pincerna_CurrencyConversion/_perform_filtering/should_return_valid_values.yml +38 -0
  76. data/spec/cassettes/Pincerna_Ip/_get_local_addresses/should_return_a_list_of_addresses.yml +47 -0
  77. data/spec/cassettes/Pincerna_Ip/_get_public_address/should_return_public_IP_address.yml +44 -0
  78. data/spec/cassettes/Pincerna_Translation/_perform_filtering/should_default_from_English_to_the_given_language_when_only_one_is_present.yml +124 -0
  79. data/spec/cassettes/Pincerna_Translation/_perform_filtering/should_query_Google_Translate_for_sentences_returning_no_alternatives.yml +51 -0
  80. data/spec/cassettes/Pincerna_Translation/_perform_filtering/should_query_Google_Translate_for_single_words.yml +71 -0
  81. data/spec/cassettes/Pincerna_Weather/_get_forecast/should_append_name.yml +177 -0
  82. data/spec/cassettes/Pincerna_Weather/_get_forecast/should_get_correct_forecasts.yml +339 -0
  83. data/spec/cassettes/Pincerna_Weather/_lookup_places/should_return_an_existing_WOEID_without_making_any_request.yml +56 -0
  84. data/spec/cassettes/Pincerna_Weather/_lookup_places/should_search_for_places.yml +189 -0
  85. data/spec/cassettes/Pincerna_Weather/_perform_filtering/should_get_forecast.yml +56 -0
  86. data/spec/coverage_helper.rb +44 -0
  87. data/spec/pincerna/base_spec.rb +166 -0
  88. data/spec/pincerna/bookmark_spec.rb +65 -0
  89. data/spec/pincerna/cache_spec.rb +88 -0
  90. data/spec/pincerna/chrome_bookmark_spec.rb +114 -0
  91. data/spec/pincerna/currency_conversion_spec.rb +46 -0
  92. data/spec/pincerna/firefox_bookmark_spec.rb +46 -0
  93. data/spec/pincerna/ip_spec.rb +194 -0
  94. data/spec/pincerna/map_spec.rb +24 -0
  95. data/spec/pincerna/safari_bookmark_spec.rb +237 -0
  96. data/spec/pincerna/server_spec.rb +108 -0
  97. data/spec/pincerna/translation_spec.rb +55 -0
  98. data/spec/pincerna/unit_conversion_spec.rb +98 -0
  99. data/spec/pincerna/vpn_spec.rb +68 -0
  100. data/spec/pincerna/weather_spec.rb +131 -0
  101. data/spec/spec_helper.rb +48 -0
  102. metadata +283 -0
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: utf-8
3
+ #
4
+ # This file is part of the pincerna gem. Copyright (C) 2013 and above Shogun <shogun@cowtech.it>.
5
+ # Licensed under the MIT license, which can be found at https://choosealicense.com/licenses/mit.
6
+ #
7
+
8
+ module Pincerna
9
+ # Main HTTP server to handle requests.
10
+ class Server < Goliath::API
11
+ use Goliath::Rack::Params
12
+ use Goliath::Rack::Heartbeat
13
+ use Goliath::Rack::Validation::RequestMethod, %w(GET)
14
+
15
+ # Delay before responding to a request.
16
+ DELAY=0.05
17
+
18
+ # Enqueues a request.
19
+ def self.enqueue_request
20
+ @requests ||= Queue.new
21
+ @requests << Time.now.to_f
22
+ EM::Synchrony.sleep(DELAY)
23
+ end
24
+
25
+ # Enqueues a request.
26
+ #
27
+ # @return [Boolean] `true` if the request was the last arrived and therefore must be performed, `false` otherwise.
28
+ def self.perform_request?
29
+ @requests.pop
30
+ @requests.empty?
31
+ end
32
+
33
+ # Handles a valid request.
34
+ #
35
+ # @param type [String] The type of request.
36
+ # @param args [Hash] The parameters of the request.
37
+ # @return [Array] A response complaint to Rack interface.
38
+ def handle_request(type, args)
39
+ # Enqueue the request. This will wait to avoid too many requests.
40
+ Server.enqueue_request
41
+
42
+ # Execute the request, if none were added.
43
+ response = Server.perform_request? ? Pincerna::Base.execute!(type, (args["q"] || "").strip, args["format"], args["debug"]) : false
44
+
45
+ if response then
46
+ [200, {"Content-Type" => response.format_content_type}, response.output]
47
+ else
48
+ [response.nil? ? 404 : 429, {"Content-Type" => "text/plain"}, ""]
49
+ end
50
+ end
51
+
52
+ # Schedule the server's stop.
53
+ # @return [Array] A response complaint to Rack interface.
54
+ def handle_stop
55
+ EM.add_timer(0.1) { stop_server }
56
+ [200, {}, ""]
57
+ end
58
+
59
+ # Send a response to a request.
60
+ #
61
+ # @param env [Goliath::Env] The environment of the request.
62
+ # @return [Array] A response complaint to Rack interface.
63
+ def response(env)
64
+ begin
65
+ type = env["REQUEST_PATH"].gsub(/\//, "")
66
+
67
+ case type
68
+ when "quit" then handle_stop
69
+ when "install" then handle_install
70
+ when "uninstall" then handle_uninstall
71
+ else handle_request(type, params)
72
+ end
73
+ rescue => e
74
+ [500, {"X-Error" => e.class.to_s, "X-Error-Message" => e.message, "Content-Type" => "text/plain"}, e.backtrace.join("\n")]
75
+ end
76
+ end
77
+
78
+ private
79
+ # Stops the server.
80
+ def stop_server
81
+ Pincerna::Cache.instance.destroy
82
+ EM.stop
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,67 @@
1
+ # encoding: utf-8
2
+ #
3
+ # This file is part of the pincerna gem. Copyright (C) 2013 and above Shogun <shogun@cowtech.it>.
4
+ # Licensed under the MIT license, which can be found at https://choosealicense.com/licenses/mit.
5
+ #
6
+
7
+ module Pincerna
8
+ # Translates text using Google Translate.
9
+ class Translation < Base
10
+ # The expression to match.
11
+ MATCHER = /^
12
+ (from\s+)?(?<from>[a-z_-]{2,5})
13
+ \s+
14
+ (to\s+)?((?<to>[a-z_-]{2,5})\s+)?
15
+ (?<text>.+)
16
+ $/mix
17
+
18
+ # Relevant groups in the match.
19
+ RELEVANT_MATCHES = {
20
+ "from" => ->(_, value) { value.downcase },
21
+ "to" => ->(_, value) { value && value.downcase },
22
+ "text" => ->(_, value) { value.strip },
23
+ }
24
+
25
+ # The icon to show for each feedback item.
26
+ ICON = Pincerna::Base::ROOT + "/images/translate.png"
27
+
28
+ # The URL of the webservice.
29
+ URL = "http://translate.google.com.br/translate_a/t"
30
+
31
+ # Translates text using Google Translate.
32
+ #
33
+ # @param from [String] The code of the source language.
34
+ # @param to [String] The code of the target language.
35
+ # @param value [String] The text to translate.
36
+ # @return [Hash|NilClass] The translation data or `nil` if the translation failed.
37
+ def perform_filtering(from, to, value)
38
+ # By default we translate from English
39
+ if !to then
40
+ to = from
41
+ from = "en"
42
+ end
43
+
44
+ response = Pincerna::Cache.instance.use("translation:#{from}:#{to}:#{value}", Pincerna::Cache::EXPIRATIONS[:short]) {
45
+ fetch_remote_resource(URL, {client: "p", text: value, sl: from, tl: to, multires: 1, ssel: 0, tsel: 0, sc: 1, ie: "UTF-8", oe: "UTF-8"})
46
+ }
47
+
48
+ # Parse results
49
+ if response["dict"] then
50
+ translations = response["dict"][0]["entry"].map {|t| t["word"] }
51
+ {main: translations.shift, alternatives: translations}
52
+ else
53
+ translation = response["sentences"][0]["trans"]
54
+ {main: translation} if translation != value
55
+ end
56
+ end
57
+
58
+ # Processes items to obtain feedback items.
59
+ #
60
+ # @param results [Array] The items to process.
61
+ # @return [Array] The feedback items.
62
+ def process_results(results)
63
+ alternatives = results[:alternatives] ? "Alternatives: #{results[:alternatives].join(", ")}" : "Action this item to copy the translation on the clipboard."
64
+ [{title: results[:main], arg: results[:main], subtitle: alternatives, icon: ICON}]
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,120 @@
1
+ # encoding: utf-8
2
+ #
3
+ # This file is part of the pincerna gem. Copyright (C) 2013 and above Shogun <shogun@cowtech.it>.
4
+ # Licensed under the MIT license, which can be found at https://choosealicense.com/licenses/mit.
5
+ #
6
+
7
+ module Pincerna
8
+ # Converts a value from a unit to another.
9
+ class UnitConversion < Base
10
+ # The expression to match.
11
+ MATCHER = /^
12
+ (?<value>([+-]?)(\d+)([.,]\d+)?)
13
+ \s+
14
+ (?<from>\S+?)
15
+ \s+
16
+ (to\s+)?
17
+ (?<to>\S+)
18
+ (?<rate>\s+with\s+rate)?
19
+ (?<split>\s+split\s+units)?
20
+ $/mix
21
+
22
+ # Relevant groups in the match.
23
+ RELEVANT_MATCHES = {
24
+ "value" => ->(context, value) { context.round_float(value.gsub(",", ".").to_f) },
25
+ "from" => ->(_, value) { value },
26
+ "to" => ->(_, value) { value },
27
+ "rate" => ->(_, value) { !value.nil? }, # If show conversion rate
28
+ "split" => ->(_, value) { !value.nil? } # If group unit for ft+in and lb+oz
29
+ }
30
+
31
+ # The icon to show for each feedback item.
32
+ ICON = Pincerna::Base::ROOT + "/images/unit.png"
33
+
34
+ # Defines a new unit.
35
+ #
36
+ # @param name [String] The name of the unit.
37
+ # @param definition [String] The definition of the unit.
38
+ # @param aliases [Array] The aliases of this unit.
39
+ def self.define_unit(name, definition, aliases)
40
+ RubyUnits::Unit.define(name) do |unit|
41
+ unit.definition = RubyUnits::Unit.new(definition)
42
+ unit.aliases = aliases
43
+ end
44
+ end
45
+
46
+ # Converts a value from a unit to another.
47
+ #
48
+ # @param value [Float] The value to convert.
49
+ # @param from [String] The origin unit.
50
+ # @param to [String] The target unit.
51
+ # @param with_rate [Boolean] If to return the conversion rate in the results.
52
+ # @param multiple [Boolean] If to use multiple units for ft (ft+in) and lb/oz (lb+oz).
53
+ # @return [Hash|NilClass] The converted data or `nil` if the conversion failed.
54
+ def perform_filtering(value, from, to, with_rate, multiple)
55
+ from = check_temperature(from)
56
+ to = check_temperature(to)
57
+ converted = convert_value(value, from, to)
58
+ converted ? {from: from, to: to, value: convert_value(value, from, from), unit: convert_value(1, from, from), result: converted, rate: convert_value(1, from, to), with_rate: with_rate, multiple: multiple} : nil
59
+ end
60
+
61
+ # Processes items to obtain feedback items.
62
+ #
63
+ # @param results [Hash] The items to process.
64
+ # @return [Array] The feedback items.
65
+ def process_results(results)
66
+ multiple = results[:multiple]
67
+ title = "#{format_value(results[:value], multiple)} = #{format_value(results[:result], multiple)}"
68
+ title << " (#{format_value(results[:unit], multiple)} = #{format_value(results[:rate], multiple)})" if results[:with_rate]
69
+
70
+ [{title: title, arg: format_value(results[:result], :raw), subtitle: "Action this item to copy the converted amount on the clipboard.", icon: ICON}]
71
+ end
72
+
73
+ # Checks if a unit is a temperature and prepend "temp" if needed.
74
+ #
75
+ # @param unit [String] The unit to check.
76
+ # @return [String] The adjusted unit.
77
+ def check_temperature(unit)
78
+ unit = unit.gsub("°", "")
79
+ /^[CFKR]$/.match(unit.upcase) ? "temp#{unit.upcase}" : unit
80
+ end
81
+
82
+ # Converts a value from a unit to another.
83
+ #
84
+ # @param value [Float] The value to convert.
85
+ # @param from [String] The origin unit.
86
+ # @param to [String] The target unit.
87
+ # @return [String|NilClass] The converted unit or `nil` if the conversion failed.
88
+ def convert_value(value, from, to)
89
+ Unit.new("#{value} #{from}").convert_to(to)
90
+ end
91
+
92
+ # Formats a value.
93
+ #
94
+ # @param value [String] The value to format.
95
+ # @param modifier [Boolean|Symbol] If to use multiple units for ft (ft+in) and lb/oz (lb+oz). If `:raw`, only the unitless (float) value is returned.
96
+ # @param precision [Fixnum] The precision to use for rounding.
97
+ # @return [String|Float] The formatted value or the unitless value.
98
+ def format_value(value, modifier = nil, precision = 3)
99
+ rounded = round_float(value.scalar.to_f, precision)
100
+
101
+ if modifier != :raw then
102
+ format = "%0.#{precision}f"
103
+ units = value.units
104
+
105
+ if modifier && units =~ /ft|oz|lb/ then
106
+ format = units == "ft" ? :ft : :lbs
107
+ elsif rounded.to_i == rounded then
108
+ format = "%0.0f"
109
+ end
110
+
111
+ value.to_s(format).gsub(", ", " ").gsub(/ temp([CFKR])/, "°\\1")
112
+ else
113
+ rounded
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ Pincerna::UnitConversion.define_unit("miles per gallon", "1 mi/gal", ["mpg" "miles-per-gallon"])
120
+ Pincerna::UnitConversion.define_unit("kilometers per liter", "1 km/L", ["kpl" "kilometers-per-liter"])
@@ -0,0 +1,24 @@
1
+ # encoding: utf-8
2
+ #
3
+ # This file is part of the pincerna gem. Copyright (C) 2013 and above Shogun <shogun@cowtech.it>.
4
+ # Licensed under the MIT license, which can be found at https://choosealicense.com/licenses/mit.
5
+ #
6
+
7
+ module Pincerna
8
+ # The current version of pincerna, according to semantic versioning.
9
+ #
10
+ # @see http://semver.org
11
+ module Version
12
+ # The major version.
13
+ MAJOR = 1
14
+
15
+ # The minor version.
16
+ MINOR = 1
17
+
18
+ # The patch version.
19
+ PATCH = 3
20
+
21
+ # The current version of pincerna.
22
+ STRING = [MAJOR, MINOR, PATCH].compact.join(".")
23
+ end
24
+ end
@@ -0,0 +1,74 @@
1
+ # encoding: utf-8
2
+ #
3
+ # This file is part of the pincerna gem. Copyright (C) 2013 and above Shogun <shogun@cowtech.it>.
4
+ # Licensed under the MIT license, which can be found at https://choosealicense.com/licenses/mit.
5
+ #
6
+
7
+ module Pincerna
8
+ # Connects or disconnects from system's VPNs.
9
+ class Vpn < Base
10
+ # The expression to match.
11
+ MATCHER = /^(?<all>.*)$/i
12
+
13
+ # The icon to show for each feedback item.
14
+ ICON = Pincerna::Base::ROOT + "/images/vpn.png"
15
+
16
+ # Connects to or disconnects from system VPN.
17
+ #
18
+ # @param query [Array] A query to match against VPNs names.
19
+ # @return [Array] A list of items to process.
20
+ def perform_filtering(query)
21
+ rv = []
22
+ interface_filter ||= query.empty? ? /.+/ : /#{query}/i
23
+
24
+ execute_command("/usr/sbin/networksetup", "-listnetworkserviceorder").split(/\n\n/).each do |i|
25
+ # Scan every interface
26
+ token = StringScanner.new(i)
27
+
28
+ if token.scan_until(/^\(\d+\)/) then
29
+ name = token.scan_until(/\n/).strip # Get VPN name
30
+ next if !interface_filter.match(name)
31
+
32
+ # Get the type
33
+ token.scan_until(/Hardware Port:\s/)
34
+
35
+ # If type matches
36
+ rv << {name: name, connected: vpn_connected?(name)} if is_vpn_service?(token.scan_until(/,/))
37
+ end
38
+ end
39
+
40
+ rv
41
+ end
42
+
43
+ # Processes items to obtain feedback items.
44
+ #
45
+ # @param results [Array] The items to process.
46
+ # @return [Array] The feedback items.
47
+ def process_results(results)
48
+ results.map do |result|
49
+ title = "#{result[:connected] ? "Disconnect from" : "Connect to"} #{result[:name]}"
50
+ subtitle = "Action this item to #{result[:connected] ? "disconnect from" : "connect to"} the VPN service."
51
+ arg = "#{result[:connected] ? "disconnect" : "connect"} service \"#{result[:name]}\""
52
+
53
+ {title: title, arg: arg, subtitle: subtitle, icon: ICON}
54
+ end
55
+ end
56
+
57
+ # Checks if a VPN is connected.
58
+ #
59
+ # @param name [String] The VPN's name.
60
+ # @return [Boolean] `true` if the VPN is connected, `false` otherwise.
61
+ def vpn_connected?(name)
62
+ execute_command("/usr/sbin/networksetup", "-showpppoestatus", "\"#{name}\"").strip == "connected"
63
+ end
64
+
65
+ private
66
+ # Check if a service is a VPN.
67
+ #
68
+ # @param service [String] The service name.
69
+ # @return `true` if the service is a VPN service, `false` otherwise.
70
+ def is_vpn_service?(service)
71
+ ["L2TP", "IPSec"].include?(service.gsub(/,$/, ""))
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,188 @@
1
+ # encoding: utf-8
2
+ #
3
+ # This file is part of the pincerna gem. Copyright (C) 2013 and above Shogun <shogun@cowtech.it>.
4
+ # Licensed under the MIT license, which can be found at https://choosealicense.com/licenses/mit.
5
+ #
6
+
7
+ module Pincerna
8
+ # Gets weather forecast from Yahoo! Weather.
9
+ class Weather < Base
10
+ # The expression to match.
11
+ MATCHER = /^
12
+ (?<place>.+?)
13
+ (\s+in\s+(?<scale>[cf]))?
14
+ $/mix
15
+
16
+ # Relevant groups in the match.
17
+ RELEVANT_MATCHES = {
18
+ "place" => ->(_, value) { value }, # Place or WOEID
19
+ "scale" => ->(_, value) { value.nil? ? "c" : value.downcase } # Temperature scale
20
+ }
21
+
22
+ # The icon to show for each feedback item.
23
+ ICON = Pincerna::Base::ROOT + "/images/weather.png"
24
+
25
+ # The URL of the webservice.
26
+ URL = "http://where.yahooapis.com/v1/places.q(%s);count=5"
27
+
28
+ # Yahoo! API key.
29
+ API_KEY = "dj0yJmk9ZUpBZk1hQTJGRHM5JmQ9WVdrOVlUSnBjMGhUTjJVbWNHbzlOemsyTURNeU5EWXkmcz1jb25zdW1lcnNlY3JldCZ4PWRi"
30
+
31
+ # Gets forecast for a place.
32
+ #
33
+ # @param query [String] A place to search.
34
+ # @return [Array] A list of items to process.
35
+ def perform_filtering(query, scale)
36
+ places = lookup_places(query)
37
+ places.empty? ? nil : get_forecast(places, scale) if !places.empty?
38
+ end
39
+
40
+ # Processes items to obtain feedback items.
41
+ #
42
+ # @param results [Array] The items to process.
43
+ # @return [Array] The feedback items.
44
+ def process_results(results)
45
+ results.map do |result|
46
+ # Format results
47
+ current = result[:current]
48
+ forecast = result[:forecast]
49
+ combined = "#{current[:temperature]}, #{current[:description].downcase.capitalize}, wind #{current[:wind][:speed]} #{current[:wind][:direction]} - Next: #{forecast[:high]} / #{forecast[:low]}, #{forecast[:description]}"
50
+
51
+ {title: result[:name], arg: result[:link], subtitle: combined, icon: result[:image]}
52
+ end
53
+ end
54
+
55
+ # Lookups a place on Yahoo! to obtain WOEID(s).
56
+ #
57
+ # @param query [String] The place to search.
58
+ # @return [Array] A list of matching places data.
59
+ def lookup_places(query)
60
+ if query !~ /^(\d+)$/ then
61
+ Pincerna::Cache.instance.use("woeid:#{query}", Pincerna::Cache::EXPIRATIONS[:long]) do
62
+ response = fetch_remote_resource(URL % CGI.escape(query), {appid: API_KEY, format: :json})
63
+ response["places"].fetch("place", []).map { |place| parse_place(place) }
64
+ end
65
+ else
66
+ # We already have the woeid. The name will be given by Yahoo!
67
+ [{woeid: query}]
68
+ end
69
+ end
70
+
71
+ # Gets weather forecast for one or more places.
72
+ #
73
+ # @param places [Array] The places to query.
74
+ # @param scale [String] The unit system to use: `f` for the US system (Farenheit) and `c` for the International System one (Celsius).
75
+ # @return [Array|NilClass] An array with forecasts data or `nil` if the query failed.
76
+ def get_forecast(places, scale = "c")
77
+ client = Weatherman::Client.new(unit: scale)
78
+ temperature_unit = "°#{scale.upcase}"
79
+
80
+ places.map do |place|
81
+ Pincerna::Cache.instance.use("forecast:#{place[:woeid]}", Pincerna::Cache::EXPIRATIONS[:short]) {
82
+ parse_forecast_response(place, client.lookup_by_woeid(place[:woeid]), temperature_unit)
83
+ }
84
+ end
85
+ end
86
+
87
+ # Converts the degrees direction of the wind to the cardinal points notation (like NE or SW).
88
+ #
89
+ # @param degrees [Fixnum] The direction in degrees.
90
+ # @return [String] The direction in cardinal points notation.
91
+ def get_wind_direction(degrees)
92
+ # Normalize value
93
+ degrees += 360 if degrees < 0
94
+ degrees = degrees % 360
95
+
96
+ # Get the position
97
+ directions = ["N", "NE", "NE", "E", "E", "SE", "SE", "S", "S", "SW", "SW", "W", "W", "NW", "NW", "N"]
98
+ position = ((degrees.to_f / 22.5) - 0.5).ceil.to_i % directions.count # The mod operation is needed for values close to 360 who, after ceiling, would otherwise overflow.
99
+ directions[position]
100
+ end
101
+
102
+ private
103
+ # Gets and downloads an image for a forecast.
104
+ #
105
+ # @param url [String] The image URL.
106
+ # @return [String] The path of the downloaded image.
107
+ def download_image(url)
108
+ # Extract the URL and use it to build the path
109
+ rv = (@cache_dir + "/weather/#{File.basename(URI.parse(url).path)}")
110
+
111
+ if !File.exists?(rv) then
112
+ # Create the directory and download the file
113
+ FileUtils.mkdir_p(@cache_dir + "/weather/")
114
+ open(rv, 'wb') {|f| f.write(open(url).read) }
115
+ end
116
+
117
+ rv
118
+ end
119
+
120
+ # Gets a location name.
121
+ #
122
+ # @param location [Hash] The location data.
123
+ # @return [String] The location name.
124
+ def get_name(location)
125
+ ["city", "region", "country"].map { |field| location[field].strip }.reject(&:empty?).join(", ")
126
+ end
127
+
128
+ # Parses a WOEID lookup.
129
+ #
130
+ # @param place [Hash] The place to parse.
131
+ # @return [Hash] The parsed place.
132
+ def parse_place(place)
133
+ {
134
+ woeid: place["woeid"],
135
+ name: ["locality1", "admin3", "admin2", "admin1", "country"].map { |field| place[field] }.reject(&:empty?).uniq.join(", ")
136
+ }
137
+ end
138
+
139
+ # Formats a weather forecast.
140
+ #
141
+ # @param place [Hash] The basic place information.
142
+ # @param response [Weatherman::Response] The forecast response.
143
+ # @param temperature_unit [String] The temperature unit.
144
+ # @return [Hash] A formatted forecast.
145
+ def parse_forecast_response(place, response, temperature_unit)
146
+ image, link = extract_forecast_media(response)
147
+ place[:name] ||= get_name(response.location)
148
+
149
+ format_forecast(place, download_image(image), link, response.condition, response.forecasts.first, response.wind, temperature_unit, response.units["speed"])
150
+ end
151
+
152
+ # Formats a weather forecast.
153
+ #
154
+ # @param place [Hash] The basic place information.
155
+ # @param image [String] The icon for the current weather conditions.
156
+ # @param link [String] The link to view weather conditions on Yahoo!.
157
+ # @param current [Hash] The current weather conditions.
158
+ # @param forecast [Hash] The weather forecast for tomorrow.
159
+ # @param wind [Hash] The current wind conditions.
160
+ # @param temperature_unit [String] The temperature unit.
161
+ # @param speed_unit [String] The speed unit.
162
+ # @return [Hash] The parsed forecast.
163
+ def format_forecast(place, image, link, current, forecast, wind, temperature_unit, speed_unit)
164
+ place.merge({
165
+ image: image,
166
+ link: link,
167
+ current: {
168
+ description: current["text"],
169
+ temperature: "#{current["temp"]} #{temperature_unit}",
170
+ wind: {speed: "#{wind["speed"]} #{speed_unit}", direction: get_wind_direction(wind["direction"])}
171
+ },
172
+ forecast: {
173
+ description: forecast["text"],
174
+ high: "#{forecast["high"]} #{temperature_unit}",
175
+ low: "#{forecast["low"]} #{temperature_unit}"
176
+ },
177
+ })
178
+ end
179
+
180
+ # Extracts forecast media from a response.
181
+ #
182
+ # @param response The response to analyze.
183
+ # @return [Array] An array of media.
184
+ def extract_forecast_media(response)
185
+ [response.description_image.attr("src"), response.document_root.at_xpath("link").content.to_s]
186
+ end
187
+ end
188
+ end