jw_calendar 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/CHANGELOG.md +16 -0
- data/CODE_OF_CONDUCT.md +17 -0
- data/CONTRIBUTING.md +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +214 -0
- data/SECURITY.md +11 -0
- data/docs/architecture.md +26 -0
- data/docs/civil-dates.md +14 -0
- data/docs/cli.md +14 -0
- data/docs/correctness.md +7 -0
- data/docs/gregorian.md +9 -0
- data/docs/iso-week.md +12 -0
- data/docs/julian-day-number.md +9 -0
- data/docs/julian.md +13 -0
- data/docs/month-grid.md +7 -0
- data/docs/reform-calendars.md +14 -0
- data/exe/jwcalendar +8 -0
- data/lib/jw_calendar/arithmetic/floor_division.rb +19 -0
- data/lib/jw_calendar/boundary/analyzer.rb +147 -0
- data/lib/jw_calendar/boundary/report.rb +28 -0
- data/lib/jw_calendar/calendars/gregorian.rb +114 -0
- data/lib/jw_calendar/calendars/julian.rb +90 -0
- data/lib/jw_calendar/calendars/reform_calendar.rb +104 -0
- data/lib/jw_calendar/civil_date.rb +135 -0
- data/lib/jw_calendar/cli/runner.rb +196 -0
- data/lib/jw_calendar/conversion/calendar_converter.rb +22 -0
- data/lib/jw_calendar/conversion/julian_day_number.rb +75 -0
- data/lib/jw_calendar/conversion/ordinal_date.rb +52 -0
- data/lib/jw_calendar/errors.rb +9 -0
- data/lib/jw_calendar/formatting/iso8601.rb +20 -0
- data/lib/jw_calendar/grid/cell.rb +26 -0
- data/lib/jw_calendar/grid/month_grid.rb +114 -0
- data/lib/jw_calendar/iso/week_date.rb +80 -0
- data/lib/jw_calendar/range/date_range.rb +83 -0
- data/lib/jw_calendar/version.rb +5 -0
- data/lib/jw_calendar.rb +23 -0
- metadata +86 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JWCalendar
|
|
4
|
+
module Boundary
|
|
5
|
+
# Immutable collection of machine-readable boundary events.
|
|
6
|
+
class Report
|
|
7
|
+
include Enumerable
|
|
8
|
+
attr_reader :events
|
|
9
|
+
|
|
10
|
+
def initialize(events)
|
|
11
|
+
@events = events.map(&:freeze).freeze
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def each(&)
|
|
16
|
+
events.each(&)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_a
|
|
20
|
+
events
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
{ events: }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JWCalendar
|
|
4
|
+
module Calendars
|
|
5
|
+
# Proleptic Gregorian arithmetic using an integer Julian Day Number (JDN).
|
|
6
|
+
# The supported civil year domain begins at 1 CE.
|
|
7
|
+
module Gregorian
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
MONTH_LENGTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
|
|
11
|
+
|
|
12
|
+
# Whether the Gregorian year has 366 days.
|
|
13
|
+
def leap_year?(year)
|
|
14
|
+
valid_year!(year)
|
|
15
|
+
(year % 4).zero? && (!(year % 100).zero? || (year % 400).zero?)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Number of days in the requested Gregorian month.
|
|
19
|
+
def days_in_month(year, month)
|
|
20
|
+
valid_year!(year)
|
|
21
|
+
valid_month!(month)
|
|
22
|
+
return 29 if month == 2 && leap_year?(year)
|
|
23
|
+
|
|
24
|
+
MONTH_LENGTHS.fetch(month - 1)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Number of days in a validated positive Gregorian year.
|
|
28
|
+
def days_in_year(year)
|
|
29
|
+
leap_year?(year) ? 366 : 365
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Whether the components form a valid Gregorian date.
|
|
33
|
+
def valid_date?(year, month, day)
|
|
34
|
+
return false unless year.is_a?(Integer) && year.positive?
|
|
35
|
+
return false unless month.is_a?(Integer) && month.between?(1, 12)
|
|
36
|
+
return false unless day.is_a?(Integer) && day.positive?
|
|
37
|
+
|
|
38
|
+
day <= days_in_month(year, month)
|
|
39
|
+
rescue ArgumentError
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Raise {InvalidDateError} unless the components form a valid date.
|
|
44
|
+
def validate_date!(year, month, day)
|
|
45
|
+
return true if valid_date?(year, month, day)
|
|
46
|
+
|
|
47
|
+
raise InvalidDateError, "invalid Gregorian date: #{format_components(year, month, day)}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Convert a Gregorian date to its integer JDN at astronomical noon.
|
|
51
|
+
# Convert Gregorian components to an integer JDN.
|
|
52
|
+
def to_jdn(year, month, day)
|
|
53
|
+
validate_date!(year, month, day)
|
|
54
|
+
a = Arithmetic::FloorDivision.div(14 - month, 12)
|
|
55
|
+
y = year + 4_800 - a
|
|
56
|
+
m = month + (12 * a) - 3
|
|
57
|
+
day + Arithmetic::FloorDivision.div((153 * m) + 2, 5) + (365 * y) +
|
|
58
|
+
Arithmetic::FloorDivision.div(y, 4) - Arithmetic::FloorDivision.div(y, 100) +
|
|
59
|
+
Arithmetic::FloorDivision.div(y, 400) - 32_045
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Convert an integer JDN to `[year, month, day]` Gregorian components.
|
|
63
|
+
def from_jdn(jdn)
|
|
64
|
+
integer_jdn!(jdn)
|
|
65
|
+
a = jdn + 32_044
|
|
66
|
+
b = Arithmetic::FloorDivision.div((4 * a) + 3, 146_097)
|
|
67
|
+
c = a - Arithmetic::FloorDivision.div(146_097 * b, 4)
|
|
68
|
+
d = Arithmetic::FloorDivision.div((4 * c) + 3, 1_461)
|
|
69
|
+
e = c - Arithmetic::FloorDivision.div(1_461 * d, 4)
|
|
70
|
+
m = Arithmetic::FloorDivision.div((5 * e) + 2, 153)
|
|
71
|
+
day = e - Arithmetic::FloorDivision.div((153 * m) + 2, 5) + 1
|
|
72
|
+
month = m + 3 - (12 * Arithmetic::FloorDivision.div(m, 10))
|
|
73
|
+
year = (100 * b) + d - 4_800 + Arithmetic::FloorDivision.div(m, 10)
|
|
74
|
+
raise InvalidDateError, "JDN is earlier than the supported Gregorian domain" unless year.positive?
|
|
75
|
+
|
|
76
|
+
[year, month, day]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Return the one-based ordinal day for a valid Gregorian date.
|
|
80
|
+
def day_of_year(year, month, day)
|
|
81
|
+
validate_date!(year, month, day)
|
|
82
|
+
prior_month_days = (1...month).inject(0) { |total, m| total + days_in_month(year, m) }
|
|
83
|
+
prior_month_days + day
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def valid_year!(year)
|
|
87
|
+
return if year.is_a?(Integer) && year.positive?
|
|
88
|
+
|
|
89
|
+
raise ArgumentError,
|
|
90
|
+
"year must be a positive Integer (1 CE or later)"
|
|
91
|
+
end
|
|
92
|
+
private_class_method :valid_year!
|
|
93
|
+
|
|
94
|
+
def valid_month!(month)
|
|
95
|
+
return if month.is_a?(Integer) && month.between?(1, 12)
|
|
96
|
+
|
|
97
|
+
raise ArgumentError, "month must be an Integer in 1..12"
|
|
98
|
+
end
|
|
99
|
+
private_class_method :valid_month!
|
|
100
|
+
|
|
101
|
+
def integer_jdn!(jdn)
|
|
102
|
+
return if jdn.is_a?(Integer)
|
|
103
|
+
|
|
104
|
+
raise ArgumentError, "JDN must be an Integer"
|
|
105
|
+
end
|
|
106
|
+
private_class_method :integer_jdn!
|
|
107
|
+
|
|
108
|
+
def format_components(year, month, day)
|
|
109
|
+
[year, month, day].map(&:to_s).join("-")
|
|
110
|
+
end
|
|
111
|
+
private_class_method :format_components
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JWCalendar
|
|
4
|
+
module Calendars
|
|
5
|
+
# Julian civil-calendar arithmetic. This is distinct from a Julian Day Number.
|
|
6
|
+
module Julian
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
MONTH_LENGTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
|
|
10
|
+
|
|
11
|
+
# Julian leap years occur every four years.
|
|
12
|
+
def leap_year?(year)
|
|
13
|
+
valid_year!(year)
|
|
14
|
+
(year % 4).zero?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Number of days in the requested Julian month.
|
|
18
|
+
def days_in_month(year, month)
|
|
19
|
+
valid_year!(year)
|
|
20
|
+
valid_month!(month)
|
|
21
|
+
return 29 if month == 2 && leap_year?(year)
|
|
22
|
+
|
|
23
|
+
MONTH_LENGTHS.fetch(month - 1)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def days_in_year(year)
|
|
27
|
+
leap_year?(year) ? 366 : 365
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Whether the components form a valid Julian date.
|
|
31
|
+
def valid_date?(year, month, day)
|
|
32
|
+
return false unless year.is_a?(Integer) && year.positive?
|
|
33
|
+
return false unless month.is_a?(Integer) && month.between?(1, 12)
|
|
34
|
+
return false unless day.is_a?(Integer) && day.positive?
|
|
35
|
+
|
|
36
|
+
day <= days_in_month(year, month)
|
|
37
|
+
rescue ArgumentError
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Raise {InvalidDateError} unless the components form a valid date.
|
|
42
|
+
def validate_date!(year, month, day)
|
|
43
|
+
return true if valid_date?(year, month, day)
|
|
44
|
+
|
|
45
|
+
raise InvalidDateError, "invalid Julian calendar date: #{[year, month, day].join('-')}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Convert Julian components to an integer JDN.
|
|
49
|
+
def to_jdn(year, month, day)
|
|
50
|
+
validate_date!(year, month, day)
|
|
51
|
+
a = Arithmetic::FloorDivision.div(14 - month, 12)
|
|
52
|
+
y = year + 4_800 - a
|
|
53
|
+
m = month + (12 * a) - 3
|
|
54
|
+
day + Arithmetic::FloorDivision.div((153 * m) + 2, 5) + (365 * y) +
|
|
55
|
+
Arithmetic::FloorDivision.div(y, 4) - 32_083
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Convert an integer JDN to `[year, month, day]` Julian components.
|
|
59
|
+
def from_jdn(jdn)
|
|
60
|
+
raise ArgumentError, "JDN must be an Integer" unless jdn.is_a?(Integer)
|
|
61
|
+
|
|
62
|
+
c = jdn + 32_082
|
|
63
|
+
d = Arithmetic::FloorDivision.div((4 * c) + 3, 1_461)
|
|
64
|
+
e = c - Arithmetic::FloorDivision.div(1_461 * d, 4)
|
|
65
|
+
m = Arithmetic::FloorDivision.div((5 * e) + 2, 153)
|
|
66
|
+
day = e - Arithmetic::FloorDivision.div((153 * m) + 2, 5) + 1
|
|
67
|
+
month = m + 3 - (12 * Arithmetic::FloorDivision.div(m, 10))
|
|
68
|
+
year = d - 4_800 + Arithmetic::FloorDivision.div(m, 10)
|
|
69
|
+
raise InvalidDateError, "JDN is earlier than the supported Julian domain" unless year.positive?
|
|
70
|
+
|
|
71
|
+
[year, month, day]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def valid_year!(year)
|
|
75
|
+
return if year.is_a?(Integer) && year.positive?
|
|
76
|
+
|
|
77
|
+
raise ArgumentError,
|
|
78
|
+
"year must be a positive Integer (1 CE or later)"
|
|
79
|
+
end
|
|
80
|
+
private_class_method :valid_year!
|
|
81
|
+
|
|
82
|
+
def valid_month!(month)
|
|
83
|
+
return if month.is_a?(Integer) && month.between?(1, 12)
|
|
84
|
+
|
|
85
|
+
raise ArgumentError, "month must be an Integer in 1..12"
|
|
86
|
+
end
|
|
87
|
+
private_class_method :valid_month!
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JWCalendar
|
|
4
|
+
module Calendars
|
|
5
|
+
# A local civil calendar switching from Julian to Gregorian labels on a
|
|
6
|
+
# caller-supplied pair of consecutive absolute days.
|
|
7
|
+
class ReformCalendar
|
|
8
|
+
attr_reader :last_julian_date, :first_gregorian_date
|
|
9
|
+
|
|
10
|
+
def self.papal
|
|
11
|
+
new(last_julian_date: CivilDate.julian(1582, 10, 4),
|
|
12
|
+
first_gregorian_date: CivilDate.gregorian(1582, 10, 15))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.british_empire
|
|
16
|
+
new(last_julian_date: CivilDate.julian(1752, 9, 2),
|
|
17
|
+
first_gregorian_date: CivilDate.gregorian(1752, 9, 14))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(last_julian_date:, first_gregorian_date:)
|
|
21
|
+
@last_julian_date = coerce_date(last_julian_date, :julian)
|
|
22
|
+
@first_gregorian_date = coerce_date(first_gregorian_date, :gregorian)
|
|
23
|
+
unless (label(@last_julian_date) <=> label(@first_gregorian_date)) == -1
|
|
24
|
+
raise ArgumentError, "last Julian label must precede first Gregorian label"
|
|
25
|
+
end
|
|
26
|
+
unless @first_gregorian_date.to_jdn == @last_julian_date.to_jdn + 1
|
|
27
|
+
raise ArgumentError, "cutover dates must be consecutive absolute days"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def valid_date?(year, month, day)
|
|
34
|
+
return false unless [year, month, day].all? { |part| part.is_a?(Integer) }
|
|
35
|
+
|
|
36
|
+
calendar_for_label(year, month, day)
|
|
37
|
+
true
|
|
38
|
+
rescue InvalidDateError, ReformGapError, ArgumentError
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def date(year, month, day)
|
|
43
|
+
calendar = calendar_for_label(year, month, day)
|
|
44
|
+
CivilDate.new(year, month, day, calendar:)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_jdn(year, month, day)
|
|
48
|
+
date(year, month, day).to_jdn
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def from_jdn(jdn)
|
|
52
|
+
raise ArgumentError, "JDN must be an Integer" unless jdn.is_a?(Integer)
|
|
53
|
+
|
|
54
|
+
return CivilDate.from_jdn(jdn, calendar: :julian) if jdn <= last_julian_date.to_jdn
|
|
55
|
+
|
|
56
|
+
CivilDate.from_jdn(jdn, calendar: :gregorian)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def skipped_labels
|
|
60
|
+
start_label = last_julian_date
|
|
61
|
+
end_label = first_gregorian_date
|
|
62
|
+
labels = []
|
|
63
|
+
cursor = start_label
|
|
64
|
+
loop do
|
|
65
|
+
cursor = cursor.next_day
|
|
66
|
+
break if (label(cursor) <=> label(end_label)) >= 0
|
|
67
|
+
|
|
68
|
+
labels << cursor.to_s
|
|
69
|
+
end
|
|
70
|
+
labels.freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def coerce_date(value, calendar)
|
|
76
|
+
return value if value.is_a?(CivilDate) && value.calendar == calendar
|
|
77
|
+
return CivilDate.new(*value, calendar:) if value.is_a?(Array) && value.length == 3
|
|
78
|
+
|
|
79
|
+
raise ArgumentError, "cutover date must be a #{calendar} CivilDate or [year, month, day]"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def calendar_for_label(year, month, day)
|
|
83
|
+
unless [year, month, day].all? { |part| part.is_a?(Integer) }
|
|
84
|
+
raise InvalidDateError, "year, month, and day must be integers"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
tuple = [year, month, day]
|
|
88
|
+
if (tuple <=> label(last_julian_date)) <= 0
|
|
89
|
+
Julian.validate_date!(year, month, day)
|
|
90
|
+
:julian
|
|
91
|
+
elsif (tuple <=> label(first_gregorian_date)) >= 0
|
|
92
|
+
Gregorian.validate_date!(year, month, day)
|
|
93
|
+
:gregorian
|
|
94
|
+
else
|
|
95
|
+
raise ReformGapError, "#{year}-#{month}-#{day} was skipped by this calendar reform"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def label(date)
|
|
100
|
+
[date.year, date.month, date.day]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JWCalendar
|
|
4
|
+
# Immutable civil date. It is a calendar label, not an instant or a UTC midnight.
|
|
5
|
+
# Years start at 1 CE; supported calendars are proleptic Gregorian and Julian.
|
|
6
|
+
class CivilDate
|
|
7
|
+
include Comparable
|
|
8
|
+
|
|
9
|
+
CALENDARS = %i[gregorian julian].freeze
|
|
10
|
+
attr_reader :year, :month, :day, :calendar
|
|
11
|
+
|
|
12
|
+
# Build a validated date label.
|
|
13
|
+
# @param year [Integer] positive civil year
|
|
14
|
+
# @param month [Integer] month from 1 to 12
|
|
15
|
+
# @param day [Integer] valid day in the selected month
|
|
16
|
+
# @param calendar [Symbol] `:gregorian` or `:julian`
|
|
17
|
+
def initialize(year, month, day, calendar: :gregorian)
|
|
18
|
+
raise InvalidCalendarError, "calendar must be :gregorian or :julian" unless CALENDARS.include?(calendar)
|
|
19
|
+
|
|
20
|
+
validator = calendar == :gregorian ? Calendars::Gregorian : Calendars::Julian
|
|
21
|
+
validator.validate_date!(year, month, day)
|
|
22
|
+
@year = year
|
|
23
|
+
@month = month
|
|
24
|
+
@day = day
|
|
25
|
+
@calendar = calendar
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Create a proleptic Gregorian date.
|
|
30
|
+
def self.gregorian(year, month, day)
|
|
31
|
+
new(year, month, day, calendar: :gregorian)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Create a proleptic Julian date.
|
|
35
|
+
def self.julian(year, month, day)
|
|
36
|
+
new(year, month, day, calendar: :julian)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Parse an extended `YYYY-MM-DD` civil date.
|
|
40
|
+
def self.parse(value, calendar: :gregorian)
|
|
41
|
+
match = /\A(\d{4,})-(\d{2})-(\d{2})\z/.match(value.to_s)
|
|
42
|
+
raise InvalidDateError, "expected YYYY-MM-DD, got #{value.inspect}" unless match
|
|
43
|
+
|
|
44
|
+
new(match[1].to_i, match[2].to_i, match[3].to_i, calendar:)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Compare by absolute day, independent of the calendar label.
|
|
48
|
+
def <=>(other)
|
|
49
|
+
return nil unless other.is_a?(CivilDate)
|
|
50
|
+
|
|
51
|
+
to_jdn <=> other.to_jdn
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Equality includes the year, month, day, and calendar system.
|
|
55
|
+
def ==(other)
|
|
56
|
+
other.is_a?(CivilDate) && [year, month, day, calendar] == [other.year, other.month, other.day, other.calendar]
|
|
57
|
+
end
|
|
58
|
+
alias eql? ==
|
|
59
|
+
|
|
60
|
+
def hash
|
|
61
|
+
[year, month, day, calendar].hash
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Return this date's integer Julian Day Number.
|
|
65
|
+
def to_jdn
|
|
66
|
+
converter = calendar == :gregorian ? Calendars::Gregorian : Calendars::Julian
|
|
67
|
+
converter.to_jdn(year, month, day)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Return ISO weekday number, Monday=1 through Sunday=7.
|
|
71
|
+
def weekday
|
|
72
|
+
(to_jdn % 7) + 1 # ISO weekday: Monday=1 … Sunday=7.
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def weekday_name
|
|
76
|
+
ISO::WeekDate::WEEKDAY_NAMES.fetch(weekday - 1)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Return the one-based day of year in this date's calendar.
|
|
80
|
+
def ordinal_day
|
|
81
|
+
first_day = calendar == :gregorian ? Calendars::Gregorian : Calendars::Julian
|
|
82
|
+
to_jdn - first_day.to_jdn(year, 1, 1) + 1
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Return a new date a fixed number of civil days from this date.
|
|
86
|
+
def add_days(amount)
|
|
87
|
+
raise ArgumentError, "amount must be an Integer" unless amount.is_a?(Integer)
|
|
88
|
+
|
|
89
|
+
self.class.from_jdn(to_jdn + amount, calendar:)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def subtract_days(amount)
|
|
93
|
+
add_days(-amount)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def next_day
|
|
97
|
+
add_days(1)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def previous_day
|
|
101
|
+
add_days(-1)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def days_until(other)
|
|
105
|
+
raise ArgumentError, "other must be a CivilDate" unless other.is_a?(CivilDate)
|
|
106
|
+
|
|
107
|
+
other.to_jdn - to_jdn
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Return the ISO week date for this absolute day.
|
|
111
|
+
def iso_week
|
|
112
|
+
ISO::WeekDate.for(self)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def to_s
|
|
116
|
+
format("%<year>04d-%<month>02d-%<day>02d", year:, month:, day:)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def to_iso8601
|
|
120
|
+
to_s
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def inspect
|
|
124
|
+
"#<#{self.class} #{self} calendar=#{calendar}>"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Convert an integer JDN to a date label in the selected calendar.
|
|
128
|
+
def self.from_jdn(jdn, calendar: :gregorian)
|
|
129
|
+
raise InvalidCalendarError, "calendar must be :gregorian or :julian" unless CALENDARS.include?(calendar)
|
|
130
|
+
|
|
131
|
+
converter = calendar == :gregorian ? Calendars::Gregorian : Calendars::Julian
|
|
132
|
+
new(*converter.from_jdn(jdn), calendar:)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
module JWCalendar
|
|
7
|
+
module CLI
|
|
8
|
+
# Standard-library command-line interface used by the jwcalendar executable.
|
|
9
|
+
class Runner
|
|
10
|
+
def initialize(stdout: $stdout, stderr: $stderr)
|
|
11
|
+
@stdout = stdout
|
|
12
|
+
@stderr = stderr
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run(argv)
|
|
16
|
+
args = argv.dup
|
|
17
|
+
command = args.shift
|
|
18
|
+
return help if command.nil? || %w[-h --help help].include?(command)
|
|
19
|
+
return version if %w[-v --version version].include?(command)
|
|
20
|
+
|
|
21
|
+
case command
|
|
22
|
+
when "inspect" then inspect_date(args)
|
|
23
|
+
when "iso-week" then iso_week(args)
|
|
24
|
+
when "jdn" then jdn(args)
|
|
25
|
+
when "convert" then convert(args)
|
|
26
|
+
when "grid" then grid(args)
|
|
27
|
+
when "boundary" then boundary(args)
|
|
28
|
+
else
|
|
29
|
+
raise ArgumentError, "unknown command #{command.inspect}; run jwcalendar --help"
|
|
30
|
+
end
|
|
31
|
+
rescue OptionParser::ParseError, ArgumentError, Error => e
|
|
32
|
+
@stderr.puts("jwcalendar: #{e.message}")
|
|
33
|
+
2
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def inspect_date(args)
|
|
39
|
+
options = { json: false, calendar: :gregorian }
|
|
40
|
+
parser = OptionParser.new do |opts|
|
|
41
|
+
opts.on("--json") { options[:json] = true }
|
|
42
|
+
opts.on("--calendar NAME", %w[gregorian julian]) { |value| options[:calendar] = value.to_sym }
|
|
43
|
+
end
|
|
44
|
+
parser.parse!(args)
|
|
45
|
+
date = parse_single_date(args, options[:calendar])
|
|
46
|
+
iso = date.iso_week
|
|
47
|
+
output = {
|
|
48
|
+
date: date.to_s, calendar: date.calendar, weekday: date.weekday_name,
|
|
49
|
+
weekday_number: date.weekday, ordinal_day: date.ordinal_day,
|
|
50
|
+
jdn: date.to_jdn, iso_week_year: iso.week_year,
|
|
51
|
+
iso_week: iso.week, iso_weekday: iso.weekday
|
|
52
|
+
}
|
|
53
|
+
if options[:json]
|
|
54
|
+
@stdout.puts(JSON.pretty_generate(output))
|
|
55
|
+
else
|
|
56
|
+
output.each { |key, value| @stdout.puts("#{key.to_s.tr('_', ' ')}: #{value}") }
|
|
57
|
+
end
|
|
58
|
+
0
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def iso_week(args)
|
|
62
|
+
json = false
|
|
63
|
+
calendar = :gregorian
|
|
64
|
+
parser = OptionParser.new do |opts|
|
|
65
|
+
opts.on("--json") { json = true }
|
|
66
|
+
opts.on("--calendar NAME", %w[gregorian julian]) { |value| calendar = value.to_sym }
|
|
67
|
+
end
|
|
68
|
+
parser.parse!(args)
|
|
69
|
+
date = parse_single_date(args, calendar)
|
|
70
|
+
week = date.iso_week
|
|
71
|
+
output = { date: date.to_s, week_year: week.week_year, week: week.week,
|
|
72
|
+
weekday: week.weekday, weekday_name: week.weekday_name,
|
|
73
|
+
iso_date: week.to_s }
|
|
74
|
+
@stdout.puts(json ? JSON.pretty_generate(output) : "#{week} (#{week.weekday_name})")
|
|
75
|
+
0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def jdn(args)
|
|
79
|
+
json = false
|
|
80
|
+
calendar = :gregorian
|
|
81
|
+
parser = OptionParser.new do |opts|
|
|
82
|
+
opts.on("--json") { json = true }
|
|
83
|
+
opts.on("--calendar NAME", %w[gregorian julian]) { |value| calendar = value.to_sym }
|
|
84
|
+
end
|
|
85
|
+
parser.parse!(args)
|
|
86
|
+
date = parse_single_date(args, calendar)
|
|
87
|
+
result = { date: date.to_s, calendar:, jdn: date.to_jdn,
|
|
88
|
+
jd_at_midnight: Conversion::JulianDayNumber.jd(date).to_s,
|
|
89
|
+
mjd_at_midnight: Conversion::JulianDayNumber.mjd(date).to_s }
|
|
90
|
+
@stdout.puts(json ? JSON.pretty_generate(result) : result.map { |key, value| "#{key}: #{value}" }.join("\n"))
|
|
91
|
+
0
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def convert(args)
|
|
95
|
+
options = { from: :gregorian, to: :julian, json: false }
|
|
96
|
+
parser = OptionParser.new do |opts|
|
|
97
|
+
opts.on("--from NAME", %w[gregorian julian]) { |value| options[:from] = value.to_sym }
|
|
98
|
+
opts.on("--to NAME", %w[gregorian julian]) { |value| options[:to] = value.to_sym }
|
|
99
|
+
opts.on("--json") { options[:json] = true }
|
|
100
|
+
end
|
|
101
|
+
parser.parse!(args)
|
|
102
|
+
date = parse_single_date(args, options[:from])
|
|
103
|
+
converted = Conversion::CalendarConverter.convert(date, to: options[:to])
|
|
104
|
+
result = { input: date.to_s, from: options[:from], result: converted.to_s, to: options[:to], jdn: date.to_jdn }
|
|
105
|
+
human_result = "#{date} (#{options[:from]}) = #{converted} (#{options[:to]})"
|
|
106
|
+
@stdout.puts(options[:json] ? JSON.pretty_generate(result) : human_result)
|
|
107
|
+
0
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def grid(args)
|
|
111
|
+
options = { week_start: :monday, fixed_weeks: nil, json: false, adjacent: true }
|
|
112
|
+
parser = OptionParser.new do |opts|
|
|
113
|
+
opts.on("--week-start DAY", %w[monday sunday]) { |value| options[:week_start] = value.to_sym }
|
|
114
|
+
opts.on("--fixed-weeks N", Integer) { |value| options[:fixed_weeks] = value }
|
|
115
|
+
opts.on("--no-adjacent") { options[:adjacent] = false }
|
|
116
|
+
opts.on("--json") { options[:json] = true }
|
|
117
|
+
end
|
|
118
|
+
parser.parse!(args)
|
|
119
|
+
unless args.length == 1 && (match = /\A(\d{4,})-(\d{2})\z/.match(args.first))
|
|
120
|
+
raise ArgumentError,
|
|
121
|
+
"expected one YYYY-MM month"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
layout = Grid::MonthGrid.new(year: match[1].to_i, month: match[2].to_i,
|
|
125
|
+
week_start: options[:week_start], fixed_weeks: options[:fixed_weeks],
|
|
126
|
+
include_adjacent: options[:adjacent])
|
|
127
|
+
@stdout.puts(options[:json] ? JSON.pretty_generate(layout.to_h) : render_grid(layout))
|
|
128
|
+
0
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def boundary(args)
|
|
132
|
+
json = false
|
|
133
|
+
parser = OptionParser.new { |opts| opts.on("--json") { json = true } }
|
|
134
|
+
parser.parse!(args)
|
|
135
|
+
raise ArgumentError, "expected one year" unless args.length == 1 && /\A\d+\z/.match?(args.first)
|
|
136
|
+
|
|
137
|
+
report = Boundary::Analyzer.year(args.first.to_i)
|
|
138
|
+
@stdout.puts(json ? JSON.pretty_generate(report.to_h) : report.map do |event|
|
|
139
|
+
"#{event[:date]} #{event[:type]}"
|
|
140
|
+
end.join("\n"))
|
|
141
|
+
0
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def render_grid(layout)
|
|
145
|
+
title = format("%<year>04d-%<month>02d", year: layout.year, month: layout.month)
|
|
146
|
+
lines = ["#{title} (week starts #{ISO::WeekDate::WEEKDAY_NAMES[layout.week_start - 1]})",
|
|
147
|
+
layout.day_names.map { |name| name[0, 3].rjust(4) }.join]
|
|
148
|
+
layout.rows.each do |row|
|
|
149
|
+
lines << row.map do |cell|
|
|
150
|
+
if cell.date.nil?
|
|
151
|
+
" "
|
|
152
|
+
elsif cell.in_current_month?
|
|
153
|
+
cell.date.day.to_s.rjust(4)
|
|
154
|
+
else
|
|
155
|
+
"(#{cell.date.day})".rjust(4)
|
|
156
|
+
end
|
|
157
|
+
end.join
|
|
158
|
+
end
|
|
159
|
+
lines.join("\n")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def parse_single_date(args, calendar)
|
|
163
|
+
raise ArgumentError, "expected one YYYY-MM-DD date" unless args.length == 1
|
|
164
|
+
|
|
165
|
+
CivilDate.parse(args.first, calendar:)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def version
|
|
169
|
+
@stdout.puts("jwcalendar #{VERSION}")
|
|
170
|
+
0
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def help
|
|
174
|
+
@stdout.puts <<~TEXT
|
|
175
|
+
JW Calendar #{VERSION} — deterministic civil-date computation
|
|
176
|
+
|
|
177
|
+
Usage: jwcalendar COMMAND [OPTIONS]
|
|
178
|
+
|
|
179
|
+
Commands:
|
|
180
|
+
inspect DATE Show date, weekday, ordinal day and ISO week
|
|
181
|
+
iso-week DATE Show the ISO week date
|
|
182
|
+
jdn DATE Show JDN, exact JD and MJD at midnight
|
|
183
|
+
convert DATE Convert between Gregorian and Julian labels
|
|
184
|
+
grid YYYY-MM Print a structured month grid
|
|
185
|
+
boundary YEAR List calendar boundary cases
|
|
186
|
+
|
|
187
|
+
Common options:
|
|
188
|
+
--json Emit machine-readable JSON where supported
|
|
189
|
+
--calendar NAME Select gregorian or julian for date commands
|
|
190
|
+
--help, --version Show help or version
|
|
191
|
+
TEXT
|
|
192
|
+
0
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JWCalendar
|
|
4
|
+
module Conversion
|
|
5
|
+
# Converts labels between the proleptic Gregorian and Julian calendars.
|
|
6
|
+
module CalendarConverter
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Convert a date label while preserving its absolute JDN.
|
|
10
|
+
def convert(date, to:)
|
|
11
|
+
raise ArgumentError, "date must be a CivilDate" unless date.is_a?(CivilDate)
|
|
12
|
+
|
|
13
|
+
unless CivilDate::CALENDARS.include?(to)
|
|
14
|
+
raise InvalidCalendarError,
|
|
15
|
+
"target calendar must be :gregorian or :julian"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
CivilDate.from_jdn(date.to_jdn, calendar: to)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|