friends 0.34 → 0.35
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +79 -20
- data/RELEASING.md +7 -6
- data/friends.md +4 -0
- data/lib/friends/activity.rb +4 -291
- data/lib/friends/commands/add.rb +15 -13
- data/lib/friends/commands/list.rb +51 -47
- data/lib/friends/commands/stats.rb +3 -0
- data/lib/friends/event.rb +298 -0
- data/lib/friends/introvert.rb +109 -57
- data/lib/friends/note.rb +19 -0
- data/lib/friends/version.rb +1 -1
- data/test/add_event_helper.rb +410 -0
- data/test/commands/add/activity_spec.rb +6 -350
- data/test/commands/add/note_spec.rb +55 -0
- data/test/commands/clean_spec.rb +25 -3
- data/test/commands/list/notes_spec.rb +179 -0
- data/test/commands/list/tags_spec.rb +24 -0
- data/test/commands/stats_spec.rb +100 -7
- data/test/helper.rb +12 -0
- metadata +10 -2
data/lib/friends/commands/add.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
desc "Adds a friend (or nickname), activity, or location"
|
3
|
+
desc "Adds a friend (or nickname), activity, note, or location"
|
4
4
|
command :add do |add|
|
5
5
|
add.desc "Adds a friend"
|
6
6
|
add.arg_name "NAME"
|
@@ -12,20 +12,22 @@ command :add do |add|
|
|
12
12
|
end
|
13
13
|
end
|
14
14
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
15
|
+
[:activity, :note].each do |event|
|
16
|
+
add.desc "Adds #{event == :note ? 'a' : 'an'} #{event}"
|
17
|
+
add.arg_name "DESCRIPTION"
|
18
|
+
add.command event do |add_event|
|
19
|
+
add_event.action do |_, _, args|
|
20
|
+
event_obj = @introvert.send("add_#{event}", serialization: args.join(" "))
|
20
21
|
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
22
|
+
# If there's no description, prompt the user for one.
|
23
|
+
if event_obj.description.nil? || event_obj.description.empty?
|
24
|
+
event_obj.description = Readline.readline(event_obj.to_s)
|
25
|
+
event_obj.highlight_description(introvert: @introvert)
|
26
|
+
end
|
26
27
|
|
27
|
-
|
28
|
-
|
28
|
+
@message = "#{event.to_s.capitalize} added: \"#{event_obj}\""
|
29
|
+
@dirty = true # Mark the file for cleaning.
|
30
|
+
end
|
29
31
|
end
|
30
32
|
end
|
31
33
|
|
@@ -28,50 +28,53 @@ command :list do |list|
|
|
28
28
|
end
|
29
29
|
end
|
30
30
|
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
31
|
+
[:activities, :notes].each do |events|
|
32
|
+
list.desc "Lists all #{events}"
|
33
|
+
list.command events do |list_events|
|
34
|
+
list_events.flag [:limit],
|
35
|
+
arg_name: "NUMBER",
|
36
|
+
desc: "The number of #{events} to return",
|
37
|
+
default_value: 10,
|
38
|
+
type: Integer
|
39
|
+
|
40
|
+
list_events.flag [:with],
|
41
|
+
arg_name: "NAME",
|
42
|
+
desc: "List only #{events} with the given friend",
|
43
|
+
type: Stripped,
|
44
|
+
multiple: true
|
45
|
+
|
46
|
+
list_events.flag [:in],
|
47
|
+
arg_name: "LOCATION",
|
48
|
+
desc: "List only #{events} in the given location",
|
49
|
+
type: Stripped
|
50
|
+
|
51
|
+
list_events.flag [:tagged],
|
52
|
+
arg_name: "@TAG",
|
53
|
+
desc: "List only #{events} with the given tag",
|
54
|
+
type: Tag,
|
55
|
+
multiple: true
|
56
|
+
|
57
|
+
list_events.flag [:since],
|
58
|
+
arg_name: "DATE",
|
59
|
+
desc: "List only #{events} on or after the given date",
|
60
|
+
type: InputDate
|
61
|
+
|
62
|
+
list_events.flag [:until],
|
63
|
+
arg_name: "DATE",
|
64
|
+
desc: "List only #{events} before or on the given date",
|
65
|
+
type: InputDate
|
66
|
+
|
67
|
+
list_events.action do |_, options|
|
68
|
+
puts @introvert.send(
|
69
|
+
"list_#{events}",
|
70
|
+
limit: options[:limit],
|
71
|
+
with: options[:with],
|
72
|
+
location_name: options[:in],
|
73
|
+
tagged: options[:tagged],
|
74
|
+
since_date: options[:since],
|
75
|
+
until_date: options[:until]
|
76
|
+
)
|
77
|
+
end
|
75
78
|
end
|
76
79
|
end
|
77
80
|
|
@@ -85,9 +88,10 @@ command :list do |list|
|
|
85
88
|
list.desc "List all tags used"
|
86
89
|
list.command :tags do |list_tags|
|
87
90
|
list_tags.flag [:from],
|
88
|
-
arg_name: '"activities" or "friends" (default:
|
89
|
-
desc: "List only tags from activities or
|
90
|
-
"
|
91
|
+
arg_name: '"activities" or "friends" or "notes" (default: all)',
|
92
|
+
desc: "List only tags from activities, friends, or notes instead of"\
|
93
|
+
"all three",
|
94
|
+
multiple: true
|
91
95
|
list_tags.action do |_, options|
|
92
96
|
puts @introvert.list_tags(from: options[:from])
|
93
97
|
end
|
@@ -5,6 +5,9 @@ command :stats do |stats|
|
|
5
5
|
stats.action do
|
6
6
|
puts "Total activities: #{@introvert.total_activities}"
|
7
7
|
puts "Total friends: #{@introvert.total_friends}"
|
8
|
+
puts "Total locations: #{@introvert.total_locations}"
|
9
|
+
puts "Total notes: #{@introvert.total_notes}"
|
10
|
+
puts "Total tags: #{@introvert.total_tags}"
|
8
11
|
days = @introvert.elapsed_days
|
9
12
|
puts "Total time elapsed: #{days} day#{'s' if days != 1}"
|
10
13
|
end
|
@@ -0,0 +1,298 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Event represents an event with a date and a description.
|
4
|
+
# It's basically a superclass for Activity and Note.
|
5
|
+
|
6
|
+
require "chronic"
|
7
|
+
require "paint"
|
8
|
+
require "set"
|
9
|
+
|
10
|
+
require "friends/serializable"
|
11
|
+
|
12
|
+
module Friends
|
13
|
+
class Event
|
14
|
+
extend Serializable
|
15
|
+
|
16
|
+
SERIALIZATION_PREFIX = "- ".freeze
|
17
|
+
DATE_PARTITION = ": ".freeze
|
18
|
+
|
19
|
+
# @return [Regexp] the regex for capturing groups in deserialization
|
20
|
+
def self.deserialization_regex
|
21
|
+
/(#{SERIALIZATION_PREFIX})?(?<str>.+)?/
|
22
|
+
end
|
23
|
+
|
24
|
+
# @param str [String] the text of the activity, of one of the formats:
|
25
|
+
# "<date>: <description>"
|
26
|
+
# "<date>" (Program will prompt for description.)
|
27
|
+
# "<description>" (The current date will be used by default.)
|
28
|
+
# @return [Activity] the new activity
|
29
|
+
def initialize(str: "")
|
30
|
+
# Partition lets us parse "Today" and "Today: I awoke." identically.
|
31
|
+
date_s, _, description = str.partition(DATE_PARTITION)
|
32
|
+
|
33
|
+
time = if date_s =~ /^\d{4}-\d{2}-\d{2}$/
|
34
|
+
Time.parse(date_s)
|
35
|
+
else
|
36
|
+
# If the user inputed a non YYYY-MM-DD format, asssume
|
37
|
+
# it is in the past.
|
38
|
+
past_time = Chronic.parse(date_s, context: :past)
|
39
|
+
|
40
|
+
# If there's no year, Chronic will sometimes parse the date
|
41
|
+
# as being the next occurrence of that date in the future.
|
42
|
+
# Instead, we want to subtract one year to make it the last
|
43
|
+
# occurrence of the date in the past.
|
44
|
+
# NOTE: This is a hacky workaround for the fact that
|
45
|
+
# Chronic's `context: :past` doesn't actually work. We should
|
46
|
+
# remove this when that behavior is fixed.
|
47
|
+
if past_time && past_time > Time.now
|
48
|
+
Time.local(past_time.year - 1, past_time.month, past_time.day)
|
49
|
+
else
|
50
|
+
past_time
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
if time
|
55
|
+
@date = time.to_date
|
56
|
+
@description = description
|
57
|
+
else
|
58
|
+
# If the user didn't input a date, we fall back to the current date.
|
59
|
+
@date = Date.today
|
60
|
+
@description = str # Use str in case DATE_PARTITION occurred naturally.
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
attr_reader :date
|
65
|
+
attr_accessor :description
|
66
|
+
|
67
|
+
# @return [String] the command-line display text for the activity
|
68
|
+
def to_s
|
69
|
+
date_s = Paint[date, :bold]
|
70
|
+
description_s = description.to_s
|
71
|
+
# rubocop:disable Lint/AssignmentInCondition
|
72
|
+
while match = description_s.match(/\*\*([^\*]+)\*\*/)
|
73
|
+
# rubocop:enable Lint/AssignmentInCondition
|
74
|
+
description_s = "#{match.pre_match}"\
|
75
|
+
"#{Paint[match[1], :bold, :magenta]}"\
|
76
|
+
"#{match.post_match}"
|
77
|
+
end
|
78
|
+
|
79
|
+
# rubocop:disable Lint/AssignmentInCondition
|
80
|
+
while match = description_s.match(/_([^_]+)_/)
|
81
|
+
# rubocop:enable Lint/AssignmentInCondition
|
82
|
+
description_s = "#{match.pre_match}"\
|
83
|
+
"#{Paint[match[1], :bold, :yellow]}"\
|
84
|
+
"#{match.post_match}"
|
85
|
+
end
|
86
|
+
|
87
|
+
description_s = description_s.
|
88
|
+
gsub(TAG_REGEX, Paint['\0', :bold, :cyan])
|
89
|
+
|
90
|
+
"#{date_s}: #{description_s}"
|
91
|
+
end
|
92
|
+
|
93
|
+
# @return [String] the file serialization text for the activity
|
94
|
+
def serialize
|
95
|
+
"#{SERIALIZATION_PREFIX}#{date}: #{description}"
|
96
|
+
end
|
97
|
+
|
98
|
+
# Modify the description to turn inputted friend names
|
99
|
+
# (e.g. "Jacob" or "Jacob Evelyn") into full asterisk'd names
|
100
|
+
# (e.g. "**Jacob Evelyn**") and inputted location names (e.g. "Atlantis")
|
101
|
+
# into full underscore'd names (e.g. "_Atlantis_").
|
102
|
+
# @param introvert [Introvert] used to access internal data structures to
|
103
|
+
# perform object matching
|
104
|
+
def highlight_description(introvert:)
|
105
|
+
highlight_locations(introvert: introvert)
|
106
|
+
highlight_friends(introvert: introvert)
|
107
|
+
end
|
108
|
+
|
109
|
+
# Updates a friend's old_name to their new_name
|
110
|
+
# @param [String] old_name
|
111
|
+
# @param [String] new_name
|
112
|
+
# @return [String] if name found in description
|
113
|
+
# @return [nil] if no change was made
|
114
|
+
def update_friend_name(old_name:, new_name:)
|
115
|
+
@description = @description.gsub(
|
116
|
+
Regexp.new("(?<=\\*\\*)#{old_name}(?=\\*\\*)"),
|
117
|
+
new_name
|
118
|
+
)
|
119
|
+
end
|
120
|
+
|
121
|
+
# Updates a location's old_name to their new_name
|
122
|
+
# @param [String] old_name
|
123
|
+
# @param [String] new_name
|
124
|
+
# @return [String] if name found in description
|
125
|
+
# @return [nil] if no change was made
|
126
|
+
def update_location_name(old_name:, new_name:)
|
127
|
+
@description = @description.gsub(
|
128
|
+
Regexp.new("(?<=_)#{old_name}(?=_)"),
|
129
|
+
new_name
|
130
|
+
)
|
131
|
+
end
|
132
|
+
|
133
|
+
# @param location [Location] the location to test
|
134
|
+
# @return [Boolean] true iff this activity includes the given location
|
135
|
+
def includes_location?(location)
|
136
|
+
@description.scan(/(?<=_)[^_]+(?=_)/).include? location.name
|
137
|
+
end
|
138
|
+
|
139
|
+
# @param friend [Friend] the friend to test
|
140
|
+
# @return [Boolean] true iff this activity includes the given friend
|
141
|
+
def includes_friend?(friend)
|
142
|
+
friend_names.include? friend.name
|
143
|
+
end
|
144
|
+
|
145
|
+
# @param tag [String] the tag to test, of the form "@tag"
|
146
|
+
# @return [Boolean] true iff this activity includes the given tag
|
147
|
+
def includes_tag?(tag)
|
148
|
+
tags.include? tag
|
149
|
+
end
|
150
|
+
|
151
|
+
# @return [Set] all tags in this activity (including the "@")
|
152
|
+
def tags
|
153
|
+
Set.new(@description.scan(TAG_REGEX))
|
154
|
+
end
|
155
|
+
|
156
|
+
# Find the names of all friends in this description.
|
157
|
+
# @return [Array] list of all friend names in the description
|
158
|
+
def friend_names
|
159
|
+
@_friend_names ||= @description.scan(/(?<=\*\*)\w[^\*]*(?=\*\*)/).uniq
|
160
|
+
end
|
161
|
+
|
162
|
+
# Find the names of all locations in this description.
|
163
|
+
# @return [Array] list of all location names in the description
|
164
|
+
def location_names
|
165
|
+
@_location_names ||= @description.scan(/(?<=_)\w[^_]*(?=_)/).uniq
|
166
|
+
end
|
167
|
+
|
168
|
+
private
|
169
|
+
|
170
|
+
# Modify the description to turn inputted location names (e.g. "Atlantis")
|
171
|
+
# into full underscore'd names (e.g. "_Atlantis_").
|
172
|
+
# @param introvert [Introvert] used to access internal data structures to
|
173
|
+
# perform location matching
|
174
|
+
def highlight_locations(introvert:)
|
175
|
+
introvert.regex_location_map.each do |regex, location|
|
176
|
+
# If we find a match, replace all instances of the matching text with
|
177
|
+
# the location's name. We use single-underscores to indicate locations.
|
178
|
+
description_matches(regex: regex, replace: true, indicator: "_") do
|
179
|
+
location.name
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
# Modify the description to turn inputted friend names
|
185
|
+
# (e.g. "Jacob" or "Jacob Evelyn") into full asterisk'd names
|
186
|
+
# (e.g. "**Jacob Evelyn**")
|
187
|
+
# @param introvert [Introvert] used to access internal data structures to
|
188
|
+
# perform friend matching
|
189
|
+
# NOTE: When a friend name matches more than one friend, this method chooses
|
190
|
+
# a friend based on a best-guess algorithm that looks at which friends do
|
191
|
+
# activities together and which friends are stronger than others. For
|
192
|
+
# more information see the comments below and the
|
193
|
+
# introvert#set_likelihood_score! method.
|
194
|
+
def highlight_friends(introvert:)
|
195
|
+
# Split the regex friend map into two maps: one for names with only one
|
196
|
+
# friend match and another for ambiguous names
|
197
|
+
definite_map, ambiguous_map =
|
198
|
+
introvert.regex_friend_map.partition { |_, arr| arr.size == 1 }
|
199
|
+
|
200
|
+
matched_friends = []
|
201
|
+
|
202
|
+
# First, we find all of the unambiguous matches, and make those
|
203
|
+
# substitutions.
|
204
|
+
definite_map.each do |regex, friend_list|
|
205
|
+
# If we find a match, add the friend to the matched list and replace all
|
206
|
+
# instances of the matching text with the friend's name.
|
207
|
+
description_matches(regex: regex, replace: true, indicator: "**") do
|
208
|
+
friend = friend_list.first # There's only one friend in the list.
|
209
|
+
matched_friends << friend
|
210
|
+
friend.name
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
possible_matched_friends = []
|
215
|
+
|
216
|
+
# Now, we look at regex matches that are ambiguous.
|
217
|
+
ambiguous_map.each do |regex, friend_list|
|
218
|
+
# If we find a match, add the friend to the possible-match list.
|
219
|
+
description_matches(regex: regex, replace: false, indicator: "**") do
|
220
|
+
possible_matched_friends << friend_list
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
# Now, we compute the likelihood of each friend in the possible-match set.
|
225
|
+
introvert.set_likelihood_score!(
|
226
|
+
matches: matched_friends,
|
227
|
+
possible_matches: possible_matched_friends
|
228
|
+
)
|
229
|
+
|
230
|
+
# Now we replace all of the ambiguous matches with our best guesses.
|
231
|
+
ambiguous_map.each do |regex, friend_list|
|
232
|
+
# If we find a match, take the most likely and replace all instances of
|
233
|
+
# the matching text with that friend's name.
|
234
|
+
description_matches(regex: regex, replace: true, indicator: "**") do
|
235
|
+
friend_list.sort_by do |friend|
|
236
|
+
[-friend.likelihood_score, -friend.n_activities]
|
237
|
+
end.first.name
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
# Lastly, we remove any backslashes, as these are used to escape friends'
|
242
|
+
# names that we don't want to match.
|
243
|
+
@description = @description.delete("\\")
|
244
|
+
end
|
245
|
+
|
246
|
+
# This method accepts a block, and tests a regex on the @description
|
247
|
+
# instance variable.
|
248
|
+
# - If the regex does not match, the block is not executed.
|
249
|
+
# - If the regex matches, the block is executed exactly once, and:
|
250
|
+
# - If `replace` is true, all of the regex's matches are replaced by the
|
251
|
+
# return value of the block, EXCEPT when the matched text is between a
|
252
|
+
# set of double-asterisks ("**") or single-underscores ("_") indicating
|
253
|
+
# it is already part of another location or friend's matched name.
|
254
|
+
# - If `replace` is not true, we do not modify @description.
|
255
|
+
# @param regex [Regexp] the regex to test against @description
|
256
|
+
# @param replace [Boolean] true iff we should replace regex matches with the
|
257
|
+
# yielded block's result in @description
|
258
|
+
def description_matches(regex:, replace:, indicator:)
|
259
|
+
# rubocop:disable Lint/AssignmentInCondition
|
260
|
+
return unless match = @description.match(regex) # Abort if no match.
|
261
|
+
# rubocop:enable Lint/AssignmentInCondition
|
262
|
+
str = yield # It's important to execute the block even if not replacing.
|
263
|
+
return unless replace # Only continue if we want to replace text.
|
264
|
+
|
265
|
+
position = 0 # Prevent infinite looping by tracking last match position.
|
266
|
+
loop do
|
267
|
+
# Only make a replacement if we're not between a set of "**"s or "_"s.
|
268
|
+
if (match.pre_match.scan("**").size % 2).zero? &&
|
269
|
+
(match.post_match.scan("**").size % 2).zero? &&
|
270
|
+
(match.pre_match.scan("_").size % 2).zero? &&
|
271
|
+
(match.post_match.scan("_").size % 2).zero?
|
272
|
+
@description = [
|
273
|
+
match.pre_match,
|
274
|
+
indicator,
|
275
|
+
str,
|
276
|
+
indicator,
|
277
|
+
match.post_match
|
278
|
+
].join
|
279
|
+
else
|
280
|
+
# If we're between double-asterisks or single-underscores we're
|
281
|
+
# already part of a name, so we don't make a substitution. We update
|
282
|
+
# `position` to avoid infinite looping.
|
283
|
+
position = match.end(0)
|
284
|
+
end
|
285
|
+
|
286
|
+
# Exit when there are no more matches.
|
287
|
+
# rubocop:disable Lint/AssignmentInCondition
|
288
|
+
break unless match = @description.match(regex, position)
|
289
|
+
# rubocop:enable Lint/AssignmentInCondition
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
# Default sorting for an array of activities is reverse-date.
|
294
|
+
def <=>(other)
|
295
|
+
other.date <=> date
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|