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.
@@ -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,10 @@
1
+ module Timezone
2
+ module Parser
3
+ def self.link(line) ; Link.new(line) ; end
4
+
5
+ class Link
6
+ def initialize(line)
7
+ end
8
+ end
9
+ end
10
+ 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