friends 0.49 → 0.54

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.
@@ -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|
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "friends/post_install_message"
4
+ require "friends/sem_ver_comparator"
4
5
 
5
6
  desc "Updates the `friends` program"
6
7
  command :update do |update|
@@ -9,8 +10,7 @@ command :update do |update|
9
10
  if match = `gem search friends`.match(/^friends\s\(([^\)]+)\)$/)
10
11
  # rubocop:enable Lint/AssignmentInCondition
11
12
  remote_version = match[1]
12
- if Semverse::Version.coerce(remote_version) >
13
- Semverse::Version.coerce(Friends::VERSION)
13
+ if Friends::SemVerComparator.greater?(remote_version, Friends::VERSION)
14
14
  `gem update friends && gem cleanup friends`
15
15
 
16
16
  unless global_options[:quiet]
@@ -14,8 +14,8 @@ module Friends
14
14
  class Event
15
15
  extend Serializable
16
16
 
17
- SERIALIZATION_PREFIX = "- ".freeze
18
- DATE_PARTITION = ": ".freeze
17
+ SERIALIZATION_PREFIX = "- "
18
+ DATE_PARTITION = ": "
19
19
 
20
20
  # @return [Regexp] the regex for capturing groups in deserialization
21
21
  def self.deserialization_regex
@@ -10,13 +10,13 @@ module Friends
10
10
  class Friend
11
11
  extend Serializable
12
12
 
13
- SERIALIZATION_PREFIX = "- ".freeze
14
- NICKNAME_PREFIX = "a.k.a. ".freeze
13
+ SERIALIZATION_PREFIX = "- "
14
+ NICKNAME_PREFIX = "a.k.a. "
15
15
 
16
16
  # @return [Regexp] the regex for capturing groups in deserialization
17
17
  def self.deserialization_regex
18
18
  # Note: this regex must be on one line because whitespace is important
19
- /(#{SERIALIZATION_PREFIX})?(?<name>[^\(\[@]*[^\(\[@\s])(\s+\(#{NICKNAME_PREFIX}(?<nickname_str>.+)\))?(\s+\[(?<location_name>[^\]]+)\])?(\s+(?<tags_str>(#{TAG_REGEX}\s*)+))?/ # rubocop:disable Metrics/LineLength
19
+ /(#{SERIALIZATION_PREFIX})?(?<name>[^\(\[@]*[^\(\[@\s])(\s+\(#{NICKNAME_PREFIX}(?<nickname_str>.+)\))?(\s+\[(?<location_name>[^\]]+)\])?(\s+(?<tags_str>(#{TAG_REGEX}\s*)+))?/ # rubocop:disable Layout/LineLength
20
20
  end
21
21
 
22
22
  # @return [Regexp] the string of what we expected during deserialization
@@ -32,11 +32,9 @@ module Friends
32
32
  tags_str: nil
33
33
  )
34
34
  @name = name
35
- @nicknames = nickname_str &&
36
- nickname_str.split(" #{NICKNAME_PREFIX}") ||
37
- []
35
+ @nicknames = nickname_str&.split(" #{NICKNAME_PREFIX}") || []
38
36
  @location_name = location_name
39
- @tags = tags_str && tags_str.split(/\s+/) || []
37
+ @tags = tags_str&.split(/\s+/) || []
40
38
  end
41
39
 
42
40
  attr_accessor :name
@@ -4,7 +4,7 @@
4
4
 
5
5
  module Friends
6
6
  class Graph
7
- DATE_FORMAT = "%b %Y".freeze
7
+ DATE_FORMAT = "%b %Y"
8
8
  SCALED_SIZE = 20
9
9
 
10
10
  # @param filtered_activities [Array<Friends::Activity>] a list of activities to highlight in
@@ -16,10 +16,10 @@ require "friends/friends_error"
16
16
 
17
17
  module Friends
18
18
  class Introvert
19
- ACTIVITIES_HEADER = "### Activities:".freeze
20
- NOTES_HEADER = "### Notes:".freeze
21
- FRIENDS_HEADER = "### Friends:".freeze
22
- LOCATIONS_HEADER = "### Locations:".freeze
19
+ ACTIVITIES_HEADER = "### Activities:"
20
+ NOTES_HEADER = "### Notes:"
21
+ FRIENDS_HEADER = "### Friends:"
22
+ LOCATIONS_HEADER = "### Locations:"
23
23
 
24
24
  # @param filename [String] the name of the friends Markdown file
25
25
  def initialize(filename:)
@@ -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
@@ -295,8 +337,8 @@ module Friends
295
337
  end
296
338
 
297
339
  # List all location names in the friends file.
298
- def list_locations
299
- @locations.each { |location| @output << location.name }
340
+ def list_locations(verbose:)
341
+ (verbose ? @locations.map(&:to_s) : @locations.map(&:name)).each { |line| @output << line }
300
342
  end
301
343
 
302
344
  # @param from [Array] containing any of: ["activities", "friends", "notes"]
@@ -427,16 +469,16 @@ module Friends
427
469
  #
428
470
  # The returned hash uses the following format:
429
471
  # {
430
- # /regex/ => [list of friends matching regex]
472
+ # /regex/ => location
431
473
  # }
432
474
  #
433
475
  # This hash is sorted (because Ruby's hashes are ordered) by decreasing
434
476
  # regex key length, so the key /Paris, France/ appears before /Paris/.
435
477
  #
436
- # @return [Hash{Regexp => Array<Friends::Location>}]
478
+ # @return [Hash{Regexp => location}]
437
479
  def regex_location_map
438
480
  @locations.each_with_object({}) do |location, hash|
439
- hash[location.regex_for_name] = location
481
+ location.regexes_for_name.each { |regex| hash[regex] = location }
440
482
  end.sort_by { |k, _| -k.to_s.size }.to_h
441
483
  end
442
484
 
@@ -725,8 +767,8 @@ module Friends
725
767
 
726
768
  begin
727
769
  instance_variable_get("@#{stage.id}") << stage.klass.deserialize(line)
728
- rescue StandardError => ex
729
- bad_line(ex, line_num)
770
+ rescue StandardError => e
771
+ bad_line(e, line_num)
730
772
  end
731
773
 
732
774
  state
@@ -749,11 +791,7 @@ module Friends
749
791
  # @raise [FriendsError] if 0 or 2+ friends match the given text
750
792
  def thing_with_name_in(type, text)
751
793
  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
794
+ thing.regexes_for_name.any? { |regex| regex.match(text) }
757
795
  end
758
796
 
759
797
  # If there's more than one match with fuzzy regexes but exactly one thing
@@ -808,7 +846,7 @@ module Friends
808
846
  a.default_location && a.default_location != activity.default_location
809
847
  end
810
848
 
811
- str += " to #{Paint[(later_activity.date if later_activity) || 'present', :bold]}"
849
+ str += " to #{Paint[later_activity&.date || 'present', :bold]}"
812
850
  end
813
851
 
814
852
  str += " already" if earlier_activity_with_default_location != activity
@@ -9,12 +9,13 @@ module Friends
9
9
  class Location
10
10
  extend Serializable
11
11
 
12
- SERIALIZATION_PREFIX = "- ".freeze
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
@@ -3,6 +3,5 @@
3
3
  module Friends
4
4
  POST_INSTALL_MESSAGE = "\nfriends is a volunteer project. If you find it useful, please "\
5
5
  "consider making a small donation:\n\n\t"\
6
- "https://github.com/JacobEvelyn/friends#contributing-its-encouraged\n\n".
7
- freeze
6
+ "https://github.com/JacobEvelyn/friends#contributing-its-encouraged\n\n"
8
7
  end
@@ -6,26 +6,26 @@ module Friends
6
6
  class RegexBuilder
7
7
  # We don't want to match strings that are "escaped" with a leading
8
8
  # backslash.
9
- NO_LEADING_BACKSLASH = "(?<!\\\\)".freeze
9
+ NO_LEADING_BACKSLASH = "(?<!\\\\)"
10
10
 
11
11
  # We don't want to match strings that are directly touching double asterisks
12
12
  # as these are treated as sacred by our program.
13
- NO_LEADING_ASTERISKS = "(?<!\\*\\*)".freeze
14
- NO_TRAILING_ASTERISKS = "(?!\\*\\*)".freeze
13
+ NO_LEADING_ASTERISKS = "(?<!\\*\\*)"
14
+ NO_TRAILING_ASTERISKS = "(?!\\*\\*)"
15
15
 
16
16
  # We don't want to match strings that are directly touching underscores
17
17
  # as these are treated as sacred by our program.
18
- NO_LEADING_UNDERSCORES = "(?<!_)".freeze
19
- NO_TRAILING_UNDERSCORES = "(?!_)".freeze
18
+ NO_LEADING_UNDERSCORES = "(?<!_)"
19
+ NO_TRAILING_UNDERSCORES = "(?!_)"
20
20
 
21
21
  # We don't want to match strings that are part of other words.
22
- NO_LEADING_ALPHABETICALS = "(?<![A-z])".freeze
23
- NO_TRAILING_ALPHABETICALS = "(?![A-z])".freeze
22
+ NO_LEADING_ALPHABETICALS = "(?<![A-z])"
23
+ NO_TRAILING_ALPHABETICALS = "(?![A-z])"
24
24
 
25
25
  # We ignore case within the regex as opposed to globally to allow consumers
26
26
  # of this API the ability to pass in strings that turn off this modifier
27
27
  # with the "(?-i)" string.
28
- IGNORE_CASE = "(?i)".freeze
28
+ IGNORE_CASE = "(?i)"
29
29
 
30
30
  def self.regex(str)
31
31
  Regexp.new(
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Friends
4
+ module SemVerComparator
5
+ SEPARATOR = "."
6
+ NUMBER_REGEX = /\d+/.freeze
7
+
8
+ def self.greater?(version_a, version_b)
9
+ version_a.split(SEPARATOR).zip(version_b.split(SEPARATOR)) do |a, b|
10
+ a_num = a&.[](NUMBER_REGEX)&.to_i
11
+ b_num = b&.[](NUMBER_REGEX)&.to_i
12
+ return false if a_num.nil?
13
+ return true if b_num.nil? || a_num > b_num
14
+ return false if a_num < b_num
15
+ end
16
+
17
+ false
18
+ end
19
+ end
20
+ end
@@ -26,7 +26,7 @@ module Serializable
26
26
  reduce(:merge).
27
27
  reject { |_, v| v.nil? }
28
28
 
29
- new(args)
29
+ new(**args)
30
30
  end
31
31
 
32
32
  class SerializationError < StandardError
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Friends
4
- VERSION = "0.49".freeze
4
+ VERSION = "0.54"
5
5
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  def date_parsing_specs(test_stdout: true)
2
4
  describe "date parsing" do
3
5
  let(:description) { "Test." }
@@ -123,7 +125,7 @@ def description_parsing_specs(test_stdout: true)
123
125
 
124
126
  it { line_added "- #{date}: Met **Grace Hopper**, and others, at 12." }
125
127
  if test_stdout
126
- it { stdout_only "#{capitalized_event} added: \"#{date}: Met Grace Hopper, and others, at 12.\"" } # rubocop:disable Metrics/LineLength
128
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Met Grace Hopper, and others, at 12.\"" } # rubocop:disable Layout/LineLength
127
129
  end
128
130
  end
129
131
 
@@ -132,7 +134,7 @@ def description_parsing_specs(test_stdout: true)
132
134
 
133
135
  it { line_added "- #{date}: Met **Grace Hopper**, King James, and others at 12." }
134
136
  if test_stdout
135
- it { stdout_only "#{capitalized_event} added: \"#{date}: Met Grace Hopper, King James, and others at 12.\"" } # rubocop:disable Metrics/LineLength
137
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Met Grace Hopper, King James, and others at 12.\"" } # rubocop:disable Layout/LineLength
136
138
  end
137
139
  end
138
140
 
@@ -141,7 +143,7 @@ def description_parsing_specs(test_stdout: true)
141
143
 
142
144
  it { line_added "- #{date}: Met someone—**Grace Hopper**?! At 12." }
143
145
  if test_stdout
144
- it { stdout_only "#{capitalized_event} added: \"#{date}: Met someone—Grace Hopper?! At 12.\"" } # rubocop:disable Metrics/LineLength
146
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Met someone—Grace Hopper?! At 12.\"" } # rubocop:disable Layout/LineLength
145
147
  end
146
148
  end
147
149
 
@@ -150,7 +152,7 @@ def description_parsing_specs(test_stdout: true)
150
152
 
151
153
  it { line_added "- #{date}: Met someone {**Grace Hopper**}—at 12." }
152
154
  if test_stdout
153
- it { stdout_only "#{capitalized_event} added: \"#{date}: Met someone {Grace Hopper}—at 12.\"" } # rubocop:disable Metrics/LineLength
155
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Met someone {Grace Hopper}—at 12.\"" } # rubocop:disable Layout/LineLength
154
156
  end
155
157
  end
156
158
 
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "./test/helper"
4
+
5
+ clean_describe "add alias" do
6
+ subject { run_cmd("add alias #{location_name} #{nickname}") }
7
+
8
+ let(:content) { CONTENT }
9
+
10
+ describe "when location name and alias are blank" do
11
+ let(:location_name) { nil }
12
+ let(:nickname) { nil }
13
+
14
+ it "prints an error message" do
15
+ stderr_only 'Error: Expected "[Location Name]" "[Alias]"'
16
+ end
17
+ end
18
+
19
+ describe "when location name has no matches" do
20
+ let(:location_name) { "Garbage" }
21
+ let(:nickname) { "Big Apple Pie" }
22
+
23
+ it "prints an error message" do
24
+ stderr_only 'Error: No location found for "Garbage"'
25
+ end
26
+ end
27
+
28
+ describe "when location alias has more than one match" do
29
+ let(:location_name) { "'New York City'" }
30
+ let(:nickname) { "'Big Apple'" }
31
+ before do
32
+ run_cmd("add location Manhattan")
33
+ run_cmd("add alias Manhattan 'Big Apple'")
34
+ end
35
+
36
+ it "prints an error message" do
37
+ stderr_only "Error: The location alias "\
38
+ '"Big Apple" is already taken by "Manhattan (a.k.a. Big Apple)"'
39
+ end
40
+ end
41
+
42
+ describe "when location name has one match" do
43
+ let(:location_name) { "'New York City'" }
44
+
45
+ describe "when alias is blank" do
46
+ let(:nickname) { "' '" }
47
+
48
+ it "prints an error message" do
49
+ stderr_only "Error: Alias cannot be blank"
50
+ end
51
+ end
52
+
53
+ describe "when alias is nil" do
54
+ let(:nickname) { nil }
55
+
56
+ it "prints an error message" do
57
+ stderr_only "Error: Alias cannot be blank"
58
+ end
59
+ end
60
+
61
+ describe "when alias is not blank" do
62
+ let(:nickname) { "'Big Apple'" }
63
+
64
+ it "adds alias to location" do
65
+ line_changed "- New York City (a.k.a. NYC a.k.a. NY)",
66
+ "- New York City (a.k.a. NYC a.k.a. NY a.k.a. Big Apple)"
67
+ end
68
+
69
+ it "prints an output message" do
70
+ stdout_only 'Alias added: "New York City (a.k.a. NYC a.k.a. NY a.k.a. Big Apple)"'
71
+ end
72
+ end
73
+ end
74
+ end