friends 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/.overcommit.yml +3 -0
- data/.rubocop.yml +6 -5
- data/README.md +146 -5
- data/bin/friends +126 -2
- data/friends.gemspec +4 -3
- data/friends.md +8 -2
- data/lib/friends.rb +2 -1
- data/lib/friends/activity.rb +127 -0
- data/lib/friends/friend.rb +29 -0
- data/lib/friends/friends_error.rb +4 -0
- data/lib/friends/introvert.rb +185 -31
- data/lib/friends/serializable.rb +32 -0
- data/lib/friends/version.rb +1 -1
- data/test/activity_spec.rb +172 -0
- data/test/friend_spec.rb +39 -4
- data/test/helper.rb +2 -2
- data/test/introvert_spec.rb +213 -0
- data/test/tmp/.keep +0 -0
- metadata +35 -13
- data/lib/friends/extrovert.rb +0 -29
data/lib/friends/friend.rb
CHANGED
@@ -1,11 +1,40 @@
|
|
1
1
|
# Friend represents a friend. You know, a real-life friend!
|
2
2
|
|
3
|
+
require "friends/serializable"
|
4
|
+
|
3
5
|
module Friends
|
4
6
|
class Friend
|
7
|
+
extend Serializable
|
8
|
+
|
9
|
+
SERIALIZATION_PREFIX = "- "
|
10
|
+
|
11
|
+
# @return [Regexp] the regex for capturing groups in deserialization
|
12
|
+
def self.deserialization_regex
|
13
|
+
/(#{SERIALIZATION_PREFIX})?(?<name>.+)/
|
14
|
+
end
|
15
|
+
|
16
|
+
# @return [Regexp] the string of what we expected during deserialization
|
17
|
+
def self.deserialization_expectation
|
18
|
+
"[Friend Name]"
|
19
|
+
end
|
20
|
+
|
21
|
+
# @param name [String] the name of the friend
|
5
22
|
def initialize(name:)
|
6
23
|
@name = name
|
7
24
|
end
|
8
25
|
|
9
26
|
attr_accessor :name
|
27
|
+
|
28
|
+
# @return [String] the file serialization text for the friend
|
29
|
+
def serialize
|
30
|
+
"#{SERIALIZATION_PREFIX}#{name}"
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
# Default sorting for an array of friends is alphabetical.
|
36
|
+
def <=>(other)
|
37
|
+
name <=> other.name
|
38
|
+
end
|
10
39
|
end
|
11
40
|
end
|
data/lib/friends/introvert.rb
CHANGED
@@ -1,29 +1,169 @@
|
|
1
|
-
# Introvert is the internal handler for the friends script.
|
1
|
+
# Introvert is the internal handler for the friends script. It is designed to be
|
2
|
+
# able to be used directly within another Ruby program, without needing to call
|
3
|
+
# the command-line script explicitly.
|
2
4
|
|
5
|
+
require "friends/activity"
|
3
6
|
require "friends/friend"
|
7
|
+
require "friends/friends_error"
|
4
8
|
|
5
9
|
module Friends
|
6
10
|
class Introvert
|
11
|
+
DEFAULT_FILENAME = "./friends.md"
|
12
|
+
ACTIVITIES_HEADER = "### Activities:"
|
7
13
|
FRIENDS_HEADER = "### Friends:"
|
8
14
|
|
9
|
-
# @
|
10
|
-
def filename
|
11
|
-
|
15
|
+
# @param filename [String] the name of the friends Markdown file
|
16
|
+
def initialize(filename: DEFAULT_FILENAME)
|
17
|
+
@filename = filename
|
18
|
+
@cleaned_file = false # Switches to true when the file is cleaned.
|
19
|
+
|
20
|
+
# Read in the input file. It's easier to do this now and optimize later
|
21
|
+
# than try to overly be clever about what we read and write.
|
22
|
+
read_file(filename: @filename)
|
12
23
|
end
|
13
24
|
|
14
|
-
|
15
|
-
|
16
|
-
|
25
|
+
attr_reader :filename
|
26
|
+
attr_reader :activities
|
27
|
+
|
28
|
+
# Write out the friends file with cleaned/sorted data.
|
29
|
+
def clean
|
30
|
+
# Short-circuit if we've already cleaned the file so we don't write it
|
31
|
+
# twice.
|
32
|
+
return filename if @cleaned_file
|
33
|
+
|
34
|
+
descriptions = activities.sort.map(&:serialize)
|
35
|
+
names = friends.sort.map(&:serialize)
|
36
|
+
|
37
|
+
# Write out the cleaned file.
|
38
|
+
File.open(filename, "w") do |file|
|
39
|
+
file.puts(ACTIVITIES_HEADER)
|
40
|
+
descriptions.each { |desc| file.puts(desc) }
|
41
|
+
file.puts # Blank line separating friends from activities.
|
42
|
+
file.puts(FRIENDS_HEADER)
|
43
|
+
names.each { |name| file.puts(name) }
|
44
|
+
end
|
45
|
+
|
46
|
+
@cleaned_file = true
|
47
|
+
|
48
|
+
filename
|
49
|
+
end
|
50
|
+
|
51
|
+
# List all friend names in the friends file.
|
52
|
+
# @return [Array] a list of all friend names
|
53
|
+
def list_friends
|
54
|
+
friends.map(&:name)
|
55
|
+
end
|
17
56
|
|
18
|
-
|
57
|
+
# Add a friend and write out the new friends file.
|
58
|
+
# @param name [String] the name of the friend to add
|
59
|
+
# @raise [FriendsError] when a friend with that name is already in the file
|
60
|
+
# @return [Friend] the added friend
|
61
|
+
def add_friend(name:)
|
62
|
+
if friend_with_exact_name(name)
|
63
|
+
raise FriendsError, "Friend named #{name} already exists"
|
64
|
+
end
|
65
|
+
|
66
|
+
begin
|
67
|
+
friend = Friend.deserialize(name)
|
68
|
+
rescue Serializable::SerializationError => e
|
69
|
+
raise FriendsError, e
|
70
|
+
end
|
71
|
+
|
72
|
+
friends << friend
|
73
|
+
clean # Write a cleaned file.
|
74
|
+
|
75
|
+
friend # Return the added friend.
|
76
|
+
end
|
77
|
+
|
78
|
+
# List all activity details.
|
79
|
+
# @param [String] the name of a friend to filter by, or nil for unfiltered
|
80
|
+
# @return [Array] a list of all activity text values
|
81
|
+
def list_activities(with:)
|
82
|
+
acts = activities
|
83
|
+
|
84
|
+
# Filter by friend name if argument is passed.
|
85
|
+
unless with.nil?
|
86
|
+
friends = friends_with_name_in(with)
|
87
|
+
|
88
|
+
case friends.size
|
89
|
+
when 1
|
90
|
+
# If exactly one friend matches, use that friend to filter.
|
91
|
+
acts = acts.select { |a| a.friend_names.include? friends.first.name }
|
92
|
+
when 0 then raise FriendsError, "No friend found for \"#{with}\""
|
93
|
+
else
|
94
|
+
raise FriendsError,
|
95
|
+
"More than one friend found for \"#{with}\": "\
|
96
|
+
"#{friends.map(&:name).join(', ')}"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
acts.map(&:display_text)
|
101
|
+
end
|
102
|
+
|
103
|
+
# Add an activity and write out the new friends file.
|
104
|
+
# @param serialization [String] the serialized activity
|
105
|
+
# @return [Activity] the added activity
|
106
|
+
def add_activity(serialization:)
|
107
|
+
begin
|
108
|
+
activity = Activity.deserialize(serialization)
|
109
|
+
rescue Serializable::SerializationError => e
|
110
|
+
raise FriendsError, e
|
111
|
+
end
|
112
|
+
|
113
|
+
activity.highlight_friends(friends: friends)
|
114
|
+
activities << activity
|
115
|
+
clean # Write a cleaned file.
|
116
|
+
|
117
|
+
activity # Return the added activity.
|
118
|
+
end
|
119
|
+
|
120
|
+
# List your favorite friends.
|
121
|
+
# @param num [Integer] the number of favorite friends to return, or nil if
|
122
|
+
# unlimited
|
123
|
+
# @return [Array] a list of the favorite friends' names and activity
|
124
|
+
# counts
|
125
|
+
def list_favorites(limit:)
|
126
|
+
if !limit.nil? && limit < 1
|
127
|
+
raise FriendsError, "Favorites limit must be positive or unlimited"
|
128
|
+
end
|
129
|
+
|
130
|
+
# Construct a hash of friend name to frequency of appearance.
|
131
|
+
freq_table = Hash.new { |h, k| h[k] = 0 }
|
132
|
+
activities.each do |activity|
|
133
|
+
activity.friend_names.each do |friend_name|
|
134
|
+
freq_table[friend_name] += 1
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
# Remove names that are not in the friends list.
|
139
|
+
freq_table.select! { |name, _| friend_with_exact_name(name) }
|
140
|
+
|
141
|
+
# Sort the results, with the most favorite friend first.
|
142
|
+
results = freq_table.sort_by { |_, count| -count }
|
143
|
+
|
144
|
+
# If we need to, trim the list.
|
145
|
+
results = results.take(limit) unless limit.nil?
|
146
|
+
|
147
|
+
results.map(&:first)
|
148
|
+
end
|
149
|
+
|
150
|
+
private
|
151
|
+
|
152
|
+
# Gets the list of friends as read from the file.
|
153
|
+
# @return [Array] a list of all friends
|
154
|
+
def friends
|
19
155
|
@friends
|
20
156
|
end
|
21
157
|
|
22
158
|
# Process the friends.md file and store its contents in internal data
|
23
159
|
# structures.
|
24
160
|
# @param filename [String] the name of the friends file
|
25
|
-
|
26
|
-
|
161
|
+
def read_file(filename:)
|
162
|
+
@friends = []
|
163
|
+
@activities = []
|
164
|
+
|
165
|
+
return unless File.exist?(filename)
|
166
|
+
|
27
167
|
state = :initial
|
28
168
|
line_num = 0
|
29
169
|
|
@@ -34,43 +174,57 @@ module Friends
|
|
34
174
|
|
35
175
|
case state
|
36
176
|
when :initial
|
37
|
-
bad_line(
|
177
|
+
bad_line(ACTIVITIES_HEADER, line_num) unless line == ACTIVITIES_HEADER
|
38
178
|
|
39
|
-
state = :
|
40
|
-
@friends = []
|
179
|
+
state = :reading_activities
|
41
180
|
when :reading_friends
|
42
|
-
|
43
|
-
|
181
|
+
begin
|
182
|
+
@friends << Friend.deserialize(line)
|
183
|
+
rescue FriendsError => e
|
184
|
+
bad_line(e, line_num)
|
185
|
+
end
|
186
|
+
when :done_reading_activities
|
187
|
+
state = :reading_friends if line == FRIENDS_HEADER
|
188
|
+
when :reading_activities
|
189
|
+
if line == ""
|
190
|
+
state = :done_reading_activities
|
44
191
|
else
|
45
|
-
|
46
|
-
|
47
|
-
|
192
|
+
begin
|
193
|
+
@activities << Activity.deserialize(line)
|
194
|
+
rescue FriendsError => e
|
195
|
+
bad_line(e, line_num)
|
196
|
+
end
|
48
197
|
end
|
49
198
|
end
|
50
199
|
end
|
51
200
|
end
|
52
201
|
|
53
|
-
#
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
202
|
+
# @param name [String] the name of the friend to search for
|
203
|
+
# @return [Friend] the friend whose name exactly matches the argument
|
204
|
+
# @raise [FriendsError] if more than one friend has the given name
|
205
|
+
def friend_with_exact_name(name)
|
206
|
+
results = friends.select { |friend| friend.name == name }
|
207
|
+
|
208
|
+
case results.size
|
209
|
+
when 0 then nil
|
210
|
+
when 1 then results.first
|
211
|
+
else raise FriendsError, "More than one friend named #{name}"
|
59
212
|
end
|
60
213
|
end
|
61
214
|
|
62
|
-
|
215
|
+
# @param name [String] the name of the friends to search for
|
216
|
+
# @return [Array] a list of all friends that match the given text
|
217
|
+
def friends_with_name_in(text)
|
218
|
+
regex = Regexp.new(text, Regexp::IGNORECASE)
|
219
|
+
friends.select { |friend| friend.name.match(regex) }
|
220
|
+
end
|
63
221
|
|
64
222
|
# Raise an error that a line in the friends file is malformed.
|
65
223
|
# @param expected [String] the expected contents of the line
|
66
224
|
# @param line_num [Integer] the line number
|
225
|
+
# @raise [FriendsError] with a constructed message
|
67
226
|
def bad_line(expected, line_num)
|
68
|
-
|
69
|
-
end
|
70
|
-
|
71
|
-
# Output the given message and exit the program.
|
72
|
-
def error(message)
|
73
|
-
Gossip.error(message)
|
227
|
+
raise FriendsError, "Expected \"#{expected}\" on line #{line_num}"
|
74
228
|
end
|
75
229
|
end
|
76
230
|
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# Serializable provides functionality around serialization to the classes that
|
2
|
+
# extend it. This includes a class method to deserialize a string and create an
|
3
|
+
# instance of the class.
|
4
|
+
|
5
|
+
module Serializable
|
6
|
+
# @param str [String] the serialized object string
|
7
|
+
# @return [Object] the object represented by the serialized string
|
8
|
+
# Note: this method assumes the calling class provides the following methods:
|
9
|
+
# - deserialization_regex
|
10
|
+
# a regex for the string which includes named parameters for the
|
11
|
+
# different initializer arguments
|
12
|
+
# - deserialization_expectation
|
13
|
+
# a string for what was expected, if the regex does not match
|
14
|
+
def deserialize(str)
|
15
|
+
match = str.match(deserialization_regex)
|
16
|
+
|
17
|
+
unless match
|
18
|
+
raise SerializationError,
|
19
|
+
"Expected \"#{deserialization_expectation}\""
|
20
|
+
end
|
21
|
+
|
22
|
+
args = match.names.
|
23
|
+
map { |name| { name.to_sym => match[name.to_sym] } }.
|
24
|
+
reduce(:merge).
|
25
|
+
select { |_, v| !v.nil? }
|
26
|
+
|
27
|
+
new(args)
|
28
|
+
end
|
29
|
+
|
30
|
+
class SerializationError < StandardError
|
31
|
+
end
|
32
|
+
end
|
data/lib/friends/version.rb
CHANGED
@@ -0,0 +1,172 @@
|
|
1
|
+
require_relative "helper"
|
2
|
+
|
3
|
+
describe Friends::Activity do
|
4
|
+
let(:date) { Date.today }
|
5
|
+
let(:date_s) { date.to_s }
|
6
|
+
let(:friend1) { Friends::Friend.new(name: "Elizabeth Cady Stanton") }
|
7
|
+
let(:friend2) { Friends::Friend.new(name: "John Cage") }
|
8
|
+
let(:description) { "Lunch with **#{friend1.name}** and **#{friend2.name}**" }
|
9
|
+
let(:activity) do
|
10
|
+
Friends::Activity.new(date_s: date_s, description: description)
|
11
|
+
end
|
12
|
+
|
13
|
+
describe ".deserialize" do
|
14
|
+
subject { Friends::Activity.deserialize(serialized_str) }
|
15
|
+
|
16
|
+
describe "when string is well-formed" do
|
17
|
+
let(:serialized_str) { "#{date_s}: #{description}" }
|
18
|
+
|
19
|
+
it "creates an activity with the correct date and description" do
|
20
|
+
new_activity = subject
|
21
|
+
new_activity.date.must_equal date
|
22
|
+
new_activity.description.must_equal description
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
describe "when no date is present" do
|
27
|
+
let(:serialized_str) { description }
|
28
|
+
|
29
|
+
it "defaults to today" do
|
30
|
+
today = Date.today
|
31
|
+
|
32
|
+
# We stub out Date.today to guarantee that it is always the same even
|
33
|
+
# when the date changes in the middle of the test's execution.
|
34
|
+
Date.stub(:today, today) { subject.date.must_equal today }
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
describe "when string is malformed" do
|
39
|
+
let(:serialized_str) { "" }
|
40
|
+
|
41
|
+
it { proc { subject }.must_raise Serializable::SerializationError }
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
describe "#new" do
|
46
|
+
subject { activity }
|
47
|
+
|
48
|
+
it { subject.date.must_equal date }
|
49
|
+
it { subject.description.must_equal description }
|
50
|
+
end
|
51
|
+
|
52
|
+
describe "#display_text" do
|
53
|
+
subject { activity.display_text }
|
54
|
+
|
55
|
+
it do
|
56
|
+
subject.
|
57
|
+
must_equal "\e[1m#{date_s}\e[0m: "\
|
58
|
+
"Lunch with \e[1m#{friend1.name}\e[0m and \e[1m#{friend2.name}\e[0m"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
describe "#serialize" do
|
63
|
+
subject { activity.serialize }
|
64
|
+
|
65
|
+
it do
|
66
|
+
subject.
|
67
|
+
must_equal "#{Friends::Activity::SERIALIZATION_PREFIX}#{date_s}: "\
|
68
|
+
"#{description}"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
describe "#highlight_friends" do
|
73
|
+
let(:friend1) { Friends::Friend.new(name: "Elizabeth Cady Stanton") }
|
74
|
+
let(:friend2) { Friends::Friend.new(name: "John Cage") }
|
75
|
+
let(:friends) { [friend1, friend2] }
|
76
|
+
let(:description) { "Lunch with #{friend1.name} and #{friend2.name}." }
|
77
|
+
subject { activity.highlight_friends(friends: friends) }
|
78
|
+
|
79
|
+
it "finds all friends" do
|
80
|
+
subject
|
81
|
+
activity.description.
|
82
|
+
must_equal "Lunch with **#{friend1.name}** and **#{friend2.name}**."
|
83
|
+
end
|
84
|
+
|
85
|
+
it "matches friends' first names" do
|
86
|
+
activity = Friends::Activity.new(
|
87
|
+
date_s: Date.today.to_s,
|
88
|
+
description: "Lunch with Elizabeth and John."
|
89
|
+
)
|
90
|
+
activity.highlight_friends(friends: friends)
|
91
|
+
activity.description.
|
92
|
+
must_equal "Lunch with **#{friend1.name}** and **#{friend2.name}**."
|
93
|
+
end
|
94
|
+
|
95
|
+
it "matches without case sensitivity" do
|
96
|
+
activity = Friends::Activity.new(
|
97
|
+
date_s: Date.today.to_s,
|
98
|
+
description: "Lunch with elizabeth cady stanton."
|
99
|
+
)
|
100
|
+
activity.highlight_friends(friends: friends)
|
101
|
+
activity.description.
|
102
|
+
must_equal "Lunch with **Elizabeth Cady Stanton**."
|
103
|
+
end
|
104
|
+
|
105
|
+
it "ignores when there are multiple matches" do
|
106
|
+
friend2.name = "Elizabeth II"
|
107
|
+
activity = Friends::Activity.new(
|
108
|
+
date_s: Date.today.to_s,
|
109
|
+
description: "Dinner with Elizabeth."
|
110
|
+
)
|
111
|
+
activity.highlight_friends(friends: friends)
|
112
|
+
activity.description.must_equal "Dinner with Elizabeth." # No match found.
|
113
|
+
end
|
114
|
+
|
115
|
+
it "does not match with leading asterisks" do
|
116
|
+
activity = Friends::Activity.new(
|
117
|
+
date_s: Date.today.to_s,
|
118
|
+
description: "Dinner with **Elizabeth Cady Stanton."
|
119
|
+
)
|
120
|
+
activity.highlight_friends(friends: friends)
|
121
|
+
|
122
|
+
# No match found.
|
123
|
+
activity.description.must_equal "Dinner with **Elizabeth Cady Stanton."
|
124
|
+
end
|
125
|
+
|
126
|
+
it "does not match with ending asterisks" do
|
127
|
+
activity = Friends::Activity.new(
|
128
|
+
date_s: Date.today.to_s,
|
129
|
+
|
130
|
+
# Note: for now we can't guarantee that "Elizabeth Cady Stanton**" won't
|
131
|
+
# match, because the Elizabeth isn't surrounded by asterisks.
|
132
|
+
description: "Dinner with Elizabeth**."
|
133
|
+
)
|
134
|
+
activity.highlight_friends(friends: friends)
|
135
|
+
|
136
|
+
# No match found.
|
137
|
+
activity.description.must_equal "Dinner with Elizabeth**."
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
describe "#friend_names" do
|
142
|
+
subject { activity.friend_names }
|
143
|
+
|
144
|
+
it "returns a list of friend names" do
|
145
|
+
names = subject
|
146
|
+
|
147
|
+
# We don't assert that the output must be in a specific order because we
|
148
|
+
# don't care about the order and it is subject to change.
|
149
|
+
names.size.must_equal 2
|
150
|
+
names.must_include "Elizabeth Cady Stanton"
|
151
|
+
names.must_include "John Cage"
|
152
|
+
end
|
153
|
+
|
154
|
+
describe "when a friend is mentioned more than once" do
|
155
|
+
let(:description) { "Lunch with **John Cage**. **John Cage** can eat!" }
|
156
|
+
|
157
|
+
it "removes duplicate names" do
|
158
|
+
subject.must_equal ["John Cage"]
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
describe "#<=>" do
|
164
|
+
it "sorts by reverse-date" do
|
165
|
+
yesterday = (Date.today - 1).to_s
|
166
|
+
tomorrow = (Date.today + 1).to_s
|
167
|
+
past_act = Friends::Activity.new(date_s: yesterday, description: "Dummy")
|
168
|
+
future_act = Friends::Activity.new(date_s: tomorrow, description: "Dummy")
|
169
|
+
[past_act, future_act].sort.must_equal [future_act, past_act]
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|