names: vcard is a VCard, and the bytes under one are bytes
The body a PUT reads is octets, and it answered to `vcard` while the
card read from it answered to `card` — one name for the thing it was
not, one line apart. Now `vcard` is the card wherever a card needs no
qualifier to say which one, `bytes` is what a card is read from, and
the two helpers that took a card's octets under the card's own name
take `bytes` instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvMYjgmPgf7wa8nzH6DJwW
diff --git a/AGENTS.md b/AGENTS.md
index 9438b8e..e77d55e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -190,6 +190,15 @@ not a fixture; edit a `.vcf` to change what the replay serves.
- `escape` and `fold` in `vcard.rb` have no caller yet. They are the
writer's half of the module, kept deliberately for PUT and the web
editor, and tested directly rather than through anything that serves.
+- `vcard` names a `VCard` and nothing else, and a card with no
+ qualifier to give it is named `vcard`. A `vcard` holding a card's
+ bytes is the confusion the rule exists to stop: the two sit one
+ `VCard.new` apart, and at a PUT that gap is the whole question —
+ the body arrives as octets and everything below the route wants
+ the card — so the octets are `bytes` (`Web#write_card`). A card
+ that needs saying which one keeps its own word (`stored`, `own`,
+ `rest`). The `cards` column and the `text/vcard` media type spell
+ it the same way and are neither of them variables.
- `docs/plans/` entries are dated records of what was decided then. Write
a new one rather than editing an old one to match current behavior.
- Comments carry the reasoning; the code carries the rest. One that
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 945e0a5..ecc62b6 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -163,9 +163,9 @@ module ProTacts
def vcard
return @vcard if defined?(@vcard)
- card = @stored.insert(@inherited.map { it.line })
+ with_inherited = @stored.insert(@inherited.map { it.line })
line = @birthday && @birthday.to_line
- @vcard = line ? card.insert([line]) : card
+ @vcard = line ? with_inherited.insert([line]) : with_inherited
end
# The same contact with nothing its groups lend it: the editor's
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 88556a6..9c54552 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -279,8 +279,8 @@ module ProTacts
# nothing that is not text gets this far; VCard's own raise, at the
# construction the caller makes, is the assertion under that, and
# the bind is the third line.
- #: (String id, VCard card) -> Contact
- def put(id, card)
+ #: (String id, VCard vcard) -> Contact
+ def put(id, vcard)
# The birthday half of the split a write makes, one arm per shape
# a submitted card's BDAY lines can take. One line that reads as
# a modeled birthday moves out of the card and into the model;
@@ -305,12 +305,12 @@ module ProTacts
# attributing any to a group.
own = stored_card(id)
birthday, stored =
- case card.extract("BDAY")
+ case vcard.extract("BDAY")
in [[line], rest]
report_unrecognized_bday_lines([line])
property = bday_of(line)
birthday = property && Birthday.from_property(property)
- [birthday, birthday ? rest : card]
+ [birthday, birthday ? rest : vcard]
in [[], _]
# The rewrite arm: carry the unrendered lines out of the
# stored card, report the unrecognized ones' loss, and keep
@@ -318,12 +318,12 @@ module ProTacts
carried, lost = carried_and_lost_bday_lines(own)
kept = existing && !existing.served? ? existing : nil
report_lost_bday_lines(lost)
- [kept, card.insert(carried)]
+ [kept, vcard.insert(carried)]
in [lines, _]
# More than one BDAY: cardinality-broken data, kept verbatim
# and reported like any other unrecognized line.
report_unrecognized_bday_lines(lines)
- [nil, card]
+ [nil, vcard]
end
# The other half of the split, the same shape as the birthday's:
@@ -359,16 +359,16 @@ module ProTacts
# than its bytes, for #put's reason: the editor spliced one to
# make this save, and re-reading its bytes here would walk them
# again to reach what the caller already had.
- #: (String id, VCard card, birthday: Birthday?) -> Contact
- def rewrite(id, card, birthday:)
- contact = Contact.new(id:, stored: card, birthday:, inherited: inherited_of(id))
+ #: (String id, VCard vcard, birthday: Birthday?) -> Contact
+ def rewrite(id, vcard, birthday:)
+ contact = Contact.new(id:, stored: vcard, birthday:, inherited: inherited_of(id))
@database.transaction do
cards
.insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
- .insert(id: contact.id, vcard: card.to_s)
+ .insert(id: contact.id, vcard: vcard.to_s)
write_birthday(contact.id, birthday)
record(contact.id, "edit", contact.etag)
- reindex(contact.id, card)
+ reindex(contact.id, vcard)
end
contact
end
@@ -493,13 +493,13 @@ module ProTacts
# parser cannot read. Takes the stored card — with the birthday
# already subtracted — so the index never sees a BDAY no stored
# card carries.
- #: (String id, VCard card) -> void
- def reindex(id, card)
+ #: (String id, VCard vcard) -> void
+ def reindex(id, vcard)
# Before the parse, so that a card which has stopped parsing does
# not keep the rows from when it did.
card_properties.where(card_id: id).delete
- card.properties.each.with_index do |property, position|
+ vcard.properties.each.with_index do |property, position|
card_properties.insert(
card_id: id,
position:,
@@ -549,11 +549,11 @@ module ProTacts
# it drops unwitnessed, which no client renders and no whitelist
# recognizes. Lines a client rendered are in neither pile: their
# absence is a deletion the rewrite already honors.
- #: (VCard? card) -> [Array[String], Array[VCard::Parser::Line]]
- def carried_and_lost_bday_lines(card)
- return [[], []] if card.nil?
+ #: (VCard? vcard) -> [Array[String], Array[VCard::Parser::Line]]
+ def carried_and_lost_bday_lines(vcard)
+ return [[], []] if vcard.nil?
- bdays, = card.extract("BDAY")
+ bdays, = vcard.extract("BDAY")
carried, rest = bdays.partition { |line|
property = bday_of(line)
property && Birthday.unrendered_value?(property.value)
@@ -614,11 +614,11 @@ module ProTacts
# this leaves keep their positions, so a submission that was the
# served card round-trips to the bytes it was composed from and the
# PUT can answer with a strong etag (RFC 6352 section 6.3.2.3).
- #: (VCard card, Array[Contact::Inherited] inherited, VCard? own) -> VCard
- def subtract_inherited(card, inherited, own)
- return card if inherited.empty?
+ #: (VCard vcard, Array[Contact::Inherited] inherited, VCard? own) -> VCard
+ def subtract_inherited(vcard, inherited, own)
+ return vcard if inherited.empty?
- unaccounted = unaccounted_lines(card, own)
+ unaccounted = unaccounted_lines(vcard, own)
untouched = [] #: Array[VCard::Parser::Line]
ambiguous = 0
@@ -640,7 +640,7 @@ module ProTacts
end
report_ambiguous_inherited_lines(ambiguous)
- untouched.reduce(card) { |rest, line| rest.substitute(line.digest, []) }
+ untouched.reduce(vcard) { |rest, line| rest.substitute(line.digest, []) }
end
# The submission's lines that the member's own stored card does not
@@ -650,10 +650,10 @@ module ProTacts
# client wrote beside it, which is the pool a lent line is
# attributed from. Everything for a card being created, which has
# no stored lines to explain anything.
- #: (VCard card, VCard? own) -> Array[VCard::Parser::Line]
- def unaccounted_lines(card, own)
+ #: (VCard vcard, VCard? own) -> Array[VCard::Parser::Line]
+ def unaccounted_lines(vcard, own)
stored = own ? own.lines.map { it.verbatim.chomp } : [] #: Array[String]
- card.lines.reject { |line|
+ vcard.lines.reject { |line|
index = stored.index(line.verbatim.chomp)
stored.delete_at(index) if index
index
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 4e57c61..b175555 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -631,8 +631,8 @@ module ProTacts
# past the point where the client could be told what was wrong.
# Paths need no counterpart: Puma hands PATH_INFO over still
# percent-encoded, so an id off the wire is ASCII.
- vcard = request.body.read.force_encoding(Encoding::UTF_8)
- return precondition("valid-address-data") unless vcard.valid_encoding?
+ bytes = request.body.read.force_encoding(Encoding::UTF_8)
+ return precondition("valid-address-data") unless bytes.valid_encoding?
# CARDDAV:valid-address-data again, on the card this time: the
# envelope RFC 2426 section 4 requires. That is the whole test. A
@@ -640,8 +640,8 @@ module ProTacts
# refusing the card: RFC 6352 section 6.3.2.2 has the server keep
# what it does not understand, and it is kept — the stored bytes
# are what goes back out.
- card = VCard.new(vcard)
- return precondition("valid-address-data") unless card.card?
+ vcard = VCard.new(bytes)
+ return precondition("valid-address-data") unless vcard.card?
# CARDDAV:no-uid-conflict: the submitted UID must not belong to a
# different resource, and a mapped URI must not be overwritten by
@@ -650,7 +650,7 @@ module ProTacts
# UID naming the resource being written, and no other card claims
# it. The href in the body is the SHOULD that section attaches to
# the first clause — report where the UID already lives.
- uid = card.uid
+ uid = vcard.uid
owner = uid && store.card_id_with_uid(uid)
return precondition("no-uid-conflict", owner) if uid != id || owner && owner != id
@@ -665,10 +665,10 @@ module ProTacts
if_none_match = request.env["HTTP_IF_NONE_MATCH"]
return plain_412 if if_none_match && if_none_match_failed?(if_none_match, existing)
- report_unreadable_lines(card)
- report_broken_assumptions(card)
+ report_unreadable_lines(vcard)
+ report_broken_assumptions(vcard)
- stored = store.put(id, card)
+ stored = store.put(id, vcard)
response.status = existing ? 204 : 201
# A strong ETag belongs on the answer only when what the resource
# now serves is the submitted bytes, octet for octet — the one
@@ -681,7 +681,7 @@ module ProTacts
# tag, while a PUT that carried a birthday somewhere other than
# where compose puts it back serves bytes that are not the ones
# it sent, and the client refetches.
- response["ETag"] = stored.etag if stored.vcard.to_s == card.to_s
+ response["ETag"] = stored.etag if stored.vcard.to_s == vcard.to_s
# A returned "" would land in the body and pin text/html and
# content-length onto the 204, which a bodyless status must not
@@ -817,14 +817,14 @@ module ProTacts
# (Contact#own).
own = contact.own
- card = contact.stored
- card = card.replace("N", [n_line(card, first, last)])
- card = edited_fn(contact, card, first, last)
- card = card.replace("NICKNAME", text_lines("NICKNAME", nickname))
- card = card.replace("NOTE", text_lines("NOTE", note))
- card = edited_phones(own, card, params)
- card = edited_emails(own, card, params)
- edited_addresses(own, card, params)
+ vcard = contact.stored
+ vcard = vcard.replace("N", [n_line(vcard, first, last)])
+ vcard = edited_fn(contact, vcard, first, last)
+ vcard = vcard.replace("NICKNAME", text_lines("NICKNAME", nickname))
+ vcard = vcard.replace("NOTE", text_lines("NOTE", note))
+ vcard = edited_phones(own, vcard, params)
+ vcard = edited_emails(own, vcard, params)
+ edited_addresses(own, vcard, params)
end
# The phones' half of the surgical save: each row names its line
@@ -836,8 +836,8 @@ module ProTacts
# parameters no field models ride. The addresses come from the
# contact, never the request, so a doctored digest names nothing
# and a missing one touches nothing.
- #: (Contact contact, VCard card, Hash[String, untyped] params) -> VCard
- def edited_phones(contact, card, params)
+ #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
+ def edited_phones(contact, vcard, params)
rows = params["phone"]
if rows.is_a?(Hash)
contact.phones.each do |phone|
@@ -851,7 +851,7 @@ module ProTacts
next if submitted.nil? || submitted.to_s.strip == phone.value
value = submitted.to_s.strip
- card = card.substitute(
+ vcard = vcard.substitute(
phone.line.digest,
value.empty? ? [] : ["#{VCard.header_of(property)}#{VCard.escape(value)}"]
)
@@ -870,12 +870,12 @@ module ProTacts
value = it.to_s.strip
"TEL:#{VCard.escape(value)}\r\n" unless value.empty?
} #: Array[String]
- card.insert(added)
+ vcard.insert(added)
end
# #edited_phones' walk, over EMAIL.
- #: (Contact contact, VCard card, Hash[String, untyped] params) -> VCard
- def edited_emails(contact, card, params)
+ #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
+ def edited_emails(contact, vcard, params)
rows = params["email"]
if rows.is_a?(Hash)
contact.emails.each do |email|
@@ -886,7 +886,7 @@ module ProTacts
next if submitted.nil? || submitted.to_s.strip == email.value
value = submitted.to_s.strip
- card = card.substitute(
+ vcard = vcard.substitute(
email.line.digest,
value.empty? ? [] : ["#{VCard.header_of(property)}#{VCard.escape(value)}"],
)
@@ -898,7 +898,7 @@ module ProTacts
value = it.to_s.strip
"EMAIL:#{VCard.escape(value)}\r\n" unless value.empty?
} #: Array[String]
- card.insert(added)
+ vcard.insert(added)
end
# #edited_phones' walk, over a row that is six fields rather than
@@ -906,8 +906,8 @@ module ProTacts
# (Contact#address_of): a row blank throughout, po box included,
# removes the line, where a partially blanked one keeps it —
# partial blanks are legal empty components.
- #: (Contact contact, VCard card, Hash[String, untyped] params) -> VCard
- def edited_addresses(contact, card, params)
+ #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
+ def edited_addresses(contact, vcard, params)
rows = params["address"]
if rows.is_a?(Hash)
contact.addresses.each do |address|
@@ -918,7 +918,7 @@ module ProTacts
next if !submitted.is_a?(Hash) || address_unchanged?(address, submitted)
line = address_line(address, submitted)
- card = card.substitute(address.line.digest, line ? [line] : [])
+ vcard = vcard.substitute(address.line.digest, line ? [line] : [])
end
end
@@ -930,7 +930,7 @@ module ProTacts
# included.
submitted = params["new_address"]
added = submitted.is_a?(Hash) ? submitted.values : Array(submitted) #: Array[untyped]
- card.insert(added.filter_map { address_line(nil, it) })
+ vcard.insert(added.filter_map { address_line(nil, it) })
end
#: (Contact::Address address, untyped submitted) -> bool
@@ -1025,13 +1025,13 @@ module ProTacts
# name is stale. A card carrying no FN is the other case: the
# property is mandatory (section 4), so a save fills it in rather
# than leaving the absence it found.
- #: (Contact contact, VCard card, String first, String last) -> VCard
- def edited_fn(contact, card, first, last)
+ #: (Contact contact, VCard vcard, String first, String last) -> VCard
+ def edited_fn(contact, vcard, first, last)
family, given = contact.name_components || []
- named = card.properties.any? { it.name.casecmp?("FN") }
- return card if named && first == given.to_s && last == family.to_s
+ named = vcard.properties.any? { it.name.casecmp?("FN") }
+ return vcard if named && first == given.to_s && last == family.to_s
- card.replace("FN", ["FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\r\n"])
+ vcard.replace("FN", ["FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\r\n"])
end
# N's replacement line. The first two components are the form's;
@@ -1042,9 +1042,9 @@ module ProTacts
# form can read splices into a bare five-component value; one
# short of five is padded, the same empties a whole-N writer
# would leave.
- #: (VCard card, String first, String last) -> String
- def n_line(card, first, last)
- property = card.properties.find { it.name.casecmp?("N") }
+ #: (VCard vcard, String first, String last) -> String
+ def n_line(vcard, first, last)
+ property = vcard.properties.find { it.name.casecmp?("N") }
components = [] #: Array[String]
components.replace(VCard.split_raw_components(property.value)) if property
components << "" while components.length < 5
@@ -1068,9 +1068,9 @@ module ProTacts
# message, and a PUT is the only arrival there is. The message
# carries no card content (ProTacts::SentryScrubber's line); the
# admin view shows the card raw.
- #: (VCard card) -> void
- def report_broken_assumptions(card)
- broken = card.lines.count { it.broke_assumption? }
+ #: (VCard vcard) -> void
+ def report_broken_assumptions(vcard)
+ broken = vcard.lines.count { it.broke_assumption? }
return if broken.zero?
Sentry.capture_message(
@@ -1084,9 +1084,9 @@ module ProTacts
# the no-card-content line the store's BDAY reports already use.
# BrokenAssumption lines are excluded, so a line reports once; why
# this lives at the PUT is the comment above, and holds for both.
- #: (VCard card) -> void
- def report_unreadable_lines(card)
- unreadable = card.lines.count { it.unreadable? }
+ #: (VCard vcard) -> void
+ def report_unreadable_lines(vcard)
+ unreadable = vcard.lines.count { it.unreadable? }
return if unreadable.zero?
Sentry.capture_message(
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
index 039b9e1..d71f257 100644
--- a/test/pro_tacts/admin/test_format.rb
+++ b/test/pro_tacts/admin/test_format.rb
@@ -12,8 +12,8 @@ class FormatTest < Minitest::Test
# beside a Birthday, a fallback case passes a card carrying an
# unmodeled BDAY beside nil, and inheritance defaults to none (see
# Contact).
- def contact(vcard = CARD, id: "aiden", birthday: nil, inherited: [])
- ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(vcard), birthday:, inherited:)
+ def contact(bytes = CARD, id: "aiden", birthday: nil, inherited: [])
+ ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(bytes), birthday:, inherited:)
end
# Initials come from the structured N property (given + family),
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 983379a..ea7c10c 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -29,9 +29,9 @@ class ContactTest < Minitest::Test
# lender passes its own.
GROUP = "Booles" #: String
- def contact(vcard = CARD, id: "aiden", birthday: nil, inherited: [], group: GROUP)
+ def contact(bytes = CARD, id: "aiden", birthday: nil, inherited: [], group: GROUP)
lent = inherited.map { ProTacts::Contact::Inherited.new(group:, line: it) }
- ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(vcard), birthday:, inherited: lent)
+ ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(bytes), birthday:, inherited: lent)
end
# The decode's refusal reports (see #photo); the transport this pins