import: show what a refused field held
A refusal now gives a few distinct values each unknown field held, beside the card that held it,
so a branch is written from what the error says, without opening a backup to read it.
A folded line is joined back together, and anything past a hundred characters is cut,
so a photo's base64 gives its length instead of filling the terminal up.
Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue: rtqvmzvp
diff --git a/docs/plans/2026-09-16-importing-from-macos.md b/docs/plans/2026-09-16-importing-from-macos.md
index 332be67..3bcf1e5 100644
--- a/docs/plans/2026-09-16-importing-from-macos.md
+++ b/docs/plans/2026-09-16-importing-from-macos.md
@@ -132,7 +132,8 @@ data, which the vCard leaves out, are unknown until a branch takes them
too. Each branch
names the parameters it accepts, and a parameter outside that list is
unknown too. Once every contact in the batch is read, any unknown fails
-the build with every unknown name, its count, and a few source ids, and
+the build with every unknown name, its count, and a few distinct values
+it held, each beside its source id and cut short when long, and
no plan is written. A small batch needs only the branches its own
contacts use.
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
index e2cbab8..701f6cc 100644
--- a/lib/pro_tacts/import/macos.rb
+++ b/lib/pro_tacts/import/macos.rb
@@ -19,19 +19,22 @@ module ProTacts
# Every field no branch handles, across every contact read, so one
# build names all of them.
class Unknown < StandardError
- # @rbs @seen: Hash[String, Array[String]]
+ # @rbs @seen: Hash[String, Array[[String, String]]]
EXAMPLES = 3 #: Integer
+ # Long enough for a line's parameters, short of a photo's payload.
+ EXAMPLE_LENGTH = 100 #: Integer
+
#: () -> void
def initialize
@seen = Hash.new { |seen, field| seen[field] = [] }
super
end
- #: (String field, String source_id) -> void
- def add(field, source_id)
- @seen[field] << source_id
+ #: (String field, String source_id, String example) -> void
+ def add(field, source_id, example)
+ @seen[field] << [source_id, example]
end
#: () -> bool
@@ -39,11 +42,23 @@ module ProTacts
#: () -> String
def message
- lines = @seen.sort.map do |field, source_ids|
- " #{field}: #{source_ids.size}, e.g. #{source_ids.uniq.first(EXAMPLES).join(", ")}"
+ lines = @seen.sort.flat_map do |field, seen|
+ examples = seen.uniq(&:last).first(EXAMPLES).map do |source_id, example|
+ " #{source_id}: #{truncate(example).inspect}"
+ end
+ [" #{field}: #{seen.size}", *examples]
end
"no branch handles these fields, so no plan was written:\n#{lines.join("\n")}"
end
+
+ private
+
+ #: (String example) -> String
+ def truncate(example)
+ return example if example.length <= EXAMPLE_LENGTH
+
+ "#{example[0, EXAMPLE_LENGTH]}… (#{example.length} characters)"
+ end
end
# The reader's output, one parsed object per contact.
@@ -76,8 +91,9 @@ module ProTacts
body = VCard::Parser.lines(vcard).filter_map { line(it, source_id, unknown) }
# Beyond the vCard: what the serializer leaves out.
- unknown.add("note", source_id) if note
- unknown.add("imageData", source_id) if contact.fetch("imageData")
+ unknown.add("note", source_id, note) if note
+ image = contact.fetch("imageData") #: String?
+ unknown.add("imageData", source_id, image) if image
backup = {"original.vcf" => vcard, "contact.json" => JSON.pretty_generate(contact)}
backup["note.txt"] = note if note
@@ -95,8 +111,9 @@ module ProTacts
#: (VCard::Parser::Line line, String source_id, Unknown unknown) -> String?
def self.line(line, source_id, unknown)
property = line.property
+ example = VCard::Parser.unfold(line.verbatim).chomp
if property.nil?
- unknown.add("unreadable line", source_id) unless line.verbatim.strip.empty?
+ unknown.add("unreadable line", source_id, example) unless example.strip.empty?
return
end
@@ -104,10 +121,10 @@ module ProTacts
when "BEGIN", "END"
nil
when "VERSION"
- unknown.add("VERSION:#{property.value}", source_id) unless property.value == "3.0"
+ unknown.add("VERSION:#{property.value}", source_id, example) unless property.value == "3.0"
nil
else
- unknown.add(property.name.upcase, source_id)
+ unknown.add(property.name.upcase, source_id, example)
nil
end
end
diff --git a/test/pro_tacts/import/test_macos.rb b/test/pro_tacts/import/test_macos.rb
index cec792a..a7720d1 100644
--- a/test/pro_tacts/import/test_macos.rb
+++ b/test/pro_tacts/import/test_macos.rb
@@ -58,14 +58,42 @@ class ImportMacosTest < Minitest::Test
error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
- assert_includes error.message, "FN: 2, e.g. A:ABPerson, B:ABPerson"
- assert_includes error.message, "N: 1, e.g. A:ABPerson"
- assert_includes error.message, "note: 1, e.g. A:ABPerson"
- assert_includes error.message, "imageData: 1, e.g. B:ABPerson"
+ assert_equal <<~MESSAGE.chomp, error.message
+ no branch handles these fields, so no plan was written:
+ FN: 2
+ A:ABPerson: "FN:Ada Lovelace"
+ B:ABPerson: "FN:Mary Boole"
+ N: 1
+ A:ABPerson: "N:Lovelace;Ada;;;"
+ imageData: 1
+ B:ABPerson: "/9j/"
+ note: 1
+ A:ABPerson: "Analyst."
+ MESSAGE
refute dir.exist?
end
end
+ def test_examples_are_distinct_unfolded_and_cut_short
+ in_tmpdir do |dir|
+ records = %w[A B C D].map { record("#{it}:ABPerson", lines: ["PHOTO;ENCODING=b:#{"A" * 60}\r\n #{"A" * 60}"]) }
+ records << record("E:ABPerson", lines: ["NOTE:one\\ntwo"], note: "one\ntwo")
+ records << record("F:ABPerson", lines: ["NOTE:one\\ntwo"])
+
+ error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
+
+ assert_equal <<~MESSAGE.chomp, error.message
+ no branch handles these fields, so no plan was written:
+ NOTE: 2
+ E:ABPerson: "NOTE:one\\\\ntwo"
+ PHOTO: 4
+ A:ABPerson: "PHOTO;ENCODING=b:#{"A" * 83}… (137 characters)"
+ note: 1
+ E:ABPerson: "one\\ntwo"
+ MESSAGE
+ end
+ end
+
def test_a_card_that_is_not_version_3_is_refused
in_tmpdir do |dir|
source = record("A:ABPerson")