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
require "json"
require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"
module ProTacts
# What one write did to a card, as the lines it added and the lines
# it removed — the change log's second fact that nothing can
# recompute (see Store), since the card this one replaced is gone the
# moment the write lands.
#
# A multiset difference over the card's lines, not an edit script:
# macOS Contacts reorders properties on every card it touches
# (docs/apple-contacts.md), so a positional diff would report a
# shuffle as a change to every line below the first move. Lines that
# both cards carry cancel, however far apart they sit, and what is
# left is the write. A card carrying the same line twice is two
# lines, which is why this counts rather than sets: dropping one copy
# is a removal, and a set difference would call it nothing.
#
# The unit is the logical line unfolded and shorn of its terminator,
# which is the line as the grammar reads it (RFC 2426 section 2.6
# folding). So a fold moved or a card rewritten CRLF for LF is no
# change here, and the diff is a record of a card's lines rather than
# of its bytes — it cannot rebuild the card it came from, and is not
# meant to.
#
# The signature lives in sig/pro_tacts/card_diff.rbs, this being a
# Data class.
# @rbs skip
CardDiff = Data.define(:added, :removed)
# Reopened rather than defined in the block above, for the reason
# VCard::Parser::Property is.
class CardDiff
# The write from the card before it to the card after it, either of
# them nil: a create adds every line of a card that was not there,
# and a delete removes every line of one that will not be.
#: (VCard? before, VCard? after) -> CardDiff
def self.between(before, after)
old_lines = content_lines(before)
new_lines = content_lines(after)
new(added: without(new_lines, old_lines), removed: without(old_lines, new_lines))
end
# The same write over an array of logical lines rather than a
# card — a group's own (Store#set_group_lines), which are stored
# as the unit this diffs, with no terminator to shed and no fold
# to unfold. The cards' path is #between's; this one exists so
# the group log's detail is the same multiset difference by the
# same code rather than a second spelling of it.
#: (Array[String] before, Array[String] after) -> CardDiff
def self.between_lines(before, after)
new(added: without(after, before), removed: without(before, after))
end
# The stored spelling: a JSON object, so a line goes in and comes
# back byte for byte whatever it holds. A diff is displayed beside
# the entry it belongs to and never queried, so the column is one
# value rather than a table of lines.
#: (String json) -> CardDiff
def self.from_json(json)
parsed = JSON.parse(json) #: Hash[String, Array[String]]
new(added: parsed.fetch("added"), removed: parsed.fetch("removed"))
end
# A card's lines, each unfolded and stripped of its terminator. The
# blank line a card may end with reads as no line at all, the same
# answer Parser gives it, so a card's trailing whitespace is not a
# line to add or remove.
#: (VCard? vcard) -> Array[String]
def self.content_lines(vcard)
return [] if vcard.nil?
vcard.lines.filter_map {
line = VCard::Parser.unfold(it.verbatim).sub(/(\r\n|[\r\n])\z/, "")
line unless line.empty?
}
end
# `lines` minus one occurrence of each line `others` also carries.
# Order is the card's own, so a diff reads down the card the lines
# came from.
#: (Array[String] lines, Array[String] others) -> Array[String]
def self.without(lines, others)
remaining = others.tally
lines.reject { |line|
count = remaining.fetch(line, 0)
next false if count.zero?
remaining[line] = count - 1
true
}
end
private_class_method :content_lines, :without
#: () -> bool
def empty? = added.empty? && removed.empty?
#: () -> String
def to_json(*) = {added:, removed:}.to_json
end
end