contact: carry the group that lends each inherited line
A borrowed line arrived with nobody's name attached;
now the group comes with it, so a row can say who lent.
And NOTE reads every line the card has, not the first,
since a member with its own still inherits the household's.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index ff85bde..176d50e 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -175,10 +175,13 @@ module ProTacts
@contact.addresses.each { address_row(it) }
birthday_row
added_rows
- label(class: "field", data: {blank_removes: !!@contact.notes}) do
+ # The first NOTE the card carries: this row edits one,
+ # and a save writes one (Web#edited_card's replace).
+ note = @contact.notes.first&.value
+ label(class: "field", data: {blank_removes: !!note}) do
span { "Note" }
textarea(name: "note", rows: 4,
- placeholder: @contact.notes ? "removed on save" : nil) { @contact.notes.to_s }
+ placeholder: note ? "removed on save" : nil) { note.to_s }
end
# Save is the form's submit; Cancel is navigation — a
# link in Gloss's `.btn` contract, which is what an
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 3ad26f0..0c3103e 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -93,7 +93,7 @@ module ProTacts
#: () -> bool
def has_data?
@contact.phones.any? || @contact.emails.any? || @contact.addresses.any? ||
- !@birthday.nil? || !@contact.notes.nil?
+ !@birthday.nil? || @contact.notes.any?
end
# A missing TYPE parameter still gets a key: the fallback names
@@ -103,7 +103,7 @@ module ProTacts
@contact.emails.each { |email| row(email.type || "email", email.value) }
@contact.addresses.each { |address| row(address.type || "address", address_lines(address)) }
row("birthday", @birthday) if @birthday
- row("notes", @contact.notes) if @contact.notes
+ @contact.notes.each { row("notes", it.value) }
end
#: (String type, String | Array[String] value) -> void
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 4a9415d..0a84a5b 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -52,9 +52,10 @@ module ProTacts
# @rbs @id: String
# @rbs @stored: VCard
# @rbs @birthday: Birthday?
- # @rbs @inherited: Array[String]
+ # @rbs @inherited: Array[Inherited]
# @rbs @vcard: VCard
# @rbs @etag: String
+ # @rbs @groups_by_line: Hash[String, String]
# Ids end up in paths and arrive from client-supplied hrefs, so an id
# outside this charset cannot be served.
@@ -77,7 +78,17 @@ module ProTacts
# @rbs skip
Email = Data.define(:value, :type, :line)
# @rbs skip
+ Note = Data.define(:value, :line)
+ # @rbs skip
Photo = Data.define(:mime_type, :bytes)
+
+ # One line a group lends this contact, and the name of the group
+ # that lends it. The name rather than the group itself, because
+ # the name is the whole of what a card shows about it today and
+ # nothing here can navigate to a record that has no screen yet
+ # (docs/DESIGN.md, "Relationships are navigable").
+ # @rbs skip
+ Inherited = Data.define(:group, :line)
# @rbs skip
Address = Data.define(
:po_box,
@@ -102,7 +113,7 @@ module ProTacts
# member its group's lines no longer reach. Named `stored:` rather
# than `vcard:` because what #vcard returns is composed, and an
# argument that is not what the reader hands back is a trap.
- #: (id: String, stored: VCard, birthday: Birthday?, inherited: Array[String]) -> Contact
+ #: (id: String, stored: VCard, birthday: Birthday?, inherited: Array[Inherited]) -> Contact
def self.for(id:, stored:, birthday:, inherited:)
raise ArgumentError, "invalid contact id: #{id}" unless id.match?(ID_FORMAT)
@@ -114,7 +125,7 @@ module ProTacts
%("#{Digest::SHA256.hexdigest(vcard.to_s)}")
end
- #: (String id, VCard stored, Birthday? birthday, Array[String] inherited) -> void
+ #: (String id, VCard stored, Birthday? birthday, Array[Inherited] inherited) -> void
def initialize(id, stored, birthday, inherited)
@id = id
@stored = stored
@@ -152,7 +163,7 @@ module ProTacts
def vcard
return @vcard if defined?(@vcard)
- card = @stored.insert(@inherited)
+ card = @stored.insert(@inherited.map { it.line })
line = @birthday && @birthday.to_line
@vcard = line ? card.insert([line]) : card
end
@@ -287,14 +298,55 @@ module ProTacts
end
end
- # The card's NOTE (RFC 2426 section 3.6.2), in text form.
- #: () -> String?
+ # The card's NOTEs (RFC 2426 section 3.6.2), in text form. Every
+ # one, not the first: a member with a note of its own and a group
+ # that lends it another carries two, and RFC 6350 section 6.7.2
+ # settles that this is a card and not a broken one — NOTE's
+ # cardinality is `*`, and 2426 restricts it nowhere. Showing one
+ # would hide whichever the composition happened to put second,
+ # which is always the group's (see #vcard).
+ #: () -> Array[Note]
def notes
- text_of(properties.find { it.name.casecmp?("NOTE") })
+ of_line("NOTE").filter_map do |line|
+ property = line.property
+ next if property.nil?
+
+ value = text_of(property)
+ Note.new(value:, line:) if value
+ end
+ end
+
+ # The name of the group a composed line came from, or nil for a
+ # line the contact's own card carries — the provenance a screen
+ # marks an inherited row with.
+ #
+ # Matched by the line's bytes, which is the same blindness the
+ # editor's digests have (VCard::Parser::Line#digest): a stored
+ # line whose bytes are a group's line reads as the group's here.
+ # That state is a member's rewrite carrying an inherited line back
+ # into its own card, which the write half subtracts
+ # (docs/plans/2026-08-24-vcard-storage-and-groups.md); until it
+ # lands, naming the group over both copies says something true
+ # about the value even where it is wrong about the byte.
+ #: (VCard::Parser::Line line) -> String?
+ def group_of(line)
+ groups_by_line[line.verbatim.chomp]
end
private
+ # Every inherited line's group, keyed by the line's own bytes with
+ # the terminator off — a group's line is stored without one and
+ # composes with the card's, so neither side is compared as it lies.
+ #: () -> Hash[String, String]
+ def groups_by_line
+ return @groups_by_line if defined?(@groups_by_line)
+
+ @groups_by_line = @inherited.to_h {
+ [it.line.chomp, it.group] #: [String, String]
+ }
+ end
+
# The lines naming `name`, parsed beside their bytes — the walk the
# provenance-carrying accessors read from, so a row can carry the
# line it was read from as its address. A line that would not
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 0ffe85f..2dd7294 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -523,7 +523,7 @@ module ProTacts
# Contact composes the served one — its vcard, its etag, everything
# a caller reads — with the inherited lines beside it, so all
# describe what a client downloads rather than the bytes on disk.
- #: (Hash[Symbol, untyped] row, Birthday? birthday, Array[String] inherited) -> Contact
+ #: (Hash[Symbol, untyped] row, Birthday? birthday, Array[Contact::Inherited] inherited) -> Contact
def contact_from(row, birthday, inherited)
Contact.for(
id: row.fetch(:id).to_s,
@@ -569,13 +569,14 @@ module ProTacts
row && birthday_from(row)
end
- # The content lines a contact inherits — every property of every
- # group it belongs to, nothing of a group that holds nothing, and
- # [] for a contact in no group at all. Ordered by group id and then
- # position, so the composed card is the same bytes every read.
- #: (String id) -> Array[String]
+ # The content lines a contact inherits, each beside the name of the
+ # group lending it — every property of every group it belongs to,
+ # nothing of a group that holds nothing, and [] for a contact in no
+ # group at all. Ordered by group id and then position, so the
+ # composed card is the same bytes every read.
+ #: (String id) -> Array[Contact::Inherited]
def inherited_of(id)
- inherited_rows.where(card_id: id).map { it.fetch(:line).to_s }
+ inherited_rows.where(card_id: id).map { inherited_from(it) }
end
# Every contact's inheritance in one pass, keyed by card, for the
@@ -583,21 +584,30 @@ module ProTacts
# birthdays_by_id, for the same reason. Only a contact with
# something to inherit appears; the absent key reads as [] at the
# caller, which is what it means.
- #: () -> Hash[String, Array[String]]
+ #: () -> Hash[String, Array[Contact::Inherited]]
def inherited_by_id
inherited_rows.all
.group_by { it.fetch(:card_id).to_s }
- .transform_values { |rows| rows.map { it.fetch(:line).to_s } }
+ .transform_values { |rows| rows.map { inherited_from(it) } }
end
# The join both inherited reads walk: a membership to the property
- # it inherits, qualified and ordered so the composition neither
- # depends on what SQLite feels like returning nor trips over the
- # group_id the two tables share.
+ # it inherits and to the group's own row for its name, qualified
+ # and ordered so the composition neither depends on what SQLite
+ # feels like returning nor trips over the group_id the tables
+ # share. Ordered by the group's id rather than its name, because a
+ # rename must not move a member's lines and change every etag in
+ # the group.
#: () -> Sequel::Dataset
def inherited_rows
group_members
.join(:group_properties, group_id: :group_id)
+ .join(:groups, id: Sequel[:group_members][:group_id])
+ .select(
+ Sequel[:group_members][:card_id],
+ Sequel[:groups][:name].as(:group_name),
+ Sequel[:group_properties][:line],
+ )
.order(
Sequel[:group_members][:card_id],
Sequel[:group_properties][:group_id],
@@ -605,6 +615,11 @@ module ProTacts
)
end
+ #: (Hash[Symbol, untyped] row) -> Contact::Inherited
+ def inherited_from(row)
+ Contact::Inherited.new(group: row.fetch(:group_name).to_s, line: row.fetch(:line).to_s)
+ end
+
# A birthday row read as the model. The shape was validated on the
# way in, so a row that no longer parses is corruption to raise on
# rather than quietly drop.
@@ -616,7 +631,7 @@ module ProTacts
# One joined birthday-and-card row as an UpcomingBirthday, or nil
# for a shape that lands on no calendar day (see
# #upcoming_birthdays).
- #: (Hash[Symbol, untyped] row, Date today, Array[String] inherited) -> UpcomingBirthday?
+ #: (Hash[Symbol, untyped] row, Date today, Array[Contact::Inherited] inherited) -> UpcomingBirthday?
def upcoming_from(row, today, inherited)
birthday = birthday_from(row)
month, day = birthday.month, birthday.day
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
index bf631a6..c3f102e 100644
--- a/sig/gems/sequel.rbs
+++ b/sig/gems/sequel.rbs
@@ -78,6 +78,10 @@ module Sequel
def where: (*untyped conditions) -> Dataset
def order: (*untyped columns) -> Dataset
+ # The columns a joined dataset comes back with, named rather than
+ # merged by whichever table's column won. Signed here so it reads
+ # as the query builder's rather than Enumerable's own #select.
+ def select: (*untyped columns) -> Dataset
def limit: (Integer count) -> Dataset
# Hash conditions pair the joined table's column with the source's
# (`id: :card_id` is cards.id = birthdays.card_id); the array form
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index 63a5576..3bbdaf3 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -1,4 +1,5 @@
-# Contact::Phone, ::Email, ::Photo, and ::Address are Data classes, whose
+# Contact::Phone, ::Email, ::Note, ::Inherited, ::Photo, and ::Address are
+# Data classes, whose
# members the inline parser cannot read (see sig/pro_tacts/birthday.rbs
# for the same limit). Each is marked `@rbs skip` in contact.rb and
# signed here; Contact itself is a plain class and carries its
@@ -21,6 +22,20 @@ module ProTacts
def self.new: (value: String, type: String?, line: VCard::Parser::Line) -> instance
end
+ class Note
+ attr_reader value: String
+ attr_reader line: VCard::Parser::Line
+
+ def self.new: (value: String, line: VCard::Parser::Line) -> instance
+ end
+
+ class Inherited
+ attr_reader group: String
+ attr_reader line: String
+
+ def self.new: (group: String, line: String) -> instance
+ end
+
class Photo
attr_reader mime_type: String
attr_reader bytes: String
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 469359e..29fc308 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -24,8 +24,14 @@ class ContactTest < Minitest::Test
# passes a card carrying an unmodeled BDAY beside nil — no stored
# card ever carries a BDAY the model recomposes — and inheritance
# defaults to none, the shape of every contact in no group.
- def contact(vcard = CARD, id: "aiden", birthday: nil, inherited: [])
- ProTacts::Contact.for(id:, stored: ProTacts::VCard.new(vcard), birthday:, inherited:)
+ # Inherited lines are given as bare strings and lent by GROUP, the
+ # name a composed row is marked with; a test that needs a second
+ # lender passes its own.
+ GROUP = "Boole household" #: String
+
+ def contact(vcard = CARD, id: "aiden", birthday: nil, inherited: [], group: GROUP)
+ lent = inherited.map { ProTacts::Contact::Inherited.new(group:, line: it) }
+ ProTacts::Contact.for(id:, stored: ProTacts::VCard.new(vcard), birthday:, inherited: lent)
end
# The decode's refusal reports (see #photo); the transport this pins
@@ -228,7 +234,7 @@ class ContactTest < Minitest::Test
assert_empty contact.phones
assert_empty contact.emails
assert_empty contact.addresses
- assert_nil contact.notes
+ assert_empty contact.notes
end
# The birthday is the model held beside the card, not a parse of
@@ -310,8 +316,34 @@ class ContactTest < Minitest::Test
assert_equal "+44 20 5555 0100", contact(card, inherited: ["TEL;TYPE=home:+44 20 5555 0100"]).phones.first&.value
end
+ # Which lines a group lends is the composed card's own question:
+ # a row a screen marks as shared asks it per line, and a line the
+ # stored card carries answers nil however much it looks inherited.
+ def test_a_line_names_the_group_that_lends_it
+ lent = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom"
+ contact = contact(STRUCTURED, inherited: [lent])
+ addresses = contact.addresses
+
+ assert_equal 2, addresses.size
+ assert_nil contact.group_of(addresses.fetch(0).line)
+ assert_equal GROUP, contact.group_of(addresses.fetch(1).line)
+ end
+
+ # A member with a note of its own and a group that lends it another
+ # carries both: NOTE's cardinality is `*` (RFC 6350 section 6.7.2),
+ # so the accessor reads every one — the member's first, the group's
+ # after it, the order #vcard composes.
+ def test_notes_read_the_members_own_and_the_groups
+ contact = contact(STRUCTURED, inherited: ["NOTE:Gate code 1854."])
+ notes = contact.notes
+
+ assert_equal ["Countess, mathematician.", "Gate code 1854."], notes.map { it.value }
+ assert_nil contact.group_of(notes.fetch(0).line)
+ assert_equal GROUP, contact.group_of(notes.fetch(1).line)
+ end
+
def test_unescapes_notes
- assert_equal "Countess, mathematician.", contact(STRUCTURED).notes
+ assert_equal ["Countess, mathematician."], contact(STRUCTURED).notes.map { it.value }
end
def test_properties_are_the_parsed_card
@@ -331,7 +363,7 @@ class ContactTest < Minitest::Test
assert_empty bare.emails
assert_empty bare.addresses
assert_nil bare.birthday
- assert_nil bare.notes
+ assert_empty bare.notes
end
# A card that will not read is still a contact: the bytes are what
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index d695431..a4893c6 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1047,6 +1047,20 @@ class StoreTest < Minitest::Test
end
end
+ # Every inherited line carries the name of the group lending it, on
+ # the single read and on the listing read that composes a whole
+ # collection — a screen marks a row as the household's from either.
+ def test_an_inherited_line_carries_its_groups_name
+ with_store({"aiden" => AIDEN}) do |store|
+ add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+
+ listed = store.contacts.find { it.id == "aiden" }
+ [store.contact("aiden"), listed].each do |contact|
+ assert_equal "Household", contact.group_of(contact.addresses.fetch(0).line)
+ end
+ end
+ end
+
# The schema is the only gate on what a group may hold until
# authoring exists, so what it admits is worth pinning: an address
# or a note, bare or parameterized, in whatever case the author