timezone 0.2.1 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.travis.yml +2 -0
- data/CHANGES.markdown +2 -2
- data/CONTRIBUTING.markdown +6 -2
- data/README.markdown +10 -4
- data/Rakefile +1 -1
- data/data/Asia/Nicosia.json +1 -1
- data/lib/timezone.rb +2 -2
- data/lib/timezone/active_support.rb +410 -0
- data/lib/timezone/parser.rb +29 -0
- data/lib/timezone/parser/data.rb +103 -0
- data/lib/timezone/parser/link.rb +10 -0
- data/lib/timezone/parser/rule.rb +55 -0
- data/lib/timezone/parser/rule/entry.rb +74 -0
- data/lib/timezone/parser/rule/on.rb +30 -0
- data/lib/timezone/parser/rule/on_rules.rb +30 -0
- data/lib/timezone/parser/zone.rb +37 -0
- data/lib/timezone/parser/zone/data_generator.rb +77 -0
- data/lib/timezone/parser/zone/entry.rb +39 -0
- data/lib/timezone/parser/zone/until.rb +28 -0
- data/lib/timezone/version.rb +1 -1
- data/lib/timezone/zone.rb +7 -2
- data/test/data/asia +2717 -0
- data/test/timezone/parser/rule/on_rules_test.rb +20 -0
- data/test/timezone/parser/rule_test.rb +52 -0
- data/test/timezone/parser/zone/data_generator_test.rb +101 -0
- data/test/timezone/parser/zone/until_test.rb +13 -0
- data/test/timezone/parser/zone_test.rb +42 -0
- data/test/timezone/parser_test.rb +12 -0
- data/test/timezone_test.rb +47 -1
- data/timezone.gemspec +1 -1
- metadata +33 -10
- data/lib/timezone/rule.rb +0 -30
- data/test/rule_test.rb +0 -42
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'timezone/parser/rule'
|
2
|
+
require 'timezone/parser/data'
|
3
|
+
require 'timezone/parser/zone'
|
4
|
+
require 'timezone/parser/zone/data_generator'
|
5
|
+
|
6
|
+
module Timezone
|
7
|
+
module Parser
|
8
|
+
COMMENT_REGEXP = /^\s*#/
|
9
|
+
RULE_REGEXP = /^Rule/
|
10
|
+
LINK_REGEXP = /^Link/
|
11
|
+
ZONE_REGEXP = /^Zone/
|
12
|
+
|
13
|
+
def self.parse(file)
|
14
|
+
IO.readlines(file).map(&:strip).each do |line|
|
15
|
+
if line =~ COMMENT_REGEXP
|
16
|
+
next
|
17
|
+
elsif line =~ RULE_REGEXP
|
18
|
+
rule(line)
|
19
|
+
elsif line =~ LINK_REGEXP
|
20
|
+
# TODO [panthomakos] Need to add linking.
|
21
|
+
elsif line =~ ZONE_REGEXP || (line != '' && !line.nil?)
|
22
|
+
zone(line)
|
23
|
+
else
|
24
|
+
Timezone::Parser::Zone.generate(Timezone::Parser::Zone.last)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
module Timezone
|
4
|
+
module Parser
|
5
|
+
def self.data(*args) ; Data.new(*args) ; end
|
6
|
+
|
7
|
+
def self.from_zone(previous, zone)
|
8
|
+
data(previous && previous.end_date, zone.end_date, zone)
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.extension(previous, zone)
|
12
|
+
data(previous.end_date, nil, zone)
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.from_rule(zone, rule)
|
16
|
+
data(rule.start_date, nil, zone, rule)
|
17
|
+
end
|
18
|
+
|
19
|
+
START_DATE = -377705116800000 # The very last date '9999-12-31T23:59:59Z'.
|
20
|
+
END_DATE = 253402300799000 # The very first date '-9999-01-01T00:00:00Z'.
|
21
|
+
|
22
|
+
class NilRule
|
23
|
+
def letter ; '-' ; end
|
24
|
+
def offset ; 0 ; end
|
25
|
+
def utime? ; false ; end
|
26
|
+
def dst? ; false ; end
|
27
|
+
end
|
28
|
+
|
29
|
+
# The resulting JSON data structure for a timezone file.
|
30
|
+
class Data
|
31
|
+
attr_accessor :start_date, :dst, :offset, :name
|
32
|
+
|
33
|
+
def initialize(start_date, end_date, zone, rule = NilRule.new)
|
34
|
+
@dst = rule.dst?
|
35
|
+
@offset = parse_offset(zone, rule)
|
36
|
+
@name = parse_name(zone.format, rule.letter)
|
37
|
+
@utime = rule.utime?
|
38
|
+
@start_date = parse_start_date(start_date)
|
39
|
+
self.end_date = end_date
|
40
|
+
end
|
41
|
+
|
42
|
+
def end_date=(date)
|
43
|
+
if date.nil? || @utime
|
44
|
+
@end_date = date
|
45
|
+
else
|
46
|
+
@end_date = date - (@offset * 1_000)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def end_date
|
51
|
+
@end_date || END_DATE
|
52
|
+
end
|
53
|
+
|
54
|
+
def has_end_date?
|
55
|
+
!!@end_date
|
56
|
+
end
|
57
|
+
|
58
|
+
def to_hash
|
59
|
+
{
|
60
|
+
'_from' => _from,
|
61
|
+
'from' => @start_date,
|
62
|
+
'_to' => _to,
|
63
|
+
'to' => end_date,
|
64
|
+
'dst' => @dst,
|
65
|
+
'offset' => @offset,
|
66
|
+
'name' => @name
|
67
|
+
}
|
68
|
+
end
|
69
|
+
|
70
|
+
def to_json
|
71
|
+
to_hash.to_json
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def parse_offset(zone, rule)
|
77
|
+
zone.offset + rule.offset
|
78
|
+
end
|
79
|
+
|
80
|
+
def parse_start_date(date)
|
81
|
+
date || START_DATE
|
82
|
+
end
|
83
|
+
|
84
|
+
# Fills in a zone entry format with a rule entry letter.
|
85
|
+
#
|
86
|
+
# Examples:
|
87
|
+
#
|
88
|
+
# format "EE%sT" w/ letter "S" results in EEST
|
89
|
+
# format "EE%sT" w/ letter "-" results in EET
|
90
|
+
def parse_name(format, letter)
|
91
|
+
format.sub(/%s/, letter == '-' ? '' : letter)
|
92
|
+
end
|
93
|
+
|
94
|
+
def _from ; ftime(@start_date) ; end
|
95
|
+
def _to ; ftime(end_date) ; end
|
96
|
+
|
97
|
+
# Converts a millisecond time into the proper JSON output string.
|
98
|
+
def ftime(time)
|
99
|
+
Time.at(time / 1_000).utc.strftime('%Y-%m-%dT%H:%M:%SZ')
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'time'
|
2
|
+
|
3
|
+
module Timezone
|
4
|
+
module Parser
|
5
|
+
# Given a rule from a TZDATA file, generate the appropriate rule objects.
|
6
|
+
def self.rule(line) ; Rule.generate(line) ; end
|
7
|
+
|
8
|
+
# Get a list of all processed rules.
|
9
|
+
def self.rules ; Rule.rules ; end
|
10
|
+
|
11
|
+
# Select rules based on a name and end date.
|
12
|
+
def self.select_rules(name, end_date)
|
13
|
+
rules.fetch(name){ [] }
|
14
|
+
.select{ |rule| end_date.nil? || rule.start_date < end_date }
|
15
|
+
end
|
16
|
+
|
17
|
+
module Rule
|
18
|
+
# Format: Rule NAME FROM TO TYPE IN ON AT SAVE LETTER/S
|
19
|
+
# Example: Rule EUAsia 1981 max - Mar lastSun 1:00u 1:00 S
|
20
|
+
RULE = /Rule\s+([\w-]+?)\s+(\d+?)\s+([^\s]+?)\s+([^\s]+?)\s+(\s*\w+?)\s+([\d\w\=\>\<]+?)\s+([\d:us]+?)\s+([\d:]+?)\s+([\w-]+)/
|
21
|
+
|
22
|
+
END_YEAR = 2050 # The actual value for the "to" field when set to "max".
|
23
|
+
|
24
|
+
# Rules are stored in a hash of arrays that are referenced by rule name.
|
25
|
+
@@rules = Hash.new{ |h, k| h[k] = [] }
|
26
|
+
def self.rules ; @@rules ; end
|
27
|
+
|
28
|
+
class << self
|
29
|
+
def generate(line)
|
30
|
+
name, from, to, *values = *line.match(RULE)[1..-1]
|
31
|
+
|
32
|
+
years(from, to).each do |year|
|
33
|
+
@@rules[name] << Entry.new(name, year, *values)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def years(from, to)
|
40
|
+
(from.to_i..parse_end_year(from, to))
|
41
|
+
end
|
42
|
+
|
43
|
+
def parse_end_year(from, to)
|
44
|
+
case to
|
45
|
+
when 'only' then from.to_i
|
46
|
+
when 'max' then END_YEAR
|
47
|
+
else to.to_i
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
require 'timezone/parser/rule/entry'
|
@@ -0,0 +1,74 @@
|
|
1
|
+
require 'timezone/parser/rule'
|
2
|
+
require 'timezone/parser/rule/on_rules'
|
3
|
+
|
4
|
+
module Timezone::Parser::Rule
|
5
|
+
class Entry
|
6
|
+
attr_accessor :name, :offset
|
7
|
+
attr_reader :letter, :start_date
|
8
|
+
|
9
|
+
UTIME = /^.*u$/
|
10
|
+
STIME = /^.*s$/
|
11
|
+
START_DATE = '%Y %b %d %H:%M %Z'
|
12
|
+
|
13
|
+
def initialize(name, year, type, month, day, time, save, letter)
|
14
|
+
@name, @offset, @letter = name, offset, letter
|
15
|
+
|
16
|
+
@month, @day = parse_month_day(day, month, year)
|
17
|
+
@utime = parse_utime(time)
|
18
|
+
@stime = parse_stime(time)
|
19
|
+
@time = parse_time(time)
|
20
|
+
@day = parse_day(@day)
|
21
|
+
@offset = parse_offset(save)
|
22
|
+
@start_date = parse_start_date(year, @month, @day, @time)
|
23
|
+
@dst = parse_dst(save)
|
24
|
+
end
|
25
|
+
|
26
|
+
def utime? ; @utime ; end
|
27
|
+
def stime? ; @stime ; end
|
28
|
+
def dst? ; @dst ; end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
# Day should be zero padded.
|
33
|
+
def parse_day(day)
|
34
|
+
'%.2d' % day.to_i
|
35
|
+
end
|
36
|
+
|
37
|
+
# Time should be zero padded and not include 'u' or 's'.
|
38
|
+
def parse_time(time)
|
39
|
+
time = "0#{time}" if time.match(/^\d:\d\d/)
|
40
|
+
time = time.gsub(/u/, '') if utime?
|
41
|
+
time = time.gsub(/s/, '') if stime?
|
42
|
+
|
43
|
+
time
|
44
|
+
end
|
45
|
+
|
46
|
+
def parse_utime(time)
|
47
|
+
time =~ UTIME
|
48
|
+
end
|
49
|
+
|
50
|
+
def parse_stime(time)
|
51
|
+
time =~ STIME
|
52
|
+
end
|
53
|
+
|
54
|
+
# Offset is calculated in seconds.
|
55
|
+
def parse_offset(save)
|
56
|
+
offset = Time.parse(save == '0' ? '0:00' : save)
|
57
|
+
offset.hour*60*60 + offset.min*60 + offset.sec
|
58
|
+
end
|
59
|
+
|
60
|
+
# Check special rules that modify the month and day depending on the year.
|
61
|
+
def parse_month_day(day, month, year)
|
62
|
+
On.parse(day, month, year)
|
63
|
+
end
|
64
|
+
|
65
|
+
# The UTC time on which the rule beings to apply in milliseconds.
|
66
|
+
def parse_start_date(y, m, d, t)
|
67
|
+
Time.strptime([y, m, d, t, 'UTC'].join(' '), START_DATE).to_i * 1_000
|
68
|
+
end
|
69
|
+
|
70
|
+
def parse_dst(save)
|
71
|
+
save != '0'
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'timezone/parser/rule'
|
2
|
+
require 'time'
|
3
|
+
|
4
|
+
module Timezone::Parser::Rule
|
5
|
+
def self.on(*args) ; On.new(*args) ; end
|
6
|
+
|
7
|
+
class On
|
8
|
+
@@rules = []
|
9
|
+
|
10
|
+
# Given a Rule `on` field, parse the appropriate day and month.
|
11
|
+
def self.parse(day, month, year)
|
12
|
+
@@rules.each do |rule|
|
13
|
+
if match = day.match(rule.expression)
|
14
|
+
return rule.block.call(match, day, month, year)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
[month, day]
|
19
|
+
end
|
20
|
+
|
21
|
+
attr_reader :expression, :block
|
22
|
+
|
23
|
+
def initialize(name, expression, block)
|
24
|
+
@name = name
|
25
|
+
@expression = expression
|
26
|
+
@block = block
|
27
|
+
@@rules << self
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'timezone/parser/rule/on'
|
2
|
+
|
3
|
+
# A simple DSL for definining rules to parse the "ON" field in TZData rules.
|
4
|
+
module Timezone::Parser::Rule
|
5
|
+
on 'lastDAY', /^last(\w+)$/, lambda{ |match, _, month, year|
|
6
|
+
31.downto(1).each do |day|
|
7
|
+
begin
|
8
|
+
date = Time.strptime("#{year} #{month} #{day}", '%Y %b %d')
|
9
|
+
|
10
|
+
if date.strftime('%a') == match[1]
|
11
|
+
return [month, date.strftime('%d')]
|
12
|
+
end
|
13
|
+
rescue
|
14
|
+
next
|
15
|
+
end
|
16
|
+
end
|
17
|
+
}
|
18
|
+
|
19
|
+
on 'DAY>=NUM', /^(\w+)>=(\d+)$/, lambda{ |match, _, month, year|
|
20
|
+
start = Time.strptime("#{year} #{month} #{match[2]}", '%Y %b %d')
|
21
|
+
|
22
|
+
(1..8).to_a.each do |plus|
|
23
|
+
date = start + (plus * 24 * 60 * 60)
|
24
|
+
|
25
|
+
if date.strftime('%a') == match[1]
|
26
|
+
return [date.strftime('%b'), date.strftime('%d')]
|
27
|
+
end
|
28
|
+
end
|
29
|
+
}
|
30
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'time'
|
2
|
+
|
3
|
+
module Timezone
|
4
|
+
module Parser
|
5
|
+
# Given a line from the TZDATA file, generate an Entry object.
|
6
|
+
def self.zone(line) ; Zone.parse(line) ; end
|
7
|
+
|
8
|
+
# Get a list of all processed entries.
|
9
|
+
def self.zones ; Zone.zones ; end
|
10
|
+
|
11
|
+
module Zone
|
12
|
+
# Each entry follows this format.
|
13
|
+
# GMT-OFFSET RULES FORMAT [UNTIL]
|
14
|
+
ENTRY = /(\d+?:\d+?:*\d*?)\s+(.+?)\s([^\s]+)\s*(.*?)$/
|
15
|
+
|
16
|
+
# The header entry also includes the Zone name.
|
17
|
+
# Zone ZONE-NAME GMT-OFFSET RULES FORMAT [UNTIL]
|
18
|
+
HEADER = /Zone\s+(.+?)\s+/
|
19
|
+
|
20
|
+
# Zones are stored in a hash of arrays that are referenced by name.
|
21
|
+
@@zones = Hash.new{ |h, k| h[k] = [] }
|
22
|
+
def self.zones ; @@zones ; end
|
23
|
+
|
24
|
+
# The name of the current zone is parsed from the header zone entry.
|
25
|
+
# It can then be accessed using `Timezone::Parser::Zone.last`.
|
26
|
+
class << self ; attr_accessor :last ; end
|
27
|
+
|
28
|
+
def self.parse(line)
|
29
|
+
self.last = $~[1] if line.match(HEADER)
|
30
|
+
|
31
|
+
@@zones[last] << Entry.new(last, *line.match(ENTRY)[1..-1])
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
require 'timezone/parser/zone/entry'
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require 'timezone/parser/zone'
|
2
|
+
require 'timezone/parser/data'
|
3
|
+
require 'time'
|
4
|
+
|
5
|
+
module Timezone::Parser::Zone
|
6
|
+
def self.generate(zones) ; DataGenerator.generate(zones) ; end
|
7
|
+
|
8
|
+
def self.data ; DataGenerator.data ; end
|
9
|
+
|
10
|
+
# TODO [panthomakos] This needs refactoring.
|
11
|
+
module DataGenerator
|
12
|
+
@@data = Hash.new{ |h,k| h[k] = [] }
|
13
|
+
def self.data ; @@data ; end
|
14
|
+
|
15
|
+
class << self
|
16
|
+
def generate(zone)
|
17
|
+
zones = Timezone::Parser.zones[zone].to_a
|
18
|
+
|
19
|
+
return if zones.empty?
|
20
|
+
|
21
|
+
@@data[zone] = zones
|
22
|
+
.each_cons(2)
|
23
|
+
.inject(update(zones.first)) do |set, (previous, zone)|
|
24
|
+
update(zone, set, previous && previous.end_date)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def update(zone, set = [], limit = nil)
|
31
|
+
additions = [first_addition(zone, set)]
|
32
|
+
|
33
|
+
zone.rules.each do |rule|
|
34
|
+
data = additions.last
|
35
|
+
|
36
|
+
if rule_applies?(rule, data, limit)
|
37
|
+
Timezone::Parser.from_rule(zone, rule).tap do |insert|
|
38
|
+
data.end_date = insert.start_date
|
39
|
+
# We do this because the start date is always based on the
|
40
|
+
# previous entry end date calculation.
|
41
|
+
insert.start_date = data.end_date
|
42
|
+
|
43
|
+
additions << insert
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
set + additions
|
49
|
+
end
|
50
|
+
|
51
|
+
# The rule has to fall within the time range (start_date..limit).
|
52
|
+
def rule_applies?(rule, data, limit)
|
53
|
+
rule.start_date > data.start_date &&
|
54
|
+
(!limit || rule.start_date > limit)
|
55
|
+
end
|
56
|
+
|
57
|
+
def first_addition(zone, set)
|
58
|
+
previous = set.last
|
59
|
+
|
60
|
+
if zone.rules.empty?
|
61
|
+
# If there are no rules, generate a new entry for this time period.
|
62
|
+
Timezone::Parser.from_zone(previous, zone)
|
63
|
+
else
|
64
|
+
if previous && previous.has_end_date?
|
65
|
+
# If the last entry had a hard cutoff end date, create a new
|
66
|
+
# addition that picks up from where the last entry left off.
|
67
|
+
Timezone::Parser.extension(previous, zone)
|
68
|
+
else
|
69
|
+
# If the last entry did not have a hard cutoff end date, pop it off
|
70
|
+
# the stack for use in these calculations.
|
71
|
+
set.pop
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|