nepdate 1.0.0.rc.1
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 +10 -0
- data/LICENSE +21 -0
- data/NOTICE +31 -0
- data/README.md +20 -0
- data/lib/nepdate/calendar.rb +85 -0
- data/lib/nepdate/calendar_table.rb +617 -0
- data/lib/nepdate/date.rb +580 -0
- data/lib/nepdate/date_diff.rb +49 -0
- data/lib/nepdate/date_range.rb +251 -0
- data/lib/nepdate/errors.rb +78 -0
- data/lib/nepdate/fiscal_year.rb +103 -0
- data/lib/nepdate/format.rb +148 -0
- data/lib/nepdate/format_options.rb +64 -0
- data/lib/nepdate/lang.rb +21 -0
- data/lib/nepdate/lenient.rb +175 -0
- data/lib/nepdate/month.rb +84 -0
- data/lib/nepdate/month_overflow.rb +21 -0
- data/lib/nepdate/names.rb +81 -0
- data/lib/nepdate/parse.rb +125 -0
- data/lib/nepdate/quarter.rb +78 -0
- data/lib/nepdate/version.rb +5 -0
- data/lib/nepdate/weekday.rb +85 -0
- data/lib/nepdate.rb +37 -0
- data/sig/nepdate.rbs +435 -0
- metadata +75 -0
data/lib/nepdate/date.rb
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module NepDate
|
|
7
|
+
# A date in the Bikram Sambat calendar, from 1901-01-01 to 2199-12-30 BS (AD 1844-04-11 to
|
|
8
|
+
# 2143-04-15). Immutable, always a real day, and ordered by its serial day.
|
|
9
|
+
#
|
|
10
|
+
# The class is named +NepDate::Date+ to read naturally as +NepDate::Date.parse("2081-04-15")+.
|
|
11
|
+
# The Gregorian type is the standard library's +::Date+; the two never share a namespace.
|
|
12
|
+
class Date
|
|
13
|
+
include Comparable
|
|
14
|
+
|
|
15
|
+
# Nepal's fixed UTC offset, 5 hours 45 minutes, used when no zone is given. Nepal has no
|
|
16
|
+
# daylight saving time, so a fixed offset is the complete rule.
|
|
17
|
+
DEFAULT_OFFSET = 20_700
|
|
18
|
+
DEFAULT_ZONE = "Asia/Kathmandu"
|
|
19
|
+
OFFSET_ALIASES = {
|
|
20
|
+
"Asia/Kathmandu" => 20_700,
|
|
21
|
+
"Asia/Katmandu" => 20_700,
|
|
22
|
+
"NPT" => 20_700,
|
|
23
|
+
"UTC" => 0,
|
|
24
|
+
"GMT" => 0,
|
|
25
|
+
"Z" => 0
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
attr_reader :year, :month, :day, :serial
|
|
29
|
+
|
|
30
|
+
def initialize(year, month, day)
|
|
31
|
+
year = Date.integer_argument(year, "year")
|
|
32
|
+
month = Date.integer_argument(month, "month")
|
|
33
|
+
day = Date.integer_argument(day, "day")
|
|
34
|
+
index = year - CalendarTable::MIN_YEAR
|
|
35
|
+
unless index >= 0 && index < CalendarTable::YEARS
|
|
36
|
+
raise OutOfRangeError, "year #{year} is outside #{CalendarTable::MIN_YEAR}..#{CalendarTable::MAX_YEAR}"
|
|
37
|
+
end
|
|
38
|
+
unless month.between?(1, 12)
|
|
39
|
+
raise InvalidMonthError, "month #{month} is outside 1..12"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
packed = CalendarTable::PACKED.fetch(index)
|
|
43
|
+
length = 29 + ((packed >> (2 * (month - 1))) & 0b11)
|
|
44
|
+
unless day.between?(1, length)
|
|
45
|
+
raise InvalidDayError, "day #{day} is outside 1..#{length}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
initialize_parts(year, month, day, CalendarTable::MONTH_START.fetch(index * 12 + month - 1) + day - 1)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
# Builds a BS date and validates the year, then the month, then the day.
|
|
53
|
+
def from_bs(year, month, day)
|
|
54
|
+
new(year, month, day)
|
|
55
|
+
end
|
|
56
|
+
alias_method :from_ymd_parts, :from_bs
|
|
57
|
+
|
|
58
|
+
# Converts a real AD day. The date part is what converts; a time of day is discarded.
|
|
59
|
+
def from_gregorian(year, month, day)
|
|
60
|
+
year = integer_argument(year, "year")
|
|
61
|
+
month = integer_argument(month, "month")
|
|
62
|
+
day = integer_argument(day, "day")
|
|
63
|
+
|
|
64
|
+
unless Calendar.valid_gregorian?(year, month, day)
|
|
65
|
+
raise InvalidGregorianError, "#{year}-#{month}-#{day} is not a real Gregorian date"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
serial = Calendar.days_from_civil(year, month, day) - CalendarTable::EPOCH_UNIX_DAYS
|
|
69
|
+
unless serial.between?(0, CalendarTable::MAX_SERIAL)
|
|
70
|
+
raise OutOfRangeError, "#{year}-#{month}-#{day} is outside the supported range"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
from_serial(serial)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Converts a standard library +::Date+ or +::DateTime+ by its date part.
|
|
77
|
+
def from_date(value)
|
|
78
|
+
unless value.is_a?(::Date)
|
|
79
|
+
raise ArgumentError, "expected a ::Date, got #{value.class}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# ::Date already validates its fields, so its Julian day is the serial plus a constant.
|
|
83
|
+
from_serial(value.jd - CalendarTable::JULIAN_EPOCH)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Converts a +Time+ in the time's own offset.
|
|
87
|
+
def from_time(value)
|
|
88
|
+
unless value.is_a?(::Time)
|
|
89
|
+
raise ArgumentError, "expected a ::Time, got #{value.class}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
from_gregorian(value.year, value.month, value.day)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The first supported date: 1901-01-01 BS.
|
|
96
|
+
def min
|
|
97
|
+
MIN
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The last supported date: 2199-12-30 BS.
|
|
101
|
+
def max
|
|
102
|
+
MAX
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Today in +zone+. The zone may be an IANA name for Nepal, +"UTC"+, a fixed offset such as
|
|
106
|
+
# +"+05:45"+, an integer number of seconds, or an object that responds to +utc_offset+.
|
|
107
|
+
# Pass +clock:+ to replace +Time.now+; the callable returns a +Time+, +DateTime+ or +Date+.
|
|
108
|
+
def today(zone: DEFAULT_ZONE, clock: nil)
|
|
109
|
+
offset = offset_seconds(zone)
|
|
110
|
+
now = clock ? clock.call : Time.now
|
|
111
|
+
case now
|
|
112
|
+
when ::Time
|
|
113
|
+
local = now.getlocal(offset)
|
|
114
|
+
from_gregorian(local.year, local.month, local.day)
|
|
115
|
+
when ::DateTime
|
|
116
|
+
from_gregorian(now.year, now.month, now.day)
|
|
117
|
+
when ::Date
|
|
118
|
+
from_gregorian(now.year, now.month, now.day)
|
|
119
|
+
else
|
|
120
|
+
raise TypeError, "clock must return a Time, DateTime or Date, got #{now.class}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The strict parser of PARSING.md section 1: year, month, day, ASCII or Devanagari digits.
|
|
125
|
+
def parse(text)
|
|
126
|
+
year, month, day = Parse.strict(text)
|
|
127
|
+
new(year, month, day)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The strict parser without an exception. Returns +nil+ on any NepDate error.
|
|
131
|
+
def try_parse(text)
|
|
132
|
+
parse(text)
|
|
133
|
+
rescue Error
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The deterministic human-input parser of PARSING.md section 2.
|
|
138
|
+
def parse_lenient(text)
|
|
139
|
+
year, month, day = Lenient.parse(text)
|
|
140
|
+
new(year, month, day)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The lenient parser without an exception. Returns +nil+ on any NepDate error.
|
|
144
|
+
def try_parse_lenient(text)
|
|
145
|
+
parse_lenient(text)
|
|
146
|
+
rescue Error
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Reads the +YYYYMMDD+ integer. Values outside the eight-digit form are rejected rather
|
|
151
|
+
# than split or truncated.
|
|
152
|
+
def from_ymd(value)
|
|
153
|
+
value = integer_argument(value, "ymd")
|
|
154
|
+
unless (0..99_999_999).cover?(value)
|
|
155
|
+
raise OutOfRangeError, "#{value} is not a positive eight-digit YYYYMMDD value"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
new(value / 10_000, (value / 100) % 100, value % 100)
|
|
159
|
+
end
|
|
160
|
+
alias_method :from_ymd_int, :from_ymd
|
|
161
|
+
|
|
162
|
+
# Reads the JSON string written by +to_json+: a quoted "YYYY-MM-DD" value. A JSON null or
|
|
163
|
+
# object is rejected, because this port has no zero date (D-01, D-11).
|
|
164
|
+
def from_json(text)
|
|
165
|
+
value = JSON.parse(text)
|
|
166
|
+
unless value.is_a?(String)
|
|
167
|
+
raise ParseError.new(ErrorKind::INVALID_CHARACTER, "the JSON value is not a string")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
parse(value)
|
|
171
|
+
rescue JSON::ParserError
|
|
172
|
+
raise ParseError.new(ErrorKind::INVALID_CHARACTER, "the text is not a JSON string")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Ordered batch conversion from AD dates. Accepts +::Date+, +DateTime+ and +Time+ values
|
|
176
|
+
# and keeps input order (D-11).
|
|
177
|
+
def from_gregorian_batch(dates)
|
|
178
|
+
dates.map do |value|
|
|
179
|
+
case value
|
|
180
|
+
when Date then value
|
|
181
|
+
when ::Time then from_time(value)
|
|
182
|
+
when ::Date then from_date(value)
|
|
183
|
+
else
|
|
184
|
+
raise ArgumentError, "expected a Date, DateTime or Time, got #{value.class}"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Ordered batch conversion from BS text to AD +::Date+ values (D-11).
|
|
190
|
+
def parse_batch(texts)
|
|
191
|
+
texts.map { |text| parse(text).to_date }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Ordered batch conversion from BS dates to AD +::Date+ values (D-11).
|
|
195
|
+
def to_gregorian_batch(dates)
|
|
196
|
+
dates.map(&:to_date)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The first boundary of the proleptic Gregorian calendar supported by this port.
|
|
200
|
+
def min_gregorian
|
|
201
|
+
MIN.to_date
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The last boundary of the proleptic Gregorian calendar supported by this port.
|
|
205
|
+
def max_gregorian
|
|
206
|
+
MAX.to_date
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Matches a fixed offset from a string, an integer or an object with +utc_offset+.
|
|
210
|
+
# @api private
|
|
211
|
+
def offset_seconds(zone)
|
|
212
|
+
offset =
|
|
213
|
+
case zone
|
|
214
|
+
when Integer then zone
|
|
215
|
+
when String
|
|
216
|
+
OFFSET_ALIASES.fetch(zone) do
|
|
217
|
+
match = /\A([+-])(\d{2}):?(\d{2})\z/.match(zone)
|
|
218
|
+
raise ArgumentError, "invalid UTC offset #{zone.inspect}" unless match
|
|
219
|
+
|
|
220
|
+
seconds = match[2].to_i * 3600 + match[3].to_i * 60
|
|
221
|
+
(match[1] == "-") ? -seconds : seconds
|
|
222
|
+
end
|
|
223
|
+
when nil then DEFAULT_OFFSET
|
|
224
|
+
else
|
|
225
|
+
if zone.respond_to?(:utc_offset)
|
|
226
|
+
zone.utc_offset
|
|
227
|
+
else
|
|
228
|
+
raise ArgumentError, "zone must be an offset string, integer or utc_offset object, got #{zone.inspect}"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
unless (-86_400...86_400).cover?(offset)
|
|
233
|
+
raise ArgumentError, "UTC offset #{offset} seconds is outside one day"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
offset
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Builds a date whose parts and serial are already known to be valid. Used by the
|
|
240
|
+
# conversion and arithmetic paths, so they do not validate and recompute a serial twice.
|
|
241
|
+
# @api private
|
|
242
|
+
def from_parts(year, month, day, serial)
|
|
243
|
+
date = allocate
|
|
244
|
+
date.send(:initialize_parts, year, month, day, serial)
|
|
245
|
+
date
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# @api private
|
|
249
|
+
def from_serial(serial)
|
|
250
|
+
unless serial.between?(0, CalendarTable::MAX_SERIAL)
|
|
251
|
+
raise OutOfRangeError, "serial #{serial} is outside 0..#{CalendarTable::MAX_SERIAL}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
index = CalendarTable::BUCKET[serial >> 5]
|
|
255
|
+
index += 1 if serial >= CalendarTable::MONTH_START[index + 1]
|
|
256
|
+
index += 1 if serial >= CalendarTable::MONTH_START[index + 1]
|
|
257
|
+
from_parts(
|
|
258
|
+
CalendarTable::MIN_YEAR + index / 12,
|
|
259
|
+
index % 12 + 1,
|
|
260
|
+
serial - CalendarTable::MONTH_START[index] + 1,
|
|
261
|
+
serial
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Public integer arguments are never coerced from floats or strings, so a truncated value
|
|
266
|
+
# cannot silently become a different date.
|
|
267
|
+
# @api private
|
|
268
|
+
def integer_argument(value, label)
|
|
269
|
+
return value if value.is_a?(Integer)
|
|
270
|
+
|
|
271
|
+
raise TypeError, "#{label} must be an Integer, got #{value.class}"
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# The BS ordinal in this BS year, starting at 1 on Baishakh 1.
|
|
276
|
+
def day_of_year
|
|
277
|
+
serial - CalendarTable::YEAR_START.fetch(year - CalendarTable::MIN_YEAR) + 1
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# The BS year length, 364 to 367 days (D-12), not Gregorian leap-ness.
|
|
281
|
+
def year_length
|
|
282
|
+
index = year - CalendarTable::MIN_YEAR
|
|
283
|
+
CalendarTable::YEAR_START.fetch(index + 1) - CalendarTable::YEAR_START.fetch(index)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# The last valid day of this BS month, 29 to 32.
|
|
287
|
+
def month_length
|
|
288
|
+
Calendar.month_length(year, month)
|
|
289
|
+
end
|
|
290
|
+
alias_method :month_end_day, :month_length
|
|
291
|
+
|
|
292
|
+
def month_enum
|
|
293
|
+
Month.from(month)
|
|
294
|
+
end
|
|
295
|
+
alias_method :month_name, :month_enum
|
|
296
|
+
|
|
297
|
+
def weekday
|
|
298
|
+
Weekday.from((serial + CalendarTable::EPOCH_WEEKDAY) % 7)
|
|
299
|
+
end
|
|
300
|
+
alias_method :day_of_week, :weekday
|
|
301
|
+
|
|
302
|
+
# This year and month on the last day.
|
|
303
|
+
def last_day_of_month
|
|
304
|
+
length = month_length
|
|
305
|
+
Date.from_parts(year, month, length, serial - day + length)
|
|
306
|
+
end
|
|
307
|
+
alias_method :month_end_date, :last_day_of_month
|
|
308
|
+
|
|
309
|
+
def to_date
|
|
310
|
+
civil = Calendar.civil_from_days(serial + CalendarTable::EPOCH_UNIX_DAYS)
|
|
311
|
+
::Date.new(*civil)
|
|
312
|
+
end
|
|
313
|
+
alias_method :english_date, :to_date
|
|
314
|
+
|
|
315
|
+
def to_datetime(zone: DEFAULT_ZONE)
|
|
316
|
+
offset = Date.offset_seconds(zone)
|
|
317
|
+
::DateTime.new(year, month, day, 0, 0, 0, Rational(offset, 86_400))
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def to_time(zone: DEFAULT_ZONE, hour: 0, minute: 0, second: 0)
|
|
321
|
+
offset = Date.offset_seconds(zone)
|
|
322
|
+
civil = Calendar.civil_from_days(serial + CalendarTable::EPOCH_UNIX_DAYS)
|
|
323
|
+
::Time.new(civil[0], civil[1], civil[2], hour, minute, second, offset)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def today?(zone: DEFAULT_ZONE, clock: nil)
|
|
327
|
+
self == Date.today(zone: zone, clock: clock)
|
|
328
|
+
end
|
|
329
|
+
alias_method :is_today, :today?
|
|
330
|
+
|
|
331
|
+
def yesterday?(zone: DEFAULT_ZONE, clock: nil)
|
|
332
|
+
self == Date.today(zone: zone, clock: clock).add_days(-1)
|
|
333
|
+
end
|
|
334
|
+
alias_method :is_yesterday, :yesterday?
|
|
335
|
+
|
|
336
|
+
def tomorrow?(zone: DEFAULT_ZONE, clock: nil)
|
|
337
|
+
self == Date.today(zone: zone, clock: clock).add_days(1)
|
|
338
|
+
end
|
|
339
|
+
alias_method :is_tomorrow, :tomorrow?
|
|
340
|
+
|
|
341
|
+
def fiscal_year
|
|
342
|
+
FiscalYear.containing(self)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def quarter
|
|
346
|
+
case month
|
|
347
|
+
when 4..6 then Quarter::Q1
|
|
348
|
+
when 7..9 then Quarter::Q2
|
|
349
|
+
when 10..12 then Quarter::Q3
|
|
350
|
+
else Quarter::Q4
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def add_days(days)
|
|
355
|
+
unless days.is_a?(Integer)
|
|
356
|
+
raise TypeError, "days must be an Integer, got #{days.class}"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
target = serial + days
|
|
360
|
+
unless target.between?(0, CalendarTable::MAX_SERIAL)
|
|
361
|
+
raise OutOfRangeError, "day offset #{days} is outside the supported range"
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
Date.from_serial(target)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def add_months(months, overflow: :clamp)
|
|
368
|
+
unless months.is_a?(Integer)
|
|
369
|
+
raise TypeError, "months must be an Integer, got #{months.class}"
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
mode = (overflow == :clamp || overflow == :spill) ? overflow : MonthOverflow.coerce(overflow)
|
|
373
|
+
total = (year - CalendarTable::MIN_YEAR) * 12 + (month - 1) + months
|
|
374
|
+
if total < 0 || total >= CalendarTable::YEARS * 12
|
|
375
|
+
raise OutOfRangeError, "month offset #{months} is outside the supported range"
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
target_year = CalendarTable::MIN_YEAR + total / 12
|
|
379
|
+
target_month = total % 12 + 1
|
|
380
|
+
target_index = total
|
|
381
|
+
packed = CalendarTable::PACKED.fetch(target_year - CalendarTable::MIN_YEAR)
|
|
382
|
+
length = 29 + ((packed >> (2 * (target_month - 1))) & 0b11)
|
|
383
|
+
target_start = CalendarTable::MONTH_START.fetch(target_index)
|
|
384
|
+
|
|
385
|
+
if day <= length
|
|
386
|
+
return Date.from_parts(target_year, target_month, day, target_start + day - 1)
|
|
387
|
+
end
|
|
388
|
+
if mode == :clamp
|
|
389
|
+
return Date.from_parts(target_year, target_month, length, target_start + length - 1)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
spill_year = (target_month == 12) ? target_year + 1 : target_year
|
|
393
|
+
spill_month = (target_month == 12) ? 1 : target_month + 1
|
|
394
|
+
if spill_year > CalendarTable::MAX_YEAR
|
|
395
|
+
raise OutOfRangeError, "month offset #{months} spills past the supported range"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
spill_day = day - length
|
|
399
|
+
Date.from_parts(
|
|
400
|
+
spill_year, spill_month, spill_day,
|
|
401
|
+
CalendarTable::MONTH_START.fetch(target_index + 1) + spill_day - 1
|
|
402
|
+
)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def add_years(years, overflow: :clamp)
|
|
406
|
+
years = Date.integer_argument(years, "years")
|
|
407
|
+
add_months(years * 12, overflow: overflow)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# +other - self+, the direction of ALGORITHM.md +days_between(a, b)+ (D-07).
|
|
411
|
+
def days_until(other)
|
|
412
|
+
expect_date!(other)
|
|
413
|
+
other.serial - serial
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# The signed years, months and days from +self+ to +other+ (ALGORITHM.md section 8a).
|
|
417
|
+
def diff(other)
|
|
418
|
+
expect_date!(other)
|
|
419
|
+
if self <= other
|
|
420
|
+
low = self
|
|
421
|
+
high = other
|
|
422
|
+
negative = false
|
|
423
|
+
else
|
|
424
|
+
low = other
|
|
425
|
+
high = self
|
|
426
|
+
negative = true
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
months = (high.year - low.year) * 12 + (high.month - low.month)
|
|
430
|
+
anchor = low.add_months(months)
|
|
431
|
+
if anchor > high
|
|
432
|
+
months -= 1
|
|
433
|
+
anchor = low.add_months(months)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
DateDiff.new(
|
|
437
|
+
negative: negative,
|
|
438
|
+
years: months / 12,
|
|
439
|
+
months: months % 12,
|
|
440
|
+
days: high.serial - anchor.serial,
|
|
441
|
+
total_days: days_until(other)
|
|
442
|
+
)
|
|
443
|
+
end
|
|
444
|
+
alias_method :calendar_breakdown, :diff
|
|
445
|
+
|
|
446
|
+
def succ
|
|
447
|
+
add_days(1)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def +(other)
|
|
451
|
+
return add_days(other) if other.is_a?(Integer)
|
|
452
|
+
|
|
453
|
+
raise TypeError, "can only add an Integer to a NepDate::Date"
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# +self - other+ keeps the C# direction and is positive when self is later (D-07). Subtracting
|
|
457
|
+
# an integer is the Ruby convenience for moving backwards.
|
|
458
|
+
def -(other)
|
|
459
|
+
return add_days(-other) if other.is_a?(Integer)
|
|
460
|
+
|
|
461
|
+
expect_date!(other)
|
|
462
|
+
serial - other.serial
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def <=>(other)
|
|
466
|
+
return nil unless other.is_a?(Date)
|
|
467
|
+
|
|
468
|
+
serial <=> other.serial
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def compare_to(other)
|
|
472
|
+
self <=> other
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def ==(other)
|
|
476
|
+
other.is_a?(Date) && serial == other.serial
|
|
477
|
+
end
|
|
478
|
+
alias_method :eql?, :==
|
|
479
|
+
|
|
480
|
+
def hash
|
|
481
|
+
[Date, serial].hash
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def to_ymd
|
|
485
|
+
year * 10_000 + month * 100 + day
|
|
486
|
+
end
|
|
487
|
+
alias_method :ymd, :to_ymd
|
|
488
|
+
|
|
489
|
+
def iso8601
|
|
490
|
+
Format.iso8601(year, month, day, :en)
|
|
491
|
+
end
|
|
492
|
+
alias_method :isoformat, :iso8601
|
|
493
|
+
|
|
494
|
+
def to_s
|
|
495
|
+
Format.default(year, month, day, :en)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def to_short_string(order: :ymd, separator: :slash, pad: true, lang: :en)
|
|
499
|
+
Format.short(year, month, day, order: order, separator: separator, pad: pad, lang: lang)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def to_long_string(weekday: false, year: true, pad: true, lang: :en)
|
|
503
|
+
lang = Lang.coerce(lang) unless lang == :en || lang == :ne
|
|
504
|
+
if lang == :ne
|
|
505
|
+
day_text = pad ? Names::TWO_DIGIT_NE[@day] : Names::NUMBER_NE[@day]
|
|
506
|
+
year_text = Names::YEAR_NE[@year - CalendarTable::MIN_YEAR]
|
|
507
|
+
month_text = Names::MONTH_NE[@month]
|
|
508
|
+
weekday_text = Names::WEEKDAY_NE[(serial + CalendarTable::EPOCH_WEEKDAY) % 7] if weekday
|
|
509
|
+
else
|
|
510
|
+
day_text = pad ? Names::TWO_DIGIT_EN[@day] : Names::NUMBER_EN[@day]
|
|
511
|
+
year_text = Names::YEAR_EN[@year - CalendarTable::MIN_YEAR]
|
|
512
|
+
month_text = Names::MONTH_EN[@month]
|
|
513
|
+
weekday_text = Names::WEEKDAY_EN[(serial + CalendarTable::EPOCH_WEEKDAY) % 7] if weekday
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
text = +""
|
|
517
|
+
text << weekday_text << ", " if weekday
|
|
518
|
+
text << month_text << " " << day_text
|
|
519
|
+
text << ", " << year_text if year
|
|
520
|
+
text
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def to_unicode_string(order: :ymd, separator: :slash, pad: true)
|
|
524
|
+
to_short_string(order: order, separator: separator, pad: pad, lang: :ne)
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def to_long_unicode_string(weekday: false, year: true, pad: true)
|
|
528
|
+
to_long_string(weekday: weekday, year: year, pad: pad, lang: :ne)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def format(pattern, lang: :en)
|
|
532
|
+
Format.pattern(pattern, year: year, month: month, day: day, lang: lang)
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# The canonical string form, +YYYY-MM-DD+, read by +from_json+.
|
|
536
|
+
def to_json(*_args)
|
|
537
|
+
JSON.generate(iso8601)
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def to_h
|
|
541
|
+
{year: year, month: month, day: day}
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def to_a
|
|
545
|
+
[year, month, day]
|
|
546
|
+
end
|
|
547
|
+
alias_method :to_ary, :to_a
|
|
548
|
+
|
|
549
|
+
def deconstruct
|
|
550
|
+
to_a
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def deconstruct_keys(_keys)
|
|
554
|
+
to_h
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def inspect
|
|
558
|
+
"#<NepDate::Date #{iso8601}>"
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
private
|
|
562
|
+
|
|
563
|
+
def initialize_parts(year, month, day, serial)
|
|
564
|
+
@year = year
|
|
565
|
+
@month = month
|
|
566
|
+
@day = day
|
|
567
|
+
@serial = serial
|
|
568
|
+
freeze
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def expect_date!(other)
|
|
572
|
+
raise TypeError, "expected a NepDate::Date, got #{other.class}" unless other.is_a?(Date)
|
|
573
|
+
|
|
574
|
+
other
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
MIN = from_bs(CalendarTable::MIN_YEAR, 1, 1)
|
|
578
|
+
MAX = from_bs(CalendarTable::MAX_YEAR, 12, Calendar.month_length(CalendarTable::MAX_YEAR, 12))
|
|
579
|
+
end
|
|
580
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NepDate
|
|
4
|
+
# The signed calendar breakdown between two dates (ALGORITHM.md section 8a). +negative+ is
|
|
5
|
+
# true when the second date is earlier; +total_days+ is the signed day count in the direction
|
|
6
|
+
# from the first date to the second.
|
|
7
|
+
class DateDiff
|
|
8
|
+
attr_reader :years, :months, :days, :total_days
|
|
9
|
+
|
|
10
|
+
def initialize(negative:, years:, months:, days:, total_days:)
|
|
11
|
+
@negative = negative
|
|
12
|
+
@years = years
|
|
13
|
+
@months = months
|
|
14
|
+
@days = days
|
|
15
|
+
@total_days = total_days
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def negative?
|
|
20
|
+
@negative
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def sign
|
|
24
|
+
negative? ? -1 : 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h
|
|
28
|
+
{negative: negative?, years: years, months: months, days: days, total_days: total_days}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ==(other)
|
|
32
|
+
other.is_a?(DateDiff) &&
|
|
33
|
+
negative? == other.negative? &&
|
|
34
|
+
years == other.years &&
|
|
35
|
+
months == other.months &&
|
|
36
|
+
days == other.days &&
|
|
37
|
+
total_days == other.total_days
|
|
38
|
+
end
|
|
39
|
+
alias_method :eql?, :==
|
|
40
|
+
|
|
41
|
+
def hash
|
|
42
|
+
[DateDiff, negative?, years, months, days, total_days].hash
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def inspect
|
|
46
|
+
"#<NepDate::DateDiff #{negative? ? "-" : "+"}#{years}y #{months}m #{days}d total=#{total_days}>"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|