railbow 0.1.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 +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE.txt +21 -0
- data/README.md +243 -0
- data/exe/railbow +110 -0
- data/lib/railbow/about_formatter.rb +37 -0
- data/lib/railbow/color_assigner.rb +53 -0
- data/lib/railbow/config.rb +113 -0
- data/lib/railbow/demo/fixtures.rb +121 -0
- data/lib/railbow/demo/migrate_demo.rb +35 -0
- data/lib/railbow/demo/routes_demo.rb +94 -0
- data/lib/railbow/demo/runner.rb +51 -0
- data/lib/railbow/demo/status_demo.rb +164 -0
- data/lib/railbow/formatters/base.rb +195 -0
- data/lib/railbow/git_utils.rb +29 -0
- data/lib/railbow/init.rb +112 -0
- data/lib/railbow/logo.rb +27 -0
- data/lib/railbow/migration_formatter.rb +64 -0
- data/lib/railbow/migration_parser.rb +87 -0
- data/lib/railbow/name_collision_resolver.rb +155 -0
- data/lib/railbow/name_formatter.rb +82 -0
- data/lib/railbow/notes_formatter.rb +311 -0
- data/lib/railbow/params.rb +229 -0
- data/lib/railbow/railtie.rb +44 -0
- data/lib/railbow/routes_formatter.rb +163 -0
- data/lib/railbow/stats_formatter.rb +99 -0
- data/lib/railbow/table/column.rb +24 -0
- data/lib/railbow/table/renderer.rb +441 -0
- data/lib/railbow/table/theme.rb +54 -0
- data/lib/railbow/table.rb +5 -0
- data/lib/railbow/tasks/init.rake +10 -0
- data/lib/railbow/tasks/migrate_status.rake +694 -0
- data/lib/railbow/tasks/stats.rake +85 -0
- data/lib/railbow/text_utils.rb +30 -0
- data/lib/railbow/version.rb +5 -0
- data/lib/railbow.rb +22 -0
- data/sig/railbow.rbs +4 -0
- metadata +137 -0
data/lib/railbow/init.rb
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Railbow
|
|
7
|
+
module Init
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
TEMPLATE = <<~YAML
|
|
11
|
+
# Railbow configuration
|
|
12
|
+
# https://github.com/amberpixels/railbow
|
|
13
|
+
#
|
|
14
|
+
# Config layers (each overrides the previous):
|
|
15
|
+
# 1. Gem defaults (built-in)
|
|
16
|
+
# 2. Global: ~/.config/railbow/config.yml
|
|
17
|
+
# 3. Project: .railbow.yml (commit to git)
|
|
18
|
+
# 4. Local: .railbow.local.yml (gitignored, personal overrides)
|
|
19
|
+
#
|
|
20
|
+
# Every key can also be overridden via RBW_* environment variables.
|
|
21
|
+
|
|
22
|
+
# Time window for migrations to display (e.g. 30d, 2mo, 1y, all)
|
|
23
|
+
since: "70d"
|
|
24
|
+
|
|
25
|
+
# Sort order: file (by filename/version) or date (by timestamp)
|
|
26
|
+
# sort: "file"
|
|
27
|
+
|
|
28
|
+
# Git integration (comma-separated compound value):
|
|
29
|
+
# author — add Author column (same as author:all)
|
|
30
|
+
# author:me — highlight your own migrations
|
|
31
|
+
# author:all — show all authors
|
|
32
|
+
# diff — tag migrations by git origin branch
|
|
33
|
+
# base:<branch> — base branch for diff (default: auto-detected)
|
|
34
|
+
# mask:auto — auto-extract ticket id from branch name
|
|
35
|
+
# mask:<re> — custom regex to extract branch label, e.g. mask:(WS-[^/]+)/
|
|
36
|
+
git: "author:all,diff,mask:auto"
|
|
37
|
+
|
|
38
|
+
# Author display format — preset or custom pattern (FF L, FFF LL, etc.):
|
|
39
|
+
# initials (J D), short (Jo D), first_name (John), last_name (Doe),
|
|
40
|
+
# full_name (John Doe), full_name_short (John D.)
|
|
41
|
+
author_format: "short"
|
|
42
|
+
|
|
43
|
+
# View mode (comma-separated): calendar, tables
|
|
44
|
+
view: "calendar,tables"
|
|
45
|
+
|
|
46
|
+
# Calendar options: wticks (show week tick separators)
|
|
47
|
+
calendar: "wticks"
|
|
48
|
+
|
|
49
|
+
# Date format: full, rel, short, or custom(%b %d, %Y)
|
|
50
|
+
# date: "full"
|
|
51
|
+
|
|
52
|
+
# Compact mode (comma-separated):
|
|
53
|
+
# oneline — one line per migration
|
|
54
|
+
# dense — reduce padding
|
|
55
|
+
# noheader — hide table headers
|
|
56
|
+
# maxw:<N> — max column width
|
|
57
|
+
# hide:<col> — hide a column (repeatable)
|
|
58
|
+
# compact: ""
|
|
59
|
+
|
|
60
|
+
# Rename column headers and cell values in table output
|
|
61
|
+
aliases:
|
|
62
|
+
columns:
|
|
63
|
+
Status: Live
|
|
64
|
+
values:
|
|
65
|
+
Status:
|
|
66
|
+
up: "↑↑"
|
|
67
|
+
down: "↓↓"
|
|
68
|
+
# Verb:
|
|
69
|
+
# GET: G
|
|
70
|
+
# POST: P
|
|
71
|
+
YAML
|
|
72
|
+
|
|
73
|
+
def global_path
|
|
74
|
+
File.join(Config.global_dir, "config.yml")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def project_path
|
|
78
|
+
File.join(Config.root, ".railbow.yml")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def run(input: $stdin, output: $stdout)
|
|
82
|
+
output.puts " Where should the config be created?"
|
|
83
|
+
output.puts ""
|
|
84
|
+
output.puts " 1) Global: #{global_path}"
|
|
85
|
+
output.puts " 2) Project: #{project_path}"
|
|
86
|
+
output.puts " 3) Cancel"
|
|
87
|
+
output.puts ""
|
|
88
|
+
output.print " Choose [1/2/3]: "
|
|
89
|
+
output.flush
|
|
90
|
+
|
|
91
|
+
choice = input.gets&.strip
|
|
92
|
+
target = case choice
|
|
93
|
+
when "1" then global_path
|
|
94
|
+
when "2" then project_path
|
|
95
|
+
else
|
|
96
|
+
output.puts " Cancelled."
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
if File.exist?(target)
|
|
101
|
+
output.puts " Already exists: #{target}"
|
|
102
|
+
output.puts " Remove it first if you want to regenerate."
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
107
|
+
File.write(target, TEMPLATE)
|
|
108
|
+
output.puts ""
|
|
109
|
+
output.puts " Created: #{target}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/railbow/logo.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railbow
|
|
4
|
+
LOGO_SEGMENTS = [
|
|
5
|
+
["░█▀▀█", "░█▀▀█", "▀█▀", "░█───", "░█▀▀█", "░█▀▀▀█", "░█───░█"],
|
|
6
|
+
["░█▄▄▀", "░█▄▄█", "░█─", "░█───", "░█▀▀▄", "░█──░█", "░█─█─░█"],
|
|
7
|
+
["░█─░█", "░█─░█", "▄█▄", "░█▄▄█", "░█▄▄█", "░█▄▄▄█", "─░█░█─"]
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
LOGO_COLORS = [
|
|
11
|
+
"\e[38;5;196m", "\e[38;5;208m", "\e[38;5;220m",
|
|
12
|
+
"\e[38;5;40m", "\e[38;5;33m", "\e[38;5;93m", "\e[38;5;163m"
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
RESET = "\e[0m"
|
|
16
|
+
|
|
17
|
+
def self.print_logo
|
|
18
|
+
LOGO_SEGMENTS.each do |segments|
|
|
19
|
+
print " "
|
|
20
|
+
segments.each_with_index do |seg, i|
|
|
21
|
+
print "#{LOGO_COLORS[i]}#{seg}#{RESET}"
|
|
22
|
+
print " " unless i == segments.length - 1
|
|
23
|
+
end
|
|
24
|
+
puts
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "benchmark"
|
|
4
|
+
require_relative "formatters/base"
|
|
5
|
+
|
|
6
|
+
module Railbow
|
|
7
|
+
module MigrationFormatter
|
|
8
|
+
FORMATTER = Formatters::Base.new.freeze
|
|
9
|
+
|
|
10
|
+
def announce(message)
|
|
11
|
+
return super if Railbow.plain?
|
|
12
|
+
|
|
13
|
+
f = FORMATTER
|
|
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
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def say_with_time(message)
|
|
38
|
+
return super { yield } if Railbow.plain?
|
|
39
|
+
|
|
40
|
+
f = FORMATTER
|
|
41
|
+
result = nil
|
|
42
|
+
time = Benchmark.measure { result = yield }
|
|
43
|
+
|
|
44
|
+
timing = f.format_timing(time.real)
|
|
45
|
+
write " #{f.green(f.emoji(:check))} #{message} → #{timing}"
|
|
46
|
+
say("#{result} rows", :subitem) if result.is_a?(Integer)
|
|
47
|
+
|
|
48
|
+
result
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def say(message, subitem = false)
|
|
52
|
+
return super if Railbow.plain?
|
|
53
|
+
|
|
54
|
+
prefix = subitem ? " " : " "
|
|
55
|
+
write("#{prefix}#{message}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def write(text = "")
|
|
59
|
+
return super if Railbow.plain?
|
|
60
|
+
|
|
61
|
+
puts text
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/inflector"
|
|
4
|
+
|
|
5
|
+
module Railbow
|
|
6
|
+
class MigrationParser
|
|
7
|
+
# Methods that take a single table name as the first argument
|
|
8
|
+
SINGLE_TABLE_METHODS = %w[
|
|
9
|
+
create_table drop_table change_table
|
|
10
|
+
add_column remove_column rename_column change_column
|
|
11
|
+
add_index remove_index
|
|
12
|
+
add_reference remove_reference
|
|
13
|
+
add_belongs_to remove_belongs_to
|
|
14
|
+
add_timestamps remove_timestamps
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
# Methods that take two table names as arguments
|
|
18
|
+
DUAL_TABLE_METHODS = %w[
|
|
19
|
+
add_foreign_key remove_foreign_key
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
SINGLE_TABLE_PATTERN = /\b(?:#{SINGLE_TABLE_METHODS.join("|")})\s+[:"](\w+)/
|
|
23
|
+
DUAL_TABLE_PATTERN = /\b(?:#{DUAL_TABLE_METHODS.join("|")})\s+[:"](\w+)["\s,]+[:"](\w+)/
|
|
24
|
+
|
|
25
|
+
# SQL keywords followed by a table name
|
|
26
|
+
SQL_TABLE_PATTERNS = [
|
|
27
|
+
/\bUPDATE\s+["']?(\w+)["']?/i,
|
|
28
|
+
/\bINSERT\s+INTO\s+["']?(\w+)["']?/i,
|
|
29
|
+
/\bDELETE\s+FROM\s+["']?(\w+)["']?/i,
|
|
30
|
+
/\bALTER\s+TABLE\s+["']?(\w+)["']?/i,
|
|
31
|
+
/\bTRUNCATE\s+(?:TABLE\s+)?["']?(\w+)["']?/i,
|
|
32
|
+
/\bDROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["']?(\w+)["']?/i,
|
|
33
|
+
/\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
# AR class-level query/mutation methods that strongly signal a constant is a model.
|
|
37
|
+
# Kept narrow on purpose — methods like `find`, `all`, `first`, `count`, `create`,
|
|
38
|
+
# `new`, `transaction`, `order`, `includes` collide with stdlib/non-model constants
|
|
39
|
+
# (e.g. Time.new, File.find, Array#first) and would produce false positives.
|
|
40
|
+
MODEL_AR_METHODS = %w[
|
|
41
|
+
find_each find_in_batches
|
|
42
|
+
find_by find_by!
|
|
43
|
+
find_or_create_by find_or_create_by!
|
|
44
|
+
find_or_initialize_by
|
|
45
|
+
where update_all delete_all destroy_all
|
|
46
|
+
upsert_all insert_all
|
|
47
|
+
pluck pick
|
|
48
|
+
in_batches
|
|
49
|
+
].freeze
|
|
50
|
+
|
|
51
|
+
MODEL_REFERENCE_PATTERN = /\b([A-Z][A-Za-z0-9]*(?:::[A-Z][A-Za-z0-9]*)*)\.(?:#{MODEL_AR_METHODS.join("|")})\b/
|
|
52
|
+
|
|
53
|
+
def self.extract_tables(filepath)
|
|
54
|
+
return [] if filepath.nil? || filepath.empty?
|
|
55
|
+
return [] unless File.exist?(filepath)
|
|
56
|
+
|
|
57
|
+
extract_tables_from_content(File.read(filepath))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.extract_tables_from_content(content)
|
|
61
|
+
return [] if content.nil? || content.empty?
|
|
62
|
+
|
|
63
|
+
tables = []
|
|
64
|
+
|
|
65
|
+
content.scan(SINGLE_TABLE_PATTERN) { |match| tables << match[0] }
|
|
66
|
+
content.scan(DUAL_TABLE_PATTERN) { |match| tables.concat(match) }
|
|
67
|
+
SQL_TABLE_PATTERNS.each do |pattern|
|
|
68
|
+
content.scan(pattern) { |match| tables << match[0] }
|
|
69
|
+
end
|
|
70
|
+
tables.uniq!
|
|
71
|
+
|
|
72
|
+
# Model-based detection is heuristic (a constant that happens to respond
|
|
73
|
+
# to one of these methods isn't necessarily an AR model). Only use it to
|
|
74
|
+
# supplement sparse DDL/SQL findings — skip once we already know of ≥2
|
|
75
|
+
# tables, and cap the final total at 2.
|
|
76
|
+
if tables.size < 2
|
|
77
|
+
content.scan(MODEL_REFERENCE_PATTERN) do |match|
|
|
78
|
+
break if tables.size >= 2
|
|
79
|
+
table = ActiveSupport::Inflector.tableize(match[0].split("::").last)
|
|
80
|
+
tables << table unless tables.include?(table)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
tables
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "name_formatter"
|
|
4
|
+
|
|
5
|
+
module Railbow
|
|
6
|
+
# Detects and resolves collisions when multiple different names produce the
|
|
7
|
+
# same formatted output. Works by progressively expanding pattern tokens
|
|
8
|
+
# (F → FF → FFF → FFFF, then L) until every distinct raw name maps to a
|
|
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.
|
|
13
|
+
#
|
|
14
|
+
# NameCollisionResolver.resolve(["John Doe", "Jane Doe"], "F L")
|
|
15
|
+
# # => {"John Doe" => "Jo D", "Jane Doe" => "Ja D"}
|
|
16
|
+
module NameCollisionResolver
|
|
17
|
+
SUPERSCRIPTS = %w[² ³ ⁴ ⁵ ⁶ ⁷ ⁸ ⁹].freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# @param names [Array<String, nil>] raw author names (may contain duplicates/nils)
|
|
22
|
+
# @param pattern [String] a NameFormatter pattern or preset name
|
|
23
|
+
# @return [Hash{String => String}] raw_name → disambiguated formatted name
|
|
24
|
+
def resolve(names, pattern)
|
|
25
|
+
pattern = NameFormatter::PRESETS.fetch(pattern, pattern)
|
|
26
|
+
uniq_names = names.compact.reject(&:empty?).uniq
|
|
27
|
+
|
|
28
|
+
# Merge names that are the same person (same first+last, differ only in middle)
|
|
29
|
+
canonical, aliases = merge_same_person(uniq_names)
|
|
30
|
+
|
|
31
|
+
# Format every canonical name with the base pattern
|
|
32
|
+
result = {}
|
|
33
|
+
canonical.each { |n| result[n] = NameFormatter.format(n, pattern) }
|
|
34
|
+
|
|
35
|
+
# Find collision groups (different raw names → same formatted output)
|
|
36
|
+
collisions = find_collisions(result)
|
|
37
|
+
unless collisions.empty?
|
|
38
|
+
tokens = parse_tokens(pattern)
|
|
39
|
+
collisions.each do |_formatted, raw_names|
|
|
40
|
+
resolve_group(raw_names, pattern, tokens, result)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Expand aliases: all variant spellings get the same formatted output
|
|
45
|
+
aliases.each { |variant, canon| result[variant] = result[canon] }
|
|
46
|
+
|
|
47
|
+
result
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# --- private helpers ---
|
|
51
|
+
|
|
52
|
+
# Group names by (first, last) and pick the shortest as canonical.
|
|
53
|
+
# Returns [canonical_names, aliases_hash] where aliases maps variant → canonical.
|
|
54
|
+
def merge_same_person(names)
|
|
55
|
+
groups = {}
|
|
56
|
+
names.each do |name|
|
|
57
|
+
parts = name.split(/[\s._-]+/)
|
|
58
|
+
key = if parts.size >= 2
|
|
59
|
+
[parts.first.downcase, parts.last.downcase]
|
|
60
|
+
else
|
|
61
|
+
[parts.first&.downcase, nil]
|
|
62
|
+
end
|
|
63
|
+
(groups[key] ||= []) << name
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
canonical = []
|
|
67
|
+
aliases = {}
|
|
68
|
+
groups.each_value do |group|
|
|
69
|
+
# Pick shortest name as canonical (the one without middle name)
|
|
70
|
+
canon = group.min_by(&:length)
|
|
71
|
+
canonical << canon
|
|
72
|
+
group.each { |name| aliases[name] = canon unless name == canon }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
[canonical, aliases]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def find_collisions(mapping)
|
|
79
|
+
mapping.group_by { |_raw, fmt| fmt }
|
|
80
|
+
.select { |_fmt, pairs| pairs.size > 1 }
|
|
81
|
+
.transform_values { |pairs| pairs.map(&:first) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Resolve a single collision group by progressively expanding tokens.
|
|
85
|
+
# Only names that still collide continue to be expanded; once a name
|
|
86
|
+
# becomes unique it keeps its current (minimal) expansion.
|
|
87
|
+
def resolve_group(raw_names, base_pattern, base_tokens, result)
|
|
88
|
+
remaining = raw_names.dup
|
|
89
|
+
tokens = base_tokens.map(&:dup)
|
|
90
|
+
|
|
91
|
+
# Expansion order: exhaust all F tokens first, then L tokens
|
|
92
|
+
expansion_order = tokens.each_index.sort_by { |i| (tokens[i][:type] == "F") ? 0 : 1 }
|
|
93
|
+
|
|
94
|
+
expansion_order.each do |ti|
|
|
95
|
+
tok = tokens[ti]
|
|
96
|
+
next if tok[:maxed]
|
|
97
|
+
|
|
98
|
+
until tok[:maxed]
|
|
99
|
+
tok[:length] += 1
|
|
100
|
+
tok[:maxed] = true if tok[:length] >= 4
|
|
101
|
+
current_pattern = rebuild_pattern(tokens, base_pattern)
|
|
102
|
+
|
|
103
|
+
remaining.each { |n| result[n] = NameFormatter.format(n, current_pattern) }
|
|
104
|
+
|
|
105
|
+
# Check collisions only within this group (not the full result),
|
|
106
|
+
# so expansion from one group can't chase collisions with other groups
|
|
107
|
+
still_colliding = find_collisions(result.slice(*raw_names))
|
|
108
|
+
if still_colliding.empty?
|
|
109
|
+
remaining = []
|
|
110
|
+
break
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Only keep expanding names that still collide within the group
|
|
114
|
+
remaining = still_colliding.values.flatten
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
break if remaining.empty?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
return if remaining.empty?
|
|
121
|
+
|
|
122
|
+
# Last resort: numeric suffixes
|
|
123
|
+
find_collisions(result.slice(*remaining)).each_value do |group|
|
|
124
|
+
group[1..].each_with_index do |name, i|
|
|
125
|
+
suffix = SUPERSCRIPTS[i] || (i + 2).to_s
|
|
126
|
+
result[name] = "#{result[name]}#{suffix}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Parse pattern into token descriptors: [{type:, length:, maxed:}, ...]
|
|
132
|
+
def parse_tokens(pattern)
|
|
133
|
+
tokens = []
|
|
134
|
+
pattern.scan(NameFormatter::TOKEN_RE) do |match|
|
|
135
|
+
type = match[0]
|
|
136
|
+
len = Regexp.last_match(0).length
|
|
137
|
+
tokens << {type: type, length: len, maxed: len >= 4}
|
|
138
|
+
end
|
|
139
|
+
tokens
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Rebuild a pattern string from token descriptors, preserving literals.
|
|
143
|
+
def rebuild_pattern(tokens, original_pattern)
|
|
144
|
+
ti = 0
|
|
145
|
+
original_pattern.gsub(NameFormatter::TOKEN_RE) do
|
|
146
|
+
tok = tokens[ti]
|
|
147
|
+
ti += 1
|
|
148
|
+
tok[:type] * tok[:length]
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private_class_method :merge_same_person, :find_collisions, :resolve_group,
|
|
153
|
+
:parse_tokens, :rebuild_pattern
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railbow
|
|
4
|
+
# Formats person names using a pattern language inspired by date-format strings.
|
|
5
|
+
#
|
|
6
|
+
# Tokens (repeat the letter to control length; 4+ means full word):
|
|
7
|
+
# F / FF / FFF / FFFF+ — first name (1 char, 2 chars, … , full)
|
|
8
|
+
# L / LL / LLL / LLLL+ — last name
|
|
9
|
+
# M / MM / MMM / MMMM+ — middle name(s)
|
|
10
|
+
#
|
|
11
|
+
# Any other characters (spaces, dots, commas) are literals and pass through.
|
|
12
|
+
#
|
|
13
|
+
# Named presets:
|
|
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
|
+
module NameFormatter
|
|
21
|
+
PRESETS = {
|
|
22
|
+
"initials" => "F L",
|
|
23
|
+
"short" => "FF L",
|
|
24
|
+
"first_name" => "FFFF",
|
|
25
|
+
"last_name" => "LLLL",
|
|
26
|
+
"full_name" => "FFFF MMMM LLLL",
|
|
27
|
+
"full_name_short" => "FFFF L."
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
TOKEN_RE = /([FLM])\1*/
|
|
31
|
+
# Sentinel inserted when a token expands to empty, so surrounding
|
|
32
|
+
# literals that only make sense next to a value can be cleaned up.
|
|
33
|
+
EMPTY = "\x00"
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# Format a name according to a pattern or preset name.
|
|
38
|
+
# Returns "" for nil/empty input.
|
|
39
|
+
def format(name, pattern)
|
|
40
|
+
return "" if name.nil? || name.empty?
|
|
41
|
+
|
|
42
|
+
pattern = PRESETS.fetch(pattern, pattern)
|
|
43
|
+
parts = name.split(/[\s._-]+/)
|
|
44
|
+
|
|
45
|
+
first = parts.first || ""
|
|
46
|
+
last = (parts.size > 1) ? parts.last : ""
|
|
47
|
+
middle = (parts.size > 2) ? parts[1..-2] : []
|
|
48
|
+
|
|
49
|
+
result = pattern.gsub(TOKEN_RE) do |match|
|
|
50
|
+
letter = match[0]
|
|
51
|
+
len = match.length
|
|
52
|
+
|
|
53
|
+
expanded = case letter
|
|
54
|
+
when "F" then truncate(first, len)
|
|
55
|
+
when "L" then truncate(last, len)
|
|
56
|
+
when "M" then middle.map { |m| truncate(m, len) }.join(" ")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
expanded.empty? ? EMPTY : expanded
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Remove sentinels and any adjacent non-letter literals (dots, commas, spaces)
|
|
63
|
+
result.gsub(/[^a-zA-Z\x00]*\x00[^a-zA-Z\x00]*/, " ")
|
|
64
|
+
.gsub(/ +/, " ")
|
|
65
|
+
.strip
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Truncate a word to `len` chars (4+ means full), preserving original case
|
|
69
|
+
# for full words, capitalizing otherwise.
|
|
70
|
+
def truncate(word, len)
|
|
71
|
+
return "" if word.nil? || word.empty?
|
|
72
|
+
|
|
73
|
+
if len >= 4 || len >= word.length
|
|
74
|
+
word
|
|
75
|
+
else
|
|
76
|
+
word[0, len].capitalize
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private_class_method :truncate
|
|
81
|
+
end
|
|
82
|
+
end
|