railbow 0.6.0 → 0.7.0
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 +78 -0
- data/README.md +40 -16
- data/lib/railbow/about_formatter.rb +57 -21
- data/lib/railbow/calendar.rb +2 -4
- data/lib/railbow/clock.rb +72 -0
- data/lib/railbow/color_assigner.rb +6 -12
- data/lib/railbow/config.rb +1 -1
- data/lib/railbow/demo/fixtures.rb +4 -4
- data/lib/railbow/demo/status_demo.rb +5 -11
- data/lib/railbow/formatters/base.rb +34 -26
- data/lib/railbow/init.rb +6 -2
- data/lib/railbow/migration_formatter.rb +41 -25
- data/lib/railbow/migration_parser.rb +12 -19
- data/lib/railbow/multi_db.rb +15 -1
- data/lib/railbow/name_collision_resolver.rb +14 -27
- data/lib/railbow/name_formatter.rb +15 -16
- data/lib/railbow/notes_formatter.rb +42 -19
- data/lib/railbow/params.rb +33 -27
- data/lib/railbow/railtie.rb +8 -6
- data/lib/railbow/routes_formatter.rb +18 -6
- data/lib/railbow/stats_formatter.rb +139 -69
- data/lib/railbow/status/ghosts.rb +5 -6
- data/lib/railbow/status/git_data.rb +11 -18
- data/lib/railbow/status/help.rb +8 -2
- data/lib/railbow/status/printer.rb +20 -34
- data/lib/railbow/status/section.rb +29 -44
- data/lib/railbow/table/renderer.rb +32 -36
- data/lib/railbow/tasks/migrate_status.rake +33 -17
- data/lib/railbow/tasks/stats.rake +11 -75
- data/lib/railbow/version.rb +1 -1
- data/lib/railbow.rb +28 -4
- metadata +2 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "zlib"
|
|
4
|
+
require_relative "../clock"
|
|
4
5
|
require_relative "../text_utils"
|
|
5
6
|
|
|
6
7
|
module Railbow
|
|
@@ -34,6 +35,9 @@ module Railbow
|
|
|
34
35
|
|
|
35
36
|
BRIGHT_WHITE = "\e[1;97m"
|
|
36
37
|
|
|
38
|
+
# Narrowest name a tagged cell will accept before the tags are dropped.
|
|
39
|
+
NAME_TAGS_FLOOR = 12
|
|
40
|
+
|
|
37
41
|
def dim(str) = "#{DIM}#{str}#{RESET}"
|
|
38
42
|
def green(str) = "#{GREEN}#{str}#{RESET}"
|
|
39
43
|
def yellow(str) = "#{YELLOW}#{str}#{RESET}"
|
|
@@ -66,23 +70,20 @@ module Railbow
|
|
|
66
70
|
table_names.map { |t| table_tag(t) }.join(" ")
|
|
67
71
|
end
|
|
68
72
|
|
|
69
|
-
# Re-fits a
|
|
70
|
-
#
|
|
71
|
-
# Accepts the full formatted string (with ANSI) and splits it into segments.
|
|
73
|
+
# Re-fits a formatted table_tags string within max_width: as many whole
|
|
74
|
+
# tags as fit plus "+N" for the rest, then one truncated tag, then "+N".
|
|
72
75
|
def table_tags_fitted(formatted_str, max_width)
|
|
73
76
|
return formatted_str if formatted_str.nil? || formatted_str.empty?
|
|
74
77
|
|
|
75
|
-
#
|
|
78
|
+
# Each tag is "\e[38;5;NNNm● name\e[0m"
|
|
76
79
|
segments = formatted_str.scan(/\e\[38;5;\d+m● [^\e]+\e\[0m/)
|
|
77
80
|
return formatted_str if segments.empty?
|
|
78
81
|
|
|
79
82
|
total = segments.size
|
|
80
83
|
plain_segments = segments.map { |s| strip_ansi(s) }
|
|
81
84
|
|
|
82
|
-
# Try fitting all tags
|
|
83
85
|
return formatted_str if display_width(plain_segments.join(" ")) <= max_width
|
|
84
86
|
|
|
85
|
-
# Try fitting progressively fewer tags with +N suffix
|
|
86
87
|
(total - 1).downto(1) do |count|
|
|
87
88
|
remaining = total - count
|
|
88
89
|
suffix = " +#{remaining}"
|
|
@@ -92,17 +93,13 @@ module Railbow
|
|
|
92
93
|
end
|
|
93
94
|
end
|
|
94
95
|
|
|
95
|
-
#
|
|
96
|
-
# Need at least 7 chars: "● t… +N" (dot + color prefix use no visible width beyond the dot)
|
|
96
|
+
# A truncated first tag needs 7 columns at least: "● t… +N"
|
|
97
97
|
if max_width >= 7
|
|
98
98
|
suffix = (total > 1) ? " +#{total - 1}" : ""
|
|
99
|
-
# Available width for "● name…" part
|
|
100
99
|
avail = max_width - display_width(suffix)
|
|
101
|
-
# "● " prefix = 2 chars visible, plus at least 1 char of name + ellipsis (1 char)
|
|
102
100
|
if avail >= 4 # "● " (2) + at least 1 char + "…" (1)
|
|
103
101
|
first_plain = plain_segments[0] # e.g. "● some_table"
|
|
104
102
|
name_part = first_plain.sub(/^● /, "")
|
|
105
|
-
# Truncate name to fit: avail - 2 ("● ") - 1 ("…") = chars for name
|
|
106
103
|
name_max = avail - 2 - 1
|
|
107
104
|
truncated_name = name_part[0, name_max]
|
|
108
105
|
color_match = segments[0].match(/\e\[38;5;\d+m/)
|
|
@@ -111,10 +108,33 @@ module Railbow
|
|
|
111
108
|
end
|
|
112
109
|
end
|
|
113
110
|
|
|
114
|
-
# Absolute fallback: just +N
|
|
115
111
|
"+#{total}"
|
|
116
112
|
end
|
|
117
113
|
|
|
114
|
+
# Re-fits a Migration Name cell whose tags (branch, landed) sit flush
|
|
115
|
+
# right. Cutting from the right would eat the tags and leave a bare
|
|
116
|
+
# ellipsis, so the name gives up the space instead. Below NAME_TAGS_FLOOR
|
|
117
|
+
# the tags go: the name is what the row is about.
|
|
118
|
+
def name_with_tags_fitted(cell, max_width)
|
|
119
|
+
str = cell.to_s
|
|
120
|
+
return truncate_ansi(str, max_width) if display_width(strip_ansi(str)) <= max_width
|
|
121
|
+
|
|
122
|
+
split = str.rindex(/\s{2,}\e\[/)
|
|
123
|
+
return truncate_ansi(str, max_width) unless split
|
|
124
|
+
|
|
125
|
+
# rindex stops at the last two spaces of the padding run, so the rest of
|
|
126
|
+
# it still hangs off the name and would be truncated in place of it.
|
|
127
|
+
name = str[0...split].rstrip
|
|
128
|
+
tags = str[split..].lstrip
|
|
129
|
+
tags_width = display_width(strip_ansi(tags))
|
|
130
|
+
name_room = max_width - tags_width - 2
|
|
131
|
+
return truncate_ansi(name, max_width) if name_room < NAME_TAGS_FLOOR
|
|
132
|
+
|
|
133
|
+
name = truncate_ansi(name, name_room)
|
|
134
|
+
padding = max_width - display_width(strip_ansi(name)) - tags_width
|
|
135
|
+
"#{name}#{" " * [padding, 2].max}#{tags}"
|
|
136
|
+
end
|
|
137
|
+
|
|
118
138
|
def format_timing(seconds)
|
|
119
139
|
milliseconds = (seconds * 1000).round(1)
|
|
120
140
|
|
|
@@ -141,16 +161,8 @@ module Railbow
|
|
|
141
161
|
|
|
142
162
|
def format_date(timestamp, mode = "full")
|
|
143
163
|
ts = timestamp.to_s
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
time = begin
|
|
147
|
-
Time.new(
|
|
148
|
-
ts[0..3].to_i, ts[4..5].to_i, ts[6..7].to_i,
|
|
149
|
-
ts[8..9].to_i, ts[10..11].to_i, ts[12..13].to_i
|
|
150
|
-
)
|
|
151
|
-
rescue ArgumentError
|
|
152
|
-
return ts
|
|
153
|
-
end
|
|
164
|
+
time = Railbow::Clock.from_version(ts)
|
|
165
|
+
return ts unless time
|
|
154
166
|
|
|
155
167
|
case mode
|
|
156
168
|
when "full"
|
|
@@ -166,8 +178,6 @@ module Railbow
|
|
|
166
178
|
end
|
|
167
179
|
end
|
|
168
180
|
|
|
169
|
-
private
|
|
170
|
-
|
|
171
181
|
def format_relative_time_from(time)
|
|
172
182
|
diff = Time.now - time
|
|
173
183
|
return "just now" if diff < 0
|
|
@@ -188,8 +198,6 @@ module Railbow
|
|
|
188
198
|
else "~#{years}y ago"
|
|
189
199
|
end
|
|
190
200
|
end
|
|
191
|
-
|
|
192
|
-
public
|
|
193
201
|
end
|
|
194
202
|
end
|
|
195
203
|
end
|
data/lib/railbow/init.rb
CHANGED
|
@@ -20,7 +20,7 @@ module Railbow
|
|
|
20
20
|
# Every key can also be overridden via RBW_* environment variables.
|
|
21
21
|
|
|
22
22
|
# Time window for migrations to display (e.g. 30d, 2mo, 1y, all)
|
|
23
|
-
since: "
|
|
23
|
+
since: "90d"
|
|
24
24
|
|
|
25
25
|
# Never show fewer than this many migrations, however old they are.
|
|
26
26
|
# The window above is a soft limit: a database with five migrations shows
|
|
@@ -37,7 +37,7 @@ module Railbow
|
|
|
37
37
|
# diff - tag migrations by git origin branch
|
|
38
38
|
# base:<branch> - base branch for diff (default: auto-detected)
|
|
39
39
|
# mask:auto - auto-extract ticket id from branch name
|
|
40
|
-
# mask:<re> - custom regex to extract branch label, e.g. mask:(
|
|
40
|
+
# mask:<re> - custom regex to extract branch label, e.g. mask:(PS-[^/]+)/
|
|
41
41
|
git: "author:all,diff,mask:auto"
|
|
42
42
|
|
|
43
43
|
# Author display format - preset or custom pattern (FF L, FFF LL, etc.):
|
|
@@ -60,6 +60,10 @@ module Railbow
|
|
|
60
60
|
# Date format: full, rel, short, or custom(%b %d, %Y)
|
|
61
61
|
# date: "full"
|
|
62
62
|
|
|
63
|
+
# Timezone dates are shown in: local (this machine), utc, or any zone
|
|
64
|
+
# Rails knows, e.g. America/New_York or "Eastern Time (US & Canada)"
|
|
65
|
+
# tz: "local"
|
|
66
|
+
|
|
63
67
|
# Multi-database runs (comma-separated). Leave empty ("") to expand every
|
|
64
68
|
# database into its own section, in database.yml order; a database with
|
|
65
69
|
# nothing in the time window collapses to a one-line summary either way.
|
|
@@ -10,30 +10,12 @@ module Railbow
|
|
|
10
10
|
def announce(message)
|
|
11
11
|
return super if Railbow.plain?
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
migration_name = self.class.name&.demodulize || "Migration"
|
|
15
|
-
|
|
16
|
-
line = case message
|
|
17
|
-
when /migrating/i
|
|
18
|
-
f.cyan("#{f.emoji(:migrating)} #{migration_name}: migrating...")
|
|
19
|
-
when /migrated\s*\((\d+\.\d+)s\)/i
|
|
20
|
-
raw_seconds = Regexp.last_match(1).to_f
|
|
21
|
-
formatted = f.format_timing(raw_seconds)
|
|
22
|
-
f.green("#{f.emoji(:migrated)} #{migration_name}: migrated") + " (#{formatted} total)"
|
|
23
|
-
when /reverting/i
|
|
24
|
-
f.yellow("#{f.emoji(:reverting)} #{migration_name}: reverting...")
|
|
25
|
-
when /reverted\s*\((\d+\.\d+)s\)/i
|
|
26
|
-
raw_seconds = Regexp.last_match(1).to_f
|
|
27
|
-
formatted = f.format_timing(raw_seconds)
|
|
28
|
-
f.green("#{f.emoji(:reverted)} #{migration_name}: reverted") + " (#{formatted} total)"
|
|
29
|
-
else
|
|
30
|
-
"#{migration_name}: #{message}"
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
write ""
|
|
34
|
-
write line
|
|
13
|
+
Railbow.guard(-> { super }) { railbow_announce(message) }
|
|
35
14
|
end
|
|
36
15
|
|
|
16
|
+
# The yield runs the migration itself, so it stays outside the guard: a
|
|
17
|
+
# failing migration must raise as usual, and a formatting error must not
|
|
18
|
+
# re-run it through super. The fallback prints what Rails would have.
|
|
37
19
|
def say_with_time(message)
|
|
38
20
|
return super { yield } if Railbow.plain?
|
|
39
21
|
|
|
@@ -41,9 +23,16 @@ module Railbow
|
|
|
41
23
|
result = nil
|
|
42
24
|
time = Benchmark.measure { result = yield }
|
|
43
25
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
26
|
+
plain = lambda do
|
|
27
|
+
say(message)
|
|
28
|
+
say("%.4fs" % time.real, :subitem)
|
|
29
|
+
say("#{result} rows", :subitem) if result.is_a?(Integer)
|
|
30
|
+
end
|
|
31
|
+
Railbow.guard(plain) do
|
|
32
|
+
timing = f.format_timing(time.real)
|
|
33
|
+
write " #{f.green(f.emoji(:check))} #{message} → #{timing}"
|
|
34
|
+
say("#{result} rows", :subitem) if result.is_a?(Integer)
|
|
35
|
+
end
|
|
47
36
|
|
|
48
37
|
result
|
|
49
38
|
end
|
|
@@ -60,5 +49,32 @@ module Railbow
|
|
|
60
49
|
|
|
61
50
|
puts text
|
|
62
51
|
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def railbow_announce(message)
|
|
56
|
+
f = FORMATTER
|
|
57
|
+
migration_name = self.class.name&.demodulize || "Migration"
|
|
58
|
+
|
|
59
|
+
line = case message
|
|
60
|
+
when /migrating/i
|
|
61
|
+
f.cyan("#{f.emoji(:migrating)} #{migration_name}: migrating...")
|
|
62
|
+
when /migrated\s*\((\d+\.\d+)s\)/i
|
|
63
|
+
raw_seconds = Regexp.last_match(1).to_f
|
|
64
|
+
formatted = f.format_timing(raw_seconds)
|
|
65
|
+
f.green("#{f.emoji(:migrated)} #{migration_name}: migrated") + " (#{formatted} total)"
|
|
66
|
+
when /reverting/i
|
|
67
|
+
f.yellow("#{f.emoji(:reverting)} #{migration_name}: reverting...")
|
|
68
|
+
when /reverted\s*\((\d+\.\d+)s\)/i
|
|
69
|
+
raw_seconds = Regexp.last_match(1).to_f
|
|
70
|
+
formatted = f.format_timing(raw_seconds)
|
|
71
|
+
f.green("#{f.emoji(:reverted)} #{migration_name}: reverted") + " (#{formatted} total)"
|
|
72
|
+
else
|
|
73
|
+
"#{migration_name}: #{message}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
write ""
|
|
77
|
+
write line
|
|
78
|
+
end
|
|
63
79
|
end
|
|
64
80
|
end
|
|
@@ -4,7 +4,6 @@ require "active_support/inflector"
|
|
|
4
4
|
|
|
5
5
|
module Railbow
|
|
6
6
|
class MigrationParser
|
|
7
|
-
# Methods that take a single table name as the first argument
|
|
8
7
|
SINGLE_TABLE_METHODS = %w[
|
|
9
8
|
create_table drop_table change_table
|
|
10
9
|
add_column remove_column rename_column change_column
|
|
@@ -15,7 +14,6 @@ module Railbow
|
|
|
15
14
|
add_timestamps remove_timestamps
|
|
16
15
|
].freeze
|
|
17
16
|
|
|
18
|
-
# Methods that take two table names as arguments
|
|
19
17
|
DUAL_TABLE_METHODS = %w[
|
|
20
18
|
add_foreign_key remove_foreign_key
|
|
21
19
|
].freeze
|
|
@@ -25,7 +23,6 @@ module Railbow
|
|
|
25
23
|
SINGLE_TABLE_PATTERN = /\b(?:#{(SINGLE_TABLE_METHODS + DUAL_TABLE_METHODS).join("|")})\s+[:"](\w+)/
|
|
26
24
|
DUAL_TABLE_PATTERN = /\b(?:#{DUAL_TABLE_METHODS.join("|")})\s+[:"](\w+)["\s,]+[:"](\w+)/
|
|
27
25
|
|
|
28
|
-
# SQL keywords followed by a table name
|
|
29
26
|
SQL_TABLE_PATTERNS = [
|
|
30
27
|
/\bUPDATE\s+["']?(\w+)["']?/i,
|
|
31
28
|
/\bINSERT\s+INTO\s+["']?(\w+)["']?/i,
|
|
@@ -36,21 +33,19 @@ module Railbow
|
|
|
36
33
|
/\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i
|
|
37
34
|
].freeze
|
|
38
35
|
|
|
39
|
-
#
|
|
40
|
-
# `convert_to_monthly_partitions(table: :apples)`
|
|
41
|
-
# `
|
|
42
|
-
# migration helpers regardless of the method name. Bare `from:`/`to:` are
|
|
43
|
-
# deliberately not matched — `change_column_default :users, :status,
|
|
36
|
+
# A table named by keyword argument, which catches project-level helpers
|
|
37
|
+
# such as `convert_to_monthly_partitions(table: :apples)` whatever their
|
|
38
|
+
# name. Not bare `from:`/`to:`: `change_column_default :users, :status,
|
|
44
39
|
# from: nil, to: "active"` would yield phantom tables.
|
|
45
40
|
KEYWORD_TABLE_PATTERN = /\b(?:table|to_table|from_table):\s*[:"']?(\w+)/
|
|
46
41
|
|
|
47
42
|
# Ruby `#` comments up to end of line, but not `#{}` interpolation.
|
|
48
43
|
COMMENT_PATTERN = /#(?!\{).*/
|
|
49
44
|
|
|
50
|
-
# AR class
|
|
51
|
-
#
|
|
52
|
-
# `
|
|
53
|
-
#
|
|
45
|
+
# AR class methods that strongly signal a constant is a model. Kept narrow:
|
|
46
|
+
# `find`, `all`, `first`, `count`, `create`, `new`, `transaction`, `order`,
|
|
47
|
+
# `includes` also exist on non-model constants (Time.new, File.find) and
|
|
48
|
+
# would produce false positives.
|
|
54
49
|
MODEL_AR_METHODS = %w[
|
|
55
50
|
find_each find_in_batches
|
|
56
51
|
find_by find_by!
|
|
@@ -74,9 +69,8 @@ module Railbow
|
|
|
74
69
|
def self.extract_tables_from_content(content)
|
|
75
70
|
return [] if content.nil? || content.empty?
|
|
76
71
|
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
# like the real thing to the patterns below.
|
|
72
|
+
# An explanatory "# ALTER TABLE foo ..." or a commented-out create_table
|
|
73
|
+
# reads exactly like the real thing to the patterns below.
|
|
80
74
|
content = content.gsub(COMMENT_PATTERN, "")
|
|
81
75
|
|
|
82
76
|
tables = []
|
|
@@ -89,10 +83,9 @@ module Railbow
|
|
|
89
83
|
content.scan(KEYWORD_TABLE_PATTERN) { |match| tables << match[0] }
|
|
90
84
|
tables.uniq!
|
|
91
85
|
|
|
92
|
-
# Model
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
# tables, and cap the final total at 2.
|
|
86
|
+
# Model references are a guess (a constant answering these methods need
|
|
87
|
+
# not be a model), so they only fill in when DDL and SQL found fewer
|
|
88
|
+
# than 2 tables, and never take the total past 2.
|
|
96
89
|
if tables.size < 2
|
|
97
90
|
content.scan(MODEL_REFERENCE_PATTERN) do |match|
|
|
98
91
|
break if tables.size >= 2
|
data/lib/railbow/multi_db.rb
CHANGED
|
@@ -51,10 +51,24 @@ module Railbow
|
|
|
51
51
|
def flush
|
|
52
52
|
return if empty?
|
|
53
53
|
|
|
54
|
-
Railbow
|
|
54
|
+
if Railbow.plain?
|
|
55
|
+
print_fallback
|
|
56
|
+
else
|
|
57
|
+
Railbow.guard(-> { print_fallback }) do
|
|
58
|
+
Railbow::Status::Printer.new(@sections, skipped: @skipped).print
|
|
59
|
+
end
|
|
60
|
+
end
|
|
55
61
|
@sections = []
|
|
56
62
|
@skipped = []
|
|
57
63
|
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# The stock Rails tables captured per database while their pools were
|
|
68
|
+
# still open; see MigrateStatusFormatter#migrate_status.
|
|
69
|
+
def print_fallback
|
|
70
|
+
@sections.each { |section| $stdout.write(section.fallback_output.to_s) }
|
|
71
|
+
end
|
|
58
72
|
end
|
|
59
73
|
|
|
60
74
|
module_function
|
|
@@ -3,13 +3,9 @@
|
|
|
3
3
|
require_relative "name_formatter"
|
|
4
4
|
|
|
5
5
|
module Railbow
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# unique formatted string.
|
|
10
|
-
#
|
|
11
|
-
# Names that share the same first+last name but differ only in middle name
|
|
12
|
-
# are treated as the same person and merged before collision detection.
|
|
6
|
+
# Makes distinct names format distinctly by widening pattern tokens (F to
|
|
7
|
+
# FFFF, then L) until no two collide. Names differing only in the middle
|
|
8
|
+
# name count as one person and share an output.
|
|
13
9
|
#
|
|
14
10
|
# NameCollisionResolver.resolve(["John Doe", "Jane Doe"], "F L")
|
|
15
11
|
# # => {"John Doe" => "Jo D", "Jane Doe" => "Ja D"}
|
|
@@ -20,19 +16,16 @@ module Railbow
|
|
|
20
16
|
|
|
21
17
|
# @param names [Array<String, nil>] raw author names (may contain duplicates/nils)
|
|
22
18
|
# @param pattern [String] a NameFormatter pattern or preset name
|
|
23
|
-
# @return [Hash{String => String}]
|
|
19
|
+
# @return [Hash{String => String}] raw name to disambiguated formatted name
|
|
24
20
|
def resolve(names, pattern)
|
|
25
21
|
pattern = NameFormatter::PRESETS.fetch(pattern, pattern)
|
|
26
22
|
uniq_names = names.compact.reject(&:empty?).uniq
|
|
27
23
|
|
|
28
|
-
# Merge names that are the same person (same first+last, differ only in middle)
|
|
29
24
|
canonical, aliases = merge_same_person(uniq_names)
|
|
30
25
|
|
|
31
|
-
# Format every canonical name with the base pattern
|
|
32
26
|
result = {}
|
|
33
27
|
canonical.each { |n| result[n] = NameFormatter.format(n, pattern) }
|
|
34
28
|
|
|
35
|
-
# Find collision groups (different raw names → same formatted output)
|
|
36
29
|
collisions = find_collisions(result)
|
|
37
30
|
unless collisions.empty?
|
|
38
31
|
tokens = parse_tokens(pattern)
|
|
@@ -41,16 +34,14 @@ module Railbow
|
|
|
41
34
|
end
|
|
42
35
|
end
|
|
43
36
|
|
|
44
|
-
# Expand aliases: all variant spellings get the same formatted output
|
|
45
37
|
aliases.each { |variant, canon| result[variant] = result[canon] }
|
|
46
38
|
|
|
47
39
|
result
|
|
48
40
|
end
|
|
49
41
|
|
|
50
|
-
#
|
|
51
|
-
|
|
52
|
-
#
|
|
53
|
-
# Returns [canonical_names, aliases_hash] where aliases maps variant → canonical.
|
|
42
|
+
# Groups names by first and last name; the shortest (no middle name) is
|
|
43
|
+
# canonical. Returns [canonical_names, aliases], aliases mapping each
|
|
44
|
+
# variant to its canonical name.
|
|
54
45
|
def merge_same_person(names)
|
|
55
46
|
groups = {}
|
|
56
47
|
names.each do |name|
|
|
@@ -66,7 +57,6 @@ module Railbow
|
|
|
66
57
|
canonical = []
|
|
67
58
|
aliases = {}
|
|
68
59
|
groups.each_value do |group|
|
|
69
|
-
# Pick shortest name as canonical (the one without middle name)
|
|
70
60
|
canon = group.min_by(&:length)
|
|
71
61
|
canonical << canon
|
|
72
62
|
group.each { |name| aliases[name] = canon unless name == canon }
|
|
@@ -81,14 +71,12 @@ module Railbow
|
|
|
81
71
|
.transform_values { |pairs| pairs.map(&:first) }
|
|
82
72
|
end
|
|
83
73
|
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
# becomes unique it keeps its current (minimal) expansion.
|
|
74
|
+
# Widens tokens, all F tokens before L, only for names that still collide:
|
|
75
|
+
# a name that becomes unique keeps its shortest distinct form.
|
|
87
76
|
def resolve_group(raw_names, base_pattern, base_tokens, result)
|
|
88
77
|
remaining = raw_names.dup
|
|
89
78
|
tokens = base_tokens.map(&:dup)
|
|
90
79
|
|
|
91
|
-
# Expansion order: exhaust all F tokens first, then L tokens
|
|
92
80
|
expansion_order = tokens.each_index.sort_by { |i| (tokens[i][:type] == "F") ? 0 : 1 }
|
|
93
81
|
|
|
94
82
|
expansion_order.each do |ti|
|
|
@@ -102,15 +90,14 @@ module Railbow
|
|
|
102
90
|
|
|
103
91
|
remaining.each { |n| result[n] = NameFormatter.format(n, current_pattern) }
|
|
104
92
|
|
|
105
|
-
#
|
|
106
|
-
#
|
|
93
|
+
# Within this group only, so one group's expansion never chases
|
|
94
|
+
# collisions with another's.
|
|
107
95
|
still_colliding = find_collisions(result.slice(*raw_names))
|
|
108
96
|
if still_colliding.empty?
|
|
109
97
|
remaining = []
|
|
110
98
|
break
|
|
111
99
|
end
|
|
112
100
|
|
|
113
|
-
# Only keep expanding names that still collide within the group
|
|
114
101
|
remaining = still_colliding.values.flatten
|
|
115
102
|
end
|
|
116
103
|
|
|
@@ -119,7 +106,7 @@ module Railbow
|
|
|
119
106
|
|
|
120
107
|
return if remaining.empty?
|
|
121
108
|
|
|
122
|
-
#
|
|
109
|
+
# Fully expanded and still equal: number them.
|
|
123
110
|
find_collisions(result.slice(*remaining)).each_value do |group|
|
|
124
111
|
group[1..].each_with_index do |name, i|
|
|
125
112
|
suffix = SUPERSCRIPTS[i] || (i + 2).to_s
|
|
@@ -128,7 +115,7 @@ module Railbow
|
|
|
128
115
|
end
|
|
129
116
|
end
|
|
130
117
|
|
|
131
|
-
#
|
|
118
|
+
# [{type:, length:, maxed:}, ...] for each token in the pattern.
|
|
132
119
|
def parse_tokens(pattern)
|
|
133
120
|
tokens = []
|
|
134
121
|
pattern.scan(NameFormatter::TOKEN_RE) do |match|
|
|
@@ -139,7 +126,7 @@ module Railbow
|
|
|
139
126
|
tokens
|
|
140
127
|
end
|
|
141
128
|
|
|
142
|
-
#
|
|
129
|
+
# The original pattern with its tokens resized, literals kept.
|
|
143
130
|
def rebuild_pattern(tokens, original_pattern)
|
|
144
131
|
ti = 0
|
|
145
132
|
original_pattern.gsub(NameFormatter::TOKEN_RE) do
|
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Railbow
|
|
4
|
-
# Formats person names
|
|
4
|
+
# Formats person names with a pattern language modeled on date formats.
|
|
5
5
|
#
|
|
6
|
-
# Tokens (
|
|
7
|
-
# F / FF / FFF / FFFF+
|
|
8
|
-
# L / LL / LLL / LLLL+
|
|
9
|
-
# M / MM / MMM / MMMM+
|
|
6
|
+
# Tokens (the letter count is the length; 4 or more means the full word):
|
|
7
|
+
# F / FF / FFF / FFFF+ first name (1 char, 2 chars, 3 chars, full)
|
|
8
|
+
# L / LL / LLL / LLLL+ last name
|
|
9
|
+
# M / MM / MMM / MMMM+ middle name(s)
|
|
10
10
|
#
|
|
11
11
|
# Any other characters (spaces, dots, commas) are literals and pass through.
|
|
12
12
|
#
|
|
13
13
|
# Named presets:
|
|
14
|
-
# "initials"
|
|
15
|
-
# "short"
|
|
16
|
-
# "first_name"
|
|
17
|
-
# "last_name"
|
|
18
|
-
# "full_name"
|
|
19
|
-
# "full_name_short"
|
|
14
|
+
# "initials" => "F L"
|
|
15
|
+
# "short" => "FF L"
|
|
16
|
+
# "first_name" => "FFFF"
|
|
17
|
+
# "last_name" => "LLLL"
|
|
18
|
+
# "full_name" => "FFFF MMMM LLLL"
|
|
19
|
+
# "full_name_short" => "FFFF L."
|
|
20
20
|
module NameFormatter
|
|
21
21
|
PRESETS = {
|
|
22
22
|
"initials" => "F L",
|
|
@@ -34,8 +34,7 @@ module Railbow
|
|
|
34
34
|
|
|
35
35
|
module_function
|
|
36
36
|
|
|
37
|
-
#
|
|
38
|
-
# Returns "" for nil/empty input.
|
|
37
|
+
# Formats a name by a pattern or preset name. "" for a nil or empty name.
|
|
39
38
|
def format(name, pattern)
|
|
40
39
|
return "" if name.nil? || name.empty?
|
|
41
40
|
|
|
@@ -59,14 +58,14 @@ module Railbow
|
|
|
59
58
|
expanded.empty? ? EMPTY : expanded
|
|
60
59
|
end
|
|
61
60
|
|
|
62
|
-
#
|
|
61
|
+
# A missing part takes its adjacent literals with it, so "F L." on a
|
|
62
|
+
# one-word name gives no stray dot.
|
|
63
63
|
result.gsub(/[^a-zA-Z\x00]*\x00[^a-zA-Z\x00]*/, " ")
|
|
64
64
|
.gsub(/ +/, " ")
|
|
65
65
|
.strip
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
#
|
|
69
|
-
# for full words, capitalizing otherwise.
|
|
68
|
+
# A full word keeps its case; a cut one is capitalized.
|
|
70
69
|
def truncate(word, len)
|
|
71
70
|
return "" if word.nil? || word.empty?
|
|
72
71
|
|