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
# A readable copy of what the store cannot rebuild: each stored card as
# its bytes, the birthdays, the groups and their photos, and the books,
# split the way the store splits them
# (docs/plans/2026-09-12-database-dump.md,
# docs/plans/2026-09-19-books-in-the-dump.md,
# docs/plans/2026-10-12-group-photos.md). cards/ and
# groups/ are made to hold exactly what the store holds, so a dump kept
# in version control shows deletions too; anything else in the
# directory is left alone.
#
# The dump then commits what it wrote, into a repository of its own
# beside the code's — `data/` is ignored here — so a snapshot can be
# reverted to rather than overwritten by the next one
# (docs/plans/2026-09-20-the-dump-commits.md). This is the one place
# the tooling runs git; the app still runs none.

require "open3"
require "pathname"
require "pro_tacts"

dump = Pathname.new(ENV.fetch("DUMP") { (ProTacts.config.data_dir / "dump").to_s })
cards = dump / "cards"
groups = dump / "groups"

directory cards.to_s
directory groups.to_s

namespace :db do
  desc "Dump the cards, birthdays, groups, and books into #{dump} and commit them there (DUMP=path to move it)"
  task dump: [cards.to_s, groups.to_s] do
    require "yaml"
    require "pro_tacts/store"

    database = ProTacts.config.database_path
    ProTacts::Store.connect(database) do |store|
      snapshot = store.snapshot

      dump_directory(cards, snapshot.cards.to_h { |id, card| ["#{id}.vcf", card] })

      birthdays = snapshot.birthdays.sort_by { |id, _| id }.to_h { |id, birthday|
        [id, {"year" => birthday.year, "month" => birthday.month, "day" => birthday.day}.compact]
      }
      dump_file(dump / "birthdays.yml", YAML.dump(birthdays))

      # A photo beside its group's YAML, named by the type its bytes
      # sniffed to, so the dump opens as the picture it is.
      photos = snapshot.group_photos.to_h { |id, photo| ["#{id}.#{photo.mime_type.delete_prefix("image/")}", photo.bytes] }
      dump_directory(groups, snapshot.groups.to_h { |group|
        ["#{group.id}.yml", YAML.dump({"name" => group.name, "lines" => group.lines, "members" => group.members})]
      }.merge(photos))

      dump_file(dump / "books.yml", YAML.dump(snapshot.books))
    end
    commit_dump(dump)
    puts "dumped #{database} into #{dump}"
  end

  # A directory holding exactly these files, so what left the store
  # leaves the dump.
  def dump_directory(directory, files)
    directory.children.each { it.delete unless files.key?(it.basename.to_s) }
    files.each { |name, content| dump_file(directory / name, content) }
  end

  # Unchanged files are not rewritten, so a dump over a dump touches
  # only what moved. Compared as bytes: a card is UTF-8 and a file read
  # back is binary.
  def dump_file(path, content)
    path.binwrite(content) unless path.exist? && path.binread == content.b
  end

  # The repository is the dump's own, made on the first dump. The
  # identity is passed per invocation rather than read from the
  # machine's git configuration, so a host that dumps needs none set up
  # (docs/plans/2026-09-20-the-dump-commits.md).
  def commit_dump(dump)
    sh "git", "-C", dump.to_s, "init", "-b", "main" unless (dump / ".git").exist?
    sh "git", "-C", dump.to_s, "add", "--all"

    # Porcelain is the whole answer: empty means nothing to commit, so a
    # dump that moved nothing adds no commit and the history is the
    # writes rather than the runs. A failure here raises rather than
    # leaving a caller believing the snapshot was recorded.
    staged, error, = Open3.capture3("git", "-C", dump.to_s, "status", "--porcelain")
    raise error unless error.empty?
    return if staged.empty?

    # The time is all a snapshot has to say for itself; what moved is
    # the store's change log, which the dump deliberately leaves out.
    sh "git", "-C", dump.to_s,
      "-c", "user.name=pro-tacts", "-c", "user.email=dump@localhost",
      "-c", "commit.gpgsign=false",
      "commit", "--message", "dump #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}"
  end
end