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,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "icalendar"
5
+ require "remind"
6
+
7
+ require_relative "event"
8
+ require_relative "recurrence"
9
+ require_relative "version"
10
+
11
+ module Rem2ics
12
+ # Reminder files in, one iCalendar out.
13
+ #
14
+ # The conversion is a fold over what Remind gives: every reminder in every
15
+ # file becomes an event, in file order, in one VCALENDAR. What it skips is
16
+ # what a calendar has nowhere to put -- RUN reminders execute shell
17
+ # commands, CAL and PS reminders draw on a PostScript calendar, and none of
18
+ # them is an appointment.
19
+ #
20
+ # "Today" is pinned for the whole conversion, because it is what a relative
21
+ # trigger is relative to: `REM Mon` starts on a different Monday depending
22
+ # on when it is asked. Pinning it makes the same file convert to the same
23
+ # calendar every time, which is what makes the output diffable and the
24
+ # conversion testable.
25
+ class Converter
26
+ PRODID = "-//rem2ics//NONSGML rem2ics #{VERSION}//EN"
27
+
28
+ attr_reader :session, :horizon, :organizer, :warnings
29
+
30
+ def initialize(
31
+ session: Remind::Session.new,
32
+ horizon: Recurrence::DEFAULT_HORIZON,
33
+ organizer: nil,
34
+ warnings: $stderr
35
+ )
36
+ @session = session
37
+ @horizon = horizon
38
+ @organizer = organizer || self.class.local_address
39
+ @warnings = warnings
40
+ end
41
+
42
+ # `id -nu`@`uname -n`, which is what the Perl this descends from used, and
43
+ # is as good a guess at an organizer as a reminder file can support.
44
+ def self.local_address
45
+ login = Etc.getlogin || ENV.fetch("USER", "remind")
46
+
47
+ "#{login}@#{Etc.uname.fetch(:nodename)}"
48
+ end
49
+
50
+ def call(paths, today: Date.today)
51
+ session.today = today
52
+
53
+ calendar.tap do |ical|
54
+ paths.each { |path| add_file(ical, path) }
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def calendar
61
+ Icalendar::Calendar.new.tap do |ical|
62
+ ical.prodid = PRODID
63
+ ical.ip_method = "PUBLISH"
64
+ end
65
+ end
66
+
67
+ def add_file(ical, path)
68
+ Remind::Source.new(path, session: session).reminders.each do |reminder|
69
+ add_reminder(ical, reminder)
70
+ end
71
+ rescue Remind::EvaluationError => error
72
+ warnings.puts("#{path}: #{error.message}")
73
+ end
74
+
75
+ def add_reminder(ical, reminder)
76
+ if reminder.display?
77
+ ical.add_event(event_for(reminder))
78
+ end
79
+ end
80
+
81
+ # The message is rendered as of the day the event starts, not as of the
82
+ # day the conversion runs: `%b` says "today" on the day, and a calendar
83
+ # entry that says "in 27 days' time" forever is a calendar entry that
84
+ # was written on the wrong day.
85
+ def event_for(reminder)
86
+ recurrence = Recurrence.new(reminder, horizon: horizon).call
87
+
88
+ Event.new(
89
+ reminder.as_of(recurrence.dates.first),
90
+ recurrence: recurrence,
91
+ organizer: organizer,
92
+ ).to_ical_event
93
+ end
94
+ end
95
+ end
96
+
97
+ __END__
98
+
99
+ require "tempfile"
100
+
101
+ describe "Rem2ics::Converter" do
102
+ written = proc do |text|
103
+ file = Tempfile.new(["reminders", ".rem"])
104
+ file.write(text)
105
+ file.close
106
+ file.path
107
+ end
108
+
109
+ convert = proc do |text, today = Date.new(2026, 8, 19)|
110
+ Rem2ics::Converter.new(organizer: "me@host", horizon: 12)
111
+ .call([written.(text)], today: today)
112
+ .to_ical
113
+ end
114
+
115
+ it "wraps the events in one calendar" do
116
+ output = convert.("REM 25 Dec MSG christmas\n")
117
+
118
+ output.should.start_with "BEGIN:VCALENDAR"
119
+ output.should.include "PRODID:-//rem2ics//NONSGML rem2ics"
120
+ output.should.include "METHOD:PUBLISH"
121
+ output.should.end_with "END:VCALENDAR\r\n"
122
+ end
123
+
124
+ it "converts every reminder in the file, in order" do
125
+ output = convert.(<<~REM)
126
+ REM 25 Dec MSG christmas
127
+ REM Mon MSG gym
128
+ REM
129
+
130
+ output.scan(/SUMMARY:(\w+)/).flatten.should == %w[christmas gym]
131
+ end
132
+
133
+ it "skips what a calendar cannot show" do
134
+ output = convert.(<<~REM)
135
+ REM 25 Dec MSG christmas
136
+ REM Mon RUN backup.sh
137
+ SET a 1
138
+ REM
139
+
140
+ output.scan(/BEGIN:VEVENT/).length.should == 1
141
+ end
142
+
143
+ it "pins today, so the same file converts to the same calendar" do
144
+ first = convert.("REM Mon MSG gym\n")
145
+ again = convert.("REM Mon MSG gym\n")
146
+
147
+ first.sub(/DTSTAMP:\S+/, "").should == again.sub(/DTSTAMP:\S+/, "")
148
+ end
149
+
150
+ it "starts a relative reminder from the day it was told about" do
151
+ convert.("REM Mon MSG gym\n", Date.new(2027, 3, 1)).should.include "DTSTART;VALUE=DATE:20270301"
152
+ end
153
+
154
+ it "says so when a file cannot be read, and carries on" do
155
+ warnings = StringIO.new
156
+ converter = Rem2ics::Converter.new(organizer: "me@host", warnings: warnings)
157
+
158
+ converter.call(["/nonexistent.rem"], today: Date.new(2026, 8, 19)).to_ical
159
+ .should.include "BEGIN:VCALENDAR"
160
+ warnings.string.should.include "/nonexistent.rem"
161
+ end
162
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "icalendar"
5
+
6
+ require_relative "recurrence"
7
+
8
+ module Rem2ics
9
+ # One reminder, as an iCalendar event.
10
+ #
11
+ # The iCalendar is built with the icalendar gem rather than by printing
12
+ # lines: escaping, folding at 75 octets, CRLF endings and DTSTAMP are all
13
+ # spelled out in RFC 5545, all fiddly, and all already written.
14
+ #
15
+ # What is decided here is the mapping, and it is a small list:
16
+ #
17
+ # the reminder's message SUMMARY, from Remind's CAL_MODE rendering,
18
+ # which is where a %"…%" title is honoured
19
+ # the rest of the message DESCRIPTION, from the NORMAL_MODE rendering
20
+ # the trigger DTSTART, and RRULE or RDATE (see Recurrence)
21
+ # AT the time on DTSTART
22
+ # DURATION DTEND
23
+ # +n advance warning VALARM, n days or n minutes before
24
+ class Event
25
+ # A reminder with no AT clause is an all-day event, and an all-day event's
26
+ # DTEND is the day after: the RFC's end is exclusive.
27
+ ALL_DAY = 1
28
+
29
+ MINUTES_PER_HOUR = 60
30
+
31
+ # UIDs have to survive re-importing the same file, or every conversion
32
+ # creates duplicates instead of updating what is there. A digest of the
33
+ # reminder as written plus the date it starts on is stable across runs and
34
+ # different between reminders.
35
+ UID_SUFFIX = "rem2ics"
36
+
37
+ attr_reader :reminder, :recurrence, :organizer
38
+
39
+ def initialize(reminder, recurrence:, organizer:)
40
+ @reminder = reminder
41
+ @recurrence = recurrence
42
+ @organizer = organizer
43
+ end
44
+
45
+ def to_ical_event
46
+ Icalendar::Event.new.tap do |event|
47
+ event.uid = uid
48
+ event.summary = reminder.summary
49
+ event.description = reminder.description
50
+ event.organizer = Icalendar::Values::CalAddress.new("mailto:#{organizer}")
51
+ event.ip_class = "PUBLIC"
52
+
53
+ add_dates(event)
54
+ add_recurrence(event)
55
+ add_alarms(event)
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def uid
62
+ digest = Digest::SHA256.hexdigest("#{reminder.line}\n#{start_date}")
63
+
64
+ "#{digest[0, 32]}@#{UID_SUFFIX}"
65
+ end
66
+
67
+ def start_date
68
+ recurrence.dates.first
69
+ end
70
+
71
+ def add_dates(event)
72
+ if reminder.at
73
+ add_timed(event)
74
+ else
75
+ add_all_day(event)
76
+ end
77
+ end
78
+
79
+ def add_all_day(event)
80
+ event.dtstart = Icalendar::Values::Date.new(start_date)
81
+ event.dtend = Icalendar::Values::Date.new(start_date + ALL_DAY)
82
+ end
83
+
84
+ # Local wall-clock time, with no zone: a reminder file says nothing
85
+ # about zones, and the calendar it lands in is the reader's own.
86
+ def add_timed(event)
87
+ event.dtstart = Icalendar::Values::DateTime.new(starts_at)
88
+
89
+ if reminder.duration
90
+ event.dtend = Icalendar::Values::DateTime.new(starts_at + minutes(reminder.duration))
91
+ end
92
+ end
93
+
94
+ def starts_at
95
+ DateTime.new(
96
+ start_date.year,
97
+ start_date.month,
98
+ start_date.day,
99
+ ) + minutes(reminder.at)
100
+ end
101
+
102
+ def minutes(count)
103
+ Rational(count, 24 * 60)
104
+ end
105
+
106
+ # A rule when the rule was checked against Remind and agreed; otherwise
107
+ # the dates Remind gave, which are exact but finite.
108
+ def add_recurrence(event)
109
+ if recurrence.rule?
110
+ event.rrule = recurrence.text
111
+ else
112
+ add_dates_after_the_first(event)
113
+ end
114
+ end
115
+
116
+ def add_dates_after_the_first(event)
117
+ recurrence.dates.drop(1).each do |date|
118
+ event.append_rdate(Icalendar::Values::Date.new(date))
119
+ end
120
+ end
121
+
122
+ # Remind writes advance warning two ways: `+n` on the trigger is n days
123
+ # before the day, `+n` on the AT clause is n minutes before the time.
124
+ def add_alarms(event)
125
+ alarm(event, "-P#{reminder.warning_days}D", reminder.warning_days)
126
+ alarm(event, "-PT#{reminder.warning_minutes}M", reminder.warning_minutes)
127
+ end
128
+
129
+ def alarm(event, trigger, wanted)
130
+ if wanted
131
+ event.alarm do |alarm|
132
+ alarm.action = "DISPLAY"
133
+ alarm.trigger = trigger
134
+ alarm.description = reminder.summary
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+
141
+ __END__
142
+
143
+ require "remind"
144
+
145
+ describe "Rem2ics::Event" do
146
+ session = Remind::Session.new
147
+ session.today = Date.new(2026, 8, 19)
148
+
149
+ ical = proc do |line|
150
+ reminder = Remind::Reminder.parse(line, session: session)
151
+ recurrence = Rem2ics::Recurrence.new(reminder, horizon: 12).call
152
+
153
+ Rem2ics::Event.new(reminder, recurrence: recurrence, organizer: "me@host")
154
+ .to_ical_event
155
+ .to_ical
156
+ end
157
+
158
+ describe "an all-day reminder" do
159
+ it "starts on the day Remind says, as a date" do
160
+ ical.("REM 25 Dec MSG christmas").should.include "DTSTART;VALUE=DATE:20261225"
161
+ end
162
+
163
+ it "ends the next day, because the RFC's end is exclusive" do
164
+ ical.("REM 25 Dec MSG christmas").should.include "DTEND;VALUE=DATE:20261226"
165
+ end
166
+ end
167
+
168
+ describe "a timed reminder" do
169
+ it "starts at the time the AT clause gave" do
170
+ ical.("REM Mon AT 9:30 MSG standup").should.include "DTSTART:20260824T093000"
171
+ end
172
+
173
+ it "ends a DURATION later" do
174
+ ical.("REM Mon AT 9:30 DURATION 1:30 MSG standup").should.include "DTEND:20260824T110000"
175
+ end
176
+
177
+ it "has no end at all when the reminder gave no duration" do
178
+ ical.("REM Mon AT 9:30 MSG standup").should.not.include "DTEND"
179
+ end
180
+ end
181
+
182
+ describe "the message" do
183
+ it "takes the title Remind marks for a calendar" do
184
+ output = ical.(%q{REM 25 Dec MSG %"christmas%" buy a tree})
185
+
186
+ output.should.include "SUMMARY:christmas"
187
+ output.should.include "DESCRIPTION:christmas buy a tree"
188
+ end
189
+
190
+ it "escapes what iCalendar gives meaning to" do
191
+ ical.("REM 25 Dec MSG lunch, then presents").should.include "lunch\\, then presents"
192
+ end
193
+ end
194
+
195
+ describe "recurrence" do
196
+ it "carries a rule that agreed with Remind" do
197
+ ical.("REM Mon MSG gym").should.include "RRULE:FREQ=WEEKLY;BYDAY=MO"
198
+ end
199
+
200
+ it "carries dates instead of a rule that did not" do
201
+ output = ical.("REM 1 Mar SKIP OMIT Sat Sun MSG payday")
202
+
203
+ output.should.not.include "RRULE"
204
+ output.should.include "RDATE"
205
+ end
206
+
207
+ it "carries neither for a reminder that happens once" do
208
+ output = ical.("REM 25 Dec 2027 MSG christmas")
209
+
210
+ output.should.not.include "RRULE"
211
+ output.should.not.include "RDATE"
212
+ end
213
+ end
214
+
215
+ describe "alarms" do
216
+ it "warns the days ahead the trigger asked for" do
217
+ ical.("REM 15 +3 MSG rent").should.include "TRIGGER:-P3D"
218
+ end
219
+
220
+ it "warns the minutes ahead the AT clause asked for" do
221
+ ical.("REM Mon AT 9:30 +15 MSG standup").should.include "TRIGGER:-PT15M"
222
+ end
223
+
224
+ it "has no alarm when the reminder asked for none" do
225
+ ical.("REM 25 Dec MSG christmas").should.not.include "VALARM"
226
+ end
227
+ end
228
+
229
+ describe "identity" do
230
+ it "gives the same reminder the same UID every run" do
231
+ ical.("REM 25 Dec MSG christmas")[/UID:(\S+)/, 1]
232
+ .should == ical.("REM 25 Dec MSG christmas")[/UID:(\S+)/, 1]
233
+ end
234
+
235
+ it "gives different reminders different UIDs" do
236
+ ical.("REM 25 Dec MSG christmas")[/UID:(\S+)/, 1]
237
+ .should.not == ical.("REM 26 Dec MSG boxing day")[/UID:(\S+)/, 1]
238
+ end
239
+ end
240
+ end