friends 0.34 → 0.35
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/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
|