import: land a plan by uploading its cards
The middle step of an import was `rake import:execute`, which ran on
the machine holding the plan and wrote to a host over HTTP. It is now
a screen: the plan's card files are uploaded at /import and land
through the store, which drops the host in the config being named
twice, the checkout on the Mac, and the identity coming from a proxy
rather than from the person doing it.
Import::Land writes what Execute wrote — Store#put for a card the
store does not have, then Store#regroup into the groups the file
names — so it is the same two writes the PUT and the groups dialog
make rather than a third way to write a card. A card already here is
left as it is and reported as such, which is what If-None-Match
bought, so a second upload writes nothing.
A plan can no longer record `landed` or `imported`, there being no
plan on the server's side, so a contact is outstanding until it is
done and finalize asks the host about each one — the check that stood
behind the status all along. The plan format is unchanged and nothing
on disk is rewritten.
docs/plans/2026-09-20-import-by-upload.md has the whole of it.
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 09eff06..73d6c1e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -33,16 +33,13 @@ rake console # irb with the store open on the configured database
rake import:macos:plan # Plan importing this Mac's iCloud contacts
# (LIMIT=n; reads Contacts, so leave it to the user)
rake import:macos:finalize # Finalize the contacts the oldest
- # landed plan imported, taking them off this
- # Mac and filing the plan away done
- # (PLAN=dir for another; writes Contacts, so
- # leave it to the user)
-rake import:execute # Land the oldest plan still to land on a host
- # (host in data/import/config.yml; PLAN=dir for
- # another)
+ # unfinished plan landed, taking them off this
+ # Mac and filing the plan away done (host in
+ # data/import/config.yml; PLAN=dir for another;
+ # writes Contacts, so leave it to the user)
rake import:status # Summarize the plans in data/import — in
- # flight and filed away done — and what execute
- # and finalize would carry next
+ # flight and filed away done — and what would
+ # carry the next one further
rake profile:install # Download carddav.mobileconfig from the app and
# stage it for approval
```
@@ -50,6 +47,11 @@ rake profile:install # Download carddav.mobileconfig from the app and
`rake profile:*` needs `PRO_TACTS_HOSTNAME` and touches installed system
profiles, so leave those to the user.
+An import's middle step is not a task: a plan's cards are uploaded at
+`/import` in the app, which lands them through the store
+(docs/plans/2026-09-20-import-by-upload.md). `rake import:execute` was
+that step and is gone.
+
## Task management
The backlog lives in `ranger`, not GitHub Issues. `RANGER_DEFAULT_BACKLOG`
@@ -68,8 +70,9 @@ 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 plan's cards, uploaded
├── web/api.rb # JSON for scripts: the group list
-├── import/ # Import plans, and landing them on a host
+├── import/ # Import plans, and landing one in the store
├── admin/ # The Phlex views those screens render, plus
│ # card_form.rb, which reads a form back into a card
├── store.rb # Sequel over SQLite: cards, change log, derived index
diff --git a/Steepfile b/Steepfile
index e6911e1..2c434f5 100644
--- a/Steepfile
+++ b/Steepfile
@@ -22,7 +22,8 @@ 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_label groups_edit groups_index groups_show icons layout list_item phlex upcoming_birthdays
+ group_dialog group_label groups_edit groups_index groups_show icons layout list_item phlex
+ plan_upload upcoming_birthdays
].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
signature "sig"
diff --git a/config.ru b/config.ru
index 5d7615c..965c93d 100644
--- a/config.ru
+++ b/config.ru
@@ -3,6 +3,7 @@
# require time, so requiring the app has no side effects.
require "pathname"
require "fileutils"
+require "rack"
require "sentry-ruby"
$LOAD_PATH.unshift(Pathname.new(__dir__) / "lib")
@@ -50,6 +51,17 @@ Sentry.init do |sentry|
sentry.traces_sample_rate = 1.0
end
+# Rack refuses a multipart body of more than 128 parts by default, and
+# the import screen is handed one part per card in a plan -- 443 for
+# this Mac's iCloud book, and no plan is split to suit a parser. The
+# limit guards a public endpoint against a cheap body; this app is
+# reachable only over the tailnet, by a few family members, all trusted
+# (README, the simplifying assumptions), so raising it spends nothing
+# that was being protected. Here rather than beside the route, because
+# Rack reads it when it parses and requiring the app has no side
+# effects.
+Rack::Utils.multipart_part_limit = 2048
+
ProTacts::Web.plugin :common_logger, $stderr
run ProTacts::Web.freeze.app
diff --git a/docs/plans/2026-09-20-import-by-upload.md b/docs/plans/2026-09-20-import-by-upload.md
new file mode 100644
index 0000000..4790cc3
--- /dev/null
+++ b/docs/plans/2026-09-20-import-by-upload.md
@@ -0,0 +1,112 @@
+# Importing by upload
+
+2026-09-20. The middle step of an import — landing a plan's cards on a
+host — stopped being a rake task and became a screen in the app. A plan
+is still built on the Mac by `rake import:macos:plan` and still
+finalized there by `rake import:macos:finalize`; between them, the cards
+are uploaded at `/import` and land through the store.
+
+## What was wrong with a task
+
+`rake import:execute` ran on the machine holding the plan and wrote to a
+host over HTTP: a PUT per card to `/dav/addressbook/<id>.vcf`, then a
+`POST /contacts/<id>/groups`, with `GET /api/groups` in front of each
+group it had to make. That meant three things a person had to keep
+right, none of which the import is about:
+
+- **A host, named twice.** `data/import/config.yml` had to name the
+ server, and the plan recorded which one it landed on so a second run
+ could not aim elsewhere. Both facts existed only because the writer
+ was somewhere else.
+- **A checkout, on the Mac.** Landing needed the repository, the gems,
+ and a Ruby, on a machine whose only real job in the import is holding
+ Contacts.app.
+- **An identity from a proxy.** The cards landed as whoever the host's
+ proxy said the request was, which is not visibly the person running
+ the task.
+
+Uploading answers all three. The person is already looking at the host
+in a browser, already past the identity gate as themselves, and the
+machine holding the plan is whichever one they are sitting at.
+
+## What is uploaded
+
+The plan's card files — `data/import/active/<plan>/cards/*.yml` — chosen
+in one multi-file picker. Not `plan.yml`, and not the directory: a card
+carries everything the server needs, because `Plan.write` names each
+file for the id it lands under and writes `sync:*` and the plan's
+`import-<timestamp>` group into the card itself. `plan.yml` stays on the
+Mac as its own ledger, which is where finalize reads it.
+
+Rack refuses a multipart body of more than 128 parts by default, and a
+whole-book plan is 443 cards. `config.ru` raises the limit. The default
+guards a public endpoint against a cheap body; this app is on a tailnet
+and everyone who can reach it is an admin (docs/DESIGN.md), so there was
+nothing there to protect.
+
+## Landing
+
+`Import::Land` writes what `Execute` wrote, 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 the PUT
+and the groups dialog make:
+
+1. Read every uploaded file first (`Import::Card.parse`), so a card that
+ will not read stops the upload with nothing landed. That was
+ `execute`'s rule and it survives unchanged.
+2. `Store#put` with `client: true` for each card the store does not
+ already have, 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 the groups the file names, making any that are
+ missing, and out of `sync:*` unless the file names it — the same
+ reading of a card's groups the browser's own save makes.
+
+A card already in the store is left as it is and reported as such rather
+than written over, which is what the PUT's `If-None-Match: *` bought
+before. So a plan uploaded twice writes nothing the second time, and a
+person who has since edited a contact here does not lose the edit to a
+re-upload.
+
+The upload answers with the page listing what arrived rather than the
+303 the other writes answer with. Landing is idempotent, so the
+re-submission a back button offers writes nothing, and there is no other
+page holding what just came in.
+
+## What the plan stopped recording
+
+A contact's status went from none through `landed` and `imported` to
+`done`. The two middle states were `execute`'s to write, and the app
+cannot write them: it has no plan, only cards. So a contact is now
+outstanding until it is `done`, and `Plan#with_status` is
+`Plan#outstanding`.
+
+Nothing is lost by it. Finalize already asked the host, card by card,
+whether it still had the contact before deleting the original
+(`GET /contacts/<id>`) — a stronger check than the status, since the
+status only said a write had happened once. That check now stands alone:
+a contact whose card is not on the host is kept, which is exactly what a
+plan whose cards were never uploaded gets.
+
+`plan.yml` also stopped recording a host, which was `execute`'s guard
+against landing one plan on two servers. Uploading is aimed by which
+server the browser is pointed at, so there is nothing to guard.
+Finalize reads the host from `data/import/config.yml` instead, which is
+still the one place a host is named
+(`2026-09-19-import-under-data-import.md`).
+
+The plan format did not change, and nothing on disk is rewritten: a plan
+`execute` left part way through still reads, and its `landed` and
+`imported` contacts read as outstanding, which is what they are.
+
+## The screen
+
+`/import`, linked from the footer beside device setup, for that link's
+reason: the dashboard is a search over contacts and a plan is not a
+contact. It is rarer than device setup — a plan arrives a handful of
+times in a book's life.
+
+The form takes the cards and nothing else. Its answer is a row per card,
+linked to the record it landed as, marking the ones that were already
+here. A refusal is a toast naming the file: which card would not read,
+or which file was not a plan card at all.
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index c14f791..0e26368 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -120,11 +120,15 @@ module ProTacts
# Device setup belongs here because the dashboard is a
# search over contacts and /setup is not a contact — a
# header link would put it beside the search on every
- # screen, at the weight the app's own name has.
+ # screen, at the weight the app's own name has. Import is
+ # here for the same reason and is rarer still: a plan
+ # arrives a handful of times in a book's life
+ # (docs/plans/2026-09-20-import-by-upload.md).
footer(class: "admin-footer") do
version = ProTacts.config.version
span(class: "type-label") { version } if version
span(class: "type-label") { "+debug" } if ProTacts.config.debug?
+ a(href: "/import", class: "type-label") { "import" }
a(href: "/setup", class: "type-label") { "device setup" }
end
# A server-rendered toast, Gloss's [role=status] contract:
diff --git a/lib/pro_tacts/admin/plan_upload.rb b/lib/pro_tacts/admin/plan_upload.rb
new file mode 100644
index 0000000..5c609fd
--- /dev/null
+++ b/lib/pro_tacts/admin/plan_upload.rb
@@ -0,0 +1,102 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/avatar"
+require "pro_tacts/admin/format"
+require "pro_tacts/admin/layout"
+require "pro_tacts/admin/list_item"
+
+module ProTacts
+ module Admin
+ # GET /import — the screen a plan lands from, and the page its
+ # upload answers with (docs/plans/2026-09-20-import-by-upload.md).
+ # Until this existed a plan was carried by `rake import:execute`,
+ # which had to run somewhere holding the plan on disk, with a host
+ # configured to aim at; here the machine holding the plan is
+ # whichever one the browser is on, and the host is the one being
+ # looked at.
+ #
+ # PlanUpload rather than Import, which is the module doing the
+ # landing: this is the one screen of it, named for what a person
+ # does there, the way DeviceSetup is.
+ #
+ # The form asks for the card files rather than the whole plan
+ # directory: a card names the id it lands under in its filename and
+ # the groups it joins in its own text, so `cards/` is the whole of
+ # what this server needs. `plan.yml` stays the Mac's ledger, for
+ # `import:macos:finalize` to read.
+ #
+ # `arrivals` is the run that just happened, rendered below the form
+ # that is still there to run another.
+ class PlanUpload < Phlex::HTML
+ # @rbs @arrivals: Array[::ProTacts::Import::Land::Arrival]?
+ # @rbs @notice: String?
+
+ #: (?arrivals: Array[::ProTacts::Import::Land::Arrival]?, ?notice: String?) -> void
+ def initialize(arrivals: nil, notice: nil)
+ @arrivals = arrivals
+ @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: "/", class: "type-label") { "‹ contacts" }
+ end
+ upload_card
+ arrivals_list if @arrivals
+ end
+ end
+ end
+
+ private
+
+ #: () -> void
+ def upload_card
+ div(class: "card") do
+ div(class: "card-body") do
+ h1(class: "type-h2", style: "margin: 0;") { "Import a plan" }
+ p(class: "type-body-sm") do
+ "Choose every card in a plan's cards directory. Each one lands as the " \
+ "contact its file describes, in the groups that file names."
+ end
+ form(action: "/import", method: "post", enctype: "multipart/form-data", class: "field-stack") do
+ label(class: "field") do
+ span(class: "type-label") { "cards" }
+ input(type: "file", name: "cards[]", multiple: true, accept: ".yml")
+ end
+ button(type: "submit", data_variant: "primary") { "import" }
+ end
+ end
+ end
+ end
+
+ # What the upload did, contact by contact: a row per card, linked
+ # to the record it landed as, so the next thing to do — reading
+ # one, fixing one — is a click away. A card already here says so
+ # rather than being left out: "nothing happened to this one" is
+ # the answer a second upload of the same plan is owed.
+ #: () -> void
+ def arrivals_list
+ arrivals = @arrivals or return
+
+ section do
+ div(class: "section-head") do
+ h2(class: "type-label") { "landed (#{arrivals.count(&:stored)} of #{arrivals.length})" }
+ end
+ ul(class: "card") do
+ arrivals.each do |arrival|
+ render ListItem.new(
+ href: "/contacts/#{arrival.contact.id}",
+ avatar: Avatar.new(contact: arrival.contact, size: "lg"),
+ trailing: ("already here" unless arrival.stored),
+ ) do
+ div { Format.name_label(arrival.contact) }
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/import/card.rb b/lib/pro_tacts/import/card.rb
index b4fac74..b92ccfd 100644
--- a/lib/pro_tacts/import/card.rb
+++ b/lib/pro_tacts/import/card.rb
@@ -41,14 +41,26 @@ module ProTacts
# bytes cannot mislabel what they are.
PHOTO_TYPES = {"image/jpeg" => "JPEG", "image/png" => "PNG", "image/gif" => "GIF"}.freeze #: Hash[String, String]
+ # A card off disk, where a plan keeps one. The BOM-tolerant mode is
+ # YAML.safe_load_file's own, kept because the read moved out of it.
+ #: (Pathname path) -> Card
+ def self.read(path)
+ parse(File.read(path, mode: "r:bom|utf-8"), path.to_s)
+ end
+
# Every problem is refused rather than skipped: a key this does not
# know, or a value YAML read as something other than text (an
# unquoted `no` is false, and an unquoted 1815-12-10 a Date), is an
# edit that would otherwise vanish.
- #: (Pathname path) -> Card
- def self.read(path)
- document = YAML.safe_load_file(path)
- invalid = ->(why) { raise Invalid, "#{path}: #{why}" }
+ #
+ # `name` is what a refusal names, and is a path only where the card
+ # came from one: an upload has no path this server can point at, so
+ # the import screen names the file the browser sent instead
+ # (docs/plans/2026-09-20-import-by-upload.md).
+ #: (String document, String name) -> Card
+ def self.parse(document, name)
+ document = YAML.safe_load(document)
+ invalid = ->(why) { raise Invalid, "#{name}: #{why}" }
invalid.("is not a mapping") unless document.is_a?(Hash)
missing = KEYS - document.keys
@@ -74,7 +86,7 @@ module ProTacts
card
rescue Psych::DisallowedClass => error
- raise Invalid, "#{path}: #{error.message.sub(/\ATried to load unspecified class: /, "reads as a ")}; quote it"
+ raise Invalid, "#{name}: #{error.message.sub(/\ATried to load unspecified class: /, "reads as a ")}; quote it"
end
#: (untyped value) -> bool
diff --git a/lib/pro_tacts/import/execute.rb b/lib/pro_tacts/import/execute.rb
deleted file mode 100644
index 2a5a54f..0000000
--- a/lib/pro_tacts/import/execute.rb
+++ /dev/null
@@ -1,119 +0,0 @@
-require "json"
-require "uri"
-
-require "pro_tacts/import/plan"
-
-module ProTacts
- module Import
- # Lands a plan on a host through the routes a client and the admin
- # UI use, the same for every source. Idempotent against the host as
- # well as the plan (docs/plans/2026-09-16-importing-from-macos.md,
- # "Execute is the same for every source").
- class Execute
- # @rbs @plan: Plan
- # @rbs @host: String
- # @rbs @client: _Client
- # @rbs @group_ids: Hash[String, String]
-
- class Failed < StandardError; end
-
- # What a host answered. Signed in sig/pro_tacts/import.rbs, being a
- # Data class.
- # @rbs skip
- Response = Data.define(:status, :headers, :body)
-
- #: (Plan plan, host: String, client: _Client) -> void
- def self.call(plan, host:, client:)
- new(plan, host:, client:).call
- end
-
- #: (Plan plan, host: String, client: _Client) -> void
- def initialize(plan, host:, client:)
- @plan = plan
- @host = host
- @client = client
- @group_ids = {}
- end
-
- #: () -> void
- def call
- landed_on = @plan.host
- if landed_on && landed_on != @host
- raise Failed, "#{@plan.dir} is landing on #{landed_on}; refusing to land it on #{@host} too"
- end
- # Every card still to carry is read before the first write, so an
- # edit that will not read stops the run with nothing landed.
- cards = @plan.contacts.reject { it.status == "imported" }.to_h {
- [it.id, @plan.card(it.id)] #: [String, Card]
- }
- # Before the first write, so a run that dies after one still ties
- # the plan to this host.
- @plan.host = @host unless landed_on
-
- @plan.contacts.each { land(it.id, cards.fetch(it.id)) if it.status.nil? }
- @plan.contacts.each { add_to_groups(it.id, cards.fetch(it.id)) if it.status == "landed" }
- end
-
- private
-
- # A bare 412 is If-None-Match failing on an id this plan minted, so
- # the card is on the host already: an earlier run's PUT landed and
- # died before the plan recorded it. A 412 with a body is the card
- # refused.
- #: (String id, Card card) -> void
- def land(id, card)
- response = @client.call(
- "PUT", "/dav/addressbook/#{id}.vcf",
- headers: {"Content-Type" => "text/vcard; charset=utf-8", "If-None-Match" => "*"},
- body: card.contact(id).vcard.to_s
- )
- unless [201, 204].include?(response.status) || response.status == 412 && response.body.empty?
- raise Failed, "PUT of #{id} answered #{response.status}: #{response.body}"
- end
-
- @plan.record(id, "landed")
- end
-
- # The card's groups, and out of `sync:*` unless it is one of them:
- # the PUT that created the card put it there.
- #: (String id, Card card) -> void
- def add_to_groups(id, card)
- form = card.groups.uniq.map { ["groups[]", group_id(it)] } #: Array[[String, String]]
- form << ["was[]", group_id(Plan::EVERYONE)]
- response = post("/contacts/#{id}/groups", form)
- raise Failed, "adding #{id} to #{card.groups.join(", ")} answered #{response.status}" unless response.status == 303
-
- @plan.record(id, "imported")
- end
-
- # Looked up before it is made, so a run that died after making it
- # reuses the one it made.
- #: (String name) -> String
- def group_id(name)
- @group_ids[name] ||= listed_group_id(name) || make_group(name)
- end
-
- #: (String name) -> String
- def make_group(name)
- response = post("/groups", [["name", name]])
- raise Failed, "creating group #{name} answered #{response.status}" unless response.status == 303
-
- listed_group_id(name) or raise Failed, "group #{name} was created and is not listed"
- end
-
- #: (String name) -> String?
- def listed_group_id(name)
- response = @client.call("GET", "/api/groups")
- raise Failed, "listing groups answered #{response.status}" unless response.status == 200
-
- groups = JSON.parse(response.body) #: Array[Hash[String, String?]]
- groups.find { it.fetch("name") == name }&.fetch("id")
- end
-
- #: (String path, Array[[String, String]] form) -> Response
- def post(path, form)
- @client.call("POST", path, headers: {"Content-Type" => "application/x-www-form-urlencoded"}, body: URI.encode_www_form(form))
- end
- end
- end
-end
diff --git a/lib/pro_tacts/import/finalize.rb b/lib/pro_tacts/import/finalize.rb
index 0e89f8c..62f0194 100644
--- a/lib/pro_tacts/import/finalize.rb
+++ b/lib/pro_tacts/import/finalize.rb
@@ -7,8 +7,15 @@ module ProTacts
# "Removing the originals"). Every check is a reason to keep a
# contact rather than to delete one, because a wrong keep costs a
# second look and a wrong delete costs the contact.
+ #
+ # The host is the one in data/import/config.yml, read by the task,
+ # rather than one the plan recorded: landing is the import screen's
+ # now and writes nothing back to the plan, so what the plan can
+ # still say is which contacts were meant to go, and the host is
+ # asked about each of them (docs/plans/2026-09-20-import-by-upload.md).
class Finalize
# @rbs @plan: Plan
+ # @rbs @host: String
# @rbs @client: _Client
# @rbs @mac: _Mac
@@ -22,23 +29,29 @@ module ProTacts
# @rbs skip
Result = Data.define(:done, :kept)
- #: (Plan plan, client: _Client, mac: _Mac) -> Result
- def self.call(plan, client:, mac:)
- new(plan, client:, mac:).call
+ #: (Plan plan, host: String, client: _Client, mac: _Mac) -> Result
+ def self.call(plan, host:, client:, mac:)
+ new(plan, host:, client:, mac:).call
end
- #: (Plan plan, client: _Client, mac: _Mac) -> void
- def initialize(plan, client:, mac:)
+ #: (Plan plan, host: String, client: _Client, mac: _Mac) -> void
+ def initialize(plan, host:, client:, mac:)
@plan = plan
+ @host = host
@client = client
@mac = mac
end
#: () -> Result
def call
- imported = @plan.with_status("imported")
- records = @mac.show(imported.map(&:source_id))
- gone, still_there = imported.partition { !records.key?(it.source_id) }
+ # Every contact the plan has not finished, whether or not the
+ # import screen has landed it: #keep asks the host about each
+ # one, and a contact whose card is not there is kept rather
+ # than deleted — the check that stood behind the recorded
+ # status, and now stands in its place.
+ outstanding = @plan.outstanding
+ records = @mac.show(outstanding.map(&:source_id))
+ gone, still_there = outstanding.partition { !records.key?(it.source_id) }
# A contact this Mac no longer has is a contact done, which is
# what a rerun of an interrupted run sees.
gone.each { @plan.record(it.id, Plan::DONE) }
@@ -73,7 +86,7 @@ module ProTacts
def keep(contact, record)
source = @plan.card(contact.id).source
if !on_host?(contact.id)
- "its card is no longer on #{@plan.host}"
+ "no card of its id is on #{@host}"
elsif record.fetch("vcard") != source.fetch("vcard")
"it has changed on this Mac since the plan"
elsif record.fetch("note") != source.fetch("note")
diff --git a/lib/pro_tacts/import/http_client.rb b/lib/pro_tacts/import/http_client.rb
index 1fa89d1..78db5e9 100644
--- a/lib/pro_tacts/import/http_client.rb
+++ b/lib/pro_tacts/import/http_client.rb
@@ -1,23 +1,31 @@
require "net/http"
-require "pro_tacts/import/execute"
-
module ProTacts
module Import
- # A host over one started connection. The identity header is the
- # host's proxy to write, so nothing here sets one.
+ # A host over one started connection, which is how `finalize` asks
+ # whether a card it is about to delete from the Mac is still on the
+ # server it landed on. The identity header is the host's proxy to
+ # write, so nothing here sets one.
class HttpClient
# @rbs @http: Net::HTTP
+ # What a host answered. It lives here rather than beside the
+ # caller because the caller is now whoever holds a client: it
+ # was Execute's, and Execute is the import screen
+ # (docs/plans/2026-09-20-import-by-upload.md). Signed in
+ # sig/pro_tacts/import.rbs, being a Data class.
+ # @rbs skip
+ Response = Data.define(:status, :headers, :body)
+
#: (Net::HTTP http) -> void
def initialize(http)
@http = http
end
- #: (String method, String path, ?headers: Hash[String, String], ?body: String?) -> Execute::Response
+ #: (String method, String path, ?headers: Hash[String, String], ?body: String?) -> Response
def call(method, path, headers: {}, body: nil)
response = @http.send_request(method, path, body, headers)
- Execute::Response.new(status: response.code.to_i, headers: response.to_hash.transform_values { it.join(", ") }, body: response.body.to_s)
+ Response.new(status: response.code.to_i, headers: response.to_hash.transform_values { it.join(", ") }, body: response.body.to_s)
end
end
end
diff --git a/lib/pro_tacts/import/land.rb b/lib/pro_tacts/import/land.rb
new file mode 100644
index 0000000..3afa536
--- /dev/null
+++ b/lib/pro_tacts/import/land.rb
@@ -0,0 +1,96 @@
+require "pro_tacts/import/card"
+require "pro_tacts/import/plan"
+
+module ProTacts
+ module Import
+ # Lands a plan's cards in this server's own store, the step the
+ # import screen runs on what a browser uploaded
+ # (docs/plans/2026-09-20-import-by-upload.md). It writes what
+ # `import:execute` used to write over HTTP, through the store
+ # rather than through the routes, and lands the same thing: the
+ # card the plan's file describes, in the groups that file names.
+ #
+ # Idempotent, so a plan uploaded twice writes nothing the second
+ # time — which is what the upload has in place of the statuses
+ # `execute` recorded, there being no plan on this side to record
+ # one in.
+ class Land
+ # @rbs @store: Store
+ # @rbs @group_ids: Hash[String, String]
+
+ # One card's arrival: the contact as it now stands, and whether
+ # this upload is what put it there. `stored` false is a card
+ # already here — a re-upload, or a plan landed once already —
+ # left as it is rather than written over, which is what the PUT's
+ # `If-None-Match: *` bought before.
+ # Signed in sig/pro_tacts/import.rbs, being a Data class.
+ # @rbs skip
+ Arrival = Data.define(:contact, :stored)
+
+ # The cards by the id each will be stored under, in the order
+ # they should land.
+ #: (Store store, Hash[String, Card] cards) -> Array[Arrival]
+ def self.call(store, cards)
+ new(store).call(cards)
+ end
+
+ #: (Store store) -> void
+ def initialize(store)
+ @store = store
+ # Read once, and kept up to date as groups are made, so a plan
+ # naming one group across four hundred cards reads the list
+ # once rather than four hundred times. A nameless group is left
+ # out: a card's groups are names, and no name finds one.
+ @group_ids = {} #: Hash[String, String]
+ store.all_groups.each do |group|
+ name = group.name
+ @group_ids[name] = group.id if name
+ end
+ end
+
+ #: (Hash[String, Card] cards) -> Array[Arrival]
+ def call(cards)
+ cards.map { |id, card| land(id, card) }
+ end
+
+ private
+
+ # A card, then its groups, the order `execute` wrote them in: the
+ # PUT creates, and the membership that follows is what puts the
+ # card in its books.
+ #: (String id, Card card) -> Arrival
+ def land(id, card)
+ stored = @store.contact(id).nil?
+ # `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).
+ # The arrival reports at the PUT (Web#report_broken_assumptions)
+ # have nothing to say about a card this built: its lines come
+ # from Admin::CardForm, the editor's own writer, not off a wire.
+ @store.put(id, card.contact(id).vcard, client: true) if stored
+
+ named = card.groups.uniq.map { group_id(it) }
+ # Out of everyone's book unless the file names it, the same
+ # reading of a card's groups the browser's own save makes
+ # (Web#apply_groups, whose `was` the import filled with this).
+ everyone = group_id(Plan::EVERYONE)
+ @store.regroup(id, join: named, leave: named.include?(everyone) ? [] : [everyone])
+
+ # Read back rather than kept from the put, because the groups
+ # above moved what the card serves. Nothing can have taken it
+ # away in between, so a miss here is a broken assumption rather
+ # than a case to handle.
+ contact = @store.contact(id) || raise("#{id} was landed and is not stored")
+ Arrival.new(contact:, stored:)
+ end
+
+ # Looked up before it is made, so a plan naming a group this
+ # server already has joins that one rather than colliding with
+ # its name (db/migrations/008_group_names.rb).
+ #: (String name) -> String
+ def group_id(name)
+ @group_ids[name] ||= @store.create_group(name:)
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/import/plan.rb b/lib/pro_tacts/import/plan.rb
index 94547c8..885731f 100644
--- a/lib/pro_tacts/import/plan.rb
+++ b/lib/pro_tacts/import/plan.rb
@@ -8,8 +8,9 @@ require "pro_tacts/import/card"
module ProTacts
module Import
# One import, as a directory: what a source builder decided, and how
- # far execute and finalize have carried it
- # (docs/plans/2026-09-16-importing-from-macos.md).
+ # far finalize has carried it
+ # (docs/plans/2026-09-16-importing-from-macos.md, and
+ # docs/plans/2026-09-20-import-by-upload.md for the step that left).
class Plan
# @rbs @dir: Pathname
# @rbs @manifest: Hash[String, untyped]
@@ -22,10 +23,15 @@ module ProTacts
Entry = Data.define(:id, :source_id, :card)
# A contact as the plan lists it, with the last step it reached:
- # nil, then landed, imported, and done, in that order. The name is
- # the card's when the plan was built, written down so a person
- # reading plan.yml can tell which line is whose without opening a
- # card; nothing reads it back to decide anything.
+ # nil until it is done. The steps between — `landed` and
+ # `imported` — were `execute`'s to record, and went with it when
+ # landing moved into the app
+ # (docs/plans/2026-09-20-import-by-upload.md); a plan carried by
+ # that task still reads, and a contact wearing either status is
+ # outstanding like any other. The name is the card's when the plan
+ # was built, written down so a person reading plan.yml can tell
+ # which line is whose without opening a card; nothing reads it
+ # back to decide anything.
# @rbs skip
Contact = Data.define(:id, :source_id, :name, :status)
@@ -45,11 +51,9 @@ module ProTacts
REMOVED = "removed" #: String
# Whether the plan has carried every contact through: nothing
- # left for execute or finalize to do.
+ # left for finalize to do.
#: () -> bool
- def done?
- contacts.all? { [DONE, REMOVED].include?(it.status) }
- end
+ def done? = outstanding.empty?
#: (Pathname dir, source: String, created_at: Time, entries: Array[Entry]) -> Plan
def self.write(dir, source:, created_at:, entries:)
@@ -71,7 +75,6 @@ module ProTacts
"source" => source,
"created_at" => created_at.utc.iso8601,
"group" => group,
- "host" => nil,
"contacts" => entries.map {
# A card may hold only one of the two names.
person = [it.card.first, it.card.last].reject(&:empty?).join(" ")
@@ -114,16 +117,6 @@ module ProTacts
#: () -> String
def group = @manifest.fetch("group")
- # The host the plan is landing on, once execute has started.
- #: () -> String?
- def host = @manifest.fetch("host")
-
- #: (String host) -> void
- def host=(host)
- @manifest["host"] = host
- save
- end
-
#: () -> Array[Contact]
def contacts
@manifest.fetch("contacts").map {
@@ -137,10 +130,12 @@ module ProTacts
save
end
- # The contacts this far along, for a task picking a plan to carry
- # further: none for a plan every step has finished.
- #: (String? status) -> Array[Contact]
- def with_status(status) = contacts.select { it.status == status }
+ # The contacts still to carry, for a task picking a plan to take
+ # further: none for a plan every step has finished. Anything that
+ # is not the final state counts, so a plan `execute` left part way
+ # through is outstanding where it left off.
+ #: () -> Array[Contact]
+ def outstanding = contacts.reject { [DONE, REMOVED].include?(it.status) }
# The card as its file now reads, edits included.
#: (String id) -> Card
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index f5743b6..e9ec6c7 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -137,4 +137,5 @@ require "pro_tacts/web/api"
require "pro_tacts/web/contacts"
require "pro_tacts/web/dav"
require "pro_tacts/web/groups"
+require "pro_tacts/web/import"
require "pro_tacts/web/setup"
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
new file mode 100644
index 0000000..b8dbf32
--- /dev/null
+++ b/lib/pro_tacts/web/import.rb
@@ -0,0 +1,83 @@
+require "pro_tacts/admin/plan_upload"
+require "pro_tacts/import/card"
+require "pro_tacts/import/land"
+
+module ProTacts
+ class Web < Roda
+ # The import screen: a plan's cards arrive as an upload and land in
+ # this server's store (docs/plans/2026-09-20-import-by-upload.md).
+ # It replaces `rake import:execute`, which carried the same cards
+ # over HTTP from the machine the plan sat on — the routes it drove
+ # are still there, and nothing here is a second way to write a
+ # card: Import::Land goes through Store#put and Store#regroup, the
+ # two writes that PUT and the groups dialog make.
+ #
+ # Under the same identity gate as every other route (web.rb), which
+ # is also where the cards' books come from: a card this creates
+ # joins everyone's book the way a client's create does, so the
+ # person importing does not have to be the person syncing.
+ hash_branch("import") do |r|
+ r.is do
+ r.get do
+ plan_upload_screen
+ end
+
+ r.post do
+ apply_import(r)
+ end
+ end
+ end
+
+ private
+
+ # The upload's whole POST, #apply_edit's shape: the request's own
+ # validity first — every file named and read before anything is
+ # written, `execute`'s rule that a card which will not read stops
+ # the run with nothing landed — and the write after it.
+ #
+ # It answers with the result rather than the 303 the other writes
+ # answer with, and can afford to: landing is idempotent, so the
+ # re-submission a back button offers writes nothing, and there is
+ # no other page that holds what just arrived.
+ #: (untyped r) -> String
+ def apply_import(r)
+ uploads = r.params["cards"]
+ uploads = uploads.is_a?(Array) ? uploads.select { it.is_a?(Hash) && it[:tempfile] } : []
+ return plan_upload_screen(notice: "Choose the card files in a plan's cards directory.") if uploads.empty?
+
+ cards = {} #: Hash[String, Import::Card]
+ uploads.each do |upload|
+ # The browser sends a basename; File.basename is against a
+ # client that sends more than one.
+ name = File.basename(upload[:filename].to_s)
+ id = name.delete_suffix(".yml")
+ unless name.end_with?(".yml") && id.match?(Contact::ID_FORMAT)
+ return plan_upload_screen(notice: "#{name} is not a plan card: a card file is named for the id it lands under.")
+ end
+
+ # Relabelled and judged in the same breath, Web#write_card's
+ # rule for the one other body this app reads: a multipart part
+ # arrives as binary, and a force_encoding nobody validates is a
+ # lie every later reader inherits — here it would surface as a
+ # 500 out of SQLite on the insert, past the point where the
+ # person could be told which file was wrong.
+ bytes = upload.fetch(:tempfile).read.force_encoding(Encoding::UTF_8)
+ return plan_upload_screen(notice: "#{name} is not UTF-8 text.") unless bytes.valid_encoding?
+
+ begin
+ cards[id] = Import::Card.parse(bytes, name)
+ rescue Import::Card::Invalid => error
+ return plan_upload_screen(notice: error.message)
+ end
+ end
+
+ plan_upload_screen(arrivals: Import::Land.call(store, cards))
+ end
+
+ #: (?arrivals: Array[Import::Land::Arrival]?, ?notice: String?) -> String
+ def plan_upload_screen(arrivals: nil, notice: nil)
+ response["Content-Type"] = "text/html; charset=utf-8"
+ Admin::PlanUpload.call(arrivals:, notice:)
+ end
+ end
+end
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index df50045..36b309e 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -38,6 +38,10 @@ module ProTacts
def self.call: (hostname: String, name: String) -> String
end
+ class PlanUpload
+ def self.call: (?arrivals: Array[::ProTacts::Import::Land::Arrival]?, ?notice: String?) -> String
+ end
+
# Rendered by Dashboard, not called from checked code; only
# its LIMIT is read in web.rb.
class UpcomingBirthdays
diff --git a/sig/pro_tacts/import.rbs b/sig/pro_tacts/import.rbs
index 36fe287..fc08004 100644
--- a/sig/pro_tacts/import.rbs
+++ b/sig/pro_tacts/import.rbs
@@ -1,8 +1,8 @@
-# Import::Card, Import::Plan::Entry, ::Contact, and Import::Execute::Response are Data
-# classes, whose members the inline parser cannot read (see
-# sig/pro_tacts/contact.rbs). Each is marked `@rbs skip` where it is
-# defined and signed here. _Client is an interface, which inline syntax
-# has no way to declare.
+# Import::Card, Import::Plan::Entry, ::Contact, Import::Land::Arrival, and
+# Import::HttpClient::Response are Data classes, whose members the inline
+# parser cannot read (see sig/pro_tacts/contact.rbs). Each is marked
+# `@rbs skip` where it is defined and signed here. _Client is an
+# interface, which inline syntax has no way to declare.
module ProTacts
module Import
class Card
@@ -65,7 +65,7 @@ module ProTacts
end
end
- class Execute
+ class HttpClient
class Response
attr_reader status: Integer
attr_reader headers: Hash[String, String]
@@ -75,10 +75,19 @@ module ProTacts
end
end
+ class Land
+ class Arrival
+ attr_reader contact: ProTacts::Contact
+ attr_reader stored: bool
+
+ def self.new: (contact: ProTacts::Contact, stored: bool) -> instance
+ end
+ end
+
# A host, spoken to by path: Import::HttpClient, or the app itself
# under test.
interface _Client
- def call: (String method, String path, ?headers: Hash[String, String], ?body: String?) -> Execute::Response
+ def call: (String method, String path, ?headers: Hash[String, String], ?body: String?) -> HttpClient::Response
end
# The Mac a plan was read from: Import::Macos::Mac, or a test's stand-in.
diff --git a/tasks/import.rake b/tasks/import.rake
index e63d347..ee3e375 100644
--- a/tasks/import.rake
+++ b/tasks/import.rake
@@ -1,11 +1,12 @@
# Importing an address book from another source
-# (docs/plans/2026-09-16-importing-from-macos.md, and
+# (docs/plans/2026-09-16-importing-from-macos.md,
# docs/plans/2026-09-19-import-under-data-import.md for the layout
-# and the vocabulary).
+# and the vocabulary, and docs/plans/2026-09-20-import-by-upload.md
+# for the middle step, which is a screen in the app rather than a
+# task here).
require "fileutils"
require "pathname"
-require "uri"
require "pro_tacts/import/config"
@@ -14,11 +15,12 @@ require "pro_tacts/import/config"
module ImportTasks
Config = ProTacts::Import::Config
- # What each carrying task waits on — a contact still to carry through
- # that step — named so a status read can say what those tasks would
- # pick next without restating their choice.
- TO_LAND = ->(plan) { plan.with_status(nil).any? || plan.with_status("landed").any? }
- TO_FINALIZE = ->(plan) { plan.with_status("imported").any? }
+ # What the one carrying task left here waits on — a contact still to
+ # finish — named so a status read can say what it would pick next
+ # without restating its choice. Landing is the import screen's, and
+ # writes nothing back to a plan, so a contact is outstanding until
+ # finalize takes it off this Mac.
+ TO_FINALIZE = ->(plan) { plan.outstanding.any? }
# The plan PLAN names, or the oldest one still waiting for this step:
# plans are carried in the order they were built, so the next one to
@@ -68,7 +70,7 @@ namespace :import do
abort error.message
end
- desc "Finalize the contacts the oldest landed plan imported, taking them off this Mac and filing the plan away done (PLAN=dir for another)"
+ desc "Finalize the contacts the oldest unfinished plan landed, taking them off this Mac and filing the plan away done (PLAN=dir for another)"
task :finalize do
require "net/http"
require "pro_tacts/import/http_client"
@@ -76,14 +78,19 @@ namespace :import do
require "pro_tacts/import/finalize"
plan = ImportTasks.plan(ImportTasks::TO_FINALIZE, "to finalize")
- host = plan.host or abort("#{plan.dir} has not landed on a host")
- uri = URI.parse(host)
- puts "finalizing the contacts #{plan.dir} landed on #{host}"
+ # The host in the config rather than one the plan recorded: the
+ # import screen lands a plan and writes nothing back to it, so the
+ # config is the only place a host is named
+ # (docs/plans/2026-09-20-import-by-upload.md). Finalize asks it
+ # about every contact before deleting any, so a plan whose cards
+ # were never uploaded keeps all of them.
+ uri = ImportTasks::Config.read.host
+ puts "finalizing the contacts #{plan.dir} landed on #{uri}"
result = nil #: ProTacts::Import::Finalize::Result?
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
result = ProTacts::Import::Finalize.call(
- plan, client: ProTacts::Import::HttpClient.new(http), mac: ProTacts::Import::Macos::Mac
+ plan, host: uri.to_s, client: ProTacts::Import::HttpClient.new(http), mac: ProTacts::Import::Macos::Mac
)
end
result.kept.each { |id, name, why| puts "kept #{id} #{name}: #{why}" }
@@ -95,7 +102,7 @@ namespace :import do
end
end
- desc "Summarize the plans in data/import — in flight and filed away done — and what execute and finalize would carry next"
+ desc "Summarize the plans in data/import — in flight and filed away done — and what would carry the next one further"
task :status do
require "pro_tacts/import/plan"
@@ -114,21 +121,21 @@ namespace :import do
next
end
- # One line per plan in flight, as far along as it is: the fraction
- # counts everything past “planned”, because from “landed” on the
- # card is on the host — which is the state a glance wants. The
- # plan names are the directories', copy-pasteable into PLAN=.
+ # One line per plan in flight, as far along as it is. The fraction
+ # counts the contacts finalized, which is the only progress a plan
+ # still records: landing happens in the app and leaves no mark here
+ # (docs/plans/2026-09-20-import-by-upload.md), so a glance answers
+ # “how much of this is off the Mac”. The plan names are the
+ # directories', copy-pasteable into PLAN=.
in_flight = plans.map {
- carried = it.contacts.size - it.with_status(nil).size
- "#{it.dir.basename} #{carried}/#{it.contacts.size} contacts"
+ "#{it.dir.basename} #{it.contacts.size - it.outstanding.size}/#{it.contacts.size} finalized"
}
- landing = plans.find(&ImportTasks::TO_LAND)
finalizing = plans.find(&ImportTasks::TO_FINALIZE)
- next_up = [
- ("next: import:execute would land #{landing.dir.basename}" if landing),
- ("next: import:macos:finalize would finish #{finalizing.dir.basename}" if finalizing),
- ].compact
+ next_up = finalizing ? [
+ "next: /import would land the cards of #{finalizing.dir.basename}",
+ "next: import:macos:finalize would finish #{finalizing.dir.basename}",
+ ] : []
# What was carried through, whole counts because every contact
# reached the final state — the fraction above can never appear
@@ -141,22 +148,4 @@ namespace :import do
sections = [in_flight, next_up, done.empty? ? [] : ["done:", *done]].reject(&:empty?)
puts sections.map { it.join("\n") }.join("\n\n")
end
-
- desc "Land the oldest plan in data/import/active still to land, or the one in PLAN, on the pro-tacts at the host in data/import/config.yml, a base URL such as https://contacts"
- task :execute do
- require "net/http"
- require "pro_tacts/import/execute"
- require "pro_tacts/import/http_client"
- require "pro_tacts/import/plan"
-
- plan = ImportTasks.plan(ImportTasks::TO_LAND, "to land")
- uri = ImportTasks::Config.read.host
- puts "landing #{plan.dir} on #{uri}"
-
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
- ProTacts::Import::Execute.call(plan, host: uri.to_s, client: ProTacts::Import::HttpClient.new(http))
- end
- groups = plan.contacts.flat_map { plan.card(it.id).groups }.uniq
- puts "#{plan.contacts.size} contacts in #{groups.join(", ")} on #{uri}"
- end
end
diff --git a/test/pro_tacts/admin/test_footer.rb b/test/pro_tacts/admin/test_footer.rb
index a9c9caa..64691ef 100644
--- a/test/pro_tacts/admin/test_footer.rb
+++ b/test/pro_tacts/admin/test_footer.rb
@@ -36,6 +36,12 @@ class AdminFooterTest < Minitest::Test
assert_includes last_response.body, %(<a href="/setup" class="type-label">device setup</a>)
end
+ def test_the_footer_links_to_the_import_screen
+ with_env({})
+
+ assert_includes last_response.body, %(<a href="/import" class="type-label">import</a>)
+ end
+
def test_the_footer_names_the_version_the_image_carries
with_env("VERSION" => "20260904-1822-a1b2c3d")
@@ -49,11 +55,11 @@ class AdminFooterTest < Minitest::Test
def test_an_unversioned_run_names_no_version
with_env("VERSION" => "")
- assert_includes last_response.body, %(<footer class="admin-footer"><a href="/setup")
+ assert_includes last_response.body, %(<footer class="admin-footer"><a href="/import")
with_env({})
- assert_includes last_response.body, %(<footer class="admin-footer"><a href="/setup")
+ assert_includes last_response.body, %(<footer class="admin-footer"><a href="/import")
end
# The label is a standing notice, not a status: the log is recording
diff --git a/test/pro_tacts/admin/test_plan_upload.rb b/test/pro_tacts/admin/test_plan_upload.rb
new file mode 100644
index 0000000..b911ced
--- /dev/null
+++ b/test/pro_tacts/admin/test_plan_upload.rb
@@ -0,0 +1,133 @@
+require_relative "../../test_helper"
+
+require "pathname"
+require "rack/test"
+require "tmpdir"
+
+require "pro_tacts/import/plan"
+require "pro_tacts/web"
+
+# The import screen, exercised the way the other admin screens are:
+# real requests through the Roda app against a throwaway store
+# (docs/plans/2026-09-20-import-by-upload.md).
+class AdminPlanUploadTest < Minitest::Test
+ include Rack::Test::Methods
+ include ThrowawayContacts
+
+ Plan = ProTacts::Import::Plan
+
+ IDS = %w[kmnuqmzxylru vmnlryyvktux].freeze
+
+ def app
+ ProTacts::Web
+ end
+
+ def setup
+ header "Remote-User", "test@example.com"
+ end
+
+ # A plan on disk, as `rake import:macos:plan` leaves one, and a store
+ # with nothing in it for its cards to land in.
+ def with_plan
+ Dir.mktmpdir do |tmp|
+ dir = Pathname.new(tmp) / "plan"
+ entries = IDS.map { |id|
+ source = {"identifier" => "#{id}:ABPerson", "vcard" => "BEGIN:VCARD\r\nEND:VCARD\r\n", "note" => nil, "contact" => {}}
+ card = ProTacts::Import::Card.new(first: "Contact", last: id, nickname: nil, birthday: nil, phones: [], emails: [], addresses: [], note: nil, photo: false, groups: [], source:)
+ Plan::Entry.new(id:, source_id: "#{id}:ABPerson", card:)
+ }
+ Plan.write(dir, source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries:)
+ with_contacts({}) { |store| yield dir, store }
+ end
+ end
+
+ def upload(path) = Rack::Test::UploadedFile.new(path.to_s, "application/yaml", true)
+
+ def upload_cards(dir, paths = IDS.map { dir / "cards/#{it}.yml" })
+ post "/import", "cards" => paths.map { upload(it) }
+ end
+
+ def test_the_screen_asks_for_a_plans_cards
+ with_plan do |_dir, _store|
+ get "/import"
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, %(<form action="/import" method="post" enctype="multipart/form-data")
+ assert_includes last_response.body, %(<input type="file" name="cards[]" multiple accept=".yml">)
+ end
+ end
+
+ def test_uploaded_cards_land_as_contacts_in_the_plans_group
+ with_plan do |dir, store|
+ upload_cards(dir)
+
+ assert_equal 200, last_response.status
+ IDS.each { assert_equal "Contact #{it}", store.contact(it).name }
+ assert_equal IDS.sort, store.all_groups.find { it.name == "import-20260916T180412Z" }.members.sort
+ assert_includes last_response.body, "landed (2 of 2)"
+ IDS.each { assert_includes last_response.body, %(href="/contacts/#{it}") }
+ end
+ end
+
+ def test_a_card_already_here_is_said_so_rather_than_landed_again
+ with_plan do |dir, store|
+ upload_cards(dir)
+ changes = store.changes.size
+
+ upload_cards(dir)
+
+ assert_includes last_response.body, "landed (0 of 2)"
+ assert_includes last_response.body, "already here"
+ assert_equal changes, store.changes.size
+ end
+ end
+
+ def test_an_upload_with_no_files_says_what_to_choose
+ with_plan do |_dir, store|
+ post "/import"
+
+ assert_includes last_response.body, "Choose the card files in a plan's cards directory."
+ assert_empty store.changes
+ end
+ end
+
+ def test_a_card_that_will_not_read_lands_nothing
+ with_plan do |dir, store|
+ path = dir / "cards/#{IDS.last}.yml"
+ path.write(path.read.sub("last: #{IDS.last}", "last: no"))
+
+ upload_cards(dir)
+
+ assert_includes last_response.body, "#{IDS.last}.yml: first and last must be text; quote them"
+ assert_empty store.changes
+ end
+ end
+
+ # The filename is the id the card lands under, so a file named
+ # anything else is a file from somewhere other than a plan.
+ def test_a_file_that_is_not_a_plan_card_lands_nothing
+ with_plan do |dir, store|
+ (dir / "cards/notes.txt").write("hello")
+
+ upload_cards(dir, [dir / "cards/notes.txt"])
+
+ assert_includes last_response.body, "notes.txt is not a plan card"
+ assert_empty store.changes
+ end
+ end
+
+ # Web#write_card's rule over the one other body this app reads: bytes
+ # that are not the text they claim to be are refused where they are
+ # read, rather than at the insert that would 500 on them.
+ def test_a_card_that_is_not_utf_8_lands_nothing
+ with_plan do |dir, store|
+ path = dir / "cards/#{IDS.first}.yml"
+ path.binwrite(path.read.sub("Contact", "Contac\xFF"))
+
+ upload_cards(dir)
+
+ assert_includes last_response.body, "#{IDS.first}.yml is not UTF-8 text."
+ assert_empty store.changes
+ end
+ end
+end
diff --git a/test/pro_tacts/import/test_execute.rb b/test/pro_tacts/import/test_execute.rb
deleted file mode 100644
index 3d6baf3..0000000
--- a/test/pro_tacts/import/test_execute.rb
+++ /dev/null
@@ -1,175 +0,0 @@
-require_relative "../../test_helper"
-
-require "pathname"
-require "rack/mock"
-require "tmpdir"
-
-require "pro_tacts/import/execute"
-require "pro_tacts/import/plan"
-require "pro_tacts/web"
-
-class ImportExecuteTest < Minitest::Test
- include ThrowawayContacts
-
- Plan = ProTacts::Import::Plan
- Execute = ProTacts::Import::Execute
-
- HOST = "https://contacts"
- IDS = %w[kmnuqmzxylru vmnlryyvktux].freeze
-
- # The app in place of a host, the proxy's identity header included.
- class RackClient
- attr_reader :requests
-
- def initialize
- @requests = []
- end
-
- def call(method, path, headers: {}, body: nil)
- @requests << [method, path]
- env = Rack::MockRequest.env_for(path, method:, input: body, "HTTP_REMOTE_USER" => "test@example.com")
- headers.each { |name, value| env[name.casecmp?("Content-Type") ? "CONTENT_TYPE" : "HTTP_#{name.upcase.tr("-", "_")}"] = value }
- status, response_headers, response_body = ProTacts::Web.call(env)
- bytes = +""
- response_body.each { bytes << it }
- response_body.close if response_body.respond_to?(:close)
- Execute::Response.new(status:, headers: response_headers, body: bytes)
- end
- end
-
- # A client whose host refuses every PUT with a precondition body.
- class RefusingClient < RackClient
- def call(method, path, headers: {}, body: nil)
- return Execute::Response.new(status: 412, headers: {}, body: "<error/>") if method == "PUT"
-
- super
- end
- end
-
- def with_plan(groups: [])
- Dir.mktmpdir do |tmp|
- dir = Pathname.new(tmp) / "plan"
- entries = IDS.map { |id|
- source = {"identifier" => "#{id}:ABPerson", "vcard" => "BEGIN:VCARD\r\nEND:VCARD\r\n", "note" => nil, "contact" => {}}
- card = ProTacts::Import::Card.new(first: "Contact", last: id, nickname: nil, birthday: nil, phones: [], emails: [], addresses: [], note: nil, photo: false, groups:, source:)
- Plan::Entry.new(id:, source_id: "#{id}:ABPerson", card:)
- }
- Plan.write(dir, source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries:)
- with_contacts({}) { |store| yield Plan.read(dir), store, RackClient.new }
- end
- end
-
- def imported_group(store) = group_named(store, "import-20260916T180412Z")
-
- def everyone(store) = group_named(store, "sync:*")
-
- def group_named(store, name)
- store.all_groups.find { it.name == name }
- end
-
- def edit_cards(plan)
- IDS.each do |id|
- path = plan.dir / "cards/#{id}.yml"
- path.write(yield(path.read))
- end
- end
-
- def test_every_card_lands_in_the_plans_group
- with_plan do |plan, store, client|
- Execute.call(plan, host: HOST, client:)
-
- IDS.each { assert_equal "Contact #{it}", store.contact(it).name }
- assert_equal IDS.sort, imported_group(store).members.sort
- assert_equal IDS.sort, everyone(store).members.sort
- assert_equal %w[imported imported], Plan.read(plan.dir).contacts.map(&:status)
- end
- end
-
- def test_a_rerun_writes_nothing_again
- with_plan do |plan, store, client|
- Execute.call(plan, host: HOST, client:)
- changes = store.changes.size
- client.requests.clear
-
- Execute.call(Plan.read(plan.dir), host: HOST, client:)
-
- assert_empty client.requests
- assert_equal changes, store.changes.size
- assert_equal 1, store.all_groups.count { it.name == "import-20260916T180412Z" }
- end
- end
-
- # A run that died between a write and the plan recording it.
- def test_a_card_already_on_the_host_counts_as_landed
- with_plan do |plan, store, client|
- client.call("PUT", "/dav/addressbook/#{IDS.first}.vcf", headers: {"Content-Type" => "text/vcard"}, body: plan.card(IDS.first).contact(IDS.first).vcard.to_s)
-
- Execute.call(plan, host: HOST, client:)
-
- assert_equal IDS.sort, imported_group(store).members.sort
- end
- end
-
- def test_a_group_already_on_the_host_is_joined_rather_than_made_again
- with_plan do |plan, store, client|
- id = store.create_group(name: "import-20260916T180412Z")
-
- Execute.call(plan, host: HOST, client:)
-
- assert_equal id, imported_group(store).id
- assert_equal IDS.sort, store.group(id).members.sort
- end
- end
-
- def test_a_plan_that_landed_on_one_host_refuses_another
- with_plan do |plan, store, client|
- Execute.call(plan, host: HOST, client:)
-
- error = assert_raises(Execute::Failed) { Execute.call(Plan.read(plan.dir), host: "http://localhost:9292", client:) }
- assert_includes error.message, HOST
- end
- end
-
- def test_a_refused_card_stops_the_run_and_is_not_recorded
- with_plan do |plan, _store, _client|
- error = assert_raises(Execute::Failed) { Execute.call(plan, host: HOST, client: RefusingClient.new) }
-
- assert_includes error.message, IDS.first
- assert_includes error.message, "412"
- assert_equal [nil, nil], Plan.read(plan.dir).contacts.map(&:status)
- end
- end
-
- def test_a_card_joins_the_groups_its_file_names_making_those_missing
- with_plan(groups: ["family"]) do |plan, store, client|
- Execute.call(plan, host: HOST, client:)
-
- assert_equal IDS.sort, group_named(store, "family").members.sort
- assert_equal 1, store.all_groups.count { it.name == "family" }
- end
- end
-
- def test_a_card_whose_file_leaves_out_everyone_is_taken_out_of_it
- with_plan do |plan, store, client|
- edit_cards(plan) { it.sub("- sync:*\n", "") }
-
- Execute.call(plan, host: HOST, client:)
-
- assert_empty everyone(store).members
- assert_equal IDS.sort, imported_group(store).members.sort
- end
- end
-
- def test_a_card_that_will_not_read_stops_the_run_before_anything_lands
- with_plan do |plan, store, client|
- path = plan.dir / "cards/#{IDS.last}.yml"
- path.write(path.read.sub("last: #{IDS.last}", "last: no"))
-
- assert_raises(ProTacts::Import::Card::Invalid) { Execute.call(plan, host: HOST, client:) }
-
- assert_empty client.requests
- assert_nil Plan.read(plan.dir).host
- assert_empty store.changes
- end
- end
-end
diff --git a/test/pro_tacts/import/test_finalize.rb b/test/pro_tacts/import/test_finalize.rb
index c369054..9aec686 100644
--- a/test/pro_tacts/import/test_finalize.rb
+++ b/test/pro_tacts/import/test_finalize.rb
@@ -4,22 +4,38 @@ require "pathname"
require "rack/mock"
require "tmpdir"
-require "pro_tacts/import/execute"
-require "pro_tacts/import/plan"
require "pro_tacts/import/finalize"
+require "pro_tacts/import/http_client"
+require "pro_tacts/import/land"
+require "pro_tacts/import/plan"
require "pro_tacts/web"
class ImportFinalizeTest < Minitest::Test
include ThrowawayContacts
Card = ProTacts::Import::Card
+ Land = ProTacts::Import::Land
Plan = ProTacts::Import::Plan
- Execute = ProTacts::Import::Execute
Finalize = ProTacts::Import::Finalize
HOST = "https://contacts"
IDS = %w[kmnuqmzxylru vmnlryyvktux].freeze
+ # The app in place of the host finalize asks about each card, the
+ # proxy's identity header included. Landing no longer goes over the
+ # wire (ProTacts::Import::Land), so this is all a client is for.
+ class RackClient
+ def call(method, path, headers: {}, body: nil)
+ env = Rack::MockRequest.env_for(path, method:, input: body, "HTTP_REMOTE_USER" => "test@example.com")
+ headers.each { |name, value| env[name.casecmp?("Content-Type") ? "CONTENT_TYPE" : "HTTP_#{name.upcase.tr("-", "_")}"] = value }
+ status, response_headers, response_body = ProTacts::Web.call(env)
+ bytes = +""
+ response_body.each { bytes << it }
+ response_body.close if response_body.respond_to?(:close)
+ ProTacts::Import::HttpClient::Response.new(status:, headers: response_headers, body: bytes)
+ end
+ end
+
# This Mac, as the contacts it still has: the reader's objects by source
# id, and a delete that takes them out of this hash. A source id in
# `refuses` is one the store will not delete, which is a real thing a Mac
@@ -64,10 +80,9 @@ class ImportFinalizeTest < Minitest::Test
Plan.write(dir, source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries:)
IDS.each { (dir / "cards/#{it}.yml").write(edit.call((dir / "cards/#{it}.yml").read)) } if edit
with_contacts({}) do |store|
- client = ImportExecuteTest::RackClient.new
plan = Plan.read(dir)
- Execute.call(plan, host: HOST, client:)
- yield plan, store, client
+ Land.call(store, IDS.to_h { [it, plan.card(it)] })
+ yield plan, store, RackClient.new
end
end
end
@@ -76,7 +91,7 @@ class ImportFinalizeTest < Minitest::Test
with_landed_plan do |plan, _store, client|
mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
- result = Finalize.call(plan, client:, mac:)
+ result = Finalize.call(plan, host: HOST, client:, mac:)
assert_equal 2, result.done
assert_empty result.kept
@@ -91,11 +106,11 @@ class ImportFinalizeTest < Minitest::Test
records[source_id(IDS.first)]["vcard"] = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Renamed\r\nEND:VCARD\r\n"
mac = Mac.new(records)
- result = Finalize.call(plan, client:, mac:)
+ result = Finalize.call(plan, host: HOST, client:, mac:)
assert_equal [[IDS.first, "Contact #{IDS.first}", "it has changed on this Mac since the plan"]], result.kept
assert_equal [source_id(IDS.last)], mac.deleted
- assert_equal ["imported", "done"], Plan.read(plan.dir).contacts.map(&:status)
+ assert_equal [nil, "done"], Plan.read(plan.dir).contacts.map(&:status)
end
end
@@ -105,7 +120,7 @@ class ImportFinalizeTest < Minitest::Test
records[source_id(IDS.first)]["note"] = "Analyst. Also a countess."
mac = Mac.new(records)
- result = Finalize.call(plan, client:, mac:)
+ result = Finalize.call(plan, host: HOST, client:, mac:)
assert_equal [[IDS.first, "Contact #{IDS.first}", "its note has changed on this Mac since the plan"]], result.kept
assert_equal [source_id(IDS.last)], mac.deleted
@@ -117,9 +132,9 @@ class ImportFinalizeTest < Minitest::Test
store.delete(IDS.first)
mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
- result = Finalize.call(plan, client:, mac:)
+ result = Finalize.call(plan, host: HOST, client:, mac:)
- assert_equal [[IDS.first, "Contact #{IDS.first}", "its card is no longer on #{HOST}"]], result.kept
+ assert_equal [[IDS.first, "Contact #{IDS.first}", "no card of its id is on #{HOST}"]], result.kept
assert_equal [source_id(IDS.last)], mac.deleted
end
end
@@ -131,7 +146,7 @@ class ImportFinalizeTest < Minitest::Test
refute_includes store.book_cards("test@example.com"), IDS.first
mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
- result = Finalize.call(plan, client:, mac:)
+ result = Finalize.call(plan, host: HOST, client:, mac:)
assert_empty result.kept
assert_equal IDS.map { source_id(it) }, mac.deleted
@@ -142,7 +157,7 @@ class ImportFinalizeTest < Minitest::Test
with_landed_plan do |plan, _store, client|
mac = Mac.new({source_id(IDS.last) => record(IDS.last)})
- result = Finalize.call(plan, client:, mac:)
+ result = Finalize.call(plan, host: HOST, client:, mac:)
assert_equal 2, result.done
assert_equal [source_id(IDS.last)], mac.deleted
@@ -157,21 +172,21 @@ class ImportFinalizeTest < Minitest::Test
with_landed_plan do |plan, _store, client|
mac = Mac.new(IDS.to_h { [source_id(it), record(it)] }, refuses: {source_id(IDS.first) => "faulting, 134092"})
- result = Finalize.call(plan, client:, mac:)
+ result = Finalize.call(plan, host: HOST, client:, mac:)
assert_equal 1, result.done
assert_equal [[IDS.first, "Contact #{IDS.first}", "this Mac would not delete #{source_id(IDS.first)}: faulting, 134092"]], result.kept
assert_equal [source_id(IDS.last)], mac.deleted
- assert_equal ["imported", "done"], Plan.read(plan.dir).contacts.map(&:status)
+ assert_equal [nil, "done"], Plan.read(plan.dir).contacts.map(&:status)
end
end
def test_a_rerun_finalizes_nothing_again
with_landed_plan do |plan, _store, client|
mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
- Finalize.call(plan, client:, mac:)
+ Finalize.call(plan, host: HOST, client:, mac:)
- result = Finalize.call(Plan.read(plan.dir), client:, mac:)
+ result = Finalize.call(Plan.read(plan.dir), host: HOST, client:, mac:)
assert_equal 0, result.done
assert_equal IDS.map { source_id(it) }, mac.deleted
diff --git a/test/pro_tacts/import/test_land.rb b/test/pro_tacts/import/test_land.rb
new file mode 100644
index 0000000..2b224fd
--- /dev/null
+++ b/test/pro_tacts/import/test_land.rb
@@ -0,0 +1,122 @@
+require_relative "../../test_helper"
+
+require "pathname"
+require "tmpdir"
+
+require "pro_tacts/import/land"
+require "pro_tacts/import/plan"
+require "pro_tacts/web"
+
+# Landing a plan's cards in the store, which is what the import screen
+# does with an upload (docs/plans/2026-09-20-import-by-upload.md). It
+# writes through Store, so these read the store back rather than a
+# response.
+class ImportLandTest < Minitest::Test
+ include ThrowawayContacts
+
+ Land = ProTacts::Import::Land
+ Plan = ProTacts::Import::Plan
+
+ IDS = %w[kmnuqmzxylru vmnlryyvktux].freeze
+
+ def with_plan(groups: [])
+ Dir.mktmpdir do |tmp|
+ dir = Pathname.new(tmp) / "plan"
+ entries = IDS.map { |id|
+ source = {"identifier" => "#{id}:ABPerson", "vcard" => "BEGIN:VCARD\r\nEND:VCARD\r\n", "note" => nil, "contact" => {}}
+ card = ProTacts::Import::Card.new(first: "Contact", last: id, nickname: nil, birthday: nil, phones: [], emails: [], addresses: [], note: nil, photo: false, groups:, source:)
+ Plan::Entry.new(id:, source_id: "#{id}:ABPerson", card:)
+ }
+ Plan.write(dir, source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries:)
+ with_contacts({}) { |store| yield Plan.read(dir), store }
+ end
+ end
+
+ def cards(plan) = IDS.to_h { [it, plan.card(it)] }
+
+ def imported_group(store) = group_named(store, "import-20260916T180412Z")
+
+ def everyone(store) = group_named(store, "sync:*")
+
+ def group_named(store, name)
+ store.all_groups.find { it.name == name }
+ end
+
+ def edit_cards(plan)
+ IDS.each do |id|
+ path = plan.dir / "cards/#{id}.yml"
+ path.write(yield(path.read))
+ end
+ end
+
+ def test_every_card_lands_in_the_plans_group
+ with_plan do |plan, store|
+ arrivals = Land.call(store, cards(plan))
+
+ IDS.each { assert_equal "Contact #{it}", store.contact(it).name }
+ assert_equal IDS.sort, imported_group(store).members.sort
+ assert_equal IDS.sort, everyone(store).members.sort
+ assert_equal [true, true], arrivals.map(&:stored)
+ assert_equal IDS, arrivals.map { it.contact.id }
+ end
+ end
+
+ def test_a_second_upload_writes_nothing_again
+ with_plan do |plan, store|
+ Land.call(store, cards(plan))
+ changes = store.changes.size
+
+ arrivals = Land.call(store, cards(plan))
+
+ assert_equal [false, false], arrivals.map(&:stored)
+ assert_equal changes, store.changes.size
+ assert_equal 1, store.all_groups.count { it.name == "import-20260916T180412Z" }
+ end
+ end
+
+ # The card on the server wins, the way the PUT's `If-None-Match: *`
+ # made it win: an upload is not an editor, and the person editing the
+ # card here would not expect a re-upload to revert them.
+ def test_a_card_already_here_is_not_written_over
+ with_plan do |plan, store|
+ Land.call(store, cards(plan))
+ edit_cards(plan) { it.sub("first: Contact", "first: Renamed") }
+
+ arrivals = Land.call(store, cards(plan))
+
+ assert_equal [false, false], arrivals.map(&:stored)
+ IDS.each { assert_equal "Contact #{it}", store.contact(it).name }
+ end
+ end
+
+ def test_a_group_already_here_is_joined_rather_than_made_again
+ with_plan do |plan, store|
+ id = store.create_group(name: "import-20260916T180412Z")
+
+ Land.call(store, cards(plan))
+
+ assert_equal id, imported_group(store).id
+ assert_equal IDS.sort, store.group(id).members.sort
+ end
+ end
+
+ def test_a_card_joins_the_groups_its_file_names_making_those_missing
+ with_plan(groups: ["family"]) do |plan, store|
+ Land.call(store, cards(plan))
+
+ assert_equal IDS.sort, group_named(store, "family").members.sort
+ assert_equal 1, store.all_groups.count { it.name == "family" }
+ end
+ end
+
+ def test_a_card_whose_file_leaves_out_everyone_is_taken_out_of_it
+ with_plan do |plan, store|
+ edit_cards(plan) { it.sub("- sync:*\n", "") }
+
+ Land.call(store, cards(plan))
+
+ assert_empty everyone(store).members
+ assert_equal IDS.sort, imported_group(store).members.sort
+ end
+ end
+end
diff --git a/test/pro_tacts/import/test_plan.rb b/test/pro_tacts/import/test_plan.rb
index 68ae2b3..f97f2e7 100644
--- a/test/pro_tacts/import/test_plan.rb
+++ b/test/pro_tacts/import/test_plan.rb
@@ -32,7 +32,6 @@ class ImportPlanTest < Minitest::Test
assert_equal "macos", plan.source
assert_equal "2026-09-16T18:04:12Z", plan.created_at
assert_equal [Plan::Contact.new(id: "kmnuqmzxylru", source_id: "kmnuqmzxylru:ABPerson", name: "Ada Lovelace", status: nil), Plan::Contact.new(id: "vmnlryyvktux", source_id: "vmnlryyvktux:ABPerson", name: "Ada Lovelace", status: nil)], plan.contacts
- assert_nil plan.host
end
end
@@ -109,13 +108,12 @@ class ImportPlanTest < Minitest::Test
%w[20260916T180412Z 20260915T090000Z].each do |stamp|
Plan.write(imports / "macos-#{stamp}", source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru")])
end
- Plan.read(imports / "macos-20260915T090000Z").record("kmnuqmzxylru", "imported")
+ Plan.read(imports / "macos-20260915T090000Z").record("kmnuqmzxylru", Plan::DONE)
plans = Plan.all(imports)
assert_equal ["macos-20260915T090000Z", "macos-20260916T180412Z"], plans.map { it.dir.basename.to_s }
- assert_equal [[], ["kmnuqmzxylru"]], plans.map { it.with_status(nil).map(&:id) }
- assert_equal [["kmnuqmzxylru"], []], plans.map { it.with_status("imported").map(&:id) }
+ assert_equal [[], ["kmnuqmzxylru"]], plans.map { it.outstanding.map(&:id) }
end
end
@@ -123,12 +121,10 @@ class ImportPlanTest < Minitest::Test
in_tmpdir do |dir|
plan = Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
- plan.host = "https://contacts"
- plan.record("kmnuqmzxylru", "landed")
+ plan.record("kmnuqmzxylru", Plan::DONE)
read = Plan.read(dir)
- assert_equal "https://contacts", read.host
- assert_equal ["landed", nil], read.contacts.map(&:status)
+ assert_equal ["done", nil], read.contacts.map(&:status)
assert_equal ["plan.yml"], dir.children.map { it.basename.to_s }.grep(/plan/)
end
end
diff --git a/test/tasks/test_import.rb b/test/tasks/test_import.rb
index e8c780a..d390cfd 100644
--- a/test/tasks/test_import.rb
+++ b/test/tasks/test_import.rb
@@ -19,26 +19,25 @@ class ImportTasksTest < Minitest::Test
root = Pathname.new(root)
plans = root / "data" / "import" / "active"
# A plan carried all the way off the Mac — done, so swept into
- # done/ rather than listed — one mid-flight with a contact through
- # to the finalizing step and one past it, and one freshly built
- # with everything to do.
+ # done/ rather than listed — one mid-flight with one contact
+ # finalized and one still to go, and one freshly built with
+ # everything to do.
through = Plan.write(plans / "macos-20260831T000000Z", source: "macos", created_at: Time.utc(2026, 8, 31), entries: [entry("qxqmqmqmqmqm"), entry("zzzzzzzzzzzz")])
through.contacts.each { it.status or through.record(it.id, Plan::DONE) }
underway = Plan.write(plans / "macos-20260901T000000Z", source: "macos", created_at: Time.utc(2026, 9, 1), entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
- underway.record("kmnuqmzxylru", "imported")
underway.record("vmnlryyvktux", Plan::DONE)
Plan.write(plans / "macos-20260916T180412Z", source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries: [entry("aaaaaaaaaaaa")])
out, = run_status(root)
- assert_includes out, "macos-20260901T000000Z 2/2 contacts\n"
- assert_includes out, "macos-20260916T180412Z 0/1 contacts\n"
- assert_includes out, "next: import:execute would land macos-20260916T180412Z\n"
+ assert_includes out, "macos-20260901T000000Z 1/2 finalized\n"
+ assert_includes out, "macos-20260916T180412Z 0/1 finalized\n"
+ assert_includes out, "next: /import would land the cards of macos-20260901T000000Z\n"
assert_includes out, "next: import:macos:finalize would finish macos-20260901T000000Z\n"
assert_includes out, "done:\nmacos-20260831T000000Z 2 contacts\n"
assert (root / "data/import/done/macos-20260831T000000Z/plan.yml").file?
refute (plans / "macos-20260831T000000Z").directory?
- refute_includes out, "macos-20260831T000000Z 2/2 contacts"
+ refute_includes out, "macos-20260831T000000Z 2/2 finalized"
end
end