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
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
|
-
- [
|
|
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
|
-
- [
|
|
147
|
-
- [
|
|
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
|
-
- [
|
|
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
|
|
@@ -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
|