import: a walk ends on the group it filed under
The upload page doubled as the page a finished import answered with,
listing what came in under the form for the next file. That list is
the import's own group, which holds the same contacts, is a page in
its own right, and is still there tomorrow — so the last Save now
leaves the walk for `/groups/<id>` and the copy under the form is
gone.
Every card saved out of that group leaves no group to land on, and
the book itself is where they went.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrREeoPLXx1qsYYq7qkL8h
diff --git a/docs/plans/2026-09-21-import-a-vcf.md b/docs/plans/2026-09-21-import-a-vcf.md
index 736d5f1..4bdd2e6 100644
--- a/docs/plans/2026-09-21-import-a-vcf.md
+++ b/docs/plans/2026-09-21-import-a-vcf.md
@@ -207,18 +207,24 @@ walk is resumable because nothing is being held back — a tab left
overnight, a window closed, a crash, all read the same way.
That makes the end of the walk a thing to say rather than a thing to
-press, and the last Save is what says it: what came in is listed on the
-same page an import has always ended on. There is no "finish" beside the
-list either, because the only power such a button would have that the
-walk does not already have is throwing away the rows nobody has read —
-nothing to gain by pressing it, and the one irreversible move on a
-screen whose whole posture is that nothing is being held back. What else
-it would have done happens anyway: the list is the running count of what
-is in, and the staged copy of the file ages out on its own
+press, and the last Save is what says it. There is no "finish" beside
+the list either, because the only power such a button would have that
+the walk does not already have is throwing away the rows nobody has
+read — nothing to gain by pressing it, and the one irreversible move on
+a screen whose whole posture is that nothing is being held back. What
+else it would have done happens anyway: the list is the running count
+of what is in, and the staged copy of the file ages out on its own
(Import::Staged::LIFETIME). So a walk ends by being over or by being
left, and the rows nobody opened are left behind with this copy of the
file, which the importer has their own of.
+Nor is there a page saying what came in. The group the import filed
+under is that page, listing the same contacts and still there
+tomorrow, so the last Save leaves the walk for it rather than for a
+copy of it rendered once under the upload form. A walk whose every
+card was saved out of that group has none to land on, and the book
+itself is where they went.
+
One thing is settled up front, and that is the group for the lot:
every Save files its contact under it. It is named when the import
opens, `import-<timestamp>`, and nothing renames it. A name typed
diff --git a/lib/pro_tacts/admin/import_upload.rb b/lib/pro_tacts/admin/import_upload.rb
index 8fce52f..1d2c7f3 100644
--- a/lib/pro_tacts/admin/import_upload.rb
+++ b/lib/pro_tacts/admin/import_upload.rb
@@ -1,14 +1,10 @@
require "pro_tacts/admin/phlex"
-require "pro_tacts/admin/avatar"
-require "pro_tacts/admin/format"
require "pro_tacts/admin/layout"
-require "pro_tacts/admin/list_item"
module ProTacts
module Admin
- # GET /import — the screen an address book arrives on, and the page
- # a finished import answers with
+ # GET /import — the screen an address book arrives on
# (docs/plans/2026-09-21-import-a-vcf.md).
# Until this existed an import meant rake tasks reading Contacts.app
# on a Mac and carrying a plan to a host over HTTP; now it is the
@@ -19,16 +15,15 @@ module ProTacts
# (ImportReview, over the walk's list of its contacts and the pair
# of cards a row there opens).
#
- # `imported` is what the import that just ended brought in — the
- # rows its walk saved, and not the ones it left behind — rendered
- # below the form that is still there to run another.
+ # Nothing comes back here at the end of a walk. What arrived is
+ # the group the import filed it under, a page that lists the same
+ # contacts and is still there tomorrow, so the walk ends on that
+ # rather than on a copy of it under this form (Web#close_walk).
class ImportUpload < Phlex::HTML
- # @rbs @imported: Array[Contact]?
# @rbs @notice: String?
- #: (?imported: Array[Contact]?, ?notice: String?) -> void
- def initialize(imported: nil, notice: nil)
- @imported = imported
+ #: (?notice: String?) -> void
+ def initialize(notice: nil)
@notice = notice
end
@@ -39,7 +34,6 @@ module ProTacts
a(href: "/", class: "type-label") { "‹ contacts" }
end
upload_card
- imported_list if @imported
end
end
end
@@ -66,30 +60,6 @@ module ProTacts
end
end
end
-
- # What came in, contact by contact, each linked to the record it
- # became — so the next thing to do, reading one or fixing one, is
- # a click away.
- #: () -> void
- def imported_list
- imported = @imported or return
-
- section do
- div(class: "section-head") do
- h2(class: "type-label") { "imported (#{imported.length})" }
- end
- ul(class: "card") do
- imported.each do |contact|
- render ListItem.new(
- href: "/contacts/#{contact.id}",
- avatar: Avatar.new(contact:, size: "lg"),
- ) do
- div { Format.name_label(contact) }
- end
- end
- end
- end
- end
end
end
end
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index e6c0023..9e0919d 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -362,37 +362,35 @@ module ProTacts
Import::Staged.record(upload, index.to_s, written.id)
# The last one: there is nothing left to come back to, so the
- # import ends here rather than on a list of rows that all say
- # the same thing.
- return closing_screen(upload) if saved.length + 1 == revised.length
+ # walk is over rather than a list of rows that all say the same
+ # thing.
+ return close_walk(r, upload) if saved.length + 1 == revised.length
r.redirect "/import/#{upload}", 303
end
- # The end of the walk: what it wrote, listed, and the file and
- # the walk's own notes gone. Reached one way only, by saving the
- # last card there was to save, because that is the only end a walk
- # has. Leaving one needs no screen and no button: the staged file
- # expires on its own (Import::Staged::LIFETIME), and what has come
- # in has come in. A control whose only power is to throw away the
- # rows nobody has read yet has nothing to gain by being pressed.
+ # The end of the walk: the file and the walk's own notes gone, and
+ # the group the import filed under standing where the walk stood.
+ # Reached one way only, by saving the last card there was to save,
+ # because that is the only end a walk has. Leaving one needs no
+ # screen and no button: the staged file expires on its own
+ # (Import::Staged::LIFETIME), and what has come in has come in.
#
- # Answered with the page rather than a 303, and has to be: the
- # staged import is gone, so the re-submission a back button offers
- # has nothing left to close, and there is no other page holding
- # what just arrived.
- #: (String upload) -> String
- def closing_screen(upload)
+ # There is no screen of its own for what arrived, because the
+ # group for the import is that screen and outlives this one — a
+ # list of the same contacts, rendered once rather than twice, on a
+ # page that is still there tomorrow. A walk whose every card was
+ # saved out of that group has none, and the whole book is where
+ # they went.
+ #: (untyped r, String upload) -> untyped
+ def close_walk(r, upload)
return expired_screen if Import::Staged.read(upload, Import::Staged::SAVED).nil?
- _group, saved = Import::Staged.saved(upload)
- # In the file's own order rather than the order the walk got to
- # them in, which is the order the list they are read back
- # instead of was in.
- imported = saved.keys.sort_by(&:to_i).filter_map { store.contact(saved.fetch(it)) }
+ group, _saved = Import::Staged.saved(upload)
Import::Staged.close(upload)
+ filed = store.group_choices.find { it.name == group }
- import_screen(imported:)
+ r.redirect(filed ? "/groups/#{filed.id}" : "/contacts", 303)
end
# The import's two readings, or none when it is gone — swept out
@@ -457,12 +455,13 @@ module ProTacts
param if param.is_a?(Hash) && param[:tempfile]
end
- # The screen a file is chosen on, and the page a finished import
- # answers with.
- #: (?imported: Array[Contact]?, ?notice: String?) -> String
- def import_screen(imported: nil, notice: nil)
+ # The screen a file is chosen on, which is all this path is: a
+ # walk that has ended has a group to land on rather than a page
+ # here saying it ended (#close_walk).
+ #: (?notice: String?) -> String
+ def import_screen(notice: nil)
response["Content-Type"] = "text/html; charset=utf-8"
- Admin::ImportUpload.call(imported:, notice:)
+ Admin::ImportUpload.call(notice:)
end
#: () -> String
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index c7d12bd..c528025 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -42,7 +42,7 @@ module ProTacts
end
class ImportUpload
- def self.call: (?imported: Array[Contact]?, ?notice: String?) -> String
+ def self.call: (?notice: String?) -> String
end
class ImportReview
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index e207247..269acbe 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -345,22 +345,29 @@ class AdminImportPagesTest < Minitest::Test
end
# Saving the last row ends the walk: there is nothing left to come
- # back to, so the import closes itself and says what came in.
+ # back to, so the import closes itself and leaves the walk for the
+ # group it filed everything under, which is the list of what came
+ # in and is still there tomorrow.
def test_saving_the_last_row_ends_the_import
with_contacts({}) do |store|
id = upload(JANE + PLAIN)
save(id, 0, first: "Jane", last: "Booles")
save(id, 1, first: "Sam", last: "Booles")
- assert_equal 200, last_response.status
- assert_includes last_response.body, "imported (2)"
assert_equal 2, store.contacts.length
+ filed = store.all_groups.find { it.name.to_s.start_with?("import-") }
+
+ assert_equal "/groups/#{filed.id}", last_response.headers["location"]
+ assert_equal store.contacts.map(&:id).sort, filed.members.sort
jane = store.contacts.find { it.name == "Jane Booles" }
assert_includes jane.vcard.to_s, "TEL;type=CELL:+1 555 0100"
refute_includes jane.vcard.to_s, "X-SOCIALPROFILE"
refute_includes jane.vcard.to_s, "X-ABRELATEDNAMES"
+
+ follow_redirect!
+
assert_includes last_response.body, %(href="/contacts/#{jane.id}")
# And the walk is gone with it.
@@ -370,6 +377,19 @@ class AdminImportPagesTest < Minitest::Test
end
end
+ # Every card saved out of the group the import named leaves no group
+ # to land on, so the walk ends on the book itself.
+ def test_a_walk_that_filed_nothing_ends_on_the_contacts
+ with_contacts({}) do |store|
+ id = upload(JANE)
+ save(id, 0, first: "Jane", last: "Booles", "named" => [], "groups" => [])
+
+ assert_equal "/contacts", last_response.headers["location"]
+ assert_equal 1, store.contacts.length
+ assert_empty store.all_groups.map(&:name).grep(/\Aimport-/)
+ end
+ end
+
# A walk is over when its last row is saved, and left when it is
# left: there is nothing to press, and nothing whose only power
# would be to throw away the rows nobody has read.