abbu 0.4.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +166 -5
- data/bin/abbu +54 -8
- data/docs/ABBU.md +146 -3
- data/docs/API_STABILITY.md +189 -0
- data/docs/CHANGELOG.md +53 -0
- data/docs/CONTRIBUTING.md +12 -0
- data/docs/FORMAT_COMPATIBILITY.md +97 -0
- data/docs/RELEASING.md +75 -0
- data/docs/TODO.md +3 -0
- data/lib/abbu/archive.rb +14 -0
- data/lib/abbu/contact.rb +3 -2
- data/lib/abbu/exporters/vcard_document.rb +54 -0
- data/lib/abbu/exporters/vcard_encoding.rb +54 -0
- data/lib/abbu/exporters/vcard_exporter.rb +30 -31
- data/lib/abbu/group.rb +28 -0
- data/lib/abbu/group_catalog.rb +32 -0
- data/lib/abbu/live_store.rb +13 -0
- data/lib/abbu/parsers/sqlite_parser.rb +6 -3
- data/lib/abbu/query.rb +19 -0
- data/lib/abbu/source.rb +53 -0
- data/lib/abbu/source_catalog.rb +32 -0
- data/lib/abbu/timestamp_range.rb +40 -0
- data/lib/abbu/version.rb +1 -1
- data/lib/abbu.rb +1 -0
- data/sig/group.rbs +36 -0
- data/sig/query.rbs +21 -0
- data/sig/source.rbs +25 -0
- data/sig/vcard_exporter.rbs +10 -0
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 71bd4536cf0e22f9bf9177d5715f4210a84a3b50bcab562266fe4d71c24d831a
|
|
4
|
+
data.tar.gz: 1bce6e9c3022f814f7ae789c232e8f0d62fe17999cca109cdba16825cea5a7f8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 378d928ea9d82de3f5ddb54e8f4d9b61e8b14a9cea61225248394e4d665bc8a5c7263a8292af2089acb879b6a0d600dd532fa9a288c5025b36de9c000fab6107
|
|
7
|
+
data.tar.gz: 573617afdbedd7af5a415dd08f0fb1b2c330d8ccc1bc4d798f0a9febc38eb455b83e292a4c3f25dd30728770fe56a0b97cb65c1acd1e9a13e59df7be43377042
|
data/README.md
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
# abbu
|
|
4
4
|
|
|
5
5
|
Read-only Apple Contacts toolkit for `.abbu` archives and opt-in live macOS stores.
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
Development version 0.8.0 strengthens vCard serialization alongside source-scoped groups, timestamp
|
|
7
|
+
queries, diagnostics, identity evidence, image extraction, and CSV/JSON/vCard export.
|
|
8
|
+
The public API remains pre-1.0.
|
|
8
9
|
|
|
9
10
|
## Features
|
|
10
11
|
|
|
@@ -77,11 +78,14 @@ live_contacts = Abbu.open_live("/path/to/AddressBook").contacts
|
|
|
77
78
|
Live databases are opened with SQLite's read-only mode. The process may require
|
|
78
79
|
Full Disk Access under **System Settings → Privacy & Security → Full Disk Access**.
|
|
79
80
|
Live inputs expose parser `diagnostics` and accept `strict: true` (CLI `--strict`).
|
|
80
|
-
The live CLI supports stats, deduplication, and exports; archive-only search, schema,
|
|
81
|
+
The live CLI supports source/group listing, stats, deduplication, and exports; archive-only search, schema,
|
|
81
82
|
and image-extraction options are rejected explicitly.
|
|
82
83
|
|
|
83
|
-
Live access does not write Contacts databases. WAL
|
|
84
|
-
|
|
84
|
+
Live access does not write Contacts databases. Synthetic WAL tests verify committed
|
|
85
|
+
data visibility and read-only access, but a read is **not an atomic snapshot**:
|
|
86
|
+
separate contact/relationship queries can observe different commits, and databases
|
|
87
|
+
are read independently. A LiveStore caches its first contact result; reopen it to
|
|
88
|
+
refresh. See [live-store consistency evidence](docs/ABBU.md#wal-and-concurrent-writer-evidence).
|
|
85
89
|
|
|
86
90
|
Labeled values expose a normalized `label` for display and retain the source
|
|
87
91
|
value in `raw_label`. For example, `_$!<Mobile>!$_` becomes `Mobile` while the
|
|
@@ -104,6 +108,129 @@ Lookup methods return every match as an `Abbu::Query`; they never silently pick
|
|
|
104
108
|
one contact when the same identifier appears in multiple sources. Returned
|
|
105
109
|
contacts retain their parser-provided source provenance.
|
|
106
110
|
|
|
111
|
+
### Timestamp queries
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
archive.query.modified_since('2026-09-01T00:00:00Z').search('stan')
|
|
115
|
+
archive.query.date_range(:created_at, since: '2026-09-01T00:00:00Z',
|
|
116
|
+
before: '2026-10-01T00:00:00Z')
|
|
117
|
+
# Also works with explicitly opened live data:
|
|
118
|
+
Abbu::Query.new(Abbu.open_live.contacts).created_since(Time.utc(2026, 9, 1))
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Bounds accept `Time` or full ISO 8601 strings with seconds and an explicit `Z`
|
|
122
|
+
or numeric offset. Start (`since`) is inclusive; end (`before`) is exclusive.
|
|
123
|
+
At least one bound is required, and start must precede end. Invalid bounds raise
|
|
124
|
+
`ArgumentError`, including on empty queries. Missing timestamps never match.
|
|
125
|
+
Filters preserve source records and compose with other Query criteria. These are
|
|
126
|
+
observed storage timestamps, not proof of human edits or a complete change feed;
|
|
127
|
+
deletions and changes without timestamps cannot be discovered this way.
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
abbu Contacts.abbu --modified-since 2026-09-01T00:00:00Z --json
|
|
131
|
+
abbu Contacts.abbu --created-since 2026-09-01T00:00:00Z --created-before 2026-10-01T00:00:00Z --search stan --json
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
All four `--created-since`, `--created-before`, `--modified-since`, and
|
|
135
|
+
`--modified-before` filters can be combined (AND). They use existing search
|
|
136
|
+
TSV/JSON output: status 0 for matches, 1 for no matches, and 2 for invalid bounds
|
|
137
|
+
or incompatible output options. Diagnostics stay on stderr. CLI timestamp queries
|
|
138
|
+
are archive-only, like existing search; use the Ruby Query API for live inputs.
|
|
139
|
+
Do not combine timestamp queries with export, stats, schema, dedupe, or extraction.
|
|
140
|
+
|
|
141
|
+
### Sources
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
sources = archive.sources # Frozen Array<Abbu::Source>, sorted by relative_path
|
|
145
|
+
source = sources.first
|
|
146
|
+
source.relative_path # "." for root, or "Sources/<observed identifier>"
|
|
147
|
+
source.identifier # Raw directory identifier, or nil for root
|
|
148
|
+
source.provider # nil: never inferred from paths or identifier spelling
|
|
149
|
+
source.files # Frozen file-level provenance descriptors
|
|
150
|
+
source.contacts.search('stan') # Query over original contacts, no automatic deduplication
|
|
151
|
+
source.group_names # Sorted unique observed membership labels, not group identities
|
|
152
|
+
source.to_h # Metadata, files, contact_count, group_names; no contact payload
|
|
153
|
+
|
|
154
|
+
Abbu.open_live('/path/to/AddressBook').sources # Same API, read-only connections
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Sources are observed input containers, not verified Apple account identities.
|
|
158
|
+
Existing `contact.source` hashes are unchanged. Membership is based on the exact
|
|
159
|
+
file provenance path; repeated contact IDs and filenames in different sources
|
|
160
|
+
never merge. Files from the same observed source directory form one container.
|
|
161
|
+
An empty discovered database still appears; an empty archive returns `[]`.
|
|
162
|
+
Plist records outside `Sources/` form the root container. Archives retain their
|
|
163
|
+
existing SQLite-first parser selection: ignored plist files are not listed.
|
|
164
|
+
|
|
165
|
+
Source metadata, file descriptors, membership arrays, and group-name strings are
|
|
166
|
+
frozen snapshots. Contacts remain the original mutable objects. `sources` eagerly
|
|
167
|
+
parses contacts, honors diagnostics/strict mode, and is cached; reopen the input to
|
|
168
|
+
refresh. It does not add live-store atomic snapshot guarantees. Group names cover
|
|
169
|
+
observed contact memberships only, not empty groups or same-name group identity.
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
abbu Contacts.abbu --sources
|
|
173
|
+
abbu --live-path /path/to/AddressBook --sources --json
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`--sources` always emits a JSON array to stdout; `--json` is optional. Each object
|
|
177
|
+
has `path`, `relative_path`, `kind`, `identifier`, `provider`, `files`,
|
|
178
|
+
`contact_count`, and `group_names`; unknown `provider` and root `identifier` are
|
|
179
|
+
explicit JSON nulls. `files` uses the existing four-key contact provenance schema.
|
|
180
|
+
Source/file ordering is by relative path; contact order is parser order within
|
|
181
|
+
those sorted files. Listings exit 0 even when empty. Conflicting operations or
|
|
182
|
+
strict parse failures exit 2, live input/access failures exit 1, and diagnostics
|
|
183
|
+
stay on stderr. Only `--strict` and `--json` may accompany this operation besides
|
|
184
|
+
input selection. Paths, raw identifiers, and group names may be sensitive;
|
|
185
|
+
do not publish source listings as sanitized logs.
|
|
186
|
+
|
|
187
|
+
### Groups
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
group = archive.groups.first # Frozen Array<Abbu::Group>
|
|
191
|
+
group.record_id # Observed SQLite group key, local to group.source[:path]
|
|
192
|
+
group.name # Original name, including whitespace/Unicode; may be nil
|
|
193
|
+
group.source # Frozen file-level provenance, including source identifier
|
|
194
|
+
group.contacts.search('stan') # Query over the original mutable contacts
|
|
195
|
+
archive.groups_for(archive.contacts.first) # Reverse lookup, without changing Contact#groups
|
|
196
|
+
archive.sources.first.groups # Same objects, scoped to that source
|
|
197
|
+
archive.query.where(company: 'Acme Corp').in_group(group)
|
|
198
|
+
Abbu.open_live('/path/to/AddressBook').groups # Same API, read-only
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Groups are identified by database path plus observed record key, never by name.
|
|
202
|
+
Same-name groups within a file and repeated keys across files/sources remain
|
|
203
|
+
distinct. `Contact#groups` remains the original array of labels (including duplicate
|
|
204
|
+
or null names); `Contact#group_memberships` additionally retains `{ record_id:, name: }`
|
|
205
|
+
for each observed join row, including duplicates. Group contacts list each original
|
|
206
|
+
contact once. Existing contact JSON/CSV/vCard output is unchanged.
|
|
207
|
+
|
|
208
|
+
Only groups reached by supported contact membership joins are listed. Empty or
|
|
209
|
+
unreferenced groups, dangling joins, and legacy plist group relationships are not
|
|
210
|
+
enumerated; no semantics are inferred for them. Missing optional membership tables
|
|
211
|
+
retain tolerant diagnostics and strict-mode errors. Ordering is source relative
|
|
212
|
+
path, file relative path, then numeric record key; contacts retain parser order.
|
|
213
|
+
|
|
214
|
+
Metadata and membership snapshots are frozen when sources/groups are first built;
|
|
215
|
+
contacts themselves remain mutable. Later edits to contact labels/evidence do not
|
|
216
|
+
rewrite these snapshots. `groups_for` and `Query#in_group` use original contact
|
|
217
|
+
object identity: a contact freshly parsed by another input instance does not
|
|
218
|
+
belong to this snapshot, even for the same path. Reopen to refresh; live reads
|
|
219
|
+
retain the consistency limitations described above.
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
abbu Contacts.abbu --groups
|
|
223
|
+
abbu --live-path /path/to/AddressBook --groups --json
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`--groups` emits a JSON array of `record_id` (integer), `name` (string or null),
|
|
227
|
+
`source` (the four-key file provenance hash), and `contact_count` (integer).
|
|
228
|
+
`Group#to_h` uses the same schema. Names are never normalized. Empty results exit 0;
|
|
229
|
+
strict failures and conflicting operations exit 2; live access failures exit 1.
|
|
230
|
+
Only input selection, `--strict`, and optional `--json` may accompany this mode;
|
|
231
|
+
`--sources` and `--groups` cannot be combined. Diagnostics stay on stderr.
|
|
232
|
+
Group names, IDs and source paths may be sensitive; this is not sanitized logging.
|
|
233
|
+
|
|
107
234
|
### Export
|
|
108
235
|
|
|
109
236
|
```ruby
|
|
@@ -117,6 +244,34 @@ Abbu::Exporters::JsonExporter.new(archive.contacts).to_file("contacts.json")
|
|
|
117
244
|
Abbu::Exporters::VcardExporter.new(archive.contacts).to_file("contacts.vcf")
|
|
118
245
|
```
|
|
119
246
|
|
|
247
|
+
vCard output uses UTF-8, CRLF endings (including the final line), escaped TEXT
|
|
248
|
+
values, and folding at no more than 75 bytes without splitting UTF-8 characters.
|
|
249
|
+
Repeated fields retain order. `N` and `ADR` escape each component independently.
|
|
250
|
+
Text newline variants become vCard `\\n`; URL/IM URI values use percent encoding
|
|
251
|
+
instead of TEXT escaping. Empty contact input emits no bytes.
|
|
252
|
+
|
|
253
|
+
Labeled emails, phones, addresses, URLs, IM handles, and anniversaries pair with
|
|
254
|
+
`itemN.X-ABLABEL` using the same per-card `itemN` group. The original `raw_label`
|
|
255
|
+
wins over the display `label`, including empty strings. Custom labels no longer
|
|
256
|
+
become arbitrary `TYPE` parameters. Only exact ASCII standard type names are
|
|
257
|
+
recognized, case-insensitively; email/phone defaults are `INTERNET`/`VOICE`.
|
|
258
|
+
For example, `Mobile` is preserved as a label, not guessed to mean `CELL`.
|
|
259
|
+
No preference is invented for anniversaries. Existing photo file URIs remain;
|
|
260
|
+
embedded photos are separate work.
|
|
261
|
+
|
|
262
|
+
Migration from 0.7: consumers must unfold CRLF continuations before parsing,
|
|
263
|
+
decode TEXT escapes, and accept grouped property names rather than matching
|
|
264
|
+
literal lines such as `EMAIL;TYPE=Work`. The exporter signatures are unchanged.
|
|
265
|
+
Unsupported controls, invalid UTF-8, unsafe social-service parameter tokens,
|
|
266
|
+
or invalid IM service schemes fail explicitly before writing output; export
|
|
267
|
+
does not sanitize or mutate Contact evidence. Other encoding conversions may
|
|
268
|
+
raise Ruby encoding errors. Existing files are overwritten on a successful
|
|
269
|
+
`to_file`, as before; this is not an atomic/no-clobber writer.
|
|
270
|
+
|
|
271
|
+
See [vCard evidence and compatibility limits](docs/ABBU.md#vcard-serialization-evidence)
|
|
272
|
+
for the standards basis and Apple-specific audit. Synthetic round-trip tests
|
|
273
|
+
are not proof of import fidelity in every Apple Contacts release.
|
|
274
|
+
|
|
120
275
|
### Duplicate Detection
|
|
121
276
|
|
|
122
277
|
```ruby
|
|
@@ -209,9 +364,15 @@ rake abbu:stats[Contacts.abbu]
|
|
|
209
364
|
See [`docs/ABBU.md`](docs/ABBU.md) for a full explanation of the archive structure,
|
|
210
365
|
SQLite table schema, and format history.
|
|
211
366
|
|
|
367
|
+
The [compatibility regression matrix](docs/FORMAT_COMPATIBILITY.md) protects
|
|
368
|
+
legacy XML and varied SQLite layouts in every CI run, and distinguishes tested
|
|
369
|
+
synthetic shapes from unverified macOS/Contacts releases.
|
|
370
|
+
|
|
212
371
|
## Roadmap
|
|
213
372
|
|
|
214
373
|
See [`docs/TODO.md`](docs/TODO.md) for the full release schedule and feature checklist.
|
|
374
|
+
The [pre-1.0 API stability gate](docs/API_STABILITY.md) inventories supported
|
|
375
|
+
surfaces, evidence gaps, compatibility policy, and required release-readiness checks.
|
|
215
376
|
|
|
216
377
|
## Ruby Compatibility
|
|
217
378
|
|
data/bin/abbu
CHANGED
|
@@ -59,6 +59,14 @@ parser.on('--schema', 'Print SQLite schema diagnostics as JSON') do
|
|
|
59
59
|
options[:schema] = true
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
parser.on('--sources', 'List observed source containers as JSON (archive or live)') do
|
|
63
|
+
options[:sources] = true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
parser.on('--groups', 'List observed groups with memberships as JSON (archive or live)') do
|
|
67
|
+
options[:groups] = true
|
|
68
|
+
end
|
|
69
|
+
|
|
62
70
|
parser.on('--email ADDRESS', 'Find contacts by exact normalized email') do |address|
|
|
63
71
|
options[:email] = address
|
|
64
72
|
end
|
|
@@ -67,10 +75,21 @@ parser.on('--phone NUMBER', 'Find contacts by exact normalized phone number') do
|
|
|
67
75
|
options[:phone] = number
|
|
68
76
|
end
|
|
69
77
|
|
|
70
|
-
parser.on('--json', 'Emit search results as
|
|
78
|
+
parser.on('--json', 'Emit search results as JSON (optional for --sources or --groups)') do
|
|
71
79
|
options[:json] = true
|
|
72
80
|
end
|
|
73
81
|
|
|
82
|
+
timestamp_bounds = %w[since before]
|
|
83
|
+
%w[created modified].each do |kind|
|
|
84
|
+
timestamp_bounds.each do |bound|
|
|
85
|
+
parser.on("--#{kind}-#{bound} TIMESTAMP", "Filter #{kind} timestamp (ISO 8601 with timezone)") do |value|
|
|
86
|
+
options[:date_filters] ||= {}
|
|
87
|
+
options[:date_filters][:"#{kind}_at"] ||= {}
|
|
88
|
+
options[:date_filters][:"#{kind}_at"][bound.to_sym] = value
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
74
93
|
parser.on('-v', '--version', 'Print version') do
|
|
75
94
|
puts "abbu #{Abbu::VERSION}"
|
|
76
95
|
exit
|
|
@@ -123,12 +142,33 @@ at_exit do
|
|
|
123
142
|
end
|
|
124
143
|
|
|
125
144
|
begin
|
|
126
|
-
|
|
127
|
-
|
|
145
|
+
listing = %i[sources groups].select { |key| options[key] }
|
|
146
|
+
if listing.any? && (listing.length > 1 || options.values_at(:extract_images, :schema, :search, :email, :phone,
|
|
147
|
+
:date_filters, :stats, :dedupe, :format, :output).any?)
|
|
148
|
+
raise ArgumentError,
|
|
149
|
+
"--#{listing.first} cannot be combined with other operations; only --json and --strict are supported"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
if live_mode && listing.empty? &&
|
|
153
|
+
options.values_at(:extract_images, :schema, :search, :email, :phone, :json, :date_filters).any?
|
|
154
|
+
warn '--live supports --sources, --groups, --stats, --dedupe, --format, and --strict; ' \
|
|
155
|
+
'archive-only options are not supported.'
|
|
128
156
|
exit 1
|
|
129
157
|
end
|
|
158
|
+
if options[:date_filters]
|
|
159
|
+
if options.values_at(:extract_images, :schema, :stats, :dedupe, :format, :output).any?
|
|
160
|
+
raise ArgumentError, 'timestamp filters cannot be combined with export, stats, schema, dedupe, or extraction'
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
options[:date_filters].each { |field, bounds| Abbu::TimestampRange.new(field, **bounds) }
|
|
164
|
+
end
|
|
130
165
|
archive = live_mode ? Abbu.open_live(live_path, strict: options[:strict]) : Abbu.open(file, strict: options[:strict])
|
|
131
166
|
|
|
167
|
+
if listing.any?
|
|
168
|
+
puts JSON.pretty_generate(archive.public_send(listing.first).map(&:to_h))
|
|
169
|
+
exit 0
|
|
170
|
+
end
|
|
171
|
+
|
|
132
172
|
if options[:extract_images]
|
|
133
173
|
result = archive.extract_images(options[:extract_images])
|
|
134
174
|
puts "Extracted #{result.files.count} image(s) to #{File.expand_path(options[:extract_images])}"
|
|
@@ -149,20 +189,26 @@ begin
|
|
|
149
189
|
exit 1
|
|
150
190
|
end
|
|
151
191
|
|
|
152
|
-
if options[:json] && search_options.empty?
|
|
153
|
-
warn '--json requires --search, --email, or --phone.'
|
|
192
|
+
if options[:json] && search_options.empty? && !options[:date_filters]
|
|
193
|
+
warn '--json requires --search, --email, or --phone (or a timestamp filter).'
|
|
154
194
|
exit 1
|
|
155
195
|
end
|
|
156
196
|
|
|
157
|
-
if search_options.any?
|
|
197
|
+
if search_options.any? || options[:date_filters]
|
|
158
198
|
results = if options[:search]
|
|
159
199
|
archive.search(options[:search])
|
|
160
200
|
elsif options[:email]
|
|
161
201
|
archive.find_by_email(options[:email])
|
|
162
|
-
|
|
202
|
+
elsif options[:phone]
|
|
163
203
|
archive.find_by_phone(options[:phone])
|
|
204
|
+
else
|
|
205
|
+
archive.query
|
|
164
206
|
end
|
|
165
207
|
|
|
208
|
+
options.fetch(:date_filters, {}).each do |field, bounds|
|
|
209
|
+
results = results.date_range(field, **bounds)
|
|
210
|
+
end
|
|
211
|
+
|
|
166
212
|
if options[:json]
|
|
167
213
|
Abbu::Exporters::JsonExporter.new(results).to_stdout
|
|
168
214
|
else
|
|
@@ -218,7 +264,7 @@ begin
|
|
|
218
264
|
rescue Abbu::LiveStore::Error => e
|
|
219
265
|
warn "abbu: #{e.message}"
|
|
220
266
|
exit 1
|
|
221
|
-
rescue Abbu::ParseError => e
|
|
267
|
+
rescue Abbu::ParseError, ArgumentError => e
|
|
222
268
|
warn "abbu: #{e.message}"
|
|
223
269
|
exit 2
|
|
224
270
|
end
|
data/docs/ABBU.md
CHANGED
|
@@ -125,13 +125,66 @@ Every parsed contact includes source provenance with the absolute source path, i
|
|
|
125
125
|
relative to the `.abbu` root, and whether it came from the root bundle or a database under
|
|
126
126
|
`Sources/<identifier>/`. Legacy plist contacts receive the same file-level provenance.
|
|
127
127
|
|
|
128
|
+
### Source container evidence
|
|
129
|
+
|
|
130
|
+
`Archive#sources` and `LiveStore#sources` group the existing `SourceDescriptor`
|
|
131
|
+
file evidence into root (`.`) and observed `Sources/<identifier>` containers.
|
|
132
|
+
This is ABBU's grouping convention over input paths, not discovery of Apple's
|
|
133
|
+
private account/container tables. It introduces no new storage-column inference.
|
|
134
|
+
Every selected input file appears, including databases with no contacts; files
|
|
135
|
+
in the same observed container are retained individually. Legacy plist files
|
|
136
|
+
outside `Sources/` belong to root. Parser selection and discovery scope are unchanged.
|
|
137
|
+
|
|
138
|
+
`Source#identifier` preserves the observed directory spelling, while `provider`
|
|
139
|
+
remains nil even for a directory named `iCloud`. Container paths are local identities
|
|
140
|
+
within that opened input, not globally stable IDs across moved archives or snapshots.
|
|
141
|
+
Contacts link through their original file-level `source[:path]`; the source hash
|
|
142
|
+
is neither replaced nor enriched with speculative provider metadata. `group_names`
|
|
143
|
+
reports distinct membership strings only; the group API below preserves identity.
|
|
144
|
+
|
|
145
|
+
`spec/abbu/source_spec.rb` copies the existing deterministic root database into
|
|
146
|
+
root, `Sources/iCloud`, and `Sources/Équipe`, including two database filenames in
|
|
147
|
+
one container and colliding contact IDs across containers. These fixtures verify
|
|
148
|
+
grouping, provenance preservation and unknown-provider behavior, not an Apple
|
|
149
|
+
account schema. Source listing parses/caches contacts and retains the existing
|
|
150
|
+
strict/tolerant diagnostics and live consistency limitations below.
|
|
151
|
+
|
|
152
|
+
### Group membership evidence
|
|
153
|
+
|
|
154
|
+
The existing synthetic fixture joins `Z_ABCDCONTACTGROUP.Z_GROUP` to
|
|
155
|
+
`ZABCDRECORD.Z_PK` and filters memberships by `Z_CONTACT`. The group parser now
|
|
156
|
+
retains that joined key alongside the exact `ZFIRSTNAME` value instead of discarding
|
|
157
|
+
the key. `Contact#group_memberships` preserves every returned join row; the existing
|
|
158
|
+
`Contact#groups` label array and exporters are unchanged. No label normalization
|
|
159
|
+
or inference from other column names is introduced.
|
|
160
|
+
|
|
161
|
+
`Archive#groups`, `LiveStore#groups`, and `Source#groups` aggregate this evidence by
|
|
162
|
+
absolute database path and record key. Keys are file-local, not global Apple IDs.
|
|
163
|
+
`Group#source` is file provenance; its `identifier` connects to the observed source
|
|
164
|
+
container. Group contacts retain original object identities with repeated joins
|
|
165
|
+
collapsed only in the navigable contact set, not in raw membership evidence.
|
|
166
|
+
Reverse lookup uses `groups_for(contact)` on the input or source; `Query#in_group`
|
|
167
|
+
intersects the caller's contacts with that snapshot without name-based matching.
|
|
168
|
+
|
|
169
|
+
`spec/abbu/group_spec.rb` extends the deterministic fixture with duplicate joins,
|
|
170
|
+
same-name/different-key groups, null and Unicode names, and repeated keys in root
|
|
171
|
+
and multiple files under `Sources/Équipe`. These establish collision boundaries
|
|
172
|
+
and name preservation through model and JSON output, not a new Apple schema.
|
|
173
|
+
No Apple version is asserted for this synthetic variation.
|
|
174
|
+
|
|
175
|
+
Only joined groups with parsed contacts are represented. Empty/unreferenced rows,
|
|
176
|
+
dangling joins, and plist group relationships remain unsupported; their semantics
|
|
177
|
+
need separate evidence. Missing membership tables retain existing diagnostics and
|
|
178
|
+
strict-mode behavior. Building groups adds no database reads beyond contact parsing
|
|
179
|
+
and makes no stronger live snapshot guarantee. JSON listings expose raw names and
|
|
180
|
+
file paths and must be treated as sensitive.
|
|
181
|
+
|
|
128
182
|
### Opt-in live Contacts stores
|
|
129
183
|
|
|
130
184
|
The CLI uses `--live` only for auto-discovery and `--live-path PATH` for an explicit
|
|
131
185
|
store. These forms are mutually exclusive and accept no positional archive/path arguments.
|
|
132
|
-
Option ordering does not change input selection.
|
|
133
|
-
|
|
134
|
-
across an actively changing Contacts store.
|
|
186
|
+
Option ordering does not change input selection. Read-only handles do not establish
|
|
187
|
+
snapshot consistency across an actively changing Contacts store.
|
|
135
188
|
|
|
136
189
|
`Abbu.open_live` and the CLI's live modes can read an AddressBook directory
|
|
137
190
|
without first exporting an `.abbu` archive. This mode is deliberately separate from
|
|
@@ -156,6 +209,41 @@ automatic discovery without a caller-supplied path raises
|
|
|
156
209
|
|
|
157
210
|
The repository verifies live-store behavior only with deterministic synthetic SQLite
|
|
158
211
|
fixtures. It does not inspect or commit a developer's real Contacts store.
|
|
212
|
+
|
|
213
|
+
### WAL and concurrent-writer evidence
|
|
214
|
+
|
|
215
|
+
`spec/abbu/live_store_wal_spec.rb` copies the existing synthetic root database into
|
|
216
|
+
a temporary directory and opens a separate writer connection in WAL mode. No
|
|
217
|
+
real Contacts data or macOS privacy permission is required. The tests deliberately
|
|
218
|
+
keep the writer open, disable its automatic checkpoint, and interleave operations
|
|
219
|
+
at known boundaries instead of using sleeps or timing races.
|
|
220
|
+
|
|
221
|
+
The fixture demonstrates that ABBU reads committed WAL changes while the writer
|
|
222
|
+
remains connected. Its database and WAL bytes stay unchanged across the read,
|
|
223
|
+
all ABBU connections use `readonly: true`, and traced statements contain no writes
|
|
224
|
+
or checkpoint requests. An uncommitted writer transaction is not visible. After
|
|
225
|
+
commit, a new LiveStore sees the new value; an existing store retains its cached
|
|
226
|
+
contacts.
|
|
227
|
+
|
|
228
|
+
There is an important consistency limit: the parser does not enclose all queries
|
|
229
|
+
in a read transaction. A commit between the contact-row SELECT and an email SELECT
|
|
230
|
+
can produce an old name with a new email from the same database. SQLite's snapshot
|
|
231
|
+
isolation applies within a read transaction, not across ABBU's independent
|
|
232
|
+
statements. Multiple database files are also read independently; there is no
|
|
233
|
+
cross-database snapshot guarantee. These tests characterize existing behavior,
|
|
234
|
+
not a new snapshot API or a guarantee for every Contacts/SQLite version.
|
|
235
|
+
|
|
236
|
+
SQLite uses `-wal` and `-shm` sidecars for WAL operation. Read-only database access
|
|
237
|
+
does not promise that shared-memory lock/index state is byte-for-byte unchanged;
|
|
238
|
+
the tests intentionally do not make that claim. Do not remove sidecars, checkpoint
|
|
239
|
+
a live Contacts database, or copy only its main file to try to obtain a snapshot.
|
|
240
|
+
Use an independently verified consistent export/backup for snapshot-sensitive work.
|
|
241
|
+
Missing/inaccessible sidecar and filesystem-lock behavior remain deployment-specific
|
|
242
|
+
limitations, not behavior established by the writable temporary fixture.
|
|
243
|
+
|
|
244
|
+
References: [SQLite WAL](https://www.sqlite.org/wal.html) and
|
|
245
|
+
[SQLite isolation](https://www.sqlite.org/isolation.html).
|
|
246
|
+
|
|
159
247
|
### Provenance-aware identity evidence
|
|
160
248
|
|
|
161
249
|
ABBU treats deduplication as a suggestion boundary rather than proof that two records are
|
|
@@ -278,8 +366,63 @@ normalizes into the same contact model used by the SQLite parser. Additional
|
|
|
278
366
|
plist keys or layouts require fixture evidence before they are treated as
|
|
279
367
|
supported semantics.
|
|
280
368
|
|
|
369
|
+
### vCard serialization evidence
|
|
370
|
+
|
|
371
|
+
The 0.8.0 exporter implements TEXT escaping and structured components from
|
|
372
|
+
[RFC 2426 §§2.3–2.6](https://www.rfc-editor.org/rfc/rfc2426), and CRLF/grouping
|
|
373
|
+
and unfolding from [RFC 2425 §5.8.1](https://www.rfc-editor.org/rfc/rfc2425).
|
|
374
|
+
ABBU folds conservatively at 75 **octets**, including the continuation space,
|
|
375
|
+
without splitting a UTF-8 code point. Escaping happens before folding; decoding
|
|
376
|
+
must unfold first. Commas, semicolons and backslashes are escaped in TEXT;
|
|
377
|
+
CRLF, bare CR and LF become the logical newline escape. This preserves logical
|
|
378
|
+
text, not the original newline byte convention. Input Contact values are unchanged.
|
|
379
|
+
URI properties use percent encoding, preserving existing escapes and URI
|
|
380
|
+
delimiters rather than applying TEXT rules. This is not a general URI validator.
|
|
381
|
+
|
|
382
|
+
`spec/abbu/exporters/vcard_exporter_fidelity_spec.rb` contains deterministic
|
|
383
|
+
inline wire fixtures and an independent fixture-only decoder. It exercises
|
|
384
|
+
repeated properties, injection-shaped input, structured names/addresses,
|
|
385
|
+
multiline notes, UTF-8 folding boundaries, per-card group numbering, and both
|
|
386
|
+
SQLite/plist → Contact → vCard label preservation. The temporary SQLite fixture
|
|
387
|
+
only varies an already supported `ZLABEL`; the plist fixture uses the existing
|
|
388
|
+
`Email.values[].label` mapping. Neither adds guessed Apple storage semantics.
|
|
389
|
+
|
|
390
|
+
The reviewed extension audit is deliberately bounded:
|
|
391
|
+
|
|
392
|
+
| Surface | Implemented rule / evidence limit |
|
|
393
|
+
| --- | --- |
|
|
394
|
+
| `itemN`, `X-ABLABEL` | Standard group syntax pairs repeated properties with ABBU's existing raw-label extension. Labels remain exact after TEXT decoding; numbering is local to each card, not a stored Apple ID. No Apple import validation is claimed. |
|
|
395
|
+
| `X-ABDATE` | Existing anniversary mapping retained, now grouped with its label. Removed the fabricated `type=pref`. Other date collections remain unsupported by this exporter. |
|
|
396
|
+
| `TYPE`, `PREF` | Only exact ASCII names from RFC 2426's EMAIL/TEL/ADR lists and RFC 4770's IMPP list are recognized from display labels. No whitespace trimming, Unicode folding, custom-label tokenization, or Mobile→CELL guess. Raw labels remain separately available. Explicit `PREF` is recognized; no priority is inferred from row order. |
|
|
397
|
+
| `UID` | Not generated: SQLite keys and source paths are not demonstrated global contact identifiers. |
|
|
398
|
+
| `IMPP` | URI-valued property per [RFC 4770](https://www.rfc-editor.org/rfc/rfc4770). Existing service-to-scheme convention retained with scheme syntax checks and address encoding. A syntactically valid scheme does not prove service interoperability; absent service retains legacy `unknown:`. |
|
|
399
|
+
| `X-SOCIALPROFILE` | Existing service/username extension retained; parameter service must be an ASCII token and username is escaped TEXT. No provider URL or Apple import semantics are inferred. |
|
|
400
|
+
| `ADR` | Seven components, each independently escaped. No new PO box/extended-address storage mapping. Missing label no longer fabricates HOME. |
|
|
401
|
+
| Partial/lunar dates, phonetic names, verification code | Existing extensions retained, not certified as standard vCard 3.0 date forms or Apple alternate-calendar semantics. |
|
|
402
|
+
| `PHOTO` | Existing local file URI retained; portable embedding belongs to #30. |
|
|
403
|
+
|
|
404
|
+
Unsafe parameter values and control characters fail rather than create extra
|
|
405
|
+
properties or silently discard evidence. Serialization completes before file
|
|
406
|
+
opening/stdout emission, so validation failures produce no partial export and
|
|
407
|
+
leave an existing output file untouched. Filesystem failures after opening can
|
|
408
|
+
still leave partial files. Exports contain sensitive contact values and photo
|
|
409
|
+
paths; callers must choose appropriate destinations and permissions.
|
|
410
|
+
|
|
411
|
+
Email preference uses `TYPE=INTERNET,PREF`, retaining the default address type
|
|
412
|
+
as required by RFC 2426's email parameter grammar; TEL includes the standard
|
|
413
|
+
`PCS` token. Unknown extension/registered type names are preserved as labels,
|
|
414
|
+
not asserted to be registered by ABBU's deliberately bounded built-in list.
|
|
415
|
+
|
|
416
|
+
These are standards-backed serialization guarantees and synthetic regression
|
|
417
|
+
observations, not a full-fidelity ABBU backup or certification against a specific
|
|
418
|
+
macOS/Contacts build. A sanitized real Apple export/import corpus remains a
|
|
419
|
+
separate compatibility gate before broader claims.
|
|
420
|
+
|
|
281
421
|
## Repository Evidence
|
|
282
422
|
|
|
423
|
+
- [Compatibility regression matrix](FORMAT_COMPATIBILITY.md): always-on XML,
|
|
424
|
+
sparse/complete SQLite, root/source/mixed layout profiles and their explicit
|
|
425
|
+
historical evidence gaps. Filename suffixes are not macOS version guarantees.
|
|
283
426
|
- `spec/fixtures/TestContacts.abbu/` exercises the supported synthetic SQLite,
|
|
284
427
|
nested source, and image-resolution behavior.
|
|
285
428
|
- `spec/fixtures/PlistContacts.abbu/` exercises the supported synthetic legacy
|