feat(import): strip non-printables from CSV/ADIF uploads

Adds Microwaveprop.Radio.TextSanitizer to remove BOM, zero-width
spaces/joiners, C0 control bytes (except CR/LF/TAB), and DEL, and to
collapse NBSP and other unicode whitespace into a regular space.

CSV: applied to the whole upload at the top of preview/2.
ADIF: applied per-field-value after extraction so the byte-counted
length tags still slice the right substring.
This commit is contained in:
Graham McIntire 2026-04-26 15:34:45 -05:00
parent faa2105861
commit fe2ecf2618
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 142 additions and 2 deletions

View file

@ -12,6 +12,7 @@ defmodule Microwaveprop.Radio.AdifImport do
alias Microwaveprop.Radio.BandResolver
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.ImportMatcher
alias Microwaveprop.Radio.TextSanitizer
alias Microwaveprop.Repo
@dedup_window_seconds 3600
@ -161,7 +162,7 @@ defmodule Microwaveprop.Radio.AdifImport do
# Value starts right after the closing > of the tag.
value_offset = tag_start + tag_len
value = :binary.part(record_string, value_offset, size)
value = record_string |> :binary.part(value_offset, size) |> TextSanitizer.sanitize()
# Standard ADIF fields take precedence over app-defined ones
if raw_name != name and Map.has_key?(acc, name) do

View file

@ -27,6 +27,7 @@ defmodule Microwaveprop.Radio.CsvImport do
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.ImportMatcher
alias Microwaveprop.Radio.ImportRun
alias Microwaveprop.Radio.TextSanitizer
alias Microwaveprop.Repo
alias Microwaveprop.Workers.ContactImportWorker
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
@ -125,7 +126,7 @@ defmodule Microwaveprop.Radio.CsvImport do
| {:error, :no_data_rows}
| {:error, {:missing_required_columns, [String.t()]}}
def preview(csv_string, submitter_email) do
with {:ok, rows} <- parse_csv(csv_string, submitter_email) do
with {:ok, rows} <- parse_csv(TextSanitizer.sanitize(csv_string), submitter_email) do
{valid, invalid} = split_parsed(rows)
existing = load_existing_for_dedup(valid)
{unique, duplicates, refinements} = dedupe(valid, existing)

View file

@ -0,0 +1,52 @@
defmodule Microwaveprop.Radio.TextSanitizer do
@moduledoc """
Strip non-printable characters from user-uploaded log text. Removes BOM,
zero-width spaces/joiners, C0 control bytes (except CR/LF/TAB), and DEL;
collapses non-breaking and other unicode whitespace into a regular space.
Used by CSV and ADIF importers because field values frequently arrive
with stray NBSPs from website copy-paste, BOM markers from Windows
exports, etc.
"""
@spec sanitize(nil) :: nil
@spec sanitize(String.t()) :: String.t()
def sanitize(nil), do: nil
def sanitize(""), do: ""
def sanitize(string) when is_binary(string) do
string
|> :unicode.characters_to_list()
|> Enum.reduce([], fn cp, acc ->
case sanitize_codepoint(cp) do
:strip -> acc
replacement -> [replacement | acc]
end
end)
|> Enum.reverse()
|> :unicode.characters_to_binary()
end
defp sanitize_codepoint(?\t), do: ?\t
defp sanitize_codepoint(?\n), do: ?\n
defp sanitize_codepoint(?\r), do: ?\r
# C0 control characters and DEL — strip.
defp sanitize_codepoint(cp) when cp < 0x20 or cp == 0x7F, do: :strip
# NBSP and other unicode whitespace → regular space.
defp sanitize_codepoint(0x00A0), do: ?\s
defp sanitize_codepoint(cp) when cp >= 0x2000 and cp <= 0x200A, do: ?\s
defp sanitize_codepoint(0x202F), do: ?\s
defp sanitize_codepoint(0x205F), do: ?\s
defp sanitize_codepoint(0x3000), do: ?\s
# Zero-width characters (ZWSP, ZWNJ, ZWJ, word-joiner, BOM) — strip.
defp sanitize_codepoint(0x200B), do: :strip
defp sanitize_codepoint(0x200C), do: :strip
defp sanitize_codepoint(0x200D), do: :strip
defp sanitize_codepoint(0x2060), do: :strip
defp sanitize_codepoint(0xFEFF), do: :strip
defp sanitize_codepoint(cp), do: cp
end

View file

@ -66,6 +66,24 @@ defmodule Microwaveprop.Radio.AdifImportTest do
assert row.attrs["user_declared_prop_mode"] == nil
end
test "strips non-printables (zero-width, BOM, control) from ADIF field values" do
# Zero-width space (U+200B = 0xE2 0x80 0x8B, 3 bytes) inside CALL and
# GRIDSQUARE. ADIF length tags count BYTES so the tag widths include
# the multi-byte payload — sanitization strips the zero-width chars
# and leaves the underlying ASCII intact.
adif =
"<STATION_CALLSIGN:4>W5XD" <>
"<CALL:7>K5\u200BTR" <>
"<GRIDSQUARE:7>EM\u200B00" <>
"<MY_GRIDSQUARE:4>EM12" <>
"<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>\n"
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
assert [row] = preview.valid
assert row.attrs["station2"] == "K5TR"
assert row.attrs["grid2"] == "EM00"
end
test "captures ADIF NOTES into notes" do
adif = """
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<NOTES:25>Rare Es along TX/OK front<EOR>

View file

@ -104,6 +104,18 @@ defmodule Microwaveprop.Radio.CsvImportTest do
assert contact.station1 == "W5XD"
end
test "strips non-printables (BOM, NBSP, zero-width) from uploaded CSV" do
# \uFEFF BOM at file start, NBSP between fields, zero-width space inside callsign.
csv =
"\uFEFF" <>
@valid_header <>
"\nW5XD,K5\u200BTR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z\u00A0"
assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
assert contact.station1 == "W5XD"
assert contact.station2 == "K5TR"
end
test "skips blank lines between data rows" do
csv =
Enum.join(

View file

@ -0,0 +1,56 @@
defmodule Microwaveprop.Radio.TextSanitizerTest do
use ExUnit.Case, async: true
alias Microwaveprop.Radio.TextSanitizer
describe "sanitize/1" do
test "passes through plain ASCII unchanged" do
assert TextSanitizer.sanitize("K5ABC EM12 1296") == "K5ABC EM12 1296"
end
test "preserves CR, LF, and TAB" do
assert TextSanitizer.sanitize("a\nb\r\nc\td") == "a\nb\r\nc\td"
end
test "replaces NBSP (U+00A0) with regular space" do
assert TextSanitizer.sanitize("K5ABC\u00A0EM12") == "K5ABC EM12"
end
test "replaces narrow no-break space (U+202F) and figure space (U+2007) with space" do
assert TextSanitizer.sanitize("a\u202Fb\u2007c") == "a b c"
end
test "replaces all unicode general-punctuation spaces (U+2000U+200A) with space" do
assert TextSanitizer.sanitize("a\u2000b\u2003c\u200Ad") == "a b c d"
end
test "strips BOM (U+FEFF) anywhere in the string" do
assert TextSanitizer.sanitize("\uFEFFK5ABC") == "K5ABC"
assert TextSanitizer.sanitize("K5\uFEFFABC") == "K5ABC"
end
test "strips zero-width joiners and non-joiners" do
assert TextSanitizer.sanitize("K5\u200BABC\u200C\u200D") == "K5ABC"
end
test "strips C0 control characters except TAB/CR/LF" do
assert TextSanitizer.sanitize("a\x00b\x07c\x1Bd") == "abcd"
end
test "strips DEL (U+007F)" do
assert TextSanitizer.sanitize("a\x7Fb") == "ab"
end
test "handles nil" do
assert TextSanitizer.sanitize(nil) == nil
end
test "handles empty string" do
assert TextSanitizer.sanitize("") == ""
end
test "preserves UTF-8 letters with diacritics" do
assert TextSanitizer.sanitize("café Müller") == "café Müller"
end
end
end