Module: Heatwave::Normalizers
- Defined in:
- app/lib/heatwave/normalizers.rb
Overview
Replacement for the normalizr gem. Provides a small set of named
normalization helpers that can be chained from a Rails 7.1 normalizes
lambda. The behaviour of each method intentionally mirrors the
corresponding add :name do |value| … end block that used to live in
config/initializers/normalizr.rb, so a mechanical migration of every
normalize :foo, with: %i[strip blank downcase] call site to
normalizes :foo, with: ->(v) { Heatwave::Normalizers.chain(v, :strip, :blank, :downcase) }
preserves behaviour exactly.
Two callsite-shape helpers:
Heatwave::Normalizers.default(v)— the global default chain
(:strip,:blank) used
whennormalizewas called
withoutwith:.Heatwave::Normalizers.chain(v, *names, **options)
— apply the named normalizers
in order.
Library code: normalizers. A flat registry of ~25 single-purpose normalizer
methods; it legitimately exceeds the module-length budget.
Constant Summary collapse
- DEFAULT_CHAIN =
Default chain.
%i[strip blank].freeze
- TRUE_VALUES =
True values.
[true, 1, '1', 't', 'T', 'true', 'TRUE', 'on', 'ON'].freeze
- NULL_STRINGS =
Null strings.
['', '{}', '[]', 'null', 'nil'].freeze
- CURRI_TRACKING_ID =
A Curri trackingId: "C-" plus exactly 30 base62 characters (four
observed live 2026-07-24/2026-08-06, all 30). The only case-SENSITIVE
tracking identifier we store, hence the carve-out in tracking_number.Deliberately anchored and exact-length so it cannot swallow another
carrier's number: it needs a literal hyphen in position 3 and a total
length of 32, which nothing else we handle has — UPS is 1Z + 16, FedEx
and USPS are all digits, Canpar is 15, and LTL PRO numbers are 9-11
digits (XPO's hyphenated "285-141102" is 10). /\AC-[A-Za-z0-9]{30}\z/- ASCII_PUNCTUATION_MAP =
Common Unicode punctuation that breaks ASCII-only protocols (e.g.
X12 EDI N3 Address Information segments). Mapped to their plain
ASCII equivalents so a downstreamI18n.transliteratecall doesn't
have to fall back to?. { "‘" => "'", "’" => "'", "‚" => "'", "‛" => "'", "“" => '"', "”" => '"', "„" => '"', "‟" => '"', "′" => "'", "″" => '"', "‐" => '-', "‑" => '-', "‒" => '-', "–" => '-', "—" => '-', "―" => '-', "−" => '-', "…" => '...', " " => ' ', " " => ' ', "" => '', "" => '' }.freeze
- ASCII_PUNCTUATION_REGEXP =
Alternation over every ASCII_PUNCTUATION_MAP key, so one
gsubpass
replaces them all. Regexp.union(ASCII_PUNCTUATION_MAP.keys).freeze
- LEGAL_MARK_REGEXP =
Legal / IP glyphs (trademark, registered, copyright, service mark, circled
C/R) to strip from outbound product-feed text. Deliberately EXCLUDES
measurement symbols (″ ′ ° ½ × µ …) so titles like275′/120°Fsurvive. /[™®©℠℗ⒸⓇ]/- FEED_INCH_REGEXP =
Measurement feet/inch marks → retailer-friendly abbreviations (used by
feed_safe: 49′ → "49 ft.", 30″ → "30 in."). Inches first (double-prime ″,
two apostrophes '', or quote ") then feet (prime ′ or single apostrophe ').
Prime/double-prime always convert; the ambiguous ASCII '/" convert only when
NOT followed by a Unicode letter (\p{L}, so accented letters count too), e.g.
49' x→ feet but possessives like3's/80's/3'Élitestay untouched. /(\d)\s*(?:″|(?:''|")(?!\p{L}))/- FEED_FOOT_REGEXP =
Feet half of the pair above: a digit followed by a prime (′) or an
ASCII apostrophe not followed by a letter, so49'converts but
80'sdoes not. /(\d)\s*(?:′|'(?!\p{L}))/- YOUTUBE_ID_REGEXP =
A YouTube video ID is exactly 11 characters from [A-Za-z0-9_-]. Anything
else (a stripped leading char, a pasted watch URL, trailing junk) must be
rejected — a malformed id silently 404s every YouTube push/sync. Shared
by the youtube_id normalizer and Video's format validation. /\A[A-Za-z0-9_-]{11}\z/- YOUTUBE_URL_ID_REGEXP =
Pull the 11-char id out of the common YouTube URL shapes:
watch?v=,
youtu.be/,/embed/,/shorts/. The trailing boundary keeps a garbled
12+-char id segment from matching on its first 11 chars and being silently
accepted — that's the exact truncation this guard exists to reject. %r{(?:youtu\.be/|/embed/|/shorts/|[?&]v=)([A-Za-z0-9_-]{11})(?![A-Za-z0-9_-])}
Class Method Summary collapse
-
.ascii_safe(value) ⇒ Object
Map common Unicode punctuation (smart quotes, primes, en/em dashes, ellipsis, NBSP, ZWSP, BOM) to ASCII, then
I18n.transliterateto strip accents. -
.blank(value) ⇒ Object?
Returns nil when the value is a String composed entirely of whitespace (or empty); leaves non-strings alone.
-
.boolean(value) ⇒ Boolean?
Coerce a loose value to a boolean against TRUE_VALUES.
-
.capitalize(value) ⇒ Object
Uppercase the first character and lowercase the rest, via
String#capitalize. -
.chain(value, *names, **options) ⇒ Object, Array
Apply a sequence of named normalizers.
-
.control_chars(value) ⇒ Object
Remove non-printing control characters, keeping whitespace (so newlines and tabs survive).
-
.currency(value) ⇒ Object
Strip everything but digits and the decimal point — turns "$1,299.00" into "1299.00" for a numeric column.
-
.date(value) ⇒ Date?
Coerce to a Date, parsing Strings and calling
to_dateon anything that responds to it. -
.deep_ascii_safe(value) ⇒ Hash, ...
Recursively apply
ascii_safeto every String leaf in a Hash/Array structure. -
.default(value) ⇒ Object?
Apply the configured default chain (
[:strip, :blank]). -
.downcase(value) ⇒ Object
Lowercase a present String; pass anything else through untouched.
-
.email(value) ⇒ String?
Extract the first well-formed email address from a String, so a pasted "Name a@b.com" stores just the address.
-
.feed_safe(value) ⇒ Object
The single shared cleanup for product names/titles emitted to external retailers and product feeds (Google, OpenAI Ads, Wayfair, Amazon, Walmart, Menard, …).
-
.hash_compactor(value) ⇒ Hash
Recursively drop null-ish entries from a Hash, so a JSONB column stores
{}rather than a tree of empty strings and nils. -
.html_scrubber(value) ⇒ String?
Clean and compress user-supplied HTML, then curl its quotes.
-
.integer(value) ⇒ Integer?
Coerce to Integer, discarding non-digits from Strings first.
-
.json_to_hash(value) ⇒ Hash, ...
Parse a JSON String into a Hash/Array, passing already-parsed structures through.
-
.new_lines(value) ⇒ String?
Normalize Windows CRLF line endings to bare LF.
-
.no_undefined(value) ⇒ Object?
Null out the literal strings "undefined" / "null" / "nil", which arrive from JavaScript clients and third-party feeds as real text.
-
.nullify_zero(value) ⇒ Object?
Treat a zero as "not set" — for columns where 0 and NULL mean the same thing and NULL is the honest one.
-
.numeric(value, precision: 0, strip_zeros: true, ceil: false) ⇒ String?
Pull the digits out of a loose value and re-render them at a fixed precision.
-
.parameterize(value) ⇒ Object
Convert a String to a URL-safe slug, via
String#parameterize. -
.phone(value, country_iso: nil) ⇒ String?
Parse and re-format a phone number to E.164-ish canonical form.
-
.resolve_shipping_carrier(carrier, tracking_number: nil) ⇒ String, Object
Chains the name-based Normalizers.shipping_carrier normalizer with the tracking-number-format fallback Normalizers.shipping_carrier_from_tracking_number.
-
.shipping_carrier(value) ⇒ String, Object
Map a free-form
shipments.carriervalue to a canonicalWyShipping.class_for_carrierkey (matching theShipping::<Name>class name). -
.shipping_carrier_from_tracking_number(value) ⇒ String?
Infer the canonical carrier name from a tracking number's format (and checksum, when the carrier defines one).
-
.squish(value) ⇒ Object
Collapse runs of whitespace and trim, via
String#squish. -
.strip(value) ⇒ Object
Strip leading/trailing whitespace from a String; pass anything else through untouched.
-
.strip_legal_marks(value) ⇒ Object
Strip legal / IP glyphs (™ ® © ℠ ℗ Ⓒ Ⓡ) and collapse any double space the removal leaves.
-
.tagify(value) ⇒ String?
Convert a String to a dasherized, lowercase tag slug.
-
.titleize(value) ⇒ String?
Squish and title-case a String.
-
.tracking_number(value) ⇒ String?
Tracking numbers carry no case or whitespace semantics: carriers print them in readable groups ("3822 5195 7215") and ShipEngine returns them packed and uppercased.
-
.truncate(value, length: 30, omission: '...') ⇒ Object
Shorten a String to
lengthcharacters, appendingomissionwhen it had to cut. -
.unit_symbolizer(value) ⇒ Object
Replaces ' and " with prime symbols when adjacent to digits.
-
.unitizer(value) ⇒ String?
Best-effort length normalizer: tolerates fractions, primes, and the plain feet/inches notation customers paste in.
-
.upcase(value) ⇒ Object
Uppercase a present String; pass anything else through untouched.
-
.whitespace(value) ⇒ Object
Collapse horizontal whitespace to single spaces and tidy the whitespace around newlines, preserving line structure (unlike Normalizers.squish, which flattens everything to one line).
-
.youtube_id(value) ⇒ Object
Coerce a YouTube video-id input into the bare 11-char id.
-
.zip_or_postal_code(value, country_iso: nil) ⇒ String?
Format a postal code for its country.
Class Method Details
.ascii_safe(value) ⇒ Object
Map common Unicode punctuation (smart quotes, primes, en/em dashes,
ellipsis, NBSP, ZWSP, BOM) to ASCII, then I18n.transliterate to
strip accents. Use for outbound payloads that must be ASCII-only,
e.g. X12 EDI N3 Address Information segments which reject "Anne’s".
236 237 238 239 240 |
# File 'app/lib/heatwave/normalizers.rb', line 236 def ascii_safe(value) return value unless value.is_a?(String) && value.present? I18n.transliterate(value.gsub(ASCII_PUNCTUATION_REGEXP, ASCII_PUNCTUATION_MAP)) end |
.blank(value) ⇒ Object?
Returns nil when the value is a String composed entirely of
whitespace (or empty); leaves non-strings alone.
144 145 146 147 148 |
# File 'app/lib/heatwave/normalizers.rb', line 144 def blank(value) return value unless value.is_a?(String) value.match?(/\A[[:space:]]*\z/) ? nil : value end |
.boolean(value) ⇒ Boolean?
Coerce a loose value to a boolean against TRUE_VALUES. A blank
String is nil (unknown) rather than false, so an empty form field
doesn't read as an explicit "no".
301 302 303 304 305 |
# File 'app/lib/heatwave/normalizers.rb', line 301 def boolean(value) return nil if value.is_a?(String) && value.blank? TRUE_VALUES.include?(value) end |
.capitalize(value) ⇒ Object
Uppercase the first character and lowercase the rest, via
String#capitalize.
187 188 189 |
# File 'app/lib/heatwave/normalizers.rb', line 187 def capitalize(value) value.is_a?(String) ? value.capitalize : value end |
.chain(value, *names, **options) ⇒ Object, Array
Apply a sequence of named normalizers.
Arrays are normalized per-element (mirroring the legacy normalizr
gem's behaviour for array-typed attributes); resulting nil entries
are dropped so callers don't accidentally write nils into PG array
columns.
118 119 120 121 122 123 124 125 126 |
# File 'app/lib/heatwave/normalizers.rb', line 118 def chain(value, *names, **) return value.filter_map { |item| chain(item, *names, **) } if value.is_a?(Array) names.inject(value) do |acc, name| method_name = name.to_sym opts = [method_name] opts.is_a?(Hash) ? public_send(method_name, acc, **opts) : public_send(method_name, acc) end end |
.control_chars(value) ⇒ Object
Remove non-printing control characters, keeping whitespace (so
newlines and tabs survive).
213 214 215 |
# File 'app/lib/heatwave/normalizers.rb', line 213 def control_chars(value) value.is_a?(String) ? value.gsub(/[[:cntrl:]&&[^[:space:]]]/, '') : value end |
.currency(value) ⇒ Object
Strip everything but digits and the decimal point — turns "$1,299.00"
into "1299.00" for a numeric column.
338 339 340 |
# File 'app/lib/heatwave/normalizers.rb', line 338 def currency(value) value.is_a?(String) ? value.gsub(/[^0-9.]+/, '') : value end |
.date(value) ⇒ Date?
Coerce to a Date, parsing Strings and calling to_date on anything
that responds to it.
403 404 405 406 407 408 |
# File 'app/lib/heatwave/normalizers.rb', line 403 def date(value) case value when String then Date.parse(value) else value.to_date if value.respond_to?(:to_date) end end |
.deep_ascii_safe(value) ⇒ Hash, ...
Recursively apply ascii_safe to every String leaf in a Hash/Array
structure. Hash keys are left alone.
248 249 250 251 252 253 254 255 |
# File 'app/lib/heatwave/normalizers.rb', line 248 def deep_ascii_safe(value) case value when Hash then value.transform_values { |v| deep_ascii_safe(v) } when Array then value.map { |v| deep_ascii_safe(v) } when String then ascii_safe(value) else value end end |
.default(value) ⇒ Object?
Apply the configured default chain ([:strip, :blank]).
95 96 97 |
# File 'app/lib/heatwave/normalizers.rb', line 95 def default(value) chain(value, *DEFAULT_CHAIN) end |
.downcase(value) ⇒ Object
Lowercase a present String; pass anything else through untouched.
154 155 156 |
# File 'app/lib/heatwave/normalizers.rb', line 154 def downcase(value) value.is_a?(String) && value.present? ? value.downcase : value end |
.email(value) ⇒ String?
Extract the first well-formed email address from a String, so a
pasted "Name a@b.com" stores just the address.
415 416 417 418 419 |
# File 'app/lib/heatwave/normalizers.rb', line 415 def email(value) return unless value.is_a?(String) value.scan(Truemail::RegexConstant::REGEX_EMAIL_PATTERN)&.first&.first&.strip&.presence end |
.feed_safe(value) ⇒ Object
The single shared cleanup for product names/titles emitted to external
retailers and product feeds (Google, OpenAI Ads, Wayfair, Amazon,
Walmart, Menard, …). Abbreviates measurement marks (feet ′ → "ft.",
inches ″ → "in."), normalizes common Unicode punctuation (smart quotes,
en/em dashes, NBSP, ZWSP, BOM) to ASCII, then strips legal/IP glyphs
(™ ® © ℠ ℗ Ⓒ Ⓡ) via strip_legal_marks.
Unlike ascii_safe it does NOT transliterate accents away, and it
preserves true measurement symbols (° ½ × µ …), so
"Crémaillère 1.5′ x 49′ TempZone™" → "Crémaillère 1.5 ft. x 49 ft. TempZone".
Feet/inch marks convert only when digit-adjacent, so possessives ("Anne's")
survive. Backs the retailer-facing CatalogItem#reported_name; idempotent,
so re-applying it downstream (e.g. in a feed presenter) is harmless.
287 288 289 290 291 292 |
# File 'app/lib/heatwave/normalizers.rb', line 287 def feed_safe(value) return value unless value.is_a?(String) && value.present? abbreviated = value.gsub(FEED_INCH_REGEXP, '\1 in.').gsub(FEED_FOOT_REGEXP, '\1 ft.') strip_legal_marks(abbreviated.gsub(ASCII_PUNCTUATION_REGEXP, ASCII_PUNCTUATION_MAP)) end |
.hash_compactor(value) ⇒ Hash
Recursively drop null-ish entries from a Hash, so a JSONB column
stores {} rather than a tree of empty strings and nils.
458 459 460 461 462 |
# File 'app/lib/heatwave/normalizers.rb', line 458 def hash_compactor(value) return {} unless value.is_a?(Hash) compact_hash(value) || {} end |
.html_scrubber(value) ⇒ String?
Clean and compress user-supplied HTML, then curl its quotes.
358 359 360 361 362 363 364 365 |
# File 'app/lib/heatwave/normalizers.rb', line 358 def html_scrubber(value) return unless value.is_a?(String) && value.present? helper_class = Class.new { include Models::Utilities::Html } cleaned = helper_class.new.clean_and_compress_html(value) curled = Heatwave::TypographicQuotes.curl_html(cleaned) curled&.strip&.presence end |
.integer(value) ⇒ Integer?
Coerce to Integer, discarding non-digits from Strings first.
390 391 392 393 394 395 |
# File 'app/lib/heatwave/normalizers.rb', line 390 def integer(value) case value when String then value.scan(/[\d+.]+/).join.to_i when Numeric, Float then value.to_i end end |
.json_to_hash(value) ⇒ Hash, ...
Parse a JSON String into a Hash/Array, passing already-parsed
structures through. Malformed JSON yields nil rather than raising —
these values arrive from third-party payloads and a parse failure
should null the column, not abort the write.
715 716 717 718 719 720 721 722 723 724 725 |
# File 'app/lib/heatwave/normalizers.rb', line 715 def json_to_hash(value) return nil if value.blank? return value if value.is_a?(Array) || value.is_a?(Hash) return nil unless value.is_a?(String) begin JSON.parse(value) rescue JSON::ParserError nil end end |
.new_lines(value) ⇒ String?
Normalize Windows CRLF line endings to bare LF.
521 522 523 |
# File 'app/lib/heatwave/normalizers.rb', line 521 def new_lines(value) value.presence&.gsub("\r\n", "\n") end |
.no_undefined(value) ⇒ Object?
Null out the literal strings "undefined" / "null" / "nil", which
arrive from JavaScript clients and third-party feeds as real text.
347 348 349 350 351 |
# File 'app/lib/heatwave/normalizers.rb', line 347 def no_undefined(value) return nil if value.is_a?(String) && %w[undefined null nil].include?(value) value end |
.nullify_zero(value) ⇒ Object?
Treat a zero as "not set" — for columns where 0 and NULL mean the
same thing and NULL is the honest one.
469 470 471 |
# File 'app/lib/heatwave/normalizers.rb', line 469 def nullify_zero(value) value.to_i.zero? ? nil : value end |
.numeric(value, precision: 0, strip_zeros: true, ceil: false) ⇒ String?
Pull the digits out of a loose value and re-render them at a fixed
precision. Non-numeric characters are discarded before parsing, so
"approx. 12.5 lb" becomes "12.5".
376 377 378 379 380 381 382 383 384 |
# File 'app/lib/heatwave/normalizers.rb', line 376 def numeric(value, precision: 0, strip_zeros: true, ceil: false) return if value.blank? helper = Class.new { include ActionView::Helpers::NumberHelper }.new digit_string = value.to_s.scan(/[\d+.]+/).join big_decimal = BigDecimal(digit_string) big_decimal = big_decimal.ceil(precision) if ceil helper.number_with_precision(big_decimal, strip_insignificant_zeros: strip_zeros, precision: precision) end |
.parameterize(value) ⇒ Object
Convert a String to a URL-safe slug, via String#parameterize.
178 179 180 |
# File 'app/lib/heatwave/normalizers.rb', line 178 def parameterize(value) value.is_a?(String) ? value.parameterize : value end |
.phone(value, country_iso: nil) ⇒ String?
Parse and re-format a phone number to E.164-ish canonical form.
427 428 429 430 431 432 |
# File 'app/lib/heatwave/normalizers.rb', line 427 def phone(value, country_iso: nil) return unless value.is_a?(String) && value.present? cleaned = value.gsub(/\P{ASCII}/u, '').strip.downcase PhoneNumber.parse_and_format(cleaned, country_iso: country_iso) end |
.resolve_shipping_carrier(carrier, tracking_number: nil) ⇒ String, Object
Chains the name-based shipping_carrier normalizer with the
tracking-number-format fallback shipping_carrier_from_tracking_number.
Use this anywhere you need a best-effort canonical carrier name
from a (possibly garbage) shipments.carrier value plus an
optional tracking number. Mirrors the resolution rule in
WyShipping.class_for_carrier.
699 700 701 702 703 704 705 |
# File 'app/lib/heatwave/normalizers.rb', line 699 def resolve_shipping_carrier(carrier, tracking_number: nil) normalized = shipping_carrier(carrier) return normalized unless tracking_number.present? && normalized == carrier sniffed = shipping_carrier_from_tracking_number(tracking_number) sniffed || normalized end |
.shipping_carrier(value) ⇒ String, Object
Map a free-form shipments.carrier value to a canonical
WyShipping.class_for_carrier key (matching the Shipping::<Name>
class name). Returns the input unchanged when no rule matches, so
unfamiliar carrier strings keep surfacing NameError instead of
being silently mis-mapped.
Covers UPS / FedEx / Canpar / Purolator / USPS / Canada Post
service-level and case-variant strings observed in production
(e.g. "Ups Standard", "FedEx Ground®", "Canpar Express",
"fedex_international_ground", "PUROLATOR_GROUND",
"Canada Post Expedited Parcel"). Carriers that already
constantize cleanly (FedEx, UPS, USPS, Canpar, Canadapost,
Purolator, AmazonSeller, WalmartSeller, SpeedeeDelivery,
Freightquote, RlCarriers, DPD, GLS, DhlExpress) are returned
untouched.
Amazon Logistics variants — "Amzl", "AMZL", "AMZL_CA_PREMIUM",
"Amazon Ground", "Amazon Shipping Ground" — canonicalize to
"Amazon Shipping", which is connected on the ShipEngine account
as carrier_code amazon_shipping_us (tracking-only,
carrier_id se-6604086). Rates/labels still go through
Shipping::AmazonSeller (SP-API direct), but tracking webhook
subscription is handled by SE just like every other parcel
carrier.
Bare "Amazon" is intentionally left alone — too ambiguous, could
be the parent corp / a marketplace name / a service. Same for
"AmazonSeller" — that's the marketplace flow where the
underlying carrier varies per shipment (USPS, FedEx, UPS, or
Amazon Logistics depending on Buy Shipping's choice). The
tracking-number sniffer chain in resolve_shipping_carrier
recovers the right carrier from the number's format.
Also leaves placeholder/free-text values alone ("Standard",
"Override", "Shipping override, please confirm", "Warehouse
Pickup", "AMJM").
593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 |
# File 'app/lib/heatwave/normalizers.rb', line 593 def shipping_carrier(value) return value unless value.is_a?(String) stripped = value.strip return value if stripped.empty? cleaned = stripped.gsub(/[®™]/, '').sub(/\*+\z/, '').squeeze(' ').strip.downcase case cleaned when 'legacyfedex' then 'LegacyFedEx' when /\Afed[\s_-]*ex[\s_-]*freight\z/ then 'FedExFreight' when /\Afed[\s_-]?ex/, 'fdx' then 'FedEx' when 'ups', /\Aups[\s_-]/ then 'UPS' when /\Acanpar\b/ then 'Canpar' when /\Apurolator/ then 'Purolator' when /\Ausps/, 'united states postal service' then 'USPS' when /\Acanadapost/, /\Acanada\s*post/ then 'Canadapost' when 'dhl', /\Adhl[\s_-]*express\b/, /\Adhl[\s_-]*express\s+(international|intl)/ then 'DhlExpress' when 'amzl', /\Aamzl[\s_-]/, /\Aamazon[\s_-]*ground\z/, /\Aamazon[\s_-]+shipping\b/ then 'Amazon Shipping' else value end end |
.shipping_carrier_from_tracking_number(value) ⇒ String?
Infer the canonical carrier name from a tracking number's format
(and checksum, when the carrier defines one). Defers to
TrackingNumber, which owns the barcode-spec families
for every carrier we care about — UPS (1Z + mod-10), FedEx
(Express 12-digit, Ground 15-digit, SmartPost/Ground Economy
20/22-digit, 96-prefix), USPS (13-char international AA######US,
IMpb 22-digit, Express Mail, Certified, etc.), Canada Post
(16-digit domestic, AA######CA international), DHL Express
(10/JJD-prefix), Canpar (D-prefix), Purolator (PIN + variants),
Spee-Dee, R+L Carriers. Mod-10 checksum validation makes false
positives rare on the families that have a check digit (UPS,
USPS IMpb, FedEx Ground, Canada Post).
Used as a fallback by WyShipping.class_for_carrier and
resolve_shipping_carrier when the name-based
shipping_carrier normalizer can't recognize the carrier
string but a tracking number is present — covers placeholder
cases like "Override" / "Shipping override, please confirm" /
"Standard" when the carrier is discoverable from the number.
Two-pass:
- TrackingNumber.parse — preferred, validates
format + checksum. - Regex fallback for the two patterns we'd accept even with
a bad checksum (typo'd UPS / USPS international where
registering is still better than skipping).
Returns nil only when neither the catalog nor the fallback regex
matches.
650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 |
# File 'app/lib/heatwave/normalizers.rb', line 650 def shipping_carrier_from_tracking_number(value) return nil unless value.is_a?(String) s = value.strip.gsub(/\s+/, '').upcase return nil if s.empty? # Some real Purolator 520-prefix PINs also satisfy FedEx's checksum, and # the catalog prefers FedEx when scores tie (fedex.json loads first). # Prefer Purolator only when its own format and Luhn checksum both validate. if s.match?(/\A520\d{9}\z/) && Heatwave::TrackingNumber.detect_all(s).any? { |match| match.courier_code == :purolator } return 'Purolator' end tn = Heatwave::TrackingNumber.parse(s) if tn.valid? && (internal = PARCEL_TRACKING_NUMBER_GEM_TO_INTERNAL[tn.courier_code]) return internal end # Format-only fallback for checksum failures. Deliberately not a # catalog format-match: 12-digit FedEx / Purolator / DHL and 22-digit # USPS IMpb collide, and a bad check digit on those must stay nil # (see the normalizers tests). UPS 1Z, USPS S10, Canpar, and Spee-Dee # are distinctive enough that registering a slightly-wrong number is # better than skipping — ShipEngine re-validates. case s when /\A1Z[0-9A-Z]{16}\z/ then 'UPS' when /\A[A-Z]{2}\d{9}US\z/ then 'USPS' when /\AD[A-Z0-9]{18,21}\z/ then 'Canpar' when /\ASP\d{18}\z/, /\ASP[0-9A-Z]+SD\d+\z/ then 'SpeedeeDelivery' end end |
.squish(value) ⇒ Object
Collapse runs of whitespace and trim, via String#squish.
170 171 172 |
# File 'app/lib/heatwave/normalizers.rb', line 170 def squish(value) value.is_a?(String) ? value.squish : value end |
.strip(value) ⇒ Object
Strip leading/trailing whitespace from a String; pass anything else
through untouched.
135 136 137 |
# File 'app/lib/heatwave/normalizers.rb', line 135 def strip(value) value.is_a?(String) ? value.strip : value end |
.strip_legal_marks(value) ⇒ Object
Strip legal / IP glyphs (™ ® © ℠ ℗ Ⓒ Ⓡ) and collapse any double space the
removal leaves. Unlike ascii_safe/transliterate, this touches ONLY
those marks — measurement symbols (″ ′ ° ½ × µ …) and accents are preserved.
The primitive behind feed_safe; call it directly only when smart-quote
normalization is unwanted.
265 266 267 268 269 |
# File 'app/lib/heatwave/normalizers.rb', line 265 def strip_legal_marks(value) return value unless value.is_a?(String) && value.present? value.gsub(LEGAL_MARK_REGEXP, '').gsub(/[ \t]{2,}/, ' ').strip end |
.tagify(value) ⇒ String?
Convert a String to a dasherized, lowercase tag slug.
477 478 479 |
# File 'app/lib/heatwave/normalizers.rb', line 477 def tagify(value) value.to_s.squish.parameterize.dasherize.downcase.presence if value.is_a?(String) end |
.titleize(value) ⇒ String?
Squish and title-case a String.
314 315 316 |
# File 'app/lib/heatwave/normalizers.rb', line 314 def titleize(value) value.to_s.squish.titleize.presence if value.is_a?(String) end |
.tracking_number(value) ⇒ String?
Tracking numbers carry no case or whitespace semantics: carriers
print them in readable groups ("3822 5195 7215") and ShipEngine
returns them packed and uppercased. Store the packed form so a
hand-keyed number matches the webhook's, the carrier tracking URL
resolves, and TrackingNumberFormatValidator sees the same string
the carrier's barcode spec describes.
Only whitespace is removed — dashes are left alone because some
LTL PRO numbers are issued with them.
ONE EXCEPTION: Curri trackingIds. Every other carrier's number is
digits or already-uppercase, so "no case semantics" holds — Curri's
is base62 and case-SENSITIVE. Upcasing one yields Curri's not-found
page, byte-identical to a made-up id (verified against
app.curri.com/track 2026-08-06), so the CRM tracking link and any
number a human copies out of the CRM both break. See
CURRI_TRACKING_ID for why the pattern can't collide.
546 547 548 549 550 551 |
# File 'app/lib/heatwave/normalizers.rb', line 546 def tracking_number(value) return value unless value.is_a?(String) packed = value.gsub(/[[:space:]]/, '') blank(CURRI_TRACKING_ID.match?(packed) ? packed : upcase(packed)) end |
.truncate(value, length: 30, omission: '...') ⇒ Object
Shorten a String to length characters, appending omission when it
had to cut. The omission counts toward the limit, so the result never
exceeds length.
326 327 328 329 330 331 |
# File 'app/lib/heatwave/normalizers.rb', line 326 def truncate(value, length: 30, omission: '...') return value unless value.is_a?(String) cutoff = length - omission.length value.length > length ? value[0...cutoff] + omission : value end |
.unit_symbolizer(value) ⇒ Object
Replaces ' and " with prime symbols when adjacent to digits.
4' → 4′ | 6" → 6″
487 488 489 490 491 |
# File 'app/lib/heatwave/normalizers.rb', line 487 def unit_symbolizer(value) return value unless value.is_a?(String) && /\d+/.match?(value) value.gsub(/(\d+)\s?'/, "\\1′").gsub(/(\d+)\s?"/, "\\1″") end |
.unitizer(value) ⇒ String?
Best-effort length normalizer: tolerates fractions, primes, and
the plain feet/inches notation customers paste in.
499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 |
# File 'app/lib/heatwave/normalizers.rb', line 499 def unitizer(value) return unless value.is_a?(String) && value.present? value = value.gsub("''", '"') ['.5', '.25', '.125', '.0625', '1/2', '1/4', '1/8', '1/16', '-', '+', ' ', '1/2', '1/4', '1/8', '1/16'].each { |s| value.gsub!(s, '') } value = "#{value} ft" unless value.match(/^\d+(\.\d+)?$/).nil? if value.index("'") && value.index('"').nil? && value.index('ft').nil? value = "#{value}\"" unless value.last == "'" value = "#{value} 0\"" if value.last == "'" end begin (RubyUnits::Unit.new(value) >> 'ft').to_s('%0.2f') rescue StandardError nil end end |
.upcase(value) ⇒ Object
Uppercase a present String; pass anything else through untouched.
162 163 164 |
# File 'app/lib/heatwave/normalizers.rb', line 162 def upcase(value) value.is_a?(String) && value.present? ? value.upcase : value end |
.whitespace(value) ⇒ Object
Collapse horizontal whitespace to single spaces and tidy the
whitespace around newlines, preserving line structure (unlike
squish, which flattens everything to one line).
223 224 225 226 227 |
# File 'app/lib/heatwave/normalizers.rb', line 223 def whitespace(value) return value unless value.is_a?(String) value.gsub(/[^\S\n]+/, ' ').gsub(/\s?\n\s?/, "\n").strip end |
.youtube_id(value) ⇒ Object
Coerce a YouTube video-id input into the bare 11-char id. Accepts a
bare id as-is, or extracts it from a watch / youtu.be / embed / shorts
URL. Leaves anything it can't confidently parse untouched, so the
model's format validation rejects it instead of silently storing junk.
198 199 200 201 202 203 204 205 206 |
# File 'app/lib/heatwave/normalizers.rb', line 198 def youtube_id(value) return value unless value.is_a?(String) trimmed = value.strip return trimmed if trimmed.match?(YOUTUBE_ID_REGEXP) match = trimmed.match(YOUTUBE_URL_ID_REGEXP) match ? match[1] : trimmed end |
.zip_or_postal_code(value, country_iso: nil) ⇒ String?
Format a postal code for its country. Without a country_iso hint,
tries Canada first, then the US, so "K1A0B1" formats as "K1A 0B1".
441 442 443 444 445 446 447 448 449 450 451 |
# File 'app/lib/heatwave/normalizers.rb', line 441 def zip_or_postal_code(value, country_iso: nil) return if value.blank? if country_iso ValidatesZipcode.format(value, country_iso) elsif ValidatesZipcode.valid?(value, 'CA') ValidatesZipcode.format(value, 'CA') elsif ValidatesZipcode.valid?(value, 'US') ValidatesZipcode.format(value, 'US') end end |