Export the whole book as a vCard from the user menu
Assisted-by: GLM-5.3 via pi
change pxnwyukmqlwrmlnyntxvpxqpspovmypp
commit 9afbbda5d19a4d7f3a3328bc31b8d12fab12ecb9
author Alpha Chen <alpha@kejadlen.dev>
date
parent kupunlpv
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({})