import: the contacts stand beside the card being read
The walk was a list page and a card page: opening a row meant leaving
the list, and coming back meant finding your place in it again. The
contacts are a sidebar now, standing on both screens after the upload,
so what the last ten screens settled is in view while the eleventh is
open. The screen the sidebar sits beside is what the whole file is
losing, and the way to stop.
Each row says the two things the walk turns on. Whether the contact is
in the book -- in words, "saved" or "not saved", with the unsaved rows
in the page's own text color: everything here is unsaved until its own
screen says otherwise, and a state nobody notices is the silence this
screen exists to undo. And what the card is losing, as a diff's own
mark: "-3" against the name, in the sign-and-color shape a change-log
entry wears, rather than a sentence that made every marked row a line
of prose.
The group for the import stops being a field. It is named when the
upload happens and nothing renames it: a name typed before anything
has been looked at is a name for nothing, and one typed after the
first contact is in would leave that one under the old name. What is
editable is the membership, and it moves to where every other group
question is asked -- the boxes beside each contact's own card, where
it is ticked and can be unticked. Everyone's book is ticked there too,
and fixed, Store#put's `client: true` joining it whatever the form
says.
So Import::Write no longer knows the import's group from any other
answer, and takes only the card and the groups its screen settled on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrREeoPLXx1qsYYq7qkL8h
diff --git a/AGENTS.md b/AGENTS.md
index 48550c8..9a1aa5b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -38,15 +38,16 @@ rake profile:install # Download carddav.mobileconfig from the app and
profiles, so leave those to the user.
Importing is not a task at all: a .vcf is uploaded at `/import` in the
-app, and worked down there contact by contact — the card as the file
-wrote it beside the card that is coming in, with the lines no screen
-here shows struck, and this book's groups as a filtered list of boxes to
-tick. Each contact's own Save is what writes it through the store, into
-a group made for the import and whichever groups that contact was given,
-so the list says which rows are in the book and stopping halfway keeps
-every one of them (docs/plans/2026-09-21-import-a-vcf.md). The `import:*`
-tasks, the Swift reader they drove Contacts.app with, and the plan
-directories they wrote are gone.
+app, and worked down there contact by contact, the file's contacts
+standing in a sidebar beside whatever is open. A row opens the card as
+the file wrote it beside the card that is coming in, with the lines no
+screen here shows struck, and this book's groups as a filtered list of
+boxes to tick — the group made for the import and everyone's book among
+them, ticked. That contact's own Save is what writes it through the
+store, so each row reads "saved" or "not saved" and stopping halfway
+keeps everything already in (docs/plans/2026-09-21-import-a-vcf.md).
+The `import:*` tasks, the Swift reader they drove Contacts.app with,
+and the plan directories they wrote are gone.
## Task management
diff --git a/Steepfile b/Steepfile
index 31930df..ae836d7 100644
--- a/Steepfile
+++ b/Steepfile
@@ -22,7 +22,7 @@ target :lib do
# the files stay checked.
ignore(*%w[
avatar contact_dialog contacts_edit contacts_index contacts_show dashboard device_setup format
- group_dialog group_filter group_label groups_edit groups_index groups_show icons import_groups import_original import_review
+ group_dialog group_filter group_label groups_edit groups_index groups_show icons import_groups import_original import_review import_sidebar
import_upload layout list_item phlex upcoming_birthdays
].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
signature "sig"
diff --git a/docs/plans/2026-09-21-import-a-vcf.md b/docs/plans/2026-09-21-import-a-vcf.md
index bb25b72..aedfb24 100644
--- a/docs/plans/2026-09-21-import-a-vcf.md
+++ b/docs/plans/2026-09-21-import-a-vcf.md
@@ -98,12 +98,8 @@ importer actually wants is to look at a contact and fix it.
So the review is a walk, three screens rather than a submission:
1. **Choose the file.**
-2. **The contacts it holds**, one row each, marked with how many of
- that contact's lines are not coming in — or marked as in the book
- already, once its own screen has been through — over a summary of
- which properties the whole file is losing. A book is hundreds of
- contacts and only some of them will have anything worth looking at,
- so the list is what says which rows are worth opening.
+2. **What the whole file is losing**, and beside it the contacts it
+ holds, one row each.
3. **One contact, two cards.** On the left, the card exactly as the
file wrote it: content lines in monospace, with every line that is
not coming in struck in Gloss's danger color and labelled "not
@@ -114,9 +110,12 @@ So the review is a walk, three screens rather than a submission:
name one it does not have yet. Read the spouse's name off the left
card and type it into the note on the right, in your own words, and
put the contact where it belongs while you are looking at it. The
- Save on that screen is the import of that contact: it goes into the
- book there and then, under the group named for the lot and whatever
- else was ticked beside it.
+ Save on that screen is the import of that contact: it goes into
+ the book there and then, in whatever the boxes beside it say. It
+ is the one editor in the app whose Save creates the record rather
+ than amending it, so it says where the card is going — "save to
+ the book" — and the row beside it reads "not saved" until it has
+ been pressed.
The right-hand card is `Admin::ContactsEdit` itself, not a copy of it.
A second editor for imports would be a place where the two could
@@ -137,6 +136,33 @@ contact is only its card, so the save says so and asks for the date
again once the contact is stored, rather than dropping it the moment
it was typed.
+## The list is a sidebar
+
+The contacts are not a screen of their own. They are a column down
+the side of both screens after the upload, so opening a row never
+costs your place in the list: the row being read is marked, the row
+after it is the next thing to click, and what the last ten screens
+settled is in view while the eleventh is open. A book is hundreds of
+contacts and only some of them will have anything worth looking at,
+so the list is what says which rows are worth opening — and that is
+a thing to read *while* reading one, not instead of.
+
+Two facts per row, and the walk turns on both.
+
+**Whether the contact is in the book**, said in words. Everything is
+unsaved until its own screen says otherwise, and a walk broken off
+overnight and come back to has to say which rows those are; a state
+nobody notices is the silence this screen exists to undo, so the
+unsaved rows stand in the page's own text color and the saved ones go
+quiet and green. The colors are the second telling: the words are
+"not saved" and "saved".
+
+**What the card is losing**, as a diff's own mark — `-3` against the
+contact's name, in the sign-and-color shape a change-log entry wears
+(`.diff-removed` in admin.css). A sentence for it made every marked
+row a line of prose to read, and the number is the whole of what a row
+has to say about it: how many, and that it is a loss.
+
## Nothing waits for the end
The first shape of this had a confirm at the end: the walk wrote its
@@ -171,13 +197,14 @@ it early. Either way the rows nobody opened are left behind with this
copy of the file, which the importer has their own of, and what came in
is listed on the same page an import has always ended on.
-One decision does have to be settled up front, and that is the group
-for the lot: every Save files its contact under it. So it is named when
-the import opens — `import-<timestamp>` by default — and stays editable
-until the first contact is in, after which the list shows it as a fact
-rather than a field. Renaming it halfway would leave what is already in
-the book under the old name and put the rest somewhere else, which is
-one import in two groups.
+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
+before anything has been looked at is a name for nothing; a name typed
+after the first contact is in would leave that one under the old name
+and put the rest somewhere else, which is one import in two groups.
+What *is* editable is the membership, which belongs beside each
+contact's own card and is a contact at a time.
## The import waits on the server
@@ -222,17 +249,37 @@ the same two writes a client's PUT and the groups dialog make:
everyone's book the way a client's create does
(`2026-09-15-client-creates-join-everyone.md`).
3. `Store#regroup` into whichever groups this contact's screen settled
- on. Two questions, asked in the two places they belong. The list
- names one group for the lot, `import-<timestamp>` by default — what
- arrived together can be found together, and undone together — and it
- is emptiable, a name this server already has joining that group
- rather than colliding with it. Beside each contact's own card, every
+ on — all of them, the group for the import included, `Import::Write`
+ knowing that one from no other answer because there is nothing it
+ could do about it that it does not do about the rest. The import's
+ own group is named when the upload happens, `import-<timestamp>`, so
+ what arrived together can be found together and undone together; a
+ name this server already has joins that group rather than colliding
+ with it. Beside each contact's own card, every
group this book already has as a box to tick: an import is as often
"these three are from the school list" as it is a batch that only
needs finding again, and which people those are is exactly what the
walk is for. Neither is required, and a card in no group is still in
everyone's book.
+ Both groups a card comes in under stand among those boxes rather
+ than out of sight, because the question this screen answers is what
+ the contact will be in, and leaving one out is the same silence the
+ walk exists to undo. Everyone's book is ticked and fixed —
+ `Store#put`'s `client: true` joins it whatever the form says, so a
+ box that could be cleared would be a box that lies, and disabling
+ it is also what keeps the value out of the submission. The group
+ for the import is ticked and can be unticked: an arrival that does
+ not belong with the lot is exactly the kind of thing the walk is
+ for deciding. That one is a name rather than an id until something
+ makes it, which is the first Save to go in under it, so it rides as
+ one of the staged names on that first screen and as an ordinary box
+ on every screen after. Everyone's book is missing from the list on
+ one screen in the life of a server — the first card of the first
+ import into an empty book, where that same put is what creates it —
+ and a row for a group that does not exist would be a worse answer
+ than none.
+
The per-contact boxes ride in the editor's own form, so one Save
writes the card and its groups together and there is no second
button to wonder about. They are filtered and capped, the groups
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 91c4c7c..6628f9e 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -72,6 +72,8 @@ module ProTacts
# @rbs @contact: Contact
# @rbs @own: Contact
+ # @rbs @sidebar: Phlex::HTML?
+ # @rbs @save: String
# @rbs @notice: String?
# @rbs @first: String?
# @rbs @middle: String?
@@ -81,24 +83,30 @@ module ProTacts
# @rbs @aside: Phlex::HTML?
# @rbs @fields: Phlex::HTML?
- # `action`, `back`, `aside` and `fields` are the import's: the
- # same editor, over a card that is not stored yet, saving into
- # the import rather than into a contact, rendered beside the
- # card as it was exported (Admin::ImportOriginal) and carrying
- # what the walk decides about a contact that a card cannot hold
- # — which groups it joins (Admin::ImportGroups), inside this
- # form so that one Save writes the lot. One editor rather than a
+ # The last six are the import's: the same editor, over a card
+ # that is not stored yet, saving into the import rather than
+ # into a contact, rendered beside the card as it was exported
+ # (Admin::ImportOriginal) and carrying what the walk decides
+ # about a contact that a card cannot hold — which groups it
+ # joins (Admin::ImportGroups), inside this form so that one Save
+ # writes the lot. `sidebar` is the walk's own list of contacts,
+ # standing beside the pair (Admin::ImportSidebar), and `save`
+ # the submit's words, which on an import say where the card is
+ # going because it is not there yet. One editor rather than a
# second one for imports, because a field the two disagreed
# about is a field an import writes and an edit cannot undo.
# Defaulted to the contact's own, so the ordinary edit says
# nothing about any of it.
- #: (contact: Contact, ?notice: String?, ?action: String?, ?back: [String, String]?, ?aside: Phlex::HTML?, ?fields: Phlex::HTML?) -> void
- def initialize(contact:, notice: nil, action: nil, back: nil, aside: nil, fields: nil)
+ #: (contact: Contact, ?notice: String?, ?action: String?, ?back: [String, String]?, ?aside: Phlex::HTML?, ?fields: Phlex::HTML?, ?sidebar: Phlex::HTML?, ?save: String?) -> void
+ def initialize(contact:, notice: nil, action: nil, back: nil, aside: nil, fields: nil,
+ sidebar: nil, save: nil)
@contact = contact
@action = action || "/contacts/#{contact.id}"
@back = back || ["/contacts/#{contact.id}", contact.name || contact.id]
@aside = aside
@fields = fields
+ @sidebar = sidebar
+ @save = save || "Save"
# The rows are the contact's own, never what a group lends it
# (Contact#own); the details page is where an inherited row is
# read (Admin::ContactsShow). The etag below is still the
@@ -121,7 +129,7 @@ module ProTacts
# width a single column wants is still what one card gets
# (admin.css).
render Layout.new(title: "Edit #{@contact.name || @contact.id}",
- wide: !@aside.nil?, notice: @notice) do
+ wide: !@aside.nil? || !@sidebar.nil?, notice: @notice) do
# The editor's Alpine scope, wrapping the record and the add
# dialog both: the dialog names a type and the form grows a
# row for it, so the two have to share state, and Alpine
@@ -135,6 +143,7 @@ module ProTacts
# same place, and the same shape, as the details page's
# edit link. Each mode's row names the other thing you can
# do to the record from it.
+ walked do
div(class: "record") do
div(class: "record-nav") do
href, label = @back
@@ -218,11 +227,12 @@ module ProTacts
# opts into.
footer do
a(href: @back.fetch(0), class: "btn") { "Cancel" }
- button(type: "submit", form: FORM, data: {variant: "primary"}) { "Save" }
+ button(type: "submit", form: FORM, data: {variant: "primary"}) { @save }
end
end
end
end
+ end
add_property_dialog
end
end
@@ -230,6 +240,21 @@ module ProTacts
private
+ # The walk's list of contacts, and the record beside it when
+ # there is one — the sidebar grid (admin.css). Skipped rather
+ # than rendered around one column for the reason #paired is:
+ # an ordinary edit has no walk to stand in.
+ #: () { () -> void } -> void
+ def walked
+ sidebar = @sidebar
+ return yield if sidebar.nil?
+
+ div(class: "walk") do
+ render sidebar
+ yield
+ end
+ end
+
# The editor's card, and what stands beside it when something
# does — the two-column grid the dashboard uses, for its reason
# (admin.css). With nothing beside it the wrapper is skipped
diff --git a/lib/pro_tacts/admin/import_groups.rb b/lib/pro_tacts/admin/import_groups.rb
index 21d5d27..8f76e07 100644
--- a/lib/pro_tacts/admin/import_groups.rb
+++ b/lib/pro_tacts/admin/import_groups.rb
@@ -17,9 +17,15 @@ module ProTacts
# (Admin::GroupDialog) — there the card exists and a membership is
# a write of its own, here neither exists until that Save.
#
- # A box per group this book has, then a box per group this import
+ # A box per group this book has, then a box per group this card
# has been told to make and has not made yet, then the filter
- # itself offered as another. The filter, the cap on how many rows
+ # itself offered as another. The two a card comes in under
+ # whatever else is ticked stand among them rather than out of
+ # sight: everyone's book, which every card this creates joins
+ # (Store#put's `client: true`) and which is therefore ticked and
+ # fixed, and the group for the import, which is ticked and can be
+ # unticked — an arrival that does not belong with the lot is a
+ # thing the walk is for deciding. The filter, the cap on how many rows
# stand shown and the button that lifts it are the dialog's
# (Admin::GroupFilter,
# docs/plans/2026-09-22-a-few-groups-at-a-time.md): a book with
@@ -36,6 +42,7 @@ module ProTacts
# @rbs @groups: Array[Store::GroupChoice]
# @rbs @joined: Array[String]
# @rbs @named: Array[String]
+ # @rbs @fixed: Array[String]
# @rbs @capped: Array[String]
# Alphabetical, the groups dialog's own order: this is a list to
@@ -50,17 +57,26 @@ module ProTacts
# the Save that would have staged it is the Save that writes the
# contact.
#
- # Both are rows the cap spares, for the dialog's own reason: a
- # box this contact has already been given is one the walk came
- # back to untick. The names take rows from the limit without
- # being in the list the cap reads, so they are counted into it
- # rather than listed in it.
- #: (groups: Array[Store::GroupChoice], joined: Array[String], named: Array[String]) -> void
- def initialize(groups:, joined:, named:)
+ # `fixed` is the ids ticked that cannot be unticked, which is
+ # everyone's book and nothing else: a card this writes joins it
+ # whatever the form says, so a box that could be cleared would
+ # be a box that lies. Fixed rather than hidden because the
+ # question this screen answers is what the contact will be in,
+ # and leaving one out is the same silence the walk exists to
+ # undo.
+ #
+ # All three are rows the cap spares, for the dialog's own
+ # reason: a box this contact has already been given is one the
+ # walk came back to untick. The names take rows from the limit
+ # without being in the list the cap reads, so they are counted
+ # into it rather than listed in it.
+ #: (groups: Array[Store::GroupChoice], joined: Array[String], named: Array[String], ?fixed: Array[String]) -> void
+ def initialize(groups:, joined:, named:, fixed: [])
@groups = groups.sort_by { it.label.downcase }
@joined = joined
@named = named
- @capped = GroupFilter.capped(@groups.map(&:id), joined:, shown: named.length)
+ @fixed = fixed
+ @capped = GroupFilter.capped(@groups.map(&:id), joined: joined + fixed, shown: named.length)
end
def view_template
@@ -73,9 +89,15 @@ module ProTacts
render GroupFilter.new(in_form: true)
div(class: "field-stack", x_ref: "options") do
@groups.each do |group|
+ # A fixed box is disabled, so the browser does not send
+ # it back and the save never reads it: the membership is
+ # the store's doing rather than this form's, and a value
+ # submitted for it would only be something to filter out
+ # again (Web#save_card).
+ fixed = @fixed.include?(group.id)
label(**GroupFilter.row(group.label, capped: @capped.include?(group.id))) do
input(type: "checkbox", name: "groups[]", value: group.id,
- checked: @joined.include?(group.id))
+ checked: fixed || @joined.include?(group.id), disabled: fixed)
render GroupLabel.new(group:)
end
end
diff --git a/lib/pro_tacts/admin/import_review.rb b/lib/pro_tacts/admin/import_review.rb
index 09c9e92..3624a83 100644
--- a/lib/pro_tacts/admin/import_review.rb
+++ b/lib/pro_tacts/admin/import_review.rb
@@ -1,68 +1,61 @@
require "pro_tacts/admin/phlex"
-require "pro_tacts/admin/format"
require "pro_tacts/admin/layout"
-require "pro_tacts/admin/list_item"
module ProTacts
module Admin
- # The contacts an uploaded .vcf holds, and which of them are in the
- # address book so far (docs/plans/2026-09-21-import-a-vcf.md).
+ # The screen an import opens on, and the one it comes back to: what
+ # the whole file is losing, what it is coming in under, and the way
+ # to stop (docs/plans/2026-09-21-import-a-vcf.md).
#
- # A list rather than every card laid open at once: a book is
- # hundreds of contacts and only some of them will have anything
- # worth looking at, so each row says how many of its lines are not
- # coming in and opening one is what shows the pair of cards
- # (Admin::ImportOriginal beside the editor). A row with nothing
- # left behind needs no visit.
+ # The contacts themselves are not here. They are the sidebar beside
+ # this (Admin::ImportSidebar), which stands on every screen of the
+ # walk, so this card is only what is true of the file rather than
+ # of any one row in it: which properties are not coming in, and how
+ # many lines each of them wears. It is what says whether the list
+ # beside it is worth working down at all. That count does not
+ # mention the noise a file is losing (Vcf::NOISE): a mark every row
+ # wears is a mark that says nothing, and an export's own PRODID is
+ # not something anyone is going to copy across by hand.
#
# The import happens a row at a time, on the Save of the screen a
- # row opens, so this is also where the walk is read back: a row
- # that has come in says so and leads to the contact rather than
- # back into the editor. Nothing here writes, which is why there is
- # no "import everything" — the file goes out the way it came in,
- # a contact at a time, and stopping halfway leaves exactly the
- # rows nobody opened.
- #
- # Above the list, the same fact for the whole file: which
- # properties are not coming in, and how many lines each of them
- # wears. It is what says whether the list is worth working down
- # at all. Neither count mentions the noise a file is losing
- # (Vcf::NOISE): a mark every row wears is a mark that says
- # nothing, and an export's own PRODID is not something anyone is
- # going to copy across by hand.
+ # row opens, so nothing here writes a contact and there is no
+ # "import everything". What this does have is the way out before
+ # the end of the file — a walk can be enough — and what it costs.
class ImportReview < Phlex::HTML
# @rbs @upload: String
- # @rbs @rows: Array[[::ProTacts::Contact, Integer]]
+ # @rbs @contacts: Integer
+ # @rbs @saved: Integer
# @rbs @unknown: Array[::ProTacts::Import::Vcf::Unknown]
# @rbs @group: String
- # @rbs @saved: Hash[String, String]
+ # @rbs @sidebar: Phlex::HTML
# @rbs @notice: String?
- # `rows` is the contact each card will be written as and how
- # many of its original's lines are not coming with it. `upload`
- # is the staged import every link and form carries, `group` the
- # name of the group this import files its arrivals under, and
- # `saved` the contact each row that has come in was written as,
- # by the row's place in the file.
- #: (upload: String, rows: Array[[::ProTacts::Contact, Integer]], unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String, saved: Hash[String, String], ?notice: String?) -> void
- def initialize(upload:, rows:, unknown:, group:, saved:, notice: nil)
+ # `contacts` is how many the file holds and `saved` how many of
+ # them are in the book. `upload` is the staged import every link
+ # and form carries, `group` the name of the group this import
+ # files its arrivals under, and `sidebar` the rows themselves.
+ #: (upload: String, contacts: Integer, saved: Integer, unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String, sidebar: Phlex::HTML, ?notice: String?) -> void
+ def initialize(upload:, contacts:, saved:, unknown:, group:, sidebar:, notice: nil)
@upload = upload
- @rows = rows
+ @contacts = contacts
+ @saved = saved
@unknown = unknown
@group = group
- @saved = saved
+ @sidebar = sidebar
@notice = notice
end
def view_template
- render Layout.new(title: "Import", notice: @notice) do
- div(class: "record") do
- div(class: "record-nav") do
- a(href: "/import", class: "type-label") { "‹ another file" }
+ render Layout.new(title: "Import", wide: true, notice: @notice) do
+ div(class: "walk") do
+ render @sidebar
+ div(class: "record") do
+ div(class: "record-nav") do
+ a(href: "/import", class: "type-label") { "‹ another file" }
+ end
+ summary_card
end
- summary_card
- contacts_list
end
end
end
@@ -73,42 +66,28 @@ module ProTacts
def summary_card
div(class: "card") do
div(class: "card-body") do
- h1(class: "type-h2", style: "margin: 0;") { count(@rows.length, "contact") }
+ h1(class: "type-h2", style: "margin: 0;") { count(@contacts, "contact") }
losses
- group_field
+ group_line
finish_form
end
end
end
- # A group made for this import and taking in every contact it
- # brings in, named for the moment by default so one import can
- # be found — or undone — apart from the next. Editable, and
- # emptiable; a name this book already uses joins that group
- # rather than making a second one by the same name
- # (Import::Write#group_id). Which of this book's own groups a
- # contact joins is that contact's question, asked beside its
- # card (Admin::ImportGroups).
- #
- # A field until the first contact comes in, and a fact
- # afterwards: renaming it halfway would leave what is already in
- # the book under the old name and put the rest somewhere else,
- # which is one import in two groups (Web#name_import_group).
+ # The group made for this import and taking in every contact it
+ # brings in, named for the moment it opened so one import can be
+ # found — or undone — apart from the next. A fact rather than a
+ # field: every Save files its contact under it, so a rename
+ # halfway would leave what is already in the book under the old
+ # name and put the rest somewhere else, and a rename before that
+ # is a name for a thing nobody has seen yet. Which groups a
+ # contact actually joins — this one among them, ticked and
+ # removable — is asked beside that contact's card
+ # (Admin::ImportGroups).
#: () -> void
- def group_field
- if @saved.any?
- p(class: "type-body-sm gl-muted") do
- @group.empty? ? "Coming in with no group of their own." : "Coming in as #{@group}."
- end
- return
- end
-
- form(action: "/import/#{@upload}/group", method: "post", class: "field-stack") do
- label(class: "field") do
- span(class: "type-label") { "group for the lot" }
- input(type: "text", name: "group", value: @group, placeholder: "no group")
- end
- button(type: "submit") { "name the group" }
+ def group_line
+ p(class: "type-body-sm gl-muted") do
+ @group.empty? ? "Coming in with no group of their own." : "Coming in as #{@group}."
end
end
@@ -120,11 +99,11 @@ module ProTacts
def finish_form
form(action: "/import/#{@upload}/done", method: "post", class: "field-stack") do
p(class: "type-body-sm gl-muted") do
- if @saved.empty?
+ if @saved.zero?
"Nothing has come in yet. Open a contact to look it over; its Save is what puts it in the book."
else
- "#{count(@saved.length, "contact")} in the book. " \
- "Finishing now leaves the other #{@rows.length - @saved.length} behind."
+ "#{count(@saved, "contact")} in the book. " \
+ "Finishing now leaves the other #{@contacts - @saved} behind."
end
end
button(type: "submit", data: {variant: "primary"}) { "finish" }
@@ -132,9 +111,9 @@ module ProTacts
end
# What the file is losing, by property rather than by line: a
- # reader deciding whether to open any of the rows below wants to
- # know it is `X-SOCIALPROFILE` and `PRODID` going, not that
- # eight hundred lines are.
+ # reader deciding whether to open any of the rows beside this
+ # wants to know it is `X-SOCIALPROFILE` and `PRODID` going, not
+ # that eight hundred lines are.
#: () -> void
def losses
if @unknown.empty?
@@ -162,33 +141,6 @@ module ProTacts
end
end
- # A row per contact: in the book, or marked where something is
- # being left behind — which is the whole of what the list is
- # for, since those are the rows worth opening.
- #: () -> void
- def contacts_list
- section do
- div(class: "section-head") do
- h2(class: "type-label") { "contacts" }
- end
- ul(class: "card") do
- @rows.each_with_index do |(contact, dropped), index|
- stored = @saved[index.to_s]
- render ListItem.new(
- # A row that has come in leads to the contact it
- # became: the card was written the moment its Save was
- # pressed, and from then on it is edited where every
- # other contact is (Web#open_card).
- href: stored ? "/contacts/#{stored}" : "/import/#{@upload}/#{index}",
- trailing: stored ? "saved" : (count(dropped, "line") + " left behind" if dropped.positive?),
- ) do
- div { Format.name_label(contact) }
- end
- end
- end
- end
- end
-
# A count with its noun, the plural spelled out where an "s" on
# the end will not do it.
#: (Integer number, String noun, ?String? plural) -> String
diff --git a/lib/pro_tacts/admin/import_sidebar.rb b/lib/pro_tacts/admin/import_sidebar.rb
new file mode 100644
index 0000000..6e5b3e6
--- /dev/null
+++ b/lib/pro_tacts/admin/import_sidebar.rb
@@ -0,0 +1,86 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/format"
+
+module ProTacts
+ module Admin
+ # The contacts an uploaded .vcf holds, down the side of every
+ # screen of the import (docs/plans/2026-09-21-import-a-vcf.md).
+ #
+ # Beside the content rather than a page of its own, because the
+ # walk is a list worked down: opening a row used to mean leaving
+ # the list, looking at one card, and coming back to find your
+ # place in it again. Here the place is never lost — the row being
+ # read is marked, the row after it is the next thing to click, and
+ # what the last ten screens settled is in view while the eleventh
+ # is open.
+ #
+ # Each row carries the two facts the walk turns on. Whether the
+ # contact is in the book, said in words rather than left to be
+ # inferred from where the row leads: everything here is unsaved
+ # until its own screen says otherwise, and a walk broken off
+ # overnight has to say which rows those are. And what the card is
+ # losing, as a diff's own mark — `-3` against the contact's name,
+ # in the sign-and-color shape a change log entry wears
+ # (Admin::ContactsShow#diff_lines, .diff-removed in admin.css) —
+ # because the number is the whole of what a row has to say about
+ # it and a sentence for it made every marked row a line of prose.
+ class ImportSidebar < Phlex::HTML
+ # @rbs @upload: String
+ # @rbs @rows: Array[[::ProTacts::Contact, Integer]]
+ # @rbs @saved: Hash[String, String]
+ # @rbs @current: Integer?
+
+ # `rows` is the contact each card will be written as and how
+ # many of its original's lines are not coming with it; `saved`
+ # the contact each row that has come in was written as, by the
+ # row's place in the file; `current` the row whose screen is
+ # open, or none on the screen that is about the file itself.
+ #: (upload: String, rows: Array[[::ProTacts::Contact, Integer]], saved: Hash[String, String], ?current: Integer?) -> void
+ def initialize(upload:, rows:, saved:, current: nil)
+ @upload = upload
+ @rows = rows
+ @saved = saved
+ @current = current
+ end
+
+ def view_template
+ nav(class: "walk-list") do
+ div(class: "section-head") do
+ h2(class: "type-label") { "contacts" }
+ end
+ ul(class: "card") do
+ @rows.each_with_index do |(contact, dropped), index|
+ row(contact, dropped, index)
+ end
+ end
+ end
+ end
+
+ private
+
+ #: (::ProTacts::Contact contact, Integer dropped, Integer index) -> void
+ def row(contact, dropped, index)
+ stored = @saved[index.to_s]
+ li do
+ # A row that has come in leads to the contact it became: the
+ # card was written the moment its Save was pressed, and from
+ # then on it is edited where every other contact is
+ # (Web#open_card). `aria-current` marks the row being read,
+ # which is the one thing a list beside its own content has
+ # to say that a list on a page of its own does not.
+ a(href: stored ? "/contacts/#{stored}" : "/import/#{@upload}/#{index}",
+ aria_current: ("page" if index == @current)) do
+ div(style: "flex: 1; min-width: 0; font-weight: 550;") do
+ div { Format.name_label(contact) }
+ span(class: "type-mono diff-removed") { "-#{dropped}" } if dropped.positive?
+ end
+ span(class: "type-label", data: {state: stored ? "saved" : "unsaved"}) do
+ stored ? "saved" : "not saved"
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/import_upload.rb b/lib/pro_tacts/admin/import_upload.rb
index b82633e..8fce52f 100644
--- a/lib/pro_tacts/admin/import_upload.rb
+++ b/lib/pro_tacts/admin/import_upload.rb
@@ -16,7 +16,8 @@ module ProTacts
#
# The form takes one .vcf and nothing else. Looking over what is
# in it needs the file open, so that is the next screen's
- # (ImportReview, and the pair of cards a row there opens).
+ # (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
diff --git a/lib/pro_tacts/import/staged.rb b/lib/pro_tacts/import/staged.rb
index f381e6d..1957f50 100644
--- a/lib/pro_tacts/import/staged.rb
+++ b/lib/pro_tacts/import/staged.rb
@@ -70,9 +70,10 @@ module ProTacts
File.binwrite(directory / file_name(ORIGINAL), original)
File.binwrite(directory / file_name(REVISED), revised)
# The group is settled here rather than at the end, there
- # being no end to settle it at any more: the first Save has to
- # know what to file its contact under, and every Save after it
- # has to agree (Web#name_import_group). Nothing saved yet, and
+ # being no end to settle it at any more: every Save files its
+ # contact under it, and nothing renames it afterwards — a
+ # rename would leave what is already in the book under the old
+ # name and put the rest somewhere else. Nothing saved yet, and
# empty rather than absent, so every slot of an import that
# exists is a file that exists and a write can say which of
# the two it found (#write).
@@ -104,9 +105,9 @@ module ProTacts
# is how a screen names one.
#
# Still rewritten for a card that was just saved into the store,
- # rather than dropped from the file: the list renders every row
- # from these bytes, and a row that has come in is one the list
- # still has to be able to name (Admin::ImportReview).
+ # rather than dropped from the file: the walk's list renders
+ # every row from these bytes, and a row that has come in is one
+ # it still has to be able to name (Admin::ImportSidebar).
#: (String id, String revised) -> void
def self.update(id, revised)
write(id, REVISED, revised)
@@ -140,17 +141,6 @@ module ProTacts
write(id, SAVED, JSON.generate({GROUP => group, CONTACTS => contacts.merge(index => contact)}))
end
- # The group the import files under, renamed. Only before the
- # first card is saved, which is the route's rule rather than
- # this one's (Web#name_import_group): a rename after that would
- # leave what is already in the book under the old name and put
- # the rest somewhere else, which is one import in two groups.
- #: (String id, String group) -> void
- def self.name_group(id, group)
- _, contacts = saved(id)
- write(id, SAVED, JSON.generate({GROUP => group, CONTACTS => contacts}))
- end
-
# The import, gone: what the walk's last screen leaves behind,
# and what keeps the ordinary case from waiting on the sweep.
# The contacts it wrote are in the store and stay there; this
diff --git a/lib/pro_tacts/import/vcf.rb b/lib/pro_tacts/import/vcf.rb
index a60c771..ce62c57 100644
--- a/lib/pro_tacts/import/vcf.rb
+++ b/lib/pro_tacts/import/vcf.rb
@@ -17,10 +17,12 @@ module ProTacts
#
# So this module owes two readings. The split into cards, and then
# per card the split #read makes: what is coming in, and what is
- # being left behind. The review screen shows both at once — the
- # card as it was exported with the doomed lines struck, beside the
- # card that is coming in, open in the editor — so nothing is lost
- # without having been shown first (Admin::ImportOriginal).
+ # being left behind. A contact's own screen shows both at once —
+ # the card as it was exported with the doomed lines struck, beside
+ # the card that is coming in, open in the editor — so nothing is
+ # lost without having been shown first (Admin::ImportOriginal),
+ # and the count of what one is losing is the mark its row in the
+ # walk wears (Admin::ImportSidebar).
module Vcf
# A file that is not a set of vCards.
class Invalid < StandardError; end
diff --git a/lib/pro_tacts/import/write.rb b/lib/pro_tacts/import/write.rb
index d8fbf55..defd016 100644
--- a/lib/pro_tacts/import/write.rb
+++ b/lib/pro_tacts/import/write.rb
@@ -26,7 +26,6 @@ module ProTacts
# rather than back into the editor (Web#open_card).
class Write
# @rbs @store: Store
- # @rbs @group: String?
# @rbs @group_ids: Hash[String, String]
# The group an import offers to put its arrivals in, named for
@@ -38,24 +37,24 @@ module ProTacts
"import-#{now.utc.strftime("%Y%m%dT%H%M%SZ")}"
end
- # `group` is a group named rather than chosen — this server's
- # own by that name, or a new one — and it takes in every card of
- # the import, that being the one thing they all have in common.
- # `joins` is the rest: the ids of groups this book already has,
- # as the walk ticked them beside this card. `named` is the same
- # answer in names, for the groups the walk asked for that do not
- # exist yet, made here because here is where the card they were
- # asked for is written. Any of them, or none: a card in no group
- # is still in everyone's book (#call).
- #: (Store store, VCard card, ?group: String?, ?joins: Array[String], ?named: Array[String]) -> Contact
- def self.call(store, card, group: nil, joins: [], named: [])
- new(store, group:).call(card, joins, named)
+ # `joins` is the ids of groups this book already has, as the
+ # screen beside this card ticked them. `named` is the same
+ # answer in names, for the groups that screen asked for that do
+ # not exist yet, made here because here is where the card they
+ # were asked for is written. The group for the import is one of
+ # the two, whichever it has become by now
+ # (Web#import_picker) — nothing here knows it from any other
+ # answer, there being nothing this can do about it that it does
+ # not do about the rest. Either list, or neither: a card in no
+ # group is still in everyone's book (#call).
+ #: (Store store, VCard card, ?joins: Array[String], ?named: Array[String]) -> Contact
+ def self.call(store, card, joins: [], named: [])
+ new(store).call(card, joins, named)
end
- #: (Store store, ?group: String?) -> void
- def initialize(store, group: nil)
+ #: (Store store) -> void
+ def initialize(store)
@store = store
- @group = group
@group_ids = {} #: Hash[String, String]
end
@@ -70,15 +69,13 @@ module ProTacts
# nobody's book (docs/plans/2026-09-15-client-creates-join-everyone.md).
@store.put(id, identified(card, id), client: true)
- # The chosen ids need no resolving, the walk having read them
- # off this same store. Deduplicated because two of these can
- # mean one group — a contact's own new group named what the
- # import's group is named, say — and a membership written
+ # The chosen ids need no resolving, the screen having read
+ # them off this same store. Deduplicated because two of these
+ # can mean one group — a name typed for a group that the box
+ # above it already offers, say — and a membership written
# twice is a constraint violation rather than a second
# membership.
- group = @group
join = [*chosen, *named.map { group_id(it) }]
- join << group_id(group) if group
@store.regroup(id, join: join.uniq, leave: []) unless join.empty?
# Read back rather than kept from the put, because the group
@@ -103,19 +100,18 @@ module ProTacts
rest.insert(["UID:#{id}\r\n"])
end
- # Looked up before it is made, so an import naming a group this
+ # Looked up before it is made, so a card naming a group this
# server already has joins that one rather than colliding with
- # its name (db/migrations/008_group_names.rb). That is what
- # carries the group for the lot across the walk: the first Save
- # makes it, and every Save after that finds it.
+ # its name (db/migrations/008_group_names.rb). That is also what
+ # carries the group for the import across the walk: the first
+ # Save makes it, and from then on it is an ordinary box.
#
# The lookup is per name rather than a list read once up front,
# because the put above makes a group of its own: the first card
# into an empty store creates `sync:*` (Store#everyone_group_id),
# and a list read before that would send this to create it again.
- # Memoized so that a card whose own new group is named what the
- # import's group is named reads the list once rather than making
- # the group twice. The choices read, not the whole groups read:
+ # Memoized so that a card given the same name twice reads the
+ # list once rather than making the group twice. The choices read, not the whole groups read:
# the match is on name, and a group's members are no part of it
# (Store#group_choices).
#: (String name) -> String
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 32bb9b4..18443cd 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -3,6 +3,7 @@ require "pro_tacts/admin/contacts_edit"
require "pro_tacts/admin/import_groups"
require "pro_tacts/admin/import_original"
require "pro_tacts/admin/import_review"
+require "pro_tacts/admin/import_sidebar"
require "pro_tacts/admin/import_upload"
require "pro_tacts/birthday"
require "pro_tacts/import/write"
@@ -20,12 +21,15 @@ module ProTacts
# the machine holding it is whichever one the browser is on.
#
# Three screens, because an import is a walk rather than a
- # submission: choose the file, look down the contacts it holds,
- # and open any of them beside the card it arrived as. The Save on
- # that last screen is the import of that one contact — not a note
- # kept until a confirm at the end — so the list is a list of what
- # is in the book and what is not yet, and what is left when the
- # walk stops is only the part nobody looked at.
+ # submission: choose the file, read what the whole of it is
+ # losing, and open any of its contacts beside the card it arrived
+ # as. The Save on that last screen is the import of that one
+ # contact — not a note kept until a confirm at the end — so the
+ # walk's list says what is in the book and what is not yet, and
+ # what is left when the walk stops is only the part nobody looked
+ # at. That list is a sidebar rather than a screen of its own
+ # (Admin::ImportSidebar): it stands beside the two later screens
+ # both, so opening a row never costs your place in it.
#
# Under the same identity gate as every other route (web.rb),
# which is also where the arrivals' books come from: a card this
@@ -46,10 +50,6 @@ module ProTacts
# Before the index below, and distinguishable from one: a
# path segment is what says which request this is, not which
# fields a form happened to carry.
- r.post "group" do
- name_import_group(r, upload)
- end
-
r.post "done" do
finish_import(upload)
end
@@ -107,10 +107,13 @@ module ProTacts
id = Import::Staged.open(
original: bytes,
revised: joined(cards.map { Import::Vcf.read(it).card }),
- # Named here and not at the end, there being no end to name it
- # at: the first Save has to know what to file its contact
- # under. Still the importer's to change until that first Save
- # (#name_import_group).
+ # Named here and not at the end, there being no end to name
+ # it at: every Save files its contact under it. Not the
+ # importer's to rename either — a name typed before anything
+ # has been looked at is a name for nothing, and one typed
+ # after would split an import across two groups. What is
+ # editable is the membership, beside each contact's own card
+ # and a contact at a time (#import_picker).
group: Import::Write.default_group,
)
# A 303, the other writes' answer, because what follows is a
@@ -118,8 +121,9 @@ module ProTacts
r.redirect "/import/#{id}", 303
end
- # The contacts the file holds: what each is losing, and whether it
- # is in the book yet.
+ # What the whole file is losing, what it is coming in under, and
+ # the way to stop. The contacts themselves are the sidebar beside
+ # it, which every screen of the walk carries.
#: (String upload, ?notice: String?) -> String
def review_screen(upload, notice: nil)
staged = staged_cards(upload)
@@ -127,24 +131,34 @@ module ProTacts
originals, revised = staged
group, saved = Import::Staged.saved(upload)
- rows = revised.each_with_index.map { |card, index|
- # A literal of several elements is an Array until something
- # says otherwise, and an inline annotation needs its own line.
- dropped = Import::Vcf.read(originals.fetch(index)).dropped
- [import_contact(card, index), Import::Vcf.losses(dropped).length] #: [Contact, Integer]
- }
response["Content-Type"] = "text/html; charset=utf-8"
Admin::ImportReview.call(
upload:,
- rows:,
+ contacts: revised.length,
+ saved: saved.length,
unknown: Import::Vcf.unknown(originals),
group:,
- saved:,
+ sidebar: walk_sidebar(upload, originals, revised, saved),
notice:,
)
end
+ # The walk's list of contacts, as every screen of it shows them:
+ # the contact each card will be written as, how many of its
+ # original's lines are not coming with it, and which rows are in
+ # the book already. `current` is the row whose screen is open.
+ #: (String upload, Array[VCard] originals, Array[VCard] revised, Hash[String, String] saved, ?current: Integer?) -> Admin::ImportSidebar
+ def walk_sidebar(upload, originals, revised, saved, current: nil)
+ rows = revised.each_with_index.map { |card, index|
+ # A literal of several elements is an Array until something
+ # says otherwise, and an inline annotation needs its own line.
+ dropped = Import::Vcf.read(originals.fetch(index)).dropped
+ [import_contact(card, index), Import::Vcf.losses(dropped).length] #: [Contact, Integer]
+ }
+ Admin::ImportSidebar.new(upload:, rows:, saved:, current:)
+ end
+
# A row opened: the card it arrived as beside the card that is
# coming in — or the contact itself, for a card this walk has
# already saved. The editor here writes into the import, and an
@@ -165,13 +179,14 @@ module ProTacts
# copy of it: a field the two disagreed about would be a field an
# import writes and an edit cannot undo.
#
- # `joined` and `named` are the groups a refused save had ticked.
- # Empty on the way in, because a card that is not in the book is a
- # card nothing has been decided about yet — there is no staged
- # answer to read back, the Save that would have written one being
- # the Save that writes the contact.
- #: (String upload, Integer index, ?notice: String?, ?joined: Array[String], ?named: Array[String]) -> String?
- def card_screen(upload, index, notice: nil, joined: [], named: [])
+ # `joined` and `named` are the groups a refused save had ticked,
+ # and none on the way in: nothing is staged between screens, the
+ # Save that would have written an answer being the Save that
+ # writes the contact, so a card opened afresh is asked the
+ # question with the defaults ticked (#import_picker) rather than
+ # with an earlier answer read back.
+ #: (String upload, Integer index, ?notice: String?, ?joined: Array[String]?, ?named: Array[String]?) -> String?
+ def card_screen(upload, index, notice: nil, joined: nil, named: nil)
staged = staged_cards(upload)
return expired_screen if staged.nil?
@@ -183,14 +198,50 @@ module ProTacts
# has.
return nil if original.nil? || card.nil?
+ group, saved = Import::Staged.saved(upload)
+
response["Content-Type"] = "text/html; charset=utf-8"
Admin::ContactsEdit.call(
contact: import_contact(card, index),
notice:,
action: "/import/#{upload}/#{index}",
back: ["/import/#{upload}", "the import"],
+ # The submit says where the card is going, because it is not
+ # there yet: this is the one editor in the app whose Save
+ # creates the record rather than amending it, and the row
+ # beside it reads "not saved" until it has been pressed
+ # (Admin::ImportSidebar).
+ save: "save to the book",
aside: Admin::ImportOriginal.new(card: original, dropped: Import::Vcf.read(original).dropped),
- fields: Admin::ImportGroups.new(groups: store.group_choices, joined:, named:),
+ fields: import_picker(group, joined, named),
+ sidebar: walk_sidebar(upload, originals, revised, saved, current: index),
+ )
+ end
+
+ # The groups beside one card: this book's own list, with the two
+ # an arrival comes in under already ticked.
+ #
+ # Everyone's book is ticked and fixed, `Store#put`'s `client:
+ # true` joining it whatever this form says. It is missing from
+ # the list on one screen in the life of a server — the first card
+ # of the first import into an empty book, where that put is what
+ # creates it — and a row for a group that does not exist would be
+ # a worse answer than none.
+ #
+ # The group for the import is ticked and can be unticked. It is a
+ # name rather than an id until something makes it, which is the
+ # first Save to go in under it (Import::Write#group_id), so it
+ # rides as one of the staged names on that first screen and as an
+ # ordinary box on every screen after.
+ #: (String group, Array[String]? joined, Array[String]? named) -> Admin::ImportGroups
+ def import_picker(group, joined, named)
+ choices = store.group_choices
+ lot = group.empty? ? nil : choices.find { it.name == group }
+ Admin::ImportGroups.new(
+ groups: choices,
+ joined: joined || [lot&.id].compact,
+ named: named || [(group unless lot || group.empty?)].compact,
+ fixed: choices.select { it.name == Store::EVERYONE }.map(&:id),
)
end
@@ -207,7 +258,7 @@ module ProTacts
card = revised[index]
return nil if card.nil?
- group, saved = Import::Staged.saved(upload)
+ _group, saved = Import::Staged.saved(upload)
# A Save pressed twice, or a back button onto the screen of a
# contact that is already in the book: a second write here would
# be a second contact rather than an edit of the first, there
@@ -221,9 +272,11 @@ module ProTacts
# Save (Admin::ImportGroups): a card the walk has looked at is
# decided about in both ways at once. Only ids naming a group,
# #apply_groups' own rule, and the boxes are the whole answer —
- # none ticked is none joined. A name typed into the filter joins
- # the names already standing for this contact, and is made by
- # the write below (Import::Write#group_id).
+ # none ticked is none joined, the group for the import included,
+ # which is a box like any other here (#import_picker). A name
+ # typed into the filter joins the names already standing for
+ # this contact, and is made by the write below
+ # (Import::Write#group_id).
#
# Read before the refusals rather than after, so a card sent
# back to be fixed comes back with its boxes as they were
@@ -296,7 +349,7 @@ module ProTacts
# is still a row the list has to be able to name.
revised[index] = edited
Import::Staged.update(upload, joined(revised))
- written = Import::Write.call(store, edited, group: group.empty? ? nil : group, joins: ticked, named: wanted)
+ written = Import::Write.call(store, edited, joins: ticked, named: wanted)
Import::Staged.record(upload, index.to_s, written.id)
# The last one: there is nothing left to come back to, so the
@@ -307,30 +360,6 @@ module ProTacts
r.redirect "/import/#{upload}", 303
end
- # The group the arrivals are filed under, named. Only while the
- # import has written nothing: the name is what every Save files
- # its contact under, and changing it halfway would leave what is
- # already in the book under the old name and put the rest
- # somewhere else, which is one import in two groups.
- #: (untyped r, String upload) -> untyped
- def name_import_group(r, upload)
- return expired_screen if Import::Staged.read(upload, Import::Staged::SAVED).nil?
-
- group, saved = Import::Staged.saved(upload)
- if saved.any?
- settled =
- if group.empty?
- "Contacts from this import are in the book already, in no group of their own."
- else
- "Contacts from this import are in #{group} already, and the rest join them."
- end
- return review_screen(upload, notice: settled)
- end
-
- Import::Staged.name_group(upload, r.params["group"].to_s.strip)
- 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 by saving the last card or by
# saying so on the list, and the rows nobody opened are simply
diff --git a/public/admin.css b/public/admin.css
index 21255ea..496a6ee 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -858,3 +858,52 @@ dialog > .field-stack > input[type="search"] {
.diff-added {
color: var(--gl-color-success);
}
+
+/* The import's walk: the contacts down one side, whatever is being
+ read beside them (Admin::ImportSidebar). Asymmetric where .paired
+ and .dashboard are even, because these columns are not peers -- one
+ is a place in a list and the other is the thing being looked at --
+ so it names a width for the first and gives the rest to the second,
+ and collapses to one column where the two cannot both stand. The
+ sidebar goes on top there rather than under: it is how you got to
+ what is below it. */
+.walk {
+ display: grid;
+ grid-template-columns: minmax(0, 17rem) minmax(0, 1fr);
+ gap: var(--gl-space-m);
+ align-items: start;
+}
+
+@media (max-width: 56rem) {
+ .walk {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+/* The row being read: the hover fill it already knows, plus a rail in
+ the accent down its inside edge. A rail rather than a filled row,
+ because the fill is the hover gesture and a row wearing it at rest
+ reads as hovered; drawn as an inset shadow rather than a border so
+ the text of one row stays in line with the others. accent-soft
+ would have said it in one declaration and is not an option here --
+ tokens.css never derives it again in dark mode (see the accent
+ block above). */
+.walk-list a[aria-current] {
+ background: var(--gl-color-surface-fill-2);
+ box-shadow: inset 3px 0 0 var(--gl-color-accent);
+}
+
+/* Whether a row is in the book yet, which is the one thing the walk
+ turns on. The unsaved state is the page's own text color rather
+ than the muted grey a type-label takes: everything here is unsaved
+ until its own screen says otherwise, and a state nobody notices is
+ the silence this list exists to undo. Saved goes quiet, and green,
+ the status color being one of the two tokens that never swap with
+ the accent. The words carry it either way and the color is the
+ second telling, the rule the diff marks beside them follow. */
+.walk-list [data-state="unsaved"] {
+ color: var(--gl-color-text-primary);
+}
+.walk-list [data-state="saved"] {
+ color: var(--gl-color-success);
+}
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index f57f4e0..3edfd48 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -14,7 +14,7 @@ module ProTacts
# ships no signatures and the views are ignored (see the
# Steepfile).
class ContactsEdit
- def self.call: (contact: Contact, ?notice: String?, ?action: String?, ?back: [String, String]?, ?aside: untyped, ?fields: untyped) -> String
+ def self.call: (contact: Contact, ?notice: String?, ?action: String?, ?back: [String, String]?, ?aside: untyped, ?fields: untyped, ?sidebar: untyped, ?save: String?) -> String
end
class Dashboard
@@ -46,7 +46,7 @@ module ProTacts
end
class ImportReview
- def self.call: (upload: String, rows: Array[[Contact, Integer]], unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String, saved: Hash[String, String], ?notice: String?) -> String
+ def self.call: (upload: String, contacts: Integer, saved: Integer, unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String, sidebar: untyped, ?notice: String?) -> String
end
# Rendered into ContactsEdit rather than called, so these are the
@@ -56,7 +56,11 @@ module ProTacts
end
class ImportGroups
- def self.new: (groups: Array[::ProTacts::Store::GroupChoice], joined: Array[String], named: Array[String]) -> instance
+ def self.new: (groups: Array[::ProTacts::Store::GroupChoice], joined: Array[String], named: Array[String], ?fixed: Array[String]) -> instance
+ end
+
+ class ImportSidebar
+ def self.new: (upload: String, rows: Array[[Contact, Integer]], saved: Hash[String, String], ?current: Integer?) -> instance
end
# Rendered by Dashboard, not called from checked code; only
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 96eb458..98c50c9 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -64,12 +64,30 @@ class AdminImportPagesTest < Minitest::Test
# A row opened and its Save pressed, which is the import of that one
# contact: the fields the editor always sends, over the etag the
- # page it came from was rendered with.
+ # page it came from was rendered with, and the boxes that page came
+ # with sent back the way a browser sends them — a card arrives with
+ # the groups it comes in under already ticked (Web#import_picker).
def save(id, index, first:, last:, **params)
get "/import/#{id}/#{index}"
post "/import/#{id}/#{index}",
{"etag" => etag, "first" => first, "middle" => "", "last" => last,
- "nickname" => "", "note" => ""}.merge(params)
+ "nickname" => "", "note" => "",
+ "groups" => ticked("groups[]"), "named" => ticked("named[]")}.merge(params)
+ end
+
+ # The values of the boxes the last page rendered ticked. A fixed box
+ # is disabled and a browser sends none of those back, so neither
+ # does this (Admin::ImportGroups).
+ def ticked(name)
+ last_response.body.scan(/name="#{Regexp.escape(name)}" value="([^"]*)" checked(?! disabled)/).flatten
+ end
+
+ # The group this import files its arrivals under, read off the
+ # screen that states it — there is no form for it, and the name is
+ # the moment the upload happened.
+ def import_group(id)
+ get "/import/#{id}"
+ last_response.body[/Coming in as ([^.<]+)\./, 1].to_s
end
def test_the_screen_asks_for_a_vcf
@@ -97,15 +115,43 @@ class AdminImportPagesTest < Minitest::Test
end
end
- # The row says what opening it would be for: a contact losing
- # nothing does not need a visit.
- def test_the_list_marks_the_contacts_losing_something
+ # The row says what opening it would be for, in a diff's own mark: a
+ # contact losing nothing does not need a visit, and wears nothing.
+ def test_the_walk_marks_what_each_contact_is_losing
+ with_contacts({}) do |_store|
+ upload(JANE + PLAIN)
+ follow_redirect!
+
+ assert_includes last_response.body, %(<span class="type-mono diff-removed">-3</span>)
+ assert_equal 1, last_response.body.scan("diff-removed").length
+ end
+ end
+
+ # The contacts stand beside whatever is open, rather than on a page
+ # of their own: opening a row never costs your place in the list.
+ def test_the_walk_stands_beside_the_card_being_read
+ with_contacts({}) do |_store|
+ id = upload(JANE + PLAIN)
+
+ get "/import/#{id}/0"
+
+ assert_includes last_response.body, %(<div class="walk">)
+ assert_includes last_response.body, %(<nav class="walk-list">)
+ assert_includes last_response.body, "Sam Booles"
+ assert_includes last_response.body, %(href="/import/#{id}/1")
+ # And says which row you are on.
+ assert_includes last_response.body, %(<a href="/import/#{id}/0" aria-current="page">)
+ end
+ end
+
+ # Everything is unsaved until its own screen says otherwise, and a
+ # walk broken off overnight has to say which rows those are.
+ def test_a_row_that_is_not_in_the_book_says_so
with_contacts({}) do |_store|
upload(JANE + PLAIN)
follow_redirect!
- assert_includes last_response.body, "3 lines left behind"
- assert_equal 1, last_response.body.scan("left behind").length
+ assert_equal 2, last_response.body.scan(%(data-state="unsaved">not saved)).length
end
end
@@ -128,7 +174,7 @@ class AdminImportPagesTest < Minitest::Test
follow_redirect!
assert_includes last_response.body, "Nothing in this file needs reading before it comes in."
- refute_includes last_response.body, "left behind"
+ refute_includes last_response.body, "diff-removed"
end
end
@@ -141,7 +187,7 @@ class AdminImportPagesTest < Minitest::Test
follow_redirect!
assert_includes last_response.body, "Nothing in this file needs reading before it comes in."
- refute_includes last_response.body, "left behind"
+ refute_includes last_response.body, "diff-removed"
refute_includes last_response.body, "PRODID"
end
end
@@ -184,6 +230,8 @@ class AdminImportPagesTest < Minitest::Test
assert_includes last_response.body, %(<form action="/import/#{id}/0" method="post")
assert_includes last_response.body, %(value="+1 555 0100")
assert_includes last_response.body, %(href="/import/#{id}")
+ # The one editor in the app whose Save creates the record.
+ assert_includes last_response.body, "save to the book"
end
end
@@ -217,7 +265,7 @@ class AdminImportPagesTest < Minitest::Test
follow_redirect!
assert_includes last_response.body, %(href="/contacts/#{jane.id}")
- assert_includes last_response.body, %(<span class="type-label">saved</span>)
+ assert_includes last_response.body, %(data-state="saved">saved</span>)
assert_includes last_response.body, %(href="/import/#{id}/1")
assert_includes last_response.body, "1 contact in the book."
end
@@ -384,9 +432,8 @@ class AdminImportPagesTest < Minitest::Test
def test_a_group_named_beside_a_card_is_made_with_it
with_contacts({}) do |store|
id = upload(JANE)
- post "/import/#{id}/group", "group" => ""
- save(id, 0, first: "Jane", last: "Booles", "new" => " Clarks ")
+ save(id, 0, first: "Jane", last: "Booles", "named" => [], "new" => " Clarks ")
clarks = store.all_groups.find { it.name == "Clarks" }
@@ -394,6 +441,84 @@ class AdminImportPagesTest < Minitest::Test
end
end
+ # The group for the import is one of the boxes beside the card, not
+ # a field on a screen of its own: it is named when the upload
+ # happens, ticked for every card, and shown rather than applied
+ # behind the walk's back.
+ def test_the_group_for_the_import_is_ticked_beside_every_card
+ with_contacts({}) do |store|
+ id = upload(JANE + PLAIN)
+ lot = import_group(id)
+
+ assert_match(/\Aimport-\d{8}T\d{6}Z\z/, lot)
+
+ # On the first card it is a name, nothing having made it yet.
+ get "/import/#{id}/0"
+
+ assert_includes last_response.body, %(<input type="checkbox" name="named[]" value="#{lot}" checked>)
+
+ save(id, 0, first: "Jane", last: "Booles")
+ made = store.all_groups.find { it.name == lot }
+
+ assert_equal [store.contacts.fetch(0).id], made.members
+
+ # On the next one it is an ordinary box, ticked.
+ get "/import/#{id}/1"
+
+ assert_includes last_response.body,
+ %(<input type="checkbox" name="groups[]" value="#{made.id}" checked>)
+ end
+ end
+
+ # And it can be unticked: an arrival that does not belong with the
+ # lot is a thing the walk is for deciding.
+ def test_an_arrival_can_be_kept_out_of_the_import_group
+ with_contacts({}) do |store|
+ id = upload(JANE)
+
+ save(id, 0, first: "Jane", last: "Booles", "named" => [])
+
+ assert_equal [ProTacts::Store::EVERYONE], store.all_groups.map(&:name)
+ end
+ end
+
+ # Renaming it is not offered. A name typed before anything has been
+ # looked at is a name for nothing, and one typed after would leave
+ # what is already in the book under the old name.
+ def test_the_group_for_the_import_cannot_be_renamed
+ with_contacts({}) do |_store|
+ id = upload(JANE)
+ follow_redirect!
+
+ assert_includes last_response.body, "Coming in as import-"
+ refute_includes last_response.body, %(name="group")
+
+ post "/import/#{id}/group", "group" => "somewhere else"
+
+ assert_equal 404, last_response.status
+ end
+ end
+
+ # Everyone's book is shown with the rest, ticked and fixed: every
+ # card this writes joins it whatever the form says, so a box that
+ # could be cleared would be a box that lies.
+ def test_everyones_book_is_shown_beside_a_card_and_cannot_be_cleared
+ with_contacts({}) do |store|
+ id = upload(JANE + PLAIN)
+ save(id, 0, first: "Jane", last: "Booles")
+ everyone = store.all_groups.find { it.name == ProTacts::Store::EVERYONE }
+
+ get "/import/#{id}/1"
+
+ assert_includes last_response.body,
+ %(<input type="checkbox" name="groups[]" value="#{everyone.id}" checked disabled>)
+
+ save(id, 1, first: "Sam", last: "Booles")
+
+ assert_equal store.contacts.map(&:id).sort, store.group(everyone.id).members.sort
+ end
+ end
+
# A save sent back to be fixed comes back with its boxes as they
# were ticked: nothing is staged between screens, so the form is
# the only record of them until the write.
@@ -439,9 +564,11 @@ class AdminImportPagesTest < Minitest::Test
# The cap the dialog got (docs/plans/2026-09-22-a-few-groups-at-a-time.md),
# over the picker the walk opens once per contact: eight rows stand
- # shown, the ninth is marked for the filter to reach, and the button
+ # shown, the rest are marked for the filter to reach, and the button
# counts the whole list. A group already ticked for this contact
- # takes a row from the cap rather than standing outside it.
+ # takes a row from the cap rather than standing outside it, and so
+ # does the group for the import, which is ticked and not yet in the
+ # list the cap reads.
def test_the_groups_beside_a_card_show_a_few_at_a_time
with_contacts({}) do |store|
("a".."h").each { store.create_group(name: "Group #{it}") }
@@ -450,16 +577,18 @@ class AdminImportPagesTest < Minitest::Test
get "/import/#{id}/0"
- # Alphabetical, so Zulus is the ninth and the one that goes under.
+ # Alphabetical, and the import's own name takes the eighth row,
+ # so Group h and Zulus are the two that go under.
assert_includes last_response.body, %(<label data-label="zulus" data-capped :hidden="!visible($el)">)
- assert_equal 1, last_response.body.scan("data-capped").length
+ assert_equal 2, last_response.body.scan("data-capped").length
assert_includes last_response.body, "show all 9 groups"
- # Ticked on a save that came back to be fixed, so Zulus is
- # spared and Group h goes under in its place.
+ # Ticked on a save that came back to be fixed — and the import's
+ # own name unticked with it — so Zulus is spared and Group h
+ # goes under alone.
post "/import/#{id}/0",
"etag" => "not the etag", "first" => "Jane", "middle" => "", "last" => "Booles",
- "nickname" => "", "note" => "", "groups" => [zulus]
+ "nickname" => "", "note" => "", "groups" => [zulus], "named" => []
assert_includes last_response.body, %(<label data-label="zulus" :hidden="!visible($el)">)
assert_includes last_response.body, %(<label data-label="group h" data-capped :hidden="!visible($el)">)
@@ -469,7 +598,8 @@ class AdminImportPagesTest < Minitest::Test
def test_the_groups_beside_a_card_offer_no_such_thing_uncapped
with_contacts({}) do |store|
- ("a".."h").each { store.create_group(name: "Group #{it}") }
+ # Seven, the import's own name being the eighth row.
+ ("a".."g").each { store.create_group(name: "Group #{it}") }
id = upload(JANE)
get "/import/#{id}/0"
@@ -487,9 +617,9 @@ class AdminImportPagesTest < Minitest::Test
with_contacts({}) do |store|
school = store.create_group(name: "school")
id = upload(JANE + PLAIN)
- post "/import/#{id}/group", "group" => "import-20260921T031655Z"
+ lot = import_group(id)
- save(id, 0, first: "Jane", last: "Booles", "groups" => [school, "zzzz"])
+ save(id, 0, first: "Jane", last: "Booles", "groups" => [school, "zzzz"], "named" => [lot])
save(id, 1, first: "Sam", last: "Booles")
assert_equal 200, last_response.status
@@ -497,9 +627,9 @@ class AdminImportPagesTest < Minitest::Test
sam = store.contacts.find { it.name == "Sam Booles" }
assert_equal [jane.id], store.group(school).members
- assert_equal ["import-20260921T031655Z", "school", ProTacts::Store::EVERYONE],
+ assert_equal [lot, "school", ProTacts::Store::EVERYONE],
store.all_groups.select { it.members.include?(jane.id) }.map(&:name).sort
- assert_equal ["import-20260921T031655Z", ProTacts::Store::EVERYONE],
+ assert_equal [lot, ProTacts::Store::EVERYONE],
store.all_groups.select { it.members.include?(sam.id) }.map(&:name).sort
end
end
@@ -510,7 +640,6 @@ class AdminImportPagesTest < Minitest::Test
with_contacts({}) do |store|
school = store.create_group(name: "school")
id = upload(JANE)
- post "/import/#{id}/group", "group" => ""
get "/import/#{id}/0"
sent = etag
store.delete_group(school)
@@ -524,59 +653,6 @@ class AdminImportPagesTest < Minitest::Test
end
end
- # The group for the lot is named before anything comes in, so one
- # import can be found — or undone — apart from the next.
- def test_the_group_for_the_lot_is_named_before_anything_comes_in
- with_contacts({}) do |_store|
- id = upload(JANE)
- follow_redirect!
-
- assert_includes last_response.body, %(<form action="/import/#{id}/group" method="post")
- assert_includes last_response.body, %(name="group" value="import-)
-
- post "/import/#{id}/group", "group" => "the Booles"
-
- assert_equal 303, last_response.status
- follow_redirect!
-
- assert_includes last_response.body, %(name="group" value="the Booles")
- end
- end
-
- # And not after: a rename halfway would leave what is already in
- # the book under the old name and put the rest somewhere else.
- def test_the_group_for_the_lot_is_settled_once_a_contact_is_in
- with_contacts({}) do |store|
- id = upload(JANE + PLAIN)
- post "/import/#{id}/group", "group" => "the Booles"
- save(id, 0, first: "Jane", last: "Booles")
-
- post "/import/#{id}/group", "group" => "somewhere else"
-
- assert_equal 200, last_response.status
- assert_includes last_response.body, "Contacts from this import are in the Booles already"
- assert_includes last_response.body, "Coming in as the Booles."
-
- save(id, 1, first: "Sam", last: "Booles")
-
- refute_includes store.all_groups.map(&:name), "somewhere else"
- assert_equal store.contacts.map(&:id).sort,
- store.all_groups.find { it.name == "the Booles" }.members.sort
- end
- end
-
- # Emptiable, for an import that is not worth a group of its own.
- def test_an_import_can_come_in_with_no_group_of_its_own
- with_contacts({}) do |store|
- id = upload(JANE)
- post "/import/#{id}/group", "group" => ""
-
- save(id, 0, first: "Jane", last: "Booles")
-
- assert_equal [ProTacts::Store::EVERYONE], store.all_groups.map(&:name)
- end
- end
-
# Swept out from under a screen left open, or a finish submitted
# twice: the import is gone and only the person has another copy.
def test_finishing_an_import_that_is_gone_writes_nothing
diff --git a/test/pro_tacts/import/test_write.rb b/test/pro_tacts/import/test_write.rb
index d1e8bf3..a88b40c 100644
--- a/test/pro_tacts/import/test_write.rb
+++ b/test/pro_tacts/import/test_write.rb
@@ -24,9 +24,9 @@ class ImportWriteTest < Minitest::Test
# One card, which is what one Save hands over.
def card = Vcf.cards(JANE).fetch(0)
- def write_card(group: nil, joins: [], named: [])
+ def write_card(joins: [], named: [])
with_contacts({}) do |store|
- yield Write.call(store, card, group:, joins:, named:), store
+ yield Write.call(store, card, joins:, named:), store
end
end
@@ -56,12 +56,13 @@ class ImportWriteTest < Minitest::Test
end
# A card this creates joins everyone's book the way a client's
- # create does, and the import's own group besides. One call per
- # card, so the second card's group is the first card's, found
- # rather than made again.
+ # create does, and whatever its screen ticked besides — the group
+ # for the import among them (Web#import_picker). One call per card,
+ # so the second card's group is the first card's, found rather than
+ # made again.
def test_arrivals_join_the_import_group_and_everyones_book
with_contacts({}) do |store|
- ids = 2.times.map { Write.call(store, card, group: "import-20260921T031655Z").id }
+ ids = 2.times.map { Write.call(store, card, named: ["import-20260921T031655Z"]).id }
assert_equal 2, ids.uniq.length
groups = store.all_groups.select { it.members.sort == ids.sort }
@@ -79,7 +80,7 @@ class ImportWriteTest < Minitest::Test
with_contacts({}) do |store|
existing = store.create_group(name: "import-20260921T031655Z")
- ids = 2.times.map { Write.call(store, card, group: "import-20260921T031655Z").id }
+ ids = 2.times.map { Write.call(store, card, named: ["import-20260921T031655Z"]).id }
assert_equal ids.sort, store.group(existing).members.sort
assert_equal 1, store.all_groups.count { it.name == "import-20260921T031655Z" }
@@ -92,7 +93,7 @@ class ImportWriteTest < Minitest::Test
with_contacts({}) do |store|
school = store.create_group(name: "school")
- id = Write.call(store, card, group: "import-20260921T031655Z", joins: [school]).id
+ id = Write.call(store, card, named: ["import-20260921T031655Z"], joins: [school]).id
assert_equal [id], store.group(school).members
assert_equal ["import-20260921T031655Z", "school", ProTacts::Store::EVERYONE],
@@ -132,19 +133,22 @@ class ImportWriteTest < Minitest::Test
end
end
- # Two answers can mean one group, and a membership written twice is
- # a constraint violation rather than a second membership.
- def test_a_name_that_is_the_imports_own_group_joins_it_once
+ # Two answers can mean one group — a box ticked and a name typed for
+ # the group it already names — and a membership written twice is a
+ # constraint violation rather than a second membership.
+ def test_a_name_that_is_a_chosen_group_joins_it_once
with_contacts({}) do |store|
- id = Write.call(store, card, group: "Clarks", named: ["Clarks"]).id
+ clarks = store.create_group(name: "Clarks")
+
+ id = Write.call(store, card, joins: [clarks], named: ["Clarks"]).id
assert_equal 1, store.all_groups.count { it.name == "Clarks" }
- assert_equal [id], store.all_groups.find { it.name == "Clarks" }.members
+ assert_equal [id], store.group(clarks).members
end
end
- # Either, or neither: a chosen group stands on its own when the
- # import files under no group at all.
+ # Either, or neither: a chosen group stands on its own when no name
+ # is given with it.
def test_arrivals_join_a_chosen_group_with_no_group_named
with_contacts({}) do |store|
school = store.create_group(name: "school")
@@ -156,7 +160,9 @@ class ImportWriteTest < Minitest::Test
end
end
- def test_no_group_name_puts_the_card_in_everyones_book_alone
+ # Nothing ticked is nothing joined, the group for the import
+ # included: a card in no group is still in everyone's book.
+ def test_no_group_at_all_puts_the_card_in_everyones_book_alone
write_card do |imported, store|
assert_equal [ProTacts::Store::EVERYONE], store.all_groups.map(&:name)
assert_equal [imported.id], store.all_groups.fetch(0).members