import: drop PRODID without saying so
PRODID names the program that wrote the file rather than anything
about the person, and every export carries one, so counting it as a
loss put a line in every file's summary and a mark on every contact's
row -- a mark every row wears says nothing.
What goes quiet is the asking, not the dropping. The line still does
not come in, and the card as exported still shows it struck: that card
is the file's own bytes, and a line shown plain there would be a line
claiming to arrive. The review list's counts and the file-wide summary
now read #losses instead, and a file whose every loss is noise says
there is nothing here to read before it lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrREeoPLXx1qsYYq7qkL8h
diff --git a/docs/plans/2026-09-21-import-a-vcf.md b/docs/plans/2026-09-21-import-a-vcf.md
index 3a72d3b..bea5e01 100644
--- a/docs/plans/2026-09-21-import-a-vcf.md
+++ b/docs/plans/2026-09-21-import-a-vcf.md
@@ -71,6 +71,15 @@ wherever that line goes — a label naming nothing is worse than either
outcome — while a label on a line that is coming in, `item1.ADR`'s
say, comes in with it.
+One property is dropped without being mentioned. `PRODID` names the
+program that wrote the file rather than anything about the person, and
+every export carries one, so counting it as a loss would put a line in
+every file's summary and a mark on every contact's row — a mark every
+row wears is a mark that says nothing. What goes quiet is the asking,
+not the dropping: the line still does not come in, and the card as
+exported still shows it struck, that card being the file's own bytes
+and a line shown plain there being a line that claims to arrive.
+
One thing is kept that nothing will show: a line that would not parse
at all. The parser hands it back without a property name, so there is
nothing to show on a screen and nothing anyone could decide about it;
@@ -92,8 +101,8 @@ So the review is a walk, four screens rather than a submission:
that contact's lines are not coming in, over a summary of which
properties the whole file is losing. A book is hundreds of contacts
and only some of them will have anything worth looking at, so the
- list is what says which rows are worth opening — and a file losing
- nothing but `PRODID` can be landed unread.
+ list is what says which rows are worth opening — and a file whose
+ every loss is noise says so at the top and can be landed unread.
3. **One contact, two cards.** On the left, the card exactly as the
file wrote it: content lines in monospace, with every line that is
not coming in struck in Gloss's danger color and labelled "not
diff --git a/lib/pro_tacts/admin/import_review.rb b/lib/pro_tacts/admin/import_review.rb
index 20dad00..e6c34be 100644
--- a/lib/pro_tacts/admin/import_review.rb
+++ b/lib/pro_tacts/admin/import_review.rb
@@ -19,7 +19,10 @@ module ProTacts
# Above the list, the same fact for the whole file: which
# properties are not coming in, and how many lines each of them
# wears. It is what says whether the list is worth working down
- # at all — a file losing nothing but PRODID can be landed unread.
+ # at all. Neither count mentions the noise a file is losing
+ # (Vcf::NOISE): a mark every row wears is a mark that says
+ # nothing, and an export's own PRODID is not something anyone is
+ # going to copy across by hand.
class ImportReview < Phlex::HTML
# @rbs @upload: String
# @rbs @rows: Array[[::ProTacts::Contact, Integer]]
@@ -81,7 +84,11 @@ module ProTacts
#: () -> void
def losses
if @unknown.empty?
- p(class: "type-body-sm gl-muted") { "Every property in this file is one pro-tacts reads." }
+ # Not "every property here is one pro-tacts reads": the
+ # noise is not read either (Vcf::NOISE), it is only not
+ # worth a sentence. What is true in both cases is that
+ # there is nothing in this file to stop over.
+ p(class: "type-body-sm gl-muted") { "Nothing in this file needs reading before it lands." }
return
end
diff --git a/lib/pro_tacts/import/vcf.rb b/lib/pro_tacts/import/vcf.rb
index 9f00cd1..be34d79 100644
--- a/lib/pro_tacts/import/vcf.rb
+++ b/lib/pro_tacts/import/vcf.rb
@@ -50,6 +50,18 @@ module ProTacts
LABEL,
].freeze #: Array[String]
+ # Unknown, and not worth saying so. PRODID names the program
+ # that wrote the file rather than anything about the person, and
+ # every export carries one: counted as a loss it would put a
+ # line in every file's summary and a mark on every contact's
+ # row, for a fact nobody is going to copy into a card. What goes
+ # quiet is the asking, not the dropping — the line still does
+ # not come in, and the card as exported still shows it struck
+ # (Admin::ImportOriginal), because that card is the file's own
+ # bytes and a line shown plain there would be a line claiming to
+ # arrive.
+ NOISE = %w[PRODID].freeze #: Array[String]
+
# What an import makes of one card: the card as it will come in,
# and the lines it leaves behind. Both, because the review
# screen shows them side by side — the original with its doomed
@@ -126,7 +138,7 @@ module ProTacts
next if property.nil?
name = property.name.upcase
- next if known?(name)
+ next if known?(name) || noise?(name)
(seen[name] ||= []) << summary(line)
end
@@ -144,6 +156,25 @@ module ProTacts
KNOWN.include?(name.upcase)
end
+ # Whether a property is one to drop without remark (NOISE).
+ #: (String name) -> bool
+ def self.noise?(name)
+ NOISE.include?(name.upcase)
+ end
+
+ # The dropped lines a reader is meant to look at: everything a
+ # card is losing but the noise. It is what the review list
+ # counts on a row, and so what says whether the pair of cards
+ # behind that row is worth opening at all — a contact losing
+ # nothing but its exporter's name is a contact to leave shut.
+ #: (Array[VCard::Parser::Line] dropped) -> Array[VCard::Parser::Line]
+ def self.losses(dropped)
+ dropped.reject { |line|
+ property = line.property
+ !property.nil? && noise?(property.name)
+ }
+ end
+
# One card as this book will hold it, beside what that costs.
# The kept lines are the bytes that arrived, in the order they
# arrived, so a card comes in as the card it was; the dropped
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 16b774f..edab387 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -116,7 +116,8 @@ module ProTacts
rows = landing.each_with_index.map { |card, index|
# A two-element literal is an Array until something says
# otherwise, and an inline annotation needs its own line.
- [import_contact(card, index), Import::Vcf.read(originals.fetch(index)).dropped.length] #: [Contact, Integer]
+ dropped = Import::Vcf.read(originals.fetch(index)).dropped
+ [import_contact(card, index), Import::Vcf.losses(dropped).length] #: [Contact, Integer]
}
response["Content-Type"] = "text/html; charset=utf-8"
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 4a898fa..72ad0fc 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -116,11 +116,43 @@ class AdminImportPagesTest < Minitest::Test
upload(PLAIN)
follow_redirect!
- assert_includes last_response.body, "Every property in this file is one pro-tacts reads."
+ assert_includes last_response.body, "Nothing in this file needs reading before it lands."
refute_includes last_response.body, "left behind"
end
end
+ # Every export names the program that wrote it, and nobody is
+ # going to copy that into a card: it is dropped without being
+ # counted, so a file losing nothing else reads as losing nothing.
+ def test_a_file_losing_only_its_exporters_name_asks_for_nothing
+ with_contacts({}) do |_store|
+ upload(PLAIN.sub("VERSION:3.0\r\n", "VERSION:3.0\r\nPRODID:-//Apple Inc.//macOS 15.0//EN\r\n"))
+ follow_redirect!
+
+ assert_includes last_response.body, "Nothing in this file needs reading before it lands."
+ refute_includes last_response.body, "left behind"
+ refute_includes last_response.body, "PRODID"
+ end
+ end
+
+ # The card as exported still says so, though: it is the file's own
+ # bytes, and a line shown plain there would be one claiming to
+ # arrive.
+ def test_the_exporters_name_is_struck_on_the_card_it_arrived_on
+ with_contacts({}) do |store|
+ id = upload(PLAIN.sub("VERSION:3.0\r\n", "VERSION:3.0\r\nPRODID:-//Apple Inc.//macOS 15.0//EN\r\n"))
+
+ get "/import/#{id}/0"
+
+ assert_includes last_response.body,
+ %(<li data-dropped><span>PRODID:-//Apple Inc.//macOS 15.0//EN</span>)
+
+ post "/import/#{id}/land", "group" => ""
+
+ refute_includes store.contacts.fetch(0).vcard.to_s, "PRODID"
+ end
+ end
+
# The two cards: the contact as the file wrote it, and the contact
# that is landing, open in the editor.
def test_a_contact_opens_beside_the_card_it_arrived_as
diff --git a/test/pro_tacts/import/test_vcf.rb b/test/pro_tacts/import/test_vcf.rb
index 86237a6..df1b17d 100644
--- a/test/pro_tacts/import/test_vcf.rb
+++ b/test/pro_tacts/import/test_vcf.rb
@@ -78,7 +78,25 @@ class ImportVcfTest < Minitest::Test
def test_the_survey_names_what_no_screen_here_shows
unknown = Vcf.unknown(Vcf.cards(JANE + SAM))
- assert_equal %w[PRODID X-ABRELATEDNAMES X-SOCIALPROFILE], unknown.map(&:name)
+ assert_equal %w[X-ABRELATEDNAMES X-SOCIALPROFILE], unknown.map(&:name)
+ end
+
+ # The exporter's own name is not a loss to report: every file
+ # carries one, and a line in every summary for it would say
+ # nothing.
+ def test_the_survey_says_nothing_about_the_noise
+ refute_includes Vcf.unknown(Vcf.cards(SAM)).map(&:name), "PRODID"
+ end
+
+ # It is still not imported, though, and the card as exported still
+ # shows it struck: #losses is what a reader is asked to look at,
+ # not what comes in.
+ def test_the_noise_is_dropped_like_anything_else_unknown
+ reading = Vcf.read(Vcf.cards(SAM).fetch(0))
+
+ refute_includes reading.card.to_s, "PRODID"
+ assert_includes reading.dropped.map { |line| line.property&.name }, "PRODID"
+ refute_includes Vcf.losses(reading.dropped).map { |line| line.property&.name }, "PRODID"
end
# The envelope, the fields a screen shows, and the label that names a