Promote Store::Group to ProTacts::Group, sync: rules and all
Group was a Data class nested in Store with behavior of its own
(reading, sync?, version), read by a dozen admin views and held by
Contact::Inherited, so the model depended on the store's namespace.
It moves to lib/pro_tacts/group.rb beside Contact, and every
Store::Group becomes Group.
The sync: naming rules were spelled twice, as Group#sync? and
Store#sync_name?. SYNC_PREFIX, EVERYONE and sync_name? now live on
Group, and both callers use Group.sync_name?.
Two reads Store repeated fold into one each:
- #contact is #contact! with the rescue, and #reid reads through
#contact! for both of its contacts.
- The groups row with its label, selected five times, is
#labeled_groups. GROUP_COLUMNS goes with it, and with it the comment
#18 cut in half by dropping its first line.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi55bFkB6wo4jguNtsase4
diff --git a/AGENTS.md b/AGENTS.md
index 4eefbd0..861b9a0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -80,6 +80,8 @@ lib/pro_tacts/
│ # group change log, and the derived index
├── contact.rb # the one model of a contact: id, vCard, etag,
│ # and the structured accessors over the card
+├── group.rb # A group as a screen reads one, and the `sync:`
+│ # names that put cards in a book
├── edited_contact.rb # A submitted card read against the contact it
│ # replaces: the card, birthday, and group edits
│ # Store#put writes
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index 398923d..2d82f5e 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -58,10 +58,10 @@ module ProTacts
# The group editor's save: the note replaced and the contact
# editor's own address splice over the group's lines read as a
- # card (Store::Group#reading), then back into the lines a group
+ # card (Group#reading), then back into the lines a group
# holds — each unfolded and shorn of its terminator, the unit
# CardDiff records a write in and group_properties stores.
- #: (Store::Group group, Hash[String, untyped] params) -> Array[String]
+ #: (Group group, Hash[String, untyped] params) -> Array[String]
def self.group_lines(group, params)
reading = group.reading
card = reading.stored.replace("NOTE", text_lines("NOTE", params["note"].to_s.strip))
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index 22b5762..03efdf9 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -27,11 +27,11 @@ module ProTacts
# (ImportSaved), whose trunk RecordCard owns.
class ContactCard < Phlex::HTML
# @rbs @contact: Contact
- # @rbs @groups: Array[Store::Group]
+ # @rbs @groups: Array[Group]
# @rbs @dialog: bool
# @rbs @card: bool
- #: (contact: Contact, groups: Array[Store::Group], ?dialog: bool, ?card: bool) -> void
+ #: (contact: Contact, groups: Array[Group], ?dialog: bool, ?card: bool) -> void
def initialize(contact:, groups:, dialog: false, card: true)
@contact = contact
@groups = groups
@@ -125,7 +125,7 @@ module ProTacts
end
end
- #: (Store::Group group) -> void
+ #: (Group group) -> void
def group_tag(group)
a(href: "/groups/#{group.id}", class: "tag") { render GroupLabel.new(group:) }
end
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index 926dbcb..310a5ab 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -15,9 +15,9 @@ module ProTacts
class ContactsIndex < Phlex::HTML
# @rbs @rows: Array[Contact]
# @rbs @login: String
- # @rbs @groups_of: Hash[String, Array[Store::Group]]
+ # @rbs @groups_of: Hash[String, Array[Group]]
- #: (contacts: Array[Contact], groups: Array[Store::Group], login: String) -> void
+ #: (contacts: Array[Contact], groups: Array[Group], login: String) -> void
def initialize(contacts:, groups:, login:)
@login = login
@rows = contacts.sort_by { [Format.sort_key(it), it.id] }
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 99708bf..5f0ad95 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -26,7 +26,7 @@ module ProTacts
# one: a card with no entries is a card whose history was lost,
# and an empty default would render that as an ordinary quiet
# record. Every group is the groups dialog's (GroupDialog).
- #: (Contact contact, String login, Array[Store::Group] groups, Array[Store::GroupChoice] all_groups, Array[Store::Change] changes, ?notice: String?) -> void
+ #: (Contact contact, String login, Array[Group] groups, Array[Store::GroupChoice] all_groups, Array[Store::Change] changes, ?notice: String?) -> void
def initialize(contact:, login:, groups:, all_groups:, changes:, notice: nil)
@contact = contact
@login = login
diff --git a/lib/pro_tacts/admin/dashboard.rb b/lib/pro_tacts/admin/dashboard.rb
index d12597c..33dfe9c 100644
--- a/lib/pro_tacts/admin/dashboard.rb
+++ b/lib/pro_tacts/admin/dashboard.rb
@@ -26,14 +26,14 @@ module ProTacts
# @rbs @login: String
# @rbs @upcoming: Array[Store::UpcomingBirthday]
# @rbs @labels: Hash[String, Array[String]]
- # @rbs @groups: Array[Store::Group]
+ # @rbs @groups: Array[Group]
# @rbs @rows: Array[Store::RecentContact]
# @rbs @notice: String?
# The groups arrive whole because a search answers from both
# sides of the relationship: a group is findable by its own name,
# and a contact by the names of the groups it is in.
- #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, login: String, groups: Array[Store::Group], ?notice: String?) -> void
+ #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, login: String, groups: Array[Group], ?notice: String?) -> void
def initialize(recent:, upcoming:, query:, login:, groups:, notice: nil)
@query = query.to_s.strip
@login = login
diff --git a/lib/pro_tacts/admin/group_label.rb b/lib/pro_tacts/admin/group_label.rb
index 8305fae..96c7fe0 100644
--- a/lib/pro_tacts/admin/group_label.rb
+++ b/lib/pro_tacts/admin/group_label.rb
@@ -7,11 +7,11 @@ module ProTacts
# its id, marked `data-nameless` so admin.css can set it as an
# identifier rather than let it pass for a name.
class GroupLabel < Phlex::HTML
- # @rbs @group: Store::Group | Store::GroupChoice
+ # @rbs @group: Group | Store::GroupChoice
# A Group or the lighter GroupChoice a picker carries: this reads
# only the name and the id, which both hold.
- #: (group: Store::Group | Store::GroupChoice) -> void
+ #: (group: Group | Store::GroupChoice) -> void
def initialize(group:)
@group = group
end
diff --git a/lib/pro_tacts/admin/groups_edit.rb b/lib/pro_tacts/admin/groups_edit.rb
index f346548..5261463 100644
--- a/lib/pro_tacts/admin/groups_edit.rb
+++ b/lib/pro_tacts/admin/groups_edit.rb
@@ -23,7 +23,7 @@ module ProTacts
# no `members[]`, so a save says nothing about membership
# (#apply_edit's is-a-Hash posture) and leaves it as it stands.
class GroupsEdit < Phlex::HTML
- # @rbs @group: Store::Group
+ # @rbs @group: Group
# @rbs @login: String
# @rbs @reading: Contact
# @rbs @notice: String?
@@ -32,7 +32,7 @@ module ProTacts
FORM = "group-form" #: String
private_constant :FORM
- #: (group: Store::Group, login: String, ?notice: String?) -> void
+ #: (group: Group, login: String, ?notice: String?) -> void
def initialize(group:, login:, notice: nil)
@group = group
@login = login
diff --git a/lib/pro_tacts/admin/groups_index.rb b/lib/pro_tacts/admin/groups_index.rb
index bc0411e..bfa7198 100644
--- a/lib/pro_tacts/admin/groups_index.rb
+++ b/lib/pro_tacts/admin/groups_index.rb
@@ -14,11 +14,11 @@ module ProTacts
# and a create lands on the new group's editor, where everything
# it will hold is added.
class GroupsIndex < Phlex::HTML
- # @rbs @groups: Array[Store::Group]
+ # @rbs @groups: Array[Group]
# @rbs @login: String
# @rbs @notice: String?
- #: (groups: Array[Store::Group], login: String, ?notice: String?) -> void
+ #: (groups: Array[Group], login: String, ?notice: String?) -> void
def initialize(groups:, login:, notice: nil)
@login = login
# The `sync:` groups first — the books a client syncs, which
@@ -60,7 +60,7 @@ module ProTacts
private
- #: (Store::Group group) -> String
+ #: (Group group) -> String
def members_label(group)
count = group.members.length
count == 1 ? "1 member" : "#{count} members"
diff --git a/lib/pro_tacts/admin/groups_members.rb b/lib/pro_tacts/admin/groups_members.rb
index a5bff21..d43e8d1 100644
--- a/lib/pro_tacts/admin/groups_members.rb
+++ b/lib/pro_tacts/admin/groups_members.rb
@@ -31,7 +31,7 @@ module ProTacts
# make what the filter names is not rendered, a filter word not
# being able to mint a contact.
class GroupsMembers < Phlex::HTML
- # @rbs @group: Store::Group
+ # @rbs @group: Group
# @rbs @login: String
# @rbs @contacts: Array[Contact]
# @rbs @capped: Array[String]
@@ -40,7 +40,7 @@ module ProTacts
FORM = "members-form" #: String
private_constant :FORM
- #: (group: Store::Group, contacts: Array[Contact], login: String) -> void
+ #: (group: Group, contacts: Array[Contact], login: String) -> void
def initialize(group:, contacts:, login:)
@group = group
@login = login
diff --git a/lib/pro_tacts/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
index be70a60..15e3e4b 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -21,14 +21,14 @@ module ProTacts
# log names the cards a join or a leave moved, and a name is what
# a person reads; the members list is the narrowing of it.
class GroupsShow < Phlex::HTML
- # @rbs @group: Store::Group
+ # @rbs @group: Group
# @rbs @login: String
# @rbs @reading: Contact
# @rbs @contacts: Array[Contact]
# @rbs @members: Array[Contact]
# @rbs @changes: Array[Store::GroupChange]
- #: (group: Store::Group, contacts: Array[Contact], changes: Array[Store::GroupChange], login: String) -> void
+ #: (group: Group, contacts: Array[Contact], changes: Array[Store::GroupChange], login: String) -> void
def initialize(group:, contacts:, changes:, login:)
@group = group
@login = login
diff --git a/lib/pro_tacts/admin/import_saved.rb b/lib/pro_tacts/admin/import_saved.rb
index 32b851f..cfcef97 100644
--- a/lib/pro_tacts/admin/import_saved.rb
+++ b/lib/pro_tacts/admin/import_saved.rb
@@ -19,7 +19,7 @@ module ProTacts
class ImportSaved < Phlex::HTML
# @rbs @contact: Contact
# @rbs @login: String
- # @rbs @groups: Array[Store::Group]
+ # @rbs @groups: Array[Group]
# @rbs @all_groups: Array[Store::GroupChoice]
# @rbs @aside: Phlex::HTML
# @rbs @sidebar: Phlex::HTML
@@ -27,7 +27,7 @@ module ProTacts
# @rbs @land: String
# @rbs @notice: String?
- #: (contact: Contact, login: String, groups: Array[Store::Group], all_groups: Array[Store::GroupChoice], aside: Phlex::HTML, sidebar: Phlex::HTML, edit: String, land: String, ?notice: String?) -> void
+ #: (contact: Contact, login: String, groups: Array[Group], all_groups: Array[Store::GroupChoice], aside: Phlex::HTML, sidebar: Phlex::HTML, edit: String, land: String, ?notice: String?) -> void
def initialize(contact:, login:, groups:, all_groups:, aside:, sidebar:, edit:, land:, notice: nil)
@contact = contact
@login = login
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 273faa1..a86c4b2 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -46,7 +46,7 @@ module ProTacts
# @rbs @inherited: Array[Inherited]
# @rbs @vcard: VCard
# @rbs @etag: String
- # @rbs @groups_by_line: Hash[String, Store::Group]
+ # @rbs @groups_by_line: Hash[String, Group]
# Ids end up in paths and arrive from client-supplied hrefs, so an id
# outside this charset cannot be served.
@@ -354,7 +354,7 @@ module ProTacts
# card spells the line exactly as its group does still has one, and
# naming the group over both copies says something true about the
# value even where it is wrong about the byte.
- #: (VCard::Parser::Line line) -> Store::Group?
+ #: (VCard::Parser::Line line) -> Group?
def group_of(line)
groups_by_line[line.verbatim.chomp]
end
@@ -364,12 +364,12 @@ module ProTacts
# 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, Store::Group]
+ #: () -> Hash[String, Group]
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, Store::Group]
+ [it.line.chomp, it.group] #: [String, Group]
}
end
diff --git a/lib/pro_tacts/group.rb b/lib/pro_tacts/group.rb
new file mode 100644
index 0000000..c43cf25
--- /dev/null
+++ b/lib/pro_tacts/group.rb
@@ -0,0 +1,71 @@
+require "digest"
+require "json"
+
+require "pro_tacts/contact"
+require "pro_tacts/vcard"
+
+module ProTacts
+ # A group as the admin screens read one: its own row, the lines it
+ # lends in the order it lends them, and its members' card ids. The
+ # label is the SQL one Store#group_label computes, so a tag and a
+ # heading can never disagree about what to call a nameless group.
+ #
+ # The signature lives in sig/pro_tacts/group.rbs, this being a Data
+ # class.
+ # @rbs skip
+ Group = Data.define(:id, :name, :label, :lines, :members)
+
+ # Reopened rather than defined in the block above, for the reason
+ # CardDiff is.
+ class Group
+ # The group names that choose what a client syncs: `sync:*` for
+ # everyone, `sync:<login>` for one user
+ # (docs/plans/2026-09-12-per-user-books.md). Unique like every
+ # group's name (db/migrations/008_group_names.rb).
+ SYNC_PREFIX = "sync:" #: String
+ EVERYONE = "#{SYNC_PREFIX}*" #: String
+
+ # Whether a group of this name chooses what a client syncs. Said of
+ # a name rather than a group, because the store asks it of a row
+ # and of a name a rename is about to write.
+ #: (String? name) -> bool
+ def self.sync_name?(name)
+ name&.start_with?(SYNC_PREFIX) == true
+ end
+
+ # The group's lines read through the one model that knows how to
+ # read an address and a note — a Contact over a card made of
+ # those lines and nothing else — for the screens that render and
+ # edit them. It composes nothing and is never stored or served.
+ #: () -> Contact
+ def reading
+ Contact.new(id:, stored: VCard.new(lines.map { "#{it}\r\n" }.join), birthday: nil, inherited: [])
+ end
+
+ # Whether this is one of the `sync:` groups, for the listing that
+ # sets those groups apart from the ones a person made
+ # (Admin::GroupsIndex).
+ #: () -> bool
+ def sync?
+ Group.sync_name?(name)
+ end
+
+ # The group's etag, for the editor's snapshot guard: a hash of
+ # everything the edit screen shows and the save writes, so a
+ # group edited since the page loaded refuses the stale save
+ # rather than reverting what changed (Web#apply_edit's rule).
+ # Nothing serves it, so nothing has to agree with it but the form.
+ #: () -> String
+ def version
+ Digest::SHA256.hexdigest([name, lines, members].to_json)
+ end
+
+ # What a console session sees for one (console.rb): the Data
+ # class's own inspect would carry every lent line and every
+ # member id, and a listing of groups is unreadable at a prompt.
+ #: () -> String
+ def inspect
+ "#<#{self.class.name} id=#{id.inspect} label=#{label.inspect} members=#{members.length}>"
+ end
+ end
+end
diff --git a/lib/pro_tacts/import/write.rb b/lib/pro_tacts/import/write.rb
index 8dca61c..bf3fe92 100644
--- a/lib/pro_tacts/import/write.rb
+++ b/lib/pro_tacts/import/write.rb
@@ -90,7 +90,7 @@ module ProTacts
# ticked and asked for — and a membership written twice is a
# constraint violation rather than a second membership.
join = [*chosen, *named.map { group_id(it) }]
- join << group_id(Store::EVERYONE) if everyone
+ join << group_id(Group::EVERYONE) if everyone
@store.regroup(id, join: join.uniq, leave: []) unless join.empty?
# Read back rather than kept from the put, because the group
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index d062457..3f92b2e 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -8,6 +8,7 @@ require "pro_tacts/card_diff"
require "pro_tacts/change_id"
require "pro_tacts/contact"
require "pro_tacts/edited_contact"
+require "pro_tacts/group"
require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"
@@ -99,54 +100,6 @@ module ProTacts
# @rbs skip
Snapshot = Data.define(:cards, :birthdays, :groups, :books)
- # A group as the admin screens read one: its own row, the lines it
- # lends in the order it lends them, and its members' card ids. The
- # label is the SQL one #group_label computes, so a tag and a
- # heading can never disagree about what to call a nameless group.
- # @rbs skip
- Group = Data.define(:id, :name, :label, :lines, :members)
-
- # Reopened rather than defined in the block above, for the reason
- # CardDiff is.
- class Group
- # The group's lines read through the one model that knows how to
- # read an address and a note — a Contact over a card made of
- # those lines and nothing else — for the screens that render and
- # edit them. It composes nothing and is never stored or served.
- #: () -> Contact
- def reading
- Contact.new(id:, stored: VCard.new(lines.map { "#{it}\r\n" }.join), birthday: nil, inherited: [])
- end
-
- # Whether this is one of the `sync:` groups that choose what a
- # client syncs — #sync_name?'s rule, said of a group a screen
- # already holds rather than of a row's name, for the listing that
- # sets those groups apart from the ones a person made
- # (Admin::GroupsIndex).
- #: () -> bool
- def sync?
- name&.start_with?(SYNC_PREFIX) == true
- end
-
- # The group's etag, for the editor's snapshot guard: a hash of
- # everything the edit screen shows and the save writes, so a
- # group edited since the page loaded refuses the stale save
- # rather than reverting what changed (Web#apply_edit's rule).
- # Nothing serves it, so nothing has to agree with it but the form.
- #: () -> String
- def version
- Digest::SHA256.hexdigest([name, lines, members].to_json)
- end
-
- # What a console session sees for one (console.rb): the Data
- # class's own inspect would carry every lent line and every
- # member id, and a listing of groups is unreadable at a prompt.
- #: () -> String
- def inspect
- "#<#{self.class.name} id=#{id.inspect} label=#{label.inspect} members=#{members.length}>"
- end
- end
-
# A group as a picker names it: enough to show it and to submit it,
# and no more. A Group carries the lines it lends and the ids of
# its members, two reads beyond the group row itself (#load_groups);
@@ -183,16 +136,6 @@ module ProTacts
# than a run of bad draws.
CONTACT_ID_ATTEMPTS = 8 #: Integer
- # the label is written against the table by name.
- GROUP_COLUMNS = [Sequel[:groups][:id], Sequel[:groups][:name]].freeze #: Array[untyped]
-
- # The group names that choose what a client syncs: `sync:*` for
- # everyone, `sync:<login>` for one user
- # (docs/plans/2026-09-12-per-user-books.md). Unique like every
- # group's name (db/migrations/008_group_names.rb).
- SYNC_PREFIX = "sync:" #: String
- EVERYONE = "#{SYNC_PREFIX}*" #: String
-
# A book named `*`, which would be everyone's (#name_book).
class EveryonesBookName < ArgumentError; end
@@ -345,7 +288,7 @@ module ProTacts
# nil.
#: (String id) -> Contact?
def contact(id)
- contact_from(cards.where(id:).sole, birthday_of(id), inherited_of(id))
+ contact!(id)
rescue Sequel::NoMatchingRow
nil
end
@@ -361,11 +304,10 @@ module ProTacts
#: (String id) -> Array[Group]
def groups_of(id)
load_groups(
- groups
+ labeled_groups
.join(:group_members, group_id: :id)
.where(card_id: id)
.order(Sequel[:groups][:id])
- .select(*GROUP_COLUMNS, group_label.as(:label))
.all,
)
end
@@ -380,7 +322,7 @@ module ProTacts
# (docs/plans/2026-09-21-books-not-book-names.md).
#: (String login) -> Set[String]
def book_cards(login)
- ids = groups.where(name: [EVERYONE, own_sync_name(login)]).select_map(:id)
+ ids = groups.where(name: [Group::EVERYONE, own_sync_name(login)]).select_map(:id)
Set.new(group_members.where(group_id: ids).select_map(:card_id).map(&:to_s))
end
@@ -408,7 +350,7 @@ module ProTacts
def name_book(login, name)
name = name.to_s.strip
name = nil if name.empty?
- raise EveryonesBookName, "#{EVERYONE} is everyone's book, not #{login}'s" if name == "*"
+ raise EveryonesBookName, "#{Group::EVERYONE} is everyone's book, not #{login}'s" if name == "*"
@database.transaction do
group = groups.where(name: own_sync_name(login)).select_map(:id).first
@@ -564,10 +506,10 @@ module ProTacts
# keep serving the dead href out of its cache forever.
#: (String id) -> String
def reid(id)
- # `sole` rather than #contact's nil-for-404: a read whose filter
- # means one row, and no row here is the caller's mistake to hear
- # about as Sequel::NoMatchingRow, not a request to answer.
- before = contact_from(cards.where(id:).sole, birthday_of(id), inherited_of(id))
+ # #contact! rather than #contact's nil-for-404: no row here is the
+ # caller's mistake to hear about as Sequel::NoMatchingRow, not a
+ # request to answer.
+ before = contact!(id)
CONTACT_ID_ATTEMPTS.times do
new_id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
@@ -595,7 +537,7 @@ module ProTacts
# rather than moved — the deal #reindex always gives it. The
# parameters follow their properties away on the cascade.
card_properties.where(card_id: id).delete
- after = contact_from(cards.where(id: new_id).sole, birthday_of(new_id), inherited_of(new_id))
+ after = contact!(new_id)
record(id, action: Action::DELETE, etag: nil, diff: CardDiff.between(before.vcard, nil))
record(
new_id,
@@ -654,7 +596,7 @@ module ProTacts
# composition both use, so a rename never moves one.
#: () -> Array[Group]
def all_groups
- load_groups(groups.select(*GROUP_COLUMNS, group_label.as(:label)).order(:id).all)
+ load_groups(labeled_groups.order(:id).all)
end
# Every group as a picker needs it: the row, its label and how many
@@ -670,7 +612,7 @@ module ProTacts
#: () -> Array[GroupChoice]
def group_choices
member_count = group_members.where(group_id: Sequel[:groups][:id]).select(Sequel.function(:count).*)
- groups.select(*GROUP_COLUMNS, group_label.as(:label), member_count.as(:member_count)).order(:id).all.map { |row|
+ labeled_groups.select_append(member_count.as(:member_count)).order(:id).all.map { |row|
GroupChoice.new(id: row.fetch(:id).to_s, name: row.fetch(:name)&.to_s, label: row.fetch(:label).to_s,
member_count: row.fetch(:member_count).to_i)
}
@@ -680,7 +622,7 @@ 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)
+ load_groups([labeled_groups.where(id:).sole]).fetch(0)
rescue Sequel::NoMatchingRow
nil
end
@@ -700,7 +642,7 @@ module ProTacts
name = nil if name.to_s.strip.empty?
@database.transaction do
was = groups.where(id:).sole.fetch(:name)&.to_s
- if was != name && (sync_name?(was) || sync_name?(name))
+ if was != name && (Group.sync_name?(was) || Group.sync_name?(name))
members = member_ids([id])
fan_out(members, moved: members) { groups.where(id:).update(name:) }
else
@@ -829,7 +771,7 @@ module ProTacts
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
+ raise EveryonesBookName, "#{Group::EVERYONE} is everyone's book, not a group to delete" if name == Group::EVERYONE
@database.transaction do
members = member_ids([id])
@@ -839,7 +781,7 @@ module ProTacts
action: GroupAction::DELETE,
detail: {"name" => row.fetch(:name), "lines" => lines, "members" => members},
)
- fan_out(members, moved: sync_name?(name) ? members : []) {
+ fan_out(members, moved: Group.sync_name?(name) ? members : []) {
groups.where(id:).delete
}
true
@@ -1057,25 +999,20 @@ module ProTacts
# (db/migrations/008_group_names.rb).
#: () -> String
def everyone_group_id
- groups.where(name: EVERYONE).sole.fetch(:id).to_s
+ groups.where(name: Group::EVERYONE).sole.fetch(:id).to_s
rescue Sequel::NoMatchingRow
- create_group(name: EVERYONE)
+ create_group(name: Group::EVERYONE)
end
# The group name that puts cards in this login's book alone.
#: (String login) -> String
def own_sync_name(login)
- "#{SYNC_PREFIX}#{book_name(login)}"
- end
-
- #: (String? name) -> bool
- def sync_name?(name)
- name&.start_with?(SYNC_PREFIX) == true
+ "#{Group::SYNC_PREFIX}#{book_name(login)}"
end
#: (String group_id) -> bool
def sync_group?(group_id)
- sync_name?(groups.where(id: group_id).sole.fetch(:name)&.to_s)
+ Group.sync_name?(groups.where(id: group_id).sole.fetch(:name)&.to_s)
end
#: (String group_id, String card_id) -> bool
@@ -1095,9 +1032,11 @@ module ProTacts
.map { it.fetch(:card_id).to_s }
end
- # A group_members row names a card by foreign key, so a missing
- # row here is the corruption `sole` exists to raise on rather than
- # the ordinary miss an href off the wire is.
+ # #contact for the callers to whom no row is not an ordinary answer:
+ # it raises Sequel::NoMatchingRow where #contact answers nil. A
+ # group_members row names a card by foreign key, so a missing row
+ # under #fan_out is the corruption `sole` exists to raise on, and
+ # #reid is handed an id its caller says exists.
#: (String id) -> Contact
def contact!(id)
contact_from(cards.where(id:).sole, birthday_of(id), inherited_of(id))
@@ -1246,7 +1185,7 @@ module ProTacts
def groups_by_id(ids)
return {} if ids.empty?
- load_groups(groups.select(*GROUP_COLUMNS, group_label.as(:label)).where(id: ids.uniq).all).to_h {
+ load_groups(labeled_groups.where(id: ids.uniq).all).to_h {
[it.id, it] #: [String, Group]
}
end
@@ -1260,6 +1199,14 @@ module ProTacts
Sequel.function(:coalesce, Sequel[:groups][:name], Sequel[:groups][:id])
end
+ # A groups row's own columns and its label, the start of every read
+ # that hands out a group, qualified for #group_label's reason: the
+ # label is written against the table by name.
+ #: () -> Sequel::Dataset
+ def labeled_groups
+ groups.select(Sequel[:groups][:id], Sequel[:groups][:name], group_label.as(:label))
+ 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
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index 0bc5413..625ff5d 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -126,13 +126,13 @@ module ProTacts
r.redirect "/groups/#{id}", 303
end
- #: (Store::Group group, ?notice: String) -> String
+ #: (Group group, ?notice: String) -> String
def group_edit_screen(group, notice: nil)
response["Content-Type"] = "text/html; charset=utf-8"
Admin::GroupsEdit.call(group:, login: @login, notice:)
end
- #: (Store::Group group) -> String
+ #: (Group group) -> String
def members_screen(group)
response["Content-Type"] = "text/html; charset=utf-8"
Admin::GroupsMembers.call(group:, contacts: store.contacts, login: @login)
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index b5a5983..858ad9e 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -316,7 +316,7 @@ module ProTacts
def import_picker(group, joined, named, member: nil)
choices = store.group_choices
lot = group.empty? ? nil : choices.find { it.name == group }
- everyone = choices.find { it.name == Store::EVERYONE }
+ everyone = choices.find { it.name == Group::EVERYONE }
none = [] #: Array[String]
was = member ? store.groups_of(member.id).map(&:id) : none
ticked = member ? was : [everyone&.id].compact
@@ -402,7 +402,7 @@ module ProTacts
# does not exist yet, so there is no row and no answer, and the
# card joins it the way every other create does
# (Import::Write#call).
- everyone = choices.find { it.name == Store::EVERYONE }
+ everyone = choices.find { it.name == Group::EVERYONE }
syncing = everyone.nil? || ticked.include?(everyone.id)
# N and FN are mandatory (RFC 2426 section 4), so a save blank
diff --git a/lib/pro_tacts/web/setup.rb b/lib/pro_tacts/web/setup.rb
index 0af4772..385a8dc 100644
--- a/lib/pro_tacts/web/setup.rb
+++ b/lib/pro_tacts/web/setup.rb
@@ -46,11 +46,11 @@ module ProTacts
response["Content-Type"] = "text/plain"
begin
store.name_book(@login, r.params["name"]&.to_s)
- "#{@login} syncs #{Store::SYNC_PREFIX}#{store.book_name(@login)}\n"
+ "#{@login} syncs #{Group::SYNC_PREFIX}#{store.book_name(@login)}\n"
rescue Sequel::UniqueConstraintViolation, Store::EveryonesBookName
# Another login's name, a group's already, or everyone's.
response.status = 409
- "#{Store::SYNC_PREFIX}#{r.params['name'].to_s.strip} is taken; #{@login}'s book is unchanged.\n"
+ "#{Group::SYNC_PREFIX}#{r.params['name'].to_s.strip} is taken; #{@login}'s book is unchanged.\n"
end
end
end
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
index 5bdb978..bfd78a5 100644
--- a/sig/gems/sequel.rbs
+++ b/sig/gems/sequel.rbs
@@ -91,6 +91,7 @@ module Sequel
# 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 select_append: (*untyped columns) -> Dataset
def limit: (Integer count) -> Dataset
# A dataset as a subquery under a name, for a column computed in
# another dataset's select (Store#group_choices' member count).
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index f25ff78..ec6e770 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -7,7 +7,7 @@
module ProTacts
module Admin
class ContactsShow
- def self.call: (contact: Contact, login: String, groups: Array[Store::Group], all_groups: Array[Store::GroupChoice], changes: Array[Store::Change], ?notice: String?) -> String
+ def self.call: (contact: Contact, login: String, groups: Array[Group], all_groups: Array[Store::GroupChoice], changes: Array[Store::Change], ?notice: String?) -> String
end
# `aside` is a Phlex component, which has no type here: phlex
@@ -18,27 +18,27 @@ module ProTacts
end
class Dashboard
- def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, login: String, groups: Array[Store::Group], ?notice: String?) -> String
+ def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, login: String, groups: Array[Group], ?notice: String?) -> String
end
class ContactsIndex
- def self.call: (contacts: Array[Contact], groups: Array[Store::Group], login: String) -> String
+ def self.call: (contacts: Array[Contact], groups: Array[Group], login: String) -> String
end
class GroupsIndex
- def self.call: (groups: Array[Store::Group], login: String, ?notice: String?) -> String
+ def self.call: (groups: Array[Group], login: String, ?notice: String?) -> String
end
class GroupsShow
- def self.call: (group: Store::Group, contacts: Array[Contact], changes: Array[Store::GroupChange], login: String) -> String
+ def self.call: (group: Group, contacts: Array[Contact], changes: Array[Store::GroupChange], login: String) -> String
end
class GroupsEdit
- def self.call: (group: Store::Group, login: String, ?notice: String?) -> String
+ def self.call: (group: Group, login: String, ?notice: String?) -> String
end
class GroupsMembers
- def self.call: (group: Store::Group, contacts: Array[Contact], login: String) -> String
+ def self.call: (group: Group, contacts: Array[Contact], login: String) -> String
end
class DeviceSetup
@@ -68,7 +68,7 @@ module ProTacts
end
class ImportSaved
- def self.call: (contact: Contact, login: String, groups: Array[Store::Group], all_groups: Array[Store::GroupChoice], aside: untyped, sidebar: untyped, edit: String, land: String, ?notice: String?) -> String
+ def self.call: (contact: Contact, login: String, groups: Array[Group], all_groups: Array[Store::GroupChoice], aside: untyped, sidebar: untyped, edit: String, land: String, ?notice: String?) -> String
end
# Rendered by Dashboard, not called from checked code; only
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index db24f36..e8c8edd 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -31,11 +31,11 @@ module ProTacts
end
class Inherited
- attr_reader group: Store::Group
+ attr_reader group: Group
attr_reader position: Integer
attr_reader line: String
- def self.new: (group: Store::Group, position: Integer, line: String) -> instance
+ def self.new: (group: Group, position: Integer, line: String) -> instance
end
class Photo
diff --git a/sig/pro_tacts/group.rbs b/sig/pro_tacts/group.rbs
new file mode 100644
index 0000000..09286f5
--- /dev/null
+++ b/sig/pro_tacts/group.rbs
@@ -0,0 +1,15 @@
+# Group is a Data class, whose members the inline parser cannot read
+# (see sig/pro_tacts/contact.rbs). It is marked `@rbs skip` in group.rb
+# and signed here instead; the methods reopened onto it carry their
+# own inline types.
+module ProTacts
+ class Group
+ attr_reader id: String
+ attr_reader name: String?
+ attr_reader label: String
+ attr_reader lines: Array[String]
+ attr_reader members: Array[String]
+
+ def self.new: (id: String, name: String?, label: String, lines: Array[String], members: Array[String]) -> instance
+ end
+end
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index f070db6..7af5774 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -38,16 +38,6 @@ module ProTacts
def self.new: (contact: Contact, occurs_on: Date) -> instance
end
- class Group
- attr_reader id: String
- attr_reader name: String?
- attr_reader label: String
- attr_reader lines: Array[String]
- attr_reader members: Array[String]
-
- def self.new: (id: String, name: String?, label: String, lines: Array[String], members: Array[String]) -> instance
- end
-
class GroupChoice
attr_reader id: String
attr_reader name: String?
diff --git a/tasks/book.rake b/tasks/book.rake
index a30bce7..b9f0b72 100644
--- a/tasks/book.rake
+++ b/tasks/book.rake
@@ -10,7 +10,7 @@ namespace :book do
login = ENV.fetch("LOGIN")
ProTacts::Store.connect(ProTacts.config.database_path) do |store|
store.name_book(login, ENV.fetch("NAME", nil))
- puts "#{login} syncs #{ProTacts::Store::SYNC_PREFIX}#{store.book_name(login)}"
+ puts "#{login} syncs #{ProTacts::Group::SYNC_PREFIX}#{store.book_name(login)}"
end
end
end
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index 4bb38b4..21307b5 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -136,7 +136,7 @@ module FixtureData
def self.seed_groups(store)
seed_group(store, name: "Booles", lines: HOUSEHOLD, members: MEMBERS)
seed_group(store, members: birthday_cards)
- seed_group(store, name: ProTacts::Store::EVERYONE, members: cards.keys - unsynced_cards)
+ seed_group(store, name: ProTacts::Group::EVERYONE, members: cards.keys - unsynced_cards)
seed_group(store, name: "Curies", members: CURIES)
seed_clubs(store)
end
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index c15901d..d4a41b7 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -614,7 +614,7 @@ class AdminImportPagesTest < Minitest::Test
save(id, 0, first: "Jane", last: "Booles", "named" => [])
- assert_equal [ProTacts::Store::EVERYONE], store.all_groups.map(&:name)
+ assert_equal [ProTacts::Group::EVERYONE], store.all_groups.map(&:name)
end
end
@@ -642,7 +642,7 @@ class AdminImportPagesTest < Minitest::Test
with_contacts({}) do |store|
id = upload(JANE + PLAIN)
save(id, 0, first: "Jane", last: "Booles")
- everyone = store.all_groups.find { it.name == ProTacts::Store::EVERYONE }
+ everyone = store.all_groups.find { it.name == ProTacts::Group::EVERYONE }
get "/import/#{id}/1"
@@ -661,7 +661,7 @@ class AdminImportPagesTest < Minitest::Test
with_contacts({}) do |store|
id = upload(JANE + PLAIN)
save(id, 0, first: "Jane", last: "Booles")
- everyone = store.all_groups.find { it.name == ProTacts::Store::EVERYONE }
+ everyone = store.all_groups.find { it.name == ProTacts::Group::EVERYONE }
save(id, 1, first: "Sam", last: "Booles", "groups" => [], "named" => [])
@@ -812,9 +812,9 @@ class AdminImportPagesTest < Minitest::Test
sam = store.contacts.find { it.name == "Sam Booles" }
assert_equal [jane.id], store.group(school).members
- assert_equal [lot, "school", ProTacts::Store::EVERYONE],
+ assert_equal [lot, "school", ProTacts::Group::EVERYONE],
store.all_groups.select { it.members.include?(jane.id) }.map(&:name).sort
- assert_equal [lot, ProTacts::Store::EVERYONE],
+ assert_equal [lot, ProTacts::Group::EVERYONE],
store.all_groups.select { it.members.include?(sam.id) }.map(&:name).sort
end
end
@@ -836,7 +836,7 @@ class AdminImportPagesTest < Minitest::Test
# The last row there was, so the walk ends: nothing was filed
# under a group of its own, and the book is where it went.
assert_equal "/contacts", last_response.headers["location"]
- assert_equal [ProTacts::Store::EVERYONE], store.all_groups.map(&:name)
+ assert_equal [ProTacts::Group::EVERYONE], store.all_groups.map(&:name)
end
end
diff --git a/test/pro_tacts/import/test_write.rb b/test/pro_tacts/import/test_write.rb
index 5a4cf52..731c187 100644
--- a/test/pro_tacts/import/test_write.rb
+++ b/test/pro_tacts/import/test_write.rb
@@ -67,7 +67,7 @@ class ImportWriteTest < Minitest::Test
assert_equal 2, ids.uniq.length
groups = store.all_groups.select { it.members.sort == ids.sort }
- assert_equal ["import-20260921T031655Z", ProTacts::Store::EVERYONE],
+ assert_equal ["import-20260921T031655Z", ProTacts::Group::EVERYONE],
groups.map(&:name).sort
end
end
@@ -96,7 +96,7 @@ class ImportWriteTest < Minitest::Test
id = Write.call(store, card, named: ["import-20260921T031655Z"], joins: [school]).id
assert_equal [id], store.group(school).members
- assert_equal ["import-20260921T031655Z", "school", ProTacts::Store::EVERYONE],
+ assert_equal ["import-20260921T031655Z", "school", ProTacts::Group::EVERYONE],
store.all_groups.select { it.members.include?(id) }.map(&:name).sort
end
end
@@ -114,7 +114,7 @@ class ImportWriteTest < Minitest::Test
assert_equal [first, third].sort, store.group(school).members.sort
assert_equal [third], store.group(work).members
- assert_equal [ProTacts::Store::EVERYONE],
+ assert_equal [ProTacts::Group::EVERYONE],
store.all_groups.select { it.members.include?(second) }.map(&:name)
end
end
@@ -156,7 +156,7 @@ class ImportWriteTest < Minitest::Test
id = Write.call(store, card, joins: [school]).id
assert_equal [id], store.group(school).members
- assert_equal %w[school], store.all_groups.map(&:name).reject { it == ProTacts::Store::EVERYONE }
+ assert_equal %w[school], store.all_groups.map(&:name).reject { it == ProTacts::Group::EVERYONE }
end
end
@@ -165,7 +165,7 @@ class ImportWriteTest < Minitest::Test
# is an answer of its own and the one a card arrives with.
def test_no_group_at_all_puts_the_card_in_everyones_book_alone
write_card do |imported, store|
- assert_equal [ProTacts::Store::EVERYONE], store.all_groups.map(&:name)
+ assert_equal [ProTacts::Group::EVERYONE], store.all_groups.map(&:name)
assert_equal [imported.id], store.all_groups.fetch(0).members
end
end
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 87b653c..601e592 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -27,7 +27,7 @@ class ContactTest < Minitest::Test
# Inherited lines are given as bare strings and lent by GROUP, the
# group a composed row is marked with; a test that needs a second
# lender passes its own.
- GROUP = ProTacts::Store::Group.new(id: "nous", name: "Booles", label: "Booles", lines: [], members: ["aiden"]) #: ProTacts::Store::Group
+ GROUP = ProTacts::Group.new(id: "nous", name: "Booles", label: "Booles", lines: [], members: ["aiden"]) #: ProTacts::Group
def contact(bytes = CARD, id: "aiden", birthday: nil, inherited: [], group: GROUP)
lent = inherited.each_with_index.map { |line, position| ProTacts::Contact::Inherited.new(group:, position:, line:) }
diff --git a/test/pro_tacts/test_edited_contact.rb b/test/pro_tacts/test_edited_contact.rb
index 818dccf..040787d 100644
--- a/test/pro_tacts/test_edited_contact.rb
+++ b/test/pro_tacts/test_edited_contact.rb
@@ -21,7 +21,7 @@ class EditedContactTest < Minitest::Test
EDITED_ADDRESS = HOUSEHOLD_ADDRESS.sub("7 Calculus", "8 Calculus") #: String
RESERIALIZED_ADDRESS = HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "ADR;type=HOME;type=pref:") #: String
OWN_ADDRESS = "ADR:;;1 Long Road;;;;" #: String
- HOUSEHOLD = ProTacts::Store::Group.new(
+ HOUSEHOLD = ProTacts::Group.new(
id: "kkkk", name: "Household", label: "Household", lines: [HOUSEHOLD_ADDRESS], members: ["aiden"],
)
LENT = [ProTacts::Contact::Inherited.new(group: HOUSEHOLD, position: 3, line: HOUSEHOLD_ADDRESS)].freeze
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 3fc2585..4654046 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1246,8 +1246,8 @@ class StoreTest < Minitest::Test
id = FixtureData.seed_group(store, name: "Household", members: %w[znorth aiden])
nameless = store.create_group
- assert_equal %(#<ProTacts::Store::Group id="#{id}" label="Household" members=2>), store.group(id).inspect
- assert_equal %(#<ProTacts::Store::Group id="#{nameless}" label="#{nameless}" members=0>), store.group(nameless).inspect
+ assert_equal %(#<ProTacts::Group id="#{id}" label="Household" members=2>), store.group(id).inspect
+ assert_equal %(#<ProTacts::Group id="#{nameless}" label="#{nameless}" members=0>), store.group(nameless).inspect
end
end
@@ -1441,7 +1441,7 @@ class StoreTest < Minitest::Test
def test_everyones_book_is_not_a_group_to_delete
with_store({"aiden" => AIDEN}) do |store|
- id = FixtureData.seed_group(store, name: ProTacts::Store::EVERYONE, members: ["aiden"])
+ id = FixtureData.seed_group(store, name: ProTacts::Group::EVERYONE, members: ["aiden"])
assert_raises(ProTacts::Store::EveryonesBookName) { store.delete_group(id) }
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index c9937fb..ee16760 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -786,7 +786,7 @@ class WebTest < Minitest::Test
# entry, so the sequences the sync tests read are the puts' alone.
def with_contacts(names, &block)
super(names.to_h { |id, name| [id, card(id, name)] }) do |store|
- FixtureData.seed_group(store, name: ProTacts::Store::EVERYONE, members: names.keys)
+ FixtureData.seed_group(store, name: ProTacts::Group::EVERYONE, members: names.keys)
block.call(store)
end
end
@@ -1035,7 +1035,7 @@ class WebTest < Minitest::Test
with_contacts({"aiden" => "Aiden", "znorth" => "Zed"}) do |store|
request "/dav/addressbook/", method: "REPORT", input: sync_collection("")
token = last_response.body[%r{<d:sync-token>([^<]+)</d:sync-token>}, 1]
- everyone = store.all_groups.find { it.name == ProTacts::Store::EVERYONE }.id
+ everyone = store.all_groups.find { it.name == ProTacts::Group::EVERYONE }.id
store.remove_member(everyone, "znorth")
request "/dav/addressbook/", method: "REPORT", input: sync_collection(token)