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
change ylszzunsynquzxstvwnvwprktqwoptxx
commit 83da1dac31798ee06a08c390853a32cb2b26854f
author Alpha Chen <alpha@kejadlen.dev>
date
parent zptomzzz
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.