jalalix 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c9eb41b1bcee603c51d99a5e359d971ca3024fee1f6e451040c8fe19af8a54f9
4
+ data.tar.gz: 50db7117c64b1899dcca2ad8f2804ee7719e8c31bf5eab8e1b040194bd75c0ab
5
+ SHA512:
6
+ metadata.gz: b3de3e6f9ada868708fdff2417c9715aa9731c67677ae43d792da41cffbc902c1188848836b7ca339bdb351eb5c481a4154eaeeb22305b2d2d64e8d4deb1977f
7
+ data.tar.gz: a25f3c0549d4a09d579cabbaaa33b0fc4469ecb1597da8d7d69673a9048ccdbcf576488a4157784f68a38b5534c49edd171581223e23b5c4a15b77037c5ee6dc
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Release Notes
2
+ All notable changes to this package will be documented in this file.
3
+
4
+ ## 0.1.0
5
+ - Initial commit
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) Hossein Maklavani
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # JalaliX
2
+
3
+ Jalali (Persian / Shamsi) calendar for Ruby. Pure Ruby 3.0+, no runtime gems (timezone via stdlib `ENV["TZ"]` / zoneinfo, with embedded offsets for `UTC` and `Asia/Tehran`).
4
+
5
+ ```bash
6
+ gem install jalalix
7
+ # or: bundle add jalalix
8
+ ```
9
+
10
+ ```ruby
11
+ require "jalalix"
12
+
13
+ date = JalaliX.create("2025-02-25")
14
+
15
+ date.format("YYYY/MM/DD") # 1403/12/07
16
+ date.format("DD MMM YYYY", persianDigits: true) # ۰۷ اسفند ۱۴۰۳
17
+
18
+ JalaliX.parseJalali("1403/12/07").toISODate # 2025-02-25
19
+ ```
20
+
21
+ `toISODate` uses wall-calendar getters in the active timezone.
22
+
23
+ ## Months
24
+
25
+ - `getMonth()` -> 1..12 (Farvardin = 1)
26
+ - `setMonth(m)` / `setFullYear(y, m)` -> 0..11, same as JS `Date` (Farvardin = 0)
27
+
28
+ ## Timezone
29
+
30
+ Internal storage is a **UTC Unix millisecond instant** plus an IANA timezone id. `setTimezone` is **instant-preserving**.
31
+
32
+ ```ruby
33
+ d = JalaliX.create(1_740_430_800_000)
34
+ tehran = d.setTimezone("Asia/Tehran")
35
+ tehran.format("YYYY/MM/DD") # 1403/12/07
36
+ tehran.format("HH:mm:ss") # 00:30:00
37
+ ```
38
+
39
+ - **Aliases:** `utc` / `gmt` -> `UTC`; `local` / `default` / `system` -> host zone when detectable.
40
+ - **Invalid zone** -> `ArgumentError`.
41
+ - Offsets use system zoneinfo through `ENV["TZ"]` when available; `Asia/Tehran` (+03:30 since 2022) and `UTC` are also embedded so golden parity works without TZInfo.
42
+
43
+ ## Test
44
+
45
+ ```bash
46
+ rake test
47
+ # or: ruby -Ilib:test test/test_golden.rb
48
+ ```
49
+
50
+ ## License
51
+
52
+ MIT
@@ -0,0 +1,795 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "date"
5
+
6
+ # JalaliX — Jalali (Persian / Shamsi) calendar for Ruby.
7
+ # Pure Ruby port of @jalalix/typescript — same algorithm and camelCase API.
8
+ # Timezone: system zoneinfo via ENV["TZ"] when available; embedded fixed
9
+ # offsets for golden zones (UTC, Asia/Tehran +03:30 since 2022) as fallback.
10
+ class JalaliX
11
+ PERSIAN_NUMBERS = %w[۰ ۱ ۲ ۳ ۴ ۵ ۶ ۷ ۸ ۹].freeze
12
+
13
+ # Fixed offsets (seconds east of UTC) for zones needed without zoneinfo.
14
+ # Asia/Tehran abolished DST in 2022 -> permanent +03:30.
15
+ EMBEDDED_OFFSETS = {
16
+ "UTC" => 0,
17
+ "Etc/UTC" => 0,
18
+ "GMT" => 0,
19
+ "Asia/Tehran" => 3 * 3600 + 30 * 60,
20
+ "Asia/Tokyo" => 9 * 3600
21
+ }.freeze
22
+
23
+ WallParts = Struct.new(:year, :month, :day, :hour, :minute, :second, :ms, keyword_init: true)
24
+
25
+ attr_reader :_ms, :_timezone
26
+
27
+ def initialize(ms = nil)
28
+ @_ms = ms.nil? ? (Time.now.to_f * 1000).to_i : ms.to_i
29
+ @_timezone = "UTC"
30
+ @_jalali = {
31
+ year: 1348, month: 10, day: 11,
32
+ hours: 0, min: 0, sec: 0, ms: 0, isLeapYear: false
33
+ }
34
+ @_is_converted = false
35
+ @_leap_year_division = 0.24219858156
36
+ @_first_day_of_week = 6
37
+ @_date_length = 86_400_000
38
+ @_week_days_names = %w[شنبه یکشنبه دوشنبه سه\ شنبه چهار\ شنبه پنج\ شنبه جمعه]
39
+ @_week_days_letters = %w[ش ی د س چ پ ج]
40
+ @_week_days_gregorian_names = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday]
41
+ @_week_days_gregorian_letters = %w[S M T W T F S]
42
+ @_months_names = %w[فروردین اردیبهشت خرداد تیر مرداد شهریور مهر آبان آذر دی بهمن اسفند]
43
+ @_months_gregorian_names = %w[January February March April May June July August September October November December]
44
+ @_am = "ق.ظ."
45
+ @_pm = "ب.ظ."
46
+ @_am_gregorian = "AM"
47
+ @_pm_gregorian = "PM"
48
+ end
49
+
50
+ def self.host_timezone
51
+ tz = ENV["TZ"]
52
+ return tz if tz && !tz.empty?
53
+
54
+ # Try local Time zone name
55
+ begin
56
+ name = Time.now.zone
57
+ return "UTC" if name.nil? || name.empty?
58
+ rescue StandardError
59
+ end
60
+ "UTC"
61
+ end
62
+
63
+ def self.zone_known?(name)
64
+ return true if EMBEDDED_OFFSETS.key?(name)
65
+ return true if name == "UTC"
66
+ # Probe system zoneinfo via ENV TZ
67
+ old = ENV["TZ"]
68
+ begin
69
+ ENV["TZ"] = name
70
+ Time.now
71
+ true
72
+ rescue StandardError
73
+ false
74
+ ensure
75
+ if old.nil?
76
+ ENV.delete("TZ")
77
+ else
78
+ ENV["TZ"] = old
79
+ end
80
+ end
81
+ end
82
+
83
+ # Offset seconds east of UTC for this instant in the given IANA zone.
84
+ def self.offset_seconds_at(ms, zone)
85
+ return 0 if zone.nil? || zone.empty? || zone.upcase == "UTC" || zone == "Etc/UTC" || zone.upcase == "GMT"
86
+
87
+ if EMBEDDED_OFFSETS.key?(zone) && (zone == "Asia/Tehran" || zone.start_with?("Etc/") || zone == "Asia/Tokyo")
88
+ # Prefer system zoneinfo when present; fall back to embedded.
89
+ sys = system_offset_seconds(ms, zone)
90
+ return sys unless sys.nil?
91
+ return EMBEDDED_OFFSETS[zone]
92
+ end
93
+
94
+ sys = system_offset_seconds(ms, zone)
95
+ return sys unless sys.nil?
96
+
97
+ emb = EMBEDDED_OFFSETS[zone]
98
+ return emb unless emb.nil?
99
+
100
+ raise ArgumentError, "Invalid IANA timezone: #{zone}"
101
+ end
102
+
103
+ def self.system_offset_seconds(ms, zone)
104
+ old = ENV["TZ"]
105
+ begin
106
+ ENV["TZ"] = zone
107
+ t = Time.at(ms / 1000.0)
108
+ # Time#utc_offset is seconds east of UTC
109
+ t.utc_offset
110
+ rescue StandardError
111
+ nil
112
+ ensure
113
+ if old.nil?
114
+ ENV.delete("TZ")
115
+ else
116
+ ENV["TZ"] = old
117
+ end
118
+ end
119
+ end
120
+
121
+ def self.wall_from_ms(ms, zone)
122
+ off = offset_seconds_at(ms, zone)
123
+ # Civil time as if UTC
124
+ wall_ms = ms + off * 1000
125
+ sec = wall_ms.div(1000)
126
+ milli = wall_ms - sec * 1000
127
+ if milli.negative?
128
+ sec -= 1
129
+ milli += 1000
130
+ end
131
+ t = Time.at(sec).utc
132
+ WallParts.new(
133
+ year: t.year, month: t.month, day: t.day,
134
+ hour: t.hour, minute: t.min, second: t.sec,
135
+ ms: milli
136
+ )
137
+ end
138
+
139
+ # Map wall clock in zone -> UTC ms (iterative for DST).
140
+ def self.wall_to_utc(zone, y, m, d, h, mi, s, ms)
141
+ want = Time.utc(y, m, d, h, mi, s).to_i * 1000
142
+ utc = want
143
+ 14.times do
144
+ parts = wall_from_ms(utc, zone)
145
+ got = Time.utc(parts.year, parts.month, parts.day, parts.hour, parts.minute, parts.second).to_i * 1000
146
+ diff = want - got
147
+ break if diff.zero?
148
+ utc += diff
149
+ end
150
+ (utc.div(1000) * 1000) + (((ms % 1000) + 1000) % 1000)
151
+ end
152
+
153
+ def get_wall_parts
154
+ self.class.wall_from_ms(@_ms, @_timezone)
155
+ end
156
+
157
+ def wall_to_utc(y, m, d, h, mi, s, ms)
158
+ self.class.wall_to_utc(@_timezone, y, m, d, h, mi, s, ms)
159
+ end
160
+
161
+ def to_jalali
162
+ unless @_is_converted
163
+ wall = get_wall_parts
164
+ civil_day = Time.utc(wall.year, wall.month, wall.day).to_i / (@_date_length / 1000)
165
+ base_day = Time.utc(1970, 1, 1).to_i / (@_date_length / 1000)
166
+ diff_days = civil_day - base_day
167
+
168
+ @_jalali[:year] = 1348
169
+ @_jalali[:month] = 10
170
+ @_jalali[:day] = 11
171
+
172
+ if diff_days.positive?
173
+ diff_days.times do
174
+ @_jalali[:day] += 1
175
+ if @_jalali[:month] < 7 && @_jalali[:day] > 31
176
+ @_jalali[:month] += 1
177
+ @_jalali[:day] = 1
178
+ elsif @_jalali[:month] > 6 && @_jalali[:month] < 12 && @_jalali[:day] > 30
179
+ @_jalali[:month] += 1
180
+ @_jalali[:day] = 1
181
+ elsif @_jalali[:month] == 12
182
+ leap = check_leap_year(@_jalali[:year])
183
+ if leap && @_jalali[:day] > 30
184
+ @_jalali[:year] += 1
185
+ @_jalali[:month] = 1
186
+ @_jalali[:day] = 1
187
+ elsif !leap && @_jalali[:day] > 29
188
+ @_jalali[:year] += 1
189
+ @_jalali[:month] = 1
190
+ @_jalali[:day] = 1
191
+ end
192
+ end
193
+ end
194
+ elsif diff_days.negative?
195
+ (-diff_days).times do
196
+ @_jalali[:day] -= 1
197
+ next unless @_jalali[:day] < 1
198
+
199
+ if @_jalali[:month] == 1
200
+ @_jalali[:year] -= 1
201
+ @_jalali[:month] = 12
202
+ @_jalali[:day] = check_leap_year(@_jalali[:year]) ? 30 : 29
203
+ elsif @_jalali[:month] < 8
204
+ @_jalali[:month] -= 1
205
+ @_jalali[:day] = 31
206
+ elsif @_jalali[:month] < 13
207
+ @_jalali[:month] -= 1
208
+ @_jalali[:day] = 30
209
+ end
210
+ end
211
+ end
212
+
213
+ @_jalali[:hours] = wall.hour
214
+ @_jalali[:min] = wall.minute
215
+ @_jalali[:sec] = wall.second
216
+ @_jalali[:ms] = wall.ms
217
+ @_jalali[:isLeapYear] = check_leap_year(@_jalali[:year])
218
+ @_is_converted = true
219
+ end
220
+
221
+ wall = get_wall_parts
222
+ @_jalali[:hours] = wall.hour
223
+ @_jalali[:min] = wall.minute
224
+ @_jalali[:sec] = wall.second
225
+ @_jalali[:ms] = wall.ms
226
+ @_jalali
227
+ end
228
+
229
+ def to_gregorian(j_date)
230
+ wall = get_wall_parts
231
+ jalali = {
232
+ year: 1348, month: 10, day: 11,
233
+ hours: j_date[:hours] || j_date["hours"] || wall.hour,
234
+ min: j_date[:min] || j_date["min"] || wall.minute,
235
+ sec: j_date[:sec] || j_date["sec"] || wall.second,
236
+ ms: j_date[:ms] || j_date["ms"] || wall.ms,
237
+ isLeapYear: false
238
+ }
239
+ jy = j_date[:year] || j_date["year"]
240
+ jm = j_date[:month] || j_date["month"]
241
+ jd = j_date[:day] || j_date["day"]
242
+ hours = (j_date.key?(:hours) || j_date.key?("hours")) ? (j_date[:hours] || j_date["hours"]) : wall.hour
243
+ min = (j_date.key?(:min) || j_date.key?("min")) ? (j_date[:min] || j_date["min"]) : wall.minute
244
+ sec = (j_date.key?(:sec) || j_date.key?("sec")) ? (j_date[:sec] || j_date["sec"]) : wall.second
245
+ ms = (j_date.key?(:ms) || j_date.key?("ms")) ? (j_date[:ms] || j_date["ms"]) : wall.ms
246
+
247
+ diff_days = 0
248
+ after = jy > 1348 || (jy == 1348 && jm >= 10) || (jy == 1348 && jm == 10 && jd >= 11)
249
+ before = jy < 1348 || (jy == 1348 && jm < 10) || (jy == 1348 && jm == 10 && jd < 11)
250
+
251
+ if after
252
+ loop do
253
+ break if jalali[:year] == jy && jalali[:month] == jm && jalali[:day] == jd
254
+
255
+ diff_days += 1
256
+ jalali[:day] += 1
257
+ if jalali[:month] < 7 && jalali[:day] > 31
258
+ jalali[:month] += 1
259
+ jalali[:day] = 1
260
+ elsif jalali[:month] > 6 && jalali[:month] < 12 && jalali[:day] > 30
261
+ jalali[:month] += 1
262
+ jalali[:day] = 1
263
+ elsif jalali[:month] == 12
264
+ leap = check_leap_year(jalali[:year])
265
+ if leap && jalali[:day] > 30
266
+ jalali[:year] += 1
267
+ jalali[:month] = 1
268
+ jalali[:day] = 1
269
+ elsif !leap && jalali[:day] > 29
270
+ jalali[:year] += 1
271
+ jalali[:month] = 1
272
+ jalali[:day] = 1
273
+ end
274
+ end
275
+ end
276
+ elsif before
277
+ loop do
278
+ break if jalali[:year] == jy && jalali[:month] == jm && jalali[:day] == jd
279
+
280
+ diff_days -= 1
281
+ jalali[:day] -= 1
282
+ next unless jalali[:day] < 1
283
+
284
+ if jalali[:month] == 1
285
+ jalali[:year] -= 1
286
+ jalali[:month] = 12
287
+ jalali[:day] = check_leap_year(jalali[:year]) ? 30 : 29
288
+ elsif jalali[:month] < 8
289
+ jalali[:month] -= 1
290
+ jalali[:day] = 31
291
+ elsif jalali[:month] < 13
292
+ jalali[:month] -= 1
293
+ jalali[:day] = 30
294
+ end
295
+ end
296
+ end
297
+
298
+ wall_as_utc = diff_days * @_date_length + (hours * 3600 + min * 60 + sec) * 1000 + ms
299
+ civil = Time.at(wall_as_utc / 1000.0).utc
300
+ wall_to_utc(civil.year, civil.month, civil.day, civil.hour, civil.min, civil.sec, civil.usec / 1000)
301
+ end
302
+
303
+ def check_leap_year(year)
304
+ return false unless year.positive?
305
+
306
+ check_year = (year + 2346) * @_leap_year_division
307
+ check_year - check_year.to_i < @_leap_year_division
308
+ end
309
+
310
+ def reset
311
+ @_is_converted = false
312
+ @_jalali[:year] = 1348
313
+ @_jalali[:month] = 10
314
+ @_jalali[:day] = 11
315
+ to_jalali
316
+ end
317
+
318
+ def add_zero(number)
319
+ "#{number > -1 && number < 10 ? '0' : ''}#{number}"
320
+ end
321
+
322
+ def get_wall_weekday
323
+ wall = get_wall_parts
324
+ Time.utc(wall.year, wall.month, wall.day).wday
325
+ end
326
+
327
+ def get_utc_timezone
328
+ wall = get_wall_parts
329
+ as_utc = Time.utc(wall.year, wall.month, wall.day, wall.hour, wall.minute, wall.second).to_i * 1000 + wall.ms
330
+ offset_min = ((as_utc - @_ms) / 60_000.0).round
331
+ sign = offset_min >= 0 ? "+" : "-"
332
+ abs = offset_min.abs
333
+ " GMT#{sign}#{add_zero(abs / 60)}#{add_zero(abs % 60)}"
334
+ end
335
+
336
+ def normalize_date(days)
337
+ setTime(@_ms + days * @_date_length)
338
+ end
339
+
340
+ def normalize_jalali(j_date, year, month, day)
341
+ if month < 1
342
+ year_diff = ((1 - month) / 12.0).ceil
343
+ year -= year_diff
344
+ month = 12 * year_diff + month
345
+ elsif month > 12
346
+ year_diff = ((month - 1) / 12.0).floor
347
+ year += year_diff
348
+ month -= 12 * year_diff
349
+ end
350
+
351
+ if month > 6 && month < 12 && day > 30
352
+ day = 30
353
+ elsif month == 12
354
+ leap = check_leap_year(year)
355
+ if leap && day > 30
356
+ day = 30
357
+ elsif !leap && day > 29
358
+ day = 29
359
+ end
360
+ end
361
+
362
+ merged = j_date.merge(year: year, month: month, day: day)
363
+ setTime(to_gregorian(merged))
364
+ end
365
+
366
+ def normalize_timezone(timezone)
367
+ return @_timezone if timezone.nil?
368
+
369
+ trimmed = timezone.to_s.strip
370
+ raise ArgumentError, "Invalid IANA timezone: empty" if trimmed.empty?
371
+
372
+ lowered = trimmed.downcase
373
+ return self.class.host_timezone if %w[default local system].include?(lowered)
374
+ return "UTC" if %w[utc gmt].include?(lowered)
375
+
376
+ # Validate
377
+ begin
378
+ self.class.offset_seconds_at(@_ms, trimmed)
379
+ rescue ArgumentError
380
+ raise ArgumentError, "Invalid IANA timezone: #{trimmed}"
381
+ end
382
+ trimmed
383
+ end
384
+
385
+ def toString
386
+ j = to_jalali
387
+ "#{week_day_name} #{add_zero(j[:day])} #{@_months_names[j[:month] - 1]} #{add_zero(j[:year])} #{j[:hours]}:#{add_zero(j[:min])}:#{add_zero(j[:sec])}#{get_utc_timezone}"
388
+ end
389
+ alias to_s toString
390
+
391
+ def toDateString
392
+ j = to_jalali
393
+ "#{week_day_name} #{add_zero(j[:day])} #{@_months_names[j[:month] - 1]} #{add_zero(j[:year])}"
394
+ end
395
+
396
+ def toLocaleDateString
397
+ j = to_jalali
398
+ "#{add_zero(j[:year])}/#{add_zero(j[:month])}/#{add_zero(j[:day])}"
399
+ end
400
+
401
+ def toLocaleTimeString
402
+ j = to_jalali
403
+ "#{j[:hours] % 12}:#{add_zero(j[:min])}:#{add_zero(j[:sec])} #{getMeridiem}"
404
+ end
405
+
406
+ def getFullYear
407
+ to_jalali[:year]
408
+ end
409
+
410
+ def getMonth
411
+ to_jalali[:month]
412
+ end
413
+
414
+ def getDate
415
+ to_jalali[:day]
416
+ end
417
+
418
+ def getDay
419
+ (get_wall_weekday - @_first_day_of_week + 7 + 1) % 7
420
+ end
421
+
422
+ def getHours
423
+ get_wall_parts.hour
424
+ end
425
+
426
+ def getMinutes
427
+ get_wall_parts.minute
428
+ end
429
+
430
+ def getSeconds
431
+ get_wall_parts.second
432
+ end
433
+
434
+ def getMilliseconds
435
+ ((@_ms % 1000) + 1000) % 1000
436
+ end
437
+
438
+ def getTime
439
+ @_ms
440
+ end
441
+
442
+ def setTime(time)
443
+ @_ms = time.to_i
444
+ reset
445
+ getTime
446
+ end
447
+
448
+ def setMilliseconds(ms)
449
+ wall = get_wall_parts
450
+ setTime(wall_to_utc(wall.year, wall.month, wall.day, wall.hour, wall.minute, wall.second, ms))
451
+ end
452
+
453
+ def setSeconds(sec, ms = nil)
454
+ wall = get_wall_parts
455
+ setTime(wall_to_utc(wall.year, wall.month, wall.day, wall.hour, wall.minute, sec, ms.nil? ? wall.ms : ms))
456
+ end
457
+
458
+ def setMinutes(min, sec = nil, ms = nil)
459
+ wall = get_wall_parts
460
+ setTime(wall_to_utc(wall.year, wall.month, wall.day, wall.hour, min, sec.nil? ? wall.second : sec, ms.nil? ? wall.ms : ms))
461
+ end
462
+
463
+ def setHours(hours, min = nil, sec = nil, ms = nil)
464
+ wall = get_wall_parts
465
+ setTime(wall_to_utc(wall.year, wall.month, wall.day, hours, min.nil? ? wall.minute : min, sec.nil? ? wall.second : sec, ms.nil? ? wall.ms : ms))
466
+ end
467
+
468
+ def setDate(date)
469
+ j = to_jalali
470
+ normalize_date(date - j[:day])
471
+ getTime
472
+ end
473
+
474
+ def setMonth(month, date = nil)
475
+ j = to_jalali
476
+ normalize_jalali(j, j[:year], month + 1, j[:day])
477
+ reset
478
+ unless date.nil?
479
+ j = to_jalali
480
+ normalize_date(date - j[:day])
481
+ reset
482
+ end
483
+ getTime
484
+ end
485
+
486
+ def setFullYear(year, month = nil, date = nil)
487
+ j = to_jalali
488
+ normalize_jalali(j, year, month.nil? ? j[:month] : month + 1, j[:day])
489
+ reset
490
+ unless date.nil?
491
+ j = to_jalali
492
+ normalize_date(date - j[:day])
493
+ reset
494
+ end
495
+ getTime
496
+ end
497
+
498
+ def self.create(date, options = nil)
499
+ ms = parse_input(date)
500
+ output = new(ms)
501
+ if options.is_a?(Hash) && (options[:timezone] || options["timezone"])
502
+ output = output.setTimezone(options[:timezone] || options["timezone"])
503
+ end
504
+ output
505
+ end
506
+
507
+ def self.parse_input(value)
508
+ case value
509
+ when JalaliX then value.getTime
510
+ when Integer, Float then value.to_i
511
+ when Time then (value.to_f * 1000).to_i
512
+ when Date then Time.utc(value.year, value.month, value.day).to_i * 1000
513
+ when String then parse_iso_string(value)
514
+ else
515
+ raise TypeError, "JalaliX.create: unsupported date type #{value.class}"
516
+ end
517
+ end
518
+
519
+ def self.parse_iso_string(value)
520
+ s = value.strip
521
+ if s.match?(/\A\d{4}-\d{2}-\d{2}\z/)
522
+ y, m, d = s.split("-").map(&:to_i)
523
+ return Time.utc(y, m, d).to_i * 1000
524
+ end
525
+ t = Time.parse(s)
526
+ (t.to_f * 1000).to_i
527
+ rescue StandardError
528
+ raise ArgumentError, "JalaliX.create: invalid date string #{value.inspect}"
529
+ end
530
+
531
+ def self.parseJalali(value, options = nil)
532
+ normalized = fromPersian(value.to_s).strip
533
+ match = normalized.match(/\A(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?\z/)
534
+ raise ArgumentError, "JalaliX.parseJalali: invalid Jalali date \"#{value}\"" unless match
535
+
536
+ year = match[1].to_i
537
+ month = match[2].to_i
538
+ day = match[3].to_i
539
+ hours = (match[4] || 0).to_i
540
+ minutes = (match[5] || 0).to_i
541
+ seconds = (match[6] || 0).to_i
542
+ raise ArgumentError, "JalaliX.parseJalali: invalid Jalali date \"#{value}\"" if month < 1 || month > 12 || day < 1 || day > 31
543
+
544
+ output = create(Time.now, options)
545
+ output.setFullYear(year, month - 1, day)
546
+ output.setHours(hours, minutes, seconds, 0)
547
+ output
548
+ end
549
+
550
+ def self.isValidDate(date)
551
+ date.is_a?(JalaliX) || date.is_a?(Time) || date.is_a?(Date)
552
+ end
553
+
554
+ def self.toPersian(str)
555
+ out = str.dup
556
+ 10.times { |i| out = out.gsub(i.to_s, PERSIAN_NUMBERS[i]) }
557
+ out
558
+ end
559
+
560
+ def self.fromPersian(str)
561
+ out = str.dup
562
+ 10.times { |i| out = out.gsub(PERSIAN_NUMBERS[i], i.to_s) }
563
+ out
564
+ end
565
+
566
+ def self.compare(date, comparing)
567
+ date.getTime < comparing.getTime
568
+ end
569
+
570
+ def clone
571
+ output = JalaliX.new(@_ms)
572
+ output.instance_variable_set(:@_timezone, @_timezone)
573
+ output
574
+ end
575
+
576
+ def getTimezone
577
+ @_timezone
578
+ end
579
+
580
+ def getWeekOfYear
581
+ day_of_year = getDayOfYear
582
+ day_of_week = JalaliX.new(@_ms).tap { |d| d.instance_variable_set(:@_timezone, @_timezone) }.getStartOfYear.getDayISO
583
+ ((day_of_year + day_of_week - 1) / 7.0).ceil
584
+ end
585
+
586
+ def getDayOfYear
587
+ j = to_jalali
588
+ if j[:month] < 7
589
+ j[:day] + (j[:month] - 1) * 31
590
+ elsif j[:month] < 12
591
+ j[:day] + 186 + (j[:month] - 7) * 30
592
+ else
593
+ j[:day] + 336
594
+ end
595
+ end
596
+
597
+ def getDayISO
598
+ ((getDay + 6) % 7) + 1
599
+ end
600
+
601
+ def getHoursISO
602
+ hours = getHours
603
+ hours > 12 ? hours - 12 : hours
604
+ end
605
+
606
+ def getMeridiem
607
+ j = to_jalali
608
+ j[:hours] > 12 ? @_pm : @_am
609
+ end
610
+
611
+ def getStartOfYear
612
+ addDays(-1 * getDayOfYear + 1).getStartOfDay
613
+ end
614
+
615
+ def getStartOfMonth
616
+ addDays(-1 * getDate + 1).getStartOfDay
617
+ end
618
+
619
+ def getStartOfWeek
620
+ addDays(-1 * getDayISO + 1).getStartOfDay
621
+ end
622
+
623
+ def getStartOfDay
624
+ setHours(0, 0, 0, 0)
625
+ self
626
+ end
627
+
628
+ def getEndOfYear
629
+ j = to_jalali
630
+ last = j[:isLeapYear] ? 366 : 365
631
+ addDays(last - getDayOfYear).getEndOfDay
632
+ end
633
+
634
+ def getEndOfMonth
635
+ j = to_jalali
636
+ last = 31
637
+ if j[:month] > 6 && j[:month] < 12
638
+ last = 30
639
+ elsif j[:month] == 12
640
+ last = j[:isLeapYear] ? 30 : 29
641
+ end
642
+ addDays(last - getDate).getEndOfDay
643
+ end
644
+
645
+ def getEndOfWeek
646
+ addDays(7 - getDayISO).getEndOfDay
647
+ end
648
+
649
+ def getEndOfDay
650
+ setHours(23, 59, 59, 999)
651
+ self
652
+ end
653
+
654
+ def setTimezone(timezone)
655
+ output = JalaliX.new(@_ms)
656
+ output.instance_variable_set(:@_timezone, normalize_timezone(timezone))
657
+ output
658
+ end
659
+
660
+ def addYears(year)
661
+ j = to_jalali
662
+ normalize_jalali(j, j[:year] + year, j[:month], j[:day])
663
+ reset
664
+ self
665
+ end
666
+
667
+ def addMonths(month)
668
+ j = to_jalali
669
+ normalize_jalali(j, j[:year], j[:month] + month, j[:day])
670
+ reset
671
+ self
672
+ end
673
+
674
+ def addWeeks(weeks)
675
+ normalize_date(weeks * 7)
676
+ reset
677
+ self
678
+ end
679
+
680
+ def addDays(days)
681
+ normalize_date(days)
682
+ reset
683
+ self
684
+ end
685
+
686
+ def toISODate(calendar = "gregorian")
687
+ return format("YYYY-MM-DD") if calendar.to_s == "jalali"
688
+
689
+ wall = get_wall_parts
690
+ "#{wall.year}-#{add_zero(wall.month)}-#{add_zero(wall.day)}"
691
+ end
692
+
693
+ def format(str, persian_or_options = nil, gregorian = nil)
694
+ persian = false
695
+ use_gregorian = false
696
+ if persian_or_options.is_a?(Hash)
697
+ persian = !!(persian_or_options[:persianDigits] || persian_or_options["persianDigits"])
698
+ cal = persian_or_options[:calendar] || persian_or_options["calendar"]
699
+ use_gregorian = cal.to_s == "gregorian"
700
+ else
701
+ persian = !!persian_or_options
702
+ use_gregorian = !!gregorian
703
+ end
704
+
705
+ placeholders = []
706
+ ph = lambda do |val|
707
+ i = placeholders.length
708
+ placeholders << val
709
+ "\uE000#{i}\uE001"
710
+ end
711
+
712
+ output = str.dup
713
+ wall = get_wall_parts
714
+ year = use_gregorian ? wall.year : getFullYear
715
+ month = use_gregorian ? wall.month : getMonth
716
+ week_of_year = use_gregorian ? gregorian_week_of_year : getWeekOfYear
717
+ day_of_year = use_gregorian ? gregorian_day_of_year : getDayOfYear
718
+ date = use_gregorian ? wall.day : getDate
719
+ month_name = use_gregorian ? @_months_gregorian_names[month - 1] : @_months_names[month - 1]
720
+ week_day_name_val = use_gregorian ? @_week_days_gregorian_names[get_wall_weekday] : week_day_name
721
+ week_day_letter = use_gregorian ? @_week_days_gregorian_letters[get_wall_weekday] : @_week_days_letters[(getDay + @_first_day_of_week) % 7]
722
+ meridiem = use_gregorian ? (getHours >= 12 ? @_pm_gregorian : @_am_gregorian) : getMeridiem
723
+ year_str = year.to_s
724
+
725
+ {
726
+ "YYYY" => year_str,
727
+ "YYY" => year_str[-3..],
728
+ "YY" => year_str[-2..],
729
+ "Y" => year_str[-1],
730
+ "MMM" => month_name,
731
+ "MM" => add_zero(month),
732
+ "M" => month.to_s,
733
+ "W" => add_zero(week_of_year),
734
+ "w" => week_of_year.to_s,
735
+ "DW" => getDayISO.to_s,
736
+ "dw" => (use_gregorian ? (get_wall_weekday.zero? ? 7 : get_wall_weekday) : getDay).to_s,
737
+ "WN" => week_day_name_val,
738
+ "wn" => week_day_name_val,
739
+ "WL" => week_day_letter,
740
+ "wl" => week_day_letter,
741
+ "DDDD" => add_zero(day_of_year),
742
+ "DDD" => day_of_year.to_s,
743
+ "DD" => add_zero(date),
744
+ "D" => date.to_s
745
+ }.each do |token, val|
746
+ output.gsub!(token, ph.call(val)) if output.include?(token)
747
+ end
748
+
749
+ hours = getHours
750
+ output.gsub!("HH", ph.call(add_zero(hours))) if output.include?("HH")
751
+ output.gsub!("H", ph.call(hours.to_s)) if output.include?("H")
752
+ hours_iso = hours > 12 ? hours - 12 : (hours.zero? ? 12 : hours)
753
+ output.gsub!("hh", ph.call(add_zero(hours_iso))) if output.include?("hh")
754
+ output.gsub!("h", ph.call(hours_iso.to_s)) if output.include?("h")
755
+ output.gsub!("mm", ph.call(add_zero(getMinutes))) if output.include?("mm")
756
+ output.gsub!("m", ph.call(getMinutes.to_s)) if output.include?("m")
757
+ output.gsub!("ss", ph.call(add_zero(getSeconds))) if output.include?("ss")
758
+ output.gsub!("s", ph.call(getSeconds.to_s)) if output.include?("s")
759
+ output.gsub!("sss", ph.call(getMilliseconds.to_s)) if output.include?("sss")
760
+ output.gsub!("SSS", ph.call(getMilliseconds.to_s)) if output.include?("SSS")
761
+ output.gsub!("A", ph.call(meridiem)) if output.include?("A")
762
+ output.gsub!("a", ph.call(meridiem)) if output.include?("a")
763
+ output.gsub!("X", ph.call((@_ms / 1000).floor.to_s)) if output.include?("X")
764
+ output.gsub!("x", ph.call(@_ms.to_s)) if output.include?("x")
765
+
766
+ placeholders.each_with_index do |val, i|
767
+ output.gsub!("\uE000#{i}\uE001", val)
768
+ end
769
+
770
+ persian ? self.class.toPersian(output) : output
771
+ end
772
+
773
+ private
774
+
775
+ def week_day_name
776
+ @_week_days_names[(getDay + @_first_day_of_week) % 7]
777
+ end
778
+
779
+ def gregorian_day_of_year
780
+ wall = get_wall_parts
781
+ start = Time.utc(wall.year, 1, 1) - 86_400
782
+ civil = Time.utc(wall.year, wall.month, wall.day)
783
+ ((civil - start) / 86_400).round
784
+ end
785
+
786
+ def gregorian_week_of_year
787
+ wall = get_wall_parts
788
+ d = Time.utc(wall.year, wall.month, wall.day)
789
+ dow = d.wday
790
+ dow = 7 if dow.zero?
791
+ d += (4 - dow) * 86_400
792
+ year_start = Time.utc(d.year, 1, 1)
793
+ ((((d - year_start) / 86_400) + 1) / 7.0).ceil
794
+ end
795
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class JalaliX
4
+ VERSION = "0.1.0"
5
+ end
data/lib/jalalix.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "date"
5
+ require_relative "jalalix/version"
6
+ require_relative "jalalix/jalalix"
metadata ADDED
@@ -0,0 +1,47 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jalalix
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Hossein Maklavani
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Pure Ruby port of JalaliX — same algorithm and camelCase API as the TypeScript
13
+ package.
14
+ email:
15
+ - hossein@maklavani.dev
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE
22
+ - README.md
23
+ - lib/jalalix.rb
24
+ - lib/jalalix/jalalix.rb
25
+ - lib/jalalix/version.rb
26
+ homepage: https://jalalix.com
27
+ licenses:
28
+ - MIT
29
+ metadata: {}
30
+ rdoc_options: []
31
+ require_paths:
32
+ - lib
33
+ required_ruby_version: !ruby/object:Gem::Requirement
34
+ requirements:
35
+ - - ">="
36
+ - !ruby/object:Gem::Version
37
+ version: 3.0.0
38
+ required_rubygems_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0'
43
+ requirements: []
44
+ rubygems_version: 4.0.23
45
+ specification_version: 4
46
+ summary: Jalali (Persian / Shamsi) calendar for Ruby
47
+ test_files: []