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
data/docs/STREAMING.md ADDED
@@ -0,0 +1,73 @@
1
+ <!-- docs/STREAMING.md -->
2
+
3
+ # Streaming Contacts
4
+
5
+ [README](../README.md) · [Format evidence](ABBU.md)
6
+
7
+ `Archive#each_contact` and `LiveStore#each_contact` return an Enumerator without
8
+ a block, or yield contacts and return the input object with a block. They do not
9
+ read or populate `@contacts`. Existing `contacts`, queries, groups, and sources
10
+ retain their buffered behavior. SQLite rows are consumed through a read-only
11
+ cursor; legacy plist files are parsed one file at a time. Normalization, raw
12
+ labels, provenance, image resolution, and diagnostics use the existing parser
13
+ rules. This is not a new interpretation of Apple's storage schema.
14
+
15
+ Use block iteration, `first`, or `take` for early termination: unwinding the block
16
+ closes the statement and database, also on consumer exceptions. An externally
17
+ suspended Enumerator (`next`) retains its reader until resumed/unwound or garbage
18
+ collected; do not abandon suspended enumerators expecting immediate closure.
19
+ Live readers are not a cross-source atomic snapshot. A slow consumer retains an
20
+ active SQLite read cursor and may delay WAL checkpoint progress. Repeated calls
21
+ read again and can see newer source state; diagnostics accumulate on the input.
22
+
23
+ ## Export And Safety
24
+
25
+ CSV and vCard exporters expose `write_to(io)` for any contact Enumerable.
26
+ `JsonlExporter` adds `write_to(io)`, `to_file(path)`, and `to_stdout` using the
27
+ existing JSON contact fields. Caller-owned IO remains open. CLI `--stream`
28
+ accepts standalone CSV, JSONL, or vCard export, including explicit live paths;
29
+ JSONL always streams. Aggregate/search modes are not streaming modes.
30
+ `--stream` also rejects machine JSON modes, snapshot diff, merge options,
31
+ SQLite/iCalendar exports and calendar metadata before reading inputs or opening
32
+ output files. Machine-mode conflicts use the existing structured JSON error contract.
33
+
34
+ Writes are incremental and **not atomic**: a malformed later record, consumer IO
35
+ failure, or unsupported embedded photo can leave a partial file/stdout stream.
36
+ Use a caller-managed temporary file and rename after success if atomic delivery
37
+ is required. Buffered vCard's pre-output validation guarantee does not extend to
38
+ `write_to`. Protect output as sensitive personal data: JSONL has the same privacy
39
+ surface as JSON, including contact details, local provenance, and stored codes.
40
+ No encryption or anonymization is implied.
41
+
42
+ Contact retention is bounded when the consumer does not collect results. Total
43
+ memory is not constant: file paths and image indexes scale with the bundle,
44
+ diagnostics can grow, and one contact's values/photo can be large. `to_a`, grouping,
45
+ or buffering output in StringIO forfeits the retention benefit.
46
+
47
+ ## Synthetic Benchmark
48
+
49
+ Run from a development checkout with mise available on PATH:
50
+
51
+ ```sh
52
+ /usr/bin/time -l bin/benchmark-streaming stream 10000
53
+ /usr/bin/time -l bin/benchmark-streaming buffered 10000
54
+ ```
55
+
56
+ The temporary fixture uses the existing synthetic SQLite schema and 10,000
57
+ name-only contacts, no photos. Six missing-optional-schema diagnostics occur in
58
+ both modes. Timing excludes fixture construction but includes periodic explicit
59
+ GC. Process peak RSS includes setup/runtime; contact counts are sampled after GC
60
+ every 1,000 contacts, not a continuous heap maximum. These are local observations,
61
+ not performance guarantees for real archives.
62
+
63
+ Observed on macOS with Ruby 3.3.11, 2026-10-01:
64
+
65
+ | Mode | Iteration seconds | Peak RSS bytes | Sampled live contacts |
66
+ | --- | ---: | ---: | ---: |
67
+ | Streaming | 1.873 | 30,384,128 | 1 |
68
+ | Buffered | 2.681 | 48,254,976 | 10,000 |
69
+
70
+ —
71
+ Stan Carver II
72
+ Made in Texas 🤠
73
+ https://stancarver.com
data/docs/TODO.md CHANGED
@@ -141,10 +141,10 @@ Version assignments below 1.0 are intentionally deferred until scopes are accept
141
141
  - [ ] [#30](https://github.com/scarver2/abbu/issues/30): embedded vCard photos (currently file URI references)
142
142
  - [ ] [#31](https://github.com/scarver2/abbu/issues/31): first-class read-only source objects
143
143
  - [ ] [#32](https://github.com/scarver2/abbu/issues/32): first-class groups and membership queries
144
- - [ ] [#33](https://github.com/scarver2/abbu/issues/33): evidence-backed My Card identification
144
+ - [x] [#33](https://github.com/scarver2/abbu/issues/33): [My Card research — private mapping remains unsupported](MY_CARD_EVIDENCE.md)
145
145
  - [ ] [#34](https://github.com/scarver2/abbu/issues/34): modified-since and date-range queries
146
- - [ ] [#35](https://github.com/scarver2/abbu/issues/35): evidence-backed save/edit history
147
- - [ ] [#36](https://github.com/scarver2/abbu/issues/36): alternate-calendar and lunar metadata research
146
+ - [x] [#35](https://github.com/scarver2/abbu/issues/35): [history research — no verified private event mapping](HISTORY_EVIDENCE.md)
147
+ - [x] [#36](https://github.com/scarver2/abbu/issues/36): [alternate-calendar research — richer decoding awaits evidence](ALTERNATE_CALENDAR_EVIDENCE.md)
148
148
  - [ ] [#37](https://github.com/scarver2/abbu/issues/37): explainable fuzzy names and configurable thresholds
149
149
  - [ ] [#38](https://github.com/scarver2/abbu/issues/38): merge plans, side-by-side evidence, and safe built-in policies
150
150
  - [ ] [#39](https://github.com/scarver2/abbu/issues/39): broader machine-readable JSON CLI contract
@@ -154,7 +154,7 @@ Version assignments below 1.0 are intentionally deferred until scopes are accept
154
154
  - [ ] [#43](https://github.com/scarver2/abbu/issues/43): identity-aware snapshot diffs
155
155
  - [ ] [#44](https://github.com/scarver2/abbu/issues/44): history across snapshot directories
156
156
  - [ ] [#45](https://github.com/scarver2/abbu/issues/45): optional MCP/agent adapter over read-only interfaces
157
- - [ ] [#46](https://github.com/scarver2/abbu/issues/46): evidence-backed new-ABBU writer research
157
+ - [x] [#46](https://github.com/scarver2/abbu/issues/46): [no private-schema writer with current evidence](WRITER_DECISION.md)
158
158
  - [ ] [#47](https://github.com/scarver2/abbu/issues/47): public API stability checklist
159
159
  - [ ] Thumbnail/full-size image selection after reproducible format evidence
160
160
  - [ ] Region filtering and an explicit CLI filter grammar
@@ -0,0 +1,90 @@
1
+ <!-- docs/WRITER_DECISION.md -->
2
+
3
+ # Decision: Do Not Generate Apple-Private ABBU Bundles
4
+
5
+ Date: 2026-10-01. Scope: research issue [#46](https://github.com/scarver2/abbu/issues/46).
6
+ Status: proposed for review; effective when merged. No runtime API or version change.
7
+
8
+ ## Decision
9
+
10
+ Do not implement an ABBU bundle writer with the evidence currently available.
11
+ Keep source archives and live stores read-only. Do not add `deduplicate!`,
12
+ in-place repair, private SQLite writes, or an archive reconstruction API from
13
+ normalized Contact objects. Continue to offer vCard as the documented contact
14
+ interchange path, with its field limitations explicit. Keep original archives
15
+ as evidence; neither vCard nor normalized JSON is a complete archive backup.
16
+
17
+ This is a decision about this project's evidence, not a claim that creating
18
+ valid archives is impossible or that no private-format research exists.
19
+
20
+ ## Evidence and Limits
21
+
22
+ - Apple's [import guide](https://support.apple.com/en-gb/guide/contacts/adrbk1457/mac)
23
+ documents vCard and ABBU import and warns that archive import replaces current
24
+ contact information. Its UI instructions are not a bundle-construction schema.
25
+ - Apple's [export guide](https://support.apple.com/guide/contacts/export-or-archive-contacts-adrbdcfd32e6/mac)
26
+ distinguishes selected-contact vCards from full Contacts archives. Photo and
27
+ note inclusion can be controlled for vCard exports. This does not establish
28
+ the fidelity of ABBU's own serializer.
29
+ - Both pages were inspected on 2026-10-01. No Contacts import experiment was
30
+ performed for this decision; no personal address book was opened or modified.
31
+ - The [compatibility matrix](FORMAT_COMPATIBILITY.md) has synthetic layouts with
32
+ unknown macOS/Contacts builds. Successful parsing proves those read contracts,
33
+ not that Contacts accepts a database produced from them.
34
+ - [Format evidence](ABBU.md) describes observed fields and explicit gaps.
35
+ Unknown private metadata, identifiers, relationships, account state and
36
+ alternate calendars must not be manufactured from table/column names.
37
+
38
+ Inference: current read fixtures and public UI documentation are insufficient
39
+ to justify a safe, version-scoped private writer. Renaming exported SQLite to
40
+ `.abcddb` or packaging normalized contacts into an `.abbu` directory would not
41
+ meet the import-evidence requirement.
42
+
43
+ ## Preservation Requirements Before Reconsideration
44
+
45
+ | Evidence | Required proof, not an assumption |
46
+ | --- | --- |
47
+ | Labels and multivalues | Raw labels, duplicates, order where significant, Unicode, empty and absent values survive; normalization does not replace originals. |
48
+ | Photos | Original bytes and source identity remain available; image relationships and supported encodings are demonstrated. No inferred thumbnail/full-size preference. |
49
+ | Groups and sources | Membership remains source-local; same-name groups and colliding record keys remain distinct. Account/sync identities are never fabricated. |
50
+ | Dates | Unknown years remain unknown; custom labels, alternate calendars and leap metadata remain raw until their semantics are demonstrated. |
51
+ | Provenance | Original snapshot/file identity remains separately traceable; destination keys are not presented as original identities or human-edit timestamps. |
52
+ | Unsupported fields | Every omission has an explicit loss report; unknown private fields cannot silently disappear behind a “lossless” claim. |
53
+
54
+ Current vCard output does not reconstruct sources/groups/private provenance or
55
+ every date collection. URI photos depend on external paths. Optional embedded
56
+ JPEG/PNG/GIF vCard photos are available, with the documented content-signature,
57
+ memory and unsupported-HEIC limits; embedding does not establish whole-archive
58
+ fidelity. Retain original bytes and use available
59
+ JSON evidence for inspection, not as an assumed reconstruction format.
60
+
61
+ ## Gate for a Future Proposal
62
+
63
+ 1. Obtain explicit approval for isolated import experiments. Use a disposable
64
+ macOS user or VM with no real contacts or synced accounts; never a user's
65
+ everyday Contacts store. A backup alone does not make replacement harmless.
66
+ 2. Record exact macOS and Contacts builds, export settings, initial synthetic
67
+ data and schema fingerprints. Record unknowns honestly. Preserve old cases.
68
+ 3. Demonstrate a minimal generated bundle importing successfully, then inspect
69
+ and re-export it. Verify field-level values, raw evidence and losses against
70
+ the table above; counts alone are insufficient. Repeat for every claimed
71
+ supported version. Negative/unsupported versions must fail explicitly.
72
+ 4. Require a new destination outside every source tree. Stage on the same
73
+ filesystem and publish atomically with no overwrite, rejecting symlink/path
74
+ aliases and existing destinations. Test interruption, failures, cleanup and
75
+ races. Never update a live Contacts database or reuse input files as staging.
76
+ 5. Independently review sanitized fixtures, import observations, preservation
77
+ reports and the safety implementation before introducing a public writer.
78
+
79
+ These are future acceptance gates, not implemented guarantees. Existing vCard
80
+ `to_file` is not an atomic/no-overwrite archive writer. This decision adds no
81
+ writer, no import automation, no mutation API and no new format claims. Issue
82
+ #46 can close on the explicitly allowed no-writer outcome; new evidence should
83
+ open a new scoped proposal rather than silently reversing this boundary.
84
+
85
+ Return to [README](../README.md), [roadmap](TODO.md), or [format evidence](ABBU.md).
86
+
87
+ —
88
+ Stan Carver II
89
+ Made in Texas 🤠
90
+ https://stancarver.com
data/lib/abbu/archive.rb CHANGED
@@ -27,6 +27,20 @@ module Abbu
27
27
  @contacts ||= parser.contacts.tap { |cs| attach_images(cs) }
28
28
  end
29
29
 
30
+ def each_contact
31
+ return enum_for(:each_contact) unless block_given?
32
+
33
+ resolver = Utils::ImageResolver.new(@path)
34
+ parser.each_contact do |contact|
35
+ if contact.image_uri
36
+ contact.image_path = resolver.resolve(contact.image_uri, source: contact.source)
37
+ record_missing_image(contact) unless contact.image_path
38
+ end
39
+ yield contact
40
+ end
41
+ self
42
+ end
43
+
30
44
  def query
31
45
  Query.new(contacts)
32
46
  end
@@ -24,6 +24,13 @@ module Abbu
24
24
  end)
25
25
  end
26
26
 
27
+ def write_to(io)
28
+ csv = CSV.new(io)
29
+ csv << headers
30
+ @contacts.each { |contact| csv << row(contact) }
31
+ nil
32
+ end
33
+
27
34
  private
28
35
 
29
36
  def headers
@@ -0,0 +1,35 @@
1
+ # lib/abbu/exporters/icalendar_encoding.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Abbu
5
+ module Exporters
6
+ # RFC 5545 TEXT and 75-octet content lines, without splitting UTF-8.
7
+ module IcalendarEncoding
8
+ module_function
9
+
10
+ def text(value)
11
+ utf8(value).gsub(/\\|;|,|\r\n|\r|\n/) do |character|
12
+ character.match?(/[\r\n]/) ? '\\n' : "\\#{character}"
13
+ end
14
+ end
15
+
16
+ def fold(line)
17
+ parts = [+'']
18
+ utf8(line).each_char do |character|
19
+ parts << +' ' if parts.last.bytesize + character.bytesize > 75
20
+ parts.last << character
21
+ end
22
+ parts.join("\r\n")
23
+ end
24
+
25
+ def utf8(value)
26
+ string = value.to_s.encode(Encoding::UTF_8)
27
+ raise ArgumentError, 'iCalendar text has invalid encoding' unless string.valid_encoding?
28
+ raise ArgumentError, 'iCalendar text contains an unsupported control character' if
29
+ string.match?(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/)
30
+
31
+ string
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,61 @@
1
+ # lib/abbu/exporters/icalendar_event.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'date'
5
+
6
+ require_relative 'icalendar_encoding'
7
+
8
+ module Abbu
9
+ module Exporters
10
+ # Internal validation of normalized Gregorian date components only.
11
+ class IcalendarEvent
12
+ attr_reader :error
13
+
14
+ def initialize(date, year:, kind:)
15
+ @date = date
16
+ @year = year
17
+ @kind = kind
18
+ @error = validate
19
+ end
20
+
21
+ def lines(name:, uid:, stamp:)
22
+ label = @date[:raw_label] || @date[:label] || @kind.to_s.capitalize
23
+ start = occurrence.strftime('%Y%m%d')
24
+ ['BEGIN:VEVENT', "UID:#{uid}", "DTSTAMP:#{stamp}", "DTSTART;VALUE=DATE:#{start}",
25
+ 'DURATION:P1D', 'RRULE:FREQ=YEARLY', 'TRANSP:TRANSPARENT',
26
+ "SUMMARY:#{IcalendarEncoding.text("#{name} — #{label}")}",
27
+ "X-ABBU-YEAR-UNKNOWN:#{unknown_year? ? 'TRUE' : 'FALSE'}",
28
+ "DESCRIPTION:#{IcalendarEncoding.text(description)}", 'END:VEVENT']
29
+ end
30
+
31
+ private
32
+
33
+ def validate
34
+ return :invalid_date unless @date.is_a?(Hash)
35
+ return :unsupported_calendar unless [nil, 'gregorian', :gregorian].include?(@date[:calendar])
36
+
37
+ values = @date.values_at(:month, :day)
38
+ year = unknown_year? ? 2000 : @date[:year]
39
+ return :invalid_date unless [year, *values].all?(Integer) && (1..9999).cover?(year)
40
+ return :invalid_date unless Date.valid_date?(year, *values, Date::GREGORIAN)
41
+
42
+ nil
43
+ end
44
+
45
+ def unknown_year?
46
+ @date[:year].nil? || (@date[:year].is_a?(Integer) && @date[:year].zero?)
47
+ end
48
+
49
+ def occurrence
50
+ year = [@year, unknown_year? ? @year : @date[:year]].max
51
+ year += 1 until Date.valid_date?(year, @date[:month], @date[:day], Date::GREGORIAN)
52
+ Date.new(year, @date[:month], @date[:day], Date::GREGORIAN)
53
+ end
54
+
55
+ def description
56
+ origin = unknown_year? ? 'Original year unknown; no age is asserted.' : "Original year: #{@date[:year]}."
57
+ "ABBU reminder. DTSTART is the recurrence start, not an assertion of birth or marriage year. #{origin}"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,86 @@
1
+ # lib/abbu/exporters/icalendar_exporter.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'date'
5
+ require 'digest'
6
+ require 'json'
7
+ require 'time'
8
+
9
+ require_relative 'icalendar_encoding'
10
+ require_relative 'icalendar_event'
11
+
12
+ module Abbu
13
+ module Exporters
14
+ # Derived reminders, not Apple calendar identities or a synchronization feed.
15
+ class IcalendarExporter
16
+ attr_reader :diagnostics
17
+
18
+ def initialize(contacts, year:, generated_at:, calendar_id:)
19
+ raise ArgumentError, 'year must be an Integer between 1 and 9996' unless
20
+ year.is_a?(Integer) && (1..9996).cover?(year)
21
+ raise ArgumentError, 'calendar_id must be a nonempty String' unless
22
+ calendar_id.is_a?(String) && !calendar_id.strip.empty?
23
+
24
+ @contacts = contacts
25
+ @year = year
26
+ @stamp = timestamp(generated_at)
27
+ @calendar_id = IcalendarEncoding.text(calendar_id)
28
+ @diagnostics = [].freeze
29
+ end
30
+
31
+ def to_file(path)
32
+ File.binwrite(path, to_ical)
33
+ end
34
+
35
+ def to_stdout
36
+ print to_ical
37
+ end
38
+
39
+ def to_ical
40
+ @diagnostics = []
41
+ lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//ABBU//Contact Reminders//EN', 'CALSCALE:GREGORIAN']
42
+ @contacts.each_with_index { |contact, index| append_contact(lines, contact, index) }
43
+ return '' if lines.length == 4
44
+
45
+ lines << 'END:VCALENDAR'
46
+ "#{lines.map { |line| IcalendarEncoding.fold(line) }.join("\r\n")}\r\n"
47
+ ensure
48
+ @diagnostics.freeze
49
+ end
50
+
51
+ private
52
+
53
+ def timestamp(value)
54
+ valid = value.is_a?(Time) && (1..9999).cover?(value.getutc.year)
55
+ raise ArgumentError, 'generated_at must be a Time with a UTC year between 1 and 9999' unless valid
56
+
57
+ value.getutc.strftime('%Y%m%dT%H%M%SZ')
58
+ end
59
+
60
+ def append_contact(lines, contact, index) # rubocop:disable Metrics/MethodLength
61
+ diagnose(:unsupported_calendar, index, :lunar_birthday) if contact.lunar_birthday
62
+ %i[birthday anniversary].each do |field|
63
+ date = contact.public_send(field)
64
+ next if date.nil?
65
+
66
+ event = IcalendarEvent.new(date, year: @year, kind: field)
67
+ if event.error
68
+ diagnose(event.error, index, field)
69
+ else
70
+ lines.concat(event.lines(name: contact.full_name, uid: uid(contact, index, field), stamp: @stamp))
71
+ end
72
+ end
73
+ end
74
+
75
+ def uid(contact, index, field)
76
+ name = IcalendarEncoding.text(contact.full_name)
77
+ evidence = [@calendar_id, index, field, contact.source&.fetch(:relative_path, nil), name]
78
+ "#{Digest::SHA256.hexdigest(JSON.generate(evidence))}@abbu.invalid"
79
+ end
80
+
81
+ def diagnose(category, index, field)
82
+ @diagnostics << { category: category, contact_index: index, field: field }.freeze
83
+ end
84
+ end
85
+ end
86
+ end
@@ -19,12 +19,12 @@ module Abbu
19
19
  puts JSON.pretty_generate(payload)
20
20
  end
21
21
 
22
- private
23
-
24
22
  def payload
25
23
  @contacts.map { |c| contact_hash(c) }
26
24
  end
27
25
 
26
+ private
27
+
28
28
  def contact_hash(contact) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
29
29
  {
30
30
  name: contact.full_name,
@@ -0,0 +1,23 @@
1
+ # lib/abbu/exporters/jsonl_exporter.rb
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'json_exporter'
5
+
6
+ module Abbu
7
+ module Exporters
8
+ class JsonlExporter < JsonExporter
9
+ def write_to(io)
10
+ @contacts.each { |contact| io.write("#{JSON.generate(contact_hash(contact))}\n") }
11
+ nil
12
+ end
13
+
14
+ def to_file(path)
15
+ File.open(path, 'wb') { |io| write_to(io) }
16
+ end
17
+
18
+ def to_stdout
19
+ write_to($stdout)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,85 @@
1
+ # lib/abbu/exporters/sqlite_exporter.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'pathname'
5
+ require 'sqlite3'
6
+ require 'tempfile'
7
+
8
+ require_relative '../version'
9
+ require_relative 'sqlite_rows'
10
+ require_relative 'sqlite_schema'
11
+
12
+ module Abbu
13
+ module Exporters
14
+ # A separate, plaintext derived artifact. Publication never replaces a file.
15
+ class SqliteExporter
16
+ SCHEMA_VERSION = SqliteSchema::VERSION
17
+
18
+ def initialize(contacts)
19
+ @contacts = contacts
20
+ end
21
+
22
+ def to_file(path)
23
+ destination = destination_path(path)
24
+ contacts = @contacts.to_a
25
+ check_source_boundaries(contacts, destination)
26
+ Tempfile.create(['.abbu-export-', '.sqlite'], File.dirname(destination)) do |file|
27
+ file.close
28
+ build(file.path, contacts)
29
+ File.link(file.path, destination)
30
+ end
31
+ destination
32
+ end
33
+
34
+ private
35
+
36
+ def destination_path(path)
37
+ expanded = File.expand_path(path)
38
+ parent = File.realpath(File.dirname(expanded))
39
+ destination = File.join(parent, File.basename(expanded))
40
+ raise Errno::EEXIST, destination if File.exist?(destination) || File.symlink?(destination)
41
+ if [parent, File.dirname(expanded)].any? { |entry| abbu_ancestors(entry).any? }
42
+ raise ArgumentError, 'SQLite export destination must be outside source ABBU bundles'
43
+ end
44
+
45
+ destination
46
+ end
47
+
48
+ def check_source_boundaries(contacts, destination)
49
+ contacts.each do |contact|
50
+ path = contact.source&.fetch(:path, nil)
51
+ next unless path
52
+
53
+ if source_bundles(path).any? { |bundle| destination.start_with?("#{bundle}/") }
54
+ raise ArgumentError, 'SQLite export destination must be outside source ABBU bundles'
55
+ end
56
+ end
57
+ end
58
+
59
+ def source_bundles(path)
60
+ paths = [File.expand_path(path)]
61
+ paths << File.realpath(path) if File.exist?(path)
62
+ paths.flat_map do |entry|
63
+ abbu_ancestors(entry).map do |ancestor|
64
+ File.exist?(ancestor) ? File.realpath(ancestor) : ancestor.to_s
65
+ end
66
+ end
67
+ end
68
+
69
+ def abbu_ancestors(path)
70
+ Pathname.new(path).ascend.select { |ancestor| ancestor.extname.downcase == '.abbu' }
71
+ end
72
+
73
+ def build(path, contacts)
74
+ SQLite3::Database.new(path) do |db|
75
+ SqliteSchema.create(db)
76
+ db.transaction do
77
+ db.execute('INSERT INTO metadata VALUES (?, ?)', [SCHEMA_VERSION, Abbu::VERSION])
78
+ rows = SqliteRows.new(db)
79
+ contacts.each_with_index { |contact, index| rows.write(contact, index + 1) }
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,113 @@
1
+ # lib/abbu/exporters/sqlite_rows.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require 'pathname'
6
+ require 'time'
7
+
8
+ require_relative 'sqlite_schema'
9
+
10
+ module Abbu
11
+ module Exporters
12
+ # Writes normalized columns and complete per-entry evidence side by side.
13
+ class SqliteRows
14
+ def initialize(db)
15
+ @db = db
16
+ @sources = {}
17
+ @groups = {}
18
+ end
19
+
20
+ def write(contact, id)
21
+ source_id = source(contact.source)
22
+ fields = flat_fields(contact)
23
+ times = [timestamp(contact.created_at), timestamp(contact.modified_at)]
24
+ insert(:contacts, [id, source_id, *fields.values.map { |value| value&.to_s }, *times, evidence(fields)])
25
+ collections(contact, id)
26
+ dates(contact, id)
27
+ groups(contact, id, source_id)
28
+ end
29
+
30
+ private
31
+
32
+ def flat_fields(contact)
33
+ SqliteSchema::CONTACT_FIELDS.to_h do |field|
34
+ value = contact.public_send(field)
35
+ [field, value.is_a?(Pathname) ? value.to_s : value]
36
+ end
37
+ end
38
+
39
+ def insert(table, values)
40
+ placeholders = Array.new(values.length, '?').join(', ')
41
+ @db.execute("INSERT INTO #{table} VALUES (#{placeholders})", values)
42
+ end
43
+
44
+ def evidence(value)
45
+ JSON.generate(canonical(value))
46
+ end
47
+
48
+ def canonical(value)
49
+ case value
50
+ when Hash
51
+ value.sort_by { |key, _| key.to_s }.to_h.transform_values { |entry| canonical(entry) }
52
+ when Array
53
+ value.map { |entry| canonical(entry) }
54
+ else
55
+ value
56
+ end
57
+ end
58
+
59
+ def timestamp(value)
60
+ value&.iso8601(9)
61
+ end
62
+
63
+ def source(value)
64
+ return if value.nil?
65
+
66
+ json = evidence(value)
67
+ @sources[json] ||= begin
68
+ id = @sources.length + 1
69
+ insert(:sources, [id, *value.values_at(:path, :relative_path, :kind, :identifier), json])
70
+ id
71
+ end
72
+ end
73
+
74
+ def collections(contact, id)
75
+ SqliteSchema::COLLECTIONS.each do |field, columns|
76
+ contact.public_send(field).each_with_index do |entry, position|
77
+ insert(field, [id, position, *entry.values_at(*columns), evidence(entry)])
78
+ end
79
+ end
80
+ scalar_values(:notes, contact.notes, id)
81
+ scalar_values(:group_labels, contact.groups, id)
82
+ end
83
+
84
+ def scalar_values(table, values, id)
85
+ values.each_with_index { |value, position| insert(table, [id, position, value&.to_s, evidence(value)]) }
86
+ end
87
+
88
+ def dates(contact, id)
89
+ SqliteSchema::DATE_FIELDS.each do |field|
90
+ values = field == :dates ? contact.dates : [contact.public_send(field)].compact
91
+ values.each_with_index do |entry, position|
92
+ components = entry.values_at(:year, :month, :day, :label, :raw_label)
93
+ insert(:dates, [id, field.to_s, position, *components, evidence(entry)])
94
+ end
95
+ end
96
+ end
97
+
98
+ def groups(contact, id, source_id)
99
+ contact.group_memberships.each_with_index do |entry, position|
100
+ json = evidence(entry)
101
+ # Without source evidence, membership observations stay contact-local.
102
+ key = [source_id || "contact:#{id}", json]
103
+ group_id = @groups[key] ||= begin
104
+ next_id = @groups.length + 1
105
+ insert(:groups, [next_id, source_id, *entry.values_at(:record_id, :name), json])
106
+ next_id
107
+ end
108
+ insert(:group_memberships, [id, position, group_id])
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end