friends 0.51 → 0.55

Sign up to get free protection for your applications and to get access to all the features.
data/RELEASING.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  These are steps for the maintainer to take to release a new version of this gem.
4
4
 
5
- 1. On the `master` branch, update the `VERSION` constant in
5
+ 1. On the `main` branch, update the `VERSION` constant in
6
6
  `lib/friends/version.rb`.
7
7
  2. Commit the change (`git add -A && git commit -m 'Bump to vX.X'`).
8
8
  3. Add a tag (`git tag -am "vX.X" vX.X`).
data/bin/friends CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- if ENV["TRAVIS"] == "true" && ENV["CODE_COVERAGE"] == "true"
4
+ if ENV["CI"] == "true" && ENV["CODE_COVERAGE"] == "true"
5
5
  require "simplecov"
6
6
  SimpleCov.print_error_status = false
7
7
  SimpleCov.formatter = SimpleCov::Formatter::SimpleFormatter
@@ -34,7 +34,7 @@ class Tag
34
34
  # conversions for arguments.
35
35
  # See: https://github.com/davetron5000/gli/issues/241
36
36
  def self.convert_to_tag(str)
37
- str = str.strip
37
+ str = str.to_s.strip
38
38
  !str.empty? && str[0] == "@" ? str : "@#{str}"
39
39
  end
40
40
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- desc "Adds a friend (or nickname), activity, note, or location"
3
+ desc "Adds a friend (or nickname), activity, note, or location (or alias)"
4
4
  command :add do |add|
5
5
  add.desc "Adds a friend"
6
6
  add.arg_name "NAME"
@@ -35,7 +35,16 @@ command :add do |add|
35
35
  add.arg_name "NAME NICKNAME"
36
36
  add.command :nickname do |add_nickname|
37
37
  add_nickname.action do |_, _, args|
38
- @introvert.add_nickname(name: args.first.strip, nickname: args[1].strip)
38
+ @introvert.add_nickname(name: args.first.to_s.strip, nickname: args[1].to_s.strip)
39
+ @dirty = true # Mark the file for cleaning.
40
+ end
41
+ end
42
+
43
+ add.desc "Adds an alias to a location"
44
+ add.arg_name "LOCATION ALIAS"
45
+ add.command :alias do |add_alias|
46
+ add_alias.action do |_, _, args|
47
+ @introvert.add_alias(name: args.first.to_s.strip, nickname: args[1].to_s.strip)
39
48
  @dirty = true # Mark the file for cleaning.
40
49
  end
41
50
  end
@@ -46,7 +55,7 @@ command :add do |add|
46
55
  add_tag.action do |_, _, args|
47
56
  @introvert.add_tag(
48
57
  name: args[0..-2].join(" "),
49
- tag: Tag.convert_to_tag(args.last.strip)
58
+ tag: Tag.convert_to_tag(args.last.to_s.strip)
50
59
  )
51
60
  @dirty = true # Mark the file for cleaning.
52
61
  end
@@ -19,11 +19,23 @@ command :list do |list|
19
19
  negatable: false,
20
20
  desc: "Output friend nicknames, locations, and tags"
21
21
 
22
+ list_friends.flag :sort,
23
+ default_value: "alphabetical",
24
+ arg_name: "ATTRIBUTE",
25
+ must_match: %w[alphabetical n-activities recency],
26
+ desc: "Sort output by one of: alphabetical, n-activities, recency"
27
+
28
+ list_friends.switch :reverse,
29
+ negatable: false,
30
+ desc: "Reverse the sort order"
31
+
22
32
  list_friends.action do |_, options|
23
33
  @introvert.list_friends(
24
34
  location_name: options[:in],
25
35
  tagged: options[:tagged],
26
- verbose: options[:verbose]
36
+ verbose: options[:verbose],
37
+ sort: options[:sort],
38
+ reverse: options[:reverse]
27
39
  )
28
40
  end
29
41
  end
@@ -73,8 +85,26 @@ command :list do |list|
73
85
 
74
86
  list.desc "List all locations"
75
87
  list.command :locations do |list_locations|
76
- list_locations.action do
77
- @introvert.list_locations
88
+ list_locations.switch [:verbose],
89
+ negatable: false,
90
+ desc: "Output location aliases"
91
+
92
+ list_locations.flag :sort,
93
+ default_value: "alphabetical",
94
+ arg_name: "ATTRIBUTE",
95
+ must_match: %w[alphabetical n-activities recency],
96
+ desc: "Sort output by one of: alphabetical, n-activities, recency"
97
+
98
+ list_locations.switch :reverse,
99
+ negatable: false,
100
+ desc: "Reverse the sort order"
101
+
102
+ list_locations.action do |_, options|
103
+ @introvert.list_locations(
104
+ verbose: options[:verbose],
105
+ sort: options[:sort],
106
+ reverse: options[:reverse]
107
+ )
78
108
  end
79
109
  end
80
110
 
@@ -89,21 +119,4 @@ command :list do |list|
89
119
  @introvert.list_tags(from: options[:from])
90
120
  end
91
121
  end
92
-
93
- list.desc "List favorite friends and locations"
94
- list.command :favorite do |list_favorite|
95
- list_favorite.desc "List favorite friends"
96
- list_favorite.command :friends do |list_favorite_friends|
97
- list_favorite_friends.action do
98
- @introvert.list_favorite_friends
99
- end
100
- end
101
-
102
- list_favorite.desc "List favorite locations"
103
- list_favorite.command :locations do |list_favorite_locations|
104
- list_favorite_locations.action do
105
- @introvert.list_favorite_locations
106
- end
107
- end
108
- end
109
122
  end
@@ -11,6 +11,15 @@ command :remove do |remove|
11
11
  end
12
12
  end
13
13
 
14
+ remove.desc "Removes an alias from a location"
15
+ remove.arg_name "LOCATION ALIAS"
16
+ remove.command :alias do |remove_alias|
17
+ remove_alias.action do |_, _, args|
18
+ @introvert.remove_alias(name: args.first.to_s.strip, nickname: args[1].to_s.strip)
19
+ @dirty = true # Mark the file for cleaning.
20
+ end
21
+ end
22
+
14
23
  remove.desc "Removes a tag from a friend"
15
24
  remove.arg_name "NAME @TAG"
16
25
  remove.command :tag do |remove_tag|
@@ -203,6 +203,7 @@ module Friends
203
203
  # @param nickname [String] the nickname to add to the friend
204
204
  # @raise [FriendsError] if 0 or 2+ friends match the given name
205
205
  def add_nickname(name:, nickname:)
206
+ raise FriendsError, "Expected \"[Friend Name]\" \"[Nickname]\"" if name.empty?
206
207
  raise FriendsError, "Nickname cannot be blank" if nickname.empty?
207
208
 
208
209
  friend = thing_with_name_in(:friend, name)
@@ -211,11 +212,37 @@ module Friends
211
212
  @output << "Nickname added: \"#{friend}\""
212
213
  end
213
214
 
215
+ # Add an alias to an existing location.
216
+ # @param name [String] the name of the location
217
+ # @param nickname [String] the alias to add to the location
218
+ # @raise [FriendsError] if 0 or 2+ locations match the given name
219
+ # @raise [FriendsError] if the alias is already taken
220
+ def add_alias(name:, nickname:)
221
+ raise FriendsError, "Expected \"[Location Name]\" \"[Alias]\"" if name.empty?
222
+ raise FriendsError, "Alias cannot be blank" if nickname.empty?
223
+
224
+ collision = @locations.find do |loc|
225
+ loc.name.casecmp(nickname).zero? || loc.aliases.any? { |a| a.casecmp(nickname).zero? }
226
+ end
227
+
228
+ if collision
229
+ raise FriendsError,
230
+ "The location alias \"#{nickname}\" is already taken by "\
231
+ "\"#{collision}\""
232
+ end
233
+
234
+ location = thing_with_name_in(:location, name)
235
+ location.add_alias(nickname)
236
+
237
+ @output << "Alias added: \"#{location}\""
238
+ end
239
+
214
240
  # Add a tag to an existing friend.
215
241
  # @param name [String] the name of the friend
216
242
  # @param tag [String] the tag to add to the friend, of the form: "@tag"
217
243
  # @raise [FriendsError] if 0 or 2+ friends match the given name
218
244
  def add_tag(name:, tag:)
245
+ raise FriendsError, "Expected \"[Friend Name]\" \"[Tag]\"" if name.empty?
219
246
  raise FriendsError, "Tag cannot be blank" if tag == "@"
220
247
 
221
248
  friend = thing_with_name_in(:friend, name)
@@ -248,6 +275,21 @@ module Friends
248
275
  @output << "Nickname removed: \"#{friend}\""
249
276
  end
250
277
 
278
+ # Remove an alias from an existing location.
279
+ # @param name [String] the name of the location
280
+ # @param nickname [String] the alias to remove from the location
281
+ # @raise [FriendsError] if 0 or 2+ locations match the given name
282
+ # @raise [FriendsError] if the location does not have the given alias
283
+ def remove_alias(name:, nickname:)
284
+ raise FriendsError, "Expected \"[Location Name]\" \"[Alias]\"" if name.empty?
285
+ raise FriendsError, "Alias cannot be blank" if nickname.empty?
286
+
287
+ location = thing_with_name_in(:location, name)
288
+ location.remove_alias(nickname)
289
+
290
+ @output << "Alias removed: \"#{location}\""
291
+ end
292
+
251
293
  # List all friend names in the friends file.
252
294
  # @param location_name [String] the name of a location to filter by, or nil
253
295
  # for unfiltered
@@ -255,7 +297,11 @@ module Friends
255
297
  # unfiltered
256
298
  # @param verbose [Boolean] true iff we should output friend names with
257
299
  # nicknames, locations, and tags; false for names only
258
- def list_friends(location_name:, tagged:, verbose:)
300
+ # @param sort [String] one of:
301
+ # ["alphabetical", "n-activities", "recency"]
302
+ # @param reverse [Boolean] true iff we should reverse the sorted order of
303
+ # our output
304
+ def list_friends(location_name:, tagged:, verbose:, sort:, reverse:)
259
305
  fs = @friends
260
306
 
261
307
  # Filter by location if a name is passed.
@@ -271,18 +317,11 @@ module Friends
271
317
  end
272
318
  end
273
319
 
274
- (verbose ? fs.map(&:to_s) : fs.map(&:name)).each { |line| @output << line }
320
+ list_things(type: :friend, arr: fs, verbose: verbose, sort: sort, reverse: reverse)
275
321
  end
276
322
 
277
- # List your favorite friends.
278
- def list_favorite_friends
279
- list_favorite_things(:friend)
280
- end
281
-
282
- # List your favorite friends.
283
- def list_favorite_locations
284
- list_favorite_things(:location)
285
- end
323
+ NA_STR = "N/A"
324
+ private_constant :NA_STR
286
325
 
287
326
  # See `list_events` for all of the parameters we can pass.
288
327
  def list_activities(**args)
@@ -295,8 +334,14 @@ module Friends
295
334
  end
296
335
 
297
336
  # List all location names in the friends file.
298
- def list_locations
299
- @locations.each { |location| @output << location.name }
337
+ # @param verbose [Boolean] true iff we should output location names with
338
+ # aliases; false for names only
339
+ # @param sort [String] one of:
340
+ # ["alphabetical", "n-activities", "recency"]
341
+ # @param reverse [Boolean] true iff we should reverse the sorted order of
342
+ # our output
343
+ def list_locations(verbose:, sort:, reverse:)
344
+ list_things(type: :location, verbose: verbose, sort: sort, reverse: reverse)
300
345
  end
301
346
 
302
347
  # @param from [Array] containing any of: ["activities", "friends", "notes"]
@@ -427,16 +472,16 @@ module Friends
427
472
  #
428
473
  # The returned hash uses the following format:
429
474
  # {
430
- # /regex/ => [list of friends matching regex]
475
+ # /regex/ => location
431
476
  # }
432
477
  #
433
478
  # This hash is sorted (because Ruby's hashes are ordered) by decreasing
434
479
  # regex key length, so the key /Paris, France/ appears before /Paris/.
435
480
  #
436
- # @return [Hash{Regexp => Array<Friends::Location>}]
481
+ # @return [Hash{Regexp => location}]
437
482
  def regex_location_map
438
483
  @locations.each_with_object({}) do |location, hash|
439
- hash[location.regex_for_name] = location
484
+ location.regexes_for_name.each { |regex| hash[regex] = location }
440
485
  end.sort_by { |k, _| -k.to_s.size }.to_h
441
486
  end
442
487
 
@@ -495,6 +540,49 @@ module Friends
495
540
 
496
541
  private
497
542
 
543
+ # List either friends or activities
544
+ # @param arr [Array<Friend|Activity>] a filtered list to print
545
+ # @param verbose [Boolean] true iff we should output names with
546
+ # aliases/nicknames/etc.; false for names only
547
+ # @param sort [String] one of:
548
+ # ["alphabetical", "n-activities", "recency"]
549
+ # @param reverse [Boolean] true iff we should reverse the sorted order of
550
+ # our output
551
+ def list_things(type:, arr: instance_variable_get("@#{type}s"), verbose:, sort:, reverse:)
552
+ case sort
553
+ when "alphabetical"
554
+ arr = stable_sort(arr) # In case the input file was not already sorted.
555
+ when "n-activities"
556
+ arr = stable_sort_by(arr) { |thing| -thing.n_activities }
557
+ when "recency"
558
+ today = Date.today
559
+
560
+ most_recent_activity_by_thing = @activities.each_with_object({}) do |activity, output|
561
+ activity.send("#{type}_names").each do |thing_name|
562
+ output[thing_name] = (today - activity.date).to_i unless output.key?(thing_name)
563
+ end
564
+ end
565
+
566
+ arr = stable_sort_by(arr) do |thing|
567
+ most_recent_activity_by_thing[thing.name] || -Float::INFINITY
568
+ end
569
+ end
570
+
571
+ (reverse ? arr.reverse : arr).each do |thing|
572
+ case sort
573
+ when "n-activities"
574
+ prefix = "#{Paint[thing.n_activities, :bold, :red]} "\
575
+ "activit#{thing.n_activities == 1 ? 'y' : 'ies'}: "
576
+ when "recency"
577
+ n_days = most_recent_activity_by_thing[thing.name] || NA_STR
578
+ prefix = "#{Paint[n_days, :bold, :red]} "\
579
+ "day#{'s' unless n_days == 1} ago: "
580
+ end
581
+
582
+ @output << "#{prefix}#{verbose ? thing.to_s : thing.name}"
583
+ end
584
+ end
585
+
498
586
  # @param from [Array] containing any of: ["activities", "friends", "notes"]
499
587
  # If not empty, limits the tags returned to only those from either
500
588
  # activities, notes, or friends.
@@ -544,6 +632,13 @@ module Friends
544
632
  arr.sort_by.with_index { |x, idx| [x, idx] }
545
633
  end
546
634
 
635
+ # @param arr [Array] an unsorted array
636
+ # @param &block [block] used to return a value for each element's sort position
637
+ # @return [Array] a stably-sorted array
638
+ def stable_sort_by(arr)
639
+ arr.sort_by.with_index { |x, idx| [yield(x), idx] }
640
+ end
641
+
547
642
  # Filter activities by friend, location and tag
548
643
  # @param events [Array<Event>] the base events to list, either @activities or @notes
549
644
  # @param with [Array<String>] the names of friends to filter by, or empty for
@@ -586,49 +681,6 @@ module Friends
586
681
  events
587
682
  end
588
683
 
589
- # @param type [Symbol] one of: [:friend, :location]
590
- # @raise [ArgumentError] if type is not one of: [:friend, :location]
591
- def list_favorite_things(type)
592
- unless [:friend, :location].include? type
593
- raise ArgumentError, "Type must be either :friend or :location"
594
- end
595
-
596
- # Sort the results, with the most favorite thing first.
597
- results = instance_variable_get("@#{type}s").sort_by do |thing|
598
- -thing.n_activities
599
- end
600
-
601
- @output << "Your favorite #{type}s:"
602
-
603
- max_str_size = results.map(&:name).map(&:size).max
604
-
605
- grouped_results = results.group_by(&:n_activities)
606
-
607
- rank = 1
608
- first = true
609
- data = grouped_results.each.with_object([]) do |(n_activities, things), arr|
610
- things.each do |thing|
611
- name = thing.name.ljust(max_str_size)
612
- if first
613
- label = n_activities == 1 ? " activity" : " activities"
614
- first = false
615
- end
616
- str = "#{name} (#{n_activities}#{label})"
617
-
618
- arr << [rank, str]
619
- end
620
- rank += things.size
621
- end
622
-
623
- # We need to use `data.last.first` instead of `rank` to determine the size
624
- # of the numbering prefix because `rank` will simply be the size of all
625
- # elements, which may be too large if the last element in the list is a tie.
626
- num_str_size = data.last.first.to_s.size + 1 unless data.empty?
627
- data.each do |ranking, str|
628
- @output << "#{"#{ranking}.".ljust(num_str_size)} #{str}"
629
- end
630
- end
631
-
632
684
  # Sets the n_activities field on each thing.
633
685
  # @param type [Symbol] one of: [:friend, :location]
634
686
  # @raise [ArgumentError] if `type` is not one of: [:friend, :location]
@@ -749,11 +801,7 @@ module Friends
749
801
  # @raise [FriendsError] if 0 or 2+ friends match the given text
750
802
  def thing_with_name_in(type, text)
751
803
  things = instance_variable_get("@#{type}s").select do |thing|
752
- if type == :friend
753
- thing.regexes_for_name.any? { |regex| regex.match(text) }
754
- else
755
- thing.regex_for_name.match(text)
756
- end
804
+ thing.regexes_for_name.any? { |regex| regex.match(text) }
757
805
  end
758
806
 
759
807
  # If there's more than one match with fuzzy regexes but exactly one thing
@@ -10,11 +10,12 @@ module Friends
10
10
  extend Serializable
11
11
 
12
12
  SERIALIZATION_PREFIX = "- "
13
+ ALIAS_PREFIX = "a.k.a. "
13
14
 
14
15
  # @return [Regexp] the regex for capturing groups in deserialization
15
16
  def self.deserialization_regex
16
17
  # Note: this regex must be on one line because whitespace is important
17
- /(#{SERIALIZATION_PREFIX})?(?<name>.+)/
18
+ /(#{SERIALIZATION_PREFIX})?(?<name>[^\(]*[^\(\s])(\s+\(#{ALIAS_PREFIX}(?<alias_str>.+)\))?/
18
19
  end
19
20
 
20
21
  # @return [Regexp] the string of what we expected during deserialization
@@ -23,21 +24,52 @@ module Friends
23
24
  end
24
25
 
25
26
  # @param name [String] the name of the location
26
- def initialize(name:)
27
+ def initialize(name:, alias_str: nil)
27
28
  @name = name
29
+ @aliases = alias_str&.split(" #{ALIAS_PREFIX}") || []
28
30
  end
29
31
 
30
32
  attr_accessor :name
33
+ attr_reader :aliases
31
34
 
32
35
  # @return [String] the file serialization text for the location
33
36
  def serialize
34
- "#{SERIALIZATION_PREFIX}#{@name}"
37
+ Paint.unpaint("#{SERIALIZATION_PREFIX}#{self}")
35
38
  end
36
39
 
37
- # @return [Regexp] the regex used to match this location's name in an
38
- # activity description
39
- def regex_for_name
40
- Friends::RegexBuilder.regex(@name)
40
+ # @return [String] a string representing the location's name and aliases
41
+ def to_s
42
+ unless @aliases.empty?
43
+ alias_str = " (" +
44
+ @aliases.map do |nickname|
45
+ "#{ALIAS_PREFIX}#{Paint[nickname, :bold, :yellow]}"
46
+ end.join(" ") + ")"
47
+ end
48
+
49
+ "#{Paint[@name, :bold]}#{alias_str}"
50
+ end
51
+
52
+ # Add an alias, ignoring duplicates.
53
+ # @param nickname [String] the alias to add
54
+ def add_alias(nickname)
55
+ @aliases << nickname
56
+ @aliases.uniq!
57
+ end
58
+
59
+ # @param nickname [String] the alias to remove
60
+ # @raise [FriendsError] if the location does not have the given alias
61
+ def remove_alias(nickname)
62
+ unless @aliases.include? nickname
63
+ raise FriendsError, "Alias \"#{nickname}\" not found for \"#{name}\""
64
+ end
65
+
66
+ @aliases.delete(nickname)
67
+ end
68
+
69
+ # @return [Array] a list of all regexes to match the name in a string
70
+ # NOTE: Only full names and aliases
71
+ def regexes_for_name
72
+ [name, *@aliases].map { |str| Friends::RegexBuilder.regex(str) }
41
73
  end
42
74
 
43
75
  # The number of activities this location is in. This is for internal use