timezone 0.2.1 → 0.3.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.
- 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
|