friends 0.49 → 0.54

Sign up to get free protection for your applications and to get access to all the features.
@@ -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