abbu 0.2.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +281 -8
  3. data/bin/abbu +230 -53
  4. data/docs/ABBU.md +369 -7
  5. data/docs/API_STABILITY.md +189 -0
  6. data/docs/CHANGELOG.md +179 -0
  7. data/docs/CONTRIBUTING.md +25 -3
  8. data/docs/FORMAT_COMPATIBILITY.md +97 -0
  9. data/docs/RELEASING.md +75 -0
  10. data/docs/TODO.md +73 -62
  11. data/lib/abbu/archive.rb +80 -6
  12. data/lib/abbu/contact.rb +6 -3
  13. data/lib/abbu/diagnostic.rb +27 -0
  14. data/lib/abbu/exporters/csv_exporter.rb +2 -2
  15. data/lib/abbu/exporters/json_exporter.rb +7 -1
  16. data/lib/abbu/exporters/vcard_document.rb +54 -0
  17. data/lib/abbu/exporters/vcard_encoding.rb +54 -0
  18. data/lib/abbu/exporters/vcard_exporter.rb +44 -27
  19. data/lib/abbu/group.rb +28 -0
  20. data/lib/abbu/group_catalog.rb +32 -0
  21. data/lib/abbu/image_extractor.rb +134 -0
  22. data/lib/abbu/live_store.rb +117 -0
  23. data/lib/abbu/parse_error.rb +13 -0
  24. data/lib/abbu/parsers/plist_parser.rb +41 -11
  25. data/lib/abbu/parsers/sqlite_parser.rb +139 -65
  26. data/lib/abbu/query.rb +92 -0
  27. data/lib/abbu/schema_inspector.rb +133 -0
  28. data/lib/abbu/source.rb +53 -0
  29. data/lib/abbu/source_catalog.rb +32 -0
  30. data/lib/abbu/timestamp_range.rb +40 -0
  31. data/lib/abbu/utils/contact_identity.rb +97 -0
  32. data/lib/abbu/utils/deduplicator.rb +130 -2
  33. data/lib/abbu/utils/image_resolver.rb +59 -0
  34. data/lib/abbu/utils/label_normalizer.rb +15 -0
  35. data/lib/abbu/utils/source_descriptor.rb +35 -0
  36. data/lib/abbu/version.rb +1 -1
  37. data/lib/abbu.rb +15 -2
  38. data/sig/group.rbs +36 -0
  39. data/sig/query.rbs +21 -0
  40. data/sig/source.rbs +25 -0
  41. data/sig/vcard_exporter.rbs +10 -0
  42. data/tasks/abbu.rake +1 -1
  43. metadata +33 -15
  44. data/bin/cleanse +0 -7
  45. data/bin/console +0 -10
  46. data/bin/dev +0 -7
  47. data/bin/lint +0 -7
  48. data/bin/outdated +0 -7
  49. data/bin/test +0 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9926de7b6edc99155f0ddf18c39ebfab80677152caeba28bc7b2bce8b0e870bd
4
- data.tar.gz: 8c7c8224f7799343d8ac994e4c21c2185b30f53aa8bd87783c5423f2ee09d1d8
3
+ metadata.gz: 71bd4536cf0e22f9bf9177d5715f4210a84a3b50bcab562266fe4d71c24d831a
4
+ data.tar.gz: 1bce6e9c3022f814f7ae789c232e8f0d62fe17999cca109cdba16825cea5a7f8
5
5
  SHA512:
6
- metadata.gz: 32b5f1d5496c8ae68f849756242588d6eac681305fee894156d1ba2a1717bb7e3b3fd8b03ce2504ff3c2340597451217a1b29fea9f7eb78e971d161c6b47072c
7
- data.tar.gz: b1e62d48dca35bf1d3cb584728e7333f85c31971bcd28c01cc718949b8d093108c407efb159bbbf6017002fa3b6c2676c08906df8502fe1647ad6974787a9c9a
6
+ metadata.gz: 378d928ea9d82de3f5ddb54e8f4d9b61e8b14a9cea61225248394e4d665bc8a5c7263a8292af2089acb879b6a0d600dd532fa9a288c5025b36de9c000fab6107
7
+ data.tar.gz: 573617afdbedd7af5a415dd08f0fb1b2c330d8ccc1bc4d798f0a9febc38eb455b83e292a4c3f25dd30728770fe56a0b97cb65c1acd1e9a13e59df7be43377042
data/README.md CHANGED
@@ -2,18 +2,27 @@
2
2
 
3
3
  # abbu
4
4
 
5
- Read and process Apple Contacts `.abbu` archives in Ruby.
5
+ Read-only Apple Contacts toolkit for `.abbu` archives and opt-in live macOS stores.
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.
6
9
 
7
10
  ## Features
8
11
 
9
12
  - Parse ABBU (Apple Contacts export) bundles
13
+ - Opt-in, read-only access to a local macOS Contacts store
10
14
  - SQLite-backed contact extraction (modern macOS)
11
15
  - Legacy plist `.abcdp` parsing (older macOS)
12
- - Full Apple Contacts schema: names, nicknames, prefix/suffix, job title, department, phonetics, pronouns, and more
16
+ - Evidence-backed Apple Contacts fields: names, nicknames, prefix/suffix, job title, department, phonetics, pronouns, and more
13
17
  - Rich relational data: addresses, URLs, notes, related names, social profiles
14
18
  - Export to CSV, JSON, vCard 3.0
15
19
  - CLI + Ruby API
16
- - Duplicate detection
20
+ - Source provenance and creation/modification timestamps
21
+ - Lossless raw labels alongside human-friendly normalization
22
+ - Schema introspection, tolerant diagnostics, and strict mode
23
+ - Chainable archive queries, exact identifier lookup, and TSV/JSON CLI search
24
+ - Provenance-aware duplicate suggestions without automatic merging
25
+ - Safe photo extraction with content detection and no destination overwrites
17
26
 
18
27
  ## Installation
19
28
 
@@ -36,13 +45,192 @@ require "abbu"
36
45
 
37
46
  archive = Abbu.open("Contacts.abbu")
38
47
  contacts = archive.contacts
48
+ schema = archive.schema_report # Evidence-only SQLite schema diagnostics
39
49
 
40
50
  contacts.first.full_name # => "Honorable Stan \"Stretch\" Carver II"
41
- contacts.first.emails # => [{ address: "stan@example.com", label: "Work" }]
42
- contacts.first.phones # => [{ number: "555-1234", label: "Mobile" }]
51
+ contacts.first.emails # => [{ address: "stan@example.com", label: "Work", raw_label: "_$!<Work>!$_" }]
52
+ contacts.first.phones # => [{ number: "555-1234", label: "Mobile", raw_label: "Mobile" }]
43
53
  contacts.first.job_title # => "Engineer"
54
+
55
+ # Copy resolved photos using safe, content-derived filenames.
56
+ result = archive.extract_images("exported-photos")
57
+ result.files # copied-file metadata, including source_path and media_type
58
+ result.diagnostics # image errors or destination_exists; may contain contact identifiers
59
+
60
+ # Recover safe records and inspect non-fatal data loss.
61
+ archive.diagnostics.each { |diagnostic| warn diagnostic.to_h }
62
+
63
+ # Or fail on the first corrupt/unsupported optional input.
64
+ strict_contacts = Abbu.open("Contacts.abbu", strict: true).contacts
65
+ ```
66
+
67
+ Live-store access is a separate, explicit API and never changes `Abbu.open` archive
68
+ validation:
69
+
70
+ ```ruby
71
+ # Auto-discover the current macOS user's AddressBook directory
72
+ live_contacts = Abbu.open_live.contacts
73
+
74
+ # Or supply a directory for automation and platform-independent testing
75
+ live_contacts = Abbu.open_live("/path/to/AddressBook").contacts
76
+ ```
77
+
78
+ Live databases are opened with SQLite's read-only mode. The process may require
79
+ Full Disk Access under **System Settings → Privacy & Security → Full Disk Access**.
80
+ Live inputs expose parser `diagnostics` and accept `strict: true` (CLI `--strict`).
81
+ The live CLI supports source/group listing, stats, deduplication, and exports; archive-only search, schema,
82
+ and image-extraction options are rejected explicitly.
83
+
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).
89
+
90
+ Labeled values expose a normalized `label` for display and retain the source
91
+ value in `raw_label`. For example, `_$!<Mobile>!$_` becomes `Mobile` while the
92
+ original wrapper remains available in `raw_label`.
93
+
94
+ ### Search and identifier lookup
95
+
96
+ ```ruby
97
+ # Exact lookup normalizes email case/whitespace and phone punctuation.
98
+ archive.find_by_email("STAN@EXAMPLE.COM").each { |contact| puts contact.full_name }
99
+ archive.find_by_phone("(555) 123-4567").each { |contact| puts contact.full_name }
100
+
101
+ # Name and email search is case-insensitive and can be chained with `where`.
102
+ archive.where(company: "Acme Corp").search("stan").each do |contact|
103
+ puts [contact.full_name, contact.source[:relative_path]].join("\t")
104
+ end
105
+ ```
106
+
107
+ Lookup methods return every match as an `Abbu::Query`; they never silently pick
108
+ one contact when the same identifier appears in multiple sources. Returned
109
+ contacts retain their parser-provided source provenance.
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
44
224
  ```
45
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
+
46
234
  ### Export
47
235
 
48
236
  ```ruby
@@ -56,6 +244,34 @@ Abbu::Exporters::JsonExporter.new(archive.contacts).to_file("contacts.json")
56
244
  Abbu::Exporters::VcardExporter.new(archive.contacts).to_file("contacts.vcf")
57
245
  ```
58
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
+
59
275
  ### Duplicate Detection
60
276
 
61
277
  ```ruby
@@ -66,6 +282,23 @@ dupes.each do |email, contacts|
66
282
  end
67
283
  ```
68
284
 
285
+ For provenance-aware suggestions, use `#matches`. Results preserve both contacts,
286
+ their source records, raw and normalized evidence, confidence, and ambiguity:
287
+
288
+ ```ruby
289
+ matches = Abbu::Utils::Deduplicator.new(archive.contacts).matches
290
+ matches.each do |match|
291
+ puts "#{match.confidence}: #{match.left.full_name} / #{match.right.full_name}"
292
+ pp match.sources
293
+ pp match.evidence
294
+ end
295
+
296
+ # Matching never mutates or collapses contacts. Merging requires a caller policy:
297
+ merged = matches.first.merge(policy: ->(left, right, evidence:) {
298
+ MyContactMerge.call(left, right, evidence: evidence)
299
+ })
300
+ ```
301
+
69
302
  ## CLI
70
303
 
71
304
  ```bash
@@ -78,13 +311,41 @@ abbu Contacts.abbu -f json | jq .
78
311
  # vCard export
79
312
  abbu Contacts.abbu -f vcard -o contacts.vcf
80
313
 
314
+ # Copy contact photos to a selected directory
315
+ abbu Contacts.abbu --extract-images exported-photos
316
+
81
317
  # Stats
82
318
  abbu Contacts.abbu --stats
83
319
 
84
320
  # Find duplicates
85
321
  abbu Contacts.abbu --dedupe
322
+
323
+ # Read the current macOS user's live Contacts store
324
+ abbu --live --stats
325
+
326
+ # Read a caller-supplied AddressBook directory
327
+ abbu --live-path /path/to/AddressBook -f json
328
+
329
+ # Fail on the first corrupt or unsupported optional record/table.
330
+ abbu Contacts.abbu --stats --strict
331
+
332
+ # Inspect each SQLite schema without inferring undocumented semantics
333
+ abbu Contacts.abbu --schema
334
+
335
+ # Tab-separated search output: name, emails, phones, source-relative path
336
+ abbu Contacts.abbu --search stan
337
+ abbu Contacts.abbu --email stan@example.com
338
+ abbu Contacts.abbu --phone '(555) 123-4567'
339
+
340
+ # Stable structured search output using the regular contact JSON schema
341
+ abbu Contacts.abbu --search stan --json | jq .
86
342
  ```
87
343
 
344
+ CLI search defaults to tab-separated output and exits successfully when at least
345
+ one contact matches. A search with no matches exits with status 1; TSV mode emits
346
+ no output, while `--json` emits a valid empty array. This makes both modes
347
+ suitable for shell conditionals, pipelines, and agent integrations.
348
+
88
349
  ## Rake Tasks
89
350
 
90
351
  ```ruby
@@ -103,17 +364,29 @@ rake abbu:stats[Contacts.abbu]
103
364
  See [`docs/ABBU.md`](docs/ABBU.md) for a full explanation of the archive structure,
104
365
  SQLite table schema, and format history.
105
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
+
106
371
  ## Roadmap
107
372
 
108
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.
376
+
377
+ ## Ruby Compatibility
378
+
379
+ `abbu` supports Ruby 3.3 and newer. CI exercises Ruby 3.3, 3.4, and 4.0;
380
+ Ruby 3.3 is the compatibility floor and designated lint/tooling job.
109
381
 
110
382
  ## Development
111
383
 
112
384
  ```bash
113
385
  mise exec -- bundle install
114
- mise exec -- bundle exec guard # DX loop: auto-test + auto-lint
115
- mise exec -- bundle exec rspec # run specs
116
- mise exec -- bundle exec rubocop # lint
386
+ bin/dev # Guard feedback loop
387
+ bin/spec # RSpec with the 100% coverage gate
388
+ bin/lint # RuboCop
389
+ bin/package # build and verify the gem in isolation
117
390
  ```
118
391
 
119
392
  ## Contributing