friends 0.35 → 0.36

Sign up to get free protection for your applications and to get access to all the features.
@@ -8,13 +8,6 @@ command :suggest do |suggest|
8
8
  type: Stripped
9
9
 
10
10
  suggest.action do |_, options|
11
- suggestions = @introvert.suggest(location_name: options[:in])
12
-
13
- puts "Distant friend: "\
14
- "#{Paint[suggestions[:distant].sample || 'None found', :bold, :magenta]}"
15
- puts "Moderate friend: "\
16
- "#{Paint[suggestions[:moderate].sample || 'None found', :bold, :magenta]}"
17
- puts "Close friend: "\
18
- "#{Paint[suggestions[:close].sample || 'None found', :bold, :magenta]}"
11
+ @introvert.suggest(location_name: options[:in])
19
12
  end
20
13
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  desc "Updates the `friends` program"
4
4
  command :update do |update|
5
- update.action do
5
+ update.action do |global_options|
6
6
  # rubocop:disable Lint/AssignmentInCondition
7
7
  if match = `gem search friends`.match(/^friends\s\(([^\)]+)\)$/)
8
8
  # rubocop:enable Lint/AssignmentInCondition
@@ -10,19 +10,18 @@ command :update do |update|
10
10
  if Semverse::Version.coerce(remote_version) >
11
11
  Semverse::Version.coerce(Friends::VERSION)
12
12
  `gem update friends && gem cleanup friends`
13
- @message = if $?.success?
14
- Paint[
15
- "Updated to friends #{remote_version}", :bold, :green
16
- ]
17
- else
18
- Paint[
19
- "Error updating to friends version #{remote_version}", :bold, :red
20
- ]
21
- end
13
+
14
+ unless global_options[:quiet]
15
+ if $?.success?
16
+ puts Paint["Updated to friends #{remote_version}", :bold, :green]
17
+ else
18
+ puts Paint["Error updating to friends version #{remote_version}", :bold, :red]
19
+ end
20
+ end
22
21
  else
23
- @message = Paint[
24
- "Already up-to-date (#{Friends::VERSION})", :bold, :green
25
- ]
22
+ unless global_options[:quiet]
23
+ puts Paint["Already up-to-date (#{Friends::VERSION})", :bold, :green]
24
+ end
26
25
  end
27
26
  end
28
27
  end
@@ -156,13 +156,13 @@ module Friends
156
156
  # Find the names of all friends in this description.
157
157
  # @return [Array] list of all friend names in the description
158
158
  def friend_names
159
- @_friend_names ||= @description.scan(/(?<=\*\*)\w[^\*]*(?=\*\*)/).uniq
159
+ @description.scan(/(?<=\*\*)\w[^\*]*(?=\*\*)/).uniq
160
160
  end
161
161
 
162
162
  # Find the names of all locations in this description.
163
163
  # @return [Array] list of all location names in the description
164
164
  def location_names
165
- @_location_names ||= @description.scan(/(?<=_)\w[^_]*(?=_)/).uniq
165
+ @description.scan(/(?<=_)\w[^_]*(?=_)/).uniq
166
166
  end
167
167
 
168
168
  private
@@ -15,15 +15,16 @@ require "friends/friends_error"
15
15
 
16
16
  module Friends
17
17
  class Introvert
18
- DEFAULT_FILENAME = "./friends.md".freeze
19
18
  ACTIVITIES_HEADER = "### Activities:".freeze
20
19
  NOTES_HEADER = "### Notes:".freeze
21
20
  FRIENDS_HEADER = "### Friends:".freeze
22
21
  LOCATIONS_HEADER = "### Locations:".freeze
23
22
 
24
23
  # @param filename [String] the name of the friends Markdown file
25
- def initialize(filename: DEFAULT_FILENAME)
24
+ # @param quiet [Boolean] true iff we should suppress all STDOUT output
25
+ def initialize(filename:, quiet:)
26
26
  @filename = filename
27
+ @quiet = quiet
27
28
 
28
29
  # Read in the input file. It's easier to do this now and optimize later
29
30
  # than try to overly be clever about what we read and write.
@@ -31,7 +32,25 @@ module Friends
31
32
  end
32
33
 
33
34
  # Write out the friends file with cleaned/sorted data.
34
- def clean
35
+ # @param clean_command [Boolean] true iff the command the user
36
+ # executed is `friends clean`; false if this is called as the
37
+ # result of another command
38
+ def clean(clean_command:)
39
+ friend_names = Set.new(@friends.map(&:name))
40
+ location_names = Set.new(@locations.map(&:name))
41
+
42
+ # Iterate through all events and add missing friends and
43
+ # locations.
44
+ (@activities + @notes).each do |event|
45
+ event.friend_names.each do |name|
46
+ add_friend(name: name) unless friend_names.include? name
47
+ end
48
+
49
+ event.location_names.each do |name|
50
+ add_location(name: name) unless location_names.include? name
51
+ end
52
+ end
53
+
35
54
  File.open(@filename, "w") do |file|
36
55
  file.puts(ACTIVITIES_HEADER)
37
56
  stable_sort(@activities).each { |act| file.puts(act.serialize) }
@@ -46,13 +65,14 @@ module Friends
46
65
  @locations.sort.each { |location| file.puts(location.serialize) }
47
66
  end
48
67
 
49
- @filename
68
+ # This is a special-case piece of code that lets us print a message that
69
+ # includes the filename when `friends clean` is called.
70
+ safe_puts "File cleaned: \"#{@filename}\"" if clean_command
50
71
  end
51
72
 
52
73
  # Add a friend.
53
74
  # @param name [String] the name of the friend to add
54
75
  # @raise [FriendsError] when a friend with that name is already in the file
55
- # @return [Friend] the added friend
56
76
  def add_friend(name:)
57
77
  if @friends.any? { |friend| friend.name == name }
58
78
  raise FriendsError, "Friend named \"#{name}\" already exists"
@@ -62,32 +82,45 @@ module Friends
62
82
 
63
83
  @friends << friend
64
84
 
65
- friend # Return the added friend.
85
+ safe_puts "Friend added: \"#{friend.name}\""
66
86
  end
67
87
 
68
88
  # Add an activity.
69
89
  # @param serialization [String] the serialized activity
70
- # @return [Activity] the added activity
71
90
  def add_activity(serialization:)
72
91
  Activity.deserialize(serialization).tap do |activity|
73
- activity.highlight_description(introvert: self) if activity.description
92
+ # If there's no description, prompt the user for one.
93
+ if activity.description.nil? || activity.description.empty?
94
+ activity.description = Readline.readline(activity.to_s)
95
+ end
96
+
97
+ activity.highlight_description(introvert: self)
98
+
74
99
  @activities.unshift(activity)
100
+
101
+ safe_puts "Activity added: \"#{activity}\""
75
102
  end
76
103
  end
77
104
 
78
105
  # Add a note.
79
106
  # @param serialization [String] the serialized note
80
- # @return [Note] the added note
81
107
  def add_note(serialization:)
82
108
  Note.deserialize(serialization).tap do |note|
83
- note.highlight_description(introvert: self) if note.description
109
+ # If there's no description, prompt the user for one.
110
+ if note.description.nil? || note.description.empty?
111
+ note.description = Readline.readline(note.to_s)
112
+ end
113
+
114
+ note.highlight_description(introvert: self)
115
+
84
116
  @notes.unshift(note)
117
+
118
+ safe_puts "Note added: \"#{note}\""
85
119
  end
86
120
  end
87
121
 
88
122
  # Add a location.
89
123
  # @param name [String] the serialized location
90
- # @return [Location] the added location
91
124
  # @raise [FriendsError] if a location with that name already exists
92
125
  def add_location(name:)
93
126
  if @locations.any? { |location| location.name == name }
@@ -98,7 +131,7 @@ module Friends
98
131
 
99
132
  @locations << location
100
133
 
101
- location # Return the added location.
134
+ safe_puts "Location added: \"#{location.name}\"" # Return the added location.
102
135
  end
103
136
 
104
137
  # Set a friend's location.
@@ -106,39 +139,38 @@ module Friends
106
139
  # @param location_name [String] the name of an existing location
107
140
  # @raise [FriendsError] if 0 or 2+ friends match the given name
108
141
  # @raise [FriendsError] if 0 or 2+ locations match the given location name
109
- # @return [Friend] the modified friend
110
142
  def set_location(name:, location_name:)
111
143
  friend = thing_with_name_in(:friend, name)
112
144
  location = thing_with_name_in(:location, location_name)
113
145
  friend.location_name = location.name
114
- friend
146
+
147
+ safe_puts "#{friend.name}'s location set to: \"#{location.name}\""
115
148
  end
116
149
 
117
150
  # Rename an existing friend.
118
151
  # @param old_name [String] the name of the friend
119
152
  # @param new_name [String] the new name of the friend
120
153
  # @raise [FriendsError] if 0 or 2+ friends match the given name
121
- # @return [Friend] the existing friend
122
154
  def rename_friend(old_name:, new_name:)
123
155
  friend = thing_with_name_in(:friend, old_name)
124
- @activities.each do |activity|
125
- activity.update_friend_name(old_name: friend.name, new_name: new_name)
156
+ (@activities + @notes).each do |event|
157
+ event.update_friend_name(old_name: friend.name, new_name: new_name)
126
158
  end
127
159
  friend.name = new_name
128
- friend
160
+
161
+ safe_puts "Name changed: \"#{friend}\""
129
162
  end
130
163
 
131
164
  # Rename an existing location.
132
165
  # @param old_name [String] the name of the location
133
166
  # @param new_name [String] the new name of the location
134
167
  # @raise [FriendsError] if 0 or 2+ friends match the given name
135
- # @return [Location] the existing location
136
168
  def rename_location(old_name:, new_name:)
137
169
  loc = thing_with_name_in(:location, old_name)
138
170
 
139
- # Update locations in activities.
140
- @activities.each do |activity|
141
- activity.update_location_name(old_name: loc.name, new_name: new_name)
171
+ # Update locations in activities and notes.
172
+ (@activities + @notes).each do |event|
173
+ event.update_location_name(old_name: loc.name, new_name: new_name)
142
174
  end
143
175
 
144
176
  # Update locations of friends.
@@ -147,29 +179,30 @@ module Friends
147
179
  end
148
180
 
149
181
  loc.name = new_name # Update location itself.
150
- loc
182
+
183
+ safe_puts "Location renamed: \"#{loc.name}\""
151
184
  end
152
185
 
153
186
  # Add a nickname to an existing friend.
154
187
  # @param name [String] the name of the friend
155
188
  # @param nickname [String] the nickname to add to the friend
156
189
  # @raise [FriendsError] if 0 or 2+ friends match the given name
157
- # @return [Friend] the existing friend
158
190
  def add_nickname(name:, nickname:)
159
191
  friend = thing_with_name_in(:friend, name)
160
192
  friend.add_nickname(nickname)
161
- friend
193
+
194
+ safe_puts "Nickname added: \"#{friend}\""
162
195
  end
163
196
 
164
197
  # Add a tag to an existing friend.
165
198
  # @param name [String] the name of the friend
166
199
  # @param tag [String] the tag to add to the friend, of the form: "@tag"
167
200
  # @raise [FriendsError] if 0 or 2+ friends match the given name
168
- # @return [Friend] the existing friend
169
201
  def add_tag(name:, tag:)
170
202
  friend = thing_with_name_in(:friend, name)
171
203
  friend.add_tag(tag)
172
- friend
204
+
205
+ safe_puts "Tag added to friend: \"#{friend}\""
173
206
  end
174
207
 
175
208
  # Remove a tag from an existing friend.
@@ -177,11 +210,11 @@ module Friends
177
210
  # @param tag [String] the tag to remove from the friend, of the form: "@tag"
178
211
  # @raise [FriendsError] if 0 or 2+ friends match the given name
179
212
  # @raise [FriendsError] if the friend does not have the given nickname
180
- # @return [Friend] the existing friend
181
213
  def remove_tag(name:, tag:)
182
214
  friend = thing_with_name_in(:friend, name)
183
215
  friend.remove_tag(tag)
184
- friend
216
+
217
+ safe_puts "Tag removed from friend: \"#{friend}\""
185
218
  end
186
219
 
187
220
  # Remove a nickname from an existing friend.
@@ -189,11 +222,11 @@ module Friends
189
222
  # @param nickname [String] the nickname to remove from the friend
190
223
  # @raise [FriendsError] if 0 or 2+ friends match the given name
191
224
  # @raise [FriendsError] if the friend does not have the given nickname
192
- # @return [Friend] the existing friend
193
225
  def remove_nickname(name:, nickname:)
194
226
  friend = thing_with_name_in(:friend, name)
195
227
  friend.remove_nickname(nickname)
196
- friend
228
+
229
+ safe_puts "Nickname removed: \"#{friend}\""
197
230
  end
198
231
 
199
232
  # List all friend names in the friends file.
@@ -203,7 +236,6 @@ module Friends
203
236
  # unfiltered
204
237
  # @param verbose [Boolean] true iff we should output friend names with
205
238
  # nicknames, locations, and tags; false for names only
206
- # @return [Array] a list of all friend names
207
239
  def list_friends(location_name:, tagged:, verbose:)
208
240
  fs = @friends
209
241
 
@@ -220,69 +252,41 @@ module Friends
220
252
  end
221
253
  end
222
254
 
223
- verbose ? fs.map(&:to_s) : fs.map(&:name)
255
+ safe_puts(verbose ? fs.map(&:to_s) : fs.map(&:name))
224
256
  end
225
257
 
226
258
  # List your favorite friends.
227
259
  # @param limit [Integer] the number of favorite friends to return
228
- # @return [Array] a list of the favorite friends' names and activity
229
- # counts
230
260
  def list_favorite_friends(limit:)
231
261
  list_favorite_things(:friend, limit: limit)
232
262
  end
233
263
 
234
264
  # List your favorite friends.
235
265
  # @param limit [Integer] the number of favorite locations to return
236
- # @return [Array] a list of the favorite locations' names and activity
237
- # counts
238
266
  def list_favorite_locations(limit:)
239
267
  list_favorite_things(:location, limit: limit)
240
268
  end
241
269
 
242
270
  # See `list_events` for all of the parameters we can pass.
243
- # @return [Array] a list of all activities' text values
244
271
  def list_activities(**args)
245
272
  list_events(events: @activities, **args)
246
273
  end
247
274
 
248
275
  # See `list_events` for all of the parameters we can pass.
249
- # @return [Array] a list of all notes' text values
250
276
  def list_notes(**args)
251
277
  list_events(events: @notes, **args)
252
278
  end
253
279
 
254
280
  # List all location names in the friends file.
255
- # @return [Array] a list of all location names
256
281
  def list_locations
257
- @locations.map(&:name)
282
+ safe_puts @locations.map(&:name)
258
283
  end
259
284
 
260
285
  # @param from [Array] containing any of: ["activities", "friends", "notes"]
261
286
  # If not empty, limits the tags returned to only those from either
262
287
  # activities, notes, or friends.
263
- # @return [Array] a sorted list of all tags in activity descriptions
264
288
  def list_tags(from:)
265
- output = Set.new
266
-
267
- if from.empty? || from.include?("activities")
268
- @activities.each_with_object(output) do |activity, set|
269
- set.merge(activity.tags)
270
- end
271
- end
272
-
273
- if from.empty? || from.include?("notes")
274
- @notes.each_with_object(output) do |note, set|
275
- set.merge(note.tags)
276
- end
277
- end
278
-
279
- if from.empty? || from.include?("friends")
280
- @friends.each_with_object(output) do |friend, set|
281
- set.merge(friend.tags)
282
- end
283
- end
284
-
285
- output.sort_by(&:downcase)
289
+ safe_puts tags(from: from).sort_by(&:downcase)
286
290
  end
287
291
 
288
292
  # Find data points for graphing activities over time.
@@ -305,7 +309,6 @@ module Friends
305
309
  # unfiltered
306
310
  # @param since_date [Date] a date on or after which to find activities, or nil for unfiltered
307
311
  # @param until_date [Date] a date before or on which to find activities, or nil for unfiltered
308
- # @return [Hash{String => Integer}]
309
312
  # @raise [FriendsError] if friend, location or tag cannot be found or
310
313
  # is ambiguous
311
314
  def graph(with:, location_name:, tagged:, since_date:, until_date:)
@@ -350,7 +353,6 @@ module Friends
350
353
  #
351
354
  # @param location_name [String] the name of a location to filter by, or nil
352
355
  # for unfiltered
353
- # @return [Hash{String => Array<String>}]
354
356
  def suggest(location_name:)
355
357
  # Filter our friends by location if necessary.
356
358
  fs = @friends
@@ -359,22 +361,25 @@ module Friends
359
361
  # Sort our friends, with the least favorite friend first.
360
362
  sorted_friends = fs.sort_by(&:n_activities)
361
363
 
362
- output = Hash.new { |h, k| h[k] = [] }
363
-
364
364
  # Set initial value in case there are no friends and the while loop is
365
365
  # never entered.
366
- output[:distant] = []
366
+ distant_friend_names = []
367
367
 
368
368
  # First, get not-so-good friends.
369
369
  while !sorted_friends.empty? && sorted_friends.first.n_activities < 2
370
- output[:distant] << sorted_friends.shift.name
370
+ distant_friend_names << sorted_friends.shift.name
371
371
  end
372
372
 
373
- output[:moderate] = sorted_friends.slice!(0, sorted_friends.size * 3 / 4).
374
- map!(&:name)
375
- output[:close] = sorted_friends.map!(&:name)
373
+ moderate_friend_names = sorted_friends.slice!(0, sorted_friends.size * 3 / 4).
374
+ map!(&:name)
375
+ close_friend_names = sorted_friends.map!(&:name)
376
376
 
377
- output
377
+ safe_puts "Distant friend: "\
378
+ "#{Paint[distant_friend_names.sample || 'None found', :bold, :magenta]}"
379
+ safe_puts "Moderate friend: "\
380
+ "#{Paint[moderate_friend_names.sample || 'None found', :bold, :magenta]}"
381
+ safe_puts "Close friend: "\
382
+ "#{Paint[close_friend_names.sample || 'None found', :bold, :magenta]}"
378
383
  end
379
384
 
380
385
  ###################################################################
@@ -452,41 +457,50 @@ module Friends
452
457
  end
453
458
  end
454
459
 
455
- # @return [Integer] the total number of friends
456
- def total_friends
457
- @friends.size
458
- end
460
+ def stats
461
+ safe_puts "Total activities: #{@activities.size}"
462
+ safe_puts "Total friends: #{@friends.size}"
463
+ safe_puts "Total locations: #{@locations.size}"
464
+ safe_puts "Total notes: #{@notes.size}"
465
+ safe_puts "Total tags: #{tags.size}"
459
466
 
460
- # @return [Integer] the total number of activities
461
- def total_activities
462
- @activities.size
463
- end
467
+ events = @activities + @notes
464
468
 
465
- # @return [Integer] the total number of locations
466
- def total_locations
467
- @locations.size
468
- end
469
+ elapsed_days = if events.size < 2
470
+ 0
471
+ else
472
+ sorted_events = events.sort
473
+ (sorted_events.first.date - sorted_events.last.date).to_i
474
+ end
469
475
 
470
- # @return [Integer] the total number of notes
471
- def total_notes
472
- @notes.size
476
+ safe_puts "Total time elapsed: #{elapsed_days} day#{'s' if elapsed_days != 1}"
473
477
  end
474
478
 
475
- # @return [Integer] the total number of tags
476
- def total_tags
477
- list_tags(from: []).size
478
- end
479
+ private
479
480
 
480
- # @return [Integer] the number of days elapsed between
481
- # the first and last activity
482
- def elapsed_days
483
- events = @activities + @notes
484
- return 0 if events.size < 2
485
- sorted_events = events.sort
486
- (sorted_events.first.date - sorted_events.last.date).to_i
481
+ # Print the message unless we're in `quiet` mode.
482
+ # @param str [String] a message to print
483
+ def safe_puts(str)
484
+ puts str unless @quiet
487
485
  end
488
486
 
489
- private
487
+ # @param from [Array] containing any of: ["activities", "friends", "notes"]
488
+ # If not empty, limits the tags returned to only those from either
489
+ # activities, notes, or friends.
490
+ # @return [Set] the set of tags present in the given things
491
+ def tags(from: [])
492
+ Set.new.tap do |output|
493
+ if from.empty? || from.include?("activities")
494
+ @activities.each { |activity| output.merge(activity.tags) }
495
+ end
496
+
497
+ @notes.each { |note| output.merge(note.tags) } if from.empty? || from.include?("notes")
498
+
499
+ if from.empty? || from.include?("friends")
500
+ @friends.each { |friend| output.merge(friend.tags) }
501
+ end
502
+ end
503
+ end
490
504
 
491
505
  # List all event details.
492
506
  # @param events [Array<Event>] the base events to list, either @activities or @notes
@@ -500,7 +514,6 @@ module Friends
500
514
  # unfiltered
501
515
  # @param since_date [Date] a date on or after which to find events, or nil for unfiltered
502
516
  # @param until_date [Date] a date before or on which to find events, or nil for unfiltered
503
- # @return [Array] a list of all event (activity or note) text values
504
517
  # @raise [ArgumentError] if limit is present but limit < 1
505
518
  # @raise [FriendsError] if friend, location or tag cannot be found or
506
519
  # is ambiguous
@@ -519,7 +532,7 @@ module Friends
519
532
  # If we need to, trim the list.
520
533
  events = events.take(limit) unless limit.nil?
521
534
 
522
- events.map(&:to_s)
535
+ safe_puts events.map(&:to_s)
523
536
  end
524
537
 
525
538
  # @param arr [Array] an unsorted array
@@ -538,7 +551,7 @@ module Friends
538
551
  # unfiltered
539
552
  # @param since_date [Date] a date on or after which to find activities, or nil for unfiltered
540
553
  # @param until_date [Date] a date before or on which to find activities, or nil for unfiltered
541
- # @return [Array] an array of activities
554
+ # @return [Array] an array of activities or notes
542
555
  # @raise [FriendsError] if friend, location or tag cannot be found or
543
556
  # is ambiguous
544
557
  def filtered_events(events:, with:, location_name:, tagged:, since_date:, until_date:)
@@ -572,7 +585,6 @@ module Friends
572
585
 
573
586
  # @param type [Symbol] one of: [:friend, :location]
574
587
  # @param limit [Integer] the number of favorite things to return
575
- # @return [Array] a list of the favorite things' names and activity counts
576
588
  # @raise [ArgumentError] if type is not one of: [:friend, :location]
577
589
  # @raise [ArgumentError] if limit is < 1
578
590
  def list_favorite_things(type, limit:)
@@ -589,12 +601,12 @@ module Friends
589
601
 
590
602
  if results.size == 1
591
603
  favorite = results.first
592
- puts "Your favorite #{type} is "\
593
- "#{favorite.name} "\
594
- "(#{favorite.n_activities} "\
595
- "#{favorite.n_activities == 1 ? 'activity' : 'activities'})"
604
+ safe_puts "Your favorite #{type} is "\
605
+ "#{favorite.name} "\
606
+ "(#{favorite.n_activities} "\
607
+ "#{favorite.n_activities == 1 ? 'activity' : 'activities'})"
596
608
  else
597
- puts "Your favorite #{type}s:"
609
+ safe_puts "Your favorite #{type}s:"
598
610
 
599
611
  max_str_size = results.map(&:name).map(&:size).max
600
612
 
@@ -621,7 +633,7 @@ module Friends
621
633
  # elements, which may be too large if the last element in the list is a tie.
622
634
  num_str_size = data.last.first.to_s.size + 1 unless data.empty?
623
635
  data.each do |ranking, str|
624
- puts "#{"#{ranking}.".ljust(num_str_size)} #{str}"
636
+ safe_puts "#{"#{ranking}.".ljust(num_str_size)} #{str}"
625
637
  end
626
638
  end
627
639
  end