1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
require "pro_tacts/change_id"
require "pro_tacts/vcard"

module ProTacts
  module Import
    # Writes one card an import has settled on into this server's
    # store, on that card's Save: a minted id and the UID that spells
    # it, then Store#put and Store#regroup into every group the boxes
    # beside it ticked, everyone's book included
    # (docs/plans/2026-09-21-import-a-vcf.md, "Writing them in", which
    # also says why this is not idempotent).
    class Write
      # @rbs @store: Store
      # @rbs @group_ids: Hash[String, String]

      # The group an import offers to put its arrivals in, named for
      # when it happened: what came in 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

      # `joins` is the ids of groups this book already has, as the
      # screen beside this card ticked them. `named` is the same
      # answer in names, for the groups that screen asked for that do
      # not exist yet, made here with the card they were asked for. The
      # group for the import is one of the two, whichever it has become
      # by now (Web#import_picker). `everyone` is whether to join
      # `sync:*`, which may not exist yet to have an id, resolved here
      # like a name (the plan's "Writing them in").
      #: (Store store, VCard card, ?joins: Array[String], ?named: Array[String], ?everyone: bool) -> Contact
      def self.call(store, card, joins: [], named: [], everyone: true)
        new(store).call(card, joins, named, everyone)
      end

      #: (Store store) -> void
      def initialize(store)
        @store = store
        @group_ids = {} #: Hash[String, String]
      end

      # The card, written as a contact: then its groups, the order a
      # create writes them in — the put makes the contact, and the
      # memberships that follow put it somewhere to be found.
      #: (VCard card, Array[String] chosen, Array[String] named, bool everyone) -> Contact
      def call(card, chosen, named, everyone)
        id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
        # `client: false`, though this is a create: everyone's book is
        # a box here, joined through #regroup with the rest
        # (docs/plans/2026-09-21-import-a-vcf.md, "Writing them in").
        @store.put(id, identified(card, id), client: false)

        # The chosen ids need no resolving, the screen having read
        # them off this same store. Deduplicated because two of these
        # can mean one group — a name typed for a group that the box
        # above it already offers, or everyone's book arriving both
        # ticked and asked for — and a second join of one group would
        # only be asked of Store#add_member to come to nothing.
        join = [*chosen, *named.map { group_id(it) }]
        join << group_id(Group::EVERYONE) if everyone
        @store.regroup(id, join: join.uniq, leave: []) unless join.empty?

        # 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.
        @store.contact(id) || raise("#{id} was written and is not stored")
      end

      # The other Save a row can make: its card folded into a contact
      # the book already has, rather than written as a new one
      # (Import::Merge, docs/plans/2026-09-23-merging-on-import.md).
      # The contact editor's own write, Store#save_edit, because this
      # is that contact edited — its id, its UID and its change-log
      # entry stay its own — and then its groups moved the way the
      # groups dialog moves them: `joins` and `leaves` are what the
      # boxes beside it changed, `named` the groups they asked to be
      # made.
      #: (Store store, String id, VCard card, birthday: Birthday?, ?joins: Array[String], ?leaves: Array[String], ?named: Array[String]) -> Contact
      def self.update(store, id, card, birthday:, joins: [], leaves: [], named: [])
        new(store).update(id, card, birthday, joins, leaves, named)
      end

      #: (String id, VCard card, Birthday? birthday, Array[String] chosen, Array[String] leaving, Array[String] named) -> Contact
      def update(id, card, birthday, chosen, leaving, named)
        @store.save_edit(id, card, birthday:)

        join = [*chosen, *named.map { group_id(it) }]
        @store.regroup(id, join: join.uniq, leave: leaving) unless join.empty? && leaving.empty?

        # Read back for #call's reason: the groups moved what it serves.
        @store.contact(id) || raise("#{id} was edited and is not stored")
      end

      private

      # The card under the id it is stored as: the source's UID
      # replaced by the minted id, the shape every other create here
      # writes (Admin::CardForm.new_card; why, the plan's "Writing them
      # in").
      #: (VCard card, String id) -> VCard
      def identified(card, id)
        _, rest = card.extract("UID")
        rest.insert(["UID:#{id}\r\n"])
      end

      # Looked up before it is made, so a card naming a group this
      # server already has joins that one rather than colliding with
      # its name (db/migrations/008_group_names.rb). That is also what
      # carries the group for the import across the walk: the first
      # Save makes it, and from then on it is an ordinary box.
      #
      # Memoized so that a card given the same name twice reads the
      # list once rather than making the group twice. The choices read,
      # not the whole groups read: the match is on name, and a group's
      # members are no part of it (Store#group_choices).
      #: (String name) -> String
      def group_id(name)
        @group_ids[name] ||= @store.group_choices.find { it.name == name }&.id || @store.create_group(name:)
      end
    end
  end
end