lib: let the code carry what several comments were carrying
Every seam below was a piece of reasoning written out more than once,
because the code under it was written more than once. The duplicate
explanations go with the duplicate code.
- Sequel::Sole#sole_or_nil: the read where no row is the ordinary
answer, said once instead of at five rescues.
- VCard#terminate: the line-break convention rule, which #replace,
#substitute, and both arms of #insert each restated.
- VCard::Parser.physical_lines: one split, where VCard kept a private
copy of it with a copy of its comment.
- VCard::Parser::Line#content: the form a group stores a line in,
which Store and CardForm each spelled out and each explained.
- VCard::Parser::Property#types: the TYPE reading, with `pref` and its
reason in one place rather than in Store and Contact both.
- VCard#property: the case-insensitive lookup seven callers wrote out.
- Store#split_birthday and #upsert_card: two blocks lifted out of #put,
the second of which #rewrite also carried.
- Web#etag_matches?: If-Match and If-None-Match ask one question; they
had two identical methods and two comments for it.
- Web#member_id and #no_content: the member-URI shape and the bodyless
204, each explained at both of the routes that had it.
- CardForm.edited_values and ContactsEdit#value_row: the email walk and
the email row were the phone ones copied, and said so in a comment.
Behavior is unchanged but for #delete_group, which no longer swallows a
Sequel::NoMatchingRow raised from inside its transaction — the
membership's foreign key leaves none to raise, and swallowing one would
hide an error rather than answer a miss.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WWFQaLD58uLFedh3Dvfb2C
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index e248f77..1d95117 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -65,8 +65,8 @@ module ProTacts
vcard = edited_fn(contact, vcard, first, middle, 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)
+ vcard = edited_values(own.phones, "phone", "TEL", vcard, params)
+ vcard = edited_values(own.emails, "email", "EMAIL", vcard, params)
edited_addresses(own, vcard, params)
end
@@ -81,7 +81,7 @@ module ProTacts
card = reading.stored.replace("NOTE", text_lines("NOTE", params["note"].to_s.strip))
card = edited_addresses(reading, card, params)
card.lines.filter_map {
- line = VCard::Parser.unfold(it.verbatim).sub(/(\r\n|[\r\n])\z/, "")
+ line = it.content
line unless line.empty?
}
end
@@ -106,81 +106,56 @@ module ProTacts
Birthday.new(year:, month:, day:)
end
- # The phones' half of the surgical save: each row names its line
+ # The single-value rows' half of the surgical save, walked once
+ # for the phones and once for the emails: each row names its line
# by digest, so a row the request leaves out is a line the save
# never mentions, and an unchanged row is skipped — the line keeps
# its own bytes by construction, not by careful re-rendering. A
# blank row removes its line; a changed row swaps the value under
# the line's own header (VCard.header_of), where the TYPE
- # parameters no field models ride. The addresses come from the
+ # parameters no field models ride. The rows come from the
# contact, never the request, so a doctored digest names nothing
# and a missing one touches nothing.
- #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
- def self.edited_phones(contact, vcard, params)
- rows = params["phone"]
- if rows.is_a?(Hash)
- contact.phones.each do |phone|
+ #
+ # `field` is the form's name for the kind and `name` the property
+ # an added row is written as. The rows the add dialog reveals
+ # (Admin::ContactsEdit) land as bare lines before END:VCARD, and a
+ # blank one inserts nothing — inserting absence is a no-op, the
+ # plan's rule for new rows, and the reason the rows nobody typed
+ # in cost nothing whether they were on screen or not. Array()
+ # because the field is a list and a request carrying one value
+ # is still a list of one.
+ #: (Array[Contact::Phone | Contact::Email] rows, String field, String name, VCard vcard, Hash[String, untyped] params) -> VCard
+ def self.edited_values(rows, field, name, vcard, params)
+ edits = params[field]
+ if edits.is_a?(Hash)
+ rows.each do |row|
# The property is nil only for a line that would not read,
- # and phones read from lines that did; the guard is the
+ # and these rows read from lines that did; the guard is the
# type's honesty, not a reachable case (n_line's own shape).
- property = phone.line.property
- next if property.nil?
-
- submitted = rows[phone.line.digest]
- next if submitted.nil? || submitted.to_s.strip == phone.value
-
- value = submitted.to_s.strip
- vcard = vcard.substitute(
- phone.line.digest,
- value.empty? ? [] : ["#{VCard.header_of(property)}#{VCard.escape(value)}"]
- )
- end
- end
-
- # The rows the add dialog reveals (Admin::ContactsEdit): each
- # value lands as a bare TEL before END:VCARD, and a blank one
- # inserts nothing — inserting absence is a no-op, the plan's
- # rule for new rows, and the reason the rows nobody typed in
- # cost nothing whether they were on screen or not. Array()
- # because the field is a list and a request carrying one value
- # is still a list of one.
- submitted = Array(params["new_phone"]) #: Array[untyped]
- added = submitted.filter_map {
- value = it.to_s.strip
- "TEL:#{VCard.escape(value)}\r\n" unless value.empty?
- } #: Array[String]
- vcard.insert(added)
- end
-
- # #edited_phones' walk, over EMAIL.
- #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
- def self.edited_emails(contact, vcard, params)
- rows = params["email"]
- if rows.is_a?(Hash)
- contact.emails.each do |email|
- property = email.line.property
+ property = row.line.property
next if property.nil?
- submitted = rows[email.line.digest]
- next if submitted.nil? || submitted.to_s.strip == email.value
+ submitted = edits[row.line.digest]
+ next if submitted.nil? || submitted.to_s.strip == row.value
value = submitted.to_s.strip
vcard = vcard.substitute(
- email.line.digest,
+ row.line.digest,
value.empty? ? [] : ["#{VCard.header_of(property)}#{VCard.escape(value)}"],
)
end
end
- submitted = Array(params["new_email"]) #: Array[untyped]
+ submitted = Array(params["new_#{field}"]) #: Array[untyped]
added = submitted.filter_map {
value = it.to_s.strip
- "EMAIL:#{VCard.escape(value)}\r\n" unless value.empty?
+ "#{name}:#{VCard.escape(value)}\r\n" unless value.empty?
} #: Array[String]
vcard.insert(added)
end
- # #edited_phones' walk, over a row that is six fields rather than
+ # #edited_values' walk, over a row that is six fields rather than
# one (Admin::ContactsEdit). Removal is the reader's own rule
# (Contact#address_of): a row blank throughout, po box included,
# removes the line, where a partially blanked one keeps it —
@@ -287,7 +262,7 @@ module ProTacts
#: (Contact contact, VCard vcard, String first, String middle, String last) -> VCard
def self.edited_fn(contact, vcard, first, middle, last)
family, given, additional = contact.name_components || []
- named = vcard.properties.any? { it.name.casecmp?("FN") }
+ named = !vcard.property("FN").nil?
return vcard if named && first == given.to_s && middle == additional.to_s && last == family.to_s
vcard.replace("FN", ["FN:#{VCard.escape(display_name(first, middle, last))}\r\n"])
@@ -315,7 +290,7 @@ module ProTacts
# empties a whole-N writer would leave.
#: (VCard vcard, String first, String middle, String last) -> String
def self.n_line(vcard, first, middle, last)
- property = vcard.properties.find { it.name.casecmp?("N") }
+ property = vcard.property("N")
components = [] #: Array[String]
readings = [] #: Array[String]
if property
@@ -332,7 +307,7 @@ module ProTacts
"N:#{components.join(";")}\r\n"
end
- private_class_method :edited_phones, :edited_emails, :edited_addresses, :address_unchanged?,
+ private_class_method :edited_values, :edited_addresses, :address_unchanged?,
:address_line, :birthday_component, :text_lines, :edited_fn, :display_name,
:n_line
end
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 34ae5ab..b54af9e 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -173,32 +173,13 @@ module ProTacts
input(type: "text", name: "nickname", value: @own.nickname,
placeholder: @own.nickname ? "removed on save" : nil)
end
- # A phone row edits its value and nothing else: the
- # TYPE parameters ride in the line's own header,
- # which the save keeps (VCard.header_of) — a header
- # rebuilt from form fields would drop the parameters
- # no field models, and macOS writes three TYPE
- # parameters on one TEL. The digest in the field's
- # name is the row's address, and a blank value
- # removes the line. Identical duplicate lines share a
- # digest and therefore a field name, and Rack keeps
- # the last value of a duplicated name — editing one
- # of a pair of byte-identical rows means blanking
- # one, saving, then editing the other.
@own.phones.each do |phone|
- label(class: "field", data: {blank_removes: true}) do
- span { Format.type_label(phone.label, phone.types, "phone") }
- input(type: "tel", name: "phone[#{phone.line.digest}]",
- value: phone.value, placeholder: "removed on save")
- end
+ value_row(Format.type_label(phone.label, phone.types, "phone"),
+ type: "tel", name: "phone[#{phone.line.digest}]", value: phone.value)
end
- # An email row, the phone row's own shape over EMAIL.
@own.emails.each do |email|
- label(class: "field", data: {blank_removes: true}) do
- span { Format.type_label(nil, email.types, "email") }
- input(type: "email", name: "email[#{email.line.digest}]",
- value: email.value, placeholder: "removed on save")
- end
+ value_row(Format.type_label(nil, email.types, "email"),
+ type: "email", name: "email[#{email.line.digest}]", value: email.value)
end
@own.addresses.each do
address_row(it)
@@ -241,7 +222,7 @@ module ProTacts
# scaffold docs/DESIGN.md refuses, and a trailing one left over
# from a row already filled is the same scaffold arriving late.
# A blank added row is a no-op rather than a removal
- # (CardForm.edited_phones), so these rows wear no removal state —
+ # (CardForm.edited_values), so these rows wear no removal state —
# the class comment's exemption.
# That verb — make a row, now, without a round trip — is what
# CSS could not do and what Alpine is here for (see Layout).
@@ -301,6 +282,26 @@ module ProTacts
end
end
+ # A standing single-value row — a phone, an email — editing its
+ # value and nothing else: the TYPE parameters ride in the line's
+ # own header, which the save keeps (VCard.header_of), where a
+ # header rebuilt from form fields would drop the parameters no
+ # field models, and macOS writes three TYPE parameters on one
+ # TEL. The digest in the field's name is the row's address, and
+ # a blank value removes the line — the state is unconditional,
+ # a row standing only over a line there is to lose. Identical
+ # duplicate lines share a digest and therefore a field name, and
+ # Rack keeps the last value of a duplicated name — editing one of
+ # a pair of byte-identical rows means blanking one, saving, then
+ # editing the other.
+ #: (String caption, type: String, name: String, value: String) -> void
+ def value_row(caption, type:, name:, value:)
+ label(class: "field", data: {blank_removes: true}) do
+ span { caption }
+ input(type:, name:, value:, placeholder: "removed on save")
+ end
+ end
+
# The one row that saves to the model rather than the card's
# bytes, so the prefill is Contact#birthday and not a reading
# off the card — why, and why every shape the grammar admits is
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index e17dcc6..764f771 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -238,7 +238,7 @@ module ProTacts
# repeats into one issue.
#: () -> Photo?
def photo
- property = properties.find { it.name.casecmp?("PHOTO") }
+ property = vcard.property("PHOTO")
return if property.nil?
begin
@@ -268,7 +268,7 @@ module ProTacts
# FN's value (RFC 2426 section 3.1.1), in text form.
#: () -> String?
def name
- text_of(properties.find { it.name.casecmp?("FN") })
+ text_of(vcard.property("FN"))
end
# N's components (RFC 2426 section 3.1.2: family; given; additional;
@@ -278,7 +278,7 @@ module ProTacts
# free-text FN.
#: () -> Array[String?]?
def name_components
- property = properties.find { it.name.casecmp?("N") }
+ property = vcard.property("N")
property && components_of(property)
end
@@ -286,7 +286,7 @@ module ProTacts
# whole value, as the card spells it, when it comma-lists several.
#: () -> String?
def nickname
- text_of(properties.find { it.name.casecmp?("NICKNAME") })
+ text_of(vcard.property("NICKNAME"))
end
#: () -> Array[Phone]
@@ -300,7 +300,7 @@ module ProTacts
Phone.new(
value:,
label: group && labels[group],
- types: types_of(property, except: %w[voice]),
+ types: property.types - %w[voice],
line:,
) if value
}
@@ -312,7 +312,7 @@ module ProTacts
value = text_of(property)
# RFC 2426 section 3.3.2: `internet` is the format every address
# has by default, so it names none.
- Email.new(value:, types: types_of(property, except: %w[internet]), line:) if value
+ Email.new(value:, types: property.types - %w[internet], line:) if value
}
end
@@ -437,20 +437,8 @@ module ProTacts
return if components.none?
po_box, extended, street, locality, region, postal_code, country = components
- Address.new(po_box:, extended:, street:, locality:, region:, postal_code:, country:, types: types_of(property), line:)
+ Address.new(po_box:, extended:, street:, locality:, region:, postal_code:, country:, types: property.types, line:)
end
- # Every TYPE value (RFC 2426 section 3.3.1) that names a kind of
- # line. `pref` ranks a line instead, and the client adds it on its
- # own (docs/apple-contacts.md, "The client rewrites every card it
- # touches"); `except` holds a property's own values that name no
- # kind either. Downcased: the spelling is the card's, and a screen
- # shows one.
- #: (VCard::Parser::Property property, ?except: Array[String]) -> Array[String]
- def types_of(property, except: [])
- property.parameters
- .filter_map { |name, value| value.downcase if name.casecmp?("TYPE") }
- .uniq - ["pref", *except]
- end
end
end
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
index 8139ae6..a7b2485 100644
--- a/lib/pro_tacts/import/macos.rb
+++ b/lib/pro_tacts/import/macos.rb
@@ -313,7 +313,8 @@ module ProTacts
# `item2.` are one spelling, since the number counts a card's
# groups rather than saying anything about the line, and a
# trailing `type=pref` is left off, ranking a line rather than
- # naming a kind (Contact#types_of drops it for the same reason).
+ # naming a kind (VCard::Parser::Property#types drops it for the
+ # same reason).
FORMS = {
"FN" => ["FN"],
"N" => ["N"],
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 9cf96d0..7b8f34a 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -348,15 +348,12 @@ module ProTacts
end
# `sole` rather than `first`: the id is the primary key, so a second
- # row is a corruption and not a choice to make quietly. It raises on
- # no row too, which here is the ordinary answer for an href nobody
- # has — the 404 path — so that one is caught and turned back into
- # nil.
+ # row is a corruption and not a choice to make quietly. No row is
+ # the ordinary answer for an href nobody has — the 404 path.
#: (String id) -> Contact?
def contact(id)
- contact_from(cards.where(id:).sole, birthday_of(id), inherited_of(id))
- rescue Sequel::NoMatchingRow
- nil
+ row = cards.where(id:).sole_or_nil
+ row && contact_from(row, birthday_of(id), inherited_of(id))
end
# The groups a contact belongs to, whole — a tag names one and
@@ -401,9 +398,8 @@ module ProTacts
# is set either way.
#: (String login) -> String
def book_name(login)
- books.where(login:).sole.fetch(:name).to_s
- rescue Sequel::NoMatchingRow
- login
+ row = books.where(login:).sole_or_nil
+ row ? row.fetch(:name).to_s : login
end
# Sets the name a login's book goes by, or with nil or a blank goes
@@ -436,9 +432,8 @@ module ProTacts
# cards sharing a UID is a corruption to raise on, not a choice.
#: (String uid) -> String?
def card_id_with_uid(uid)
- card_properties.where(name: "UID", value: uid).sole.fetch(:card_id).to_s
- rescue Sequel::NoMatchingRow
- nil
+ row = card_properties.where(name: "UID", value: uid).sole_or_nil
+ row && row.fetch(:card_id).to_s
end
# Stores a card and everything that has to move with it: the
@@ -452,7 +447,7 @@ module ProTacts
# it back in (docs/plans/2026-08-31-partial-birthdays.md).
#
# A card rather than its bytes, so the reading a caller already
- # made is the one the split below decides from: a PUT has asked
+ # made is the one #split_birthday decides from: a PUT has asked
# whether the bytes are a card at all and whose UID they carry
# before it gets here (Web#write_card), and taking the card it
# asked those of leaves the walk behind them made once.
@@ -473,43 +468,14 @@ module ProTacts
# A rewrite of a card that exists joins nothing.
#: (String id, VCard vcard, ?client: bool) -> Contact
def put(id, vcard, client: false)
- # The birthday half of the split a write makes
- # (docs/plans/2026-09-11-every-birthday-in-the-model.md). A card
- # holds at most one BDAY, and one that reads as a birthday leaves
- # the card for the model. Any other stays in the card byte for
- # byte, reported, with the model emptied so nothing composes a
- # second BDAY beside it (RFC 6352 section 6.3.2.2).
existing = birthday_of(id)
- # The card as it stands before this write, read once for the two
- # halves that need it: the no-BDAY arm below, which reports what
- # it drops, and the subtraction after it, which accounts for the
- # member's own lines before attributing any to a group.
+ # The card as it stands before this write, read once for the
+ # three things that want it: the birthday split, which reports
+ # what a rewrite drops; the subtraction after it, which accounts
+ # for the member's own lines before attributing any to a group;
+ # and the card the change log records this write as replacing.
own = stored_card(id)
- bdays, rest = vcard.extract("BDAY")
- birthday, stored =
- if bdays.empty?
- # No BDAY: a client that was sent the birthday removed it, and
- # one that was never sent it cannot have. A BDAY line left in
- # the stored card goes with the rewrite, which is worth saying.
- report_lost_bday_lines(own)
- [existing && !existing.served? ? existing : nil, vcard]
- elsif bdays.length > 1
- report_many_bday_lines(bdays.length)
- [nil, vcard]
- else
- line = BirthdayLine.read(bdays.fetch(0))
- case line
- when BirthdayLine::Modeled
- [line.birthday, rest]
- when BirthdayLine::Unrecognized
- report_unrecognized_bday_line
- [nil, vcard]
- when BirthdayLine::Unreadable
- [nil, vcard]
- else
- raise "no arm for #{line.class}"
- end
- end
+ birthday, stored = split_birthday(vcard, existing, own)
# The other half of the split, the same shape as the birthday's:
# what the groups lend comes back out of the submission before it
@@ -544,9 +510,7 @@ module ProTacts
fan_out(member_ids(edits.map(&:group_id).uniq), except: id) { apply_group_edits(edits) }
contact = Contact.new(id:, stored:, birthday:, inherited: inheritance(lent_of(id)))
end
- cards
- .insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
- .insert(id: contact.id, vcard: stored.to_s)
+ upsert_card(contact.id, stored)
# After the card, which the membership names by foreign key, and
# before the log entry, which records the card composed with
# whatever the group lends. The put's entry is the card's arrival
@@ -577,9 +541,7 @@ module ProTacts
before = contact(id)
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: vcard.to_s)
+ upsert_card(contact.id, vcard)
write_birthday(contact.id, birthday)
record(contact.id, "edit", contact.etag, CardDiff.between(before&.vcard, contact.vcard))
reindex(contact.id, vcard)
@@ -705,9 +667,8 @@ module ProTacts
# #contact's own shape.
#: (String id) -> Group?
def group(id)
- load_groups([groups.select(*GROUP_COLUMNS, group_label.as(:label)).where(id:).sole]).fetch(0)
- rescue Sequel::NoMatchingRow
- nil
+ row = groups.select(*GROUP_COLUMNS, group_label.as(:label)).where(id:).sole_or_nil
+ row && load_groups([row]).fetch(0)
end
# A group's name, or none — NULL being the one spelling of
@@ -801,7 +762,10 @@ module ProTacts
# the ordinary miss, #delete's shape.
#: (String id) -> bool
def delete_group(id)
- name = groups.where(id:).sole.fetch(:name).to_s
+ row = groups.where(id:).sole_or_nil
+ return false if row.nil?
+
+ name = row.fetch(:name).to_s
# Refused before the transaction, #name_book's reason for the
# same refusal: Sequel wraps what a rollback raises.
raise EveryonesBookName, "#{EVERYONE} is everyone's book, not a group to delete" if name == EVERYONE
@@ -813,8 +777,6 @@ module ProTacts
}
true
end
- rescue Sequel::NoMatchingRow
- false
end
# A card's side of #edit_group: one transaction, leavers first and
@@ -915,6 +877,16 @@ module ProTacts
@database[:group_properties]
end
+ # A card written where one may already stand. The stamp is set
+ # again on the way past because SQLite has no ON UPDATE and the
+ # column default only fires on insert (NOW).
+ #: (String id, VCard vcard) -> void
+ def upsert_card(id, vcard)
+ cards
+ .insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
+ .insert(id:, vcard: vcard.to_s)
+ end
+
# The diff comes in already computed rather than being taken here
# off a `before` and an `after`: only the caller knows which two
# cards its write was between, and a delete's `after` is nothing at
@@ -1001,14 +973,13 @@ module ProTacts
}
end
- # The id of the `sync:*` group, created on first use. `sole` because
- # a group's name is unique (db/migrations/008_group_names.rb), and
- # no row is the ordinary answer for the first create.
+ # The id of the `sync:*` group, created on first use. One row
+ # because a group's name is unique
+ # (db/migrations/008_group_names.rb).
#: () -> String
def everyone_group_id
- groups.where(name: EVERYONE).sole.fetch(:id).to_s
- rescue Sequel::NoMatchingRow
- create_group(name: EVERYONE)
+ row = groups.where(name: EVERYONE).sole_or_nil
+ row ? row.fetch(:id).to_s : create_group(name: EVERYONE)
end
# The group name that puts cards in this login's book alone.
@@ -1090,6 +1061,48 @@ module ProTacts
Sequel::Migrator.run(@database, MIGRATIONS.to_s)
end
+ # The birthday half of the split a write makes
+ # (docs/plans/2026-09-11-every-birthday-in-the-model.md): the
+ # model's new birthday, and the card to store beside it. A card
+ # holds at most one BDAY, and one that reads as a birthday leaves
+ # the card for the model. Any other stays in the card byte for
+ # byte, reported, with the model emptied so nothing composes a
+ # second BDAY beside it (RFC 6352 section 6.3.2.2).
+ #
+ # No BDAY is a client that was sent the birthday removing it, or
+ # one that was never sent it and so cannot have. A BDAY line left
+ # in the stored card goes with the rewrite, which is worth saying.
+ #
+ # case/when rather than case/in, BirthdayLine's own rule: Steep
+ # does not check the bodies of case/in branches, and these are the
+ # ones worth checking — which is what the last arm is for.
+ #: (VCard vcard, Birthday? existing, VCard? own) -> [Birthday?, VCard]
+ def split_birthday(vcard, existing, own)
+ bdays, rest = vcard.extract("BDAY")
+ if bdays.empty?
+ report_lost_bday_lines(own)
+ return [existing && !existing.served? ? existing : nil, vcard]
+ end
+
+ if bdays.length > 1
+ report_many_bday_lines(bdays.length)
+ return [nil, vcard]
+ end
+
+ line = BirthdayLine.read(bdays.fetch(0))
+ case line
+ when BirthdayLine::Modeled
+ [line.birthday, rest]
+ when BirthdayLine::Unrecognized
+ report_unrecognized_bday_line
+ [nil, vcard]
+ when BirthdayLine::Unreadable
+ [nil, vcard]
+ else
+ raise "no arm for #{line.class}"
+ end
+ end
+
# The card currently stored for an id, or nil for a card being
# created. A plain `first` read: the primary key leaves `sole`
# nothing to catch, and no row is the ordinary answer.
@@ -1211,7 +1224,7 @@ module ProTacts
return :unshareable unless shareable?(edited)
taken << strike(unaccounted, edited)
- GroupEdit.new(group_id: row.group_id, position: row.position, line: content_line(edited))
+ GroupEdit.new(group_id: row.group_id, position: row.position, line: edited.content)
end
# The line out of the pool, so no second lent line is attributed
@@ -1225,18 +1238,6 @@ module ProTacts
line
end
- # A line as a group holds one: unfolded and shorn of its
- # terminator, the same unit CardDiff records a write in and the
- # shape db/migrations/004_groups.rb's own rows are written in.
- # The bytes are the client's, not a re-render under the group's old
- # header — a relabel is one of the edits this carries, and
- # rebuilding the header would propagate the value and drop the
- # label (the plan's "What a propagated edit stores").
- #: (VCard::Parser::Line line) -> String
- def content_line(line)
- VCard::Parser.unfold(line.verbatim).sub(/(\r\n|[\r\n])\z/, "")
- end
-
# Whether a group could hold this line: one of the names
# SHAREABLE_NAMES admits, and no property group in front of it —
# the CHECK's two clauses, read off the parse rather than matched
@@ -1333,22 +1334,17 @@ module ProTacts
STRUCTURED_VALUES.include?(property.name.upcase) ? property.components : [property.text]
end
- # A line's TYPE values, as the set the round trip preserves:
- # casefolded, `pref` dropped as the client's own addition, and
- # sorted because `TYPE=home;TYPE=pref` and `TYPE=pref,home` are one
- # thing (RFC 2426 section 3.2.1, which the parser reads into pairs
- # either way). Empty for a line that would not read, which has no
+ # A line's TYPE values as the set the round trip preserves, sorted
+ # because `TYPE=home;TYPE=pref` and `TYPE=pref,home` are one thing
+ # (RFC 2426 section 3.2.1, which the parser reads into pairs either
+ # way). Empty for a line that would not read, which has no
# parameters to compare.
#: (VCard::Parser::Line line) -> Array[String]
def types_of(line)
property = line.property
return [] if property.nil?
- property.parameters
- .filter_map { |name, value| value.downcase if name.casecmp?("TYPE") }
- .reject { it == "pref" }
- .uniq
- .sort
+ property.types.sort
end
# The submission's lines that the member's own stored card does not
@@ -1383,8 +1379,7 @@ module ProTacts
# attribute — two lines of that name arrived that the member's card
# does not explain, and calling either the edit would be a guess.
# The card is stored as it arrived and the line is news, the same
- # bargain report_unrecognized_bday_line makes, and carries no card
- # content for the same reason (config.ru).
+ # bargain report_unrecognized_bday_line makes.
#: (Integer count) -> void
def report_ambiguous_inherited_lines(count)
return if count.zero?
@@ -1402,8 +1397,7 @@ module ProTacts
# under one — which comes back as a property group with an
# `X-ABLabel` beside it (#kept_types?), and the news is worth
# having because that member now serves the line twice — its own
- # copy and the group's — until someone reconciles them. No card
- # content, the ambiguity report's own line.
+ # copy and the group's — until someone reconciles them.
#: (Integer count) -> void
def report_unshareable_lines(count)
return if count.zero?
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index d648b9a..64a953c 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -144,7 +144,15 @@ module ProTacts
# already assumes for them.
#: () -> String?
def uid
- properties.find { it.name.casecmp?("UID") }&.value
+ property("UID")&.value
+ end
+
+ # The first property naming `name`, or nil where the card carries
+ # none — the read every caller after a property a card holds one of
+ # makes. Names match without case (RFC 2426 section 4).
+ #: (String name) -> Parser::Property?
+ def property(name)
+ properties.find { it.name.casecmp?(name) }
end
# The lines naming `name`, and the card without them: the split
@@ -189,15 +197,10 @@ module ProTacts
first = all.index { it.names?(name) }
return insert(lines) if first.nil?
- # The replaced line's own terminator, so a card written in one
- # line-break convention stays single-convention; a terminated
- # replacement line keeps its own, folds and all.
- terminator = terminator_of(all.fetch(first).verbatim)
- replacements = lines.map { it.end_with?("\n") ? it : it + terminator }
kept = all.reject { it.names?(name) }.map(&:verbatim)
# `first` still names the splice point: the lines before it are
# untouched, and every line it counted past was a named one.
- kept.insert(first, *replacements)
+ kept.insert(first, *terminate(lines, all.fetch(first).verbatim))
VCard.new(kept.join)
end
@@ -210,28 +213,23 @@ module ProTacts
# the snapshot guard makes a miss news, not an ordinary case. A
# card carrying the same line twice is addressed as one row by
# both copies (they hash alike), and this substitutes the first;
- # the digests cannot tell identical bytes apart. A replacement
- # line with no terminator of its own takes the substituted line's,
- # #replace's own convention rule.
+ # the digests cannot tell identical bytes apart.
#: (String digest, Array[String] lines) -> VCard
def substitute(digest, lines)
all = self.lines
index = all.index { it.digest == digest }
raise KeyError, "no line of this card hashes to #{digest}" if index.nil?
- terminator = terminator_of(all.fetch(index).verbatim)
- replacements = lines.map { it.end_with?("\n") ? it : it + terminator }
+ replacements = terminate(lines, all.fetch(index).verbatim)
kept = all.map(&:verbatim)
kept.delete_at(index)
kept.insert(index, *replacements)
VCard.new(kept.join)
end
- # Lines into the card immediately before END:VCARD. A line with no
- # terminator of its own takes the END line's, so a card stays
- # single-convention; a terminated line keeps its own, folds and
- # all. With no END to anchor to there is no envelope worth
- # respecting, and the lines are appended with CRLF.
+ # Lines into the card immediately before END:VCARD. With no END to
+ # anchor to there is no envelope worth respecting, and the lines
+ # are appended to the end.
#
# A card back, like #extract's remainder: text with text put into it
# is still text, so there is nothing here for a caller to re-read or
@@ -240,31 +238,25 @@ module ProTacts
def insert(lines)
return self if lines.empty?
- physical = physical_lines
+ physical = Parser.physical_lines(@bytes)
index = physical.rindex { it.match?(END_LINE) }
- if index
- terminator = terminator_of(physical.fetch(index))
- physical.insert(index, *lines.map { it.end_with?("\n") ? it : it + terminator })
- VCard.new(physical.join)
- else
- VCard.new(@bytes + lines.map { it.end_with?("\n") ? it : it + "\r\n" }.join)
- end
+ return VCard.new(@bytes + terminate(lines).join) if index.nil?
+
+ physical.insert(index, *terminate(lines, physical.fetch(index)))
+ VCard.new(physical.join)
end
private
- # Physical lines with their terminators attached, so surgery on them
- # cannot lose or normalize a line break.
- #: () -> Array[String]
- def physical_lines
- @bytes.split(/(?<=\n)/, -1)
- end
-
- # The line break a line ends with, for the line inserted beside it
- # to match. CRLF when it has none, being the grammar's own.
- #: (String line) -> String
- def terminator_of(line)
- line[/\r?\n\z/] || "\r\n"
+ # The given lines, each with a terminator on it that has none,
+ # taken from the line it lands beside — so a card written in one
+ # line-break convention stays single-convention. A line that came
+ # with its own keeps it, folds and all, and with no neighbour to
+ # match the terminator is CRLF, the grammar's own.
+ #: (Array[String] lines, ?String neighbour) -> Array[String]
+ def terminate(lines, neighbour = "\r\n")
+ terminator = neighbour[/\r?\n\z/] || "\r\n"
+ lines.map { it.end_with?("\n") ? it : it + terminator }
end
end
end
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index 9c75bf1..22f7e1e 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -99,6 +99,19 @@ module ProTacts
.filter_map { |parameter, value| value if parameter.casecmp?(name) }
.first
end
+
+ # The TYPE values naming a kind of line (RFC 2426 section
+ # 3.3.1), casefolded because the spelling is the card's and
+ # every reader wants one. `pref` is not one of them: it ranks a
+ # line rather than naming a kind, and the client adds it on its
+ # own (docs/apple-contacts.md, "The client rewrites every card
+ # it touches").
+ #: () -> Array[String]
+ def types
+ parameters
+ .filter_map { |name, value| value.downcase if name.casecmp?("TYPE") }
+ .uniq - ["pref"]
+ end
end
# One logical line of a card, parsed beside the exact bytes that
@@ -150,6 +163,18 @@ module ProTacts
# the one pair of lines no address can tell apart.
#: () -> String
def digest = Digest::SHA256.hexdigest(verbatim)
+
+ # The line as a group holds one: unfolded and shorn of its
+ # terminator, the unit CardDiff records a write in and the
+ # shape db/migrations/004_groups.rb's own rows are written in.
+ # The bytes are the ones that arrived, not a re-render under an
+ # old header — a relabel is one of the edits a member's save
+ # carries, and rebuilding the header would propagate the value
+ # and drop the label
+ # (docs/plans/2026-09-09-group-edits-propagate.md, "What a
+ # propagated edit stores").
+ #: () -> String
+ def content = Parser.unfold(verbatim).sub(/#{LINE_BREAK}\z/, "")
end
# The content line, RFC 2426 section 4:
@@ -242,14 +267,15 @@ module ProTacts
.reject { it == "" }
end
- # Physical lines with their terminators attached, so the split
- # cannot lose or normalize a line break.
+ # Physical lines with their terminators attached, so no split of
+ # a card can lose or normalize a line break. Public for VCard,
+ # whose surgery on a card's bytes wants the same split.
#: (String card) -> Array[String]
def self.physical_lines(card)
card.split(/(?<=\n)/, -1)
end
- private_class_method :line_of, :logical_lines, :physical_lines
+ private_class_method :line_of, :logical_lines
#: (String logical_line) -> void
def initialize(logical_line)
diff --git a/lib/pro_tacts/web/dav.rb b/lib/pro_tacts/web/dav.rb
index 1cd17b9..ff18c45 100644
--- a/lib/pro_tacts/web/dav.rb
+++ b/lib/pro_tacts/web/dav.rb
@@ -255,7 +255,8 @@ module ProTacts
# Read on its own rather than through the collection: serving
# one href has no reason to load every other contact first.
r.get String do |filename|
- contact = member(filename.delete_suffix(".vcf"))
+ id = member_id(filename)
+ contact = id && member(id)
if contact
response["Content-Type"] = "text/vcard; charset=utf-8"
@@ -273,19 +274,16 @@ module ProTacts
# carrying the strong etag it was last served — see
# docs/apple-contacts.md, "What a write looks like on the wire".
r.put String do |filename|
- id = filename.delete_suffix(".vcf")
- # A last segment that is not this server's <id>.vcf shape
- # cannot address a resource here, created or read — the
- # same fall-through-to-404 the GET handler gives it.
- write_card(id) if id.match?(Contact::ID_FORMAT)
+ id = member_id(filename)
+ write_card(id) if id
end
# DELETE removes the card at the member URI (RFC 4918 section
# 9.6) — the DAV:unbind privilege the collection advertises,
# and what iOS sends when a contact is deleted on the phone.
r.delete String do |filename|
- id = filename.delete_suffix(".vcf")
- remove_card(id) if id.match?(Contact::ID_FORMAT)
+ id = member_id(filename)
+ remove_card(id) if id
end
end
end
@@ -333,6 +331,16 @@ module ProTacts
store.contact(id) if book.include?(id)
end
+ # The contact id a member URI's last segment names, or nil for a
+ # segment that is not this server's <id>.vcf shape and so addresses
+ # no resource here, created or read — every route under the
+ # collection falling through to the same 404.
+ #: (String filename) -> String?
+ def member_id(filename)
+ id = filename.delete_suffix(".vcf")
+ id if id.match?(Contact::ID_FORMAT)
+ end
+
#: () -> String
def ctag
@ctag ||= store.ctag
@@ -419,10 +427,12 @@ module ProTacts
# macOS sends If-Match on updates and If-None-Match: * on creates;
# a PUT carrying neither is unconditional and allowed to proceed.
if_match = request.env["HTTP_IF_MATCH"]
- return plain_412 if if_match && !if_match_satisfied?(if_match, existing)
+ return plain_412 if if_match && !etag_matches?(if_match, existing)
+ # The create — If-None-Match: * against an unmapped URI — is the
+ # case CardDAV clients send (RFC 6352 section 6.3.2).
if_none_match = request.env["HTTP_IF_NONE_MATCH"]
- return plain_412 if if_none_match && if_none_match_failed?(if_none_match, existing)
+ return plain_412 if if_none_match && etag_matches?(if_none_match, existing)
report_unreadable_lines(vcard)
report_broken_assumptions(vcard)
@@ -442,9 +452,7 @@ module ProTacts
# it sent, and the client refetches.
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
- # carry (Rack 3's lint rejects both); nil leaves it bodyless.
+ # Bodyless either way, #no_content's reason.
nil
end
@@ -469,15 +477,21 @@ module ProTacts
# because ignoring the header is the lost update it exists to
# refuse, and the PUT's own check is right here to reuse.
if_match = request.env["HTTP_IF_MATCH"]
- return plain_412 if if_match && !if_match_satisfied?(if_match, existing)
+ return plain_412 if if_match && !etag_matches?(if_match, existing)
# The change-log entry Store#delete leaves in the same transaction
# is what a syncing client is told: sync-collection answers a
# removed member as href plus 404 (DavXml::DAV#missing).
store.delete(id)
- response.status = 204
+ no_content
+ end
- # Bodyless for the 204, write_card's reason.
+ # A 204, bodyless: a returned "" would land in the body and pin
+ # text/html and content-length onto a status that must carry
+ # neither (Rack 3's lint rejects both), where nil leaves it empty.
+ #: () -> nil
+ def no_content
+ response.status = 204
nil
end
@@ -547,22 +561,13 @@ module ProTacts
""
end
- # RFC 7232 section 3.1: If-Match passes when the current etag is one
- # of those listed, or, for `*`, when there is a current
- # representation at all.
- #: (String header, Contact? existing) -> bool
- def if_match_satisfied?(header, existing)
- return !existing.nil? if header.strip == "*"
-
- !existing.nil? && header.split(",").map(&:strip).include?(existing.etag)
- end
-
- # RFC 7232 section 3.2: If-None-Match fails a non-GET request when
- # the current etag is one of those listed, or, for `*`, whenever the
- # resource exists. The create — If-None-Match: * against an unmapped
- # URI — is the case CardDAV clients send (RFC 6352 section 6.3.2).
+ # Whether the current representation is one the header names: an
+ # etag in its list, or, for `*`, any representation at all. The one
+ # question both conditionals ask, each reading the answer its own
+ # way — If-Match passes on a match (RFC 7232 section 3.1) and
+ # If-None-Match fails on one (section 3.2).
#: (String header, Contact? existing) -> bool
- def if_none_match_failed?(header, existing)
+ def etag_matches?(header, existing)
return !existing.nil? if header.strip == "*"
!existing.nil? && header.split(",").map(&:strip).include?(existing.etag)
diff --git a/lib/sequel/extensions/sole.rb b/lib/sequel/extensions/sole.rb
index 0fc9066..7390e05 100644
--- a/lib/sequel/extensions/sole.rb
+++ b/lib/sequel/extensions/sole.rb
@@ -40,6 +40,18 @@ module Sequel
rows.fetch(0)
end
+
+ # The same read where no row is the ordinary answer — an href
+ # nobody has, a login whose book was never named — so the caller
+ # says so once here rather than spelling the rescue out again.
+ # Only the empty case: more rows than the filter claimed is still
+ # the broken assumption #sole exists to raise on.
+ #: () -> Hash[Symbol, untyped]?
+ def sole_or_nil
+ sole
+ rescue Sequel::NoMatchingRow
+ nil
+ end
end
Dataset.register_extension(:sole, Sole)