import: save each contact as the walk reaches it
The import wrote nothing until a confirm at the bottom of the list, so
an evening's work down a book was held by a temporary directory and a
browser tab, and stopping halfway imported nothing at all. The Save on
a contact's own screen is that contact's import now: it goes into the
book there and then, under the group named for the lot and whatever
else was ticked beside it.
What follows from that:
- The list is where the walk is read back. A row that has come in says
so and leads to the contact; a row nobody has opened still leads into
the editor. Finishing is a "finish" on the list, or the Save of the
last unsaved row, and either way the rest are left behind with this
copy of the file.
- The group for the lot is named when the import opens rather than at
the end, there being no end to name it at, and settles once the first
contact is in: renaming it halfway would leave what is already in the
book under the old name and put the rest somewhere else.
- The staged groups slot is gone. Nothing is decided-but-unwritten any
more, so a card opened afresh has no box ticked, and a save sent back
to be fixed carries its own boxes back on the form.
- Import::Write takes one card rather than a file's worth.
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 f20c30f..48550c8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -38,14 +38,15 @@ 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 reviewed 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
-— before the lot is written through the store, into a group made for the
-import and whichever groups each contact was given
-(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 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.
## Task management
@@ -65,7 +66,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/import.rb # The import screen: a .vcf, uploaded and decided
+├── web/import.rb # The import screens: a .vcf, uploaded and saved
├── web/api.rb # JSON for scripts: the group list
├── import/ # Reading an uploaded .vcf, and writing its cards
├── admin/ # The Phlex views those screens render, plus
diff --git a/docs/plans/2026-09-21-import-a-vcf.md b/docs/plans/2026-09-21-import-a-vcf.md
index 3e2badd..bb25b72 100644
--- a/docs/plans/2026-09-21-import-a-vcf.md
+++ b/docs/plans/2026-09-21-import-a-vcf.md
@@ -2,8 +2,9 @@
2026-09-21. Importing is one upload of one file. `/import` takes a
`.vcf`, says what is in it, asks what to do with the properties no
-screen here shows, and writes the cards through the store. Everything
-that came before it — `import:macos:plan`, `import:macos:finalize`,
+screen here shows, and writes each card through the store as that
+card's own screen is looked over and saved. Everything that came
+before it — `import:macos:plan`, `import:macos:finalize`,
`import:status`, the Swift reader they drove Contacts.app with, and the
plan directories under `data/import` — is gone.
@@ -94,15 +95,15 @@ the wrong remedy: it asks about `X-ABRELATEDNAMES` in the abstract,
and the answer it can give back is a line this app composed. What the
importer actually wants is to look at a contact and fix it.
-So the review is a walk, four screens rather than a submission:
+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, 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 — and a file whose
- every loss is noise says so at the top and can be confirmed unread.
+ 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.
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
@@ -112,9 +113,10 @@ So the review is a walk, four screens rather than a submission:
a filtered list of boxes to tick, the filter doubling as the way to
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.
-4. **Confirm**, under a group for the lot, and the cards are written as
- the walk left them.
+ 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.
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
@@ -135,6 +137,48 @@ 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.
+## Nothing waits for the end
+
+The first shape of this had a confirm at the end: the walk wrote its
+decisions down, and one button at the bottom of the list wrote every
+card at once. What that bought was a single point of no return, and
+what it cost was everything around it.
+
+- **A walk that has to be finished to be worth anything.** A book is
+ hundreds of contacts, and looking them over is an evening rather
+ than a sitting. Written only at the end, that evening's work is held
+ by a temporary directory and a browser tab, and stopping halfway
+ imports nothing at all.
+- **A second record of decisions already made.** The groups ticked
+ beside a card had to be staged, because nothing could be written
+ yet — so a contact's groups lived in two places, and a slot on disk
+ existed whose only reader was the button at the bottom.
+- **A button that means more than it says.** "Import 242 contacts",
+ under a list where twelve rows have been read and two hundred and
+ thirty have not, is one press standing for two quite different
+ decisions.
+
+So the Save is the import. A contact is in the book the moment its own
+screen says so, and the list reads that back: a row that has come in
+says so and leads to the contact rather than back into the editor,
+which is also the answer to what a second Save on it would mean. The
+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. Saving the last unsaved row ends it; a "finish" on the list ends
+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.
+
## The import waits on the server
An import is a directory under `data/imports`, keyed by a minted id
@@ -143,19 +187,18 @@ that every screen of the walk carries in its path. Three slots in it.
`revised.vcf` the cards as they will come in — pared when the import
opens and rewritten whole each time the editor saves one of them. A card that
has been edited still has to show what it arrived as, which is why
-both. `groups.json` is which groups each of those cards is joining,
-keyed by the card's place in the file: a vCard says nothing about this
-book's groups, and a line invented to hold the answer would end up in
-the contact. Two maps in it, because a group this book has and a group this
-import is about to invent are not the same answer — ids for the first,
-which is the only way to name a group that has no name of its own, and
-names for the second, made at the confirm.
+both. `saved.json` is what the import has already done: the group it
+files its arrivals under, and the contact each saved card became, keyed
+by the card's place in the file. That last map is only how the list
+knows which rows are done — the store is the record of the contact
+itself — and it is the whole of what this holds back, the groups a
+contact joins being written by the same Save that writes the contact.
On disk rather than in the browser: a book with pictures in it is tens
of megabytes, and a form carrying it back and forth is the upload done
once per screen. An id off a link is checked against the shape
`ChangeId.mint` draws before it is made into a path — `../../contacts.db`
-is a filename too. The directory goes the moment the cards are written, and
+is a filename too. The directory goes the moment the walk ends, and
anything older than a day is swept on the next upload: long enough to
work down a book over an evening, short enough that a closed window
does not leave that book on disk for a week.
@@ -166,10 +209,10 @@ place and never adds or removes one.
## Writing them in
-`Import::Write` writes through `Store#put` and `Store#regroup` rather
-than through the routes those two sit behind. It is not a second way to
-write a card — it is the same two writes a client's PUT and the groups
-dialog make:
+`Import::Write` writes one card, on the Save of that card's own screen,
+through `Store#put` and `Store#regroup` rather than through the routes
+those two sit behind. It is not a second way to write a card — it is
+the same two writes a client's PUT and the groups dialog make:
1. Mint an id, and give the card the UID that spells it. The source's
own UID names a record in a book this is not, and a contact with two
@@ -178,8 +221,8 @@ dialog make:
2. `Store#put` with `client: true`, so a card this creates joins
everyone's book the way a client's create does
(`2026-09-15-client-creates-join-everyone.md`).
-3. `Store#regroup` into whichever groups the walk settled on. Two
- questions, asked in the two places they belong. The review screen
+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
@@ -201,20 +244,29 @@ dialog make:
The filter is also how a group this book does not have yet is
asked for. Naming no group exactly, it offers itself as one to
- make, and what that writes is a name rather than a group: nothing
- is in the store until the confirm, and a group created while the
- walk was still going is one left behind by an import that was
- abandoned. A name waiting like that shows as a box of its own,
- ticked and in the italic the groups dialog gives a name that is not
- a group yet, so it can be taken off again — and filtered alongside
- the real groups, so a name this import is already making is not
- offered a second time as a name to make. What each contact is
- joining is read back on its row in the list, because the walk is a
- screen at a time and the list is where ten screens' worth of
- decisions are seen at once.
+ make, and what that rides as until the Save is a name rather than a
+ group: a group created while its card is still being looked over is
+ one left behind if that card is never saved. A name waiting like
+ that shows as a box of its own, ticked and in the italic the groups
+ dialog gives a name that is not a group yet, so it can be taken off
+ again — and filtered alongside the real groups, so a name this
+ contact is already making is not offered a second time as a name to
+ make. It is made by the Save, with the contact it was named for,
+ and found rather than made again by the next card that asks for it
+ (`Import::Write#group_id`, which is also how the group for the lot
+ carries across the walk).
+
+ Nothing is staged between screens, which is why a card opened
+ afresh has no box ticked: there is no earlier answer to read back,
+ the Save that would have written one being the Save that writes the
+ contact. A save sent back to be fixed — a blank name, a birthday no
+ card can spell — is the one case where there is something to carry,
+ and it comes back with its boxes as the form had them.
Importing is not idempotent and cannot be. A `.vcf` carries no id this
server minted, so a second upload of the same file is a second set of
-contacts. The review screen is what stands in for the `If-None-Match:
-*` the old PUT sent: it says how many contacts are about to come in, and
-under what group name, before any of them do.
+contacts. The walk is what stands in for the `If-None-Match: *` the old
+PUT sent: a row is looked at before it comes in, and once it has, both
+its row and its screen lead to the contact it became rather than back
+into the editor — so the second press of a Save, and the back button
+onto a screen already done, are the same nothing.
diff --git a/lib/pro_tacts/admin/import_groups.rb b/lib/pro_tacts/admin/import_groups.rb
index 4b794ac..21d5d27 100644
--- a/lib/pro_tacts/admin/import_groups.rb
+++ b/lib/pro_tacts/admin/import_groups.rb
@@ -15,7 +15,7 @@ module ProTacts
# is no second button to wonder about. That is the difference from
# a stored contact, whose groups are a dialog on the details page
# (Admin::GroupDialog) — there the card exists and a membership is
- # a write of its own, here neither is true until the confirm.
+ # 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
# has been told to make and has not made yet, then the filter
@@ -26,11 +26,12 @@ module ProTacts
# three hundred groups buries either picker, and this is the one
# the walk opens once per contact.
#
- # A named group is not created here: it is made at the confirm
- # (Import::Write#group_id), because a group created while the walk
- # is still going is a group left behind by an import that was
- # abandoned. So it rides as a name until then, and shows as a
- # ticked box like any other so that it can be unticked.
+ # A named group is not created here: it is made by the Save, with
+ # the contact it was named for (Import::Write#group_id), because a
+ # group created while a card is still being looked over is one
+ # left behind if that card is never saved. So it rides as a name
+ # until then, and shows as a ticked box like any other so that it
+ # can be unticked.
class ImportGroups < Phlex::HTML
# @rbs @groups: Array[Store::GroupChoice]
# @rbs @joined: Array[String]
@@ -40,14 +41,20 @@ module ProTacts
# Alphabetical, the groups dialog's own order: this is a list to
# find a name in, and nothing here stays put across a rename.
# `groups` is every group as a choice, no members read
- # (Store#group_choices); `joined` the ids ticked so far, `named`
- # the groups this import is making that do not exist yet.
+ # (Store#group_choices); `joined` the ids ticked, `named` the
+ # groups this card is making that do not exist yet.
#
- # Both of those 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.
+ # Those last two are empty whenever the card is opened, and hold
+ # what was ticked only when a save was sent back to be fixed
+ # (Web#card_screen): nothing is staged between screens, because
+ # 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:)
@groups = groups.sort_by { it.label.downcase }
diff --git a/lib/pro_tacts/admin/import_review.rb b/lib/pro_tacts/admin/import_review.rb
index 26cd583..09c9e92 100644
--- a/lib/pro_tacts/admin/import_review.rb
+++ b/lib/pro_tacts/admin/import_review.rb
@@ -6,8 +6,8 @@ require "pro_tacts/admin/list_item"
module ProTacts
module Admin
- # The contacts an uploaded .vcf holds, before any of them are in
- # the address book (docs/plans/2026-09-21-import-a-vcf.md).
+ # 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).
#
# A list rather than every card laid open at once: a book is
# hundreds of contacts and only some of them will have anything
@@ -16,6 +16,14 @@ module ProTacts
# (Admin::ImportOriginal beside the editor). A row with nothing
# left behind needs no visit.
#
+ # 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
@@ -25,22 +33,25 @@ module ProTacts
# going to copy across by hand.
class ImportReview < Phlex::HTML
# @rbs @upload: String
- # @rbs @rows: Array[[::ProTacts::Contact, Integer, Array[String]]]
+ # @rbs @rows: Array[[::ProTacts::Contact, Integer]]
# @rbs @unknown: Array[::ProTacts::Import::Vcf::Unknown]
# @rbs @group: String
+ # @rbs @saved: Hash[String, String]
# @rbs @notice: String?
- # `rows` is the contact each card will be written as, how many
- # of its original's lines are not coming with it, and the groups
- # the walk has put it in so far. `upload` is the staged import every
- # link and the confirm carry, and `group` the name of the group
- # this import offers to make for the lot of them.
- #: (upload: String, rows: Array[[::ProTacts::Contact, Integer, Array[String]]], unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String, ?notice: String?) -> void
- def initialize(upload:, rows:, unknown:, group:, notice: nil)
+ # `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)
@upload = upload
@rows = rows
@unknown = unknown
@group = group
+ @saved = saved
@notice = notice
end
@@ -64,22 +75,59 @@ module ProTacts
div(class: "card-body") do
h1(class: "type-h2", style: "margin: 0;") { count(@rows.length, "contact") }
losses
- form(action: "/import/#{@upload}/confirm", method: "post", class: "field-stack") do
- # A group made for this import and taking in every
- # contact, 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).
- 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", data: {variant: "primary"}) { "import #{count(@rows.length, "contact")}" }
+ group_field
+ 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).
+ #: () -> 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" }
+ end
+ end
+
+ # The way out before the end of the file. An import is a walk,
+ # and a walk can be enough: what has come in stays, and what
+ # nobody opened is left behind with the copy of the file this
+ # was holding.
+ #: () -> void
+ 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?
+ "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."
end
end
+ button(type: "submit", data: {variant: "primary"}) { "finish" }
end
end
@@ -114,9 +162,9 @@ module ProTacts
end
end
- # A row per contact, marked where something is being left
- # behind — which is the whole of what the list is for, since
- # those are the rows worth opening.
+ # 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
@@ -124,17 +172,17 @@ module ProTacts
h2(class: "type-label") { "contacts" }
end
ul(class: "card") do
- @rows.each_with_index do |(contact, dropped, joined), index|
+ @rows.each_with_index do |(contact, dropped), index|
+ stored = @saved[index.to_s]
render ListItem.new(
- href: "/import/#{@upload}/#{index}",
- trailing: (count(dropped, "line") + " left behind" if dropped.positive?),
+ # 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) }
- # The groups this contact is joining, read back under
- # its name: the walk is a screen at a time, and the
- # list is where you see what the last ten screens
- # settled on without opening them again.
- div(class: "type-label") { joined.join(", ") } if joined.any?
end
end
end
diff --git a/lib/pro_tacts/admin/import_upload.rb b/lib/pro_tacts/admin/import_upload.rb
index c588e24..b82633e 100644
--- a/lib/pro_tacts/admin/import_upload.rb
+++ b/lib/pro_tacts/admin/import_upload.rb
@@ -8,7 +8,8 @@ require "pro_tacts/admin/list_item"
module ProTacts
module Admin
# GET /import — the screen an address book arrives on, and the page
- # a confirm answers with (docs/plans/2026-09-21-import-a-vcf.md).
+ # a finished import answers with
+ # (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
# file that Mac already knows how to export.
@@ -17,8 +18,9 @@ module ProTacts
# in it needs the file open, so that is the next screen's
# (ImportReview, and the pair of cards a row there opens).
#
- # `imported` is the import that just happened, rendered below the
- # form that is still there to run another.
+ # `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.
class ImportUpload < Phlex::HTML
# @rbs @imported: Array[Contact]?
# @rbs @notice: String?
diff --git a/lib/pro_tacts/import/staged.rb b/lib/pro_tacts/import/staged.rb
index 260df88..f381e6d 100644
--- a/lib/pro_tacts/import/staged.rb
+++ b/lib/pro_tacts/import/staged.rb
@@ -17,11 +17,16 @@ module ProTacts
# a card that has been edited still has to show what it arrived
# as. REVISED is the cards as they will be written — pared to what
# this book reads when the import opens (Vcf.read), and rewritten
- # whole each time the editor saves one of them. GROUPS is which
- # groups each of those cards is joining, a decision the cards
- # themselves cannot carry: a vCard says nothing about this book's
- # groups, and a line invented to hold the answer would end up in
- # the contact.
+ # whole each time the editor saves one of them. SAVED is what the
+ # import has already done: the group it files its arrivals under,
+ # and which of its cards are contacts now.
+ #
+ # That last one is what makes the walk resumable rather than
+ # batched. A card is written the moment its Save is pressed
+ # (Import::Write), so the store is the record of it and this slot
+ # is only how the list knows which rows are done. Nothing here
+ # holds a decision back: the groups a contact joins are ticked and
+ # written by that same Save, so they are never staged at all.
#
# On disk rather than in the browser: a book with pictures in it is
# tens of megabytes, and a form carrying it back and forth is the
@@ -37,13 +42,14 @@ module ProTacts
ORIGINAL = "original" #: String
REVISED = "revised" #: String
- GROUPS = "groups" #: String
- SLOTS = [ORIGINAL, REVISED, GROUPS].freeze #: Array[String]
+ SAVED = "saved" #: String
+ SLOTS = [ORIGINAL, REVISED, SAVED].freeze #: Array[String]
- # The two halves of the groups slot: the ids of groups this
- # book has, and the names of groups it does not have yet.
- CHOSEN = "chosen" #: String
- NAMED = "named" #: String
+ # The two halves of the saved slot: the name of the group this
+ # import files its arrivals under, and the contact each saved
+ # card became, by the card's place in the file.
+ GROUP = "group" #: String
+ CONTACTS = "contacts" #: String
# How long an import is worth keeping. Long enough to work down
# a book's worth of contacts over an evening, and short enough
@@ -55,26 +61,30 @@ module ProTacts
# it carries. Minted, so the id a link or a form hands back is
# checked against the one shape this writes (#path) rather than
# trusted as a filename.
- #: (original: String, revised: String) -> String
- def self.open(original:, revised:)
+ #: (original: String, revised: String, group: String) -> String
+ def self.open(original:, revised:, group:)
sweep
id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
directory = root / id
FileUtils.mkdir_p(directory)
File.binwrite(directory / file_name(ORIGINAL), original)
File.binwrite(directory / file_name(REVISED), revised)
- # Empty rather than absent, so every slot of an import that
+ # 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
+ # 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).
- nothing = {} #: Hash[String, Array[String]]
- File.binwrite(directory / file_name(GROUPS), JSON.generate({CHOSEN => nothing, NAMED => nothing}))
+ none = {} #: Hash[String, String]
+ File.binwrite(directory / file_name(SAVED), JSON.generate({GROUP => group, CONTACTS => none}))
id
end
# One slot's bytes, or none — an import swept out from under a
- # screen left open overnight, or one already written, its second
- # confirm finding what the first removed. Ordinary enough for
- # the screen to say so and ask for the file again.
+ # screen left open overnight, or one already finished, a second
+ # press finding what the first removed. Ordinary enough for the
+ # screen to say so and ask for the file again.
#: (String id, String slot) -> String?
def self.read(id, slot)
file = path(id, slot)
@@ -88,55 +98,63 @@ module ProTacts
end
# The cards as they will be written, rewritten: the editor's
- # save, and the only write this takes after the import opens.
- # Whole rather than a card at a time, because the file is the
- # unit a re-read splits (Vcf.cards) and a card's index in it is
- # how a screen names one.
+ # save, and one of the two writes this takes after the import
+ # opens. Whole rather than a card at a time, because the file is
+ # the unit a re-read splits (Vcf.cards) and a card's index in it
+ # 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).
#: (String id, String revised) -> void
def self.update(id, revised)
write(id, REVISED, revised)
end
- # Which groups each contact joins, by its place in the file —
- # the name every screen of the walk calls a contact by, there
- # being no minted id until it is written. Rewritten whole like
- # the cards beside it, and for the same reason: one save is one
- # state of the whole import.
- #
- # Two maps rather than one, because a group this book has and a
- # group this import is about to invent are not the same answer.
- # `chosen` holds ids, the only way to name a group that has no
- # name of its own (db/migrations/005_group_identity.rb).
- # `named` holds names of groups that do not exist yet: made at
- # the confirm and not before, because a group created while the
- # walk is still going is a group left behind by an import that
- # was abandoned (Import::Write#group_id).
- #: (String id, Hash[String, Array[String]] chosen, Hash[String, Array[String]] named) -> void
- def self.update_groups(id, chosen, named)
- write(id, GROUPS, JSON.generate({CHOSEN => chosen, NAMED => named}))
- end
-
- # What #update_groups last wrote — the ids and then the names —
- # and two empty maps for an import that is gone. Read back into
- # the shape it was written in rather than trusted: this is a
- # file on disk, and a slot that will not parse is a broken
- # assumption JSON says so about.
- #: (String id) -> [Hash[String, Array[String]], Hash[String, Array[String]]]
- def self.groups(id)
- # #lists makes an empty map of anything that is not one, so
- # the gone import and the half a file are the same answer
- # here rather than two spellings of it.
- raw = read(id, GROUPS)
- return [lists(nil), lists(nil)] if raw.nil?
+ # What this import has done: the group it files under, and the
+ # contact each saved card became, by the card's place in the
+ # file — the name every screen of the walk calls a contact by
+ # until there is a minted id to call it by instead. Two empty
+ # answers for an import that is gone or a slot that will not
+ # parse: this is a file on disk, and one that will not read back
+ # in the shape it was written in is a broken assumption JSON
+ # says so about.
+ #: (String id) -> [String, Hash[String, String]]
+ def self.saved(id)
+ raw = read(id, SAVED)
+ return ["", ids(nil)] if raw.nil?
parsed = JSON.parse(raw)
- return [lists(nil), lists(nil)] unless parsed.is_a?(Hash)
+ return ["", ids(nil)] unless parsed.is_a?(Hash)
- [lists(parsed[CHOSEN]), lists(parsed[NAMED])]
+ [parsed[GROUP].to_s, ids(parsed[CONTACTS])]
end
- # The import, gone: the last step of a confirm, and what keeps
- # the ordinary case from waiting on the sweep.
+ # One card, written: its place in the file against the id the
+ # store minted for it. Read back and rewritten whole, the slot
+ # beside this one being written the same way.
+ #: (String id, String index, String contact) -> void
+ def self.record(id, index, contact)
+ group, contacts = saved(id)
+ 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
+ # takes only the file and the walk's own notes.
#: (String id) -> void
def self.close(id)
directory = import_root(id)
@@ -193,28 +211,24 @@ module ProTacts
File.binwrite(file.to_s, contents)
end
- # One map of a contact's place in the file to a list of
- # strings, out of whatever the file actually holds.
- #: (untyped raw) -> Hash[String, Array[String]]
- def self.lists(raw)
- lists = {} #: Hash[String, Array[String]]
- return lists unless raw.is_a?(Hash)
+ # One map of a contact's place in the file to the id it was
+ # stored under, out of whatever the file actually holds.
+ #: (untyped raw) -> Hash[String, String]
+ def self.ids(raw)
+ ids = {} #: Hash[String, String]
+ return ids unless raw.is_a?(Hash)
- raw.each do |index, values|
- next unless values.is_a?(Array)
-
- lists[index.to_s] = values.map(&:to_s)
- end
- lists
+ raw.each { |index, contact| ids[index.to_s] = contact.to_s }
+ ids
end
- # The cards are a .vcf, and the groups beside them are not.
+ # The cards are a .vcf, and the walk's notes beside them are not.
#: (String slot) -> String
def self.file_name(slot)
- slot == GROUPS ? "#{slot}.json" : "#{slot}.vcf"
+ slot == ORIGINAL || slot == REVISED ? "#{slot}.vcf" : "#{slot}.json"
end
- private_class_method :root, :import_root, :path, :file_name, :write, :lists
+ private_class_method :root, :import_root, :path, :file_name, :write, :ids
end
end
end
diff --git a/lib/pro_tacts/import/vcf.rb b/lib/pro_tacts/import/vcf.rb
index fa6eccd..a60c771 100644
--- a/lib/pro_tacts/import/vcf.rb
+++ b/lib/pro_tacts/import/vcf.rb
@@ -150,7 +150,7 @@ module ProTacts
# Whether a property is one this address book reads, asked of a
# name as a line spells it — the one question the survey, the
- # review screen and the confirm all ask.
+ # review screen and the write all ask.
#: (String name) -> bool
def self.known?(name)
KNOWN.include?(name.upcase)
diff --git a/lib/pro_tacts/import/write.rb b/lib/pro_tacts/import/write.rb
index c51390c..d8fbf55 100644
--- a/lib/pro_tacts/import/write.rb
+++ b/lib/pro_tacts/import/write.rb
@@ -3,7 +3,7 @@ require "pro_tacts/vcard"
module ProTacts
module Import
- # Writes the cards an import has settled on into this server's
+ # Writes one card an import has settled on into this server's
# store (docs/plans/2026-09-21-import-a-vcf.md).
#
# What arrives here is already what is coming in: the review screen
@@ -14,15 +14,19 @@ module ProTacts
# writes a client's PUT and the groups dialog make, Store#put and
# Store#regroup. It is not a second way to write a card.
#
+ # One card per call, because the walk is one card per Save: a
+ # contact is in the book the moment its own screen says so, and an
+ # import abandoned halfway keeps everything it had already written
+ # (Import::Staged). Nothing here knows about the rest of the file.
+ #
# Not idempotent, and cannot be: a .vcf carries no id this server
# minted, so a second upload of the same file is a second set of
- # cards. The review screen is what stands in for that — it says how
- # many contacts are about to be written before any of them are.
+ # cards. The walk is what stands in for that — a card already
+ # saved is a contact, and both its row and its screen lead there
+ # rather than back into the editor (Web#open_card).
class Write
# @rbs @store: Store
# @rbs @group: String?
- # @rbs @joins: Array[Array[String]]
- # @rbs @named: Array[Array[String]]
# @rbs @group_ids: Hash[String, String]
# The group an import offers to put its arrivals in, named for
@@ -35,57 +39,43 @@ module ProTacts
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,
- # the import being the one thing they all have in common.
- # `joins` is the rest, a card at a time: the ids of groups this
- # book already has, as the walk ticked them contact by contact,
- # in the cards' own order. `named` is the same list in names,
- # for the groups the walk asked for that do not exist yet —
- # made here and not while the walk was going, an abandoned
- # import being the case that argues for waiting. Any of them, or
- # none: a card in no group is still in everyone's book
- # (#contact).
- #: (Store store, Array[VCard] cards, ?group: String?, ?joins: Array[Array[String]], ?named: Array[Array[String]]) -> Array[Contact]
- def self.call(store, cards, group: nil, joins: [], named: [])
- new(store, group:, joins:, named:).call(cards)
+ # 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)
end
- #: (Store store, ?group: String?, ?joins: Array[Array[String]], ?named: Array[Array[String]]) -> void
- def initialize(store, group: nil, joins: [], named: [])
+ #: (Store store, ?group: String?) -> void
+ def initialize(store, group: nil)
@store = store
@group = group
- @joins = joins
- @named = named
@group_ids = {} #: Hash[String, String]
end
- #: (Array[VCard] cards) -> Array[Contact]
- def call(cards)
- cards.each_with_index.map { |card, index|
- contact(card, @joins.fetch(index, []), @named.fetch(index, []))
- }
- end
-
- private
-
- # One card, written as a contact: then its groups, the order a
+ # The card, written as a contact: then its groups, the order a
# create writes them in — the put makes the contact, and the
# memberships that follow put it somewhere to be found.
#: (VCard card, Array[String] chosen, Array[String] named) -> Contact
- def contact(card, chosen, named)
+ def call(card, chosen, named)
id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
# `client: true` for what a client's PUT means here: a card this
# creates joins `sync:*`, or it would be on the server and in
# nobody's book (docs/plans/2026-09-15-client-creates-join-everyone.md).
@store.put(id, identified(card, id), client: true)
- # Every name is resolved per card rather than up front, for
- # #group_id's reason; 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 twice is a constraint violation
- # rather than a second membership.
+ # 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
+ # 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
@@ -98,6 +88,8 @@ module ProTacts
@store.contact(id) || raise("#{id} was written and is not stored")
end
+ private
+
# The card under the id it is stored as. The source's UID goes
# and the minted id takes its place, the shape every other create
# here writes (Admin::CardForm.new_card) and the one rake
@@ -113,15 +105,18 @@ module ProTacts
# Looked up before it is made, so an import naming a group this
# server already has joins that one rather than colliding with
- # its name (db/migrations/008_group_names.rb).
+ # 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.
#
# 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 four hundred cards under one group name read the
- # list once. The choices read, not the whole groups read: the
- # match is on name, and a group's members are no part of it
+ # 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:
+ # the match is on name, and a group's members are no part of it
# (Store#group_choices).
#: (String name) -> String
def group_id(name)
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 180951a..32bb9b4 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -19,11 +19,13 @@ module ProTacts
# .vcf is what every address book on earth already exports, and
# the machine holding it is whichever one the browser is on.
#
- # Four screens, because an import is a walk rather than a
+ # Three screens, because an import is a walk rather than a
# submission: choose the file, look down the contacts it holds,
- # open any of them beside the card it arrived as, and confirm.
- # What the walk reads and writes waits on the server between them
- # (Import::Staged); nothing is in the store until the confirm.
+ # 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.
#
# Under the same identity gate as every other route (web.rb),
# which is also where the arrivals' books come from: a card this
@@ -44,8 +46,12 @@ 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 "confirm" do
- confirm_upload(r, upload)
+ r.post "group" do
+ name_import_group(r, upload)
+ end
+
+ r.post "done" do
+ finish_import(upload)
end
r.is do
@@ -54,15 +60,14 @@ module ProTacts
end
end
- # The contact's place in the file is its name here. There is
- # no minted id until it is written, and the file's own order is
- # the one thing about a card that cannot change under the
- # walk: the editor's save rewrites a card in place and never
- # adds or removes one.
+ # The contact's place in the file is its name here, until its
+ # own Save mints one. The file's own order is the one thing
+ # about a card that cannot change under the walk: the editor's
+ # save rewrites a card in place and never adds or removes one.
r.on Integer do |index|
r.is do
r.get do
- card_screen(upload, index)
+ open_card(r, upload, index)
end
r.post do
@@ -102,34 +107,31 @@ 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).
+ group: Import::Write.default_group,
)
# A 303, the other writes' answer, because what follows is a
# walk: every screen of it is a GET a back button can revisit.
r.redirect "/import/#{id}", 303
end
- # The contacts the file holds, each with what it is losing.
+ # The contacts the file holds: what each is losing, and whether it
+ # is in the book yet.
#: (String upload, ?notice: String?) -> String
def review_screen(upload, notice: nil)
staged = staged_cards(upload)
return expired_screen if staged.nil?
originals, revised = staged
- chosen, named = Import::Staged.groups(upload)
- # By name, because the row is read rather than submitted: a
- # group the walk ticked and someone then deleted is simply not
- # among these.
- names = store.group_choices.to_h { |group|
- # A pair is a two-element array until something says
- # otherwise, and an inline annotation needs its own line.
- [group.id, group.label] #: [String, String]
- }
+ 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
- joined = (chosen.fetch(index.to_s, []).filter_map { names[it] } + named.fetch(index.to_s, [])).sort
- [import_contact(card, index), Import::Vcf.losses(dropped).length, joined] #: [Contact, Integer, Array[String]]
+ [import_contact(card, index), Import::Vcf.losses(dropped).length] #: [Contact, Integer]
}
response["Content-Type"] = "text/html; charset=utf-8"
@@ -137,17 +139,39 @@ module ProTacts
upload:,
rows:,
unknown: Import::Vcf.unknown(originals),
- group: Import::Write.default_group,
+ group:,
+ saved:,
notice:,
)
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
+ # import that has already written this one has nothing left to say
+ # about it: from then on it is a contact, edited where every other
+ # contact is.
+ #: (untyped r, String upload, Integer index) -> untyped
+ def open_card(r, upload, index)
+ _group, saved = Import::Staged.saved(upload)
+ contact = saved[index.to_s]
+ return r.redirect "/contacts/#{contact}" if contact
+
+ card_screen(upload, index)
+ end
+
# One contact, the card it arrived as beside the card that is
# coming in. The right-hand half is the contact editor itself, not a
# copy of it: a field the two disagreed about would be a field an
# import writes and an edit cannot undo.
- #: (String upload, Integer index, ?notice: String?) -> String?
- def card_screen(upload, index, notice: nil)
+ #
+ # `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: [])
staged = staged_cards(upload)
return expired_screen if staged.nil?
@@ -166,13 +190,14 @@ module ProTacts
action: "/import/#{upload}/#{index}",
back: ["/import/#{upload}", "the import"],
aside: Admin::ImportOriginal.new(card: original, dropped: Import::Vcf.read(original).dropped),
- fields: import_groups(upload, index),
+ fields: Admin::ImportGroups.new(groups: store.group_choices, joined:, named:),
)
end
- # The editor's save, written back into the import rather than into
- # the store: #apply_edit's shape over a card that is not a contact
- # yet, down to the refusals, because it is the same form.
+ # The editor's save, which is this contact's import: #apply_edit's
+ # shape over a card that is not a contact yet, down to the
+ # refusals, because it is the same form — and then the write, the
+ # card and its groups going in together.
#: (untyped r, String upload, Integer index) -> untyped
def save_card(r, upload, index)
staged = staged_cards(upload)
@@ -182,8 +207,33 @@ module ProTacts
card = revised[index]
return nil if card.nil?
+ 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
+ # being no id in the file to recognise it by.
+ stored = saved[index.to_s]
+ return r.redirect "/contacts/#{stored}", 303 if stored
+
contact = import_contact(card, index)
+ # The groups ride in the same form and are written by the same
+ # 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).
+ #
+ # Read before the refusals rather than after, so a card sent
+ # back to be fixed comes back with its boxes as they were
+ # ticked: nothing is staged, and the form is the only record of
+ # them until the write.
+ ticked = ids_in(r.params["groups"]) & store.group_choices.map(&:id)
+ standing = ids_in(r.params["named"]).map(&:strip).reject(&:empty?)
+ fresh = r.params["new"].to_s.strip
+ wanted = (fresh.empty? ? standing : standing + [fresh]).uniq
+
# N and FN are mandatory (RFC 2426 section 4), so a save blank
# throughout is refused — the toast is the backstop, the form's
# name pair (Admin::NamePair) being the browser's own refusal of
@@ -191,7 +241,9 @@ module ProTacts
first = r.params["first"].to_s.strip
middle = r.params["middle"].to_s.strip
last = r.params["last"].to_s.strip
- return card_screen(upload, index, notice: "A contact needs a name.") if first.empty? && last.empty?
+ if first.empty? && last.empty?
+ return card_screen(upload, index, notice: "A contact needs a name.", joined: ticked, named: wanted)
+ end
fields = r.params["birthday"]
birthday =
@@ -199,7 +251,8 @@ module ProTacts
begin
Admin::CardForm.birthday(fields)
rescue ArgumentError
- return card_screen(upload, index, notice: "That birthday is not a shape a date can take.")
+ return card_screen(upload, index, notice: "That birthday is not a shape a date can take.",
+ joined: ticked, named: wanted)
end
else
contact.birthday
@@ -210,7 +263,8 @@ module ProTacts
# import are the case, and applying this save over the other
# one's would revert it.
if r.params["etag"].to_s != contact.etag
- return card_screen(upload, index, notice: "This card changed since the page loaded; nothing was saved.")
+ return card_screen(upload, index, notice: "This card changed since the page loaded; nothing was saved.",
+ joined: ticked, named: wanted)
end
edited = Admin::CardForm.contact_card(contact, first, middle, last, r.params)
@@ -229,73 +283,81 @@ module ProTacts
# what it is, and writing it without the date would lose the
# date the moment it was typed. Say so instead.
if birthday && line.nil?
- return card_screen(upload, index, notice: "A card cannot hold a birthday that partial. Import the contact, then add the date on its own page.")
+ return card_screen(upload, index,
+ notice: "A card cannot hold a birthday that partial. Import the contact, then add the date on its own page.",
+ joined: ticked, named: wanted)
end
edited = edited.replace("BDAY", line ? [line] : [])
end
+ # Staged before it is stored, and kept afterwards: the list
+ # renders every row from these bytes, so a row that has come in
+ # is still a row the list has to be able to name.
revised[index] = edited
Import::Staged.update(upload, joined(revised))
- # The groups ride in the same form and save with it
- # (Admin::ImportGroups): a card the walk has looked at has been
- # 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, not "leave it as it was". A name
- # typed into the new-group box joins the names already standing
- # for this contact, and none of them is made until the confirm.
- chosen, named = Import::Staged.groups(upload)
- ticked = ids_in(r.params["groups"]) & store.group_choices.map(&:id)
- standing = ids_in(r.params["named"]).map(&:strip).reject(&:empty?)
- fresh = r.params["new"].to_s.strip
- Import::Staged.update_groups(
- upload,
- chosen.merge(index.to_s => ticked),
- named.merge(index.to_s => (fresh.empty? ? standing : standing + [fresh]).uniq),
- )
+ written = Import::Write.call(store, edited, group: group.empty? ? nil : group, 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
+ # import ends here rather than on a list of rows that all say
+ # the same thing and a button under them.
+ return finish_import(upload) if saved.length + 1 == revised.length
+
r.redirect "/import/#{upload}", 303
end
- # The confirm: the cards as the walk left them, written into the
- # store.
- #: (untyped r, String upload) -> String
- def confirm_upload(r, upload)
- staged = staged_cards(upload)
- return expired_screen if staged.nil?
+ # 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
- _originals, revised = staged
- group = r.params["group"].to_s.strip
- # Filtered again here, and not only where the walk wrote them
- # (#save_card): a group can be deleted between the tick and the
- # confirm, and Store#add_member reads a group with `sole`, so a
- # stale id would be a 500 rather than the nothing it means.
- known = store.group_choices.map(&:id)
- chosen, named = Import::Staged.groups(upload)
- joins = revised.each_index.map { chosen.fetch(it.to_s, []) & known }
- makes = revised.each_index.map { named.fetch(it.to_s, []) }
- imported = Import::Write.call(store, revised, group: group.empty? ? nil : group, joins:, named: makes)
+ 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
+ # left behind — the file they came from is the importer's own, and
+ # this copy of it goes with the button.
+ #
+ # 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 finish, and there is no other page holding
+ # what just arrived.
+ #: (String upload) -> String
+ def finish_import(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)) }
Import::Staged.close(upload)
import_screen(imported:)
end
- # The groups half of one contact's screen: what this book has,
- # what the walk has ticked, and what it has said to make. Read
- # here rather than in the view, which is handed the answer like
- # every other view (Admin::ImportGroups).
- #: (String upload, Integer index) -> Admin::ImportGroups
- def import_groups(upload, index)
- chosen, named = Import::Staged.groups(upload)
- Admin::ImportGroups.new(
- groups: store.group_choices,
- joined: chosen.fetch(index.to_s, []),
- named: named.fetch(index.to_s, []),
- )
- end
-
# The import's two readings, or none when it is gone — swept out
- # from under a screen left open overnight, or written already, a
- # second confirm finding what the first removed. Ordinary enough
+ # from under a screen left open overnight, or finished already, a
+ # second press finding what the first removed. Ordinary enough
# for the screen to say so and ask for the file again.
#
# The re-read parses files this already parsed, which is not a
@@ -355,11 +417,8 @@ module ProTacts
param if param.is_a?(Hash) && param[:tempfile]
end
- # The screen a file is chosen on, and the page a confirm answers
- # with. The confirm answers with the result rather than a 303, and
- # has to: the staged import is gone, so the re-submission a back
- # button offers has nothing to import, and there is no other page
- # holding what just arrived.
+ # 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)
response["Content-Type"] = "text/html; charset=utf-8"
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index b4660ba..f57f4e0 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -46,7 +46,7 @@ module ProTacts
end
class ImportReview
- def self.call: (upload: String, rows: Array[[Contact, Integer, Array[String]]], unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String, ?notice: String?) -> String
+ def self.call: (upload: String, rows: Array[[Contact, Integer]], unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String, saved: Hash[String, String], ?notice: String?) -> String
end
# Rendered into ContactsEdit rather than called, so these are the
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 1570859..96eb458 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -62,6 +62,16 @@ class AdminImportPagesTest < Minitest::Test
# quotes themselves.
def etag = last_response.body[/name="etag" value="([^"]+)"/, 1].to_s.gsub(""", '"')
+ # 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.
+ 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)
+ end
+
def test_the_screen_asks_for_a_vcf
get "/import"
@@ -82,6 +92,7 @@ class AdminImportPagesTest < Minitest::Test
assert_includes last_response.body, "Jane Booles"
assert_includes last_response.body, "Sam Booles"
assert_includes last_response.body, %(href="/import/#{id}/0")
+ assert_includes last_response.body, "Nothing has come in yet."
assert_empty store.changes
end
end
@@ -147,7 +158,7 @@ class AdminImportPagesTest < Minitest::Test
assert_includes last_response.body,
%(<li data-dropped><span>PRODID:-//Apple Inc.//macOS 15.0//EN</span>)
- post "/import/#{id}/confirm", "group" => ""
+ save(id, 0, first: "Sam", last: "Booles")
refute_includes store.contacts.fetch(0).vcard.to_s, "PRODID"
end
@@ -186,30 +197,125 @@ class AdminImportPagesTest < Minitest::Test
end
end
+ # The Save on a row's own screen is the import of that contact:
+ # nothing waits for the end of the file.
+ def test_saving_a_row_puts_that_one_contact_in_the_book
+ with_contacts({}) do |store|
+ id = upload(JANE + PLAIN)
+
+ save(id, 0, first: "Jane", last: "Booles")
+
+ assert_equal 303, last_response.status
+ assert_equal 1, store.contacts.length
+
+ jane = store.contacts.fetch(0)
+
+ assert_equal "Jane Booles", jane.name
+ # And the list it comes back to says so: the row leads to the
+ # contact now, and the one nobody has opened still leads into
+ # the walk.
+ 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, %(href="/import/#{id}/1")
+ assert_includes last_response.body, "1 contact in the book."
+ end
+ end
+
# The point of the pair: read what is being left behind on the
# left, and put what matters into the card on the right.
- def test_an_edit_is_held_against_the_contact_until_the_import_is_confirmed
+ def test_an_edit_on_the_pair_comes_in_with_the_contact
with_contacts({}) do |store|
id = upload(JANE)
+
+ save(id, 0, first: "Jane", last: "Booles", "note" => "Spouse: Sam Booles")
+
+ assert_includes store.contacts.fetch(0).vcard.to_s, "NOTE:Spouse: Sam Booles"
+ end
+ end
+
+ # A row already in the book is a contact, edited where every other
+ # contact is: the import's editor writes, and writing it a second
+ # time would be a second contact rather than an edit of the first.
+ def test_a_row_already_in_the_book_leads_to_the_contact
+ with_contacts({}) do |store|
+ id = upload(JANE + PLAIN)
+ save(id, 0, first: "Jane", last: "Booles")
+ jane = store.contacts.fetch(0)
+
get "/import/#{id}/0"
- post "/import/#{id}/0",
- "etag" => etag, "first" => "Jane", "middle" => "", "last" => "Booles",
- "nickname" => "", "note" => "Spouse: Sam Booles"
+ assert_equal 302, last_response.status
+ assert_equal "/contacts/#{jane.id}", last_response.headers["location"]
+ end
+ end
- assert_equal 303, last_response.status
- assert_empty store.changes
+ def test_a_save_pressed_twice_writes_one_contact
+ with_contacts({}) do |store|
+ id = upload(JANE + PLAIN)
+ get "/import/#{id}/0"
+ sent = etag
- post "/import/#{id}/confirm", "group" => ""
+ 2.times do
+ post "/import/#{id}/0",
+ "etag" => sent, "first" => "Jane", "middle" => "", "last" => "Booles",
+ "nickname" => "", "note" => ""
+ end
- assert_includes store.contacts.fetch(0).vcard.to_s, "NOTE:Spouse: Sam Booles"
+ assert_equal 1, store.contacts.length
+ assert_equal "/contacts/#{store.contacts.fetch(0).id}", last_response.headers["location"]
+ end
+ 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.
+ 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
+
+ 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"
+ assert_includes last_response.body, %(href="/contacts/#{jane.id}")
+
+ # And the walk is gone with it.
+ get "/import/#{id}"
+
+ assert_includes last_response.body, "That import is no longer here."
+ end
+ end
+
+ # A walk can be enough: what came in stays, and the rows nobody
+ # opened are left behind with this copy of the file.
+ def test_finishing_early_leaves_the_rest_behind
+ with_contacts({}) do |store|
+ id = upload(JANE + PLAIN)
+ save(id, 0, first: "Jane", last: "Booles")
+ follow_redirect!
+
+ assert_includes last_response.body, "Finishing now leaves the other 1 behind."
+
+ post "/import/#{id}/done"
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, "imported (1)"
+ assert_equal 1, store.contacts.length
end
end
# The editor's own guard, over the staged card: two tabs on one
# import, and the second save would revert the first.
def test_a_save_against_a_stale_card_is_refused
- with_contacts({}) do |_store|
+ with_contacts({}) do |store|
id = upload(JANE)
get "/import/#{id}/0"
@@ -219,25 +325,19 @@ class AdminImportPagesTest < Minitest::Test
assert_includes last_response.body, "This card changed since the page loaded"
refute_includes last_response.body, "lost"
+ assert_empty store.contacts
end
end
# The birthday is the one field the editor holds in the model
# rather than in the card, and a staged contact has no model: the
- # save writes it back as the BDAY line it will come in as.
+ # save writes it back as the BDAY line it comes in as.
def test_a_birthday_typed_on_the_import_comes_in_with_the_contact
with_contacts({}) do |store|
id = upload(JANE)
- get "/import/#{id}/0"
-
- post "/import/#{id}/0",
- "etag" => etag, "first" => "Jane", "middle" => "", "last" => "Booles",
- "nickname" => "", "note" => "",
- "birthday" => { "year" => "1985", "month" => "4", "day" => "12" }
- assert_equal 303, last_response.status
-
- post "/import/#{id}/confirm", "group" => ""
+ save(id, 0, first: "Jane", last: "Booles",
+ "birthday" => {"year" => "1985", "month" => "4", "day" => "12"})
assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), store.contacts.fetch(0).birthday
end
@@ -247,23 +347,21 @@ class AdminImportPagesTest < Minitest::Test
# the model that holds a partial birthday does not exist until the
# contact is stored (docs/plans/2026-08-31-partial-birthdays.md).
def test_a_birthday_no_card_can_spell_is_refused_until_the_contact_is_stored
- with_contacts({}) do |_store|
+ with_contacts({}) do |store|
id = upload(JANE)
- get "/import/#{id}/0"
- post "/import/#{id}/0",
- "etag" => etag, "first" => "Jane", "middle" => "", "last" => "Booles",
- "nickname" => "", "note" => "",
- "birthday" => { "year" => "1985", "month" => "", "day" => "" }
+ save(id, 0, first: "Jane", last: "Booles",
+ "birthday" => {"year" => "1985", "month" => "", "day" => ""})
assert_equal 200, last_response.status
assert_includes last_response.body, "A card cannot hold a birthday that partial."
+ assert_empty store.contacts
end
end
# The whole file's group is named on the review screen; which of
# this book's own groups a contact joins is asked beside that
- # contact's card, and saved by the same Save as its fields.
+ # contact's card, and written by the same Save as its fields.
def test_a_contact_is_put_in_its_groups_beside_its_own_card
with_contacts({}) do |store|
school = store.create_group(name: "school")
@@ -273,23 +371,49 @@ class AdminImportPagesTest < Minitest::Test
assert_includes last_response.body, %(<input type="checkbox" name="groups[]" value="#{school}">school)
- post "/import/#{id}/0",
- "etag" => etag, "first" => "Jane", "middle" => "", "last" => "Booles",
- "nickname" => "", "note" => "", "groups" => [school]
+ save(id, 0, first: "Jane", last: "Booles", "groups" => [school])
assert_equal 303, last_response.status
- assert_empty store.changes
+ assert_equal [store.contacts.fetch(0).id], store.group(school).members
+ end
+ end
- # Read back on the row, so the walk can be seen without opening
- # every contact again, and in the box when the card is reopened.
- follow_redirect!
+ # A group that does not exist yet is named beside the contact and
+ # made with it: a group created before its card is saved is one
+ # left behind if that card never is.
+ def test_a_group_named_beside_a_card_is_made_with_it
+ with_contacts({}) do |store|
+ id = upload(JANE)
+ post "/import/#{id}/group", "group" => ""
- assert_includes last_response.body, %(<div class="type-label">school</div>)
+ save(id, 0, first: "Jane", last: "Booles", "new" => " Clarks ")
+
+ clarks = store.all_groups.find { it.name == "Clarks" }
+
+ assert_equal [store.contacts.fetch(0).id], clarks.members
+ 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.
+ def test_a_refused_save_keeps_the_groups_it_was_sent_with
+ with_contacts({}) do |store|
+ school = store.create_group(name: "school")
+ id = upload(JANE)
get "/import/#{id}/0"
+ post "/import/#{id}/0",
+ "etag" => "not the etag", "first" => "Jane", "middle" => "", "last" => "Booles",
+ "nickname" => "", "note" => "", "groups" => [school], "new" => "Clarks"
+
assert_includes last_response.body,
%(<input type="checkbox" name="groups[]" value="#{school}" checked>school)
+ assert_includes last_response.body,
+ %(<input type="checkbox" name="named[]" value="Clarks" checked>)
+ assert_includes last_response.body, %(data-label="clarks")
+ # And nothing was made for a card that is not in the book.
+ refute_includes store.all_groups.map(&:name), "Clarks"
end
end
@@ -331,13 +455,12 @@ class AdminImportPagesTest < Minitest::Test
assert_equal 1, 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.
post "/import/#{id}/0",
- "etag" => etag, "first" => "Jane", "middle" => "", "last" => "Booles",
+ "etag" => "not the etag", "first" => "Jane", "middle" => "", "last" => "Booles",
"nickname" => "", "note" => "", "groups" => [zulus]
- # Ticked, so Zulus is spared and Group h goes under in its place.
- get "/import/#{id}/0"
-
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)">)
assert_equal 1, last_response.body.scan("data-capped").length
@@ -356,54 +479,18 @@ class AdminImportPagesTest < Minitest::Test
end
end
- # A group that does not exist yet is named beside the contact and
- # made at the confirm, not before: a group created while the walk
- # is still going is one left behind by an import that was
- # abandoned.
- def test_a_group_named_during_the_walk_waits_for_the_confirm
- with_contacts({}) do |store|
- id = upload(JANE)
- get "/import/#{id}/0"
-
- post "/import/#{id}/0",
- "etag" => etag, "first" => "Jane", "middle" => "", "last" => "Booles",
- "nickname" => "", "note" => "", "new" => " Clarks "
-
- assert_equal 303, last_response.status
- assert_empty store.all_groups
-
- # Read back as a box of its own, ticked, so it can be taken off
- # again before the confirm — and filtered like the rest, so a
- # name this import is already making is not offered again as a
- # name to make.
- get "/import/#{id}/0"
-
- assert_includes last_response.body,
- %(<input type="checkbox" name="named[]" value="Clarks" checked>)
- assert_includes last_response.body, %(data-label="clarks")
-
- post "/import/#{id}/confirm", "group" => ""
- clarks = store.all_groups.find { it.name == "Clarks" }
-
- assert_equal [store.contacts.fetch(0).id], clarks.members
- end
- end
-
# Contact by contact, so the cards do not all come in alike. The id
# that names no group is dropped rather than carried into
# Store#add_member, which reads a group with `sole` and would
# answer bad input with a 500.
- def test_confirming_joins_each_contact_to_its_own_groups
+ def test_each_contact_comes_in_with_its_own_groups
with_contacts({}) do |store|
school = store.create_group(name: "school")
id = upload(JANE + PLAIN)
+ post "/import/#{id}/group", "group" => "import-20260921T031655Z"
- get "/import/#{id}/0"
- post "/import/#{id}/0",
- "etag" => etag, "first" => "Jane", "middle" => "", "last" => "Booles",
- "nickname" => "", "note" => "", "groups" => [school, "zzzz"]
-
- post "/import/#{id}/confirm", "group" => "import-20260921T031655Z"
+ save(id, 0, first: "Jane", last: "Booles", "groups" => [school, "zzzz"])
+ save(id, 1, first: "Sam", last: "Booles")
assert_equal 200, last_response.status
jane = store.contacts.find { it.name == "Jane Booles" }
@@ -417,54 +504,87 @@ class AdminImportPagesTest < Minitest::Test
end
end
- # A group deleted between the tick and the confirm is nothing, not
- # a 500: the ids are filtered again where they are used.
- def test_a_group_that_went_away_before_the_confirm_is_dropped
+ # A group deleted between the tick and the Save is nothing, not a
+ # 500: the ids are filtered again where they are used.
+ def test_a_group_that_went_away_before_the_save_is_dropped
with_contacts({}) do |store|
school = store.create_group(name: "school")
id = upload(JANE)
-
+ post "/import/#{id}/group", "group" => ""
get "/import/#{id}/0"
- post "/import/#{id}/0",
- "etag" => etag, "first" => "Jane", "middle" => "", "last" => "Booles",
- "nickname" => "", "note" => "", "groups" => [school]
+ sent = etag
store.delete_group(school)
- post "/import/#{id}/confirm", "group" => ""
+ post "/import/#{id}/0",
+ "etag" => sent, "first" => "Jane", "middle" => "", "last" => "Booles",
+ "nickname" => "", "note" => "", "groups" => [school]
assert_equal 200, last_response.status
assert_equal [ProTacts::Store::EVERYONE], store.all_groups.map(&:name)
end
end
- def test_confirming_writes_the_contacts_as_they_stand
+ # 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}/confirm", "group" => "import-20260921T031655Z"
+ post "/import/#{id}/group", "group" => "somewhere else"
assert_equal 200, last_response.status
- assert_includes last_response.body, "imported (2)"
- assert_equal 2, store.contacts.length
- assert_includes store.all_groups.map(&:name), "import-20260921T031655Z"
+ assert_includes last_response.body, "Contacts from this import are in the Booles already"
+ assert_includes last_response.body, "Coming in as the Booles."
- jane = store.contacts.find { it.name == "Jane Booles" }
+ save(id, 1, first: "Sam", last: "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"
- assert_includes last_response.body, %(href="/contacts/#{jane.id}")
+ 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 confirm submitted
+ # 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_a_confirm_with_no_staged_import_writes_nothing
+ def test_finishing_an_import_that_is_gone_writes_nothing
with_contacts({}) do |store|
id = upload(JANE)
+ save(id, 0, first: "Jane", last: "Booles")
- post "/import/#{id}/confirm", "group" => ""
- post "/import/#{id}/confirm", "group" => ""
+ post "/import/#{id}/done"
assert_includes last_response.body, "That import is no longer here."
assert_equal 1, store.contacts.length
@@ -477,7 +597,7 @@ class AdminImportPagesTest < Minitest::Test
with_contacts({}) do |store|
refute ProTacts::ChangeId.minted?("notminted")
- post "/import/notminted/confirm", "group" => ""
+ post "/import/notminted/done"
assert_includes last_response.body, "That import is no longer here."
assert_empty store.changes
diff --git a/test/pro_tacts/import/test_write.rb b/test/pro_tacts/import/test_write.rb
index 211900a..d1e8bf3 100644
--- a/test/pro_tacts/import/test_write.rb
+++ b/test/pro_tacts/import/test_write.rb
@@ -3,7 +3,7 @@ require_relative "../../test_helper"
require "pro_tacts/import/write"
require "pro_tacts/import/vcf"
-# Writing the cards an import has settled on
+# Writing a card an import has settled on
# (docs/plans/2026-09-21-import-a-vcf.md).
class ImportWriteTest < Minitest::Test
include ThrowawayContacts
@@ -21,42 +21,47 @@ class ImportWriteTest < Minitest::Test
END:VCARD
CARD
- def write_cards(cards: Vcf.cards(JANE), group: nil)
+ # One card, which is what one Save hands over.
+ def card = Vcf.cards(JANE).fetch(0)
+
+ def write_card(group: nil, joins: [], named: [])
with_contacts({}) do |store|
- yield Write.call(store, cards, group:), store
+ yield Write.call(store, card, group:, joins:, named:), store
end
end
# What arrives here is already what is coming in, so nothing is
# pared, rewritten or rebuilt but the card's identity.
def test_the_card_comes_in_as_it_was_handed_over
- write_cards do |imported, _store|
- card = imported.fetch(0).vcard.to_s
+ write_card do |imported, _store|
+ written = imported.vcard.to_s
- assert_includes card, "N:Booles;Jane;;;\r\n"
- assert_includes card, "FN:Jane Booles\r\n"
- assert_includes card, "NOTE:Met at work\r\n"
+ assert_includes written, "N:Booles;Jane;;;\r\n"
+ assert_includes written, "FN:Jane Booles\r\n"
+ assert_includes written, "NOTE:Met at work\r\n"
end
end
# A minted id, and the UID spelling it: the source's own UID named a
# record in a book this is not (rake contacts:reid's whole subject).
def test_the_card_is_stored_under_a_minted_id_its_uid_spells
- write_cards do |imported, store|
- id = imported.fetch(0).id
+ write_card do |imported, store|
+ id = imported.id
assert ProTacts::ChangeId.minted?(id)
- refute_includes imported.fetch(0).vcard.to_s, "ABC-123"
- assert_includes imported.fetch(0).vcard.to_s, "UID:#{id}\r\n"
+ refute_includes imported.vcard.to_s, "ABC-123"
+ assert_includes imported.vcard.to_s, "UID:#{id}\r\n"
assert_equal id, store.card_id_with_uid(id)
end
end
# A card this creates joins everyone's book the way a client's
- # create does, and the import's own group besides.
+ # 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.
def test_arrivals_join_the_import_group_and_everyones_book
- write_cards(cards: Vcf.cards(JANE + JANE), group: "import-20260921T031655Z") do |imported, store|
- ids = imported.map(&:id)
+ with_contacts({}) do |store|
+ ids = 2.times.map { Write.call(store, card, group: "import-20260921T031655Z").id }
assert_equal 2, ids.uniq.length
groups = store.all_groups.select { it.members.sort == ids.sort }
@@ -74,22 +79,20 @@ class ImportWriteTest < Minitest::Test
with_contacts({}) do |store|
existing = store.create_group(name: "import-20260921T031655Z")
- imported = Write.call(store, Vcf.cards(JANE + JANE), group: "import-20260921T031655Z")
+ ids = 2.times.map { Write.call(store, card, group: "import-20260921T031655Z").id }
- assert_equal imported.map(&:id).sort, store.group(existing).members.sort
+ assert_equal ids.sort, store.group(existing).members.sort
assert_equal 1, store.all_groups.count { it.name == "import-20260921T031655Z" }
end
end
- # The groups the walk ticked, joined alongside the name the review
- # screen typed: the import's own group takes in everything, and the
- # rest is a contact at a time.
+ # The groups the walk ticked beside this card, joined alongside the
+ # group the import files everything under.
def test_an_arrival_joins_the_groups_chosen_for_it
with_contacts({}) do |store|
school = store.create_group(name: "school")
- imported = Write.call(store, Vcf.cards(JANE), group: "import-20260921T031655Z", joins: [[school]])
- id = imported.fetch(0).id
+ id = Write.call(store, card, group: "import-20260921T031655Z", joins: [school]).id
assert_equal [id], store.group(school).members
assert_equal ["import-20260921T031655Z", "school", ProTacts::Store::EVERYONE],
@@ -97,15 +100,16 @@ class ImportWriteTest < Minitest::Test
end
end
- # A contact at a time means the cards do not all come in alike: the
- # choices are read by the card's own place in the file.
+ # A card at a time means the cards do not all come in alike: each
+ # call carries the answer its own screen gave.
def test_each_arrival_joins_its_own_groups
with_contacts({}) do |store|
school = store.create_group(name: "school")
work = store.create_group(name: "work")
- imported = Write.call(store, Vcf.cards(JANE + JANE + JANE), joins: [[school], [], [work, school]])
- first, second, third = imported.map(&:id)
+ first = Write.call(store, card, joins: [school]).id
+ second = Write.call(store, card).id
+ third = Write.call(store, card, joins: [work, school]).id
assert_equal [first, third].sort, store.group(school).members.sort
assert_equal [third], store.group(work).members
@@ -114,15 +118,16 @@ class ImportWriteTest < Minitest::Test
end
end
- # A group the walk asked for by name is made here and not before:
- # a group created while the walk was still going is one left behind
- # by an import that was abandoned.
- def test_a_group_named_during_the_walk_is_made_at_the_confirm
+ # A group the walk asked for by name is made with the contact it was
+ # named for, and found by the next card that asks for it: a group
+ # created before the card is saved is one left behind if that card
+ # never is.
+ def test_a_group_named_during_the_walk_is_made_with_its_contact
with_contacts({}) do |store|
- imported = Write.call(store, Vcf.cards(JANE + JANE), named: [["Clarks"], ["Clarks"]])
+ ids = 2.times.map { Write.call(store, card, named: ["Clarks"]).id }
clarks = store.all_groups.find { it.name == "Clarks" }
- assert_equal imported.map(&:id).sort, clarks.members.sort
+ assert_equal ids.sort, clarks.members.sort
assert_equal 1, store.all_groups.count { it.name == "Clarks" }
end
end
@@ -131,31 +136,30 @@ class ImportWriteTest < Minitest::Test
# a constraint violation rather than a second membership.
def test_a_name_that_is_the_imports_own_group_joins_it_once
with_contacts({}) do |store|
- imported = Write.call(store, Vcf.cards(JANE), group: "Clarks", named: [["Clarks"]])
- id = imported.fetch(0).id
+ id = Write.call(store, card, group: "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
end
end
- # Either, or neither: a chosen group stands on its own when no name
- # is typed.
+ # Either, or neither: a chosen group stands on its own when the
+ # import files under no group at all.
def test_arrivals_join_a_chosen_group_with_no_group_named
with_contacts({}) do |store|
school = store.create_group(name: "school")
- imported = Write.call(store, Vcf.cards(JANE), joins: [[school]])
+ id = Write.call(store, card, joins: [school]).id
- assert_equal imported.map(&:id), store.group(school).members
+ assert_equal [id], store.group(school).members
assert_equal %w[school], store.all_groups.map(&:name).reject { it == ProTacts::Store::EVERYONE }
end
end
- def test_no_group_name_puts_the_cards_in_everyones_book_alone
- write_cards do |imported, store|
+ def test_no_group_name_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.map(&:id), store.all_groups.fetch(0).members
+ assert_equal [imported.id], store.all_groups.fetch(0).members
end
end