timely-calendar 1.0.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/.gitignore +39 -0
- data/CLAUDE.md +95 -0
- data/README.md +188 -0
- data/bin/timely +41 -0
- data/img/timely.svg +111 -0
- data/lib/timely/astronomy.rb +180 -0
- data/lib/timely/config.rb +93 -0
- data/lib/timely/database.rb +378 -0
- data/lib/timely/event.rb +76 -0
- data/lib/timely/notifications.rb +83 -0
- data/lib/timely/sources/google.rb +276 -0
- data/lib/timely/sources/ics_file.rb +247 -0
- data/lib/timely/sources/outlook.rb +286 -0
- data/lib/timely/sync/poller.rb +119 -0
- data/lib/timely/ui/application.rb +1960 -0
- data/lib/timely/ui/panes.rb +46 -0
- data/lib/timely/ui/views/month.rb +118 -0
- data/lib/timely/version.rb +3 -0
- data/lib/timely/weather.rb +111 -0
- data/lib/timely.rb +45 -0
- data/timely.gemspec +33 -0
- metadata +98 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Timely
|
|
2
|
+
module UI
|
|
3
|
+
module Panes
|
|
4
|
+
def setup_display
|
|
5
|
+
require 'io/console'
|
|
6
|
+
if IO.console
|
|
7
|
+
@h, @w = IO.console.winsize
|
|
8
|
+
else
|
|
9
|
+
@h = ENV['LINES']&.to_i || 24
|
|
10
|
+
@w = ENV['COLUMNS']&.to_i || 80
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create_panes
|
|
15
|
+
@panes = {}
|
|
16
|
+
|
|
17
|
+
# Layout: info(1) + top(months, fixed 9) + mid(week, flexible) + bottom(details) + status(1)
|
|
18
|
+
# Top pane: 1 blank row + 8 month rows = 9
|
|
19
|
+
top_h = 9
|
|
20
|
+
bottom_h = (@h * 0.2).to_i
|
|
21
|
+
bottom_h = [bottom_h, 5].max
|
|
22
|
+
mid_h = @h - 2 - top_h - bottom_h # 2 = info + status
|
|
23
|
+
mid_h = [mid_h, 4].max
|
|
24
|
+
|
|
25
|
+
# Adjust if overflow
|
|
26
|
+
if 2 + top_h + mid_h + bottom_h > @h
|
|
27
|
+
bottom_h = @h - 2 - top_h - mid_h
|
|
28
|
+
bottom_h = [bottom_h, 3].max
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
info_bg = @config ? @config.get('colors.info_bg', 235) : 235
|
|
32
|
+
status_bg = @config ? @config.get('colors.status_bg', 235) : 235
|
|
33
|
+
@panes[:info] = Rcurses::Pane.new(1, 1, @w, 1, 255, info_bg)
|
|
34
|
+
@panes[:top] = Rcurses::Pane.new(1, 2, @w, top_h)
|
|
35
|
+
@panes[:mid] = Rcurses::Pane.new(1, 2 + top_h, @w, mid_h)
|
|
36
|
+
@panes[:bottom] = Rcurses::Pane.new(1, 2 + top_h + mid_h, @w, bottom_h)
|
|
37
|
+
@panes[:status] = Rcurses::Pane.new(1, @h, @w, 1, 252, status_bg)
|
|
38
|
+
|
|
39
|
+
@panes.each_value do |p|
|
|
40
|
+
p.border = false
|
|
41
|
+
p.scroll = false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module Timely
|
|
2
|
+
module UI
|
|
3
|
+
module Views
|
|
4
|
+
module Month
|
|
5
|
+
WEEKDAYS = %w[Mo Tu We Th Fr Sa Su].freeze
|
|
6
|
+
MINI_WIDTH = 25 # 3 extra for "WW " week number prefix
|
|
7
|
+
|
|
8
|
+
# Render a compact mini-month calendar.
|
|
9
|
+
# Returns an array of strings (one per line), about 8-9 lines tall.
|
|
10
|
+
# selected_day: day number to highlight with reverse video (or nil)
|
|
11
|
+
# today: Date.today for bold+underline marking
|
|
12
|
+
# events_by_date: hash of Date => [event_hashes] for coloring event days
|
|
13
|
+
# width: available width (typically 22 chars)
|
|
14
|
+
def self.render_mini_month(year, month, selected_day, today, events_by_date, width = MINI_WIDTH, today_bg: 246)
|
|
15
|
+
lines = []
|
|
16
|
+
|
|
17
|
+
# Title: centered month and year
|
|
18
|
+
title = Date.new(year, month, 1).strftime("%B %Y")
|
|
19
|
+
pad = [(width - title.length) / 2, 1].max
|
|
20
|
+
lines << (" " * pad + title).b
|
|
21
|
+
|
|
22
|
+
# Weekday headers (with space for week number column)
|
|
23
|
+
header = " " + WEEKDAYS.each_with_index.map { |d, i|
|
|
24
|
+
s = d.rjust(2)
|
|
25
|
+
case i
|
|
26
|
+
when 5 then s.fg(208) # Saturday
|
|
27
|
+
when 6 then s.fg(167) # Sunday
|
|
28
|
+
else s.fg(245)
|
|
29
|
+
end
|
|
30
|
+
}.join(" ")
|
|
31
|
+
lines << header
|
|
32
|
+
|
|
33
|
+
# Build calendar grid
|
|
34
|
+
first_day = Date.new(year, month, 1)
|
|
35
|
+
last_day = Date.new(year, month, -1)
|
|
36
|
+
start_offset = first_day.cwday - 1
|
|
37
|
+
|
|
38
|
+
week = []
|
|
39
|
+
start_offset.times { week << nil }
|
|
40
|
+
|
|
41
|
+
(1..last_day.day).each do |day|
|
|
42
|
+
week << day
|
|
43
|
+
if week.length == 7
|
|
44
|
+
lines << format_week(week, year, month, today, selected_day, events_by_date, today_bg)
|
|
45
|
+
week = []
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Final partial week
|
|
50
|
+
unless week.empty?
|
|
51
|
+
week << nil while week.length < 7
|
|
52
|
+
lines << format_week(week, year, month, today, selected_day, events_by_date, today_bg)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Pad to consistent height (title + header + 6 week rows = 8 lines)
|
|
56
|
+
while lines.length < 8
|
|
57
|
+
lines << " " * width
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
lines
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def self.format_week(week, year, month, today, selected_day, events_by_date, today_bg = 236)
|
|
66
|
+
# Find week number from first non-nil day in this row
|
|
67
|
+
first_day = week.compact.first
|
|
68
|
+
wn = first_day ? Date.new(year, month, first_day).cweek.to_s.rjust(2) : " "
|
|
69
|
+
|
|
70
|
+
cells = week.map do |day|
|
|
71
|
+
if day.nil?
|
|
72
|
+
" "
|
|
73
|
+
else
|
|
74
|
+
format_day(day, year, month, today, selected_day, events_by_date, today_bg)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
wn.fg(238) + " " + cells.join(" ")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.format_day(day, year, month, today, selected_day, events_by_date, today_bg = 236)
|
|
81
|
+
date = Date.new(year, month, day)
|
|
82
|
+
events = events_by_date[date]
|
|
83
|
+
|
|
84
|
+
is_today = (date == today)
|
|
85
|
+
is_selected = (selected_day && day == selected_day && date.month == today.month && date.year == today.year) ||
|
|
86
|
+
(selected_day && day == selected_day)
|
|
87
|
+
|
|
88
|
+
# selected_day is only meaningful when this is the selected month
|
|
89
|
+
# The caller controls which month gets a non-nil selected_day
|
|
90
|
+
|
|
91
|
+
# Base fg color: event > sunday > saturday > default
|
|
92
|
+
base_color = if events && !events.empty?
|
|
93
|
+
events.first['calendar_color'] || 39
|
|
94
|
+
elsif date.sunday?
|
|
95
|
+
167
|
|
96
|
+
elsif date.saturday?
|
|
97
|
+
208
|
|
98
|
+
else
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
d = day.to_s.rjust(2)
|
|
103
|
+
if is_selected && is_today
|
|
104
|
+
base_color ? d.b.u.fg(base_color).bg(today_bg) : d.b.u.bg(today_bg)
|
|
105
|
+
elsif is_selected
|
|
106
|
+
base_color ? d.b.u.fg(base_color) : d.b.u
|
|
107
|
+
elsif is_today
|
|
108
|
+
base_color ? d.fg(base_color).bg(today_bg) : d.bg(today_bg)
|
|
109
|
+
elsif base_color
|
|
110
|
+
d.fg(base_color)
|
|
111
|
+
else
|
|
112
|
+
d
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
require 'net/http'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Timely
|
|
6
|
+
module Weather
|
|
7
|
+
SYMBOLS = {
|
|
8
|
+
0 => "\u2600", # Clear sky
|
|
9
|
+
1 => "\u{1F324}", # Mostly clear
|
|
10
|
+
2 => "\u26C5", # Partly cloudy
|
|
11
|
+
3 => "\u2601", # Cloudy
|
|
12
|
+
5 => "\u{1F326}", # Rain showers
|
|
13
|
+
6 => "\u{1F327}", # Rain
|
|
14
|
+
8 => "\u{1F328}", # Snow
|
|
15
|
+
9 => "\u{1F329}", # Thunder
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# Fetch weather forecast from met.no
|
|
19
|
+
# Returns hash of date_str => { temp_high:, temp_low:, symbol:, wind:, description: }
|
|
20
|
+
def self.fetch(lat, lon, db = nil)
|
|
21
|
+
# Check cache first (6 hour TTL)
|
|
22
|
+
if db
|
|
23
|
+
cached = db.execute(
|
|
24
|
+
"SELECT data, fetched_at FROM weather_cache WHERE date = 'forecast' LIMIT 1"
|
|
25
|
+
).first
|
|
26
|
+
if cached && (Time.now.to_i - cached['fetched_at'].to_i) < 21600
|
|
27
|
+
return JSON.parse(cached['data']) rescue {}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
uri = URI("https://api.met.no/weatherapi/locationforecast/2.0/complete?lat=#{lat}&lon=#{lon}")
|
|
32
|
+
req = Net::HTTP::Get.new(uri)
|
|
33
|
+
req['User-Agent'] = 'timely-calendar/0.1 g@isene.com'
|
|
34
|
+
|
|
35
|
+
res = Net::HTTP.start(uri.hostname, uri.port,
|
|
36
|
+
use_ssl: true,
|
|
37
|
+
read_timeout: 10,
|
|
38
|
+
open_timeout: 5) do |http|
|
|
39
|
+
http.request(req)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
return {} unless res.is_a?(Net::HTTPSuccess)
|
|
43
|
+
|
|
44
|
+
series = JSON.parse(res.body).dig('properties', 'timeseries') || []
|
|
45
|
+
|
|
46
|
+
# Group by date, find high/low temps and midday conditions
|
|
47
|
+
by_date = {}
|
|
48
|
+
series.each do |ts|
|
|
49
|
+
det = ts.dig('data', 'instant', 'details')
|
|
50
|
+
next unless det
|
|
51
|
+
time = ts['time']
|
|
52
|
+
date = time[0..9]
|
|
53
|
+
hour = time[11..12].to_i
|
|
54
|
+
temp = det['air_temperature'].to_f
|
|
55
|
+
|
|
56
|
+
by_date[date] ||= { temps: [], wind: 0, cloud: 0, midday_temp: nil }
|
|
57
|
+
by_date[date][:temps] << temp
|
|
58
|
+
# Capture midday (12:00) conditions for the symbol
|
|
59
|
+
if hour == 12
|
|
60
|
+
by_date[date][:midday_temp] = temp
|
|
61
|
+
by_date[date][:wind] = det['wind_speed'].to_f.round(1)
|
|
62
|
+
by_date[date][:cloud] = det['cloud_area_fraction'].to_i
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
forecast = {}
|
|
67
|
+
by_date.each do |date, data|
|
|
68
|
+
cloud = data[:cloud]
|
|
69
|
+
symbol = if cloud < 15
|
|
70
|
+
SYMBOLS[0]
|
|
71
|
+
elsif cloud < 40
|
|
72
|
+
SYMBOLS[1]
|
|
73
|
+
elsif cloud < 70
|
|
74
|
+
SYMBOLS[2]
|
|
75
|
+
else
|
|
76
|
+
SYMBOLS[3]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
temps = data[:temps]
|
|
80
|
+
forecast[date] = {
|
|
81
|
+
'temp_high' => temps.max.round(1),
|
|
82
|
+
'temp_low' => temps.min.round(1),
|
|
83
|
+
'temp_mid' => (data[:midday_temp] || temps[temps.size / 2]).round(1),
|
|
84
|
+
'symbol' => symbol,
|
|
85
|
+
'wind' => data[:wind],
|
|
86
|
+
'cloud' => cloud
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Cache result
|
|
91
|
+
if db
|
|
92
|
+
db.execute(
|
|
93
|
+
"INSERT OR REPLACE INTO weather_cache (date, hour, data, fetched_at) VALUES (?, ?, ?, ?)",
|
|
94
|
+
'forecast', '00', JSON.generate(forecast), Time.now.to_i
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
forecast
|
|
99
|
+
rescue SocketError, Timeout::Error, Net::OpenTimeout, Errno::ECONNREFUSED => e
|
|
100
|
+
{}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Get a short weather string for a date: "☀ 12°"
|
|
104
|
+
def self.short_for_date(forecast, date)
|
|
105
|
+
date_str = date.strftime('%Y-%m-%d')
|
|
106
|
+
w = forecast[date_str]
|
|
107
|
+
return nil unless w
|
|
108
|
+
"#{w['symbol']} #{w['temp_mid']}°"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
data/lib/timely.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Main Timely module
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'sqlite3'
|
|
6
|
+
require 'time'
|
|
7
|
+
require 'date'
|
|
8
|
+
|
|
9
|
+
# Create Timely home directory structure
|
|
10
|
+
TIMELY_HOME = File.expand_path('~/.timely')
|
|
11
|
+
TIMELY_DB = File.join(TIMELY_HOME, 'timely.db')
|
|
12
|
+
TIMELY_CONFIG = File.join(TIMELY_HOME, 'config.yml')
|
|
13
|
+
TIMELY_LOGS = File.join(TIMELY_HOME, 'logs')
|
|
14
|
+
TIMELY_CACHE = File.join(TIMELY_HOME, 'cache')
|
|
15
|
+
|
|
16
|
+
# Create directory structure
|
|
17
|
+
[TIMELY_HOME, TIMELY_LOGS, TIMELY_CACHE].each do |dir|
|
|
18
|
+
FileUtils.mkdir_p(dir)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Load all components
|
|
22
|
+
require_relative 'timely/version'
|
|
23
|
+
|
|
24
|
+
# Core infrastructure
|
|
25
|
+
require_relative 'timely/database'
|
|
26
|
+
require_relative 'timely/config'
|
|
27
|
+
require_relative 'timely/event'
|
|
28
|
+
require_relative 'timely/astronomy'
|
|
29
|
+
require_relative 'timely/weather'
|
|
30
|
+
|
|
31
|
+
# Sources and sync
|
|
32
|
+
require_relative 'timely/sources/ics_file'
|
|
33
|
+
require_relative 'timely/sources/google'
|
|
34
|
+
require_relative 'timely/sources/outlook'
|
|
35
|
+
require_relative 'timely/sync/poller'
|
|
36
|
+
require_relative 'timely/notifications'
|
|
37
|
+
|
|
38
|
+
# UI components
|
|
39
|
+
require_relative 'timely/ui/panes'
|
|
40
|
+
require_relative 'timely/ui/views/month'
|
|
41
|
+
require_relative 'timely/ui/application'
|
|
42
|
+
|
|
43
|
+
module Timely
|
|
44
|
+
class Error < StandardError; end
|
|
45
|
+
end
|
data/timely.gemspec
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require_relative 'lib/timely/version'
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = 'timely-calendar'
|
|
5
|
+
spec.version = Timely::VERSION
|
|
6
|
+
spec.authors = ['Geir Isene', 'Claude Code']
|
|
7
|
+
spec.email = ['g@isene.com']
|
|
8
|
+
|
|
9
|
+
spec.summary = 'Terminal Calendar - companion to Heathrow'
|
|
10
|
+
spec.description = 'A unified TUI calendar with Google Calendar and Outlook/365 integration, moon phases, weather, astronomy, desktop notifications, and Heathrow messaging handoff. Built on rcurses.'
|
|
11
|
+
spec.homepage = 'https://github.com/isene/timely'
|
|
12
|
+
spec.license = 'Unlicense'
|
|
13
|
+
|
|
14
|
+
spec.required_ruby_version = '>= 2.7.0'
|
|
15
|
+
|
|
16
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
17
|
+
spec.metadata['source_code_uri'] = 'https://github.com/isene/timely'
|
|
18
|
+
|
|
19
|
+
# Specify which files should be added to the gem
|
|
20
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
21
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
22
|
+
f.match(%r{\A(?:test|spec|features)/})
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
spec.bindir = 'bin'
|
|
27
|
+
spec.executables = ['timely']
|
|
28
|
+
spec.require_paths = ['lib']
|
|
29
|
+
|
|
30
|
+
# Runtime dependencies
|
|
31
|
+
spec.add_runtime_dependency 'rcurses', '>= 5.0'
|
|
32
|
+
spec.add_runtime_dependency 'sqlite3', '>= 1.4'
|
|
33
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: timely-calendar
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Geir Isene
|
|
8
|
+
- Claude Code
|
|
9
|
+
autorequire:
|
|
10
|
+
bindir: bin
|
|
11
|
+
cert_chain: []
|
|
12
|
+
date: 2026-03-23 00:00:00.000000000 Z
|
|
13
|
+
dependencies:
|
|
14
|
+
- !ruby/object:Gem::Dependency
|
|
15
|
+
name: rcurses
|
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
|
17
|
+
requirements:
|
|
18
|
+
- - ">="
|
|
19
|
+
- !ruby/object:Gem::Version
|
|
20
|
+
version: '5.0'
|
|
21
|
+
type: :runtime
|
|
22
|
+
prerelease: false
|
|
23
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
24
|
+
requirements:
|
|
25
|
+
- - ">="
|
|
26
|
+
- !ruby/object:Gem::Version
|
|
27
|
+
version: '5.0'
|
|
28
|
+
- !ruby/object:Gem::Dependency
|
|
29
|
+
name: sqlite3
|
|
30
|
+
requirement: !ruby/object:Gem::Requirement
|
|
31
|
+
requirements:
|
|
32
|
+
- - ">="
|
|
33
|
+
- !ruby/object:Gem::Version
|
|
34
|
+
version: '1.4'
|
|
35
|
+
type: :runtime
|
|
36
|
+
prerelease: false
|
|
37
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
38
|
+
requirements:
|
|
39
|
+
- - ">="
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '1.4'
|
|
42
|
+
description: A unified TUI calendar with Google Calendar and Outlook/365 integration,
|
|
43
|
+
moon phases, weather, astronomy, desktop notifications, and Heathrow messaging handoff.
|
|
44
|
+
Built on rcurses.
|
|
45
|
+
email:
|
|
46
|
+
- g@isene.com
|
|
47
|
+
executables:
|
|
48
|
+
- timely
|
|
49
|
+
extensions: []
|
|
50
|
+
extra_rdoc_files: []
|
|
51
|
+
files:
|
|
52
|
+
- ".gitignore"
|
|
53
|
+
- CLAUDE.md
|
|
54
|
+
- README.md
|
|
55
|
+
- bin/timely
|
|
56
|
+
- img/timely.svg
|
|
57
|
+
- lib/timely.rb
|
|
58
|
+
- lib/timely/astronomy.rb
|
|
59
|
+
- lib/timely/config.rb
|
|
60
|
+
- lib/timely/database.rb
|
|
61
|
+
- lib/timely/event.rb
|
|
62
|
+
- lib/timely/notifications.rb
|
|
63
|
+
- lib/timely/sources/google.rb
|
|
64
|
+
- lib/timely/sources/ics_file.rb
|
|
65
|
+
- lib/timely/sources/outlook.rb
|
|
66
|
+
- lib/timely/sync/poller.rb
|
|
67
|
+
- lib/timely/ui/application.rb
|
|
68
|
+
- lib/timely/ui/panes.rb
|
|
69
|
+
- lib/timely/ui/views/month.rb
|
|
70
|
+
- lib/timely/version.rb
|
|
71
|
+
- lib/timely/weather.rb
|
|
72
|
+
- timely.gemspec
|
|
73
|
+
homepage: https://github.com/isene/timely
|
|
74
|
+
licenses:
|
|
75
|
+
- Unlicense
|
|
76
|
+
metadata:
|
|
77
|
+
homepage_uri: https://github.com/isene/timely
|
|
78
|
+
source_code_uri: https://github.com/isene/timely
|
|
79
|
+
post_install_message:
|
|
80
|
+
rdoc_options: []
|
|
81
|
+
require_paths:
|
|
82
|
+
- lib
|
|
83
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: 2.7.0
|
|
88
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '0'
|
|
93
|
+
requirements: []
|
|
94
|
+
rubygems_version: 3.4.20
|
|
95
|
+
signing_key:
|
|
96
|
+
specification_version: 4
|
|
97
|
+
summary: Terminal Calendar - companion to Heathrow
|
|
98
|
+
test_files: []
|