birdwatcher 0.1.0

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 (86) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/.travis.yml +5 -0
  4. data/Gemfile +4 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +481 -0
  7. data/Rakefile +10 -0
  8. data/bin/console +42 -0
  9. data/birdwatcher.gemspec +40 -0
  10. data/data/english_stopwords.txt +319 -0
  11. data/data/top100Kenglishwords.txt +100000 -0
  12. data/db/migrations/001_create_workspaces.rb +11 -0
  13. data/db/migrations/002_create_users.rb +29 -0
  14. data/db/migrations/003_create_statuses.rb +28 -0
  15. data/db/migrations/004_create_mentions.rb +13 -0
  16. data/db/migrations/005_create_mentions_statuses.rb +8 -0
  17. data/db/migrations/006_create_hashtags.rb +11 -0
  18. data/db/migrations/007_create_hashtags_statuses.rb +8 -0
  19. data/db/migrations/008_create_urls.rb +16 -0
  20. data/db/migrations/009_create_statuses_urls.rb +8 -0
  21. data/db/migrations/010_create_klout_topics.rb +10 -0
  22. data/db/migrations/011_create_klout_topics_users.rb +8 -0
  23. data/db/migrations/012_create_influencers.rb +10 -0
  24. data/db/migrations/013_create_influencers_users.rb +8 -0
  25. data/db/migrations/014_create_influencees.rb +10 -0
  26. data/db/migrations/015_create_influencees_users.rb +8 -0
  27. data/exe/birdwatcher +12 -0
  28. data/lib/birdwatcher/command.rb +78 -0
  29. data/lib/birdwatcher/commands/back.rb +15 -0
  30. data/lib/birdwatcher/commands/exit.rb +16 -0
  31. data/lib/birdwatcher/commands/help.rb +60 -0
  32. data/lib/birdwatcher/commands/irb.rb +34 -0
  33. data/lib/birdwatcher/commands/module.rb +106 -0
  34. data/lib/birdwatcher/commands/query.rb +58 -0
  35. data/lib/birdwatcher/commands/query_csv.rb +56 -0
  36. data/lib/birdwatcher/commands/resource.rb +45 -0
  37. data/lib/birdwatcher/commands/run.rb +19 -0
  38. data/lib/birdwatcher/commands/schema.rb +116 -0
  39. data/lib/birdwatcher/commands/set.rb +56 -0
  40. data/lib/birdwatcher/commands/shell.rb +21 -0
  41. data/lib/birdwatcher/commands/show.rb +86 -0
  42. data/lib/birdwatcher/commands/status.rb +114 -0
  43. data/lib/birdwatcher/commands/unset.rb +37 -0
  44. data/lib/birdwatcher/commands/use.rb +25 -0
  45. data/lib/birdwatcher/commands/user.rb +155 -0
  46. data/lib/birdwatcher/commands/workspace.rb +176 -0
  47. data/lib/birdwatcher/concerns/concurrency.rb +25 -0
  48. data/lib/birdwatcher/concerns/core.rb +105 -0
  49. data/lib/birdwatcher/concerns/outputting.rb +114 -0
  50. data/lib/birdwatcher/concerns/persistence.rb +101 -0
  51. data/lib/birdwatcher/concerns/presentation.rb +122 -0
  52. data/lib/birdwatcher/concerns/util.rb +138 -0
  53. data/lib/birdwatcher/configuration.rb +63 -0
  54. data/lib/birdwatcher/configuration_wizard.rb +65 -0
  55. data/lib/birdwatcher/console.rb +201 -0
  56. data/lib/birdwatcher/http_client.rb +164 -0
  57. data/lib/birdwatcher/klout_client.rb +83 -0
  58. data/lib/birdwatcher/kml.rb +125 -0
  59. data/lib/birdwatcher/module.rb +253 -0
  60. data/lib/birdwatcher/modules/statuses/kml.rb +106 -0
  61. data/lib/birdwatcher/modules/statuses/sentiment.rb +77 -0
  62. data/lib/birdwatcher/modules/statuses/word_cloud.rb +205 -0
  63. data/lib/birdwatcher/modules/urls/crawl.rb +138 -0
  64. data/lib/birdwatcher/modules/urls/most_shared.rb +98 -0
  65. data/lib/birdwatcher/modules/users/activity_plot.rb +62 -0
  66. data/lib/birdwatcher/modules/users/import.rb +61 -0
  67. data/lib/birdwatcher/modules/users/influence_graph.rb +93 -0
  68. data/lib/birdwatcher/modules/users/klout_id.rb +62 -0
  69. data/lib/birdwatcher/modules/users/klout_influence.rb +83 -0
  70. data/lib/birdwatcher/modules/users/klout_score.rb +64 -0
  71. data/lib/birdwatcher/modules/users/klout_topics.rb +72 -0
  72. data/lib/birdwatcher/modules/users/social_graph.rb +110 -0
  73. data/lib/birdwatcher/punchcard.rb +183 -0
  74. data/lib/birdwatcher/util.rb +83 -0
  75. data/lib/birdwatcher/version.rb +3 -0
  76. data/lib/birdwatcher.rb +43 -0
  77. data/models/hashtag.rb +8 -0
  78. data/models/influencee.rb +8 -0
  79. data/models/influencer.rb +8 -0
  80. data/models/klout_topic.rb +8 -0
  81. data/models/mention.rb +8 -0
  82. data/models/status.rb +11 -0
  83. data/models/url.rb +8 -0
  84. data/models/user.rb +11 -0
  85. data/models/workspace.rb +26 -0
  86. metadata +405 -0
@@ -0,0 +1,62 @@
1
+ module Birdwatcher
2
+ module Modules
3
+ module Users
4
+ class KloutId < Birdwatcher::Module
5
+ self.meta = {
6
+ :name => "User Klout IDs",
7
+ :description => "Enrich users with their Klout ID",
8
+ :author => "Michael Henriksen <michenriksen@neomailbox.ch>",
9
+ :options => {
10
+ "THREADS" => {
11
+ :value => 5,
12
+ :description => "Number of concurrent threads",
13
+ :required => false
14
+ }
15
+ }
16
+ }
17
+
18
+ def self.info
19
+ <<-INFO
20
+ The User Klout IDs module can populate the current workspace's users with their
21
+ Klout ID. Having a Klout ID on users makes it possible to gather information
22
+ with other modules such as #{'users/klout_influence'.bold}, #{'users/klout_topics'.bold} and
23
+ #{'users/klout_score'.bold}.
24
+
25
+ #{'Note:'.bold} Birdwatcher must be configured with one or more Klout API keys
26
+ in order to work.
27
+ INFO
28
+ end
29
+
30
+ def run
31
+ if !klout_client
32
+ error("Birdwatcher has not been configured with any Klout API keys")
33
+ return false
34
+ end
35
+ users = current_workspace.users_dataset.order(:screen_name)
36
+ if users.empty?
37
+ error("There are no users to process")
38
+ return false
39
+ end
40
+ threads = thread_pool(option_setting("THREADS").to_i)
41
+ users.each do |user|
42
+ threads.process do
43
+ begin
44
+ klout_id = klout_client.get_id(user.screen_name)
45
+ if klout_id.nil?
46
+ warn("User #{user.screen_name.bold} doesn't have a Klout ID; skipping")
47
+ next
48
+ end
49
+ user.klout_id = klout_id
50
+ user.save
51
+ info("User #{user.screen_name.bold} has a Klout ID: #{klout_id.to_s.bold}")
52
+ rescue => e
53
+ error("Processing of #{user.screen_name.bold} failed (#{e.class})")
54
+ end
55
+ end
56
+ end
57
+ threads.shutdown
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,83 @@
1
+ module Birdwatcher
2
+ module Modules
3
+ module Users
4
+ class KloutInfluence < Birdwatcher::Module
5
+ self.meta = {
6
+ :name => "User Klout Influence",
7
+ :description => "Enrich users with their Klout influence",
8
+ :author => "Michael Henriksen <michenriksen@neomailbox.ch>",
9
+ :options => {
10
+ "THREADS" => {
11
+ :value => 5,
12
+ :description => "Number of concurrent threads",
13
+ :required => false
14
+ }
15
+ }
16
+ }
17
+
18
+ def self.info
19
+ <<-INFO
20
+ The User Klout Influence module can be used to gather an influence graph of all
21
+ users in the currently active workspace. The Klout Influence API can tell who
22
+ users are being influenced by as well as who they are influencing.
23
+
24
+ The influence graph can be generated with the #{'users/influence_graph'.bold} module
25
+ when the raw data has been gathered with this module.
26
+
27
+ #{'Note:'.bold} This module requires that users have been enriched with their
28
+ Klout ID from the #{'users/klout_id'.bold} module. It also requires that Birdwatcher has
29
+ been configured with one or more Klout API keys in order to work.
30
+ INFO
31
+ end
32
+
33
+ def run
34
+ if !klout_client
35
+ error("Birdwatcher has not been configured with any Klout API keys")
36
+ return false
37
+ end
38
+ users = current_workspace.users_dataset.where("klout_id IS NOT NULL").order(:screen_name)
39
+ if users.empty?
40
+ error("There are no users with Klout IDs")
41
+ return false
42
+ end
43
+ threads = thread_pool(option_setting("THREADS").to_i)
44
+ influencer_mutex = Mutex.new
45
+ influencee_mutex = Mutex.new
46
+ users.each do |user|
47
+ threads.process do
48
+ begin
49
+ if influence = klout_client.get_influence(user.klout_id)
50
+ influence[:influencers].each do |screen_name|
51
+ db_influencer = influencer_mutex.synchronize do
52
+ Birdwatcher::Models::Influencer.find_or_create(:workspace_id => current_workspace.id, :screen_name => screen_name)
53
+ end
54
+ if !user.influencers.include?(db_influencer)
55
+ user.add_influencer(db_influencer)
56
+ end
57
+ end
58
+ influence[:influencees].each do |screen_name|
59
+ db_influencee = influencee_mutex.synchronize do
60
+ Birdwatcher::Models::Influencee.find_or_create(:workspace_id => current_workspace.id, :screen_name => screen_name)
61
+ end
62
+ if !user.influencees.include?(db_influencee)
63
+ user.add_influencee(db_influencee)
64
+ end
65
+ end
66
+ info("User #{user.screen_name.bold} is influenced by: #{influence[:influencers].map(&:bold).join(', ')}")
67
+ info("User #{user.screen_name.bold} is influencing: #{influence[:influencees].map(&:bold).join(', ')}")
68
+ else
69
+ error("Could not get Klout influence for #{user.screen_name.bold}")
70
+ end
71
+ user.save
72
+ rescue => e
73
+ error("Processing of #{user.screen_name.bold} failed (#{e.class})")
74
+ info(e.backtrace.join("\n"))
75
+ end
76
+ end
77
+ end
78
+ threads.shutdown
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,64 @@
1
+ module Birdwatcher
2
+ module Modules
3
+ module Users
4
+ class KloutScore < Birdwatcher::Module
5
+ self.meta = {
6
+ :name => "User Klout Score",
7
+ :description => "Enrich users with their Klout score",
8
+ :author => "Michael Henriksen <michenriksen@neomailbox.ch>",
9
+ :options => {
10
+ "THREADS" => {
11
+ :value => 5,
12
+ :description => "Number of concurrent threads",
13
+ :required => false
14
+ }
15
+ }
16
+ }
17
+
18
+ def self.info
19
+ <<-INFO
20
+ The User Klout Score module can be used to retrieve the Klout Score of all users
21
+ in the currently active workspace.
22
+
23
+ The Klout score is a score between 1-100 and represents a user's influence. The
24
+ more influential a user is, the higher their Klout score. Read more about how it
25
+ works here: https://klout.com/corp/score
26
+
27
+ #{'Note:'.bold} This module requires that users have been enriched with their
28
+ Klout ID from the #{'users/klout_id'.bold} module. It also requires that Birdwatcher has
29
+ been configured with one or more Klout API keys in order to work.
30
+ INFO
31
+ end
32
+
33
+ def run
34
+ if !klout_client
35
+ error("Birdwatcher has not been configured with any Klout API keys")
36
+ return false
37
+ end
38
+ users = current_workspace.users_dataset.where("klout_id IS NOT NULL").order(:screen_name)
39
+ if users.empty?
40
+ error("There are no users with Klout IDs")
41
+ return false
42
+ end
43
+ threads = thread_pool(option_setting("THREADS").to_i)
44
+ users.each do |user|
45
+ threads.process do
46
+ begin
47
+ if klout_score = klout_client.get_score(user.klout_id)
48
+ user.klout_score = klout_score
49
+ info("User #{user.screen_name.bold} has a Klout score of #{klout_score.to_s.bold}")
50
+ else
51
+ error("Could not get Klout score for #{user.screen_name.bold}")
52
+ end
53
+ user.save
54
+ rescue => e
55
+ error("Processing of #{user.screen_name.bold} failed (#{e.class})")
56
+ end
57
+ end
58
+ end
59
+ threads.shutdown
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,72 @@
1
+ module Birdwatcher
2
+ module Modules
3
+ module Users
4
+ class KloutTopics < Birdwatcher::Module
5
+ self.meta = {
6
+ :name => "User Klout Topics",
7
+ :description => "Enrich users with their Klout topics",
8
+ :author => "Michael Henriksen <michenriksen@neomailbox.ch>",
9
+ :options => {
10
+ "THREADS" => {
11
+ :value => 5,
12
+ :description => "Number of concurrent threads",
13
+ :required => false
14
+ }
15
+ }
16
+ }
17
+
18
+ def self.info
19
+ <<-INFO
20
+ The User Klout Topics module can be used to retrieve the general topics that
21
+ users in the currently active workspace are tweeting about.
22
+
23
+ #{'Note:'.bold} This module requires that users have been enriched with their
24
+ Klout ID from the #{'users/klout_id'.bold} module. It also requires that Birdwatcher has
25
+ been configured with one or more Klout API keys in order to work.
26
+ INFO
27
+ end
28
+
29
+ def run
30
+ if !klout_client
31
+ error("Birdwatcher has not been configured with any Klout API keys")
32
+ return false
33
+ end
34
+ users = current_workspace.users_dataset.where("klout_id IS NOT NULL").order(:screen_name)
35
+ if users.empty?
36
+ error("There are no users with Klout IDs")
37
+ return false
38
+ end
39
+ threads = thread_pool(option_setting("THREADS").to_i)
40
+ mutex = Mutex.new
41
+ users.each do |user|
42
+ threads.process do
43
+ begin
44
+ if klout_topics = klout_client.get_topics(user.klout_id)
45
+ if klout_topics.empty?
46
+ warn("User #{user.screen_name.bold} has no topics; skipping")
47
+ next
48
+ end
49
+ klout_topics.each do |topic|
50
+ db_topic = mutex.synchronize do
51
+ Birdwatcher::Models::KloutTopic.find_or_create(:workspace_id => current_workspace.id, :topic => topic)
52
+ end
53
+ if !user.klout_topics.include?(db_topic)
54
+ user.add_klout_topic(db_topic)
55
+ end
56
+ end
57
+ info("User #{user.screen_name.bold} has topics: #{klout_topics.map(&:bold).join(', ')}")
58
+ else
59
+ error("Could not get Klout topics for #{user.screen_name.bold}")
60
+ end
61
+ user.save
62
+ rescue => e
63
+ error("Processing of #{user.screen_name.bold} failed (#{e.class})")
64
+ end
65
+ end
66
+ end
67
+ threads.shutdown
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,110 @@
1
+ module Birdwatcher
2
+ module Modules
3
+ module Users
4
+ class SocialGraph < Birdwatcher::Module
5
+ self.meta = {
6
+ :name => "Social Graph",
7
+ :description => "Graphs the social relations between users",
8
+ :author => "Michael Henriksen <michenriksen@neomailbox.ch>",
9
+ :options => {
10
+ "DEST" => {
11
+ :value => nil,
12
+ :description => "Destination file",
13
+ :required => true
14
+ },
15
+ "USERS" => {
16
+ :value => nil,
17
+ :description => "Space-separated list of screen names (all users if empty)",
18
+ :required => false
19
+ },
20
+ "MIN_WEIGHT" => {
21
+ :value => 10,
22
+ :description => "Percentage of the highest edge weight to be considered minimum edge weight",
23
+ :required => true
24
+ },
25
+ "FORMAT" => {
26
+ :value => "png",
27
+ :description => "Destination file format (any format supported by Graphviz)",
28
+ :required => true
29
+ }
30
+ }
31
+ }
32
+
33
+ def self.info
34
+ <<-INFO
35
+ The Social Graph module generates an undirected graph between users in the
36
+ currently active workspace. The edges between users will be weighted by simply
37
+ counting the amount of times they mutually mention each other in statuses. The
38
+ module can identify social clusters within the collection of users.
39
+
40
+ The generated graph is in PNG format.
41
+ INFO
42
+ end
43
+
44
+ def run
45
+ if !GraphViz::Constants::FORMATS.include?(option_setting("FORMAT"))
46
+ error("Unsupported format: #{option_setting('FORMAT').bold}")
47
+ return false
48
+ end
49
+ if screen_names = option_setting("USERS")
50
+ users = current_workspace.users_dataset
51
+ .where("screen_name IN ?", screen_names.split(" ").map(&:strip))
52
+ .order(:screen_name)
53
+ else
54
+ users = current_workspace.users_dataset.order(:screen_name)
55
+ end
56
+ if users.empty?
57
+ error("There are no users to process")
58
+ return false
59
+ end
60
+ graph = GraphViz.new(:G, :type => :graph, :use => "sfdp", :overlap => "prism", :splines => "curved")
61
+ edge_weights = {}
62
+ highest_edge_weight = 0
63
+ nodes = {}
64
+ users.each do |user|
65
+ task("Calculating social graph for #{user.screen_name.bold}...") do
66
+ users.each do |other_user|
67
+ next if user.id == other_user.id
68
+ edge_weights[user.screen_name] ||= {}
69
+ edge_weights[other_user.screen_name] ||= {}
70
+ next if (edge_weights[user.screen_name].key?(other_user.screen_name) && edge_weights[other_user.screen_name].key?(user.screen_name))
71
+ edge_weights[user.screen_name][other_user.screen_name] = user.statuses_dataset.where("text LIKE ?", "%#{other_user.screen_name}%").count
72
+ edge_weights[other_user.screen_name][user.screen_name] = other_user.statuses_dataset.where("text LIKE ?", "%#{user.screen_name}%").count
73
+ total_edge_weight = edge_weights[user.screen_name][other_user.screen_name] + edge_weights[other_user.screen_name][user.screen_name]
74
+ highest_edge_weight = total_edge_weight if (total_edge_weight > highest_edge_weight)
75
+ end
76
+ end
77
+ end
78
+ task("Generating social graph...") do
79
+ edge_weights.each_pair do |user, user_graph|
80
+ user_graph.each_pair do |other_user, edge_weight|
81
+ total_edge_weight = edge_weight + edge_weights[other_user][user]
82
+ percentage = (total_edge_weight.to_f / highest_edge_weight.to_f * 100).to_i
83
+ if percentage >= option_setting("MIN_WEIGHT").to_i
84
+ nodes[user] ||= graph.add_nodes(user)
85
+ nodes[other_user] ||= graph.add_nodes(other_user)
86
+ case percentage
87
+ when 0..25
88
+ pen_width = 1
89
+ when 26..50
90
+ pen_width = 2
91
+ when 51..75
92
+ pen_width = 4
93
+ when 76..100
94
+ pen_width = 5
95
+ end
96
+ graph.add_edges(nodes[user], nodes[other_user], :weight => total_edge_weight, :penwidth => pen_width, :color => "lightblue", :fontcolor => "cornflowerblue", :label => total_edge_weight.to_s)
97
+ edge_weights[other_user].delete(user)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ task("Outputting graph...") do
103
+ graph.output(option_setting("FORMAT") => option_setting("DEST"))
104
+ end
105
+ info("Graph written to #{option_setting('DEST').bold}")
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,183 @@
1
+ # Code adapted from https://github.com/jashank/punchcard-plot
2
+
3
+ module Birdwatcher
4
+ class Punchcard
5
+ LEFT_PADDING = 10
6
+ TOP_PADDING = 10
7
+
8
+ WIDTH = 1100
9
+
10
+ DAYS = %w(Sat Fri Thu Wed Tue Mon Sun)
11
+ HOURS = %w(12am 1 2 3 4 5 6 7 8 9 10 11 12pm 1 2 3 4 5 6 7 8 9 10 11)
12
+
13
+ FONT_FACE = "sans-serif"
14
+
15
+ def initialize(timestamps)
16
+ @timestamps = timestamps
17
+ end
18
+
19
+ def generate(destination)
20
+ @data_log = Hash.new { |h, k| h[k] = Hash.new }
21
+ final_data = []
22
+ @timestamps.each do |timestamp|
23
+ day = timestamp.strftime("%a")
24
+ hour = timestamp.strftime("%H").to_i
25
+ @data_log[day][hour] = (@data_log[day][hour] || 0) + 1
26
+ end
27
+ @data_log.each do |d, hour_pair|
28
+ hour_pair.each do |h, value|
29
+ glr = @data_log[d][h] * 1.0
30
+ glr /= max_value
31
+ glr *= max_range
32
+ glrb = get_weight(glr)
33
+ final_data.push([glrb, get_x_y_from_day_and_hour(d, h)])
34
+ end
35
+ end
36
+ final_data.each do |x|
37
+ draw_circle(x[1], x[0])
38
+ end
39
+ surface.write_to_png(destination)
40
+ end
41
+
42
+ private
43
+
44
+ def width
45
+ WIDTH
46
+ end
47
+
48
+ def height
49
+ (width / 2.75).round(0)
50
+ end
51
+
52
+ def distance
53
+ distance = Math.sqrt((width * height) / 270.5).round
54
+ if distance % 2 == 1
55
+ distance -= 1
56
+ end
57
+ distance
58
+ end
59
+
60
+ def max_range
61
+ (distance / 2)**2
62
+ end
63
+
64
+ def left
65
+ (width / 18) + LEFT_PADDING
66
+ end
67
+
68
+ def indicator_length
69
+ height / 20
70
+ end
71
+
72
+ def top
73
+ indicator_length + TOP_PADDING
74
+ end
75
+
76
+ def get_x_y_from_day_and_hour(day, hour)
77
+ y = top + (DAYS.index(day.to_s) + 1) * distance
78
+ x = left + (hour.to_i + 1) * distance
79
+ [x, y]
80
+ end
81
+
82
+ def get_weight(number)
83
+ return 0 if number.zero?
84
+ (1..(distance / 2)).to_a.each do |i|
85
+ if i * i <= number && number < (i + 1) * (i + 1)
86
+ return i
87
+ end
88
+ end
89
+
90
+ if number == max_range
91
+ return distance/2-1
92
+ end
93
+
94
+ nil
95
+ end
96
+
97
+ def all_values
98
+ @all_values = []
99
+ @data_log.each do |d, e|
100
+ e.each do |h, i|
101
+ @all_values << @data_log[d][h]
102
+ end
103
+ end
104
+ @all_values
105
+ end
106
+
107
+ def max_value
108
+ all_values.sort.last
109
+ end
110
+
111
+ def surface
112
+ @surface ||= Cairo::ImageSurface.new(Cairo::FORMAT_ARGB32, width, height)
113
+ end
114
+
115
+ def context
116
+ @context ||= Cairo::Context.new(surface).tap do |c|
117
+ c.line_width = 1
118
+ c.set_source_rgb(1, 1, 1)
119
+ c.rectangle(0, 0, width, height)
120
+ c.fill
121
+
122
+ # Set black
123
+ c.set_source_rgb(0, 0, 0)
124
+
125
+ # Draw X and Y axis
126
+ c.move_to(left, top)
127
+ c.rel_line_to(0, 8 * distance)
128
+ c.rel_line_to(25 * distance, 0)
129
+ c.stroke
130
+
131
+ # Draw indicators on X and Y axis
132
+ x, y = left, top
133
+ 8.times do
134
+ c.move_to(x, y)
135
+ c.rel_line_to(-indicator_length, 0)
136
+ c.stroke
137
+ y += distance
138
+ end
139
+
140
+ x += distance
141
+ 26.times do
142
+ c.move_to(x, y)
143
+ c.rel_line_to(0, indicator_length)
144
+ c.stroke
145
+ x += distance
146
+ end
147
+
148
+ # Select font
149
+ c.select_font_face(FONT_FACE, Cairo::FONT_SLANT_NORMAL, Cairo::FONT_WEIGHT_NORMAL)
150
+
151
+ # Set and appropiate font size
152
+ c.set_font_size(Math.sqrt( (width * height) / 3055.6))
153
+
154
+ # Draw days on Y axis
155
+ x, y = (left - 5), (top + distance)
156
+ DAYS.each do |day|
157
+ t_ext = c.text_extents(day.to_s)
158
+ c.move_to(x - indicator_length - t_ext.width, y + t_ext.height / 2)
159
+ c.show_text(day.to_s)
160
+ y += distance
161
+ end
162
+
163
+ # Draw hours on X axis
164
+ x, y = (left + distance), (top + (7 + 1) * distance + 5)
165
+ HOURS.each do |hour|
166
+ t_ext = c.text_extents(hour.to_s)
167
+ c.move_to(x - t_ext.width / 2 - t_ext.x_bearing, y + indicator_length + t_ext.height / 2)
168
+ c.show_text(hour.to_s)
169
+ x += distance
170
+ end
171
+ end
172
+ end
173
+
174
+ def draw_circle(position, weight)
175
+ x, y = position
176
+ alpha = (weight.to_f / max_value.to_f)
177
+ context.set_source_rgba(0, 0, 0, alpha)
178
+ context.move_to(x, y)
179
+ context.arc(x, y, weight, 0, 2 * Math::PI)
180
+ context.fill
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,83 @@
1
+ module Birdwatcher
2
+ module Util
3
+ def self.time_ago_in_words(time)
4
+ return "a very very long time ago" if time.year < 1800
5
+ secs = Time.now - time
6
+ return "just now" if secs > -1 && secs < 1
7
+ return "" if secs <= -1
8
+ pair = ago_in_words_pair(secs)
9
+ ary = ago_in_words_singularize(pair)
10
+ ary.size == 0 ? "" : ary.join(" and ") << " ago"
11
+ end
12
+
13
+ def self.ago_in_words_pair(secs)
14
+ [[60, :seconds], [60, :minutes], [24, :hours], [100_000, :days]].map{ |count, name|
15
+ if secs > 0
16
+ secs, n = secs.divmod(count)
17
+ "#{n.to_i} #{name}"
18
+ end
19
+ }.compact.reverse[0..1]
20
+ end
21
+
22
+ def self.ago_in_words_singularize(pair)
23
+ if pair.size == 1
24
+ pair.map! {|part| part[0, 2].to_i == 1 ? part.chomp("s") : part }
25
+ else
26
+ pair.map! {|part| part[0, 2].to_i == 1 ? part.chomp("s") : part[0, 2].to_i == 0 ? nil : part }
27
+ end
28
+ pair.compact
29
+ end
30
+
31
+ def self.parse_time(time)
32
+ ::Chronic.parse(time)
33
+ end
34
+
35
+ def self.strip_html(string)
36
+ string.to_s.gsub(/<\/?[^>]*>/, "")
37
+ end
38
+
39
+ def self.strip_control_characters(string)
40
+ string = string.to_s.uncolorize
41
+ string.split("").delete_if do |char|
42
+ char.ascii_only? and (char.ord < 32 or char.ord == 127)
43
+ end.join("")
44
+ end
45
+
46
+ def self.escape_html(string)
47
+ CGI.escapeHTML(string.to_s)
48
+ end
49
+
50
+ def self.unescape_html(string)
51
+ CGI.unescapeHTML(string.to_s)
52
+ end
53
+
54
+ def self.pluralize(count, singular, plural)
55
+ count == 1 ? "1 #{singular}" : "#{count} #{plural}"
56
+ end
57
+
58
+ def self.excerpt(text, max_length, omission = "...")
59
+ text = text.gsub(/\s/, " ").split(" ").map(&:strip).join(" ")
60
+ return text if text.length < max_length
61
+ text[0..max_length] + omission
62
+ end
63
+
64
+ def self.suppress_output(&block)
65
+ original_stdout = $stdout
66
+ $stdout = fake = StringIO.new
67
+ begin
68
+ yield
69
+ ensure
70
+ $stdout = original_stdout
71
+ end
72
+ fake.string
73
+ end
74
+
75
+ def self.suppress_warnings(&block)
76
+ warn_level = $VERBOSE
77
+ $VERBOSE = nil
78
+ result = block.call
79
+ $VERBOSE = warn_level
80
+ result
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,3 @@
1
+ module Birdwatcher
2
+ VERSION = "0.1.0"
3
+ end