1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
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, its members' card ids, and
# whether it has a photo (docs/plans/2026-10-12-group-photos.md). 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, :photo)
# Reopened rather than defined in the block above, for the reason
# CardDiff is.
class Group
# A photo is the exception, not the rule, so a group built without
# saying is one without.
#: (?photo: bool, **untyped) -> void
def initialize(photo: false, **)
super
end
# 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 listings
# that set those apart from the ones a person made.
#: () -> bool
def sync?
Group.sync_name?(name)
end
# The order every listing of groups reads in: the `sync:` groups
# — the books a client syncs, the store's own rather than
# anything a person made — first, then the rest alphabetically,
# and the nameless ones last, their ids being no names to read.
# GroupChoice spells this again for the pickers; keep the two
# alike. Comparable deliberately left out: its `==` would sit in
# front of Data's whole-value one, and `sort` asks only for `<=>`.
#: (Group other) -> Integer?
def <=>(other)
[sync? ? 0 : 1, name.nil? ? 1 : 0, label.downcase, id] <=>
[other.sync? ? 0 : 1, other.name.nil? ? 1 : 0, other.label.downcase, other.id]
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