admin: elide any overly long value in the raw card
Generalizes the PHOTO elision: a parameter value over the threshold
renders as its count too, so a memoji's 1,685-octet binary-plist
parameter no longer leaves a wall on the header line, and a long NOTE
elides like a payload. Elided lines rebuild from the parse; lines
with nothing long stay byte for byte.
Assisted-by: GLM-5.3 via pi
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 1116af8..8618fac 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -93,9 +93,10 @@ Consequences worth keeping:
row, not a scaffold of blank fields. The card's height is the record's real
weight — an unfilled record should look unfilled.
- **The stored card is reachable.** In its own card beneath the record, the
- raw vCard sits collapsed by default, its inline photo payloads elided to
- their size — the interpretation is the interface, but nothing a card
- carries is invisible from it.
+ raw vCard sits collapsed by default, with overly long values (a photo's
+ base64 payload, a memoji's binary parameter) elided to their size — the
+ interpretation is the interface, but nothing a card carries is invisible
+ from it.
- **Adding is explicit.** A quiet add affordance opens a native `dialog` that
names the attribute types available. Multi-part additions (picking a group,
then a role) are steps inside that one dialog, never a chain of popovers —
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 189bfdc..daa400a 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -17,12 +17,12 @@ module ProTacts
# keeps its mono type label for now. Revisit once Gloss or this
# project settles on how icons ship.
class ContactsShow < Phlex::HTML
- # A PHOTO value at or under this many octets renders whole — a
- # URI reference does; an inline payload never does, and a photo
- # card's raw section would otherwise be a wall of folded base64
- # (the seed photo card carries ~200KB of it).
- ELIDE_PHOTO_ABOVE = 120 #: Integer
- private_constant :ELIDE_PHOTO_ABOVE
+ # A value — a property's or a parameter's — at or under this many
+ # octets renders whole. Anything longer is a wall of folded or
+ # unbroken octets (an inline PHOTO payload, a memoji's binary
+ # plist parameter, a novel-length NOTE) and renders as its count.
+ ELIDE_ABOVE = 120 #: Integer
+ private_constant :ELIDE_ABOVE
#: (Contact contact) -> void
def initialize(contact:)
@@ -122,31 +122,53 @@ module ProTacts
end
end
- # The stored card for display: byte for byte, except a PHOTO
- # value long enough to be nothing but a wall of folded base64,
- # which renders as its octet count. The property stays visible —
- # name and parameters whole, so what the card carries is still
- # seeable — because the payload is not readable text and never
- # was; the count says exactly what is there.
+ # The stored card for display: byte for byte, except a value — a
+ # property's or a parameter's — long enough to be a wall, which
+ # renders as its octet count. The count says exactly what is
+ # there; what it stands in for is not readable text.
#: () -> String
def raw_card
- @contact.vcard.lines.map { elide_photo(it) }.join
+ @contact.vcard.lines.map { elide(it) }.join
end
- # One line of the display card: verbatim, unless it is a PHOTO
- # whose value runs past ELIDE_PHOTO_ABOVE — then the value's
- # octets stand in for it, under the line's own terminator so the
- # card's line-break convention survives the elision.
+ # One line of the display card: verbatim when nothing in it runs
+ # past ELIDE_ABOVE; rebuilt from its parse when something does,
+ # each long value standing in as its count. The rebuild is the
+ # parser's spelling rather than the bytes' — an elided line is a
+ # rendering — and the line keeps its own terminator so the
+ # card's line-break convention survives.
#: (VCard::Parser::Line line) -> String
- def elide_photo(line)
- return line.verbatim unless line.names?("PHOTO")
+ def elide(line)
+ property = line.property
+ return elide_unparsed(line.verbatim) if property.nil?
- prefix, value = line.verbatim.split(":", 2)
- value = value.to_s
- terminator = value.slice!(/\r?\n\z/) || "\r\n"
- return line.verbatim if value.bytesize <= ELIDE_PHOTO_ABOVE
+ long = property.parameters.any? { |_, value| value.bytesize > ELIDE_ABOVE } ||
+ property.value.bytesize > ELIDE_ABOVE
+ return line.verbatim unless long
- "#{prefix}:[#{value.bytesize} octets of base64 elided]#{terminator}"
+ header = property.group ? "#{property.group}.#{property.name}" : property.name
+ params = property.parameters.map { |name, value| "#{name}=#{count_octets(value)}" }
+ terminator = line.verbatim[/\r?\n\z/] || "\r\n"
+ "#{[header, *params].join(';')}:#{count_octets(property.value)}#{terminator}"
+ end
+
+ # A value over the threshold stands in as its octet count;
+ # anything shorter renders as it is.
+ #: (String value) -> String
+ def count_octets(value)
+ value.bytesize > ELIDE_ABOVE ? "[#{value.bytesize} octets elided]" : value
+ end
+
+ # A line that would not parse has no structure to rebuild from,
+ # so its value is elided by surgery on the bytes: everything up
+ # to the first colon stands, and the rest is the count.
+ #: (String verbatim) -> String
+ def elide_unparsed(verbatim)
+ prefix, value = verbatim.split(":", 2)
+ return verbatim if value.nil?
+
+ terminator = value.slice!(/\r?\n\z/) || ""
+ "#{prefix}:#{count_octets(value)}#{terminator}"
end
end
end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 615abbd..1fc558f 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -297,13 +297,44 @@ class AdminContactsPagesTest < Minitest::Test
get "/contacts/pic"
body = last_response.body
- assert_includes body, " octets of base64 elided]"
+ assert_includes body, " octets elided]"
chunk = store.contact("pic").vcard.to_s.lines.grep(/\A /).first
refute_includes body, chunk
assert_includes body, "X-IMAGETYPE:PHOTO"
end
end
+ # A memoji's parameter section is itself 1,685 octets of base64 on
+ # one line — a long parameter value elides like a long property
+ # value, and the short parameters around it render whole.
+ def test_show_elides_long_parameter_values_in_the_raw_card
+ with_contacts({"mochi" => PhotoCard.memoji("mochi")}) do |store|
+ get "/contacts/mochi"
+
+ body = last_response.body
+ assert_includes body, "VND-63-MEMOJI-DETAILS=["
+ assert_includes body, ";ENCODING=b;TYPE=JPEG:["
+ details = store.contact("mochi").properties
+ .find { it.name.casecmp?("PHOTO") }.parameters
+ .find { |name, _| name.casecmp?("VND-63-MEMOJI-DETAILS") }.last
+ refute_includes body, details[0, 60]
+ end
+ end
+
+ # Any overly long value elides, not only a picture's: a long NOTE
+ # renders as its count in the raw card while the grid above still
+ # shows the text itself.
+ def test_show_elides_any_overly_long_value
+ note = "n" * 200
+ card = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Wordy\r\nNOTE:#{note}\r\nUID:wordy\r\nEND:VCARD\r\n"
+
+ with_contacts({"wordy" => card}) do
+ get "/contacts/wordy"
+
+ assert_includes last_response.body, "NOTE:[#{note.bytesize} octets elided]"
+ end
+ end
+
# A card that will not parse is served from its bytes regardless —
# the raw section is the one thing such a card has to show, so it
# renders with no grid under the header, not instead of the page.