pincerna 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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