jcard_jcal 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +132 -0
- data/SECURITY.md +10 -0
- data/data/ical_properties.yml +52 -0
- data/data/vcard_properties.yml +42 -0
- data/lib/jcal.rb +30 -0
- data/lib/jcard.rb +28 -0
- data/lib/jcard_jcal/component.rb +72 -0
- data/lib/jcard_jcal/content_line/param_codec.rb +48 -0
- data/lib/jcard_jcal/content_line/reader.rb +86 -0
- data/lib/jcard_jcal/content_line/text_codec.rb +56 -0
- data/lib/jcard_jcal/content_line/writer.rb +57 -0
- data/lib/jcard_jcal/converter.rb +229 -0
- data/lib/jcard_jcal/errors.rb +21 -0
- data/lib/jcard_jcal/property.rb +113 -0
- data/lib/jcard_jcal/registry.rb +83 -0
- data/lib/jcard_jcal/value_types.rb +358 -0
- data/lib/jcard_jcal/version.rb +5 -0
- data/lib/jcard_jcal.rb +21 -0
- data/sig/jcard_jcal.rbs +123 -0
- metadata +86 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 299d8848386a5c2d4135c70971b2d3d86c936e6b30d4a10dd566decf2e6ce209
|
|
4
|
+
data.tar.gz: f1a458a16b7372f372c923dc1e38c3948582f017f818b622e7e33efd0ec39d96
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c11c6411c6e8ade8821fdabce43e460599d4cd2dfdaab6d2de912361d91e49e6e535b63dfed4bdae62783d5fd7ae6d012f30e6deb3aea38f64a20f007ac99d0b
|
|
7
|
+
data.tar.gz: 8e56ea0a3543d5885da109c88fdc0c303e21287a2f5adce38774ada875b95ff06d6f7aca5855e67b5a129661e1a7af91662896e4b306481d73c19841de6425f0
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# jcard_jcal
|
|
2
|
+
|
|
3
|
+
Convert vCard 4.0 and iCalendar text to and from their JSON representations,
|
|
4
|
+
[jCard (RFC 7095)](https://www.rfc-editor.org/rfc/rfc7095) and
|
|
5
|
+
[jCal (RFC 7265)](https://www.rfc-editor.org/rfc/rfc7265).
|
|
6
|
+
Requires Ruby 3.3 or later and json 2.19.4 or later (including json 3.x).
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
gem install jcard_jcal
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
require "jcard_jcal" # or require "jcard" / require "jcal"
|
|
14
|
+
|
|
15
|
+
card = JCard.from_vcard("BEGIN:VCARD\nVERSION:4.0\nFN:Alice\nEND:VCARD\n").first
|
|
16
|
+
card.properties("fn").first.values # => ["Alice"]
|
|
17
|
+
card.to_json # => '["vcard",[["version",{},"text","4.0"],["fn",{},"text","Alice"]]]'
|
|
18
|
+
card.to_vcard # CRLF line endings, UTF-8 safe folding
|
|
19
|
+
|
|
20
|
+
calendar = JCal.from_ical(File.read("calendar.ics"))
|
|
21
|
+
calendar.components("vevent").each do |event|
|
|
22
|
+
puts event.property("dtstart")&.values
|
|
23
|
+
end
|
|
24
|
+
JCal.parse(calendar.to_json).to_ical
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`JCard.from_vcard` returns an array because a text document can contain several
|
|
28
|
+
cards. `JCard.parse` and `JCal.parse` each read one JSON component. Properties retain
|
|
29
|
+
their order. `to_a`, `params`, and `values` return copies; unknown properties,
|
|
30
|
+
parameters, and explicitly declared extension value types are preserved.
|
|
31
|
+
|
|
32
|
+
## Supported formats
|
|
33
|
+
|
|
34
|
+
| Supported | Outside this library's scope |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| vCard 4.0 ↔ jCard | vCard 2.1/3.0, xCard |
|
|
37
|
+
| Nested iCalendar ↔ jCal | Calendar semantic rules such as mandatory DTSTART |
|
|
38
|
+
| RFC 6350/5545 value types, partial dates, structured values | Time zone conversion and recurrence expansion |
|
|
39
|
+
| RFC 7529 recurrence syntax, including RSCALE/SKIP and leap months | Non-Gregorian calendar calculations |
|
|
40
|
+
| RFC 6868 parameter escaping and 75-octet line folding | CardDAV/CalDAV transport, URL fetching |
|
|
41
|
+
| RFC 7986 calendar properties and RFC 9554 contact extensions | Other extension-specific semantics without a custom registry |
|
|
42
|
+
|
|
43
|
+
Text serialization normalizes capitalization, parameter order, folding, and
|
|
44
|
+
VERSION placement. It selects the scalar form for a single structured component
|
|
45
|
+
and a single parameter value. Binary iCalendar output includes `ENCODING=BASE64`.
|
|
46
|
+
These equivalent forms need not retain identical JSON array shapes or parameters
|
|
47
|
+
after a round trip. Recurrence rule object keys are lowercase; value case is
|
|
48
|
+
preserved. Numeric spelling is normalized, and jCard integer output follows
|
|
49
|
+
RFC 7095's truncation rule for fractional JSON numbers.
|
|
50
|
+
|
|
51
|
+
## Validation and limits
|
|
52
|
+
|
|
53
|
+
Parsing is strict by default. Syntax failures raise `JCardJCal::ParseError`;
|
|
54
|
+
property violations raise `ValidationError` or its subclass `ValueError`.
|
|
55
|
+
Errors expose `line_number` for text input or `pointer` for JSON input.
|
|
56
|
+
`JcardJcal` remains an alias for compatibility with the generated gem namespace.
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
card = JCard.from_vcard(text, strict: false).first
|
|
60
|
+
card.warnings.each { |warning| warn warning.message }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Lenient text parsing retains an invalid property value as type `unknown` and
|
|
64
|
+
records a warning. Lenient JSON parsing retains invalid values with warnings.
|
|
65
|
+
Malformed structures, component boundaries, duplicate JSON keys, and resource
|
|
66
|
+
limit violations still raise errors. Generation always validates the model.
|
|
67
|
+
Binary values remain encoded; the library does not decode attachments.
|
|
68
|
+
|
|
69
|
+
Both entry points accept the following limits:
|
|
70
|
+
|
|
71
|
+
| Option | Default |
|
|
72
|
+
| --- | ---: |
|
|
73
|
+
| `max_input_size` | 16 MiB |
|
|
74
|
+
| `max_line_length` | 1 MiB per unfolded content line |
|
|
75
|
+
| `max_properties` | 10,000 across the input |
|
|
76
|
+
| `max_depth` | 32 component levels |
|
|
77
|
+
| `max_binary_size` | 1 MiB of decoded attachment data |
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
JCal.from_ical(text, max_depth: 8, max_properties: 1000)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Custom properties
|
|
84
|
+
|
|
85
|
+
Default registries are frozen. Duplicate one to add application-specific defaults
|
|
86
|
+
without affecting other callers:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
registry = JCard::Registry.default.dup
|
|
90
|
+
registry.register("x-website", type: "uri")
|
|
91
|
+
card = JCard.from_vcard(text, registry: registry).first
|
|
92
|
+
card.to_vcard # reuses the same registry
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Register a multiple-valued property with `multiple: true`, or a semicolon-separated
|
|
96
|
+
structured property with `structured: true`. An unregistered property without a
|
|
97
|
+
VALUE parameter uses `unknown`, keeping the raw content-line value.
|
|
98
|
+
Register structured or multiple-valued extensions before converting them through
|
|
99
|
+
text; their separators cannot otherwise be distinguished from literal values.
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
bundle install
|
|
105
|
+
bundle exec rake test
|
|
106
|
+
COVERAGE=1 bundle exec rake test
|
|
107
|
+
bundle exec rubocop
|
|
108
|
+
bundle exec rbs -I sig validate
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The first test run needs `curl` and HTTPS access to RFC Editor to prepare fixtures
|
|
112
|
+
under ignored `tmp/rfc/`; later runs reuse verified source downloads. Before
|
|
113
|
+
running RSpec directly, run `bundle exec rake fixtures`. See
|
|
114
|
+
[conformance maintenance](docs/conformance.md) for fixture updates, property
|
|
115
|
+
tables, and interoperability checks.
|
|
116
|
+
|
|
117
|
+
## Releases
|
|
118
|
+
|
|
119
|
+
Update `lib/jcard_jcal/version.rb` and add a user-facing entry to `CHANGELOG.md`,
|
|
120
|
+
then push the matching `vVERSION` tag after main's checks pass. The tag workflow
|
|
121
|
+
tests, builds, publishes to RubyGems, and creates a GitHub release from that entry.
|
|
122
|
+
Documentation-only and internal maintenance changes do not need a release.
|
|
123
|
+
|
|
124
|
+
Before the first tag, configure a RubyGems trusted publisher for gem `jcard_jcal`,
|
|
125
|
+
owner `ydah`, repository `jcard_jcal`, workflow `release.yml`, and environment
|
|
126
|
+
`release`.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT; see [LICENSE.txt](LICENSE.txt). Downloaded RFC documents and generated test
|
|
131
|
+
fixtures are excluded from Git and the gem and retain their own
|
|
132
|
+
[license notices](docs/conformance.md#rfc-examples).
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
Report security issues privately to t.yudai92@gmail.com. Include the affected
|
|
4
|
+
version and a small reproduction. Do not include private contact or calendar
|
|
5
|
+
data. Security fixes target the latest released version.
|
|
6
|
+
|
|
7
|
+
The library does not fetch URLs, evaluate recurrence rules, or decode binary
|
|
8
|
+
attachments automatically. Input size, content-line length, component depth,
|
|
9
|
+
property count, and binary attachment size are bounded. Keep those limits in
|
|
10
|
+
place when processing untrusted input.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
calscale: {type: text, types: [text], multiple: false, structured: false, source: 'RFC5545 3.7.1'}
|
|
2
|
+
method: {type: text, types: [text], multiple: false, structured: false, source: 'RFC5545 3.7.2'}
|
|
3
|
+
prodid: {type: text, types: [text], multiple: false, structured: false, source: 'RFC5545 3.7.3'}
|
|
4
|
+
version: {type: text, types: [text], multiple: false, structured: false, source: 'RFC5545 3.7.4'}
|
|
5
|
+
attach: {type: uri, types: [uri, binary], multiple: false, structured: false, source: 'RFC5545 3.8.1.1'}
|
|
6
|
+
categories: {type: text, types: [text], multiple: true, structured: false, source: 'RFC5545 3.8.1.2'}
|
|
7
|
+
class: {type: text, types: [text], multiple: false, structured: false, source: 'RFC5545 3.8.1.3'}
|
|
8
|
+
comment: {type: text, types: [text], multiple: false, structured: false, source: 'RFC5545 3.8.1.4'}
|
|
9
|
+
description: {type: text, types: [text], multiple: false, structured: false, source: 'RFC5545 3.8.1.5'}
|
|
10
|
+
geo: {type: float, types: [float], multiple: false, structured: true, source: 'RFC5545 3.8.1.6', components: [2]}
|
|
11
|
+
location: {type: text, types: [text], multiple: false, structured: false, source: 'RFC5545 3.8.1.7'}
|
|
12
|
+
percent-complete: {type: integer, types: [integer], multiple: false, structured: false, source: 'RFC5545 3.8.1.8'}
|
|
13
|
+
priority: {type: integer, types: [integer], multiple: false, structured: false, source: 'RFC5545 3.8.1.9'}
|
|
14
|
+
resources: {type: text, types: [text], multiple: true, structured: false, source: 'RFC5545 3.8.1.10'}
|
|
15
|
+
status: {type: text, types: [text], multiple: false, structured: false, source: 'RFC5545 3.8.1.11'}
|
|
16
|
+
summary: {type: text, types: [text], multiple: false, structured: false, source: 'RFC5545 3.8.1.12'}
|
|
17
|
+
completed: {type: date-time, types: [date-time], multiple: false, structured: false, source: 'RFC5545 3.8.2.1'}
|
|
18
|
+
dtend: {type: date-time, types: [date-time, date], multiple: false, structured: false, source: 'RFC5545 3.8.2.2'}
|
|
19
|
+
due: {type: date-time, types: [date-time, date], multiple: false, structured: false, source: 'RFC5545 3.8.2.3'}
|
|
20
|
+
dtstart: {type: date-time, types: [date-time, date], multiple: false, structured: false, source: 'RFC5545 3.8.2.4'}
|
|
21
|
+
duration: {type: duration, types: [duration], multiple: false, structured: false, source: 'RFC5545 3.8.2.5'}
|
|
22
|
+
freebusy: {type: period, types: [period], multiple: true, structured: false, source: 'RFC5545 3.8.2.6'}
|
|
23
|
+
transp: {type: text, types: [text], multiple: false, structured: false, source: 'RFC5545 3.8.2.7'}
|
|
24
|
+
tzid: {type: text, types: [text], multiple: false, structured: false, source: 'RFC5545 3.8.3.1'}
|
|
25
|
+
tzname: {type: text, types: [text], multiple: false, structured: false, source: 'RFC5545 3.8.3.2'}
|
|
26
|
+
tzoffsetfrom: {type: utc-offset, types: [utc-offset], multiple: false, structured: false, source: 'RFC5545 3.8.3.3'}
|
|
27
|
+
tzoffsetto: {type: utc-offset, types: [utc-offset], multiple: false, structured: false, source: 'RFC5545 3.8.3.4'}
|
|
28
|
+
tzurl: {type: uri, types: [uri], multiple: false, structured: false, source: 'RFC5545 3.8.3.5'}
|
|
29
|
+
attendee: {type: cal-address, types: [cal-address], multiple: false, structured: false, source: 'RFC5545 3.8.4.1'}
|
|
30
|
+
contact: {type: text, types: [text], multiple: false, structured: false, source: 'RFC5545 3.8.4.2'}
|
|
31
|
+
organizer: {type: cal-address, types: [cal-address], multiple: false, structured: false, source: 'RFC5545 3.8.4.3'}
|
|
32
|
+
recurrence-id: {type: date-time, types: [date-time, date], multiple: false, structured: false, source: 'RFC5545 3.8.4.4'}
|
|
33
|
+
related-to: {type: text, types: [text], multiple: false, structured: false, source: 'RFC5545 3.8.4.5'}
|
|
34
|
+
url: {type: uri, types: [uri], multiple: false, structured: false, source: 'RFC5545 3.8.4.6'}
|
|
35
|
+
uid: {type: text, types: [text], multiple: false, structured: false, source: 'RFC5545 3.8.4.7'}
|
|
36
|
+
exdate: {type: date-time, types: [date-time, date], multiple: true, structured: false, source: 'RFC5545 3.8.5.1'}
|
|
37
|
+
rdate: {type: date-time, types: [date-time, date, period], multiple: true, structured: false, source: 'RFC5545 3.8.5.2'}
|
|
38
|
+
rrule: {type: recur, types: [recur], multiple: false, structured: false, source: 'RFC5545 3.8.5.3'}
|
|
39
|
+
action: {type: text, types: [text], multiple: false, structured: false, source: 'RFC5545 3.8.6.1'}
|
|
40
|
+
repeat: {type: integer, types: [integer], multiple: false, structured: false, source: 'RFC5545 3.8.6.2'}
|
|
41
|
+
trigger: {type: duration, types: [duration, date-time], multiple: false, structured: false, source: 'RFC5545 3.8.6.3'}
|
|
42
|
+
created: {type: date-time, types: [date-time], multiple: false, structured: false, source: 'RFC5545 3.8.7.1'}
|
|
43
|
+
dtstamp: {type: date-time, types: [date-time], multiple: false, structured: false, source: 'RFC5545 3.8.7.2'}
|
|
44
|
+
last-modified: {type: date-time, types: [date-time], multiple: false, structured: false, source: 'RFC5545 3.8.7.3'}
|
|
45
|
+
sequence: {type: integer, types: [integer], multiple: false, structured: false, source: 'RFC5545 3.8.7.4'}
|
|
46
|
+
request-status: {type: text, types: [text], multiple: false, structured: true, source: 'RFC5545 3.8.8.3', components: [2, 3]}
|
|
47
|
+
name: {type: text, types: [text], multiple: false, structured: false, source: 'RFC7986 5.1'}
|
|
48
|
+
refresh-interval: {type: unknown, types: [duration], multiple: false, structured: false, source: 'RFC7986 5.7', value_required: true}
|
|
49
|
+
source: {type: unknown, types: [uri], multiple: false, structured: false, source: 'RFC7986 5.8', value_required: true}
|
|
50
|
+
color: {type: text, types: [text], multiple: false, structured: false, source: 'RFC7986 5.9'}
|
|
51
|
+
image: {type: unknown, types: [uri, binary], multiple: false, structured: false, source: 'RFC7986 5.10', value_required: true}
|
|
52
|
+
conference: {type: unknown, types: [uri], multiple: false, structured: false, source: 'RFC7986 5.11', value_required: true}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Property defaults and value shapes; cardinality is separate from multiple values on one line.
|
|
2
|
+
source: {type: uri, types: [uri], multiple: false, structured: false, source: 'RFC6350 6.1.3'}
|
|
3
|
+
kind: {type: text, types: [text], multiple: false, structured: false, source: 'RFC6350 6.1.4'}
|
|
4
|
+
xml: {type: text, types: [text], multiple: false, structured: false, source: 'RFC6350 6.1.5'}
|
|
5
|
+
fn: {type: text, types: [text], multiple: false, structured: false, source: 'RFC6350 6.2.1'}
|
|
6
|
+
n: {type: text, types: [text], multiple: false, structured: true, source: 'RFC6350 6.2.2', component_multiple: true, components: [5, 7], extension_source: 'RFC9554 2.2'}
|
|
7
|
+
nickname: {type: text, types: [text], multiple: true, structured: false, source: 'RFC6350 6.2.3'}
|
|
8
|
+
photo: {type: uri, types: [uri], multiple: false, structured: false, source: 'RFC6350 6.2.4'}
|
|
9
|
+
bday: {type: date-and-or-time, types: [date-and-or-time, date, time, date-time, text], multiple: false, structured: false, source: 'RFC6350 6.2.5', conversion_source: 'RFC7095 3.5.3'}
|
|
10
|
+
anniversary: {type: date-and-or-time, types: [date-and-or-time, date, time, date-time, text], multiple: false, structured: false, source: 'RFC6350 6.2.6', conversion_source: 'RFC7095 3.5.5'}
|
|
11
|
+
gender: {type: text, types: [text], multiple: false, structured: true, source: 'RFC6350 6.2.7', components: [1, 2]}
|
|
12
|
+
adr: {type: text, types: [text], multiple: false, structured: true, source: 'RFC6350 6.3.1', component_multiple: true, components: [7, 18], extension_source: 'RFC9554 2.1'}
|
|
13
|
+
tel: {type: text, types: [text, uri], multiple: false, structured: false, source: 'RFC6350 6.4.1'}
|
|
14
|
+
email: {type: text, types: [text], multiple: false, structured: false, source: 'RFC6350 6.4.2'}
|
|
15
|
+
impp: {type: uri, types: [uri], multiple: false, structured: false, source: 'RFC6350 6.4.3'}
|
|
16
|
+
lang: {type: language-tag, types: [language-tag], multiple: false, structured: false, source: 'RFC6350 6.4.4'}
|
|
17
|
+
tz: {type: text, types: [text, uri, utc-offset], multiple: false, structured: false, source: 'RFC6350 6.5.1'}
|
|
18
|
+
geo: {type: uri, types: [uri], multiple: false, structured: false, source: 'RFC6350 6.5.2'}
|
|
19
|
+
title: {type: text, types: [text], multiple: false, structured: false, source: 'RFC6350 6.6.1'}
|
|
20
|
+
role: {type: text, types: [text], multiple: false, structured: false, source: 'RFC6350 6.6.2'}
|
|
21
|
+
logo: {type: uri, types: [uri], multiple: false, structured: false, source: 'RFC6350 6.6.3'}
|
|
22
|
+
org: {type: text, types: [text], multiple: false, structured: true, source: 'RFC6350 6.6.4'}
|
|
23
|
+
member: {type: uri, types: [uri], multiple: false, structured: false, source: 'RFC6350 6.6.5'}
|
|
24
|
+
related: {type: uri, types: [uri, text], multiple: false, structured: false, source: 'RFC6350 6.6.6'}
|
|
25
|
+
categories: {type: text, types: [text], multiple: true, structured: false, source: 'RFC6350 6.7.1'}
|
|
26
|
+
note: {type: text, types: [text], multiple: false, structured: false, source: 'RFC6350 6.7.2'}
|
|
27
|
+
prodid: {type: text, types: [text], multiple: false, structured: false, source: 'RFC6350 6.7.3'}
|
|
28
|
+
rev: {type: timestamp, types: [timestamp], multiple: false, structured: false, source: 'RFC6350 6.7.4'}
|
|
29
|
+
sound: {type: uri, types: [uri], multiple: false, structured: false, source: 'RFC6350 6.7.5'}
|
|
30
|
+
uid: {type: uri, types: [uri, text], multiple: false, structured: false, source: 'RFC6350 6.7.6'}
|
|
31
|
+
clientpidmap: {type: text, types: [text], multiple: false, structured: true, source: 'RFC6350 6.7.7', components: [2]}
|
|
32
|
+
url: {type: uri, types: [uri], multiple: false, structured: false, source: 'RFC6350 6.7.8'}
|
|
33
|
+
version: {type: text, types: [text], multiple: false, structured: false, source: 'RFC6350 6.7.9'}
|
|
34
|
+
key: {type: uri, types: [uri, text], multiple: false, structured: false, source: 'RFC6350 6.8.1'}
|
|
35
|
+
fburl: {type: uri, types: [uri], multiple: false, structured: false, source: 'RFC6350 6.9.1'}
|
|
36
|
+
caladruri: {type: uri, types: [uri], multiple: false, structured: false, source: 'RFC6350 6.9.2'}
|
|
37
|
+
caluri: {type: uri, types: [uri], multiple: false, structured: false, source: 'RFC6350 6.9.3'}
|
|
38
|
+
created: {type: timestamp, types: [timestamp], multiple: false, structured: false, source: 'RFC9554 3.1'}
|
|
39
|
+
gramgender: {type: text, types: [text], multiple: false, structured: false, source: 'RFC9554 3.2'}
|
|
40
|
+
language: {type: language-tag, types: [language-tag], multiple: false, structured: false, source: 'RFC9554 3.3'}
|
|
41
|
+
pronouns: {type: text, types: [text], multiple: false, structured: false, source: 'RFC9554 3.4'}
|
|
42
|
+
socialprofile: {type: uri, types: [uri, text], multiple: false, structured: false, source: 'RFC9554 3.5'}
|
data/lib/jcal.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "jcard_jcal"
|
|
4
|
+
|
|
5
|
+
module JCal
|
|
6
|
+
# An iCalendar property in RFC 7265 array form.
|
|
7
|
+
class Property < JCardJCal::Property
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def default_registry = Registry.default
|
|
11
|
+
def format = :ical
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# An iCalendar component, including vendor components.
|
|
15
|
+
class Component < JCardJCal::Component
|
|
16
|
+
def to_ical = to_text(:ical)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Calendar < Component; end
|
|
20
|
+
|
|
21
|
+
# Parse one jCal JSON document.
|
|
22
|
+
def self.parse(source, **)
|
|
23
|
+
JCardJCal::Converter.new(format: :ical, **).parse_json(source)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Convert a VCALENDAR text document and its nested components.
|
|
27
|
+
def self.from_ical(source, **)
|
|
28
|
+
JCardJCal::Converter.new(format: :ical, **).parse_text(source)
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/jcard.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "jcard_jcal"
|
|
4
|
+
|
|
5
|
+
module JCard
|
|
6
|
+
# A vCard property in RFC 7095 array form.
|
|
7
|
+
class Property < JCardJCal::Property
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def default_registry = Registry.default
|
|
11
|
+
def format = :vcard
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# A vCard 4.0 represented as jCard.
|
|
15
|
+
class Card < JCardJCal::Component
|
|
16
|
+
def to_vcard = to_text(:vcard)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Parse one jCard JSON document. Lenient parsing attaches validation warnings.
|
|
20
|
+
def self.parse(source, **)
|
|
21
|
+
JCardJCal::Converter.new(format: :vcard, **).parse_json(source)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Convert one or more vCards to ordered card objects.
|
|
25
|
+
def self.from_vcard(source, **)
|
|
26
|
+
JCardJCal::Converter.new(format: :vcard, **).parse_text(source)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JCardJCal
|
|
4
|
+
# Ordered properties and nested components with lossless JSON serialization.
|
|
5
|
+
class Component
|
|
6
|
+
attr_reader :name, :warnings
|
|
7
|
+
|
|
8
|
+
def initialize(name, properties, components = [], warnings: [], registry: nil, max_binary_size: 1_048_576)
|
|
9
|
+
@name = name
|
|
10
|
+
@properties = properties
|
|
11
|
+
@components = components
|
|
12
|
+
@warnings = warnings
|
|
13
|
+
@registry = registry
|
|
14
|
+
@max_binary_size = max_binary_size
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def properties(name = nil)
|
|
18
|
+
return @properties.dup unless name
|
|
19
|
+
|
|
20
|
+
@properties.select { |property| property.name == name.downcase }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def property(name) = properties(name).first
|
|
24
|
+
|
|
25
|
+
def components(name = nil)
|
|
26
|
+
return @components.dup unless name
|
|
27
|
+
|
|
28
|
+
@components.select { |component| component.name == name.downcase }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_a
|
|
32
|
+
result = [@name, @properties.map(&:to_a)]
|
|
33
|
+
result << @components.map(&:to_a) unless @name == "vcard"
|
|
34
|
+
result
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_json(*)
|
|
38
|
+
validate!
|
|
39
|
+
JSON.generate(to_a, *)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def validate!
|
|
43
|
+
valid_name = @name.is_a?(String) && /\A[a-z0-9-]+\z/.match?(@name)
|
|
44
|
+
valid_name &&= @name == "vcard" if is_a?(JCard::Card)
|
|
45
|
+
valid_name &&= @name == "vcalendar" if is_a?(JCal::Calendar)
|
|
46
|
+
raise ValidationError.new("invalid component name", pointer: "/0") unless valid_name
|
|
47
|
+
unless @properties.is_a?(Array) && @properties.all?(Property) && @components.is_a?(Array) && @components.all?(Component)
|
|
48
|
+
raise ValidationError, "invalid component children"
|
|
49
|
+
end
|
|
50
|
+
raise ValidationError, "vCard cannot contain components" if is_a?(JCard::Card) && !@components.empty?
|
|
51
|
+
|
|
52
|
+
@properties.each(&:validate!)
|
|
53
|
+
@components.each(&:validate!)
|
|
54
|
+
return self unless @name == "vcard"
|
|
55
|
+
|
|
56
|
+
versions = properties("version")
|
|
57
|
+
unless versions.size == 1 && versions.first.type == "text" && versions.first.values == ["4.0"]
|
|
58
|
+
raise ValidationError.new("vCard requires exactly one VERSION:4.0 property", pointer: "/1")
|
|
59
|
+
end
|
|
60
|
+
raise ValidationError.new("VERSION must be the first vCard property", pointer: "/1/0") unless @properties.first == versions.first
|
|
61
|
+
|
|
62
|
+
self
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def to_text(format)
|
|
68
|
+
validate!
|
|
69
|
+
Converter.write(self, format: format, registry: @registry, max_binary_size: @max_binary_size)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JCardJCal
|
|
4
|
+
module ContentLine
|
|
5
|
+
# RFC 6868 escaping and content-line parameter quoting.
|
|
6
|
+
module ParamCodec
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def decode(value)
|
|
10
|
+
value = validate(value)
|
|
11
|
+
if value.start_with?('"') && value.end_with?('"') && value.length >= 2
|
|
12
|
+
value = value[1...-1]
|
|
13
|
+
invalid = value.include?('"')
|
|
14
|
+
else
|
|
15
|
+
invalid = value.match?(/[":;,]/)
|
|
16
|
+
end
|
|
17
|
+
raise ValueError, "Invalid parameter quoting" if invalid || value.match?(/[\r\n]/)
|
|
18
|
+
|
|
19
|
+
value.gsub(/\^[n'^]/) do |escape|
|
|
20
|
+
{ "^n" => "\n", "^'" => '"', "^^" => "^" }.fetch(escape)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def encode(value)
|
|
25
|
+
value = validate(value)
|
|
26
|
+
encoded = value.gsub(/\r\n|[\r\n"^]/) do |character|
|
|
27
|
+
case character
|
|
28
|
+
when '"' then "^'"
|
|
29
|
+
when "^" then "^^"
|
|
30
|
+
else "^n"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
encoded.empty? || encoded.match?(/[:;,]/) ? "\"#{encoded}\"" : encoded
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def validate(value)
|
|
37
|
+
value = value.dup.force_encoding(Encoding::UTF_8) if value.is_a?(String)
|
|
38
|
+
unless value.is_a?(String) && value.valid_encoding? &&
|
|
39
|
+
!value.match?(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/)
|
|
40
|
+
raise ValueError, "Parameter value must be a string without control characters"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
value
|
|
44
|
+
end
|
|
45
|
+
private_class_method :validate
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
require_relative "param_codec"
|
|
5
|
+
|
|
6
|
+
module JCardJCal
|
|
7
|
+
module ContentLine
|
|
8
|
+
Line = Struct.new(:name, :group, :params, :value, :line_number, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# Enumerates unfolded lines, reporting the first physical line of each one.
|
|
11
|
+
class Reader
|
|
12
|
+
include Enumerable
|
|
13
|
+
|
|
14
|
+
def initialize(text, max_line_length: 1_048_576)
|
|
15
|
+
raise ArgumentError, "text must be a string" unless text.is_a?(String)
|
|
16
|
+
raise ArgumentError, "max_line_length must be a positive integer" unless max_line_length.is_a?(Integer) && max_line_length.positive?
|
|
17
|
+
|
|
18
|
+
@text = text
|
|
19
|
+
@max_line_length = max_line_length
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def each
|
|
23
|
+
return enum_for(__method__) unless block_given?
|
|
24
|
+
|
|
25
|
+
unfolded = nil
|
|
26
|
+
first_line = nil
|
|
27
|
+
# Unfold bytes before checking UTF-8; peers may split a multibyte character.
|
|
28
|
+
@text.b.each_line.with_index(1) do |physical, number|
|
|
29
|
+
physical.delete_suffix!("\r") if physical.delete_suffix!("\n")
|
|
30
|
+
check_length(physical.bytesize, number)
|
|
31
|
+
if physical.start_with?(" ", "\t")
|
|
32
|
+
fail_line("Continuation without a preceding line", number) unless unfolded
|
|
33
|
+
check_length(unfolded.bytesize + physical.bytesize - 1, first_line)
|
|
34
|
+
unfolded << physical.byteslice(1..)
|
|
35
|
+
else
|
|
36
|
+
yield parse(unfolded, first_line) if unfolded
|
|
37
|
+
unfolded = physical
|
|
38
|
+
first_line = number
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
yield parse(unfolded, first_line) if unfolded
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def parse(text, number)
|
|
48
|
+
text.force_encoding(Encoding::UTF_8)
|
|
49
|
+
fail_line("Invalid UTF-8 or control character", number) unless text.valid_encoding? && !text.match?(/[\x00-\x08\x0a-\x1f\x7f]/)
|
|
50
|
+
|
|
51
|
+
scanner = StringScanner.new(text)
|
|
52
|
+
name = scanner.scan(/[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)?/)
|
|
53
|
+
fail_line("Invalid property name", number) unless name
|
|
54
|
+
group, name = name.split(".", 2) if name.include?(".")
|
|
55
|
+
params = {}
|
|
56
|
+
while scanner.scan(";")
|
|
57
|
+
key = scanner.scan(/[A-Za-z0-9-]+/)&.downcase
|
|
58
|
+
fail_line("Invalid parameter name or missing equals sign", number) unless key && scanner.scan("=")
|
|
59
|
+
fail_line("Duplicate parameter #{key}", number) if params.key?(key)
|
|
60
|
+
|
|
61
|
+
values = []
|
|
62
|
+
loop do
|
|
63
|
+
raw = scanner.peek(1) == '"' ? scanner.scan(/"[^"]*"/) : scanner.scan(/[^":;,]*/)
|
|
64
|
+
fail_line("Invalid parameter quoting", number) unless raw
|
|
65
|
+
values << ParamCodec.decode(raw)
|
|
66
|
+
break unless scanner.scan(",")
|
|
67
|
+
end
|
|
68
|
+
params[key] = values.length == 1 ? values.first : values
|
|
69
|
+
end
|
|
70
|
+
fail_line("Missing colon or malformed parameter", number) unless scanner.scan(":")
|
|
71
|
+
|
|
72
|
+
Line.new(name: name.downcase, group: group, params: params, value: scanner.rest, line_number: number)
|
|
73
|
+
rescue ValueError => e
|
|
74
|
+
fail_line(e.message, number)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def check_length(length, number)
|
|
78
|
+
fail_line("Content line exceeds maximum length of #{@max_line_length} bytes", number) if length > @max_line_length
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def fail_line(message, number)
|
|
82
|
+
raise ParseError.new(message, line_number: number)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JCardJCal
|
|
4
|
+
module ContentLine
|
|
5
|
+
# Escaping for TEXT property values (RFC 5545 section 3.3.11).
|
|
6
|
+
module TextCodec
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def decode(value)
|
|
10
|
+
value = validate(value)
|
|
11
|
+
value.gsub(/\\([\\,;nN])|\\(.)|\\\z/m) do
|
|
12
|
+
escaped = Regexp.last_match(1)
|
|
13
|
+
raise ValueError, "Invalid TEXT escape" unless escaped
|
|
14
|
+
|
|
15
|
+
%w[n N].include?(escaped) ? "\n" : escaped
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def encode(value)
|
|
20
|
+
value = validate(value)
|
|
21
|
+
value.gsub(/\r\n|[\r\n\\,;]/) do |character|
|
|
22
|
+
["\r\n", "\r", "\n"].include?(character) ? '\n' : "\\#{character}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Split before decoding so escaped list and field separators remain data.
|
|
27
|
+
def split(value, delimiter)
|
|
28
|
+
value = validate(value)
|
|
29
|
+
raise ArgumentError, "delimiter must be one character" unless delimiter.is_a?(String) && delimiter.length == 1
|
|
30
|
+
|
|
31
|
+
parts = [String.new]
|
|
32
|
+
escaped = false
|
|
33
|
+
value.each_char do |character|
|
|
34
|
+
if character == delimiter && !escaped
|
|
35
|
+
parts << String.new
|
|
36
|
+
else
|
|
37
|
+
parts.last << character
|
|
38
|
+
end
|
|
39
|
+
escaped = character == "\\" && !escaped
|
|
40
|
+
end
|
|
41
|
+
parts
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validate(value)
|
|
45
|
+
value = value.dup.force_encoding(Encoding::UTF_8) if value.is_a?(String)
|
|
46
|
+
unless value.is_a?(String) && value.valid_encoding? &&
|
|
47
|
+
!value.match?(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/)
|
|
48
|
+
raise ValueError, "TEXT value must be a string without control characters"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
value
|
|
52
|
+
end
|
|
53
|
+
private_class_method :validate
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "param_codec"
|
|
4
|
+
|
|
5
|
+
module JCardJCal
|
|
6
|
+
module ContentLine
|
|
7
|
+
# Serializes an already encoded property value, folding at UTF-8 boundaries.
|
|
8
|
+
module Writer
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def line(name:, params:, value:, group: nil)
|
|
12
|
+
validate_name(name)
|
|
13
|
+
validate_name(group) unless group.nil?
|
|
14
|
+
value = value.dup.force_encoding(Encoding::UTF_8) if value.is_a?(String)
|
|
15
|
+
unless value.is_a?(String) && value.valid_encoding? &&
|
|
16
|
+
!value.match?(/[\x00-\x08\x0a-\x1f\x7f]/)
|
|
17
|
+
raise ValueError, "Property value must be a string without line breaks or control characters"
|
|
18
|
+
end
|
|
19
|
+
raise ValueError, "Parameters must be a hash" unless params.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
content = group ? "#{group}.#{name.upcase}" : name.upcase
|
|
22
|
+
seen = {}
|
|
23
|
+
params.each do |key, parameter|
|
|
24
|
+
validate_name(key)
|
|
25
|
+
raise ValueError, "Duplicate parameter #{key}" if seen[key.downcase]
|
|
26
|
+
|
|
27
|
+
seen[key.downcase] = true
|
|
28
|
+
values = parameter.is_a?(Array) ? parameter : [parameter]
|
|
29
|
+
raise ValueError, "Parameter lists cannot be empty" if values.empty?
|
|
30
|
+
|
|
31
|
+
content << ";#{key.upcase}=#{values.map { |item| ParamCodec.encode(item) }.join(",")}"
|
|
32
|
+
end
|
|
33
|
+
content << ":#{value}"
|
|
34
|
+
content.force_encoding(Encoding::UTF_8)
|
|
35
|
+
result = String.new
|
|
36
|
+
width = 0
|
|
37
|
+
content.each_char do |character|
|
|
38
|
+
if width + character.bytesize > 75
|
|
39
|
+
result << "\r\n "
|
|
40
|
+
width = 1
|
|
41
|
+
end
|
|
42
|
+
result << character
|
|
43
|
+
width += character.bytesize
|
|
44
|
+
end
|
|
45
|
+
result << "\r\n"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_name(name)
|
|
49
|
+
unless name.is_a?(String) && name.valid_encoding? && name.encoding.ascii_compatible? &&
|
|
50
|
+
name.match?(/\A[A-Za-z0-9-]+\z/)
|
|
51
|
+
raise ValueError, "Invalid property, group, or parameter name"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
private_class_method :validate_name
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|