rem2ics 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.
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ice_cube"
4
+
5
+ module Rem2ics
6
+ # How a reminder repeats, expressed the way iCalendar expresses it -- when
7
+ # iCalendar can express it at all.
8
+ #
9
+ # A reminder's trigger and an RRULE overlap, but neither contains the other.
10
+ # `REM 15` and `FREQ=MONTHLY;BYMONTHDAY=15` are the same thing. `REM Mon 13
11
+ # SKIP OMIT Sat Sun` is not any RRULE: it means "the Monday of the week the
12
+ # 13th falls in, unless that lands on an omitted day, in which case do not
13
+ # trigger at all", and BYDAY has no way to say the second half.
14
+ #
15
+ # So the rule is a guess, and the guess is checked. `Remind::Reminder` can
16
+ # produce the dates the reminder actually fires on -- they come from
17
+ # ComputeTrigger, which is what `remind` itself runs -- so the candidate
18
+ # RRULE is expanded with ice_cube and compared against them. If they agree
19
+ # for the whole horizon, the event carries the rule, and every calendar that
20
+ # reads it recurs forever, correctly. If they disagree anywhere, the event
21
+ # carries Remind's dates instead: fewer of them, but none of them wrong.
22
+ #
23
+ # That check is the reason this converter is built on bindings rather than
24
+ # on a parser of its own. Without Remind there is nothing to check against,
25
+ # and every previous converter has had to guess and hope.
26
+ class Recurrence
27
+ # How many occurrences the guess has to get right. Ten years of a monthly
28
+ # reminder, or two of a weekly one -- far enough out for the leap years,
29
+ # the month lengths and the moving holidays that break a rule to have
30
+ # shown up.
31
+ DEFAULT_HORIZON = 120
32
+
33
+ # A rule that agreed with Remind for the whole horizon.
34
+ Rule = Struct.new(:text, :dates) do
35
+ def rule?
36
+ true
37
+ end
38
+ end
39
+
40
+ # Remind's dates, for a reminder no rule describes.
41
+ Dates = Struct.new(:dates) do
42
+ def rule?
43
+ false
44
+ end
45
+
46
+ def text
47
+ nil
48
+ end
49
+ end
50
+
51
+ attr_reader :reminder, :horizon
52
+
53
+ def initialize(reminder, horizon: DEFAULT_HORIZON)
54
+ @reminder = reminder
55
+ @horizon = horizon
56
+ end
57
+
58
+ def call
59
+ if single?
60
+ Dates.new(remind_dates)
61
+ elsif agrees?
62
+ Rule.new(candidate, remind_dates)
63
+ else
64
+ Dates.new(remind_dates)
65
+ end
66
+ end
67
+
68
+ # A reminder Remind only ever triggers once needs no rule to describe it,
69
+ # whatever its trigger looks like. Asking Remind is more reliable than
70
+ # reading the trigger: `REM 25 Dec 2027` and `REM 1 Jan 2027 *14 UNTIL 14
71
+ # Jan 2027` both happen once, and only one of them looks like it.
72
+ def single?
73
+ remind_dates.length <= 1
74
+ end
75
+
76
+ # The dates Remind says the reminder fires on, bounded by the horizon.
77
+ def remind_dates
78
+ @remind_dates ||= reminder.occurrences(limit: horizon).to_a
79
+ end
80
+
81
+ # The RRULE this reminder looks like it means, before anyone checks.
82
+ #
83
+ # The shape is decided by which parts of the date the trigger left out,
84
+ # because that is how Remind's trigger language works: `REM 15` says
85
+ # nothing about the month, so it happens every month.
86
+ def candidate
87
+ @candidate ||= bounded(frequency)
88
+ end
89
+
90
+ private
91
+
92
+ def frequency
93
+ if reminder.repeat
94
+ "FREQ=DAILY;INTERVAL=#{reminder.repeat}"
95
+ elsif weekday_and_day?
96
+ "FREQ=MONTHLY;BYDAY=#{weekdays};#{week_containing(reminder.day)}"
97
+ elsif weekday_only?
98
+ "FREQ=WEEKLY;BYDAY=#{weekdays}#{in_month}"
99
+ elsif last_day_of_month?
100
+ "FREQ=MONTHLY;BYMONTHDAY=-#{reminder.back}"
101
+ elsif yearly?
102
+ "FREQ=YEARLY;BYMONTH=#{reminder.month};BYMONTHDAY=#{reminder.day}"
103
+ elsif monthly?
104
+ "FREQ=MONTHLY;BYMONTHDAY=#{reminder.day}"
105
+ elsif within_month?
106
+ "FREQ=DAILY;BYMONTH=#{reminder.month}"
107
+ else
108
+ "FREQ=DAILY"
109
+ end
110
+ end
111
+
112
+ def weekdays
113
+ reminder.weekdays.join(",")
114
+ end
115
+
116
+ def weekday_only?
117
+ reminder.weekdays.any? && reminder.day.nil?
118
+ end
119
+
120
+ def weekday_and_day?
121
+ reminder.weekdays.any? && !reminder.day.nil?
122
+ end
123
+
124
+ # `REM Mon Jun` is every Monday, but only in June.
125
+ def in_month
126
+ if reminder.month
127
+ ";BYMONTH=#{reminder.month}"
128
+ else
129
+ ""
130
+ end
131
+ end
132
+
133
+ # `REM Wed 15` is the Wednesday on or after the 15th, which is not the
134
+ # third Wednesday of the month -- in a month whose 1st is a Wednesday
135
+ # they are a week apart. What it is, exactly, is the Wednesday inside
136
+ # the seven days starting on the 15th, and BYMONTHDAY can say that.
137
+ def week_containing(day)
138
+ "BYMONTHDAY=#{(day..(day + 6)).to_a.join(",")}"
139
+ end
140
+
141
+ # `REM 1 -1` is the 1st, counted back one day: the last day of the month
142
+ # before. BYMONTHDAY counts back from the end of the month the same way.
143
+ def last_day_of_month?
144
+ reminder.day == 1 && reminder.back.to_i > 0
145
+ end
146
+
147
+ def yearly?
148
+ reminder.day && reminder.month
149
+ end
150
+
151
+ def monthly?
152
+ reminder.day && reminder.month.nil?
153
+ end
154
+
155
+ # `REM Feb` is every day in February.
156
+ def within_month?
157
+ reminder.day.nil? && reminder.month
158
+ end
159
+
160
+ # Where the series stops, if it does.
161
+ #
162
+ # An UNTIL clause says so outright. So does a year in a trigger that
163
+ # leaves the day or the month out: `REM Sat Sun 2021` is every weekend
164
+ # *in 2021*, and a rule without an UNTIL would go on for ever. A year in
165
+ # a fully-specified trigger is a start date rather than a bound, and a
166
+ # `*n` repeat starts from its date rather than being confined to it, so
167
+ # neither of those closes anything.
168
+ #
169
+ # Failing all that, a walk that ran out before the horizon says the
170
+ # reminder stops on its own, and where.
171
+ def bounded(rule)
172
+ last = reminder.until_date || year_end || ran_out
173
+
174
+ if last
175
+ "#{rule};UNTIL=#{last.strftime("%Y%m%d")}"
176
+ else
177
+ rule
178
+ end
179
+ end
180
+
181
+ def year_end
182
+ if reminder.year && !reminder.repeat && partial?
183
+ Date.new(reminder.year, 12, 31)
184
+ end
185
+ end
186
+
187
+ def partial?
188
+ reminder.day.nil? || reminder.month.nil?
189
+ end
190
+
191
+ def ran_out
192
+ if remind_dates.length < horizon
193
+ remind_dates.last
194
+ end
195
+ end
196
+
197
+ # --- the check --------------------------------------------------------
198
+
199
+ def agrees?
200
+ expanded == remind_dates
201
+ end
202
+
203
+ # The candidate rule's own dates, seeded at the first date Remind gives,
204
+ # since DTSTART is where a calendar starts expanding it.
205
+ def expanded
206
+ if parsed_rule
207
+ schedule(parsed_rule).first(remind_dates.length).map(&:to_date)
208
+ else
209
+ []
210
+ end
211
+ end
212
+
213
+ # A rule nobody can expand is a rule this converter will not emit. The
214
+ # rescue is around the parse alone: an error anywhere else is a bug
215
+ # here, and swallowing it would turn every bug into a silent fallback.
216
+ def parsed_rule
217
+ IceCube::Rule.from_ical(candidate)
218
+ rescue ArgumentError, RangeError
219
+ nil
220
+ end
221
+
222
+ def schedule(rule)
223
+ start = remind_dates.first
224
+
225
+ IceCube::Schedule.new(Time.new(start.year, start.month, start.day)) do |built|
226
+ built.add_recurrence_rule(rule)
227
+ end
228
+ end
229
+ end
230
+ end
231
+
232
+ __END__
233
+
234
+ require "remind"
235
+
236
+ describe "Rem2ics::Recurrence" do
237
+ session = Remind::Session.new
238
+ session.today = Date.new(2026, 8, 19)
239
+
240
+ recurrence = proc do |line, horizon = 12|
241
+ Rem2ics::Recurrence.new(Remind::Reminder.parse(line, session: session), horizon: horizon).call
242
+ end
243
+
244
+ describe "reminders an RRULE describes" do
245
+ it "makes a weekly reminder a weekly rule" do
246
+ result = recurrence.("REM Mon MSG gym")
247
+
248
+ result.should.be.rule
249
+ result.text.should == "FREQ=WEEKLY;BYDAY=MO"
250
+ end
251
+
252
+ it "makes a several-weekday reminder one rule" do
253
+ recurrence.("REM Mon Wed Fri MSG gym").text.should == "FREQ=WEEKLY;BYDAY=MO,WE,FR"
254
+ end
255
+
256
+ it "makes a day-of-month reminder a monthly rule" do
257
+ recurrence.("REM 15 MSG rent").text.should == "FREQ=MONTHLY;BYMONTHDAY=15"
258
+ end
259
+
260
+ it "makes a day-and-month reminder a yearly rule" do
261
+ recurrence.("REM 25 Dec MSG christmas").text.should ==
262
+ "FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25"
263
+ end
264
+
265
+ it "makes a repeat a daily rule with an interval" do
266
+ recurrence.("REM 1 Jan 2027 *14 MSG fortnightly").text.should.start_with "FREQ=DAILY;INTERVAL=14"
267
+ end
268
+
269
+ it "bounds a rule by the year the trigger names" do
270
+ # `REM Sat Sun 2031` is every weekend in 2031, and a rule without an
271
+ # UNTIL would run for ever.
272
+ recurrence.("REM Sat Sun 2031 MSG weekends", 8).text
273
+ .should == "FREQ=WEEKLY;BYDAY=SA,SU;UNTIL=20311231"
274
+ end
275
+
276
+ it "narrows a weekly rule to the month the trigger names" do
277
+ recurrence.("REM Mon Jun MSG summer mondays").text
278
+ .should == "FREQ=WEEKLY;BYDAY=MO;BYMONTH=6"
279
+ end
280
+
281
+ it "counts back from the end of the month when the trigger does" do
282
+ recurrence.("REM 1 -1 MSG last day").text.should == "FREQ=MONTHLY;BYMONTHDAY=-1"
283
+ end
284
+
285
+ it "carries an UNTIL through" do
286
+ recurrence.("REM 1 Jan 2027 *7 UNTIL 1 Mar 2027 MSG weekly").text
287
+ .should.include "UNTIL=20270301"
288
+ end
289
+
290
+ it "agrees with Remind, which is the only reason it is a rule at all" do
291
+ result = recurrence.("REM 15 MSG rent")
292
+
293
+ result.dates.first(3).map(&:to_s).should == %w[2026-09-15 2026-10-15 2026-11-15]
294
+ end
295
+ end
296
+
297
+ describe "reminders no RRULE describes" do
298
+ it "falls back to Remind's dates when the reminder skips omitted days" do
299
+ # 1 March 2031 is a Saturday; this reminder does not fire that year.
300
+ result = recurrence.("REM 1 Mar SKIP OMIT Sat Sun MSG payday", 6)
301
+
302
+ result.rule?.should.be.false
303
+ result.dates.map(&:to_s).should ==
304
+ %w[2027-03-01 2028-03-01 2029-03-01 2030-03-01 2032-03-01 2033-03-01]
305
+ end
306
+
307
+ it "falls back when a trigger names several weekdays and a day" do
308
+ # `REM Wed Thu 15` fires on the *first* of those weekdays on or after
309
+ # the 15th -- one date a month, not two. BYDAY=WE,TH says both, so the
310
+ # check rejects it and the dates go in instead.
311
+ result = recurrence.("REM Wed Thu 15 MSG deadline", 4)
312
+
313
+ result.rule?.should.be.false
314
+ result.dates.map(&:to_s).should == %w[2026-08-19 2026-09-16 2026-10-15 2026-11-18]
315
+ end
316
+
317
+ it "keeps a rule when one weekday and a day do line up" do
318
+ result = recurrence.("REM Mon 15 MSG deadline", 4)
319
+
320
+ result.should.be.rule
321
+ result.text.should == "FREQ=MONTHLY;BYDAY=MO;BYMONTHDAY=15,16,17,18,19,20,21"
322
+ end
323
+
324
+ it "keeps the dates rather than a rule that would invent an occurrence" do
325
+ result = recurrence.("REM 1 Mar SKIP OMIT Sat Sun MSG payday", 6)
326
+
327
+ result.dates.map(&:to_s).should.not.include "2031-03-01"
328
+ end
329
+ end
330
+
331
+ describe "reminders that happen once" do
332
+ it "needs no rule for a reminder Remind triggers once" do
333
+ result = recurrence.("REM 25 Dec 2027 MSG christmas")
334
+
335
+ result.rule?.should.be.false
336
+ result.dates.map(&:to_s).should == %w[2027-12-25]
337
+ end
338
+
339
+ it "asks Remind rather than reading the trigger" do
340
+ # Fully specified, and still repeating.
341
+ recurrence.("REM 1 Jan 2027 *14 MSG fortnightly").should.be.rule
342
+ end
343
+ end
344
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rem2ics
4
+ VERSION = "0.1.0"
5
+ end
data/lib/rem2ics.rb ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rem2ics/cli"
4
+ require_relative "rem2ics/converter"
5
+ require_relative "rem2ics/event"
6
+ require_relative "rem2ics/recurrence"
7
+ require_relative "rem2ics/version"
8
+
9
+ # Reminder files to iCalendar.
10
+ #
11
+ # This is a port of Martin Michel's remmy.pl, and the interesting difference
12
+ # is not that it is Ruby. remmy.pl read the reminder language with regular
13
+ # expressions -- as every converter before it did -- and got some of it right.
14
+ # There is a lot of that language: eighteen worked examples in Remind's manual
15
+ # before the exceptions start, weekday-and-day triggers that are not what they
16
+ # look like, SKIP and OMIT and SCANFROM and BEFORE and AFTER.
17
+ #
18
+ # rem2ics does not read it at all. It is built on the remind-rb bindings, so
19
+ # Remind parses the trigger, Remind computes the dates it fires on, and Remind
20
+ # renders the message. What is left for this side is the mapping to
21
+ # iCalendar's vocabulary, and one judgement:
22
+ #
23
+ # A recurring reminder becomes an event with an RRULE -- but only when that
24
+ # RRULE has been expanded and checked, occurrence by occurrence, against the
25
+ # dates Remind gives. Where the two agree the calendar recurs forever and
26
+ # correctly. Where they disagree -- a reminder that skips holidays, one that
27
+ # moves off weekends -- the event carries Remind's own dates instead.
28
+ #
29
+ # So the output is never wrong about when something happens; at worst it is
30
+ # finite where the reminder is not.
31
+ module Rem2ics
32
+ end
metadata ADDED
@@ -0,0 +1,144 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rem2ics
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nathan Kidd
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-01 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: icalendar
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.12'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.12'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ice_cube
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.17'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.17'
40
+ - !ruby/object:Gem::Dependency
41
+ name: remind-rb
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '6.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '6.2'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rubocop
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.88'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.88'
82
+ - !ruby/object:Gem::Dependency
83
+ name: scampi
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '1.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '1.0'
96
+ description: |
97
+ Rem2ics turns the reminder files used by Remind into iCalendar, the format
98
+ Outlook, Apple Calendar and Google Calendar import.
99
+
100
+ It does not parse the reminder language. Remind does: rem2ics is built on
101
+ the remind-rb bindings, so the trigger is parsed by ParseRem, the dates it
102
+ fires on come from ComputeTrigger, and the message is rendered by DoSubst
103
+ with its substitutions expanded. A recurring reminder becomes one event
104
+ with an RRULE -- but only when the RRULE has been expanded and checked
105
+ against the dates Remind gives; when the two disagree, as they do for a
106
+ reminder that skips holidays, the event carries Remind's dates instead.
107
+ email: nathanblenheimkidd@gmail.com
108
+ executables:
109
+ - rem2ics
110
+ extensions: []
111
+ extra_rdoc_files:
112
+ - docs/rem2ics.md
113
+ files:
114
+ - LICENSE
115
+ - docs/rem2ics.md
116
+ - exe/rem2ics
117
+ - lib/rem2ics.rb
118
+ - lib/rem2ics/cli.rb
119
+ - lib/rem2ics/converter.rb
120
+ - lib/rem2ics/event.rb
121
+ - lib/rem2ics/recurrence.rb
122
+ - lib/rem2ics/version.rb
123
+ homepage: https://dianne.skoll.ca/projects/remind/
124
+ licenses:
125
+ - GPL-2.0-only
126
+ metadata: {}
127
+ rdoc_options: []
128
+ require_paths:
129
+ - lib
130
+ required_ruby_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ version: '3.3'
135
+ required_rubygems_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
140
+ requirements: []
141
+ rubygems_version: 3.7.2
142
+ specification_version: 4
143
+ summary: Convert Remind reminder files to iCalendar (.ics)
144
+ test_files: []