Print a forecast of upcoming events from iCal files
1#!/usr/bin/env ruby
2#
3# ical_forecast.rb
4# print upcoming events from iCal files
5#
6# Copyright (c) 2010, 2014, 2017 joshua stein <jcs@jcs.org>
7#
8# Redistribution and use in source and binary forms, with or without
9# modification, are permitted provided that the following conditions
10# are met:
11#
12# 1. Redistributions of source code must retain the above copyright
13# notice, this list of conditions and the following disclaimer.
14# 2. Redistributions in binary form must reproduce the above copyright
15# notice, this list of conditions and the following disclaimer in the
16# documentation and/or other materials provided with the distribution.
17# 3. The name of the author may not be used to endorse or promote products
18# derived from this software without specific prior written permission.
19#
20# THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR
21# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
22# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
23# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
24# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
25# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
29# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30#
31
32require "ri_cal"
33require "tzinfo"
34require "date"
35
36FORECAST_SPAN = (60 * 60 * 24 * 7)
37
38BOLD = "\e[1;1m"
39UNBOLD = "\e[0;0m"
40GRAY = "\e[38;5;239m"
41LIGHTGRAY = "\e[38;5;242m"
42RESET = "\e[0;0m"
43
44upcoming = []
45
46if !ARGV.any? || ARGV[0] == "-h"
47 puts "usage: #{$0} <ical file...>"
48 exit 1
49end
50
51ARGV.each do |file|
52 RiCal.parse(File.open(file, "rb")).first.events.each do |ev|
53 ev.occurrences(:count => 10).each do |oc|
54 begin
55 start = oc.dtstart.to_time.getlocal
56 finish = oc.dtend.to_time.getlocal
57 rescue RiCal::InvalidTimezoneIdentifier
58 next
59 end
60
61 if finish < Time.now || start > (Time.now + FORECAST_SPAN)
62 next
63 end
64
65 upcoming.push oc
66 end
67 end
68end
69
70upcoming.sort_by{|u| u.dtstart.to_time.getlocal }.reverse.each do |ev|
71 start = ev.dtstart.to_time.getlocal
72 finish = ev.dtend.to_time.getlocal
73
74 weeks = 0
75 days = (start.to_date - Date.today).to_i
76 if days >= 7
77 weeks = (days.to_f / 7.0).floor
78 days = days - (weeks * 7)
79 end
80
81 out = ""
82 if weeks == 0 && days == 0
83 out << BOLD
84 elsif weeks <= 1
85 out << GRAY
86 elsif weeks > 1
87 out << LIGHTGRAY
88 end
89
90 out << ev.summary << " "
91
92 if weeks > 0
93 out << "in #{weeks} week#{weeks == 1 ? "" : "s"}"
94
95 if days > 0
96 out << ", #{days} day#{days == 1 ? "" : "s"}"
97 end
98 else
99 if days < -1
100 out << "#{days.abs} day#{days == -1 ? "" : "s"} ago"
101 elsif days == -1
102 out << "yesterday"
103 elsif days == 0
104 out << "today"
105 elsif days == 1
106 out << "tomorrow"
107 else
108 out << "in #{days} day#{days == 1 ? "" : "s"}"
109 end
110 end
111
112 all_day = (start != finish && start.hour == 0 && finish.hour == 0 &&
113 (finish.to_i - start.to_i == (60 * 60 * 24)))
114
115 if !all_day
116 out << " at #{start.strftime("%H:%M")}"
117
118 if start.to_date == Time.now.to_date
119 mins = ((start - Time.now) / 60).floor
120 if start < Time.now
121 out << " (#{mins} minute#{mins == 1 ? "" : "s"} ago)"
122 else
123 out << " (in #{mins} minute#{mins == 1 ? "" : "s"})"
124 end
125 end
126 end
127
128 if weeks == 0 && days == 0
129 out << UNBOLD
130 end
131
132 out << " (" << start.strftime("%a #{start.day} %b").downcase
133 if !all_day && (start.to_date != finish.to_date)
134 out << " to " << finish.strftime("%a #{finish.day} %b").downcase
135 end
136 out << ")"
137
138 out << RESET
139
140 puts out
141end