friends 0.0.1 → 0.0.2
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.
- 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
|