Export the whole book as a vCard from the user menu
Assisted-by: GLM-5.3 via pi
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index c573893..9763161 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -126,10 +126,11 @@ module ProTacts
# account has no page: a login decides which cards sync
# to whose devices (docs/plans/2026-09-12-per-user-
# books.md) and nothing else, so what hangs off it is
- # the two destinations that are not records and so
+ # the destinations that are not records and so
# cannot be searched for — import, a handful of times in
# a book's life (docs/plans/2026-09-21-import-a-vcf.md),
- # and device setup. A popover by the Popover API, like
+ # export, its counterpart, and device setup. A popover
+ # by the Popover API, like
# every floated layer here; the list sits beside its
# button in the header because a promoted popover is
# positioned against the viewport, not the DOM beside
@@ -143,6 +144,7 @@ module ProTacts
end
ul(id: MENU, popover: "auto", class: "user-menu-list") do
li { a(href: "/import") { "import" } }
+ li { a(href: "/contacts/export.vcf") { "export" } }
li { a(href: "/setup") { "device setup" } }
end
end
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 44e1e3f..23705e0 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -56,6 +56,21 @@ module ProTacts
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
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 2526854..120a7ba 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -212,6 +212,25 @@ class AdminContactsPagesTest < Minitest::Test
assert_includes last_response.body, "No contacts yet."
end
+ # The link's answer: every stored card's bytes in id order — the
+ # same read rake db:dump makes (Store#snapshot) — joined whole, each
+ # card ending in its own final newline so the cards never run
+ # together. The stored bytes are the assertion's truth, not the
+ # input: a write composes the modeled BDAY back in.
+ def test_the_export_serves_every_stored_card_as_one_vcard_file
+ grace = ADA.sub("Ada Lovelace", "Grace Hopper").sub("UID:ada", "UID:grace")
+
+ with_contacts({"grace" => grace, "ada" => ADA}) do |store|
+ get "/contacts/export.vcf"
+
+ assert_equal 200, last_response.status
+ assert_equal "text/vcard; charset=utf-8", last_response["Content-Type"]
+ assert_equal 'attachment; filename="contacts.vcf"', last_response["Content-Disposition"]
+ assert_equal store.snapshot.cards.values.join, last_response.body
+ assert_equal 2, last_response.body.scan("BEGIN:VCARD").length
+ end
+ end
+
# A contact born on `date`'s month and day in 2000, so the
# birthdays column's ordering and labels are deterministic against
# the real clock.
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 4b10f9e..47132d7 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -143,14 +143,17 @@ class AdminGroupsPagesTest < Minitest::Test
# The members list the way /contacts does (Format.sort_key), not in
# the store's id order. Seeded so the two disagree. The scan takes
# the list's own rows — an li's link, not the change log's, which
- # names the same cards for other reasons.
+ # names the same cards for other reasons — and only from the
+ # members heading down: the header's account menu carries an li
+ # link into /contacts too (the export), above every list.
def test_the_members_list_in_name_order
with_contacts(BOOLES.merge("zz" => "Alice Aardvark")) do |store|
id = household(store, members: %w[mary zz george])
get "/groups/#{id}"
- assert_equal %w[zz george mary], last_response.body.scan(%r{<li><a href="/contacts/([^"]+)">}).flatten
+ members = last_response.body.split("members (").last
+ assert_equal %w[zz george mary], members.scan(%r{<li><a href="/contacts/([^"]+)">}).flatten
end
end
diff --git a/test/pro_tacts/admin/test_layout.rb b/test/pro_tacts/admin/test_layout.rb
index e484814..6e389d1 100644
--- a/test/pro_tacts/admin/test_layout.rb
+++ b/test/pro_tacts/admin/test_layout.rb
@@ -63,6 +63,14 @@ class AdminLayoutTest < Minitest::Test
assert_includes header_html, %(<a href="/import">import</a>)
end
+ # The menu's one link that is not a screen: the whole book out as
+ # a file, import's counterpart.
+ def test_the_account_menu_links_to_the_export
+ with_env({})
+
+ assert_includes header_html, %(<a href="/contacts/export.vcf">export</a>)
+ end
+
def test_the_account_menu_links_to_device_setup
with_env({})