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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +129 -2
  3. data/bin/abbu +158 -6
  4. data/bin/abbu-mcp +36 -0
  5. data/docs/ABBU.md +28 -0
  6. data/docs/ALTERNATE_CALENDAR_EVIDENCE.md +82 -0
  7. data/docs/CHANGELOG.md +98 -0
  8. data/docs/CONTRIBUTING.md +3 -0
  9. data/docs/FUZZY_MATCHING.md +72 -0
  10. data/docs/HISTORY_EVIDENCE.md +98 -0
  11. data/docs/ICALENDAR.md +101 -0
  12. data/docs/MACHINE_JSON.md +128 -0
  13. data/docs/MCP.md +148 -0
  14. data/docs/MERGE_PLANS.md +78 -0
  15. data/docs/MY_CARD_EVIDENCE.md +92 -0
  16. data/docs/PORTABLE_SQLITE.md +142 -0
  17. data/docs/RELEASING.md +76 -68
  18. data/docs/SNAPSHOT_DIFF.md +74 -0
  19. data/docs/SNAPSHOT_HISTORY.md +93 -0
  20. data/docs/STREAMING.md +73 -0
  21. data/docs/TODO.md +4 -4
  22. data/docs/WRITER_DECISION.md +90 -0
  23. data/lib/abbu/archive.rb +14 -0
  24. data/lib/abbu/exporters/csv_exporter.rb +7 -0
  25. data/lib/abbu/exporters/icalendar_encoding.rb +35 -0
  26. data/lib/abbu/exporters/icalendar_event.rb +61 -0
  27. data/lib/abbu/exporters/icalendar_exporter.rb +86 -0
  28. data/lib/abbu/exporters/json_exporter.rb +2 -2
  29. data/lib/abbu/exporters/jsonl_exporter.rb +23 -0
  30. data/lib/abbu/exporters/sqlite_exporter.rb +85 -0
  31. data/lib/abbu/exporters/sqlite_rows.rb +113 -0
  32. data/lib/abbu/exporters/sqlite_schema.rb +76 -0
  33. data/lib/abbu/exporters/vcard_exporter.rb +10 -11
  34. data/lib/abbu/exporters/vcard_photo.rb +50 -0
  35. data/lib/abbu/history_tracker.rb +96 -0
  36. data/lib/abbu/live_store.rb +11 -0
  37. data/lib/abbu/machine_command.rb +159 -0
  38. data/lib/abbu/machine_output.rb +52 -0
  39. data/lib/abbu/mcp/read_session.rb +57 -0
  40. data/lib/abbu/mcp/server.rb +100 -0
  41. data/lib/abbu/mcp.rb +11 -0
  42. data/lib/abbu/merge_plan.rb +132 -0
  43. data/lib/abbu/merge_preview.rb +35 -0
  44. data/lib/abbu/merge_values.rb +26 -0
  45. data/lib/abbu/parsers/plist_parser.rb +9 -0
  46. data/lib/abbu/parsers/sqlite_parser.rb +25 -0
  47. data/lib/abbu/snapshot_diff.rb +125 -0
  48. data/lib/abbu/snapshot_history.rb +66 -0
  49. data/lib/abbu/utils/fuzzy_matcher.rb +116 -0
  50. data/lib/abbu/version.rb +1 -1
  51. data/lib/abbu.rb +9 -0
  52. data/sig/fuzzy_matcher.rbs +22 -0
  53. data/sig/icalendar_exporter.rbs +13 -0
  54. data/sig/machine_output.rbs +23 -0
  55. data/sig/mcp.rbs +19 -0
  56. data/sig/merge_plan.rbs +16 -0
  57. data/sig/snapshot_diff.rbs +8 -0
  58. data/sig/snapshot_history.rbs +10 -0
  59. data/sig/sqlite_exporter.rbs +10 -0
  60. data/sig/streaming.rbs +27 -0
  61. data/sig/vcard_exporter.rbs +1 -1
  62. metadata +46 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 71bd4536cf0e22f9bf9177d5715f4210a84a3b50bcab562266fe4d71c24d831a
4
- data.tar.gz: 1bce6e9c3022f814f7ae789c232e8f0d62fe17999cca109cdba16825cea5a7f8
3
+ metadata.gz: 99f31bf1bbf67088eedcdca46c8a5c90835d3371a93713c7c93c15d571504e4c
4
+ data.tar.gz: f1bacec2990fcf1c55765377d3985a92faa2be7ea921ecb91b26565e22f8c865
5
5
  SHA512:
6
- metadata.gz: 378d928ea9d82de3f5ddb54e8f4d9b61e8b14a9cea61225248394e4d665bc8a5c7263a8292af2089acb879b6a0d600dd532fa9a288c5025b36de9c000fab6107
7
- data.tar.gz: 573617afdbedd7af5a415dd08f0fb1b2c330d8ccc1bc4d798f0a9febc38eb455b83e292a4c3f25dd30728770fe56a0b97cb65c1acd1e9a13e59df7be43377042
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.8.0 strengthens vCard serialization alongside source-scoped groups, timestamp
7
- queries, diagnostics, identity evidence, image extraction, and CSV/JSON/vCard export.
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 vcard], 'Export format (csv, json, vcard)') do |f|
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 search results as JSON (optional for --sources or --groups)') do
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
- parser.parse!
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