birdwatcher 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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