import: take a .vcf, and ask what to do with what it carries
Before: importing meant rake tasks on a Mac -- a Swift helper reading
Contacts.app, a plan directory on disk with a status per contact, an
upload of that plan's cards, and a second task to delete the originals.
The reader decided what a contact could carry, and the plan was a ledger
whose only reader was the machinery that wrote it.
After: /import takes one .vcf, the file Contacts.app already exports.
The screen reads it, says how many contacts are in it, and lists every
property no screen here shows -- with how many lines wear it and a few
real values out of that very file. Each one is kept (the default, which
loses nothing), dropped, or written under the note. Confirming lands the
cards in a group named for the moment.
A card is stored as the card it arrived as, so there is no field mapping
to approve: the one real question is what to do with the properties this
address book will not display, and that is what the review screen asks.
A label Contacts hung beside a line it names goes wherever that line
goes, and under the note the label is what the value is called -- the
note reads "Spouse: Jane", not "X-ABRELATEDNAMES: Jane".
How: Import::Vcf splits the upload into cards and surveys the properties
KNOWN does not name. The file waits under data/imports between the two
requests, keyed by a minted id the review screen carries in a hidden
field, checked against ChangeId's shape before it is made into a path.
Import::Land mints an id per card, rewrites the UID to spell it, spends
the decisions, and writes through Store#put and Store#regroup. The rake
tasks, the Swift reader, the plan machinery and finalize are gone, and
config.ru no longer raises Rack's multipart part limit.
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 73d6c1e..fd173f3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -30,16 +30,6 @@ rake dev # Dev server, seeded from test/fixtures/cards into a
rake console # irb with the store open on the configured database
# (console.rb loads it), after db:dump commits a
# safety net for what the session might do there
-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
- # 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 would
- # carry the next one further
rake profile:install # Download carddav.mobileconfig from the app and
# stage it for approval
```
@@ -47,10 +37,12 @@ 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.
+Importing is not a task at all: a .vcf is uploaded at `/import` in the
+app, which reads it, asks what to do with the properties no screen here
+shows, and lands the cards through the store
+(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
@@ -70,9 +62,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/import.rb # The import screen: a .vcf, uploaded and decided
├── web/api.rb # JSON for scripts: the group list
-├── import/ # Import plans, and landing one in the store
+├── import/ # Reading an uploaded .vcf, and landing its cards
├── 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 2c434f5..d84c8ee 100644
--- a/Steepfile
+++ b/Steepfile
@@ -22,8 +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
- plan_upload upcoming_birthdays
+ group_dialog group_label groups_edit groups_index groups_show icons import_review import_upload
+ layout list_item phlex upcoming_birthdays
].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
signature "sig"
diff --git a/config.ru b/config.ru
index 965c93d..5d7615c 100644
--- a/config.ru
+++ b/config.ru
@@ -3,7 +3,6 @@
# 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")
@@ -51,17 +50,6 @@ 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-21-import-a-vcf.md b/docs/plans/2026-09-21-import-a-vcf.md
new file mode 100644
index 0000000..7e5b89c
--- /dev/null
+++ b/docs/plans/2026-09-21-import-a-vcf.md
@@ -0,0 +1,120 @@
+# Importing a .vcf
+
+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 lands the cards through the store. 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.
+
+## Why the plan machinery went
+
+Yesterday's change (`2026-09-20-import-by-upload.md`) moved the middle
+step of an import into the app and left the two ends on a Mac: a rake
+task read Contacts.app through a Swift helper and wrote a plan
+directory, the plan's cards were uploaded, and a second task went back
+to delete the originals. That kept three things alive for one import:
+
+- **A reader for one application.** `script/macos-contacts.swift` spoke
+ to the Contacts framework, and everything downstream was shaped by
+ what it chose to hand over. A contact could only carry what the
+ reader knew to ask for.
+- **A plan on disk.** Directories under `data/import`, a status per
+ contact, a format version, and the rules for moving a plan from
+ active to done — a ledger whose only reader was the machinery that
+ wrote it.
+- **A checkout on the Mac.** The repository, the gems and a Ruby, on a
+ machine whose part in the import was holding an address book.
+
+A `.vcf` replaces all three. Contacts.app exports one (File, Export,
+Export vCard), and so does every other address book worth importing
+from. The export is the reading, done by the application that owns the
+data, and the file is the whole of what has to travel.
+
+What goes with it is the delete: nothing takes the originals off the
+Mac any more. Finalize's check — ask the host, contact by contact,
+whether it has the card, then delete what it has — was the safe half of
+a two-sided sync, and there is no second side now. Deleting what you
+exported is a thing to do in Contacts.app, once you have looked at what
+landed.
+
+## Two screens, because of the question
+
+A card is stored as the card it arrived as. That is this server's whole
+posture (`2026-08-24-vcard-storage-and-groups.md`): RFC 6352 section
+6.3.2.2 requires a server to keep what it does not understand, and this
+one keeps it byte for byte. So there is no field mapping to approve on
+the way in, and nothing to confirm about N, TEL, ADR or PHOTO.
+
+There is one real question. A book exported from Contacts.app carries
+properties no screen here shows — `X-ABRELATEDNAMES`, `X-SOCIALPROFILE`,
+`X-ABADR`, `PRODID`, whatever the source thought worth writing. Kept,
+they travel with the card and come back out of it untouched; they are
+simply invisible in this app. That is the right default and it is not
+always what is wanted: some of it is the source's bookkeeping, and some
+of it is worth reading even with no field to read it in.
+
+So the importer gets three choices per unknown property:
+
+- **keep it in the card** — the default, and the one that loses nothing.
+- **drop it** — for what the source keeps and this book has no use for.
+- **write it under the note** — for a value worth reading anyway. A
+ spouse's name is worth more under the note than nowhere.
+
+The question cannot be asked until the file has been read, which is why
+this is two requests rather than one. The first stages the upload and
+surveys it; the second spends the answers. Each unknown property is
+shown with how many lines wear it and a few real values out of that very
+file, so the choice is made looking at the book's own data rather than
+at a property name.
+
+The decision is per property name, not per line: `X-SOCIALPROFILE;
+type=twitter` is not a second decision from `X-SOCIALPROFILE`. A label
+Contacts hung beside a line it names (`item3.X-ABLabel`) goes wherever
+that line goes — a label naming nothing is worse than either choice.
+Under the note, the label is what the value is called, so the note
+reads `Spouse: Jane` and not `X-ABRELATEDNAMES: Jane`.
+
+"Known" is the reader's list, not the writer's: the envelope, the fields
+a screen shows (`Contact`'s own accessors), and BDAY, which the store
+takes into the model. Adding a screen for a property is what takes it
+off the list of things to decide about.
+
+## The file waits on the server
+
+Between the two requests the bytes sit under `data/imports`, keyed by a
+minted id, and the review screen carries the id in a hidden field. Not
+the file: a book with pictures in it is tens of megabytes, and a form
+that carries it back to the server is the upload done twice. An id off a
+form is checked against the shape `ChangeId.mint` draws before it is
+made into a path — `../../contacts.db` is a filename too. The file goes
+the moment its cards land, and anything older than an hour is swept on
+the next upload.
+
+Rack's 128-part multipart limit is no longer raised in `config.ru`. A
+plan was one part per card; a `.vcf` is one part.
+
+## Landing
+
+`Import::Land` 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:
+
+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
+ spellings of its identity is what `rake contacts:reid` exists to
+ undo.
+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 the import's group, made if it is missing. The
+ group is named `import-<timestamp>` by default and can be renamed or
+ emptied on the review screen: what landed together can be found
+ together, and undone together.
+
+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 land, and
+under what group name, before any of them do.
diff --git a/lib/pro_tacts/admin/import_review.rb b/lib/pro_tacts/admin/import_review.rb
new file mode 100644
index 0000000..67a6d21
--- /dev/null
+++ b/lib/pro_tacts/admin/import_review.rb
@@ -0,0 +1,134 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/layout"
+require "pro_tacts/import/vcf"
+
+module ProTacts
+ module Admin
+ # The second screen of an import: what the uploaded file holds, and
+ # the one decision it asks the importer to make
+ # (docs/plans/2026-09-21-import-a-vcf.md).
+ #
+ # A card is stored as the card it arrived as, so nothing here is a
+ # field-by-field mapping to approve. The only question is about the
+ # properties no screen in this address book shows: keep them (they
+ # are served back untouched, just invisible here), drop them, or
+ # write them under the note where they can at least be read. Keep
+ # is checked, because it is the choice that loses nothing.
+ #
+ # Each property is shown with how many lines wear it and a few real
+ # values out of this very file, so the choice is made looking at
+ # the book's own data rather than at a property name.
+ #
+ # `upload` is the staged file (Import::Staged), carried in a hidden
+ # field: the bytes stay on the server, and only the ticket to them
+ # makes the round trip.
+ class ImportReview < Phlex::HTML
+ # @rbs @upload: String
+ # @rbs @file: String
+ # @rbs @cards: Integer
+ # @rbs @unknown: Array[::ProTacts::Import::Vcf::Unknown]
+ # @rbs @group: String
+
+ # What each choice is called where it is made. The wording says
+ # what happens to the line, not what the server does with it: an
+ # importer is deciding about their own address book, not about
+ # RFC 6352's rule for a property a server does not understand.
+ WORDING = {
+ ::ProTacts::Import::Vcf::KEEP => "keep it in the card",
+ ::ProTacts::Import::Vcf::DROP => "drop it",
+ ::ProTacts::Import::Vcf::NOTE => "write it under the note",
+ }.freeze #: Hash[String, String]
+
+ #: (upload: String, file: String, cards: Integer, unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String) -> void
+ def initialize(upload:, file:, cards:, unknown:, group:)
+ @upload = upload
+ @file = file
+ @cards = cards
+ @unknown = unknown
+ @group = group
+ end
+
+ def view_template
+ render Layout.new(title: "Import") do
+ div(class: "record") do
+ div(class: "record-nav") do
+ a(href: "/import", class: "type-label") { "‹ another file" }
+ end
+ div(class: "card") do
+ div(class: "card-body") do
+ h1(class: "type-h2", style: "margin: 0;") { @file }
+ p(class: "type-body-sm") { "#{count(@cards, "contact")}, ready to land." }
+ form(action: "/import/land", method: "post", class: "field-stack") do
+ input(type: "hidden", name: "upload", value: @upload)
+ group_field
+ unknown_fields
+ button(type: "submit", data: {variant: "primary"}) { "import #{count(@cards, "contact")}" }
+ end
+ end
+ end
+ end
+ end
+ end
+
+ private
+
+ # The group everything lands in, named for the moment by default
+ # so one import can be found — or undone — apart from the next.
+ # Editable, and emptiable: a blank name puts the arrivals in
+ # nobody's group but everyone's book.
+ #: () -> void
+ def group_field
+ label(class: "field") do
+ span(class: "type-label") { "group" }
+ input(type: "text", name: "group", value: @group, placeholder: "no group")
+ end
+ end
+
+ #: () -> void
+ def unknown_fields
+ if @unknown.empty?
+ p(class: "type-body-sm gl-muted") do
+ "Every property in this file is one this address book reads. Nothing to decide."
+ end
+ return
+ end
+
+ p(class: "type-body-sm") do
+ "#{count(@unknown.length, "property", "properties")} here " \
+ "#{@unknown.length == 1 ? "is one" : "are ones"} " \
+ "no screen in this address book shows. Kept, they travel with the card and come back out " \
+ "of it untouched; they are just not on display anywhere."
+ end
+ @unknown.each { unknown_field(it) }
+ end
+
+ #: (::ProTacts::Import::Vcf::Unknown property) -> void
+ def unknown_field(property)
+ div(class: "field") do
+ span(class: "type-label") { "#{property.name} (#{count(property.count, "line")})" }
+ ul(class: "type-body-sm gl-muted", style: "margin: 0; padding-left: 1.25em;") do
+ property.examples.each { |example| li { example } }
+ end
+ div(class: "field-stack", role: "radiogroup", aria_label: property.name) do
+ ::ProTacts::Import::Vcf::CHOICES.each do |choice|
+ # A label wrapping its own radio is Gloss's Radio.
+ label do
+ input(type: "radio", name: "decide[#{property.name}]", value: choice,
+ checked: choice == ::ProTacts::Import::Vcf::KEEP)
+ span { WORDING.fetch(choice) }
+ end
+ end
+ end
+ end
+ end
+
+ # A count with its noun, the plural spelled out where an "s" on
+ # the end will not do it.
+ #: (Integer number, String noun, ?String? plural) -> String
+ def count(number, noun, plural = nil)
+ number == 1 ? "#{number} #{noun}" : "#{number} #{plural || "#{noun}s"}"
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/import_upload.rb b/lib/pro_tacts/admin/import_upload.rb
new file mode 100644
index 0000000..2c4a9c0
--- /dev/null
+++ b/lib/pro_tacts/admin/import_upload.rb
@@ -0,0 +1,92 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/avatar"
+require "pro_tacts/admin/format"
+require "pro_tacts/admin/layout"
+require "pro_tacts/admin/list_item"
+
+module ProTacts
+ module Admin
+ # GET /import — the screen an address book arrives on, and the page
+ # a landing 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.
+ #
+ # The form takes one .vcf and nothing else. What it does not ask
+ # for here is what to do with the properties this address book does
+ # not read — that question needs the file open to be worth asking,
+ # so it is the next screen's (ImportReview).
+ #
+ # `landed` is the import that just happened, rendered below the
+ # form that is still there to run another.
+ class ImportUpload < Phlex::HTML
+ # @rbs @landed: Array[Contact]?
+ # @rbs @notice: String?
+
+ #: (?landed: Array[Contact]?, ?notice: String?) -> void
+ def initialize(landed: nil, notice: nil)
+ @landed = landed
+ @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
+ landed_list if @landed
+ 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 contacts" }
+ p(class: "type-body-sm") do
+ "Choose a vCard file — Contacts exports one with File, Export, Export vCard. " \
+ "The next screen says what is in it before anything lands."
+ end
+ form(action: "/import", method: "post", enctype: "multipart/form-data", class: "field-stack") do
+ label(class: "field") do
+ span(class: "type-label") { "vcard file" }
+ input(type: "file", name: "vcf", accept: ".vcf,text/vcard")
+ end
+ button(type: "submit", data: {variant: "primary"}) { "read the file" }
+ end
+ end
+ end
+ end
+
+ # What landed, contact by contact, each linked to the record it
+ # became — so the next thing to do, reading one or fixing one, is
+ # a click away.
+ #: () -> void
+ def landed_list
+ landed = @landed or return
+
+ section do
+ div(class: "section-head") do
+ h2(class: "type-label") { "landed (#{landed.length})" }
+ end
+ ul(class: "card") do
+ landed.each do |contact|
+ render ListItem.new(
+ href: "/contacts/#{contact.id}",
+ avatar: Avatar.new(contact:, size: "lg"),
+ ) do
+ div { Format.name_label(contact) }
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index 0e26368..de9376c 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -121,9 +121,9 @@ module ProTacts
# 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. 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).
+ # here for the same reason and is rarer still: an address
+ # book arrives a handful of times in a book's life
+ # (docs/plans/2026-09-21-import-a-vcf.md).
footer(class: "admin-footer") do
version = ProTacts.config.version
span(class: "type-label") { version } if version
diff --git a/lib/pro_tacts/admin/plan_upload.rb b/lib/pro_tacts/admin/plan_upload.rb
deleted file mode 100644
index 5c609fd..0000000
--- a/lib/pro_tacts/admin/plan_upload.rb
+++ /dev/null
@@ -1,102 +0,0 @@
-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
deleted file mode 100644
index b92ccfd..0000000
--- a/lib/pro_tacts/import/card.rb
+++ /dev/null
@@ -1,206 +0,0 @@
-require "base64"
-require "yaml"
-
-require "pro_tacts/admin/card_form"
-require "pro_tacts/birthday"
-require "pro_tacts/contact"
-require "pro_tacts/vcard"
-
-module ProTacts
- module Import
- # One contact in a plan, as the file a person edits before execute:
- # the fields the web editor has, and the groups it belongs to
- # (docs/plans/2026-09-16-importing-from-macos.md, "A card is a form
- # filled in"). Signed in sig/pro_tacts/import.rbs, being a Data class.
- # @rbs skip
- Card = Data.define(:first, :last, :nickname, :birthday, :phones, :emails, :addresses, :note, :photo, :groups, :source)
-
- # Reopened for the reason VCard::Parser::Property is.
- class Card
- # A card file that will not read as one.
- class Invalid < StandardError; end
-
- KEYS = %w[first last nickname birthday phones emails addresses note photo groups source].freeze #: Array[String]
-
- # What the source gave, kept in the card rather than off to the
- # side: the fields above are read out of it, and `finalize`
- # compares the Mac's contact against it before deleting anything,
- # so the two are read together and edited in one place.
- SOURCE_KEYS = %w[identifier vcard note contact].freeze #: Array[String]
-
- # A phone's parts: a number, and the label the source card's
- # X-ABLabel gave the row, left out rather than written empty
- # (docs/plans/2026-09-18-phone-labels.md, "The plan card carries
- # one").
- PHONE_KEYS = %w[number label].freeze #: Array[String]
-
- BIRTHDAY = /\A(\d{4})-(\d{2})-(\d{2})\z/ #: Regexp
-
- # A picture's format as `PHOTO`'s TYPE names it, read off the
- # decoded bytes the way Contact#photo reads one back: the magic
- # 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.
- #
- # `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
- invalid.("has no #{missing.join(", ")}") unless missing.empty?
- unknown = document.keys - KEYS
- invalid.("has #{unknown.join(", ")}, which no field takes") unless unknown.empty?
-
- first, last, nickname, birthday, phones, emails, addresses, note, photo, groups, source = document.values_at(*KEYS)
- invalid.("first and last must be text; quote them") unless first.is_a?(String) && last.is_a?(String)
- invalid.("needs a first or last name") if first.strip.empty? && last.strip.empty?
- invalid.("nickname must be text, or blank") unless nickname.nil? || nickname.is_a?(String)
- invalid.("birthday must be a quoted YYYY-MM-DD, or blank") unless birthday.nil? || birthday.is_a?(String) && birthday.match?(BIRTHDAY)
- invalid.("phones must be a list, each with a quoted number and an optional label") unless phones.is_a?(Array) && phones.all? { phone?(it) }
- invalid.("emails must be a list of addresses") unless texts?(emails)
- addresses.is_a?(Array) && addresses.all? { address?(it) } or
- invalid.("addresses must be a list, each with any of #{Contact::ADDRESS_COMPONENTS.join(", ")}")
- invalid.("groups must be a list of names") unless texts?(groups) && groups.none? { it.strip.empty? }
- invalid.("note must be text, or blank") unless note.nil? || note.is_a?(String)
- invalid.("photo must be true or false") unless [true, false].include?(photo)
- invalid.("source must hold #{SOURCE_KEYS.join(", ")}") unless source?(source)
- card = new(first:, last:, nickname:, birthday:, phones:, emails:, addresses:, note:, photo:, groups:, source:)
- invalid.("photo is true and the source holds no picture this server knows") if photo && card.picture.nil?
-
- card
- rescue Psych::DisallowedClass => error
- raise Invalid, "#{name}: #{error.message.sub(/\ATried to load unspecified class: /, "reads as a ")}; quote it"
- end
-
- #: (untyped value) -> bool
- def self.texts?(value)
- value.is_a?(Array) && value.all?(String)
- end
-
- #: (untyped value) -> bool
- def self.address?(value)
- value.is_a?(Hash) && (value.keys - Contact::ADDRESS_COMPONENTS).empty? &&
- value.values.all?(String) && value.values.any? { !it.strip.empty? }
- end
-
- # The shape Macos.entry writes and no other: a number is the one
- # fact a phone cannot lack, and a label written at all cannot be
- # blank — the builder leaves it out rather than writing it empty.
- #: (untyped value) -> bool
- def self.phone?(value)
- value.is_a?(Hash) && (value.keys - PHONE_KEYS).empty? && value.values.all?(String) &&
- !value.fetch("number", "").strip.empty? &&
- (value["label"].nil? || !value["label"].strip.empty?)
- end
-
- #: (untyped value) -> bool
- def self.source?(value)
- value.is_a?(Hash) && value.keys.sort == SOURCE_KEYS.sort &&
- value.fetch("identifier").is_a?(String) && value.fetch("vcard").is_a?(String) &&
- [NilClass, String].include?(value.fetch("note").class) && value.fetch("contact").is_a?(Hash)
- end
-
- private_class_method :texts?, :address?, :phone?, :source?
-
- # The contact the web create and then the editor's add rows would
- # make of these fields. The birthday rides beside the card as the
- # store keeps it, and Contact composes the line back in
- # (docs/plans/2026-09-11-every-birthday-in-the-model.md).
- # No middle name: a plan's card holds first and last, and a source
- # N carrying anything past them is refused rather than imported
- # (Macos.name), so there is none here to write.
- #: (String id) -> Contact
- def contact(id)
- created = Contact.new(id:, stored: Admin::CardForm.new_card(id, first, "", last), birthday: nil, inherited: [])
- stored = Admin::CardForm.contact_card(created, first, "", last, {
- "nickname" => nickname.to_s, "note" => note.to_s,
- "new_phone" => phones.filter_map { it["label"] ? nil : it.fetch("number") },
- "new_email" => emails, "new_address" => addresses
- })
- # The picture and a labeled phone are written here for the same
- # reason: no form has a field for either. A label is two lines
- # under one property group, the shape macOS keeps
- # (docs/plans/2026-09-18-phone-labels.md, "The plan card carries
- # one"), the numbers counting up from one on a card built from
- # nothing — finding a free number in a card that already has
- # groups is the editor's problem, and waits for it.
- stored = stored.insert([picture, *labeled_phone_lines].compact)
- Contact.new(id:, stored:, birthday: born_on, inherited: [])
- end
-
- # The labeled phones as their grouped lines: the number and its
- # X-ABLabel under one `item` prefix each, escaped as text is.
- #: () -> Array[String]
- def labeled_phone_lines
- phones.select { it["label"] }.each.with_index(1).flat_map { |phone, index|
- group = "item#{index}"
- ["#{group}.TEL:#{VCard.escape(phone.fetch("number"))}\r\n",
- "#{group}.X-ABLabel:#{VCard.escape(phone.fetch("label"))}\r\n"]
- }
- end
-
- # The source's picture as a PHOTO line, or nil for a card that
- # carries none and for data that decodes to no format the server
- # knows — which Card.read refuses rather than dropping. The
- # thumbnail stands in when the framework gave no full image, which
- # is the only picture some contacts have
- # (docs/plans/2026-09-16-importing-from-macos.md, "Reading the
- # Mac").
- #: () -> String?
- def picture
- return unless photo
-
- contact = source.fetch("contact") #: untyped
- photo_line(contact["imageData"]) || photo_line(contact["thumbnailImageData"])
- end
-
- # One picture as a PHOTO line, or nil for data that is not text,
- # not base64, or no format the server knows.
- #: (untyped data) -> String?
- def photo_line(data)
- return unless data.is_a?(String)
-
- bytes = begin
- Base64.strict_decode64(data)
- rescue ArgumentError
- return
- end
- signature = Contact::PHOTO_SIGNATURES.find { bytes.start_with?(it.first) }
- return if signature.nil?
-
- "PHOTO;ENCODING=b;TYPE=#{PHOTO_TYPES.fetch(signature.last)}:#{data}\r\n"
- end
-
- #: () -> Birthday?
- def born_on
- captures = birthday&.match(BIRTHDAY)&.captures
- return if captures.nil?
-
- year, month, day = captures
- Birthday.new(year: Integer(year), month: Integer(month), day: Integer(day))
- end
-
- #: () -> Hash[String, untyped]
- def document
- KEYS.zip([first, last, nickname, birthday, phones, emails, addresses, note, photo, groups, source]).to_h
- end
- end
- end
-end
diff --git a/lib/pro_tacts/import/config.rb b/lib/pro_tacts/import/config.rb
deleted file mode 100644
index 13a9128..0000000
--- a/lib/pro_tacts/import/config.rb
+++ /dev/null
@@ -1,61 +0,0 @@
-require "pathname"
-require "uri"
-require "yaml"
-
-module ProTacts
- module Import
- # The import tasks' world under data/import: where everything
- # lives — the paths below — and the standing data kept between
- # plans, read from config.yml as the members of the Data. Not
- # committed — /data is ignored — because it holds this
- # deployment's own host and choices. Each key of the file is a
- # member, grown one at a time as the tasks take standing data off
- # the command line.
- # @rbs skip
- class Config < Data.define(:host)
- # The root of the import collateral: the standing data in FILE,
- # the plans in flight under ACTIVE, filed away under DONE once
- # every contact is off this Mac — the two states a plan's
- # directory names, beside the one file that outlives them.
- ROOT = Pathname.new("data/import")
- FILE = ROOT / "config.yml"
- ACTIVE = ROOT / "active"
- DONE = ROOT / "done"
-
- # Every problem is refused rather than skipped, naming the file —
- # Card#read's rules for a hand-edited file. The host is required
- # rather than nil-able, and parsed: a bare hostname is the base
- # URL of a server that serves HTTPS, which every deployment
- # does, so the scheme is spelled out — the host a plan records
- # is the one a second run is compared against — and anything
- # that is not an http or https URL is refused.
- def self.read(path = FILE)
- invalid = ->(why) { raise ArgumentError, "#{path}: #{why}" }
- invalid.("does not exist") unless path.file?
-
- document = YAML.safe_load_file(path)
- invalid.("has no host") if document.nil?
- invalid.("is not a mapping") unless document.is_a?(Hash)
- unknown = document.keys - members.map(&:to_s)
- invalid.("has #{unknown.join(", ")}, which no member takes") unless unknown.empty?
-
- host = document.fetch("host", nil)
- invalid.("has no host") if host.nil?
- invalid.("host must be text") unless host.is_a?(String)
- invalid.("host is blank") if host.strip.empty?
-
- begin
- uri = URI.parse(host)
- uri = URI.parse("https://#{uri}") if uri.scheme.nil?
- rescue URI::InvalidURIError
- invalid.("host does not read as a URL")
- end
- unless uri.is_a?(URI::HTTP)
- raise ArgumentError, "#{path}: host must be an http or https URL, such as https://contacts"
- end
-
- new(host: uri)
- end
- end
- end
-end
diff --git a/lib/pro_tacts/import/finalize.rb b/lib/pro_tacts/import/finalize.rb
deleted file mode 100644
index 62f0194..0000000
--- a/lib/pro_tacts/import/finalize.rb
+++ /dev/null
@@ -1,113 +0,0 @@
-require "pro_tacts/import/plan"
-
-module ProTacts
- module Import
- # Finalizes a plan's contacts off this Mac, once they have landed
- # on a host (docs/plans/2026-09-16-importing-from-macos.md,
- # "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
-
- class Failed < StandardError; end
-
- # What a run did: how many contacts it took off the Mac, and the
- # ones it left there, each as its plan id, the name the plan recorded
- # and why it stayed. Both, since the person reading has a contact to
- # find in Contacts and a line to find in plan.yml. Signed in
- # sig/pro_tacts/import.rbs, being a Data class.
- # @rbs skip
- Result = Data.define(:done, :kept)
-
- #: (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, 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
- # 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) }
-
- kept = [] #: Array[[String, String, String]]
- going = still_there.select { |contact|
- why = keep(contact, records.fetch(contact.source_id))
- kept << [contact.id, contact.name, why] if why
- why.nil?
- }
- # One call for the batch, since each one starts the script again,
- # and one save request per contact inside it, so a contact the
- # store refuses is the only one that stays. A run that dies before
- # the plan records the rest finds them gone next time.
- refused = @mac.delete(going.map(&:source_id))
- deleted, stuck = going.partition { !refused.key?(it.source_id) }
- deleted.each { @plan.record(it.id, Plan::DONE) }
- stuck.each {
- kept << [it.id, it.name, "this Mac would not delete #{it.source_id}: #{refused.fetch(it.source_id)}"]
- }
-
- Result.new(done: gone.size + deleted.size, kept:)
- end
-
- private
-
- # Why this contact stays on the Mac, or nil for one to delete. The
- # comparison is exact because CNContact carries no modification
- # date: a contact Contacts.app rewrote on its own is kept, which is
- # the safe direction to be wrong in.
- #: (Plan::Contact contact, Hash[String, untyped] record) -> String?
- def keep(contact, record)
- source = @plan.card(contact.id).source
- if !on_host?(contact.id)
- "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")
- "its note has changed on this Mac since the plan"
- end
- end
-
- # Asked of the card browser rather than of `/dav/addressbook`,
- # which serves the asking user's book alone: a card in no `sync:`
- # group is on the host and outside every book, and the question
- # here is whether the host still has it at all
- # (docs/plans/2026-09-12-per-user-books.md).
- #: (String id) -> bool
- def on_host?(id)
- response = @client.call("GET", "/contacts/#{id}")
- case response.status
- when 200 then true
- when 404 then false
- else raise Failed, "GET of /contacts/#{id} answered #{response.status}: #{response.body}"
- end
- end
- end
- end
-end
diff --git a/lib/pro_tacts/import/http_client.rb b/lib/pro_tacts/import/http_client.rb
deleted file mode 100644
index 78db5e9..0000000
--- a/lib/pro_tacts/import/http_client.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-require "net/http"
-
-module ProTacts
- module Import
- # 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?) -> Response
- def call(method, path, headers: {}, body: nil)
- response = @http.send_request(method, path, body, headers)
- Response.new(status: response.code.to_i, headers: response.to_hash.transform_values { it.join(", ") }, body: response.body.to_s)
- end
- end
- end
-end
diff --git a/lib/pro_tacts/import/land.rb b/lib/pro_tacts/import/land.rb
index 054d373..80b2665 100644
--- a/lib/pro_tacts/import/land.rb
+++ b/lib/pro_tacts/import/land.rb
@@ -1,92 +1,211 @@
-require "pro_tacts/import/card"
-require "pro_tacts/import/plan"
+require "pro_tacts/change_id"
+require "pro_tacts/import/vcf"
+require "pro_tacts/vcard"
+require "pro_tacts/vcard/parser"
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.
+ # Lands an uploaded .vcf's cards in this server's store, with the
+ # importer's decisions applied on the way in
+ # (docs/plans/2026-09-21-import-a-vcf.md).
#
- # 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.
+ # A card arrives a card and is stored as one: nothing here rebuilds
+ # it out of fields, so a property this address book has never heard
+ # of is kept by default and served back byte for byte (RFC 6352
+ # section 6.3.2.2, and VCard's whole posture). What the review
+ # screen collects is the exception to that — the handful of
+ # properties the importer would rather drop, or read under the
+ # note — and this is where those are spent.
+ #
+ # It writes through Store#put and Store#regroup rather than through
+ # the routes those sit behind, so 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.
+ #
+ # 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 land before any of them do.
class Land
# @rbs @store: Store
+ # @rbs @decisions: Hash[String, String]
+ # @rbs @group: String?
# @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)
+ # The group an import offers to put its arrivals in, named for
+ # when it happened: what landed together can be found together,
+ # and undone together, without anything having to be recorded
+ # about the file it came from.
+ #: (?Time now) -> String
+ def self.default_group(now = Time.now)
+ "import-#{now.utc.strftime("%Y%m%dT%H%M%SZ")}"
+ end
+
+ # `decisions` is Vcf::CHOICES by property name, upper case, as
+ # Vcf#unknown names them; a property missing from it is kept.
+ # `group` is the group every arrival joins, or none.
+ #: (Store store, Array[VCard] cards, decisions: Hash[String, String], ?group: String?) -> Array[Contact]
+ def self.call(store, cards, decisions:, group: nil)
+ new(store, decisions:, group:).call(cards)
end
- #: (Store store) -> void
- def initialize(store)
+ #: (Store store, decisions: Hash[String, String], ?group: String?) -> void
+ def initialize(store, decisions:, group: nil)
@store = store
+ @decisions = decisions
+ @group = group
@group_ids = {} #: Hash[String, String]
end
- #: (Hash[String, Card] cards) -> Array[Arrival]
+ #: (Array[VCard] cards) -> Array[Contact]
def call(cards)
- cards.map { |id, card| land(id, card) }
+ cards.map { land(it) }
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?
+ # A card, then its group, the order a create writes them in: the
+ # put makes the contact, and the membership that follows puts it
+ # somewhere to be found.
+ #: (VCard card) -> Contact
+ def land(card)
+ 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).
- # 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
+ @store.put(id, identified(decide(card), id), client: true)
+
+ group = @group
+ @store.regroup(id, join: [group_id(group)], leave: []) if group
+
+ # Read back rather than kept from the put, because the group
# 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:)
+ @store.contact(id) || raise("#{id} was landed and is not stored")
+ end
+
+ # 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
+ # contacts:reid exists to restore: a UID from the book this file
+ # was exported from names a record on some other server, and
+ # keeping it would leave a contact with two spellings of who it
+ # is.
+ #: (VCard card, String id) -> VCard
+ def identified(card, id)
+ _, rest = card.extract("UID")
+ rest.insert(["UID:#{id}\r\n"])
+ end
+
+ # The card with the importer's decisions spent on it: the lines
+ # they dropped gone, the lines they sent to the note gone from
+ # where they were and written under it, and everything else —
+ # every known property, and every unknown one they kept — exactly
+ # as it arrived, bytes and all.
+ #: (VCard card) -> VCard
+ def decide(card)
+ lines = card.lines
+ orphaned = orphaned_groups(lines)
+ notes = [] #: Array[String]
+ kept = [] #: Array[String]
+
+ lines.each do |line|
+ property = line.property
+ # A blank line, or one that would not read: there is no field
+ # to have made a decision about, and the bytes travel
+ # (VCard::Parser's own posture).
+ if property.nil?
+ kept << line.verbatim
+ next
+ end
+
+ choice = choice_for(property)
+ notes << noted(property, lines) if choice == Vcf::NOTE
+ next unless choice == Vcf::KEEP
+ # A label whose line is gone names nothing, so it goes too.
+ next if orphaned.include?(property.group)
+
+ kept << line.verbatim
+ end
+
+ noted_into(VCard.new(kept.join), notes)
+ end
+
+ # What the importer asked for this property. Anything they were
+ # not asked about — every known property — is kept, which is also
+ # what an unknown one gets when the form sent no answer for it
+ # (Web#decisions_in).
+ #: (VCard::Parser::Property property) -> String
+ def choice_for(property)
+ @decisions.fetch(property.name.upcase, Vcf::KEEP)
+ end
+
+ # The group prefixes (`item1.`) whose every line but the label is
+ # leaving. Apple hangs a label off the line it names rather than
+ # inside it, so dropping `item3.X-ABRELATEDNAMES` without this
+ # would leave `item3.X-ABLabel:_$!<Spouse>!$_` behind, naming a
+ # line that is no longer there.
+ #: (Array[VCard::Parser::Line] lines) -> Array[String]
+ def orphaned_groups(lines)
+ anchored = {} #: Hash[String, bool]
+ lines.each do |line|
+ property = line.property
+ next if property.nil?
+
+ group = property.group
+ next if group.nil? || property.name.casecmp?(Vcf::LABEL)
+
+ anchored[group] = anchored.fetch(group, false) || choice_for(property) == Vcf::KEEP
+ end
+ anchored.reject { |_group, kept| kept }.keys
+ end
+
+ # One line as a note reads it: what it was called, then what it
+ # said.
+ #: (VCard::Parser::Property property, Array[VCard::Parser::Line] lines) -> String
+ def noted(property, lines)
+ "#{label_for(property, lines)}: #{property.text.strip}"
+ end
+
+ # What to call a value under the note: the row's own label when
+ # Contacts wrote one beside it, unwrapped the way every other
+ # reader here unwraps one (VCard.unwrap), and the property's name
+ # when there is none. "Spouse: Jane" is the point of the choice;
+ # "X-ABRELATEDNAMES: Jane" would be a property name in the one
+ # place nobody wanted to read one.
+ #: (VCard::Parser::Property property, Array[VCard::Parser::Line] lines) -> String
+ def label_for(property, lines)
+ group = property.group
+ label = group && lines.filter_map { it.property }
+ .find { it.group == group && it.name.casecmp?(Vcf::LABEL) }
+ label ? VCard.unwrap(label.text) : property.name
+ end
+
+ # The noted values under the note the card already carries, one
+ # to a line. Read, joined, and re-escaped rather than spliced: a
+ # NOTE's value is escaped text (RFC 2426 section 3.6.2), so the
+ # line break between two of them is a `\n` in the value and not a
+ # line break in the card.
+ #: (VCard card, Array[String] notes) -> VCard
+ def noted_into(card, notes)
+ return card if notes.empty?
+
+ existing = card.properties.find { it.name.casecmp?("NOTE") }
+ text = [existing&.text, *notes].compact.reject(&:empty?).join("\n")
+ card.replace("NOTE", ["NOTE:#{VCard.escape(text)}\r\n"])
end
- # Looked up before it is made, so a plan naming a group this
+ # 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) — Execute's own
- # shape, and for its reason.
+ # its name (db/migrations/008_group_names.rb).
#
# 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 a plan of four hundred cards under three group
- # names reads the list three times.
+ # Memoized, so four hundred cards under one group name read the
+ # list once.
#: (String name) -> String
def group_id(name)
@group_ids[name] ||= @store.all_groups.find { it.name == name }&.id || @store.create_group(name:)
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
deleted file mode 100644
index 23c5906..0000000
--- a/lib/pro_tacts/import/macos.rb
+++ /dev/null
@@ -1,420 +0,0 @@
-require "json"
-require "open3"
-require "pathname"
-
-require "pro_tacts/change_id"
-require "pro_tacts/contact"
-require "pro_tacts/import/card"
-require "pro_tacts/import/plan"
-require "pro_tacts/vcard/parser"
-
-module ProTacts
- module Import
- # A plan from this Mac's iCloud contacts, read by
- # script/macos-contacts.swift. The builder knows only the fields it has
- # been taught, and refuses the rest
- # (docs/plans/2026-09-16-importing-from-macos.md, "The builder knows
- # only what it was taught").
- module Macos
- SCRIPT = Pathname.new(__FILE__).dirname.join("../../../script/macos-contacts.swift").expand_path #: Pathname
-
- # Every field no branch handles, across every contact read, so one
- # build names all of them.
- class Unknown < StandardError
- # @rbs @seen: Hash[String, Array[[String, String]]]
-
- EXAMPLES = 3 #: Integer
-
- # Long enough for a line's parameters, short of a photo's payload.
- EXAMPLE_LENGTH = 100 #: Integer
-
- #: () -> void
- def initialize
- @seen = Hash.new { |seen, field| seen[field] = [] }
- super
- end
-
- #: (String field, String source_id, String example) -> void
- def add(field, source_id, example)
- @seen[field] << [source_id, example]
- end
-
- #: () -> bool
- def any? = !@seen.empty?
-
- #: () -> String
- def message
- lines = @seen.sort.flat_map do |field, seen|
- examples = seen.uniq(&:last).first(EXAMPLES).map do |source_id, example|
- " #{source_id}: #{truncate(example).inspect}"
- end
- [" #{field}: #{seen.size}", *examples]
- end
- "no branch handles these fields, so no plan was written:\n#{lines.join("\n")}"
- end
-
- private
-
- #: (String example) -> String
- def truncate(example)
- return example if example.length <= EXAMPLE_LENGTH
-
- "#{example[0, EXAMPLE_LENGTH]}… (#{example.length} characters)"
- end
- end
-
- # The reader's output, one parsed object per contact.
- #: (limit: Integer?) -> Array[Hash[String, untyped]]
- def self.read(limit:)
- run("read", *[limit&.to_s].compact)
- end
-
- # This Mac as `Finalize` speaks to it: the contacts it still
- # has, by source id, and the delete that takes them off it.
- module Mac
- #: (Array[String] source_ids) -> Hash[String, untyped]
- def self.show(source_ids)
- return {} if source_ids.empty?
-
- Macos.run("show", *source_ids).to_h {
- [it.fetch("identifier"), it] #: [String, untyped]
- }
- end
-
- # The contacts this Mac would not delete, each to what it said;
- # empty when every one of them left.
- #: (Array[String] source_ids) -> Hash[String, String]
- def self.delete(source_ids)
- return {} if source_ids.empty?
-
- Macos.run("delete", *source_ids).reject { it.fetch("deleted") }.to_h {
- [it.fetch("identifier"), it.fetch("error")] #: [String, String]
- }
- end
- end
-
- #: (*String arguments) -> Array[Hash[String, untyped]]
- def self.run(*arguments)
- output, status = Open3.capture2("swift", SCRIPT.to_s, *arguments)
- raise "#{SCRIPT.basename} #{arguments.first} exited #{status.exitstatus}" unless status.success?
-
- output.each_line.map { JSON.parse(it) }
- end
-
- #: (Pathname dir, Array[Hash[String, untyped]] records, created_at: Time) -> Plan
- def self.plan(dir, records, created_at:)
- unknown = Unknown.new
- entries = records.map { entry(it, unknown) }
- raise unknown if unknown.any?
-
- Plan.write(dir, source: "macos", created_at:, entries:)
- end
-
- #: (Hash[String, untyped] record, Unknown unknown) -> Plan::Entry
- def self.entry(record, unknown)
- id = ChangeId.mint(12)
- source_id = record.fetch("identifier") #: String
- vcard = record.fetch("vcard") #: String
- note = record.fetch("note") #: String?
- contact = record.fetch("contact") #: Hash[String, untyped]
-
- lines = VCard::Parser.lines(vcard)
- grouped = grouped(lines)
- carried = lines.select { carried?(it, grouped, source_id, unknown) }
- first, last = name(carried, source_id, unknown)
- nickname = only(carried, "NICKNAME", source_id, unknown)
- birthday = only(carried, "BDAY", source_id, unknown)
- maiden_name = only(carried, "X-MAIDENNAME", source_id, unknown)
- labels = labels_by_group(lines)
- phones = properties(carried, "TEL").filter_map { |property|
- number = property.text
- # A number the card left empty is a row Contacts kept the
- # label of and nothing else, and an empty add row inserts
- # nothing (Admin::CardForm).
- next if number.empty?
-
- label = labels[property.group]
- label ? {"number" => number, "label" => label} : {"number" => number} #: Hash[String, String]
- }
- emails = values(carried, "EMAIL")
- addresses = properties(carried, "ADR").map { address(it) }
- source = {"identifier" => source_id, "vcard" => vcard, "note" => note, "contact" => contact}
- # Beyond the vCard, and carried from the source the card holds:
- # the note AppleScript gave up, and the picture the serializer
- # leaves out. A contact whose picture came back only as a
- # thumbnail still has one.
- photo = !contact.fetch("imageData").nil? || !contact.fetch("thumbnailImageData").nil?
- card = Card.new(
- first:, last:, nickname:, birthday:, phones:, emails:, addresses:,
- note: noted(note, maiden_name, related_names(lines, carried)), photo:, groups: [], source:
- )
- Plan::Entry.new(id:, source_id:, card:)
- end
-
- # The note, with the maiden name and the people this contact is
- # related to written under it: this address book has no field for
- # either, and a name worth keeping is worth more in the note than
- # nowhere (the related names' reasoning,
- # docs/plans/2026-09-16-importing-from-macos.md, "The builder
- # knows only what it was taught").
- #: (String? note, String? maiden, Array[String] related) -> String?
- def self.noted(note, maiden, related)
- under = [maiden&.then { "Maiden name: #{it}" }, *related].compact
- return note if under.empty?
-
- [note, under.join("\n")].compact.reject(&:empty?).join("\n\n")
- end
-
- # Each related name as "<label>: <name>", the label being the
- # X-ABLabel sharing its group — Apple's own labels arrive wrapped
- # (`_$!<Spouse>!$_`) and a person's own arrives as they typed it.
- # The labels are read off every line rather than the carried ones,
- # a label being dropped with the group it annotates.
- #: (Array[VCard::Parser::Line] lines, Array[VCard::Parser::Line] carried) -> Array[String]
- def self.related_names(lines, carried)
- labels = labels_by_group(lines)
- properties(carried, "X-ABRELATEDNAMES").map {
- label = labels[it.group]
- label ? "#{label}: #{it.text}" : it.text
- }
- end
-
- # Each `item` group to the label its X-ABLabel gives it, unescaped
- # and unwrapped of Apple's own vocabulary. The map both label
- # readers walk — a carried TEL's label and a related name's —
- # read off every line, an X-ABLabel never being carried itself.
- #: (Array[VCard::Parser::Line] lines) -> Hash[String?, String]
- def self.labels_by_group(lines)
- properties(lines, "X-ABLabel").to_h {
- [it.group, VCard.unwrap(VCard.unescape(it.value))] #: [String?, String]
- }
- end
-
- # The properties of the carried lines naming one property, in the
- # order the card wrote them.
- #: (Array[VCard::Parser::Line] lines, String name) -> Array[VCard::Parser::Property]
- def self.properties(lines, name)
- lines.filter_map { it.property }.select { it.name.casecmp?(name) }
- end
-
- #: (Array[VCard::Parser::Line] lines, String name) -> Array[String]
- def self.values(lines, name) = properties(lines, name).map(&:text)
-
- # An address as the editor's add row holds one, the blank
- # components left out rather than written as empty fields.
- #: (VCard::Parser::Property property) -> Hash[String, String]
- def self.address(property)
- # The post office box leads the value and has no field; a card
- # carrying one does not get this far (see ADR's value check).
- _po_box, *parts = property.components
- Contact::ADDRESS_COMPONENTS.zip(parts).to_h { |key, value|
- [key, value.to_s] #: [String, String]
- }.reject { |_key, value| value.empty? }
- end
-
- # The one line naming a property a card holds once, or nil for a
- # card with none; more than one is refused, the field having room
- # for a single value.
- #: (Array[VCard::Parser::Line] lines, String name, String source_id, Unknown unknown) -> String?
- def self.only(lines, name, source_id, unknown)
- found = lines.select { it.names?(name) }
- return found.first&.property&.text if found.size <= 1
-
- unknown.add("#{name} lines: #{found.size}", source_id, found.map { example(it) }.join(" / "))
- nil
- end
-
- # The `item` groups this card explains: a group with a line in it
- # that says what the group is for, rather than annotating it.
- #: (Array[VCard::Parser::Line] lines) -> Array[String]
- def self.grouped(lines)
- lines.filter_map { it.property }
- .reject { ANNOTATIONS.include?(it.name.upcase) }
- .filter_map { it.group }
- end
-
- # Whether a line of the source card is carried into the card's
- # fields; one that is not is recorded unknown or, for the envelope
- # and the dropped properties, left behind.
- #: (VCard::Parser::Line line, Array[String] grouped, String source_id, Unknown unknown) -> bool
- def self.carried?(line, grouped, source_id, unknown)
- property = line.property
- example = example(line)
- if property.nil?
- unknown.add("unreadable line", source_id, example) unless example.strip.empty?
- return false
- end
-
- name = property.name.upcase
- case name
- when "BEGIN", "END"
- false
- when "VERSION"
- unknown.add("VERSION:#{property.value}", source_id, example) unless property.value == "3.0"
- false
- when *DROPPED
- # Fields this address book does not have. The source keeps
- # them, the card carrying what it was read from.
- false
- when *ANNOTATIONS
- # A line that annotates the line sharing its group — a label,
- # or an address's country code — goes wherever that line goes,
- # which is why dropping `item1.URL` drops `item1.X-ABADR` with
- # it. One annotating nothing is a line with no explanation, so
- # it is unknown like any other.
- group = property.group
- unknown.add(name, source_id, example) unless group && grouped.include?(group)
- false
- when "PRODID"
- # Contacts writes its own on every save; the source's stays in the backup.
- form = form(property, example)
- unknown.add(form, source_id, example) unless form == "PRODID"
- unknown.add("PRODID value", source_id, example) unless property.value.start_with?("-//Apple Inc.//")
- false
- when *FORMS.keys
- refused = [] #: Array[String]
- form = form(property, example)
- refused << form unless FORMS.fetch(name).include?(form)
- refused << "#{name} value" unless value?(name, property)
- refused.each { unknown.add(it, source_id, example) }
- refused.empty? && !dropped_value?(name, property)
- else
- unknown.add(name, source_id, example)
- false
- end
- end
-
- # The first and last name, from the one N, when the one FN is those
- # two joined as the web editor joins them: a card holding more of a
- # name than that would lose it.
- #: (Array[VCard::Parser::Line] lines, String source_id, Unknown unknown) -> [String, String]
- def self.name(lines, source_id, unknown)
- ns = lines.select { it.names?("N") }
- fns = lines.select { it.names?("FN") }
- unknown.add("N lines: #{ns.size}", source_id, ns.map { example(it) }.join(" / ")) unless ns.size == 1
- unknown.add("FN lines: #{fns.size}", source_id, fns.map { example(it) }.join(" / ")) unless fns.size == 1
-
- n = ns.first
- family, given, *rest = n&.property&.components || []
- first = given.to_s
- last = family.to_s
- unknown.add("N beyond first and last", source_id, example(n)) if n && rest.any? { !it.empty? }
- unknown.add("no name", source_id, n ? example(n) : "") if n && first.empty? && last.empty?
-
- fn = fns.first
- if fn && fn.property&.text != [first, last].reject(&:empty?).join(" ")
- unknown.add("FN other than first and last", source_id, example(fn))
- end
-
- [first, last]
- end
-
- # The spellings each carried property is taken in, group and
- # parameters included, as the source wrote them: `item1.` and
- # `item2.` are one spelling, since the number counts a card's
- # groups rather than saying anything about the line, and a
- # trailing `type=pref` is left off, ranking a line rather than
- # naming a kind (VCard::Parser::Property#types drops it for the
- # same reason).
- FORMS = {
- "FN" => ["FN"],
- "N" => ["N"],
- "NICKNAME" => ["NICKNAME"],
- "BDAY" => ["BDAY"],
- "TEL" => [
- "TEL",
- "TEL;type=CELL;type=VOICE",
- "TEL;type=HOME;type=VOICE",
- "TEL;type=IPHONE;type=CELL;type=VOICE",
- "TEL;type=MAIN",
- "TEL;type=OTHER;type=VOICE",
- "TEL;type=WORK;type=VOICE",
- "item#.TEL"
- ],
- "EMAIL" => [
- "EMAIL;type=INTERNET",
- "EMAIL;type=INTERNET;type=HOME",
- "EMAIL;type=INTERNET;type=WORK",
- "item#.EMAIL;type=INTERNET"
- ],
- "ADR" => ["ADR;type=HOME", "item#.ADR;type=HOME", "ADR;type=WORK", "item#.ADR;type=WORK"],
- "X-ABRELATEDNAMES" => ["item#.X-ABRELATEDNAMES"],
- "X-MAIDENNAME" => ["X-MAIDENNAME"]
- }.freeze #: Hash[String, Array[String]]
-
- # A number as Contacts hands one over, which is as a person typed
- # it: "+12532189075" and "(206) 651-4359" are both numbers, and an
- # empty value is a row with no number in it at all.
- PHONE = /\A[0-9 +().-]*\z/ #: Regexp
-
- # A number with the extension a person wrote after it, "+1 (425)
- # 707-1712 X71712" as one line. Digits have to come first: a value
- # that is an extension and nothing else ("ext. 4") labels a row
- # rather than filling it, and stays refused.
- EXTENSION = /\A[0-9 +().-]*[0-9][0-9 +().-]*(?:x|ext)\.?\s*[0-9]+\z/i #: Regexp
-
- # Properties read and thrown away, whatever form they take: this
- # address book has no field for any of them, and a card's `source`
- # still holds the line.
- DROPPED = %w[
- IMPP ORG TITLE URL X-SOCIALPROFILE
- X-APPLE-SUBADMINISTRATIVEAREA X-APPLE-SUBLOCALITY X-AIM
- ].freeze #: Array[String]
-
- # Properties that say something about the line sharing their group
- # rather than about the contact: a label, and the country code
- # Contacts writes beside an address.
- ANNOTATIONS = %w[X-ABLABEL X-ABADR].freeze #: Array[String]
-
- # Whether a value is one its field can hold: a phone is a `+` and
- # digits, with or without an extension, a birthday a whole date,
- # an address the seven components RFC 2426 section 3.2.1 gives it
- # with no post office box, since no field carries one. A name is
- # any text, and an email is asked nothing here — see
- # #dropped_value?.
- #: (String name, VCard::Parser::Property property) -> bool
- def self.value?(name, property)
- case name
- when "TEL" then property.value.match?(PHONE) || property.value.match?(EXTENSION)
- when "BDAY" then property.value.match?(Card::BIRTHDAY)
- when "ADR" then property.components.size == 7 && property.components.fetch(0).empty?
- else true
- end
- end
-
- # A value its field has no use for, dropped rather than refused.
- # An EMAIL without an `@` is not an address at all — Exchange
- # writes a directory name into one
- # (`/O=microsoft/OU=.../cn=algersha`) — so it is not a spelling to
- # learn but a row this address book has no field for, and it goes
- # the way the DROPPED properties go, the source keeping the line.
- # A work address goes the same way for the opposite reason: the
- # editor's address rows carry no type, so a work address would
- # land as an unmarked second one beside the home, and the one
- # address the fields can hold without lying is the home — the
- # work stays in the source. The form is still checked around
- # this, so a value like it in a spelling nobody has seen is
- # reported like any other.
- #: (String name, VCard::Parser::Property property) -> bool
- def self.dropped_value?(name, property)
- return true if name == "EMAIL" && !property.value.include?("@")
-
- name == "ADR" && property.parameters.any? { |key, value| key.casecmp?("TYPE") && value.casecmp?("WORK") }
- end
-
- # A line unfolded, without its terminator.
- #: (VCard::Parser::Line line) -> String
- def self.example(line)
- VCard::Parser.unfold(line.verbatim).chomp
- end
-
- # A line as the source spelled it, less its value.
- #: (VCard::Parser::Property property, String example) -> String
- def self.form(property, example)
- example.delete_suffix(":#{property.value}").sub(/\Aitem\d+\./, "item#.").delete_suffix(";type=pref")
- end
-
- private_class_method :entry, :grouped, :carried?, :name, :noted, :related_names, :labels_by_group, :only, :properties, :values, :address, :value?, :dropped_value?, :example, :form
- end
- end
-end
diff --git a/lib/pro_tacts/import/plan.rb b/lib/pro_tacts/import/plan.rb
deleted file mode 100644
index 885731f..0000000
--- a/lib/pro_tacts/import/plan.rb
+++ /dev/null
@@ -1,159 +0,0 @@
-require "fileutils"
-require "pathname"
-require "time"
-require "yaml"
-
-require "pro_tacts/import/card"
-
-module ProTacts
- module Import
- # One import, as a directory: what a source builder decided, and how
- # 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]
-
- # What a builder hands over for one contact: its card, which the
- # plan's own groups are added to. The original rides inside the
- # card (Card::SOURCE_KEYS). Signed in sig/pro_tacts/import.rbs,
- # being a Data class.
- # @rbs skip
- Entry = Data.define(:id, :source_id, :card)
-
- # A contact as the plan lists it, with the last step it reached:
- # 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)
-
- FORMAT = 2 #: Integer
-
- # Store::EVERYONE, spelled out so a plan loads no database.
- EVERYONE = "sync:*" #: String
-
- # A contact's final state: on the host, in its groups, and off
- # this Mac. A plan whose every contact has reached it is done and
- # files away under done/ (tasks/import.rake).
- DONE = "done" #: String
-
- # The spelling of DONE plans recorded before the state was
- # named for what it is; read as it so a plan built then files
- # away now.
- REMOVED = "removed" #: String
-
- # Whether the plan has carried every contact through: nothing
- # left for finalize to do.
- #: () -> bool
- def done? = outstanding.empty?
-
- #: (Pathname dir, source: String, created_at: Time, entries: Array[Entry]) -> Plan
- def self.write(dir, source:, created_at:, entries:)
- FileUtils.mkdir_p(dir.dirname)
- # Raises when the directory is there, so no plan is written over
- # another.
- Dir.mkdir(dir)
-
- group = "import-#{created_at.utc.strftime("%Y%m%dT%H%M%SZ")}"
- (dir / "cards").mkpath
- entries.each do |entry|
- card = entry.card
- card = card.with(groups: [EVERYONE, group, *card.groups])
- (dir / "cards/#{entry.id}.yml").write(YAML.dump(card.document))
- end
-
- plan = new(dir, {
- "format" => FORMAT,
- "source" => source,
- "created_at" => created_at.utc.iso8601,
- "group" => group,
- "contacts" => entries.map {
- # A card may hold only one of the two names.
- person = [it.card.first, it.card.last].reject(&:empty?).join(" ")
- {"id" => it.id, "source_id" => it.source_id, "name" => person, "status" => nil}
- }
- })
- plan.save
- plan
- end
-
- # Every plan under dir, oldest first: a plan's directory is named
- # for the minute it was built, so the names sort as the plans do.
- #: (Pathname dir) -> Array[Plan]
- def self.all(dir)
- Pathname.glob("#{dir}/*/plan.yml").sort_by { it.dirname.basename.to_s }.map { read(it.dirname) }
- end
-
- #: (Pathname dir) -> Plan
- def self.read(dir)
- new(dir, YAML.safe_load_file(dir / "plan.yml"))
- end
-
- attr_reader :dir #: Pathname
-
- #: (Pathname dir, Hash[String, untyped] manifest) -> void
- def initialize(dir, manifest)
- format = manifest.fetch("format")
- raise ArgumentError, "#{dir} is plan format #{format}, and this reads #{FORMAT}" unless format == FORMAT
-
- @dir = dir
- @manifest = manifest
- end
-
- #: () -> String
- def source = @manifest.fetch("source")
-
- #: () -> String
- def created_at = @manifest.fetch("created_at")
-
- #: () -> String
- def group = @manifest.fetch("group")
-
- #: () -> Array[Contact]
- def contacts
- @manifest.fetch("contacts").map {
- Contact.new(id: it.fetch("id"), source_id: it.fetch("source_id"), name: it.fetch("name"), status: it.fetch("status"))
- }
- end
-
- #: (String id, String status) -> void
- def record(id, status)
- @manifest.fetch("contacts").find { it.fetch("id") == id }.store("status", status)
- save
- end
-
- # 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
- def card(id)
- Card.read(dir / "cards/#{id}.yml")
- end
-
- # Every step is saved as it is taken, and replaced whole by a rename
- # so a run that dies mid-write leaves the plan as it was before.
- #: () -> void
- def save
- written = dir / "plan.yml.tmp"
- written.open("w") do |file|
- file.write(YAML.dump(@manifest))
- file.fsync
- end
- written.rename(dir / "plan.yml")
- end
- end
- end
-end
diff --git a/lib/pro_tacts/import/staged.rb b/lib/pro_tacts/import/staged.rb
new file mode 100644
index 0000000..f80a754
--- /dev/null
+++ b/lib/pro_tacts/import/staged.rb
@@ -0,0 +1,106 @@
+require "fileutils"
+require "pathname"
+
+require "pro_tacts"
+require "pro_tacts/change_id"
+
+module ProTacts
+ module Import
+ # The uploaded file, held between the two halves of an import
+ # (docs/plans/2026-09-21-import-a-vcf.md). The screen cannot ask
+ # what to do with a property until it has read the file, and the
+ # answer comes back in a second request — so the bytes wait here
+ # rather than riding out to the browser and back in a hidden
+ # field, which would carry a book's pictures twice over.
+ #
+ # Under the data directory, which is the one place this app writes
+ # (Config#data_dir), and not the system temp dir: a half-finished
+ # import is this server's own state, short-lived without being
+ # anyone else's.
+ module Staged
+ # Where they wait. Beside the database rather than under it: a
+ # file here is not a card yet and nothing reads it but the
+ # request that lands it.
+ DIRECTORY = "imports" #: String
+
+ # How long an upload is worth keeping. Long enough to read a
+ # list of properties and think about it, short enough that a
+ # window closed on the review screen does not leave a book on
+ # disk for a week.
+ LIFETIME = 3600 #: Integer
+
+ # The bytes staged under a fresh id, which is what the review
+ # screen carries so the confirm can find them again. Minted, so
+ # the id a form hands back is checked against the one shape this
+ # writes (#path) rather than trusted as a filename.
+ #: (String bytes) -> String
+ def self.write(bytes)
+ directory = root
+ FileUtils.mkdir_p(directory)
+ sweep
+ id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
+ File.binwrite(directory / file_name(id), bytes)
+ id
+ end
+
+ # The staged bytes, or none — an upload swept out from under a
+ # review screen left open, or a confirm submitted twice, the
+ # second finding what the first removed. Ordinary enough for the
+ # screen to say so and ask for the file again.
+ #: (String id) -> String?
+ def self.read(id)
+ file = path(id)
+ return nil if file.nil? || !file.file?
+
+ # UTF-8 because #write is only ever handed bytes the route has
+ # already judged to be text (Web#survey_upload, Web#write_card's
+ # rule); a card refuses to be made of anything else regardless
+ # (VCard#initialize).
+ File.read(file, encoding: Encoding::UTF_8)
+ end
+
+ # The staged file, gone: the last step of a landing, and what
+ # keeps the ordinary case from waiting on the sweep.
+ #: (String id) -> void
+ def self.remove(id)
+ file = path(id)
+ FileUtils.rm_f(file) if file
+ end
+
+ # Every upload past its LIFETIME, gone. Run on the way in rather
+ # than on a timer, because an import is the only thing that
+ # writes here and a directory nobody is adding to is not growing.
+ #: (?now: Time) -> void
+ def self.sweep(now: Time.now)
+ directory = root
+ return unless directory.directory?
+
+ directory.each_child do |path|
+ FileUtils.rm_f(path) if path.file? && now - path.mtime > LIFETIME
+ end
+ end
+
+ #: () -> Pathname
+ def self.root
+ ProTacts.config.data_dir / DIRECTORY
+ end
+
+ # The file an id names, or none for an id this never wrote. The
+ # check is the whole of the path safety here: an id comes back
+ # off a form, and `../../contacts.db` is a filename too.
+ #: (String id) -> Pathname?
+ def self.path(id)
+ return nil unless ChangeId.minted?(id)
+
+ root / file_name(id)
+ end
+
+ #: (String id) -> String
+ def self.file_name(id)
+ "#{id}.vcf"
+ end
+
+ private_class_method :root, :path, :file_name
+ end
+ end
+end
diff --git a/lib/pro_tacts/import/vcf.rb b/lib/pro_tacts/import/vcf.rb
new file mode 100644
index 0000000..75e49cc
--- /dev/null
+++ b/lib/pro_tacts/import/vcf.rb
@@ -0,0 +1,144 @@
+require "pro_tacts/vcard"
+require "pro_tacts/vcard/parser"
+
+module ProTacts
+ module Import
+ # An uploaded .vcf, read as the cards it holds and as what this
+ # server does not recognise in them
+ # (docs/plans/2026-09-21-import-a-vcf.md).
+ #
+ # Nothing here rebuilds a card out of fields. A contact arrives as a
+ # vCard and is stored as one — RFC 6352 section 6.3.2.2 requires a
+ # server to keep what it does not understand, and this one does
+ # (vcard.rb) — so the reading this module owes is the split into
+ # cards and the question the importer is asked: of the properties
+ # no screen here shows, which are worth keeping?
+ module Vcf
+ # A file that is not a set of vCards.
+ class Invalid < StandardError; end
+
+ # The properties this address book reads. The envelope (BEGIN,
+ # END, VERSION, UID), the fields a screen shows (Contact's own
+ # readers: N, FN, NICKNAME, TEL, EMAIL, ADR, NOTE, PHOTO, and the
+ # X-ABLabel that names a row), and BDAY, which the store takes
+ # into the model on the way in
+ # (docs/plans/2026-09-11-every-birthday-in-the-model.md).
+ #
+ # Everything else is unknown, which here means "nothing here will
+ # show it", not "nothing here can hold it": a kept line is served
+ # back byte for byte like any other. That is why the list is the
+ # reader's and not the writer's.
+ # Apple's own name for a row, carried on a line of its own beside
+ # the one it names (`item1.X-ABLabel:_$!<Spouse>!$_`). Known, and
+ # named apart from the rest because a decision about the line it
+ # annotates is a decision about it too: a label whose line is
+ # gone names nothing (Land#decide).
+ LABEL = "X-ABLABEL" #: String
+
+ KNOWN = [
+ *%w[BEGIN END VERSION UID N FN NICKNAME BDAY TEL EMAIL ADR NOTE PHOTO],
+ LABEL,
+ ].freeze #: Array[String]
+
+ # What the importer can ask for an unknown property. Keeping is
+ # the default because it loses nothing: the line is stored as it
+ # arrived and served back untouched, invisible to these screens
+ # rather than gone. Dropping is for what the source keeps and
+ # this book has no use for. The note is for a value worth reading
+ # even with no field to read it in — a spouse's name is worth
+ # more under the note than nowhere.
+ KEEP = "keep" #: String
+ DROP = "drop" #: String
+ NOTE = "note" #: String
+ CHOICES = [KEEP, DROP, NOTE].freeze #: Array[String]
+
+ # One property the file carries that KNOWN does not name, across
+ # every card in it: how many lines wear it, and a few of their
+ # real values, so a choice is made looking at this book's own
+ # data rather than at a property name. Signed in
+ # sig/pro_tacts/import.rbs, being a Data class.
+ # @rbs skip
+ Unknown = Data.define(:name, :count, :examples)
+
+ EXAMPLES = 3 #: Integer
+
+ # Long enough for a line's parameters, short of a photo's payload.
+ EXAMPLE_LENGTH = 120 #: Integer
+
+ # The cards a file holds, in the order it wrote them, each as the
+ # bytes it arrived in. A card is what lies from BEGIN to END, and
+ # the lines between cards are blank or the file is not a set of
+ # vCards — refused whole rather than partly imported, `execute`'s
+ # own rule that a source which will not read lands nothing.
+ #: (String bytes) -> Array[VCard]
+ def self.cards(bytes)
+ cards = [] #: Array[VCard]
+ open = nil #: Array[VCard::Parser::Line]?
+ VCard::Parser.lines(bytes).each do |line|
+ name = line.property&.name
+ if open.nil?
+ next if line.verbatim.strip.empty?
+
+ raise Invalid, "a line outside a card: #{example(line)}" unless name&.casecmp?("BEGIN")
+
+ open = [line]
+ else
+ open << line
+ next unless name&.casecmp?("END")
+
+ cards << VCard.new(open.map(&:verbatim).join)
+ open = nil
+ end
+ end
+ raise Invalid, "the last card has no END:VCARD" unless open.nil?
+ raise Invalid, "this file holds no vCards" if cards.empty?
+
+ refused = cards.reject(&:card?)
+ raise Invalid, "#{refused.size} of these are not vCards: BEGIN, VERSION and END are what makes one" if refused.any?
+
+ cards
+ end
+
+ # Every property in these cards that KNOWN does not name, by
+ # name rather than by the spelling the line wore: the three
+ # choices read the same whatever parameters a line carries, so
+ # `X-SOCIALPROFILE;type=twitter` is not a second decision from
+ # `X-SOCIALPROFILE`. Ordered by name, a list to work down.
+ #
+ # An unreadable line is not among them and asks nothing: the
+ # parser hands one back without a property, the card is served
+ # from its bytes regardless, and there is no field it could be
+ # dropped out of (vcard/parser.rb's own posture).
+ #: (Array[VCard] cards) -> Array[Unknown]
+ def self.unknown(cards)
+ seen = {} #: Hash[String, Array[String]]
+ cards.each do |card|
+ card.lines.each do |line|
+ property = line.property
+ next if property.nil?
+
+ name = property.name.upcase
+ next if KNOWN.include?(name)
+
+ (seen[name] ||= []) << example(line)
+ end
+ end
+ seen.sort.map { |name, examples|
+ Unknown.new(name:, count: examples.size, examples: examples.uniq.first(EXAMPLES))
+ }
+ end
+
+ # A line unfolded, without its terminator, cut short of a
+ # payload nobody wants to read.
+ #: (VCard::Parser::Line line) -> String
+ def self.example(line)
+ text = VCard::Parser.unfold(line.verbatim).chomp
+ return text if text.length <= EXAMPLE_LENGTH
+
+ "#{text[0, EXAMPLE_LENGTH]}… (#{text.length} characters)"
+ end
+
+ private_class_method :example
+ end
+ end
+end
diff --git a/lib/pro_tacts/web/api.rb b/lib/pro_tacts/web/api.rb
index 7c2dc35..3210422 100644
--- a/lib/pro_tacts/web/api.rb
+++ b/lib/pro_tacts/web/api.rb
@@ -2,10 +2,12 @@ require "json"
module ProTacts
class Web < Roda
- # JSON for scripts, beside the HTML screens a person uses. An import
- # looks its group up here by name before making it, so a rerun
- # joins the group an earlier run made
- # (docs/plans/2026-09-16-importing-from-macos.md).
+ # JSON for scripts, beside the HTML screens a person uses. It was
+ # written for `rake import:execute`, which looked a group up by
+ # name over HTTP before making it; that import is gone
+ # (docs/plans/2026-09-21-import-a-vcf.md) and the route stays as
+ # what it always was — the group list, for anything scripted
+ # pointed at this server.
hash_branch("api") do |r|
r.get "groups" do
response["Content-Type"] = "application/json"
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index d639af6..d8f6869 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -1,91 +1,151 @@
-require "pro_tacts/admin/plan_upload"
-require "pro_tacts/import/card"
+require "pro_tacts/admin/import_review"
+require "pro_tacts/admin/import_upload"
require "pro_tacts/import/land"
+require "pro_tacts/import/staged"
+require "pro_tacts/import/vcf"
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.
+ # The import screen: a .vcf lands its cards in this server's store
+ # (docs/plans/2026-09-21-import-a-vcf.md). It replaces the rake
+ # tasks that read Contacts.app on a Mac, built a plan directory,
+ # and carried it to a host over HTTP — a .vcf is what every
+ # address book on earth already exports, and the machine holding
+ # it is whichever one the browser is on.
#
- # 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.
+ # Two requests, because the question this screen exists to ask
+ # cannot be asked until the file has been read: the first stages
+ # the upload and surveys what is in it, the second spends the
+ # answers. The file waits on the server between them
+ # (Import::Staged) rather than riding back through the browser.
+ #
+ # Under the same identity gate as every other route (web.rb),
+ # which is also where the arrivals' 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
+ import_screen
end
r.post do
- apply_import(r)
+ survey_upload(r)
end
end
+
+ # The second half, on its own path rather than the same one: two
+ # posts to /import would be told apart by which fields they
+ # carried, and a form's fields are the last thing that should
+ # decide what a request means.
+ r.post "land" do
+ land_upload(r)
+ 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.
+ # The upload's own POST: the file read and judged whole before
+ # anything is staged, `execute`'s old rule that a source which
+ # will not read lands nothing. Its answer is the review screen,
+ # which is where the decisions are made.
#: (untyped r) -> String
- def apply_import(r)
- uploads = files_in(r.params["cards"])
- return plan_upload_screen(notice: "Choose the card files in a plan's cards directory.") if uploads.empty?
+ def survey_upload(r)
+ upload = file_in(r.params["vcf"])
+ return import_screen(notice: "Choose a .vcf file to import.") if upload.nil?
- 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
+ name = File.basename(upload[:filename].to_s)
+ # 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.
+ bytes = upload.fetch(:tempfile).read.force_encoding(Encoding::UTF_8)
+ return import_screen(notice: "#{name} is not UTF-8 text.") unless bytes.valid_encoding?
- # 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
+ begin
+ cards = Import::Vcf.cards(bytes)
+ rescue Import::Vcf::Invalid => error
+ return import_screen(notice: error.message)
end
- plan_upload_screen(arrivals: Import::Land.call(store, cards))
+ review_screen(
+ upload: Import::Staged.write(bytes),
+ file: name,
+ cards: cards.length,
+ unknown: Import::Vcf.unknown(cards),
+ )
+ end
+
+ # The confirm: the staged file read back, the decisions the review
+ # screen collected applied, and the cards landed.
+ #
+ # The re-read parses a file this already parsed once, on the way to
+ # the review screen — not a second judgment of it, but the only way
+ # back to the cards, the bytes being what was staged. A file that
+ # read then and will not read now is a broken assumption and raises
+ # rather than being handled.
+ #: (untyped r) -> String
+ def land_upload(r)
+ id = r.params["upload"].to_s
+ bytes = Import::Staged.read(id)
+ # Swept out from under a review screen left open overnight, or a
+ # confirm submitted twice, the second finding what the first
+ # removed. The file is gone either way and only the person has
+ # another copy.
+ return import_screen(notice: "That upload is no longer here. Choose the file again.") if bytes.nil?
+
+ cards = Import::Vcf.cards(bytes)
+ group = r.params["group"].to_s.strip
+ landed = Import::Land.call(
+ store, cards,
+ decisions: decisions_in(r.params["decide"], Import::Vcf.unknown(cards)),
+ group: group.empty? ? nil : group,
+ )
+ Import::Staged.remove(id)
+
+ import_screen(landed:)
end
- # The file parts the form sent, and none for a POST carrying no
+ # What the form said to do with each unknown property, read against
+ # the survey rather than trusted: the names are the ones this run
+ # found, and anything else a post carries is not a property of this
+ # file. An answer missing or unrecognized is a keep, the choice
+ # that loses nothing.
+ #: (untyped param, Array[Import::Vcf::Unknown] unknown) -> Hash[String, String]
+ def decisions_in(param, unknown)
+ unknown.to_h { |property|
+ answer = param.is_a?(Hash) ? param[property.name].to_s : ""
+ choice = Import::Vcf::CHOICES.include?(answer) ? answer : Import::Vcf::KEEP
+ # A two-element literal is an Array until something says
+ # otherwise, and an inline annotation needs its own line.
+ [property.name, choice] #: [String, String]
+ }
+ end
+
+ # The file part the form sent, and none for a POST carrying no
# file: #ids_in's shape over an upload, and its reason for being a
# method — the empty case needs a type, and the signature is where
# one fits.
- #: (untyped param) -> Array[untyped]
- def files_in(param)
- param.is_a?(Array) ? param.select { it.is_a?(Hash) && it[:tempfile] } : []
+ #: (untyped param) -> untyped
+ def file_in(param)
+ param if param.is_a?(Hash) && param[:tempfile]
+ end
+
+ # The screen a file is chosen on, and the page a landing answers
+ # with. It answers with the result rather than the 303 the other
+ # writes answer with, and has to: the staged file is gone, so the
+ # re-submission a back button offers has nothing to land, and there
+ # is no other page holding what just arrived.
+ #: (?landed: Array[Contact]?, ?notice: String?) -> String
+ def import_screen(landed: nil, notice: nil)
+ response["Content-Type"] = "text/html; charset=utf-8"
+ Admin::ImportUpload.call(landed:, notice:)
end
- #: (?arrivals: Array[Import::Land::Arrival]?, ?notice: String?) -> String
- def plan_upload_screen(arrivals: nil, notice: nil)
+ #: (upload: String, file: String, cards: Integer, unknown: Array[Import::Vcf::Unknown]) -> String
+ def review_screen(upload:, file:, cards:, unknown:)
response["Content-Type"] = "text/html; charset=utf-8"
- Admin::PlanUpload.call(arrivals:, notice:)
+ Admin::ImportReview.call(upload:, file:, cards:, unknown:, group: Import::Land.default_group)
end
end
end
diff --git a/script/macos-contacts.swift b/script/macos-contacts.swift
deleted file mode 100644
index f6ab711..0000000
--- a/script/macos-contacts.swift
+++ /dev/null
@@ -1,203 +0,0 @@
-// Reads and deletes this Mac's Contacts for an import plan
-// (docs/plans/2026-09-16-importing-from-macos.md, "Reading the Mac" and
-// "Removing the originals").
-//
-// swift script/macos-contacts.swift read [limit]
-// swift script/macos-contacts.swift show <identifier>...
-// swift script/macos-contacts.swift delete <identifier>...
-//
-// read prints one JSON object per person in the iCloud account, companies
-// skipped, in Contacts.app's own order: the identifier, the framework's
-// vCard, every fetched key, and the note, which only AppleScript will give
-// up. Cards are read unmerged, so a contact linked to a card in another
-// account (Monica, or pro-tacts itself) is this account's card alone.
-//
-// show prints those same objects for the identifiers it is given, leaving
-// out the ones this Mac no longer has; delete deletes them one at a time
-// and prints, per identifier it still has, whether the delete took and the
-// error if it did not. An identifier this Mac no longer has is already
-// deleted, and prints nothing.
-
-import Contacts
-import Foundation
-
-func fail(_ message: String) -> Never {
- FileHandle.standardError.write(Data("macos-contacts: \(message)\n".utf8))
- exit(1)
-}
-
-// Every key but the note, which the framework strips from an unentitled
-// fetch without an error.
-let keys: [String] = [
- CNContactIdentifierKey, CNContactTypeKey,
- CNContactNamePrefixKey, CNContactGivenNameKey, CNContactMiddleNameKey, CNContactFamilyNameKey,
- CNContactPreviousFamilyNameKey, CNContactNameSuffixKey, CNContactNicknameKey,
- CNContactPhoneticGivenNameKey, CNContactPhoneticMiddleNameKey, CNContactPhoneticFamilyNameKey,
- CNContactOrganizationNameKey, CNContactDepartmentNameKey, CNContactJobTitleKey,
- CNContactPhoneticOrganizationNameKey,
- CNContactBirthdayKey, CNContactNonGregorianBirthdayKey, CNContactDatesKey,
- CNContactImageDataKey, CNContactThumbnailImageDataKey, CNContactImageDataAvailableKey,
- CNContactPhoneNumbersKey, CNContactEmailAddressesKey, CNContactPostalAddressesKey,
- CNContactUrlAddressesKey, CNContactRelationsKey, CNContactSocialProfilesKey,
- CNContactInstantMessageAddressesKey,
-]
-
-func json(_ value: Any?) -> Any {
- switch value {
- case nil: return NSNull()
- case let string as String: return string
- case let data as Data: return data.base64EncodedString()
- case let number as NSNumber: return number
- case let components as DateComponents:
- var fields: [String: Any] = [:]
- if let calendar = components.calendar { fields["calendar"] = calendar.identifier.debugDescription }
- if let era = components.era { fields["era"] = era }
- if let year = components.year { fields["year"] = year }
- if let month = components.month { fields["month"] = month }
- if let day = components.day { fields["day"] = day }
- if let leap = components.isLeapMonth { fields["leap_month"] = leap }
- return fields
- // A CNLabeledValue's type parameter is an Objective-C lightweight
- // generic, so its parts are read by key rather than through a cast.
- case let labeled as [NSObject] where labeled.allSatisfy({ $0 is CNLabeledValue<NSString> }):
- return labeled.map {
- ["identifier": json($0.value(forKey: "identifier")), "label": json($0.value(forKey: "label")), "value": json($0.value(forKey: "value"))]
- }
- case let phone as CNPhoneNumber: return phone.stringValue
- case let address as CNPostalAddress:
- return [
- "street": address.street, "sub_locality": address.subLocality, "city": address.city,
- "sub_administrative_area": address.subAdministrativeArea, "state": address.state,
- "postal_code": address.postalCode, "country": address.country, "iso_country_code": address.isoCountryCode,
- ]
- case let relation as CNContactRelation: return relation.name
- case let profile as CNSocialProfile:
- return [
- "url": profile.urlString, "username": profile.username,
- "user_identifier": profile.userIdentifier, "service": profile.service,
- ]
- case let im as CNInstantMessageAddress: return ["username": im.username, "service": im.service]
- default: fail("no JSON spelling for a \(type(of: value!))")
- }
-}
-
-// Every person's note by identifier, in one Apple event, and every
-// identifier AppleScript knows, so a contact it spells differently fails
-// rather than reading as one with no note.
-func notes() -> (ids: Set<String>, notes: [String: String]) {
- let source = "tell application \"Contacts\" to get {id, note} of every person"
- var error: NSDictionary?
- guard let result = NSAppleScript(source: source)?.executeAndReturnError(&error) else {
- fail("reading notes through AppleScript: \(error ?? [:])")
- }
- guard let ids = result.atIndex(1), let notes = result.atIndex(2), ids.numberOfItems == notes.numberOfItems else {
- fail("AppleScript answered notes in an unexpected shape")
- }
- var known: Set<String> = []
- var byId: [String: String] = [:]
- if ids.numberOfItems > 0 {
- for index in 1...ids.numberOfItems {
- guard let id = ids.atIndex(index)?.stringValue else { fail("a person with no id") }
- known.insert(id)
- if let note = notes.atIndex(index)?.stringValue { byId[id] = note }
- }
- }
- return (known, byId)
-}
-
-let arguments = Array(CommandLine.arguments.dropFirst())
-let usage = "usage: macos-contacts.swift read [limit] | show <identifier>... | delete <identifier>..."
-guard let command = arguments.first, ["read", "show", "delete"].contains(command) else { fail(usage) }
-let rest = Array(arguments.dropFirst())
-
-var limit: Int? = nil
-if command == "read" {
- if let first = rest.first {
- guard let n = Int(first), n > 0 else { fail("a limit is a positive number") }
- limit = n
- }
-} else {
- guard !rest.isEmpty else { fail(usage) }
-}
-
-let store = CNContactStore()
-let icloud: [CNContainer]
-do {
- icloud = try store.containers(matching: nil).filter { $0.name == "iCloud" }
-} catch {
- fail("listing accounts: \(error)")
-}
-guard icloud.count == 1 else { fail("found \(icloud.count) accounts named iCloud, and reads exactly one") }
-
-let request = CNContactFetchRequest(
- keysToFetch: keys.map { $0 as NSString } + [CNContactVCardSerialization.descriptorForRequiredKeys()])
-request.predicate =
- command == "read"
- ? CNContact.predicateForContactsInContainer(withIdentifier: icloud[0].identifier)
- // An identifier names one card, and the named ones were read out of
- // iCloud by an earlier run of this script.
- : CNContact.predicateForContacts(withIdentifiers: rest)
-// Merged contacts carry identifiers no card has, which AppleScript cannot
-// find and a delete could not name.
-request.unifyResults = false
-request.sortOrder = .userDefault
-
-var people: [CNContact] = []
-do {
- try store.enumerateContacts(with: request) { contact, stop in
- guard contact.contactType == .person else { return }
- people.append(contact)
- if let limit, people.count >= limit { stop.pointee = true }
- }
-} catch {
- fail("reading contacts: \(error)")
-}
-
-if command == "delete" {
- // One save request per contact, because CNSaveRequest is all or nothing
- // and the store refuses some contacts it will hand over quite happily:
- // batched, one of those takes every other delete down with it. An
- // identifier that matched nothing prints no line, a delete of a contact
- // this Mac no longer has being a delete that already happened.
- for contact in people {
- guard let mutable = contact.mutableCopy() as? CNMutableContact else { fail("\(contact.identifier) will not copy") }
- let request = CNSaveRequest()
- request.delete(mutable)
- var result: [String: Any] = ["identifier": contact.identifier]
- do {
- try store.execute(request)
- result["deleted"] = true
- } catch {
- result["deleted"] = false
- result["error"] = "\(error)"
- }
- let line = try! JSONSerialization.data(withJSONObject: result, options: [.sortedKeys])
- FileHandle.standardOutput.write(line + Data("\n".utf8))
- }
- exit(0)
-}
-
-let (noted, noteById) = notes()
-for contact in people {
- guard noted.contains(contact.identifier) else {
- fail("AppleScript has no person \(contact.identifier), so its note cannot be read")
- }
- for key in keys where !contact.isKeyAvailable(key) {
- fail("\(key) came back unavailable for \(contact.identifier)")
- }
- var fields: [String: Any] = [:]
- for key in keys { fields[key] = json(contact.value(forKey: key)) }
-
- let vcard: String
- do {
- vcard = String(decoding: try CNContactVCardSerialization.data(with: [contact]), as: UTF8.self)
- } catch {
- fail("serializing \(contact.identifier): \(error)")
- }
-
- let object: [String: Any] = [
- "identifier": contact.identifier, "vcard": vcard, "contact": fields, "note": json(noteById[contact.identifier]),
- ]
- let line = try! JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
- FileHandle.standardOutput.write(line + Data("\n".utf8))
-}
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 36b309e..0cbe91c 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -38,8 +38,12 @@ 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
+ class ImportUpload
+ def self.call: (?landed: Array[Contact]?, ?notice: String?) -> String
+ end
+
+ class ImportReview
+ def self.call: (upload: String, file: String, cards: Integer, unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String) -> String
end
# Rendered by Dashboard, not called from checked code; only
diff --git a/sig/pro_tacts/import.rbs b/sig/pro_tacts/import.rbs
index fc08004..729d645 100644
--- a/sig/pro_tacts/import.rbs
+++ b/sig/pro_tacts/import.rbs
@@ -1,100 +1,16 @@
-# 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.
+# Import::Vcf::Unknown is a Data class, whose members the inline parser
+# cannot read (see sig/pro_tacts/contact.rbs). It is marked `@rbs skip`
+# where it is defined and signed here.
module ProTacts
module Import
- class Card
- attr_reader first: String
- attr_reader last: String
- attr_reader nickname: String?
- attr_reader birthday: String?
- attr_reader phones: Array[Hash[String, String]]
- attr_reader emails: Array[String]
- attr_reader addresses: Array[Hash[String, String]]
- attr_reader note: String?
- attr_reader photo: bool
- attr_reader groups: Array[String]
- attr_reader source: Hash[String, untyped]
-
- def self.new: (first: String, last: String, nickname: String?, birthday: String?, phones: Array[Hash[String, String]], emails: Array[String], addresses: Array[Hash[String, String]], note: String?, photo: bool, groups: Array[String], source: Hash[String, untyped]) -> instance
-
- # Data#with, for the one member a plan fills in itself.
- def with: (groups: Array[String]) -> Card
- end
-
- class Plan
- class Entry
- attr_reader id: String
- attr_reader source_id: String
- attr_reader card: Card
-
- def self.new: (id: String, source_id: String, card: Card) -> instance
- end
-
- class Contact
- attr_reader id: String
- attr_reader source_id: String
+ module Vcf
+ class Unknown
attr_reader name: String
- attr_reader status: String?
-
- def self.new: (id: String, source_id: String, name: String, status: String?) -> instance
- end
- end
-
- class Config
- ROOT: Pathname
- FILE: Pathname
- ACTIVE: Pathname
- DONE: Pathname
-
- attr_reader host: URI::HTTP
-
- def self.members: () -> Array[Symbol]
- def self.new: (host: URI::HTTP) -> instance
- def self.read: (?Pathname path) -> instance
- end
+ attr_reader count: Integer
+ attr_reader examples: Array[String]
- class Finalize
- class Result
- attr_reader done: Integer
- attr_reader kept: Array[[String, String, String]]
-
- def self.new: (done: Integer, kept: Array[[String, String, String]]) -> instance
- end
- end
-
- class HttpClient
- class Response
- attr_reader status: Integer
- attr_reader headers: Hash[String, String]
- attr_reader body: String
-
- def self.new: (status: Integer, headers: Hash[String, String], body: String) -> instance
- end
- end
-
- class Land
- class Arrival
- attr_reader contact: ProTacts::Contact
- attr_reader stored: bool
-
- def self.new: (contact: ProTacts::Contact, stored: bool) -> instance
+ def self.new: (name: String, count: Integer, examples: Array[String]) -> 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?) -> HttpClient::Response
- end
-
- # The Mac a plan was read from: Import::Macos::Mac, or a test's stand-in.
- interface _Mac
- def show: (Array[String] source_ids) -> Hash[String, untyped]
-
- def delete: (Array[String] source_ids) -> Hash[String, String]
- end
end
end
diff --git a/tasks/import.rake b/tasks/import.rake
deleted file mode 100644
index ee3e375..0000000
--- a/tasks/import.rake
+++ /dev/null
@@ -1,151 +0,0 @@
-# Importing an address book from another source
-# (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 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 "pro_tacts/import/config"
-
-# The plan a task carries further. A module rather than task-file methods,
-# which rake redefines noisily when a file is loaded twice.
-module ImportTasks
- Config = ProTacts::Import::Config
-
- # 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
- # take further is the oldest that has not been. A PLAN that is not a
- # directory is read as a plan's name under Config::ACTIVE, the way
- # the directories there are named.
- def self.plan(waiting, step)
- named = ENV.fetch("PLAN", nil)&.then { Pathname.new(it) }
- named = Config::ACTIVE / named if named && !named.directory?
- if named
- abort "#{named} holds no plan.yml" unless (named / "plan.yml").file?
- return ProTacts::Import::Plan.read(named)
- end
-
- ProTacts::Import::Plan.all(Config::ACTIVE).find(&waiting) ||
- abort("no plan in #{Config::ACTIVE} is waiting #{step}: run rake import:macos:plan, or name one in PLAN")
- end
-
- # A finished plan's final state: moved under Config::DONE, nothing
- # left to carry. The finalize task files one away as its last step;
- # the status read sweeps any a dead run left behind.
- def self.file_away(plan)
- FileUtils.mkdir_p(Config::DONE)
- FileUtils.mv(plan.dir, Config::DONE / plan.dir.basename)
- end
-end
-
-namespace :import do
- namespace :macos do
- desc "Plan importing this Mac's iCloud contacts into data/import/active (LIMIT=n for the first n)"
- task :plan do
- require "pro_tacts/import/macos"
-
- created_at = Time.now.utc
- dir = ImportTasks::Config::ACTIVE / "macos-#{created_at.strftime("%Y%m%dT%H%M%SZ")}"
- limit = ENV.fetch("LIMIT", nil)&.then { Integer(it) }
-
- records = ProTacts::Import::Macos.read(limit:)
- plan = ProTacts::Import::Macos.plan(dir, records, created_at:)
- phone = ->(row) { [row.fetch("number"), row["label"]].compact.join(" ") }
- plan.contacts.each do |contact|
- card = plan.card(contact.id)
- puts "#{contact.id} #{contact.name}#{card.phones.map { " #{phone.call(it)}" }.join}"
- end
- puts "#{plan.contacts.size} contacts planned in #{dir}"
- rescue ProTacts::Import::Macos::Unknown => error
- abort error.message
- end
-
- 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"
- require "pro_tacts/import/macos"
- require "pro_tacts/import/finalize"
-
- plan = ImportTasks.plan(ImportTasks::TO_FINALIZE, "to finalize")
- # 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, 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}" }
- puts "#{result.done} contacts finalized"
- if plan.done?
- ImportTasks.file_away(plan)
- puts "filed #{plan.dir.basename} away in #{ImportTasks::Config::DONE}"
- end
- end
- end
-
- 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"
-
- # A finished plan files away; this sweeps any a dead run left in
- # active/, so the in-flight list below is what still has work —
- # which is also why nothing in it is ever finished: the sweep took
- # it.
- ProTacts::Import::Plan.all(ImportTasks::Config::ACTIVE).each do |plan|
- ImportTasks.file_away(plan) if plan.done?
- end
-
- plans = ProTacts::Import::Plan.all(ImportTasks::Config::ACTIVE)
- filed = ProTacts::Import::Plan.all(ImportTasks::Config::DONE)
- if plans.empty? && filed.empty?
- puts "no plans in #{ImportTasks::Config::ACTIVE}; run rake import:macos:plan"
- next
- end
-
- # 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 {
- "#{it.dir.basename} #{it.contacts.size - it.outstanding.size}/#{it.contacts.size} finalized"
- }
-
- finalizing = plans.find(&ImportTasks::TO_FINALIZE)
- 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
- # here.
- done = filed.map { "#{it.dir.basename} #{it.contacts.size} contacts" }
-
- # Sections separated by a blank line only where one follows
- # another, so a status with no plans in flight says nothing of
- # next steps and still reports its history.
- sections = [in_flight, next_up, done.empty? ? [] : ["done:", *done]].reject(&:empty?)
- puts sections.map { it.join("\n") }.join("\n\n")
- end
-end
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
new file mode 100644
index 0000000..bcb339c
--- /dev/null
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -0,0 +1,190 @@
+require_relative "../../test_helper"
+
+require "pathname"
+require "rack/test"
+require "tmpdir"
+
+require "pro_tacts/web"
+
+# The import screens, exercised the way the other admin screens are:
+# real requests through the Roda app against a throwaway store
+# (docs/plans/2026-09-21-import-a-vcf.md).
+class AdminImportPagesTest < Minitest::Test
+ include Rack::Test::Methods
+ include ThrowawayContacts
+
+ JANE = <<~CARD.gsub("\n", "\r\n")
+ BEGIN:VCARD
+ VERSION:3.0
+ N:Booles;Jane;;;
+ FN:Jane Booles
+ item1.X-ABRELATEDNAMES:Sam Booles
+ item1.X-ABLabel:_$!<Spouse>!$_
+ X-SOCIALPROFILE;type=twitter:https://twitter.com/jb
+ UID:ABC-123
+ END:VCARD
+ CARD
+
+ PLAIN = <<~CARD.gsub("\n", "\r\n")
+ BEGIN:VCARD
+ VERSION:3.0
+ N:Booles;Sam;;;
+ FN:Sam Booles
+ UID:DEF-456
+ END:VCARD
+ CARD
+
+ def app
+ ProTacts::Web
+ end
+
+ def setup
+ header "Remote-User", "test@example.com"
+ end
+
+ # The file as a browser sends one, written out because Rack::Test
+ # uploads a path.
+ def upload(bytes, name: "contacts.vcf")
+ Dir.mktmpdir do |tmp|
+ path = Pathname.new(tmp) / name
+ path.binwrite(bytes)
+ post "/import", "vcf" => Rack::Test::UploadedFile.new(path.to_s, "text/vcard", true)
+ end
+ end
+
+ # The ticket to the staged file, as the review screen carries it.
+ def staged = last_response.body[/name="upload" value="([a-z]+)"/, 1]
+
+ def test_the_screen_asks_for_a_vcf
+ 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="vcf" accept=".vcf,text/vcard">)
+ end
+
+ def test_an_upload_is_reviewed_before_anything_lands
+ with_contacts({}) do |store|
+ upload(JANE + PLAIN)
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, "contacts.vcf"
+ assert_includes last_response.body, "2 contacts, ready to land."
+ assert_empty store.changes
+ end
+ end
+
+ # The one question the screen exists to ask, with the file's own
+ # values under it so it can be answered by looking.
+ def test_the_review_asks_what_to_do_with_each_unknown_property
+ with_contacts({}) { upload(JANE) }
+
+ assert_includes last_response.body, "X-ABRELATEDNAMES (1 line)"
+ assert_includes last_response.body, "X-SOCIALPROFILE (1 line)"
+ assert_includes last_response.body, "item1.X-ABRELATEDNAMES:Sam Booles"
+ %w[keep drop note].each do |choice|
+ assert_includes last_response.body,
+ %(<input type="radio" name="decide[X-SOCIALPROFILE]" value="#{choice}")
+ end
+ # Keeping loses nothing, so it is what an unread form sends.
+ assert_includes last_response.body,
+ %(name="decide[X-SOCIALPROFILE]" value="keep" checked>)
+ end
+
+ def test_a_file_this_book_reads_whole_has_nothing_to_decide
+ with_contacts({}) { upload(PLAIN) }
+
+ assert_includes last_response.body, "Nothing to decide."
+ refute_includes last_response.body, "decide["
+ end
+
+ def test_confirming_lands_the_cards_with_the_decisions_applied
+ with_contacts({}) do |store|
+ upload(JANE)
+
+ post "/import/land",
+ "upload" => staged,
+ "group" => "import-20260921T031655Z",
+ "decide" => {"X-SOCIALPROFILE" => "drop", "X-ABRELATEDNAMES" => "note"}
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, "landed (1)"
+
+ contact = store.contacts.fetch(0)
+
+ assert_equal "Jane Booles", contact.name
+ assert_includes last_response.body, %(href="/contacts/#{contact.id}")
+ refute_includes contact.vcard.to_s, "X-SOCIALPROFILE"
+ assert_includes contact.vcard.to_s, "NOTE:Spouse: Sam Booles"
+ assert_includes store.all_groups.map(&:name), "import-20260921T031655Z"
+ end
+ end
+
+ # The default is the choice that loses nothing, so a form submitted
+ # without touching a radio keeps everything.
+ def test_confirming_with_no_answers_keeps_everything
+ with_contacts({}) do |store|
+ upload(JANE)
+
+ post "/import/land", "upload" => staged, "group" => ""
+
+ assert_includes store.contacts.fetch(0).vcard.to_s, "X-SOCIALPROFILE"
+ assert_equal [ProTacts::Store::EVERYONE], store.all_groups.map(&:name)
+ end
+ end
+
+ # Swept out from under a review screen left open, or a confirm
+ # submitted twice: the file is gone and only the person has another.
+ def test_a_confirm_with_no_staged_file_lands_nothing
+ with_contacts({}) do |store|
+ upload(JANE)
+ id = staged
+
+ post "/import/land", "upload" => id, "group" => ""
+ post "/import/land", "upload" => id, "group" => ""
+
+ assert_includes last_response.body, "That upload is no longer here."
+ assert_equal 1, store.contacts.length
+ end
+ end
+
+ # An id off a form is a filename if nothing checks it.
+ def test_an_upload_id_this_server_never_minted_reads_nothing
+ with_contacts({}) do |store|
+ post "/import/land", "upload" => "../../contacts.db", "group" => ""
+
+ assert_includes last_response.body, "That upload is no longer here."
+ assert_empty store.changes
+ end
+ end
+
+ def test_an_upload_with_no_file_says_what_to_choose
+ with_contacts({}) do |store|
+ post "/import"
+
+ assert_includes last_response.body, "Choose a .vcf file to import."
+ assert_empty store.changes
+ end
+ end
+
+ def test_a_file_that_is_not_a_set_of_vcards_lands_nothing
+ with_contacts({}) do |store|
+ upload("hello\r\n")
+
+ assert_includes last_response.body, "a line outside a 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_file_that_is_not_utf_8_lands_nothing
+ with_contacts({}) do |store|
+ upload(JANE.b.sub("Jane".b, "Jan\xFF".b))
+
+ assert_includes last_response.body, "contacts.vcf is not UTF-8 text."
+ assert_empty store.changes
+ end
+ end
+end
diff --git a/test/pro_tacts/admin/test_plan_upload.rb b/test/pro_tacts/admin/test_plan_upload.rb
deleted file mode 100644
index b911ced..0000000
--- a/test/pro_tacts/admin/test_plan_upload.rb
+++ /dev/null
@@ -1,133 +0,0 @@
-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_card.rb b/test/pro_tacts/import/test_card.rb
deleted file mode 100644
index 70adc43..0000000
--- a/test/pro_tacts/import/test_card.rb
+++ /dev/null
@@ -1,193 +0,0 @@
-require_relative "../../test_helper"
-
-require "pathname"
-require "tmpdir"
-
-require "pro_tacts/import/card"
-
-class ImportCardTest < Minitest::Test
- Card = ProTacts::Import::Card
-
- ADA = <<~YAML
- first: Ada
- last: Lovelace
- nickname: The Countess
- birthday: '1815-12-10'
- phones:
- - number: "+12532189075"
- - number: "+17863533882"
- label: Google Voice
- emails:
- - ada@example.com
- addresses:
- - street: 12 Marylebone
- locality: London
- note: An analyst.
- photo: false
- groups:
- - sync:*
- - import-20260916T180412Z
- source:
- identifier: A:ABPerson
- vcard: "BEGIN:VCARD\\r\\nEND:VCARD\\r\\n"
- note:
- contact: {}
- YAML
-
- SOURCE = {"identifier" => "A:ABPerson", "vcard" => "BEGIN:VCARD\r\nEND:VCARD\r\n", "note" => nil, "contact" => {}}.freeze
-
- def read(yaml)
- Dir.mktmpdir do |tmp|
- path = Pathname.new(tmp) / "kmnuqmzxylru.yml"
- path.write(yaml)
- Card.read(path)
- end
- end
-
- def assert_invalid(yaml, message)
- error = assert_raises(Card::Invalid) { read(yaml) }
- assert_includes error.message, message
- end
-
- def test_a_card_reads_its_fields
- assert_equal Card.new(first: "Ada", last: "Lovelace", nickname: "The Countess", birthday: "1815-12-10", phones: [{"number" => "+12532189075"}, {"number" => "+17863533882", "label" => "Google Voice"}], emails: ["ada@example.com"], addresses: [{"street" => "12 Marylebone", "locality" => "London"}], note: "An analyst.", photo: false, groups: ["sync:*", "import-20260916T180412Z"], source: SOURCE), read(ADA)
- end
-
- def test_a_card_is_the_contact_the_web_create_and_add_rows_make
- contact = read(ADA).contact("kmnuqmzxylru")
-
- assert_equal "kmnuqmzxylru", contact.id
- assert_equal <<~CARD.gsub("\n", "\r\n"), contact.vcard.to_s
- BEGIN:VCARD
- VERSION:3.0
- N:Lovelace;Ada;;;
- FN:Ada Lovelace
- UID:kmnuqmzxylru
- NICKNAME:The Countess
- NOTE:An analyst.
- TEL:+12532189075
- EMAIL:ada@example.com
- ADR:;;12 Marylebone;London;;;
- item1.TEL:+17863533882
- item1.X-ABLabel:Google Voice
- BDAY:1815-12-10
- END:VCARD
- CARD
- end
-
- def test_a_name_is_escaped_into_the_card
- card = Card.new(first: "Ada; Countess", last: "", nickname: nil, birthday: nil, phones: [], emails: [], addresses: [], note: nil, photo: false, groups: [], source: SOURCE)
-
- assert_equal ["Ada; Countess"], [card.contact("kmnuqmzxylru").name]
- end
-
- def test_the_document_reads_back_as_the_card
- card = read(ADA)
-
- assert_equal card, read(YAML.dump(card.document))
- end
-
- def test_a_missing_field_is_refused
- assert_invalid ADA.sub("phones:\n- number: \"+12532189075\"\n- number: \"+17863533882\"\n label: Google Voice\n", ""), "has no phones"
- end
-
- def test_a_field_no_card_takes_is_refused
- assert_invalid "#{ADA}url: https://example.com\n", "has url, which no field takes"
- end
-
- def test_an_unquoted_number_is_refused
- assert_invalid ADA.sub('number: "+12532189075"', "number: +12532189075"), "phones must be a list, each with a quoted number and an optional label"
- end
-
- # The shape a plan built before labels carried, refused rather than
- # read leniently: no plan is re-read, but a stale one a person lands
- # by hand is news, not a silent loss of every number
- # (docs/plans/2026-09-18-phone-labels.md, "The plan card carries
- # one").
- def test_a_bare_string_phone_is_refused
- assert_invalid ADA.sub("phones:\n- number: \"+12532189075\"\n- number: \"+17863533882\"\n label: Google Voice\n", "phones:\n- \"+12532189075\"\n"), "phones must be a list, each with a quoted number and an optional label"
- end
-
- def test_a_phone_with_no_number_is_refused
- assert_invalid ADA.sub("- number: \"+17863533882\"\n label: Google Voice", "- number: ''\n label: Google Voice"), "phones must be a list"
- end
-
- # The label the builder leaves out rather than writes empty, an edit
- # putting back as one.
- def test_a_blank_label_is_refused
- assert_invalid ADA.sub("label: Google Voice", "label: ''"), "phones must be a list"
- end
-
- # A label is text, escaped as text is: a comma in one must not read
- # as structure when the card is read back.
- def test_a_label_is_escaped_into_its_line
- labeled = ADA.sub("label: Google Voice", "label: Work, mobile")
-
- assert_includes read(labeled).contact("kmnuqmzxylru").vcard.to_s, "item1.X-ABLabel:Work\\, mobile\r\n"
- end
-
- def test_a_name_yaml_reads_as_something_else_is_refused
- assert_invalid ADA.sub("last: Lovelace", "last: no"), "first and last must be text"
- end
-
- def test_a_card_with_no_name_is_refused
- assert_invalid ADA.sub("first: Ada", "first: ''").sub("last: Lovelace", "last: ' '"), "needs a first or last name"
- end
-
- def test_an_unquoted_date_is_refused
- assert_invalid ADA.sub("birthday: '1815-12-10'", "birthday: 1815-12-10"), "reads as a Date; quote it"
- end
-
- def test_a_birthday_that_is_not_a_whole_date_is_refused
- assert_invalid ADA.sub("birthday: '1815-12-10'", "birthday: '--12-10'"), "birthday must be a quoted YYYY-MM-DD"
- end
-
- def test_a_card_with_no_birthday_reads_and_writes_the_blank
- card = read(ADA.sub("birthday: '1815-12-10'", "birthday:"))
-
- assert_nil card.birthday
- refute_includes card.contact("kmnuqmzxylru").vcard.to_s, "BDAY"
- end
-
- def test_an_address_part_no_field_takes_is_refused
- assert_invalid ADA.sub(" locality: London", " po_box: '4'"), "addresses must be a list, each with any of"
- end
-
- def test_a_picture_the_source_holds_is_a_photo_line
- jpeg = ["\xFF\xD8\xFFhello".b].pack("m0")
- source = SOURCE.merge("contact" => {"imageData" => jpeg})
- card = read(ADA.sub("photo: false", "photo: true").sub(" contact: {}", " contact:\n imageData: #{jpeg}"))
-
- assert_equal source.fetch("contact"), card.source.fetch("contact")
- assert_includes card.contact("kmnuqmzxylru").vcard.to_s, "PHOTO;ENCODING=b;TYPE=JPEG:#{jpeg}\r\n"
- assert_equal "image/jpeg", card.contact("kmnuqmzxylru").photo.mime_type
- end
-
- def test_a_thumbnail_is_the_picture_when_the_source_holds_no_image
- jpeg = ["\xFF\xD8\xFFthumbnail".b].pack("m0")
- card = read(ADA.sub("photo: false", "photo: true").sub(" contact: {}", " contact:\n imageData:\n thumbnailImageData: #{jpeg}"))
-
- assert_includes card.contact("kmnuqmzxylru").vcard.to_s, "PHOTO;ENCODING=b;TYPE=JPEG:#{jpeg}\r\n"
- end
-
- def test_the_full_image_wins_over_the_thumbnail
- image = ["\xFF\xD8\xFFimage".b].pack("m0")
- thumbnail = ["\xFF\xD8\xFFthumbnail".b].pack("m0")
- card = read(ADA.sub("photo: false", "photo: true")
- .sub(" contact: {}", " contact:\n imageData: #{image}\n thumbnailImageData: #{thumbnail}"))
-
- assert_includes card.contact("kmnuqmzxylru").vcard.to_s, "PHOTO;ENCODING=b;TYPE=JPEG:#{image}\r\n"
- end
-
- def test_a_photo_the_source_cannot_supply_is_refused
- assert_invalid ADA.sub("photo: false", "photo: true"), "photo is true and the source holds no picture"
- end
-
- def test_a_blank_group_is_refused
- assert_invalid ADA.sub("- sync:*", "- ''"), "groups must be a list of names"
- end
-
- def test_a_file_that_is_not_a_mapping_is_refused
- assert_invalid "- Ada\n", "is not a mapping"
- end
-end
diff --git a/test/pro_tacts/import/test_config.rb b/test/pro_tacts/import/test_config.rb
deleted file mode 100644
index 60e35ca..0000000
--- a/test/pro_tacts/import/test_config.rb
+++ /dev/null
@@ -1,111 +0,0 @@
-require_relative "../../test_helper"
-
-require "pathname"
-require "tmpdir"
-require "uri"
-
-require "pro_tacts/import/config"
-
-class ImportConfigTest < Minitest::Test
- Config = ProTacts::Import::Config
-
- def test_the_mapping_is_read
- with_config_file("host: https://contacts\n") do |path|
- assert_equal Config.new(host: URI.parse("https://contacts")), Config.read(path)
- end
- end
-
- def test_a_bare_hostname_is_read_as_https
- with_config_file("host: contacts\n") do |path|
- assert_equal URI.parse("https://contacts"), Config.read(path).host
- end
- end
-
- def test_read_names_the_config_under_the_data_directory_by_default
- Dir.mktmpdir do |dir|
- Dir.chdir(dir) do
- error = assert_raises(ArgumentError) { Config.read }
-
- assert_equal "data/import/config.yml: does not exist", error.message
- end
- end
- end
-
- def test_a_missing_file_is_refused
- Dir.mktmpdir do |dir|
- path = Pathname.new(dir) / "config.yml"
-
- error = assert_raises(ArgumentError) { Config.read(path) }
-
- assert_equal "#{path}: does not exist", error.message
- end
- end
-
- def test_an_empty_file_has_no_host
- with_config_file("") do |path|
- error = assert_raises(ArgumentError) { Config.read(path) }
-
- assert_equal "#{path}: has no host", error.message
- end
- end
-
- def test_a_file_whose_top_level_is_not_a_mapping_is_refused
- with_config_file("- one\n- two\n") do |path|
- error = assert_raises(ArgumentError) { Config.read(path) }
-
- assert_equal "#{path}: is not a mapping", error.message
- end
- end
-
- def test_a_key_no_member_takes_is_refused
- with_config_file("hots: contacts\n") do |path|
- error = assert_raises(ArgumentError) { Config.read(path) }
-
- assert_equal "#{path}: has hots, which no member takes", error.message
- end
- end
-
- def test_a_host_that_is_not_text_is_refused
- with_config_file("host: 9292\n") do |path|
- error = assert_raises(ArgumentError) { Config.read(path) }
-
- assert_equal "#{path}: host must be text", error.message
- end
- end
-
- def test_a_blank_host_is_refused
- with_config_file("host: \"\"\n") do |path|
- error = assert_raises(ArgumentError) { Config.read(path) }
-
- assert_equal "#{path}: host is blank", error.message
- end
- end
-
- def test_a_host_that_does_not_read_as_a_url_is_refused
- with_config_file("host: exa mple\n") do |path|
- error = assert_raises(ArgumentError) { Config.read(path) }
-
- assert_equal "#{path}: host does not read as a URL", error.message
- end
- end
-
- def test_a_host_that_is_not_http_or_https_is_refused
- with_config_file("host: ftp://example.com\n") do |path|
- error = assert_raises(ArgumentError) { Config.read(path) }
-
- assert_equal "#{path}: host must be an http or https URL, such as https://contacts", error.message
- end
- end
-
- private
-
- #: (String content) { (Pathname) -> void } -> void
- def with_config_file(content)
- Dir.mktmpdir do |dir|
- path = Pathname.new(dir) / "config.yml"
- path.write(content)
-
- yield path
- end
- end
-end
diff --git a/test/pro_tacts/import/test_finalize.rb b/test/pro_tacts/import/test_finalize.rb
deleted file mode 100644
index 9aec686..0000000
--- a/test/pro_tacts/import/test_finalize.rb
+++ /dev/null
@@ -1,195 +0,0 @@
-require_relative "../../test_helper"
-
-require "pathname"
-require "rack/mock"
-require "tmpdir"
-
-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
- 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
- # does (docs/plans/2026-09-16-importing-from-macos.md, "Removing the
- # originals").
- class Mac
- attr_reader :deleted
-
- def initialize(records, refuses: {})
- @records = records
- @refuses = refuses
- @deleted = []
- end
-
- def show(source_ids) = @records.slice(*source_ids)
-
- def delete(source_ids)
- going = source_ids - @refuses.keys
- @deleted.concat(going)
- @records = @records.except(*going)
- @refuses.slice(*source_ids)
- end
- end
-
- def vcard(id) = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Contact #{id}\r\nEND:VCARD\r\n"
-
- def source_id(id) = "#{id}:ABPerson"
-
- def record(id, note: nil)
- {"identifier" => source_id(id), "vcard" => vcard(id), "note" => note}
- end
-
- # A plan already landed on the host, since that is what finalize works on.
- def with_landed_plan(notes: {}, edit: nil)
- Dir.mktmpdir do |tmp|
- dir = Pathname.new(tmp) / "plan"
- entries = IDS.map { |id|
- source = {"identifier" => source_id(id), "vcard" => vcard(id), "note" => notes[id], "contact" => {}}
- card = 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: source_id(id), card:)
- }
- 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|
- plan = Plan.read(dir)
- Land.call(store, IDS.to_h { [it, plan.card(it)] })
- yield plan, store, RackClient.new
- end
- end
- end
-
- def test_a_contact_that_matches_its_source_leaves_the_mac
- with_landed_plan do |plan, _store, client|
- mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
-
- result = Finalize.call(plan, host: HOST, client:, mac:)
-
- assert_equal 2, result.done
- assert_empty result.kept
- assert_equal IDS.map { source_id(it) }, mac.deleted
- assert_equal %w[done done], Plan.read(plan.dir).contacts.map(&:status)
- end
- end
-
- def test_a_contact_changed_on_the_mac_is_kept
- with_landed_plan do |plan, _store, client|
- records = IDS.to_h { [source_id(it), record(it)] }
- 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, 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 [nil, "done"], Plan.read(plan.dir).contacts.map(&:status)
- end
- end
-
- def test_a_note_written_since_the_plan_keeps_its_contact
- with_landed_plan(notes: {IDS.first => "Analyst."}) do |plan, _store, client|
- records = IDS.to_h { [source_id(it), record(it)] }
- records[source_id(IDS.first)]["note"] = "Analyst. Also a countess."
- mac = Mac.new(records)
-
- 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
- end
- end
-
- def test_a_contact_whose_card_left_the_host_is_kept
- with_landed_plan do |plan, store, client|
- store.delete(IDS.first)
- mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
-
- result = Finalize.call(plan, host: HOST, client:, mac:)
-
- 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
-
- # The card browser shows every card; /dav/addressbook serves the asking
- # user's book, which a card in no `sync:` group is outside of.
- def test_a_contact_whose_card_is_in_no_book_still_leaves_the_mac
- with_landed_plan(edit: ->(yaml) { yaml.sub("- sync:*\n", "") }) do |plan, store, client|
- 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, host: HOST, client:, mac:)
-
- assert_empty result.kept
- assert_equal IDS.map { source_id(it) }, mac.deleted
- end
- end
-
- def test_a_contact_already_off_the_mac_counts_as_done
- with_landed_plan do |plan, _store, client|
- mac = Mac.new({source_id(IDS.last) => record(IDS.last)})
-
- result = Finalize.call(plan, host: HOST, client:, mac:)
-
- assert_equal 2, result.done
- assert_equal [source_id(IDS.last)], mac.deleted
- assert_equal %w[done done], Plan.read(plan.dir).contacts.map(&:status)
- end
- end
-
- # The store refuses some contacts it will hand over quite happily, so a
- # refusal keeps its own contact and no other: the rest still leave, and
- # the plan records them.
- def test_a_contact_the_mac_will_not_delete_is_kept_alone
- 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, 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 [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, host: HOST, 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
- end
- end
-end
diff --git a/test/pro_tacts/import/test_land.rb b/test/pro_tacts/import/test_land.rb
index 2b224fd..f50ab47 100644
--- a/test/pro_tacts/import/test_land.rb
+++ b/test/pro_tacts/import/test_land.rb
@@ -1,122 +1,158 @@
require_relative "../../test_helper"
-require "pathname"
-require "tmpdir"
-
require "pro_tacts/import/land"
-require "pro_tacts/import/plan"
-require "pro_tacts/web"
+require "pro_tacts/import/vcf"
-# 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.
+# Landing an uploaded .vcf's cards, with the importer's decisions
+# spent on the way in (docs/plans/2026-09-21-import-a-vcf.md).
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 }
+ Vcf = ProTacts::Import::Vcf
+
+ JANE = <<~CARD.gsub("\n", "\r\n")
+ BEGIN:VCARD
+ VERSION:3.0
+ N:Booles;Jane;;;
+ FN:Jane Booles
+ NOTE:Met at work
+ item1.X-ABRELATEDNAMES:Sam Booles
+ item1.X-ABLabel:_$!<Spouse>!$_
+ X-SOCIALPROFILE;type=twitter:https://twitter.com/jb
+ UID:ABC-123
+ END:VCARD
+ CARD
+
+ def land(decisions, cards: Vcf.cards(JANE), group: nil)
+ with_contacts({}) do |store|
+ landed = Land.call(store, cards, decisions:, group:)
+ yield landed, store
end
end
- def cards(plan) = IDS.to_h { [it, plan.card(it)] }
-
- def imported_group(store) = group_named(store, "import-20260916T180412Z")
+ # Nothing rebuilds a card out of fields: the bytes that arrived are
+ # the bytes that are stored, minus the id this server gives it.
+ def test_a_kept_property_travels_with_the_card
+ land({"X-SOCIALPROFILE" => Vcf::KEEP}) do |landed, _store|
+ card = landed.fetch(0).vcard.to_s
- def everyone(store) = group_named(store, "sync:*")
-
- def group_named(store, name)
- store.all_groups.find { it.name == name }
+ assert_includes card, "X-SOCIALPROFILE;type=twitter:https://twitter.com/jb\r\n"
+ assert_includes card, "item1.X-ABRELATEDNAMES:Sam Booles\r\n"
+ end
end
- def edit_cards(plan)
- IDS.each do |id|
- path = plan.dir / "cards/#{id}.yml"
- path.write(yield(path.read))
+ # A property nobody was asked about is kept, which is every known
+ # one and any unknown one the form said nothing about.
+ def test_a_property_nobody_decided_is_kept
+ land({}) do |landed, _store|
+ assert_includes landed.fetch(0).vcard.to_s, "X-SOCIALPROFILE"
end
end
- def test_every_card_lands_in_the_plans_group
- with_plan do |plan, store|
- arrivals = Land.call(store, cards(plan))
+ def test_a_dropped_property_is_gone
+ land({"X-SOCIALPROFILE" => Vcf::DROP}) do |landed, _store|
+ card = landed.fetch(0).vcard.to_s
- 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 }
+ refute_includes card, "X-SOCIALPROFILE"
+ assert_includes card, "FN:Jane Booles\r\n"
end
end
- def test_a_second_upload_writes_nothing_again
- with_plan do |plan, store|
- Land.call(store, cards(plan))
- changes = store.changes.size
+ # Apple hangs a row's label off the line it names rather than inside
+ # it, so a label whose line is dropped would be left naming nothing.
+ def test_a_dropped_line_takes_its_label_with_it
+ land({"X-ABRELATEDNAMES" => Vcf::DROP}) do |landed, _store|
+ card = landed.fetch(0).vcard.to_s
- arrivals = Land.call(store, cards(plan))
+ refute_includes card, "X-ABRELATEDNAMES"
+ refute_includes card, "X-ABLabel"
+ end
+ end
- assert_equal [false, false], arrivals.map(&:stored)
- assert_equal changes, store.changes.size
- assert_equal 1, store.all_groups.count { it.name == "import-20260916T180412Z" }
+ # The note is where a value goes when it is worth reading and there
+ # is no field to read it in. It is called what the row was called,
+ # not what the property was named.
+ def test_a_noted_property_is_written_under_the_note_by_its_label
+ land({"X-ABRELATEDNAMES" => Vcf::NOTE}) do |landed, _store|
+ card = landed.fetch(0).vcard.to_s
+
+ refute_includes card, "X-ABRELATEDNAMES"
+ refute_includes card, "X-ABLabel"
+ # One NOTE, the card's own and the addition, joined by the
+ # escape a line break inside a text value is written as.
+ assert_includes card, "NOTE:Met at work\\nSpouse: Sam Booles\r\n"
+ assert_equal 1, card.scan("NOTE:").length
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") }
+ def test_a_noted_property_with_no_label_is_written_under_its_own_name
+ land({"X-SOCIALPROFILE" => Vcf::NOTE}) do |landed, _store|
+ assert_includes landed.fetch(0).vcard.to_s,
+ "NOTE:Met at work\\nX-SOCIALPROFILE: https://twitter.com/jb\r\n"
+ end
+ end
- arrivals = Land.call(store, cards(plan))
+ def test_a_card_with_no_note_gets_one
+ cards = Vcf.cards(JANE.sub("NOTE:Met at work\r\n", ""))
- assert_equal [false, false], arrivals.map(&:stored)
- IDS.each { assert_equal "Contact #{it}", store.contact(it).name }
+ land({"X-ABRELATEDNAMES" => Vcf::NOTE}, cards:) do |landed, _store|
+ assert_includes landed.fetch(0).vcard.to_s, "NOTE:Spouse: Sam Booles\r\n"
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")
+ # 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_lands_under_a_minted_id_its_uid_spells
+ land({}) do |landed, store|
+ id = landed.fetch(0).id
- Land.call(store, cards(plan))
-
- assert_equal id, imported_group(store).id
- assert_equal IDS.sort, store.group(id).members.sort
+ assert ProTacts::ChangeId.minted?(id)
+ refute_includes landed.fetch(0).vcard.to_s, "ABC-123"
+ assert_includes landed.fetch(0).vcard.to_s, "UID:#{id}\r\n"
+ assert_equal id, store.card_id_with_uid(id)
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))
+ # A card this creates joins everyone's book the way a client's
+ # create does, and the import's own group besides.
+ def test_arrivals_join_the_import_group_and_everyones_book
+ land({}, cards: Vcf.cards(JANE + JANE), group: "import-20260921T031655Z") do |landed, store|
+ ids = landed.map(&:id)
+
+ assert_equal 2, ids.uniq.length
+ groups = store.all_groups.select { it.members.sort == ids.sort }
- assert_equal IDS.sort, group_named(store, "family").members.sort
- assert_equal 1, store.all_groups.count { it.name == "family" }
+ assert_equal [ProTacts::Store::EVERYONE, "import-20260921T031655Z"],
+ groups.map(&:name).sort
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", "") }
+ # The group is looked up before it is made, and the first card's put
+ # is what creates `sync:*` — so a list read once up front would send
+ # the next lookup to create a group that now exists
+ # (db/migrations/008_group_names.rb).
+ def test_landing_into_a_group_that_already_exists_joins_it
+ with_contacts({}) do |store|
+ existing = store.create_group(name: "import-20260921T031655Z")
- Land.call(store, cards(plan))
+ landed = Land.call(store, Vcf.cards(JANE + JANE), decisions: {}, group: "import-20260921T031655Z")
- assert_empty everyone(store).members
- assert_equal IDS.sort, imported_group(store).members.sort
+ assert_equal landed.map(&:id).sort, store.group(existing).members.sort
+ assert_equal 1, store.all_groups.count { it.name == "import-20260921T031655Z" }
end
end
+
+ def test_no_group_name_lands_the_cards_in_everyones_book_alone
+ land({}) do |landed, store|
+ assert_equal [ProTacts::Store::EVERYONE], store.all_groups.map(&:name)
+ assert_equal landed.map(&:id), store.all_groups.fetch(0).members
+ end
+ end
+
+ # Named for when it happened, so what landed together can be found
+ # together.
+ def test_the_default_group_is_named_for_the_moment
+ assert_equal "import-20260921T031655Z", Land.default_group(Time.utc(2026, 9, 21, 3, 16, 55))
+ end
end
diff --git a/test/pro_tacts/import/test_macos.rb b/test/pro_tacts/import/test_macos.rb
deleted file mode 100644
index 1b41440..0000000
--- a/test/pro_tacts/import/test_macos.rb
+++ /dev/null
@@ -1,559 +0,0 @@
-require_relative "../../test_helper"
-
-require "json"
-require "pathname"
-require "tmpdir"
-
-require "pro_tacts/import/macos"
-require "pro_tacts/import/plan"
-
-class ImportMacosTest < Minitest::Test
- Macos = ProTacts::Import::Macos
-
- Card = ProTacts::Import::Card
-
- CREATED_AT = Time.utc(2026, 9, 16, 18, 4, 12)
- NAME = ["N:Lovelace;Ada;;;", "FN:Ada Lovelace"].freeze
- GROUPS = ["sync:*", "import-20260916T180412Z"].freeze
-
- def record(identifier, name: NAME, lines: [], note: nil, image: nil, thumbnail: nil)
- {
- "identifier" => identifier,
- "vcard" => ["BEGIN:VCARD", "VERSION:3.0", *name, *lines, "END:VCARD", ""].join("\r\n"),
- "contact" => {"identifier" => identifier, "imageData" => image, "thumbnailImageData" => thumbnail},
- "note" => note
- }
- end
-
- def in_tmpdir
- Dir.mktmpdir { yield Pathname.new(it) / "plan" }
- end
-
- def test_a_named_card_is_planned_under_a_minted_id
- in_tmpdir do |dir|
- plan = Macos.plan(dir, [record("A:ABPerson")], created_at: CREATED_AT)
-
- assert_equal 1, plan.contacts.size
- contact = plan.contacts.first
- assert_match(/\A[k-z]{12}\z/, contact.id)
- assert_equal "A:ABPerson", contact.source_id
- card = plan.card(contact.id)
- assert_equal ["Ada", "Lovelace", nil, nil, [], [], [], GROUPS],
- [card.first, card.last, card.nickname, card.birthday, card.phones, card.emails, card.addresses, card.groups]
- end
- end
-
- def test_the_card_holds_the_source_it_was_read_from
- in_tmpdir do |dir|
- read = record("A:ABPerson")
- read["contact"] = read.fetch("contact").merge("givenName" => "Ada")
- plan = Macos.plan(dir, [read], created_at: CREATED_AT)
-
- source = plan.card(plan.contacts.first.id).source
- assert_equal read.fetch("vcard"), source.fetch("vcard")
- assert_equal "A:ABPerson", source.fetch("identifier")
- assert_nil source.fetch("note")
- assert_equal read.fetch("contact"), source.fetch("contact")
- end
- end
-
- def test_every_unknown_field_is_named_and_no_plan_is_written
- in_tmpdir do |dir|
- records = [
- record("A:ABPerson", lines: ["ROLE:Analyst", "EMAIL:ada@example.com"], note: "Analyst."),
- record("B:ABPerson", lines: ["EMAIL:mary@example.com"], image: "/9j/")
- ]
-
- error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
- assert_equal <<~MESSAGE.chomp, error.message
- no branch handles these fields, so no plan was written:
- EMAIL: 2
- A:ABPerson: "EMAIL:ada@example.com"
- B:ABPerson: "EMAIL:mary@example.com"
- ROLE: 1
- A:ABPerson: "ROLE:Analyst"
- MESSAGE
- refute dir.exist?
- end
- end
-
- def test_examples_are_distinct_unfolded_and_cut_short
- in_tmpdir do |dir|
- records = %w[A B C D].map { record("#{it}:ABPerson", lines: ["PHOTO;ENCODING=b:#{"A" * 60}\r\n #{"A" * 60}"]) }
- records << record("E:ABPerson", lines: ["NOTE:one\\ntwo"], note: "one\ntwo")
- records << record("F:ABPerson", lines: ["NOTE:one\\ntwo"])
-
- error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
- assert_equal <<~MESSAGE.chomp, error.message
- no branch handles these fields, so no plan was written:
- NOTE: 2
- E:ABPerson: "NOTE:one\\\\ntwo"
- PHOTO: 4
- A:ABPerson: "PHOTO;ENCODING=b:#{"A" * 83}… (137 characters)"
- MESSAGE
- end
- end
-
- def test_a_cell_number_is_a_phone
- in_tmpdir do |dir|
- lines = ["TEL;type=CELL;type=VOICE;type=pref:+12532189075", "TEL;type=CELL;type=VOICE;type=pref:+12532189076"]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
- assert_equal [{"number" => "+12532189075"}, {"number" => "+12532189076"}], plan.card(plan.contacts.first.id).phones
- end
- end
-
- def test_a_name_is_read_unescaped
- in_tmpdir do |dir|
- plan = Macos.plan(dir, [record("A:ABPerson", name: ["N:O\\,Brien;Ada;;;", "FN:Ada O\\,Brien"])], created_at: CREATED_AT)
-
- assert_equal "O,Brien", plan.card(plan.contacts.first.id).last
- end
- end
-
- def test_an_address_a_birthday_and_the_emails_are_fields
- in_tmpdir do |dir|
- lines = [
- "item1.ADR;type=HOME;type=pref:;;12 Marylebone Rd;London;;NW1 5LS;England",
- "BDAY:1815-12-10",
- "EMAIL;type=INTERNET;type=HOME:ada@example.com",
- "EMAIL;type=INTERNET;type=WORK;type=pref:ada@analytical.example",
- "EMAIL;type=INTERNET:ada@lovelace.example"
- ]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
- card = plan.card(plan.contacts.first.id)
- assert_equal "1815-12-10", card.birthday
- assert_equal ["ada@example.com", "ada@analytical.example", "ada@lovelace.example"], card.emails
- assert_equal [{"street" => "12 Marylebone Rd", "locality" => "London", "postal_code" => "NW1 5LS", "country" => "England"}], card.addresses
- end
- end
-
- # An address is carried under its group already; an email labelled in
- # Contacts is the same shape, and its label goes where an address's
- # does. Nothing is lost that an unlabelled row would have kept: an
- # imported EMAIL line carries no type either (Admin::CardForm).
- def test_a_labelled_email_is_carried_and_its_label_dropped
- in_tmpdir do |dir|
- lines = [
- "item1.EMAIL;type=INTERNET;type=pref:ada@example.com",
- "item1.X-ABLabel:_$!<Home>!$_"
- ]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
- assert_equal ["ada@example.com"], plan.card(plan.contacts.first.id).emails
- end
- end
-
- # Contacts writes an address ungrouped when nothing labels it, and
- # grouped when something does; both are the one field.
- def test_an_ungrouped_address_is_an_address
- in_tmpdir do |dir|
- lines = ["ADR;type=HOME:;;12 Marylebone Rd;London;;NW1 5LS;England"]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
- assert_equal [{"street" => "12 Marylebone Rd", "locality" => "London", "postal_code" => "NW1 5LS", "country" => "England"}],
- plan.card(plan.contacts.first.id).addresses
- end
- end
-
- # The editor's address rows carry no type, so the work address would
- # land as an unmarked second one beside the home; the home is the one
- # the fields can hold, and the work stays in the source.
- def test_a_work_address_is_dropped_and_the_home_carried
- in_tmpdir do |dir|
- lines = [
- "ADR;type=HOME:;;12 Marylebone Rd;London;;NW1 5LS;",
- "ADR;type=WORK:;;17 Clerkenwell Rd;London;;EC1R 5BQ;",
- "item3.ADR;type=WORK;type=pref:;;4 Russell Sq;London;;WC1B 4JP;"
- ]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
- assert_equal [{"street" => "12 Marylebone Rd", "locality" => "London", "postal_code" => "NW1 5LS"}],
- plan.card(plan.contacts.first.id).addresses
- end
- end
-
- def test_a_work_address_alone_is_no_address_at_all
- in_tmpdir do |dir|
- lines = ["item1.ADR;type=WORK:;;17 Clerkenwell Rd;London;;EC1R 5BQ;"]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
- assert_empty plan.card(plan.contacts.first.id).addresses
- end
- end
-
- # A work number is a number, and one Contacts labelled is too — the
- # label riding the annotation rail to the row it names, where a
- # labelled email's and address's are still dropped
- # (docs/plans/2026-09-18-phone-labels.md).
- def test_a_work_number_and_a_labelled_one_are_phones
- in_tmpdir do |dir|
- lines = [
- "TEL;type=WORK;type=VOICE:+1 203-536-3941",
- "item1.TEL;type=pref:(206) 651-4359",
- "item1.X-ABLabel:school"
- ]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
- assert_equal [{"number" => "+1 203-536-3941"}, {"number" => "(206) 651-4359", "label" => "school"}],
- plan.card(plan.contacts.first.id).phones
- end
- end
-
- # Apple's own label vocabulary arrives wrapped, and a phone's label
- # unwraps the way a related name's does.
- def test_a_phone_with_an_apple_label_unwraps_it
- in_tmpdir do |dir|
- lines = [
- "item1.TEL;type=pref:+12532189075",
- "item1.X-ABLabel:_$!<Other>!$_"
- ]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
- assert_equal [{"number" => "+12532189075", "label" => "Other"}], plan.card(plan.contacts.first.id).phones
- end
- end
-
- # The main number, with the rank Contacts gives it: `type=pref` names
- # no kind and comes off the form, so the allowlist holds the spelling
- # without it and both arrive under the one entry.
- def test_a_main_number_is_a_phone_ranked_or_not
- in_tmpdir do |dir|
- records = [
- record("A:ABPerson", lines: ["TEL;type=MAIN;type=pref:+12532189075"]),
- record("B:ABPerson", lines: ["TEL;type=MAIN:(206) 651-4359"])
- ]
- plan = Macos.plan(dir, records, created_at: CREATED_AT)
-
- assert_equal [[{"number" => "+12532189075"}], [{"number" => "(206) 651-4359"}]],
- plan.contacts.map { plan.card(it.id).phones }
- end
- end
-
- # Where someone works has no field in this address book; the source
- # keeps the lines the card drops.
- def test_a_job_title_and_an_organization_are_dropped
- in_tmpdir do |dir|
- lines = ["TITLE:Countess of Lovelace", "ORG:Analytical Engine Co.;"]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
- card = plan.card(plan.contacts.first.id)
- assert_equal [[], [], []], [card.phones, card.emails, card.addresses]
- assert_includes card.source.fetch("vcard"), "TITLE:Countess of Lovelace"
- assert_includes card.source.fetch("vcard"), "ORG:Analytical Engine Co.;"
- end
- end
-
- def test_a_second_address_under_another_item_number_is_one_form
- in_tmpdir do |dir|
- lines = [
- "item1.ADR;type=HOME;type=pref:;;12 Marylebone Rd;London;;;",
- "item2.ADR;type=HOME:;;Ockham Park;Surrey;;;"
- ]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
- assert_equal ["12 Marylebone Rd", "Ockham Park"], plan.card(plan.contacts.first.id).addresses.map { it.fetch("street") }
- end
- end
-
- def test_every_kind_of_number_is_a_phone_and_an_empty_row_is_not
- in_tmpdir do |dir|
- lines = [
- "TEL;type=pref:+1 203-536-3941",
- "TEL;type=HOME;type=VOICE;type=pref:(206) 651-4359",
- "TEL;type=IPHONE;type=CELL;type=VOICE:",
- "TEL;type=CELL;type=VOICE:+12532189075",
- "TEL;type=OTHER;type=VOICE:+1 (425) 707-1712"
- ]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
- assert_equal [{"number" => "+1 203-536-3941"}, {"number" => "(206) 651-4359"}, {"number" => "+12532189075"},
- {"number" => "+1 (425) 707-1712"}], plan.card(plan.contacts.first.id).phones
- end
- end
-
- def test_a_nickname_is_a_field_and_an_instant_message_address_is_dropped
- in_tmpdir do |dir|
- lines = ["NICKNAME:The Countess", "IMPP;X-SERVICE-TYPE=Skype;type=HOME;type=pref:skype:ada"]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
- assert_equal "The Countess", plan.card(plan.contacts.first.id).nickname
- end
- end
-
- def test_the_note_and_the_picture_are_fields
- in_tmpdir do |dir|
- jpeg = ["\xFF\xD8\xFFhello".b].pack("m0")
- plan = Macos.plan(dir, [record("A:ABPerson", note: "Analyst.", image: jpeg)], created_at: CREATED_AT)
-
- card = plan.card(plan.contacts.first.id)
- assert_equal "Analyst.", card.note
- assert card.photo
- assert_equal "image/jpeg", card.contact(plan.contacts.first.id).photo.mime_type
- end
- end
-
- def test_a_contact_whose_picture_is_only_a_thumbnail_carries_it
- in_tmpdir do |dir|
- jpeg = ["\xFF\xD8\xFFthumbnail".b].pack("m0")
- plan = Macos.plan(dir, [record("A:ABPerson", thumbnail: jpeg)], created_at: CREATED_AT)
-
- card = plan.card(plan.contacts.first.id)
- assert card.photo
- assert_equal "image/jpeg", card.contact(plan.contacts.first.id).photo.mime_type
- end
- end
-
- def test_the_fields_this_address_book_does_not_have_are_dropped
- in_tmpdir do |dir|
- lines = [
- "IMPP;X-SERVICE-TYPE=Skype;type=HOME;type=pref:skype:ada",
- "X-SOCIALPROFILE;type=twitter:https://twitter.com/ada",
- "item1.X-APPLE-SUBADMINISTRATIVEAREA:Middlesex",
- "item1.X-APPLE-SUBLOCALITY:Marylebone",
- "X-AIM;type=HOME;type=pref:ada",
- "item2.URL;type=pref:https://example.com",
- "item2.X-ABLabel:_$!<HomePage>!$_",
- "item2.X-ABADR:us"
- ]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
- assert_equal "Ada Lovelace", plan.card(plan.contacts.first.id).contact("kmnuqmzxylru").name
- end
- end
-
- def test_a_related_name_is_written_under_the_note
- in_tmpdir do |dir|
- lines = [
- "item2.X-ABRELATEDNAMES;type=pref:Sylvia Lovelace",
- "item2.X-ABLabel:_$!<Spouse>!$_",
- "item3.X-ABRELATEDNAMES:Byron",
- "item3.X-ABLabel:the poet",
- "item4.URL;type=pref:https://example.com",
- "item4.X-ABLabel:_$!<HomePage>!$_"
- ]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:, note: "Analyst.")], created_at: CREATED_AT)
-
- assert_equal "Analyst.\n\nSpouse: Sylvia Lovelace\nthe poet: Byron", plan.card(plan.contacts.first.id).note
- end
- end
-
- def test_a_related_name_is_the_whole_note_of_a_contact_with_none
- in_tmpdir do |dir|
- lines = ["item2.X-ABRELATEDNAMES;type=pref:Sylvia", "item2.X-ABLabel:_$!<Spouse>!$_"]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
- assert_equal "Spouse: Sylvia", plan.card(plan.contacts.first.id).note
- end
- end
-
- def test_a_maiden_name_is_written_under_the_note
- in_tmpdir do |dir|
- lines = [
- "X-MAIDENNAME:Byron",
- "item2.X-ABRELATEDNAMES;type=pref:Sylvia Lovelace",
- "item2.X-ABLabel:_$!<Spouse>!$_"
- ]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:, note: "Analyst.")], created_at: CREATED_AT)
-
- assert_equal "Analyst.\n\nMaiden name: Byron\nSpouse: Sylvia Lovelace", plan.card(plan.contacts.first.id).note
- end
- end
-
- def test_a_maiden_name_is_the_whole_note_of_a_contact_with_none
- in_tmpdir do |dir|
- plan = Macos.plan(dir, [record("A:ABPerson", lines: ["X-MAIDENNAME:Byron"])], created_at: CREATED_AT)
-
- assert_equal "Maiden name: Byron", plan.card(plan.contacts.first.id).note
- end
- end
-
- def test_a_second_maiden_name_is_refused
- in_tmpdir do |dir|
- records = [record("A:ABPerson", lines: ["X-MAIDENNAME:Byron", "X-MAIDENNAME:King"])]
-
- error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
- assert_includes error.message, "X-MAIDENNAME lines: 2: 1"
- end
- end
-
- def test_a_maiden_name_in_any_other_form_is_refused_under_that_form
- in_tmpdir do |dir|
- records = [record("A:ABPerson", lines: ["item1.X-MAIDENNAME:Byron"])]
-
- error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
- assert_includes error.message, "item#.X-MAIDENNAME: 1"
- end
- end
-
- def test_an_annotation_with_no_line_to_annotate_is_refused
- in_tmpdir do |dir|
- records = [record("A:ABPerson", lines: ["item5.X-ABADR:us", "X-ABLabel:_$!<Spouse>!$_"])]
-
- error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
- assert_equal <<~MESSAGE.chomp, error.message
- no branch handles these fields, so no plan was written:
- X-ABADR: 1
- A:ABPerson: "item5.X-ABADR:us"
- X-ABLABEL: 1
- A:ABPerson: "X-ABLabel:_$!<Spouse>!$_"
- MESSAGE
- end
- end
-
- def test_a_second_nickname_is_refused
- in_tmpdir do |dir|
- records = [record("A:ABPerson", lines: ["NICKNAME:The Countess", "NICKNAME:Ada"])]
-
- error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
- assert_includes error.message, "NICKNAME lines: 2: 1"
- end
- end
-
- def test_a_value_no_field_can_hold_is_refused
- in_tmpdir do |dir|
- records = [
- record("A:ABPerson", lines: ["BDAY:--12-10"]),
- record("C:ABPerson", lines: ["item1.ADR;type=HOME:P.O. Box 4;;;London;;;"]),
- record("D:ABPerson", lines: ["item1.ADR;type=HOME:;;Ockham Park;Surrey"]),
- record("E:ABPerson", lines: ["BDAY:1815-12-10", "BDAY:1815-12-11"])
- ]
-
- error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
- assert_equal <<~MESSAGE.chomp, error.message
- no branch handles these fields, so no plan was written:
- ADR value: 2
- C:ABPerson: "item1.ADR;type=HOME:P.O. Box 4;;;London;;;"
- D:ABPerson: "item1.ADR;type=HOME:;;Ockham Park;Surrey"
- BDAY lines: 2: 1
- E:ABPerson: "BDAY:1815-12-10 / BDAY:1815-12-11"
- BDAY value: 1
- A:ABPerson: "BDAY:--12-10"
- MESSAGE
- end
- end
-
- # An address book has no field for a directory name, and Exchange
- # writes one into an EMAIL. It leaves with the IMPPs rather than
- # stopping the plan, and the source keeps the line.
- def test_an_email_that_is_not_an_address_is_dropped
- in_tmpdir do |dir|
- lines = [
- "item2.EMAIL;type=INTERNET:/O=microsoft/OU=northamerica/cn=Recipients/cn=algersha",
- "EMAIL;type=INTERNET:ada@example.com"
- ]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
- card = plan.card(plan.contacts.first.id)
- assert_equal ["ada@example.com"], card.emails
- assert_includes card.source.fetch("vcard"), "cn=algersha"
- end
- end
-
- # An extension is part of the number as a person typed it.
- def test_a_number_carries_the_extension_written_after_it
- in_tmpdir do |dir|
- lines = ["TEL;type=WORK;type=VOICE:+1 (425) 707-1712 X71712"]
- plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
-
- assert_equal [{"number" => "+1 (425) 707-1712 X71712"}], plan.card(plan.contacts.first.id).phones
- end
- end
-
- def test_a_name_beyond_first_and_last_is_refused
- in_tmpdir do |dir|
- records = [
- record("A:ABPerson", name: ["N:Lovelace;Ada;Augusta;;", "FN:Ada Lovelace"]),
- record("B:ABPerson", name: ["N:Lovelace;Ada;;;", "FN:Countess Ada Lovelace"]),
- record("C:ABPerson", name: ["FN:Ada Lovelace"]),
- record("D:ABPerson", name: ["N:;;;;", "FN:"])
- ]
-
- error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
- assert_equal <<~MESSAGE.chomp, error.message
- no branch handles these fields, so no plan was written:
- FN other than first and last: 2
- B:ABPerson: "FN:Countess Ada Lovelace"
- C:ABPerson: "FN:Ada Lovelace"
- N beyond first and last: 1
- A:ABPerson: "N:Lovelace;Ada;Augusta;;"
- N lines: 0: 1
- C:ABPerson: ""
- no name: 1
- D:ABPerson: "N:;;;;"
- MESSAGE
- end
- end
-
- def test_an_apple_prodid_is_dropped
- in_tmpdir do |dir|
- plan = Macos.plan(dir, [record("A:ABPerson", lines: ["PRODID:-//Apple Inc.//macOS 26.0//EN"])], created_at: CREATED_AT)
-
- card = plan.card(plan.contacts.first.id)
- assert_equal [[], [], []], [card.phones, card.emails, card.addresses]
- end
- end
-
- def test_any_other_prodid_is_refused
- in_tmpdir do |dir|
- records = [
- record("A:ABPerson", lines: ["PRODID:-//Monica//EN"]),
- record("B:ABPerson", lines: ["PRODID;VALUE=text:-//Apple Inc.//macOS 26.0//EN"])
- ]
-
- error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
- assert_equal <<~MESSAGE.chomp, error.message
- no branch handles these fields, so no plan was written:
- PRODID value: 1
- A:ABPerson: "PRODID:-//Monica//EN"
- PRODID;VALUE=text: 1
- B:ABPerson: "PRODID;VALUE=text:-//Apple Inc.//macOS 26.0//EN"
- MESSAGE
- end
- end
-
- def test_a_line_in_any_other_form_is_refused_under_that_form
- in_tmpdir do |dir|
- records = [
- record("C:ABPerson", lines: ["item1.TEL;type=CELL;type=VOICE;type=pref:+12532189075"]),
- record("D:ABPerson", lines: ["TEL;type=CELL;type=VOICE;type=pref:ext. 4"]),
- record("E:ABPerson", name: ["N:Lovelace;Ada;;;", "FN;CHARSET=utf-8:Ada Lovelace"])
- ]
-
- error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
-
- assert_equal <<~MESSAGE.chomp, error.message
- no branch handles these fields, so no plan was written:
- FN lines: 0: 1
- E:ABPerson: ""
- FN;CHARSET=utf-8: 1
- E:ABPerson: "FN;CHARSET=utf-8:Ada Lovelace"
- TEL value: 1
- D:ABPerson: "TEL;type=CELL;type=VOICE;type=pref:ext. 4"
- item#.TEL;type=CELL;type=VOICE: 1
- C:ABPerson: "item1.TEL;type=CELL;type=VOICE;type=pref:+12532189075"
- MESSAGE
- end
- end
-
- def test_a_card_that_is_not_version_3_is_refused
- in_tmpdir do |dir|
- source = record("A:ABPerson")
- source["vcard"] = source.fetch("vcard").sub("VERSION:3.0", "VERSION:4.0")
-
- error = assert_raises(Macos::Unknown) { Macos.plan(dir, [source], created_at: CREATED_AT) }
-
- assert_includes error.message, "VERSION:4.0: 1"
- end
- end
-end
diff --git a/test/pro_tacts/import/test_plan.rb b/test/pro_tacts/import/test_plan.rb
deleted file mode 100644
index f97f2e7..0000000
--- a/test/pro_tacts/import/test_plan.rb
+++ /dev/null
@@ -1,152 +0,0 @@
-require_relative "../../test_helper"
-
-require "pathname"
-require "tmpdir"
-
-require "pro_tacts/import/plan"
-
-class ImportPlanTest < Minitest::Test
- include ThrowawayContacts
-
- Plan = ProTacts::Import::Plan
-
- CREATED_AT = Time.utc(2026, 9, 16, 18, 4, 12)
-
- SOURCE = {"identifier" => "A:ABPerson", "vcard" => "BEGIN:VCARD\r\nEND:VCARD\r\n", "note" => nil, "contact" => {}}
- ADA = ProTacts::Import::Card.new(first: "Ada", last: "Lovelace", nickname: nil, birthday: nil, phones: [{"number" => "+12532189075"}], emails: [], addresses: [], note: nil, photo: false, groups: [], source: SOURCE)
-
- def entry(id, source_id: "#{id}:ABPerson", card: ADA)
- Plan::Entry.new(id:, source_id:, card:)
- end
-
- def in_tmpdir
- Dir.mktmpdir { yield Pathname.new(it) / "plan" }
- end
-
- def test_a_written_plan_reads_back
- in_tmpdir do |dir|
- Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
-
- plan = Plan.read(dir)
-
- 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
- end
- end
-
- def test_a_contact_with_one_name_is_listed_under_it
- in_tmpdir do |dir|
- card = ProTacts::Import::Card.new(first: "Mitch", last: "", nickname: nil, birthday: nil, phones: [], emails: [], addresses: [], note: nil, photo: false, groups: [], source: SOURCE)
-
- Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru", card:)])
-
- assert_equal ["Mitch"], Plan.read(dir).contacts.map(&:name)
- end
- end
-
- def test_the_group_is_named_for_when_the_plan_was_built
- in_tmpdir do |dir|
- Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [])
-
- assert_equal "import-20260916T180412Z", Plan.read(dir).group
- end
- end
-
- def test_a_card_joins_everyone_and_the_plans_group_after_its_own
- in_tmpdir do |dir|
- card = ADA.with(groups: ["family"])
- Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru", card:)])
-
- assert_equal <<~YAML, (dir / "cards/kmnuqmzxylru.yml").read
- ---
- first: Ada
- last: Lovelace
- nickname:
- birthday:
- phones:
- - number: "+12532189075"
- emails: []
- addresses: []
- note:
- photo: false
- groups:
- - sync:*
- - import-20260916T180412Z
- - family
- source:
- identifier: A:ABPerson
- vcard: "BEGIN:VCARD\\r\\nEND:VCARD\\r\\n"
- note:
- contact: {}
- YAML
- end
- end
-
- def test_a_card_reads_back_with_its_edits
- in_tmpdir do |dir|
- plan = Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru")])
- path = dir / "cards/kmnuqmzxylru.yml"
- path.write(path.read.sub("first: Ada", "first: Augusta Ada"))
-
- assert_equal "Augusta Ada", plan.card("kmnuqmzxylru").first
- end
- end
-
- def test_a_plan_is_never_written_over_another
- in_tmpdir do |dir|
- Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru")])
-
- assert_raises(Errno::EEXIST) { Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: []) }
- assert_equal ["kmnuqmzxylru"], Plan.read(dir).contacts.map(&:id)
- end
- end
-
- def test_plans_read_back_oldest_first_with_what_each_is_waiting_for
- Dir.mktmpdir do |tmp|
- imports = Pathname.new(tmp)
- %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", 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.outstanding.map(&:id) }
- end
- end
-
- def test_progress_is_saved_into_the_plan_as_it_is_recorded
- in_tmpdir do |dir|
- plan = Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
-
- plan.record("kmnuqmzxylru", Plan::DONE)
-
- read = Plan.read(dir)
- assert_equal ["done", nil], read.contacts.map(&:status)
- assert_equal ["plan.yml"], dir.children.map { it.basename.to_s }.grep(/plan/)
- end
- end
-
- def test_a_plan_is_done_once_every_contact_reaches_done
- in_tmpdir do |dir|
- plan = Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
-
- refute plan.done?
- plan.record("kmnuqmzxylru", Plan::DONE)
- refute plan.done?
- plan.record("vmnlryyvktux", Plan::DONE)
- assert plan.done?
- end
- end
-
- def test_a_plan_recorded_under_the_old_vocabulary_finishes_too
- in_tmpdir do |dir|
- plan = Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru")])
- plan.record("kmnuqmzxylru", "removed")
-
- assert plan.done?
- end
- end
-end
diff --git a/test/pro_tacts/import/test_vcf.rb b/test/pro_tacts/import/test_vcf.rb
new file mode 100644
index 0000000..f067dbc
--- /dev/null
+++ b/test/pro_tacts/import/test_vcf.rb
@@ -0,0 +1,132 @@
+require_relative "../../test_helper"
+
+require "pro_tacts/import/vcf"
+
+# Reading an uploaded .vcf: the split into cards, and the survey of
+# what this address book does not show
+# (docs/plans/2026-09-21-import-a-vcf.md).
+class ImportVcfTest < Minitest::Test
+ Vcf = ProTacts::Import::Vcf
+
+ JANE = <<~CARD.gsub("\n", "\r\n")
+ BEGIN:VCARD
+ VERSION:3.0
+ N:Booles;Jane;;;
+ FN:Jane Booles
+ TEL;type=CELL;type=VOICE;type=pref:+1 555 0100
+ item1.X-ABRELATEDNAMES:Sam Booles
+ item1.X-ABLabel:_$!<Spouse>!$_
+ X-SOCIALPROFILE;type=twitter:https://twitter.com/jb
+ UID:ABC-123
+ END:VCARD
+ CARD
+
+ SAM = <<~CARD.gsub("\n", "\r\n")
+ BEGIN:VCARD
+ VERSION:3.0
+ N:Booles;Sam;;;
+ FN:Sam Booles
+ X-SOCIALPROFILE;type=facebook:https://facebook.com/sb
+ PRODID:-//Apple Inc.//macOS 15.0//EN
+ UID:DEF-456
+ END:VCARD
+ CARD
+
+ def test_a_file_reads_as_the_cards_it_holds_byte_for_byte
+ cards = Vcf.cards(JANE + SAM)
+
+ assert_equal [JANE, SAM], cards.map(&:to_s)
+ end
+
+ # Contacts writes one card after another with nothing between them,
+ # but an export edited by hand or joined with `cat` has blank lines,
+ # and they are not a card's business.
+ def test_blank_lines_between_cards_are_not_cards
+ cards = Vcf.cards("#{JANE}\r\n\r\n#{SAM}")
+
+ assert_equal [JANE, SAM], cards.map(&:to_s)
+ end
+
+ def test_a_line_outside_a_card_refuses_the_whole_file
+ error = assert_raises(Vcf::Invalid) { Vcf.cards("FN:Stray\r\n#{JANE}") }
+
+ assert_includes error.message, "FN:Stray"
+ end
+
+ def test_a_card_with_no_end_refuses_the_whole_file
+ error = assert_raises(Vcf::Invalid) { Vcf.cards(JANE.sub("END:VCARD\r\n", "")) }
+
+ assert_includes error.message, "no END:VCARD"
+ end
+
+ def test_a_file_with_no_cards_in_it_refuses
+ error = assert_raises(Vcf::Invalid) { Vcf.cards("\r\n") }
+
+ assert_includes error.message, "holds no vCards"
+ end
+
+ # BEGIN, VERSION and END are what makes a vCard (RFC 2426 section 4),
+ # and VCard#card? is what says so — the same judgment a PUT makes.
+ def test_something_shaped_like_a_card_without_a_version_refuses
+ error = assert_raises(Vcf::Invalid) { Vcf.cards(JANE.sub("VERSION:3.0\r\n", "")) }
+
+ assert_includes error.message, "are not vCards"
+ end
+
+ def test_the_survey_names_what_no_screen_here_shows
+ unknown = Vcf.unknown(Vcf.cards(JANE + SAM))
+
+ assert_equal %w[PRODID X-ABRELATEDNAMES X-SOCIALPROFILE], unknown.map(&:name)
+ end
+
+ # The envelope, the fields a screen shows, and the label that names a
+ # row are all read here, so none of them is a decision.
+ def test_the_survey_asks_nothing_about_a_property_this_book_reads
+ names = Vcf.unknown(Vcf.cards(JANE + SAM)).map(&:name)
+
+ refute_includes names, "TEL"
+ refute_includes names, "X-ABLABEL"
+ refute_includes names, "UID"
+ end
+
+ # The three choices read the same whatever parameters a line wears,
+ # so a property is one decision however many spellings it arrives in.
+ def test_parameters_do_not_make_a_second_decision
+ social = Vcf.unknown(Vcf.cards(JANE + SAM)).find { it.name == "X-SOCIALPROFILE" }
+
+ assert_equal 2, social.count
+ assert_equal ["X-SOCIALPROFILE;type=twitter:https://twitter.com/jb",
+ "X-SOCIALPROFILE;type=facebook:https://facebook.com/sb"],
+ social.examples
+ end
+
+ # A choice is made looking at the book's own data, so the examples
+ # are real lines — but no more of them than a list can be read at,
+ # and never a picture's payload.
+ def test_the_examples_are_a_few_real_lines_and_stop_short_of_a_payload
+ cards = Vcf.cards((1..5).map { |n| JANE.sub("jb", "jb#{n}") }.join)
+
+ examples = Vcf.unknown(cards).find { it.name == "X-SOCIALPROFILE" }.examples
+
+ assert_equal Vcf::EXAMPLES, examples.length
+
+ long = JANE.sub("https://twitter.com/jb", "x" * 400)
+
+ example = Vcf.unknown(Vcf.cards(long)).find { it.name == "X-SOCIALPROFILE" }.examples.fetch(0)
+
+ assert_includes example, "characters)"
+ assert_operator example.length, :<, 200
+ end
+
+ # A line's value arrives folded across physical lines (RFC 2426
+ # section 2.6); what it says is one line, and that is what the
+ # screen shows.
+ def test_an_example_is_unfolded
+ folded = JANE.sub("X-SOCIALPROFILE;type=twitter:https://twitter.com/jb\r\n",
+ "X-SOCIALPROFILE;type=twitter:https://twitter.com\r\n /jb\r\n")
+
+ example = Vcf.unknown(Vcf.cards(folded)).find { it.name == "X-SOCIALPROFILE" }.examples.fetch(0)
+
+ assert_equal "X-SOCIALPROFILE;type=twitter:https://twitter.com/jb", example
+ end
+end
diff --git a/test/tasks/test_import.rb b/test/tasks/test_import.rb
deleted file mode 100644
index d390cfd..0000000
--- a/test/tasks/test_import.rb
+++ /dev/null
@@ -1,81 +0,0 @@
-require_relative "../test_helper"
-
-require "pathname"
-require "rake"
-require "tmpdir"
-
-require "pro_tacts/import/plan"
-
-class ImportTasksTest < Minitest::Test
- Plan = ProTacts::Import::Plan
-
- SOURCE = {"identifier" => "A:ABPerson", "vcard" => "BEGIN:VCARD\r\nEND:VCARD\r\n", "note" => nil, "contact" => {}}
- ADA = ProTacts::Import::Card.new(first: "Ada", last: "Lovelace", nickname: nil, birthday: nil, phones: [], emails: [], addresses: [], note: nil, photo: false, groups: [], source: SOURCE)
-
- def entry(id) = Plan::Entry.new(id:, source_id: "#{id}:ABPerson", card: ADA)
-
- def test_status_counts_each_plans_contacts_and_files_finished_plans_away
- Dir.mktmpdir do |root|
- 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 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("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 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 finalized"
- end
- end
-
- def test_status_with_only_done_plans_reports_them_alone
- Dir.mktmpdir do |root|
- root = Pathname.new(root)
- plan = Plan.write(root / "data/import/done/macos-20260831T000000Z", source: "macos", created_at: Time.utc(2026, 8, 31), entries: [entry("qxqmqmqmqmqm")])
- plan.contacts.each { it.status or plan.record(it.id, Plan::DONE) }
-
- out, = run_status(root)
-
- assert_equal "done:\nmacos-20260831T000000Z 1 contacts\n", out
- end
- end
-
- def test_status_with_no_plans_says_how_to_build_one
- Dir.mktmpdir do |root|
- out, = run_status(Pathname.new(root))
-
- assert_includes out, "no plans in data/import/active; run rake import:macos:plan"
- end
- end
-
- private
-
- def run_status(root)
- with_import_tasks(root) { capture_io { Rake.application["import:status"].invoke } }
- end
-
- # Loads tasks/import.rake in a Rake application of its own, from root
- # as the working directory: the tasks read data/import relative to
- # where rake runs, not through ProTacts.config.
- def with_import_tasks(root)
- application = Rake.application
- Rake.application = Rake::Application.new
- load (Pathname.new(__dir__).parent.parent / "tasks" / "import.rake").to_s
- Dir.chdir(root) { yield }
- ensure
- Rake.application = application
- end
-end