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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
require "digest"
require "pro_tacts/admin/card_form"
require "pro_tacts/admin/contact_dialog"
require "pro_tacts/admin/contacts_edit"
require "pro_tacts/admin/contacts_index"
require "pro_tacts/admin/contacts_show"
require "pro_tacts/admin/dashboard"
require "pro_tacts/admin/group_tag"
require "pro_tacts/admin/groups_new"
require "pro_tacts/change_id"
module ProTacts
class Web < Roda
# The card browser, one segment down from the page at the root:
# /contacts/:id names what the id is without spending the whole
# single-segment namespace on contact ids. Under the same auth
# gate as the CardDAV routes — "a few family members, all
# trusted" is the whole access model this app has, see README's
# simplifying assumptions.
hash_branch("contacts") do |r|
# `r.is` because a bare verb block matches any remaining path in
# Roda — without it, the collection's create would swallow the
# record's apply, POST /contacts/:id below.
r.is do
# The whole-set listing, the one screen that browses the
# contacts (docs/DESIGN.md, "The core idea").
r.get do
response["Content-Type"] = "text/html; charset=utf-8"
contacts = store.listed_contacts
groups = store.all_listed_groups(contacts, first: Admin::GroupTag::SHOWN)
Admin::ContactsIndex.call(contacts:, groups:, login: @login)
end
# The browser's create, from the dashboard's dialog: POST is
# the one method the admin UI adds to the DAV set (see
# config/puma.rb, whose list Puma replaces rather than
# extends), and the collection is the resource a create
# names. Stored through Store#put like any client write, so
# the change log a sync token counts on lands with the card.
# A nameless create is a dashboard re-render with a toast: the
# browser cannot produce one (the dialog requires one name box
# or the other, Admin::NamePair), so this is the backstop, and
# a popover cannot be declared open in markup — the toast is
# the refusal the
# re-rendered page can actually show.
r.post do
first = typecast_params.str("first", "").strip
middle = typecast_params.str("middle", "").strip
last = typecast_params.str("last", "").strip
# A middle name is not one of the halves that make a name: the
# form asks for it beside a pair it stands outside of
# (Admin::NamePair), and nobody is known by their middle name
# alone.
if first.empty? && last.empty?
dashboard(notice: "A contact needs a name.")
else
id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
store.put(id, Admin::CardForm.new_card(id, first, middle, last))
r.redirect "/contacts/#{id}", 303
end
end
end
# The whole book as one multi-vCard file: every stored card's
# bytes in id order, the same read rake db:dump makes
# (Store#snapshot), joined with nothing between — each card is
# stored ending in its final newline, so one card's END:VCARD
# never rides against the next one's BEGIN. Above `r.on String`,
# which would otherwise take the segment for a contact id. An
# attachment disposition, because a download is the point here
# where the profile's own route leaves it off for its install
# flow (web/setup.rb).
r.get "export.vcf" do
response["Content-Type"] = "text/vcard; charset=utf-8"
response["Content-Disposition"] = 'attachment; filename="contacts.vcf"'
store.snapshot.cards.values.join
end
r.on String do |id|
# The browser's edit of one contact
# (docs/plans/2026-09-05-web-card-editor.md): an explicit
# mode — GET renders the form, POST applies it, success is a
# 303 back to the details page so the back button cannot
# double-submit. POST stays the wire verb, the create's
# precedent: HTML forms speak only GET and POST, and the
# admin surface's one client is the form.
r.get "edit" do
contact = store.contact(id)
# No match falls through to the empty-body 404 the
# not_found handler fills in, same as the page below.
if contact
response["Content-Type"] = "text/html; charset=utf-8"
Admin::ContactsEdit.call(contact:, login: @login, back: edit_back(contact))
end
end
# Membership from the contact's side, the groups dialog's save
# (Admin::GroupDialog). Above the edit's POST, whose bare verb
# block would match this path too.
r.post "groups" do
apply_groups(r, id)
end
# The groups row's "new" (Admin::ContactCard): the members
# screen of a group not yet made, this contact ticked, whose
# Create posts back here. Above the edit's POST, as the dialog's
# save is.
r.get "groups", "new" do
contact = store.contact(id)
new_group_screen(contact) if contact
end
r.post "groups", "new" do
apply_new_group(r, id)
end
r.post do
apply_edit(r, id)
end
# The picture the avatars render (Admin::Avatar): decoded
# bytes under their own content type, served from a route
# rather than inlined as base64 so a page of avatars is a
# page of cacheable image requests instead of ten 330 KB
# payloads stitched into the HTML (#serve_photo).
r.get "photo" do
contact = store.contact(id)
# The views never point at a photo the card lacks, so a
# contact with none is the 404 case above.
if contact && (photo = contact.photo)
serve_photo(photo)
end
end
r.get do
contact = store.contact(id)
contact_screen(contact) if contact
end
end
end
private
# A picture, a contact's or a group's, answered so a browser keeps
# it: an ETag of its own bytes and `no-cache`, which stores the
# picture but asks before each reuse, so a page of avatars costs a
# 304 apiece rather than every picture again on every visit, and a
# replaced picture shows the moment it is. The bytes' digest rather
# than the card's etag, so an edit to a phone number keeps the
# picture a browser holds. Roda's caching plugin answers the 304,
# halting before the bytes are sent.
#: (Contact::Photo photo) -> String
def serve_photo(photo)
response["Content-Type"] = photo.mime_type
response.cache_control(private: true, no_cache: true)
request.etag(Digest::SHA256.hexdigest(photo.bytes))
photo.bytes
end
# The dashboard GET and a refused create render the same page.
#: (?notice: String?) -> String
def dashboard(notice: nil)
response["Content-Type"] = "text/html; charset=utf-8"
Admin::Dashboard.call(
recent: store.contacts_by_recency(limit: Admin::Dashboard::RECENT_LIMIT),
upcoming: store.upcoming_birthdays(Admin::UpcomingBirthdays::LIMIT),
login: @login,
notice:,
)
end
# The whole of the edit POST, a private method for the reason
# Web#write_card is one, and its checks in the same order: the
# request's own validity first, the conditionals on stored state
# after.
#
# `refuse` renders the editor back with the notice — this page's
# own screen by default; the walk renders its row's card in
# place — and `land` is where a successful save redirects, the
# contact's page by default.
#: (untyped r, String id, ?refuse: ^(Contact, ?notice: String) -> String, ?land: String?) -> String?
def apply_edit(r, id, refuse: ->(contact, notice:) { edit_screen(contact, notice:) }, land: nil)
contact = store.contact(id)
return if contact.nil?
# N and FN are mandatory (RFC 2426 section 4), so a save blank
# throughout is refused — the toast is the backstop, the form's
# name pair (Admin::NamePair) being the browser's own refusal of
# the same, and a company's organization field the one name it
# has (Admin::CardForm.nameless?).
params = card_form_params
company = typecast_params.bool!("company")
first = params["first"].to_s.strip
middle = params["middle"].to_s.strip
last = params["last"].to_s.strip
return refuse.call(contact, notice: "A contact needs a name.") if Admin::CardForm.nameless?(company, params)
# Request validity, standing with the name check rather than the
# stored-state conditionals below (write_card's ordering rule). A
# POST that carries no birthday group keeps the model — this
# form's own save does when it rendered no birthday row, and
# anything else never carried one; a group of three blanks is the
# row's blank-equals-absent, a removal.
birthday =
if (fields = params["birthday"])
begin
Admin::CardForm.birthday(fields)
rescue ArgumentError
return refuse.call(contact, notice: "That birthday is not a shape a date can take.")
end
else
contact.birthday
end
# The snapshot guard, the lost-update half If-Match gives DAV
# clients (RFC 7232 section 3.1): the form carried the etag of
# the card it was rendered from, and a contact that hashes
# differently now was edited in between — another tab, or a
# client sync — so applying this save over that one would revert
# it. The refusal re-renders from the current card, so the screen
# shows what changed. Like write_card's, the check is not atomic
# with the write.
return refuse.call(contact, notice: "This contact changed since the page loaded; nothing was saved.") if typecast_params.str!("etag") != contact.etag
# The one state the birthday row cannot write, refused whole:
# docs/plans/2026-09-07-web-birthday-editor.md, "The one hazard:
# a card that carries its own BDAY", which also records the
# migration not taken.
if birthday && contact.stored.lines.any? { it.names?("BDAY") }
return refuse.call(contact, notice: "This contact's card carries its own birthday spelling; nothing was saved.")
end
store.save_edit(id, Admin::CardForm.contact_card(contact, company, first, middle, last, params), birthday:)
r.redirect land || "/contacts/#{id}", 303
end
# The dialog's save as what it toggled rather than the set it shows:
# `was` is the boxes checked when the page loaded, so a group joined
# or left elsewhere since stays as it is, and a stale page has
# nothing to revert and no snapshot to refuse. Only ids naming a
# group: Store#add_member reads the group with `sole`, so a doctored
# one would be a 500 rather than the bad input it is.
#: (untyped r, String id) -> String?
def apply_groups(r, id)
contact = store.contact(id)
return if contact.nil?
known = store.group_choices.map(&:id)
checked = ids_in("groups") & known
was = ids_in("was") & known
# The names the picker's offer committed by their ticks, each
# made with this save (Admin::GroupFilter::Named); a blank one is
# none.
names = ids_in("named").map(&:strip).reject(&:empty?)
# The walk's row, when the dialog was opened from one
# (Admin::ImportSaved's `land`): where a save returns to and a
# refusal renders, so a look back is not a navigation away from
# the walk.
row = typecast_params.walk_row("land")
begin
store.regroup(id, join: checked - was, leave: was - checked, create: names)
rescue Sequel::UniqueConstraintViolation
# A taken name (db/migrations/008_group_names.rb), refused with
# the rest of the save in regroup's transaction. The page's own
# list is what keeps the picker from offering a taken name, so
# a save that finds one is looking at a list gone stale — the
# name is asked of the store rather than of the form.
clash = names.find { |name| store.group_choices.any? { it.name == name } }
notice =
if clash
"Another group is already named #{clash}; nothing was saved."
else
"A group already holds one of those names; nothing was saved."
end
if row
upload, index = row
return card_screen(upload, index, notice:)
end
return contact_screen(contact, notice:)
end
r.redirect(row ? "/import/#{row.fetch(0)}/#{row.fetch(1)}" : "/contacts/#{id}", 303)
end
# The new group's Create: a nameless group of the ticked, landed
# on, it being something already. None ticked makes none, the
# store keeping no group that never had a member
# (Store#create_group). Only ids that name a card
# (#apply_members' reason).
#: (untyped r, String id) -> String?
def apply_new_group(r, id)
contact = store.contact(id)
return if contact.nil?
members = ids_in("members") & store.contact_ids
return new_group_screen(contact, notice: "A group needs a member; nothing was made.") if members.empty?
r.redirect "/groups/#{store.create_group(members:)}", 303
end
#: (Contact contact, ?notice: String) -> String
def new_group_screen(contact, notice: nil)
response["Content-Type"] = "text/html; charset=utf-8"
Admin::GroupsNew.call(contact:, contacts: store.listed_contacts, login: @login, notice:)
end
#: (Contact contact, ?notice: String) -> String
def contact_screen(contact, notice: nil)
response["Content-Type"] = "text/html; charset=utf-8"
Admin::ContactsShow.call(
contact:,
login: @login,
groups: store.listed_groups_of(contact.id, first: Admin::GroupTag::SHOWN),
all_groups: store.group_choices,
changes: store.changes_of(contact.id),
notice:,
)
end
#: (Contact contact, ?notice: String) -> String
def edit_screen(contact, notice: nil)
response["Content-Type"] = "text/html; charset=utf-8"
Admin::ContactsEdit.call(contact:, login: @login, notice:, back: edit_back(contact))
end
# The edit's way back, named by the route rather than left to the
# view: the import's editor passes none, its navigation being the
# list beside it.
#: (Contact contact) -> [String, String]
def edit_back(contact)
["/contacts/#{contact.id}", contact.name || contact.id]
end
end
end