abbu 0.8.1 → 0.18.0
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 +129 -2
- data/bin/abbu +158 -6
- data/bin/abbu-mcp +36 -0
- data/docs/ABBU.md +28 -0
- data/docs/ALTERNATE_CALENDAR_EVIDENCE.md +82 -0
- data/docs/CHANGELOG.md +98 -0
- data/docs/CONTRIBUTING.md +3 -0
- data/docs/FUZZY_MATCHING.md +72 -0
- data/docs/HISTORY_EVIDENCE.md +98 -0
- data/docs/ICALENDAR.md +101 -0
- data/docs/MACHINE_JSON.md +128 -0
- data/docs/MCP.md +148 -0
- data/docs/MERGE_PLANS.md +78 -0
- data/docs/MY_CARD_EVIDENCE.md +92 -0
- data/docs/PORTABLE_SQLITE.md +142 -0
- data/docs/RELEASING.md +76 -68
- data/docs/SNAPSHOT_DIFF.md +74 -0
- data/docs/SNAPSHOT_HISTORY.md +93 -0
- data/docs/STREAMING.md +73 -0
- data/docs/TODO.md +4 -4
- data/docs/WRITER_DECISION.md +90 -0
- data/lib/abbu/archive.rb +14 -0
- data/lib/abbu/exporters/csv_exporter.rb +7 -0
- data/lib/abbu/exporters/icalendar_encoding.rb +35 -0
- data/lib/abbu/exporters/icalendar_event.rb +61 -0
- data/lib/abbu/exporters/icalendar_exporter.rb +86 -0
- data/lib/abbu/exporters/json_exporter.rb +2 -2
- data/lib/abbu/exporters/jsonl_exporter.rb +23 -0
- data/lib/abbu/exporters/sqlite_exporter.rb +85 -0
- data/lib/abbu/exporters/sqlite_rows.rb +113 -0
- data/lib/abbu/exporters/sqlite_schema.rb +76 -0
- data/lib/abbu/exporters/vcard_exporter.rb +10 -11
- data/lib/abbu/exporters/vcard_photo.rb +50 -0
- data/lib/abbu/history_tracker.rb +96 -0
- data/lib/abbu/live_store.rb +11 -0
- data/lib/abbu/machine_command.rb +159 -0
- data/lib/abbu/machine_output.rb +52 -0
- data/lib/abbu/mcp/read_session.rb +57 -0
- data/lib/abbu/mcp/server.rb +100 -0
- data/lib/abbu/mcp.rb +11 -0
- data/lib/abbu/merge_plan.rb +132 -0
- data/lib/abbu/merge_preview.rb +35 -0
- data/lib/abbu/merge_values.rb +26 -0
- data/lib/abbu/parsers/plist_parser.rb +9 -0
- data/lib/abbu/parsers/sqlite_parser.rb +25 -0
- data/lib/abbu/snapshot_diff.rb +125 -0
- data/lib/abbu/snapshot_history.rb +66 -0
- data/lib/abbu/utils/fuzzy_matcher.rb +116 -0
- data/lib/abbu/version.rb +1 -1
- data/lib/abbu.rb +9 -0
- data/sig/fuzzy_matcher.rbs +22 -0
- data/sig/icalendar_exporter.rbs +13 -0
- data/sig/machine_output.rbs +23 -0
- data/sig/mcp.rbs +19 -0
- data/sig/merge_plan.rbs +16 -0
- data/sig/snapshot_diff.rbs +8 -0
- data/sig/snapshot_history.rbs +10 -0
- data/sig/sqlite_exporter.rbs +10 -0
- data/sig/streaming.rbs +27 -0
- data/sig/vcard_exporter.rbs +1 -1
- metadata +46 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 99f31bf1bbf67088eedcdca46c8a5c90835d3371a93713c7c93c15d571504e4c
|
|
4
|
+
data.tar.gz: f1bacec2990fcf1c55765377d3985a92faa2be7ea921ecb91b26565e22f8c865
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 33b31e1dfe606f52276bc237449714d48fdd11f83c7407ac135679f5cdb928fba56f41259b968d53e1798aa13add86b1e76b1678a6b207a3bdc6637d4d20ac12
|
|
7
|
+
data.tar.gz: 7516e100a44c66fea179276a7e40cc8f48f1e70bb647f8c0ceaf55840144bf62af01bdf20b0a6e04353c7050ad97c89b4ed3f13abfc7625b3b3818c6a77a417d
|
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
|
-
Development version 0.
|
|
7
|
-
|
|
6
|
+
Development version 0.18.0 adds an optional read-only MCP adapter alongside streaming,
|
|
7
|
+
bounded snapshot-history observations,
|
|
8
|
+
portable SQLite, merge previews, snapshot comparison, iCalendar, machine JSON, and queries.
|
|
8
9
|
The public API remains pre-1.0.
|
|
9
10
|
|
|
10
11
|
## Features
|
|
@@ -272,6 +273,50 @@ See [vCard evidence and compatibility limits](docs/ABBU.md#vcard-serialization-e
|
|
|
272
273
|
for the standards basis and Apple-specific audit. Synthetic round-trip tests
|
|
273
274
|
are not proof of import fidelity in every Apple Contacts release.
|
|
274
275
|
|
|
276
|
+
### Portable SQLite
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
Abbu::Exporters::SqliteExporter.new(archive.contacts).to_file('contacts.sqlite')
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
abbu Contacts.abbu --format sqlite --output contacts.sqlite
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
This creates a separate, queryable ABBU-owned relational database with schema
|
|
287
|
+
version metadata, source provenance, timestamps, multivalues, groups, and raw
|
|
288
|
+
labels. It does not copy Apple tables or create an Apple-importable backup.
|
|
289
|
+
Existing destinations (including symlinks) are never replaced. Output is a
|
|
290
|
+
sensitive **unencrypted** artifact with owner-only file permissions, not a secure
|
|
291
|
+
storage service; place it on appropriately encrypted storage and limit retention.
|
|
292
|
+
See the [portable SQLite contract](docs/PORTABLE_SQLITE.md) for the schema,
|
|
293
|
+
round-trip evaluation, deterministic-order boundary, and publication safeguards.
|
|
294
|
+
|
|
295
|
+
### Birthday And Anniversary Calendars
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
calendar = Abbu::Exporters::IcalendarExporter.new(
|
|
299
|
+
archive.contacts, year: 2026, generated_at: Time.utc(2026, 10, 1),
|
|
300
|
+
calendar_id: 'your-unique-calendar-namespace'
|
|
301
|
+
)
|
|
302
|
+
calendar.to_file('reminders.ics')
|
|
303
|
+
calendar.diagnostics # Non-PII omission categories and zero-based contact indexes
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
abbu Contacts.abbu --format icalendar --calendar-year 2026 \
|
|
308
|
+
--calendar-stamp 2026-10-01T00:00:00Z --calendar-id your-unique-calendar-namespace \
|
|
309
|
+
--output reminders.ics
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
The three metadata arguments are explicit so the same ordered input and metadata
|
|
313
|
+
produce identical bytes without inventing a birth year, revision time, or Apple
|
|
314
|
+
identity. Choose your own unique calendar namespace and actual export revision
|
|
315
|
+
time. `year` is the first reminder year, not an original year. Unknown years stay
|
|
316
|
+
unknown, and February 29 reminders recur only in leap years. Lunar/alternate
|
|
317
|
+
calendar dates are diagnosed and omitted, not converted. See the
|
|
318
|
+
[iCalendar contract](docs/ICALENDAR.md) for identity, privacy, and import limits.
|
|
319
|
+
|
|
275
320
|
### Duplicate Detection
|
|
276
321
|
|
|
277
322
|
```ruby
|
|
@@ -299,8 +344,53 @@ merged = matches.first.merge(policy: ->(left, right, evidence:) {
|
|
|
299
344
|
})
|
|
300
345
|
```
|
|
301
346
|
|
|
347
|
+
### Fuzzy Name Suggestions (Ruby)
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
suggestions = Abbu::Utils::FuzzyMatcher.new(
|
|
351
|
+
archive.contacts.first, archive.contacts, threshold: 0.85
|
|
352
|
+
).matches
|
|
353
|
+
suggestions.each { |match| pp [match.status, match.score, match.evidence] }
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
This opt-in API compares one anchor against candidates, not all candidate pairs.
|
|
357
|
+
It preserves parser evidence and existing exact deduplication behavior. See
|
|
358
|
+
[fuzzy matching](docs/FUZZY_MATCHING.md) for score contributions, Unicode rules,
|
|
359
|
+
resource bounds and ambiguity limits. Similarity is not identity.
|
|
360
|
+
|
|
302
361
|
## CLI
|
|
303
362
|
|
|
363
|
+
An opt-in [MCP adapter](docs/MCP.md) exposes public read APIs to a trusted local
|
|
364
|
+
agent host. It requires a separately installed SDK; ordinary ABBU use does not.
|
|
365
|
+
|
|
366
|
+
### Snapshot History
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
Abbu::SnapshotHistory.new(['01.abbu', '02.abbu'], retention: 2).each do |transition|
|
|
370
|
+
pp transition # current observations, absences and ambiguous continuity
|
|
371
|
+
end
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
`abbu snapshots-directory --history` streams one JSON object per snapshot in
|
|
375
|
+
lexical directory order. See [history semantics and bounds](docs/SNAPSHOT_HISTORY.md).
|
|
376
|
+
Timeline IDs describe this analysis only, never universal contact identities.
|
|
377
|
+
|
|
378
|
+
### Safe Merge Plans
|
|
379
|
+
|
|
380
|
+
```ruby
|
|
381
|
+
plan = Abbu::MergePlan.new(left, right, policy: :prefer_newer)
|
|
382
|
+
pp plan.to_h # detached inputs, selected fields, alternatives, conflicts and reasons
|
|
383
|
+
merged = plan.materialize # raises without a policy or with unresolved conflicts
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
`abbu Contacts.abbu --merge-preview` emits exact-evidence candidate plans as JSON
|
|
387
|
+
without applying them. Optional `--merge-policy prefer_source --prefer-source
|
|
388
|
+
Sources/Example/AddressBook-v22.abcddb` previews an explicit preference. See
|
|
389
|
+
[merge-plan boundaries](docs/MERGE_PLANS.md) before materializing derived contacts.
|
|
390
|
+
|
|
391
|
+
For shell and agent integrations, see the [machine JSON contract](docs/MACHINE_JSON.md),
|
|
392
|
+
including structured diagnostics, identity suggestions, exit codes, and privacy.
|
|
393
|
+
|
|
304
394
|
```bash
|
|
305
395
|
# Export to CSV
|
|
306
396
|
abbu Contacts.abbu -f csv -o contacts.csv
|
|
@@ -370,10 +460,47 @@ synthetic shapes from unverified macOS/Contacts releases.
|
|
|
370
460
|
|
|
371
461
|
## Roadmap
|
|
372
462
|
|
|
463
|
+
Compare archives with `abbu Before.abbu --diff After.abbu --json` or
|
|
464
|
+
`Abbu::SnapshotDiff.new(before, after).to_h`. See the
|
|
465
|
+
[snapshot comparison contract](docs/SNAPSHOT_DIFF.md) for ambiguity, privacy,
|
|
466
|
+
resource limits and field-level evidence.
|
|
467
|
+
|
|
468
|
+
ABBU remains read-only for source archives and live stores. The
|
|
469
|
+
[writer research decision](docs/WRITER_DECISION.md) explains why generating
|
|
470
|
+
Apple-private bundles is not supported and what evidence could change that.
|
|
471
|
+
|
|
472
|
+
### Portable vCard Photos
|
|
473
|
+
|
|
474
|
+
Use `abbu Contacts.abbu --format vcard --photo-mode embedded --output contacts.vcf`
|
|
475
|
+
or `Abbu::Exporters::VcardExporter.new(contacts, photo_mode: :embedded)` to embed
|
|
476
|
+
JPEG, PNG or GIF bytes. The default `:uri` mode is unchanged. Embedded mode uses
|
|
477
|
+
content signatures, not filename extensions, and preserves original bytes and
|
|
478
|
+
contact provenance. It does not decode, validate image integrity, or transcode.
|
|
479
|
+
HEIC and unknown formats are rejected until import compatibility is evidenced.
|
|
480
|
+
Missing/unreadable/unsupported photos raise `ArgumentError` (CLI exit 2) before
|
|
481
|
+
writing output; contacts with no photo evidence omit PHOTO. Export is buffered
|
|
482
|
+
in memory, including base64 expansion. Do not embed untrusted or oversized files.
|
|
483
|
+
|
|
484
|
+
|
|
373
485
|
See [`docs/TODO.md`](docs/TODO.md) for the full release schedule and feature checklist.
|
|
374
486
|
The [pre-1.0 API stability gate](docs/API_STABILITY.md) inventories supported
|
|
375
487
|
surfaces, evidence gaps, compatibility policy, and required release-readiness checks.
|
|
376
488
|
|
|
489
|
+
### Streaming Large Archives
|
|
490
|
+
|
|
491
|
+
Use `archive.each_contact` (also available on live stores) for uncached iteration.
|
|
492
|
+
CSV and vCard exporters accept this enumerable through `write_to(io)`; the new
|
|
493
|
+
`JsonlExporter` writes one existing JSON contact representation per line.
|
|
494
|
+
|
|
495
|
+
```ruby
|
|
496
|
+
Abbu::Exporters::JsonlExporter.new(Abbu.open('Contacts.abbu').each_contact).write_to($stdout)
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
CLI: `abbu Contacts.abbu --stream --format csv --output contacts.csv`.
|
|
500
|
+
CSV, JSONL and vCard support streaming; JSONL always streams. Existing buffered
|
|
501
|
+
CSV/JSON/vCard defaults remain unchanged. Streaming can leave partial output on
|
|
502
|
+
late errors, including embedded-photo failures. See [streaming limits and benchmark](docs/STREAMING.md).
|
|
503
|
+
|
|
377
504
|
## Ruby Compatibility
|
|
378
505
|
|
|
379
506
|
`abbu` supports Ruby 3.3 and newer. CI exercises Ruby 3.3, 3.4, and 4.0;
|
data/bin/abbu
CHANGED
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
6
6
|
|
|
7
|
+
require 'date'
|
|
7
8
|
require 'json'
|
|
8
9
|
require 'optparse'
|
|
10
|
+
require 'time'
|
|
9
11
|
require 'abbu'
|
|
10
12
|
|
|
13
|
+
machine_requested = ARGV.include?('--json') || ARGV.include?('--matches') || ARGV.include?('--diagnostics')
|
|
14
|
+
|
|
11
15
|
options = {
|
|
12
16
|
format: nil,
|
|
13
17
|
output: nil,
|
|
@@ -27,14 +31,27 @@ options = {
|
|
|
27
31
|
parser = OptionParser.new
|
|
28
32
|
parser.banner = 'Usage: abbu <file.abbu> [options] | abbu --live [options] | abbu --live-path PATH [options]'
|
|
29
33
|
|
|
30
|
-
parser.on('-f', '--format FORMAT', %w[csv json
|
|
34
|
+
parser.on('-f', '--format FORMAT', %w[csv json jsonl sqlite vcard icalendar],
|
|
35
|
+
'Export format (csv, json, jsonl, sqlite, vcard, icalendar)') do |f|
|
|
31
36
|
options[:format] = f
|
|
32
37
|
end
|
|
33
38
|
|
|
39
|
+
parser.on('--stream', 'Stream CSV/JSONL/vCard; errors may leave partial output') { options[:stream] = true }
|
|
40
|
+
|
|
41
|
+
parser.on('--calendar-year YEAR', Integer, 'First reminder year (not a birth year)') { |v| options[:calendar_year] = v }
|
|
42
|
+
parser.on('--calendar-stamp TIME', 'Export revision time in ISO 8601 with timezone') do |v|
|
|
43
|
+
options[:calendar_stamp] = v
|
|
44
|
+
end
|
|
45
|
+
parser.on('--calendar-id ID', 'Unique caller-owned calendar namespace') { |v| options[:calendar_id] = v }
|
|
46
|
+
|
|
34
47
|
parser.on('-o', '--output FILE', 'Output file (default: stdout)') do |o|
|
|
35
48
|
options[:output] = o
|
|
36
49
|
end
|
|
37
50
|
|
|
51
|
+
parser.on('--photo-mode MODE', %w[uri embedded], 'vCard photos: uri (default) or embedded') do |mode|
|
|
52
|
+
options[:photo_mode] = mode.to_sym
|
|
53
|
+
end
|
|
54
|
+
|
|
38
55
|
parser.on('--extract-images DIR', 'Copy contact images to DIR') do |dir|
|
|
39
56
|
options[:extract_images] = dir
|
|
40
57
|
end
|
|
@@ -43,10 +60,28 @@ parser.on('--stats', 'Print contact statistics') do
|
|
|
43
60
|
options[:stats] = true
|
|
44
61
|
end
|
|
45
62
|
|
|
63
|
+
parser.on('--diff PATH', 'Compare this input with another archive; optional --json') do |path|
|
|
64
|
+
options[:diff] = path
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
parser.on('--history', 'Analyze a directory of .abbu snapshots as JSON Lines') do
|
|
68
|
+
options[:history] = true
|
|
69
|
+
end
|
|
70
|
+
|
|
46
71
|
parser.on('--dedupe', 'Find and print duplicate contacts') do
|
|
47
72
|
options[:dedupe] = true
|
|
48
73
|
end
|
|
49
74
|
|
|
75
|
+
parser.on('--merge-preview', 'Preview exact-evidence merge plans as JSON; never apply') do
|
|
76
|
+
options[:merge_preview] = true
|
|
77
|
+
end
|
|
78
|
+
parser.on('--merge-policy POLICY', 'Explicit merge policy for the preview') do |policy|
|
|
79
|
+
options[:merge_policy] = policy.to_sym
|
|
80
|
+
end
|
|
81
|
+
parser.on('--prefer-source PATH', 'Exact source relative_path for prefer_source policy') do |path|
|
|
82
|
+
options[:prefer_source] = path
|
|
83
|
+
end
|
|
84
|
+
|
|
50
85
|
parser.on('--search TERM', 'Find contacts by partial name or email') do |term|
|
|
51
86
|
options[:search] = term
|
|
52
87
|
end
|
|
@@ -75,10 +110,18 @@ parser.on('--phone NUMBER', 'Find contacts by exact normalized phone number') do
|
|
|
75
110
|
options[:phone] = number
|
|
76
111
|
end
|
|
77
112
|
|
|
78
|
-
parser.on('--json', 'Emit
|
|
113
|
+
parser.on('--json', 'Emit a single JSON document for the selected operation (default: contacts)') do
|
|
79
114
|
options[:json] = true
|
|
80
115
|
end
|
|
81
116
|
|
|
117
|
+
parser.on('--matches', 'Emit provenance-aware identity suggestions as JSON; never merges') do
|
|
118
|
+
options[:matches] = true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
parser.on('--diagnostics', 'Parse contacts and emit parser diagnostics as JSON') do
|
|
122
|
+
options[:diagnostics] = true
|
|
123
|
+
end
|
|
124
|
+
|
|
82
125
|
timestamp_bounds = %w[since before]
|
|
83
126
|
%w[created modified].each do |kind|
|
|
84
127
|
timestamp_bounds.each do |bound|
|
|
@@ -91,12 +134,12 @@ timestamp_bounds = %w[since before]
|
|
|
91
134
|
end
|
|
92
135
|
|
|
93
136
|
parser.on('-v', '--version', 'Print version') do
|
|
94
|
-
puts "abbu #{Abbu::VERSION}"
|
|
137
|
+
puts(machine_requested ? JSON.generate(version: Abbu::VERSION) : "abbu #{Abbu::VERSION}")
|
|
95
138
|
exit
|
|
96
139
|
end
|
|
97
140
|
|
|
98
141
|
parser.on('-h', '--help', 'Print this help') do
|
|
99
|
-
puts parser
|
|
142
|
+
puts(machine_requested ? JSON.generate(help: parser.to_s) : parser)
|
|
100
143
|
exit
|
|
101
144
|
end
|
|
102
145
|
|
|
@@ -108,7 +151,16 @@ parser.on('--live-path PATH', 'Read the specified live AddressBook directory') d
|
|
|
108
151
|
options[:live_path] = path
|
|
109
152
|
end
|
|
110
153
|
|
|
111
|
-
|
|
154
|
+
begin
|
|
155
|
+
parser.parse!
|
|
156
|
+
rescue OptionParser::ParseError => e
|
|
157
|
+
raise unless machine_requested
|
|
158
|
+
|
|
159
|
+
puts JSON.generate(error: { code: 'invalid_option', message: e.message })
|
|
160
|
+
exit 2
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
exit Abbu::MachineCommand.new(options, ARGV).run if machine_requested
|
|
112
164
|
|
|
113
165
|
live_mode = options[:live] || !options[:live_path].nil?
|
|
114
166
|
live_path = options[:live_path]
|
|
@@ -142,6 +194,69 @@ at_exit do
|
|
|
142
194
|
end
|
|
143
195
|
|
|
144
196
|
begin
|
|
197
|
+
if options[:history]
|
|
198
|
+
allowed = %i[history strict]
|
|
199
|
+
raise ArgumentError, '--history supports only directory input and --strict' if
|
|
200
|
+
options.any? { |key, value| value && !allowed.include?(key) } || ARGV.any?
|
|
201
|
+
|
|
202
|
+
Abbu::SnapshotHistory.from_directory(file, strict: options[:strict]).each do |transition|
|
|
203
|
+
puts JSON.generate(transition)
|
|
204
|
+
end
|
|
205
|
+
exit 0
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
if options[:stream] && (!%w[csv jsonl vcard].include?(options[:format]) ||
|
|
209
|
+
options.values_at(:extract_images, :schema, :search, :email, :phone, :json, :date_filters,
|
|
210
|
+
:sources, :groups, :stats, :dedupe, :diff, :merge_preview, :merge_policy,
|
|
211
|
+
:prefer_source, :calendar_year, :calendar_stamp, :calendar_id).any?)
|
|
212
|
+
raise ArgumentError, '--stream requires only CSV, JSONL or vCard export options'
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
if options[:format] == 'sqlite'
|
|
216
|
+
conflicts = options.values_at(:stats, :dedupe, :search, :email, :phone, :schema, :extract_images,
|
|
217
|
+
:date_filters, :sources, :groups, :json, :photo_mode).any?
|
|
218
|
+
if options[:output].nil? || conflicts
|
|
219
|
+
raise ArgumentError, 'standalone --format sqlite requires --output FILE and no other operation'
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
Abbu::MergePreview.validate!(options)
|
|
223
|
+
if options[:diff]
|
|
224
|
+
unless options.values_at(:format, :output, :photo_mode, :extract_images, :stats, :dedupe, :schema,
|
|
225
|
+
:sources, :groups, :search, :email, :phone, :date_filters,
|
|
226
|
+
:calendar_year, :calendar_stamp, :calendar_id).compact.none? { |v| v }
|
|
227
|
+
raise ArgumentError, '--diff supports only --json, --strict and input selection'
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
archive = if live_mode
|
|
231
|
+
Abbu.open_live(live_path, strict: options[:strict])
|
|
232
|
+
else
|
|
233
|
+
Abbu.open(file, strict: options[:strict])
|
|
234
|
+
end
|
|
235
|
+
after = Abbu.open(options[:diff], strict: options[:strict])
|
|
236
|
+
comparison = Abbu::SnapshotDiff.new(archive, after).to_h
|
|
237
|
+
%i[added removed changed unchanged ambiguous].each { |kind| puts "#{kind}: #{comparison.fetch(kind).length}" }
|
|
238
|
+
warn "Comparison input diagnostics: #{after.diagnostics.length}" unless after.diagnostics.empty?
|
|
239
|
+
exit 0
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
calendar_options = options.values_at(:calendar_year, :calendar_stamp, :calendar_id)
|
|
243
|
+
if options[:format] == 'icalendar' || calendar_options.any?
|
|
244
|
+
conflicts = options.values_at(:stats, :dedupe, :search, :email, :phone, :schema, :extract_images,
|
|
245
|
+
:date_filters, :sources, :groups, :json, :photo_mode).any?
|
|
246
|
+
if options[:format] != 'icalendar' || calendar_options.any?(&:nil?) || conflicts
|
|
247
|
+
raise ArgumentError, 'standalone --format icalendar requires --calendar-year, --calendar-stamp, and --calendar-id'
|
|
248
|
+
end
|
|
249
|
+
unless options[:calendar_stamp].match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}:\d{2})\z/)
|
|
250
|
+
raise ArgumentError, '--calendar-stamp requires full ISO 8601 with timezone'
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
Date.iso8601(options[:calendar_stamp].split('T').first)
|
|
254
|
+
end
|
|
255
|
+
if options[:photo_mode] && (options[:format] != 'vcard' ||
|
|
256
|
+
options.values_at(:stats, :dedupe, :search, :email, :phone, :schema, :extract_images, :date_filters).any?)
|
|
257
|
+
raise ArgumentError, '--photo-mode requires a standalone --format vcard export'
|
|
258
|
+
end
|
|
259
|
+
|
|
145
260
|
listing = %i[sources groups].select { |key| options[key] }
|
|
146
261
|
if listing.any? && (listing.length > 1 || options.values_at(:extract_images, :schema, :search, :email, :phone,
|
|
147
262
|
:date_filters, :stats, :dedupe, :format, :output).any?)
|
|
@@ -164,6 +279,12 @@ begin
|
|
|
164
279
|
end
|
|
165
280
|
archive = live_mode ? Abbu.open_live(live_path, strict: options[:strict]) : Abbu.open(file, strict: options[:strict])
|
|
166
281
|
|
|
282
|
+
if options[:merge_preview]
|
|
283
|
+
plans = Abbu::MergePreview.plans(archive.contacts, policy: options[:merge_policy], source: options[:prefer_source])
|
|
284
|
+
puts JSON.pretty_generate(plans)
|
|
285
|
+
exit 0
|
|
286
|
+
end
|
|
287
|
+
|
|
167
288
|
if listing.any?
|
|
168
289
|
puts JSON.pretty_generate(archive.public_send(listing.first).map(&:to_h))
|
|
169
290
|
exit 0
|
|
@@ -247,6 +368,22 @@ begin
|
|
|
247
368
|
exit
|
|
248
369
|
end
|
|
249
370
|
|
|
371
|
+
if options[:stream]
|
|
372
|
+
contacts = archive.each_contact
|
|
373
|
+
exporter = case options[:format]
|
|
374
|
+
when 'csv' then Abbu::Exporters::CsvExporter.new(contacts)
|
|
375
|
+
when 'jsonl' then Abbu::Exporters::JsonlExporter.new(contacts)
|
|
376
|
+
when 'vcard'
|
|
377
|
+
Abbu::Exporters::VcardExporter.new(contacts, photo_mode: options.fetch(:photo_mode, :uri))
|
|
378
|
+
end
|
|
379
|
+
if options[:output]
|
|
380
|
+
File.open(options[:output], 'wb') { |io| exporter.write_to(io) }
|
|
381
|
+
else
|
|
382
|
+
exporter.write_to($stdout)
|
|
383
|
+
end
|
|
384
|
+
exit 0
|
|
385
|
+
end
|
|
386
|
+
|
|
250
387
|
case options[:format]
|
|
251
388
|
when 'csv'
|
|
252
389
|
exporter = Abbu::Exporters::CsvExporter.new(archive.contacts)
|
|
@@ -254,9 +391,24 @@ begin
|
|
|
254
391
|
when 'json'
|
|
255
392
|
exporter = Abbu::Exporters::JsonExporter.new(archive.contacts)
|
|
256
393
|
options[:output] ? exporter.to_file(options[:output]) : exporter.to_stdout
|
|
394
|
+
when 'jsonl'
|
|
395
|
+
exporter = Abbu::Exporters::JsonlExporter.new(archive.each_contact)
|
|
396
|
+
options[:output] ? exporter.to_file(options[:output]) : exporter.to_stdout
|
|
397
|
+
when 'sqlite'
|
|
398
|
+
begin
|
|
399
|
+
Abbu::Exporters::SqliteExporter.new(archive.contacts).to_file(options[:output])
|
|
400
|
+
rescue SystemCallError, SQLite3::Exception, JSON::GeneratorError => e
|
|
401
|
+
raise ArgumentError, "SQLite export failed (#{e.class}); no destination was replaced"
|
|
402
|
+
end
|
|
257
403
|
when 'vcard'
|
|
258
|
-
exporter = Abbu::Exporters::VcardExporter.new(archive.contacts)
|
|
404
|
+
exporter = Abbu::Exporters::VcardExporter.new(archive.contacts, photo_mode: options.fetch(:photo_mode, :uri))
|
|
405
|
+
options[:output] ? exporter.to_file(options[:output]) : exporter.to_stdout
|
|
406
|
+
when 'icalendar'
|
|
407
|
+
metadata = { year: options[:calendar_year], generated_at: Time.iso8601(options[:calendar_stamp]),
|
|
408
|
+
calendar_id: options[:calendar_id] }
|
|
409
|
+
exporter = Abbu::Exporters::IcalendarExporter.new(archive.contacts, **metadata)
|
|
259
410
|
options[:output] ? exporter.to_file(options[:output]) : exporter.to_stdout
|
|
411
|
+
exporter.diagnostics.each { |d| warn "Calendar #{d[:category]}: contact #{d[:contact_index]}, #{d[:field]}" }
|
|
260
412
|
else
|
|
261
413
|
puts parser
|
|
262
414
|
exit 1
|
data/bin/abbu-mcp
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# bin/abbu-mcp
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
6
|
+
|
|
7
|
+
require 'optparse'
|
|
8
|
+
require 'abbu'
|
|
9
|
+
|
|
10
|
+
options = {}
|
|
11
|
+
parser = OptionParser.new do |config|
|
|
12
|
+
config.banner = 'Usage: abbu-mcp ARCHIVE | abbu-mcp --live-path DIRECTORY [--strict]'
|
|
13
|
+
config.on('--live-path PATH', 'Explicit opt-in live store (read-only)') { |path| options[:live_path] = path }
|
|
14
|
+
config.on('--strict', 'Fail on unsupported or corrupt optional input') { options[:strict] = true }
|
|
15
|
+
config.on('-h', '--help', 'Show help, without opening Contacts data') do
|
|
16
|
+
puts config
|
|
17
|
+
exit
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
begin
|
|
22
|
+
parser.parse!
|
|
23
|
+
if options[:live_path] ? ARGV.any? : ARGV.length != 1
|
|
24
|
+
raise ArgumentError, 'Select exactly one archive or explicit live path'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
require 'abbu/mcp'
|
|
28
|
+
Abbu::Mcp::Server.new(path: options[:live_path] || ARGV.first,
|
|
29
|
+
live: !options[:live_path].nil?, strict: options[:strict] || false).open
|
|
30
|
+
rescue LoadError
|
|
31
|
+
warn 'abbu-mcp: install the optional mcp SDK (~> 1.6.1) in the same Ruby environment.'
|
|
32
|
+
exit 2
|
|
33
|
+
rescue OptionParser::ParseError, ArgumentError, Abbu::LiveStore::Error, SystemCallError
|
|
34
|
+
warn 'abbu-mcp: invalid configuration or unavailable input; check --help and local permissions.'
|
|
35
|
+
exit 2
|
|
36
|
+
end
|
data/docs/ABBU.md
CHANGED
|
@@ -420,6 +420,34 @@ separate compatibility gate before broader claims.
|
|
|
420
420
|
|
|
421
421
|
## Repository Evidence
|
|
422
422
|
|
|
423
|
+
- [Portable normalized SQLite](PORTABLE_SQLITE.md) writes an ABBU-owned derived
|
|
424
|
+
schema from public contacts, preserving source and raw multivalue evidence.
|
|
425
|
+
It does not copy private tables, infer Apple IDs, convert calendar semantics,
|
|
426
|
+
or claim Apple import compatibility. XML and SQLite source immutability is tested.
|
|
427
|
+
|
|
428
|
+
- [iCalendar export](ICALENDAR.md) consumes existing normalized birthday and
|
|
429
|
+
anniversary fields. Synthetic SQLite and plist regressions preserve raw labels
|
|
430
|
+
and source bytes; they introduce no private-calendar mapping or Apple import
|
|
431
|
+
certification. Explicit lunar/alternate values are diagnosed and omitted.
|
|
432
|
+
|
|
433
|
+
- [Alternate-calendar evidence boundary](ALTERNATE_CALENDAR_EVIDENCE.md): legacy
|
|
434
|
+
lunar labels/components do not establish a calendar, leap month or year cycle.
|
|
435
|
+
|
|
436
|
+
- [History evidence boundary](HISTORY_EVIDENCE.md): record timestamps, snapshot
|
|
437
|
+
differences and documented framework change events are not interchangeable.
|
|
438
|
+
|
|
439
|
+
- [My Card evidence boundary](MY_CARD_EVIDENCE.md): a public framework query is
|
|
440
|
+
not an archive schema mapping; no designation is inferred from contact values.
|
|
441
|
+
|
|
442
|
+
- [Writer decision](WRITER_DECISION.md): current evidence does not justify
|
|
443
|
+
constructing Apple-private archives. Read compatibility is not import proof;
|
|
444
|
+
future proposals require isolated, version-attributed import evidence.
|
|
445
|
+
- Embedded vCard PHOTO follows [RFC 2426 §3.1.4](https://www.rfc-editor.org/rfc/rfc2426#section-3.1.4)
|
|
446
|
+
binary/base64 serialization with existing CRLF folding. Synthetic signature
|
|
447
|
+
regressions prove byte preservation, not image decodability or Apple import
|
|
448
|
+
certification. JPEG/PNG/GIF are supported; HEIC embedding is explicitly rejected.
|
|
449
|
+
This introduces no new Apple image lookup or private-schema assumptions.
|
|
450
|
+
|
|
423
451
|
- [Compatibility regression matrix](FORMAT_COMPATIBILITY.md): always-on XML,
|
|
424
452
|
sparse/complete SQLite, root/source/mixed layout profiles and their explicit
|
|
425
453
|
historical evidence gaps. Filename suffixes are not macOS version guarantees.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<!-- docs/ALTERNATE_CALENDAR_EVIDENCE.md -->
|
|
2
|
+
|
|
3
|
+
# Alternate Calendars: Preserve Uncertainty
|
|
4
|
+
|
|
5
|
+
Research date: 2026-10-01. Issue [#36](https://github.com/scarver2/abbu/issues/36).
|
|
6
|
+
Proposed outcome: insufficient private-format evidence for new calendar,
|
|
7
|
+
leap-month or year-cycle decoding. Existing Gregorian and legacy lunar accessors
|
|
8
|
+
remain unchanged; this is documentation, not a conversion feature.
|
|
9
|
+
|
|
10
|
+
## What Apple Documents
|
|
11
|
+
|
|
12
|
+
Apple's public [birthday property](https://developer.apple.com/documentation/contacts/cncontact/birthday)
|
|
13
|
+
uses Gregorian date components with required month/day and optional year.
|
|
14
|
+
The [non-Gregorian birthday property](https://developer.apple.com/documentation/contacts/cncontact/nongregorianbirthday)
|
|
15
|
+
requires a non-Gregorian calendar and month/day, with optional year and leap-month
|
|
16
|
+
information. These are framework contracts, not a private SQLite/plist mapping.
|
|
17
|
+
|
|
18
|
+
The distinction matters: a label containing “LunarBirthday” and three numeric
|
|
19
|
+
components do not establish which calendar, leap-month state, cycle, era or
|
|
20
|
+
conversion rule applies. Do not default an unknown calendar to Chinese, treat
|
|
21
|
+
absence of a leap marker as false, or interpret a year as Gregorian merely
|
|
22
|
+
because its numeric range looks familiar.
|
|
23
|
+
|
|
24
|
+
Sources were inspected on the research date. No native framework calls,
|
|
25
|
+
real-address-book reads, or Apple import/export experiments were performed.
|
|
26
|
+
Third-party observations would remain hypotheses until independently reproduced.
|
|
27
|
+
|
|
28
|
+
## Repository Audit at Main `77eaeda`
|
|
29
|
+
|
|
30
|
+
- `SqliteParser` exposes rows from the known date-component table and selects
|
|
31
|
+
`lunar_birthday` by the normalized `LunarBirthday` label. Existing tests use a
|
|
32
|
+
synthetic `_$!<LunarBirthday>!$_` label with year/month/day and retain raw label.
|
|
33
|
+
- `PlistParser#extract_lunar_birthday` accepts a date-like `LunarBirthday` value
|
|
34
|
+
and reads year/month/day. Its synthetic fixture is not an attributed Apple
|
|
35
|
+
alternate-calendar export.
|
|
36
|
+
- Neither mapping establishes calendar identifier, leap month, era or year
|
|
37
|
+
cycle. No reproducible version-attributed mapping for those components is in
|
|
38
|
+
the [compatibility corpus](FORMAT_COMPATIBILITY.md).
|
|
39
|
+
- Current vCard/CSV/JSON exposure is legacy behavior, not evidence that a target
|
|
40
|
+
app interprets alternate calendars correctly. Existing tests must remain; do
|
|
41
|
+
not silently reinterpret or convert those numbers to improve presentation.
|
|
42
|
+
|
|
43
|
+
The normalized Contact model is not a complete raw database or plist copy.
|
|
44
|
+
Unknown columns/keys are not guaranteed to survive it. Keep original archives
|
|
45
|
+
as evidence; do not claim this docs-only decision makes current parsing lossless
|
|
46
|
+
for unmodeled metadata. Any future recognized representation must preserve its
|
|
47
|
+
original components alongside derived display values before conversion ships.
|
|
48
|
+
|
|
49
|
+
## Reproduction Gate
|
|
50
|
+
|
|
51
|
+
1. Use an explicitly approved disposable, unsynced test account with invented
|
|
52
|
+
contacts. Record exact macOS and Contacts builds, calendar configuration,
|
|
53
|
+
locale, export method and the UI value entered.
|
|
54
|
+
2. Vary one component at a time: calendar, optional year, leap month, ordinary
|
|
55
|
+
month, era/cycle where supported, and missing versus false. Include Gregorian
|
|
56
|
+
controls and dates sharing the same numeric components across calendars.
|
|
57
|
+
3. Observe exports without changing source stores. Capture raw representation,
|
|
58
|
+
candidate links and original bytes. Record unavailable components as unknown;
|
|
59
|
+
do not invent keys or table schemas for synthetic fixtures.
|
|
60
|
+
4. Reproduce the mapping independently, then create sanitized minimal fixtures
|
|
61
|
+
retaining exact observed representation and version provenance. Test custom
|
|
62
|
+
labels, malformed near-matches, Unicode, sparse/legacy schemas and duplicate
|
|
63
|
+
records across sources. Keep every existing Gregorian regression passing.
|
|
64
|
+
5. Design raw and interpreted RBS/API fields together. Require explicit calendar
|
|
65
|
+
identity and supported conversion rules before generating Gregorian dates or
|
|
66
|
+
recurrences. A future exporter must diagnose or omit unsupported calendar
|
|
67
|
+
data explicitly, never fabricate an age, date or leap state.
|
|
68
|
+
6. Validate any interchange claim by isolated export/import/re-export testing
|
|
69
|
+
on each claimed target build; serialization alone is not import evidence.
|
|
70
|
+
|
|
71
|
+
No additional storage representation is accepted by this research. The outcome
|
|
72
|
+
is to defer decoding until concrete evidence meets these gates, not to remove
|
|
73
|
+
legacy data or declare all lunar support complete. Under the development-train
|
|
74
|
+
directive this evidence boundary resolves the research scope; a new proposal
|
|
75
|
+
must supply the missing reproducible corpus before implementation.
|
|
76
|
+
|
|
77
|
+
Return to [README](../README.md), [roadmap](TODO.md), or [format evidence](ABBU.md).
|
|
78
|
+
|
|
79
|
+
—
|
|
80
|
+
Stan Carver II
|
|
81
|
+
Made in Texas 🤠
|
|
82
|
+
https://stancarver.com
|