api: list groups as JSON
An import names its group but has to learn the id it was given,
and a rerun must find the group an earlier run made, not make it again.
GET /api/groups answers with every group's id and name,
so execute looks its group up by name before it creates one.

Assisted-by: Claude Opus 5 via Claude Code
Issue: rtqvmzvp
change kmnuqmzxylrutstuonrulrxzuskxvtos
commit d0b5da3c8592226f683cd1ad6c5e4b9ef8bc2b09
author Alpha Chen <alpha@kejadlen.dev>
date
parent yzplkwwk
diff --git a/AGENTS.md b/AGENTS.md
index 76cb21c..a65c3a5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -49,6 +49,7 @@ lib/pro_tacts/
 ├── web/contacts.rb # The card browser and its editor
 ├── web/groups.rb   # The group screens and their editor
 ├── web/setup.rb    # The device setup screen and its profile
+├── web/api.rb      # JSON for scripts: the group list
 ├── admin/          # The Phlex views those screens render, plus
 │                   # card_form.rb, which reads a form back into a card
 ├── store.rb        # Sequel over SQLite: cards, change log, derived index
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index e72ff6a..f5743b6 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -133,6 +133,7 @@ module ProTacts
 end
 
 # The branches reopen Web, so they load once the plugins they call are in.
+require "pro_tacts/web/api"
 require "pro_tacts/web/contacts"
 require "pro_tacts/web/dav"
 require "pro_tacts/web/groups"
diff --git a/lib/pro_tacts/web/api.rb b/lib/pro_tacts/web/api.rb
new file mode 100644
index 0000000..7c2dc35
--- /dev/null
+++ b/lib/pro_tacts/web/api.rb
@@ -0,0 +1,16 @@
+require "json"
+
+module ProTacts
+  class Web < Roda
+    # JSON for scripts, beside the HTML screens a person uses. An import
+    # looks its group up here by name before making it, so a rerun
+    # joins the group an earlier run made
+    # (docs/plans/2026-09-16-importing-from-macos.md).
+    hash_branch("api") do |r|
+      r.get "groups" do
+        response["Content-Type"] = "application/json"
+        store.all_groups.map { {id: it.id, name: it.name} }.to_json
+      end
+    end
+  end
+end
diff --git a/test/pro_tacts/test_api.rb b/test/pro_tacts/test_api.rb
new file mode 100644
index 0000000..5513bce
--- /dev/null
+++ b/test/pro_tacts/test_api.rb
@@ -0,0 +1,43 @@
+require_relative "../test_helper"
+
+require "json"
+require "rack/test"
+
+require "pro_tacts/web"
+
+class ApiTest < Minitest::Test
+  include Rack::Test::Methods
+  include ThrowawayContacts
+
+  def app
+    ProTacts::Web
+  end
+
+  def setup
+    header "Remote-User", "test@example.com"
+  end
+
+  def test_groups_lists_every_group_by_id_and_name
+    with_contacts({}) do |store|
+      named = store.create_group(name: "import-20260916T180412Z")
+      nameless = store.create_group
+
+      get "/api/groups"
+
+      assert_equal 200, last_response.status
+      assert_equal "application/json", last_response["Content-Type"]
+      groups = JSON.parse(last_response.body)
+      assert_equal store.all_groups.map { {"id" => it.id, "name" => it.name} }, groups
+      assert_includes groups, {"id" => named, "name" => "import-20260916T180412Z"}
+      assert_includes groups, {"id" => nameless, "name" => nil}
+    end
+  end
+
+  def test_an_unknown_api_path_is_404
+    with_contacts({}) do
+      get "/api/nope"
+
+      assert_equal 404, last_response.status
+    end
+  end
+end
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 8ee22c4..f79fead 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -196,7 +196,7 @@ class WebTest < Minitest::Test
   def test_a_refusal_covers_every_path
     header "Remote-User", ""
 
-    %w[/ /.well-known/carddav /dav/ /dav/principal/ /dav/addressbook/ /contacts /groups /setup /admin.css].each do |path|
+    %w[/ /.well-known/carddav /dav/ /dav/principal/ /dav/addressbook/ /contacts /groups /api/groups /setup /admin.css].each do |path|
       get path
 
       assert_equal 401, last_response.status, path