calendarium-romanum 0.4.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (106) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +4 -0
  3. data/.rspec +2 -0
  4. data/.rubocop.yml +47 -0
  5. data/.travis.yml +22 -0
  6. data/.yardopts +3 -0
  7. data/CHANGELOG.md +431 -0
  8. data/Gemfile +25 -0
  9. data/Gemfile.lock +86 -0
  10. data/README.md +598 -0
  11. data/Rakefile +16 -0
  12. data/bin/calendariumrom +4 -1
  13. data/calendarium-romanum.gemspec +31 -0
  14. data/config/locales/cs.yml +5 -0
  15. data/config/locales/en.yml +21 -14
  16. data/config/locales/es.yml +94 -0
  17. data/config/locales/fr.yml +7 -0
  18. data/config/locales/it.yml +7 -0
  19. data/config/locales/la.yml +7 -0
  20. data/data/README.md +70 -24
  21. data/data/czech-brno-cs.txt +4 -6
  22. data/data/czech-budejovice-cs.txt +4 -6
  23. data/data/czech-cechy-cs.txt +4 -5
  24. data/data/czech-cs.txt +236 -234
  25. data/data/czech-hradec-cs.txt +3 -5
  26. data/data/czech-litomerice-cs.txt +5 -7
  27. data/data/czech-morava-cs.txt +4 -5
  28. data/data/czech-olomouc-cs.txt +2 -4
  29. data/data/czech-ostrava-cs.txt +3 -5
  30. data/data/czech-plzen-cs.txt +3 -5
  31. data/data/czech-praha-cs.txt +3 -4
  32. data/data/easter_dates.txt +67 -0
  33. data/data/universal-1969-la.txt +234 -0
  34. data/data/universal-en.txt +214 -211
  35. data/data/universal-es.txt +243 -0
  36. data/data/universal-fr.txt +214 -210
  37. data/data/universal-it.txt +214 -211
  38. data/data/universal-la.txt +214 -210
  39. data/doc/data_readme.md +2 -0
  40. data/doc/images/class_diagram.png +0 -0
  41. data/doc/images/class_diagram.puml +44 -0
  42. data/doc/yard_readme.rdoc +76 -0
  43. data/lib/calendarium-romanum.rb +35 -22
  44. data/lib/calendarium-romanum/abstract_date.rb +15 -0
  45. data/lib/calendarium-romanum/calendar.rb +207 -42
  46. data/lib/calendarium-romanum/cli.rb +63 -80
  47. data/lib/calendarium-romanum/cli/comparator.rb +63 -0
  48. data/lib/calendarium-romanum/cli/date_parser.rb +30 -0
  49. data/lib/calendarium-romanum/cli/dumper.rb +68 -0
  50. data/lib/calendarium-romanum/cli/helper.rb +23 -0
  51. data/lib/calendarium-romanum/cli/querier.rb +73 -0
  52. data/lib/calendarium-romanum/cr.rb +16 -0
  53. data/lib/calendarium-romanum/data.rb +50 -20
  54. data/lib/calendarium-romanum/day.rb +208 -32
  55. data/lib/calendarium-romanum/enum.rb +42 -25
  56. data/lib/calendarium-romanum/enums.rb +124 -44
  57. data/lib/calendarium-romanum/errors.rb +4 -0
  58. data/lib/calendarium-romanum/ordinalizer.rb +23 -2
  59. data/lib/calendarium-romanum/perpetual_calendar.rb +58 -7
  60. data/lib/calendarium-romanum/rank.rb +43 -12
  61. data/lib/calendarium-romanum/rank_predicates.rb +43 -0
  62. data/lib/calendarium-romanum/sanctorale.rb +164 -24
  63. data/lib/calendarium-romanum/sanctorale_factory.rb +74 -3
  64. data/lib/calendarium-romanum/sanctorale_loader.rb +180 -0
  65. data/lib/calendarium-romanum/sanctorale_writer.rb +119 -0
  66. data/lib/calendarium-romanum/temporale.rb +226 -94
  67. data/lib/calendarium-romanum/temporale/celebration_factory.rb +107 -0
  68. data/lib/calendarium-romanum/temporale/dates.rb +84 -16
  69. data/lib/calendarium-romanum/temporale/easter_table.rb +27 -0
  70. data/lib/calendarium-romanum/temporale/extensions.rb +15 -0
  71. data/lib/calendarium-romanum/temporale/extensions/christ_eternal_priest.rb +16 -3
  72. data/lib/calendarium-romanum/temporale/extensions/dedication_before_all_saints.rb +73 -0
  73. data/lib/calendarium-romanum/transfers.rb +60 -15
  74. data/lib/calendarium-romanum/util.rb +22 -3
  75. data/lib/calendarium-romanum/version.rb +5 -1
  76. data/liturgical_law/1969_normae_universales.md +568 -0
  77. data/liturgical_law/1977_decretum_de_celebratione_baptismatis_domini.md +58 -0
  78. data/liturgical_law/1990_decretum_de_variatione_inducenda.md +67 -0
  79. data/liturgical_law/1998_notificatio_de_occurrentia.md +57 -0
  80. data/liturgical_law/2002_normae_universales.md +946 -0
  81. data/liturgical_law/2006_notification.md +37 -0
  82. data/liturgical_law/2012_declarationes.md +38 -0
  83. data/liturgical_law/README.md +74 -0
  84. metadata +50 -28
  85. data/lib/calendarium-romanum/sanctoraleloader.rb +0 -115
  86. data/spec/abstract_date_spec.rb +0 -62
  87. data/spec/calendar_spec.rb +0 -330
  88. data/spec/celebration_spec.rb +0 -23
  89. data/spec/cli_spec.rb +0 -26
  90. data/spec/colour_spec.rb +0 -17
  91. data/spec/data_spec.rb +0 -23
  92. data/spec/date_spec.rb +0 -61
  93. data/spec/dates_spec.rb +0 -45
  94. data/spec/day_spec.rb +0 -59
  95. data/spec/enum_spec.rb +0 -51
  96. data/spec/i18n_spec.rb +0 -59
  97. data/spec/ordinalizer_spec.rb +0 -22
  98. data/spec/perpetual_calendar_spec.rb +0 -91
  99. data/spec/rank_spec.rb +0 -57
  100. data/spec/readme_spec.rb +0 -52
  101. data/spec/sanctorale_factory_spec.rb +0 -42
  102. data/spec/sanctorale_spec.rb +0 -191
  103. data/spec/sanctoraleloader_spec.rb +0 -171
  104. data/spec/season_spec.rb +0 -17
  105. data/spec/spec_helper.rb +0 -35
  106. data/spec/temporale_spec.rb +0 -519
@@ -0,0 +1,43 @@
1
+ module CalendariumRomanum
2
+ # Mixin providing rank-describing predicates.
3
+ # Expects the including class to have instance method +#rank+ returning a {Rank}.
4
+ module RankPredicates
5
+ # @return [Boolean]
6
+ def solemnity?
7
+ rank.priority.to_i == 1
8
+ end
9
+
10
+ # @return [Boolean]
11
+ # @since 0.6.0
12
+ def sunday?
13
+ rank == Ranks::SUNDAY_UNPRIVILEGED
14
+ end
15
+
16
+ # @return [Boolean]
17
+ def feast?
18
+ rank.priority.to_i == 2
19
+ end
20
+
21
+ # @return [Boolean]
22
+ def memorial?
23
+ rank.priority.to_i == 3 && rank.priority <= 3.12
24
+ end
25
+
26
+ # @return [Boolean]
27
+ def optional_memorial?
28
+ rank == Ranks::MEMORIAL_OPTIONAL
29
+ end
30
+
31
+ # @return [Boolean]
32
+ def obligatory_memorial?
33
+ memorial? && !optional_memorial?
34
+ end
35
+
36
+ # @return [Boolean]
37
+ # @since 0.6.0
38
+ def ferial?
39
+ rank == Ranks::FERIAL ||
40
+ rank == Ranks::FERIAL_PRIVILEGED
41
+ end
42
+ end
43
+ end
@@ -1,27 +1,60 @@
1
+ require 'set'
2
+
1
3
  module CalendariumRomanum
2
4
 
3
- # knows the fixed-date celebrations
5
+ # One of the two main {Calendar} components.
6
+ # Contains celebrations with fixed date, mostly feasts of saints.
7
+ #
8
+ # Basically a mapping {AbstractDate} => Array<{Celebration}>
9
+ # additionally enforcing some constraints:
10
+ #
11
+ # - for a given {AbstractDate} there may be multiple {Celebration}s,
12
+ # but only if all of them are in the rank of an optional
13
+ # memorial
14
+ # - {Celebration#symbol} must be unique in the whole set of
15
+ # contained celebrations
4
16
  class Sanctorale
5
17
 
6
18
  def initialize
7
19
  @days = {}
8
-
9
20
  @solemnities = {}
21
+ @symbols = Set.new
22
+ @metadata = nil
10
23
  end
11
24
 
25
+ # Content subset - only {Celebration}s in the rank(s) of solemnity.
26
+ #
27
+ # @return [Hash<AbstractDate=>Celebration>]
12
28
  attr_reader :solemnities
13
29
 
30
+ # Sanctorale metadata.
31
+ #
32
+ # Data files may contain YAML front matter.
33
+ # If provided, it's loaded by {SanctoraleLoader} and
34
+ # stored in this property.
35
+ # All data files bundled in the gem (see {Data}) have YAML
36
+ # front matter which is a Hash with a few standardized keys.
37
+ # While YAML also supports top-level content of other types,
38
+ # sanctorale data authors should stick to the convention
39
+ # of using Hash as the top-level data structure of their
40
+ # front matters.
41
+ #
42
+ # @return [Hash, nil]
43
+ # @since 0.7.0
44
+ attr_accessor :metadata
45
+
46
+ # Adds a new {Celebration}
47
+ #
48
+ # @param month [Integer]
49
+ # @param day [Integer]
50
+ # @param celebration [Celebration]
51
+ # @return [void]
52
+ # @raise [ArgumentError]
53
+ # when performing the operation would break the object's invariant
14
54
  def add(month, day, celebration)
15
55
  date = AbstractDate.new(month, day)
16
- unless @days.has_key? date
17
- @days[date] = []
18
- end
19
-
20
- if celebration.solemnity?
21
- @solemnities[date] = celebration
22
- end
23
56
 
24
- unless @days[date].empty?
57
+ unless @days[date].nil? || @days[date].empty?
25
58
  present = @days[date][0]
26
59
  if present.rank != Ranks::MEMORIAL_OPTIONAL
27
60
  raise ArgumentError.new("On #{date} there is already a #{present.rank}. No more celebrations can be added.")
@@ -30,13 +63,54 @@ module CalendariumRomanum
30
63
  end
31
64
  end
32
65
 
66
+ unless celebration.symbol.nil?
67
+ if @symbols.include? celebration.symbol
68
+ raise ArgumentError.new("Attempted to add Celebration with duplicate symbol #{celebration.symbol.inspect}")
69
+ end
70
+
71
+ @symbols << celebration.symbol
72
+ end
73
+
74
+ unless @days.has_key? date
75
+ @days[date] = []
76
+ end
77
+
78
+ if celebration.solemnity?
79
+ @solemnities[date] = celebration
80
+ end
81
+
33
82
  @days[date] << celebration
34
83
  end
35
84
 
36
- # replaces content of the given day by given celebrations
37
- def replace(month, day, celebrations)
85
+ # Replaces content of the given day by given {Celebration}s
86
+ #
87
+ # @param month [Integer]
88
+ # @param day [Integer]
89
+ # @param celebrations [Array<Celebration>]
90
+ # @param symbol_uniqueness [true|false]
91
+ # allows disabling symbol uniqueness check.
92
+ # Internal feature, not intended for use by client code.
93
+ # @return [void]
94
+ # @raise [ArgumentError]
95
+ # when performing the operation would break the object's invariant
96
+ def replace(month, day, celebrations, symbol_uniqueness: true)
38
97
  date = AbstractDate.new(month, day)
39
98
 
99
+ symbols_without_day = @symbols
100
+ unless @days[date].nil?
101
+ old_symbols = @days[date].collect(&:symbol).compact
102
+ symbols_without_day = @symbols - old_symbols
103
+ end
104
+
105
+ new_symbols = celebrations.collect(&:symbol).compact
106
+ duplicate = symbols_without_day.intersection new_symbols
107
+ if symbol_uniqueness && !duplicate.empty?
108
+ raise ArgumentError.new("Attempted to add Celebrations with duplicate symbols #{duplicate.to_a.inspect}")
109
+ end
110
+
111
+ @symbols = symbols_without_day
112
+ @symbols.merge new_symbols
113
+
40
114
  if celebrations.first.solemnity?
41
115
  @solemnities[date] = celebrations.first
42
116
  elsif @solemnities.has_key? date
@@ -46,17 +120,39 @@ module CalendariumRomanum
46
120
  @days[date] = celebrations.dup
47
121
  end
48
122
 
49
- # adds all Celebrations from another instance
50
- def update(sanctorale)
51
- sanctorale.each_day do |date, celebrations|
52
- replace date.month, date.day, celebrations
123
+ # Updates the receiver with {Celebration}s from another instance.
124
+ #
125
+ # For each date contained in +other+ the content of +self+
126
+ # is _replaced_ by that of +other+.
127
+ #
128
+ # @param other [Sanctorale]
129
+ # @return [void]
130
+ # @raise (see #replace)
131
+ def update(other)
132
+ other.each_day do |date, celebrations|
133
+ replace date.month, date.day, celebrations, symbol_uniqueness: false
53
134
  end
135
+ rebuild_symbols
54
136
  end
55
137
 
56
- # returns an Array with one or more Celebrations
57
- # scheduled for the given day
138
+ # Retrieves {Celebration}s for the given date
58
139
  #
59
- # expected arguments: Date or two Integers (month, day)
140
+ # @param date [AbstractDate, Date]
141
+ # @return [Array<Celebration>] (may be empty)
142
+ # @since 0.6.0
143
+ def [](date)
144
+ adate = date.is_a?(AbstractDate) ? date : AbstractDate.from_date(date)
145
+ @days[adate] || []
146
+ end
147
+
148
+ # Retrieves {Celebration}s for the given date
149
+ #
150
+ # @overload get(date)
151
+ # @param date[AbstractDate, Date]
152
+ # @overload get(month, day)
153
+ # @param month [Integer]
154
+ # @param day [Integer]
155
+ # @return (see #[])
60
156
  def get(*args)
61
157
  if args.size == 1 && args[0].is_a?(Date)
62
158
  month = args[0].month
@@ -66,31 +162,75 @@ module CalendariumRomanum
66
162
  end
67
163
 
68
164
  date = AbstractDate.new(month, day)
69
- return @days[date] || []
165
+ self[date]
70
166
  end
71
167
 
72
- # for each day for which an entry is available
73
- # yields an AbstractDate and an Array of Celebrations
168
+ # Enumerates dates for which any {Celebration}s are available
169
+ #
170
+ # @yield [AbstractDate, Array<Celebration>] the array is never empty
171
+ # @return [void, Enumerator] if called without a block, returns +Enumerator+
74
172
  def each_day
173
+ return to_enum(__method__) unless block_given?
174
+
75
175
  @days.each_pair do |date, celebrations|
76
176
  yield date, celebrations
77
177
  end
78
178
  end
79
179
 
80
- # returns count of the _days_ with celebrations filled
180
+ # Returns count of _days_ with {Celebration}s filled
181
+ #
182
+ # @return [Integer]
81
183
  def size
82
184
  @days.size
83
185
  end
84
186
 
187
+ # It is empty if it doesn't contain any {Celebration}
188
+ #
189
+ # @return [Boolean]
85
190
  def empty?
86
191
  @days.empty?
87
192
  end
88
193
 
194
+ # Freezes the instance
89
195
  def freeze
90
196
  @days.freeze
91
- @days.values.each &:freeze
197
+ @days.values.each(&:freeze)
92
198
  @solemnities.freeze
93
199
  super
94
200
  end
201
+
202
+ # @return [Boolean]
203
+ # @since 0.6.0
204
+ def ==(b)
205
+ self.class == b.class &&
206
+ days == b.days
207
+ end
208
+
209
+ protected
210
+
211
+ attr_reader :days
212
+
213
+ # Builds the registry of celebration symbols anew,
214
+ # raises error if any duplicates are found.
215
+ def rebuild_symbols
216
+ @symbols = Set.new
217
+ duplicates = []
218
+
219
+ @days.each_pair do |date,celebrations|
220
+ celebrations.each do |celebration|
221
+ if @symbols.include?(celebration.symbol) &&
222
+ !duplicates.include?(celebration.symbol) &&
223
+ !celebration.symbol.nil?
224
+ duplicates << celebration.symbol
225
+ end
226
+
227
+ @symbols << celebration.symbol
228
+ end
229
+ end
230
+
231
+ unless duplicates.empty?
232
+ raise ArgumentError.new("Duplicate celebration symbols: #{duplicates.inspect}")
233
+ end
234
+ end
95
235
  end
96
236
  end
@@ -1,15 +1,50 @@
1
1
  module CalendariumRomanum
2
- # conveniently creates sanctorale from several data files
2
+ # Utility loading {Sanctorale} from several sources
3
+ # and building a single {Sanctorale} by layering them
4
+ # over each other.
3
5
  class SanctoraleFactory
4
6
  class << self
5
- # layers several sanctorale instances.
7
+ # Takes several {Sanctorale} instances, returns a new one,
8
+ # created by merging them all together
9
+ # (using {Sanctorale#update})
10
+ #
11
+ # @return [Sanctorale]
12
+ #
13
+ # @example
14
+ # include CalendariumRomanum
15
+ #
16
+ # prague_sanctorale = SanctoraleFactory.create_layered(
17
+ # Data['czech-cs'].load, # Czech Republic
18
+ # Data['czech-cechy-cs'].load, # Province of Bohemia
19
+ # Data['czech-praha-cs'].load, # Archdiocese of Prague
20
+ # )
6
21
  def create_layered(*instances)
7
22
  r = Sanctorale.new
8
23
  instances.each {|i| r.update i }
24
+
25
+ metadata = instances
26
+ .collect(&:metadata)
27
+ .select {|i| i.is_a? Hash }
28
+ r.metadata = metadata.inject((metadata.first || {}).dup) {|merged,i| merged.update i }
29
+ r.metadata.delete 'extends'
30
+ r.metadata['components'] = instances.collect(&:metadata)
31
+
9
32
  r
10
33
  end
11
34
 
12
- # loads and layers several sanctorale instances.
35
+ # Takes several filesystem paths, loads a {Sanctorale}
36
+ # from each of them (using {SanctoraleLoader})
37
+ # and then merges them (using {.create_layered})
38
+ #
39
+ # @return [Sanctorale]
40
+ #
41
+ # @example
42
+ # include CalendariumRomanum
43
+ #
44
+ # my_sanctorale = SanctoraleFactory.load_layered_from_files(
45
+ # 'my_data/general_calendar.txt',
46
+ # 'my_data/particular_calendar.txt'
47
+ # )
13
48
  def load_layered_from_files(*paths)
14
49
  loader = SanctoraleLoader.new
15
50
  instances = paths.collect do |p|
@@ -17,6 +52,42 @@ module CalendariumRomanum
17
52
  end
18
53
  create_layered(*instances)
19
54
  end
55
+
56
+ # Takes a single filesystem path. If the file's YAML front
57
+ # matter references any parent data files using the
58
+ # 'extends' key, it loads all the parents and assembles
59
+ # the resulting {Sanctorale}.
60
+ # If the data file doesn't reference any parents,
61
+ # result is the same as {SanctoraleLoader#load_from_file}.
62
+ #
63
+ # @return [Sanctorale]
64
+ # @since 0.7.0
65
+ def load_with_parents(path)
66
+ loader = SanctoraleLoader.new
67
+
68
+ hierarchy = load_parent_hierarchy(path, loader)
69
+ return hierarchy.first if hierarchy.size == 1
70
+
71
+ create_layered *hierarchy
72
+ end
73
+
74
+ private
75
+
76
+ def load_parent_hierarchy(path, loader)
77
+ main = loader.load_from_file path
78
+ return [main] unless main.metadata.is_a?(Hash) && main.metadata.has_key?('extends')
79
+
80
+ to_merge = [main]
81
+ parents = main.metadata['extends']
82
+ parents = [parents] unless parents.is_a? Array
83
+ parents.reverse.each do |parent_path|
84
+ expanded_path = File.expand_path parent_path, File.dirname(path)
85
+ subtree = load_parent_hierarchy(expanded_path, loader)
86
+ to_merge = subtree + to_merge
87
+ end
88
+
89
+ to_merge
90
+ end
20
91
  end
21
92
  end
22
93
  end
@@ -0,0 +1,180 @@
1
+ require 'yaml'
2
+
3
+ module CalendariumRomanum
4
+
5
+ # Understands a custom plaintext calendar format
6
+ # and knows how to transform it to {Celebration}s
7
+ # and fill them in a {Sanctorale}.
8
+ #
9
+ # For specification of the data format see {file:data/README.md}
10
+ # of the data directory, For a complete example see e.g.
11
+ # {file:universal-en.txt the file describing General Roman Calendar}.
12
+ class SanctoraleLoader
13
+
14
+ # @api private
15
+ RANK_CODES = {
16
+ nil => Ranks::MEMORIAL_OPTIONAL, # default
17
+ 'm' => Ranks::MEMORIAL_GENERAL,
18
+ 'mp' => Ranks::MEMORIAL_PROPER,
19
+ 'f' => Ranks::FEAST_GENERAL,
20
+ 'fl' => Ranks::FEAST_LORD_GENERAL,
21
+ 'fp' => Ranks::FEAST_PROPER,
22
+ 's' => Ranks::SOLEMNITY_GENERAL,
23
+ 'sp' => Ranks::SOLEMNITY_PROPER,
24
+ }.freeze
25
+
26
+ # @api private
27
+ COLOUR_CODES = {
28
+ nil => Colours::WHITE, # default
29
+ 'w' => Colours::WHITE,
30
+ 'v' => Colours::VIOLET,
31
+ 'g' => Colours::GREEN,
32
+ 'r' => Colours::RED
33
+ }.freeze
34
+
35
+ # Load from an object which understands +#each_line+
36
+ #
37
+ # @param src [String, File, #each_line]
38
+ # source of the loaded data
39
+ # @param dest [Sanctorale, nil]
40
+ # objects to populate. If not provided, a new {Sanctorale}
41
+ # instance will be created
42
+ # @return [Sanctorale]
43
+ # @raise [InvalidDataError]
44
+ def load(src, dest = nil)
45
+ dest ||= Sanctorale.new
46
+
47
+ in_front_matter = false
48
+ front_matter = ''
49
+ month_section = nil
50
+ src.each_line.with_index(1) do |l, line_num|
51
+ # skip YAML front matter
52
+ if line_num == 1 && l.start_with?('---')
53
+ in_front_matter = true
54
+ front_matter += l
55
+ next
56
+ elsif in_front_matter
57
+ if l.start_with?('---')
58
+ in_front_matter = false
59
+ dest.metadata = YAML.load(front_matter).freeze
60
+ end
61
+
62
+ front_matter += l
63
+
64
+ next
65
+ end
66
+
67
+ # strip whitespace and comments
68
+ l.sub!(/#.*/, '')
69
+ l.strip!
70
+ next if l.empty?
71
+
72
+ # month section heading
73
+ n = l.match(/^=\s*(\d+)\s*$/)
74
+ unless n.nil?
75
+ month_section = n[1].to_i
76
+ unless month_section >= 1 && month_section <= 12
77
+ raise error("Invalid month #{month_section}", line_num)
78
+ end
79
+ next
80
+ end
81
+
82
+ begin
83
+ celebration = load_line l, month_section
84
+ rescue RangeError, RuntimeError => err
85
+ raise error(err.message, line_num)
86
+ end
87
+
88
+ dest.add(
89
+ celebration.date.month,
90
+ celebration.date.day,
91
+ celebration
92
+ )
93
+ end
94
+
95
+ dest
96
+ end
97
+
98
+ alias load_from_string load
99
+
100
+ # Load from a filesystem path
101
+ #
102
+ # @param filename [String]
103
+ # @param dest [Sanctorale, nil]
104
+ # @param encoding [String]
105
+ # @return (see #load)
106
+ # @raise (see #load)
107
+ def load_from_file(filename, dest = nil, encoding = 'utf-8')
108
+ load File.open(filename, 'r', encoding: encoding), dest
109
+ end
110
+
111
+ private
112
+
113
+ def line_regexp
114
+ @line_regexp ||=
115
+ begin
116
+ rank_letters = RANK_CODES.keys.compact.join('|')
117
+ colour_letters = COLOUR_CODES.keys.compact.join('')
118
+
119
+ Regexp.new(
120
+ '^((?<month>\d+)\/)?(?<day>\d+)' + # date
121
+ '(\s+(?<rank_char>' + rank_letters + ')?(?<rank_num>\d\.\d{1,2})?)?' + # rank (optional)
122
+ '(\s+(?<colour>[' + colour_letters + ']))?' + # colour (optional)
123
+ '(\s+(?<symbol>[\w]{2,}))?' + # symbol (optional)
124
+ '\s*:(?<title>.*)$', # title
125
+ Regexp::IGNORECASE
126
+ )
127
+ end
128
+ end
129
+
130
+ # parses a line containing celebration record,
131
+ # returns a single Celebration
132
+ def load_line(line, month_section = nil)
133
+ # celebration record
134
+ m = line.match(line_regexp)
135
+ if m.nil?
136
+ raise RuntimeError.new("Syntax error, line skipped '#{line}'")
137
+ end
138
+
139
+ month = (m[:month] || month_section).to_i
140
+ day = m[:day].to_i
141
+ rank_char = m[:rank_char]
142
+ rank_num = m[:rank_num]
143
+ colour = m[:colour]
144
+ symbol_str = m[:symbol]
145
+ title = m[:title]
146
+
147
+ rank = RANK_CODES[rank_char && rank_char.downcase]
148
+
149
+ if rank_num
150
+ rank_num = rank_num.to_f
151
+ rank_by_num = Ranks[rank_num]
152
+
153
+ if rank_by_num.nil?
154
+ raise RuntimeError.new("Invalid celebration rank code #{rank_num}")
155
+ elsif rank_char && (rank.priority.to_i != rank_by_num.priority.to_i)
156
+ raise RuntimeError.new("Invalid combination of rank letter #{rank_char.inspect} and number #{rank_num}.")
157
+ end
158
+
159
+ rank = rank_by_num
160
+ end
161
+
162
+ symbol = nil
163
+ if symbol_str
164
+ symbol = symbol_str.to_sym
165
+ end
166
+
167
+ Celebration.new(
168
+ title.strip,
169
+ rank,
170
+ COLOUR_CODES[colour && colour.downcase],
171
+ symbol,
172
+ AbstractDate.new(month, day)
173
+ )
174
+ end
175
+
176
+ def error(message, line_number)
177
+ InvalidDataError.new("L#{line_number}: #{message}")
178
+ end
179
+ end
180
+ end