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.
- checksums.yaml +7 -0
- data/LICENSE +328 -0
- data/docs/rem2ics.md +83 -0
- data/exe/rem2ics +8 -0
- data/lib/rem2ics/cli.rb +201 -0
- data/lib/rem2ics/converter.rb +162 -0
- data/lib/rem2ics/event.rb +240 -0
- data/lib/rem2ics/recurrence.rb +344 -0
- data/lib/rem2ics/version.rb +5 -0
- data/lib/rem2ics.rb +32 -0
- metadata +144 -0
|
@@ -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
|