calendarium-romanum 0.5.0 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (113) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +5 -0
  3. data/.rspec +2 -0
  4. data/.rubocop.yml +50 -0
  5. data/.travis.yml +23 -0
  6. data/.yardopts +3 -0
  7. data/Appraisals +67 -0
  8. data/CHANGELOG.md +488 -0
  9. data/Gemfile +26 -0
  10. data/Gemfile.lock +95 -0
  11. data/README.md +601 -0
  12. data/Rakefile +27 -0
  13. data/bin/calendariumrom +3 -0
  14. data/calendarium-romanum.gemspec +31 -0
  15. data/config/locales/cs.yml +4 -0
  16. data/config/locales/en.yml +20 -14
  17. data/config/locales/es.yml +94 -0
  18. data/config/locales/fr.yml +6 -0
  19. data/config/locales/it.yml +6 -0
  20. data/config/locales/la.yml +6 -0
  21. data/config/locales/pt.yml +94 -0
  22. data/data/README.md +70 -24
  23. data/data/czech-brno-cs.txt +4 -6
  24. data/data/czech-budejovice-cs.txt +4 -6
  25. data/data/czech-cechy-cs.txt +4 -5
  26. data/data/czech-cs.txt +239 -235
  27. data/data/czech-hradec-cs.txt +3 -5
  28. data/data/czech-litomerice-cs.txt +5 -7
  29. data/data/czech-morava-cs.txt +4 -5
  30. data/data/czech-olomouc-cs.txt +2 -4
  31. data/data/czech-ostrava-cs.txt +3 -5
  32. data/data/czech-plzen-cs.txt +3 -5
  33. data/data/czech-praha-cs.txt +3 -4
  34. data/data/easter_dates.txt +67 -0
  35. data/data/universal-1969-la.txt +234 -0
  36. data/data/universal-en.txt +217 -211
  37. data/data/universal-es.txt +246 -0
  38. data/data/universal-fr.txt +217 -210
  39. data/data/universal-it.txt +217 -211
  40. data/data/universal-la.txt +217 -212
  41. data/data/universal-pt.txt +248 -0
  42. data/doc/data_readme.md +2 -0
  43. data/doc/images/class_diagram.png +0 -0
  44. data/doc/images/class_diagram.puml +44 -0
  45. data/doc/yard_readme.rdoc +76 -0
  46. data/lib/calendarium-romanum.rb +16 -2
  47. data/lib/calendarium-romanum/abstract_date.rb +15 -0
  48. data/lib/calendarium-romanum/calendar.rb +150 -33
  49. data/lib/calendarium-romanum/cli.rb +80 -100
  50. data/lib/calendarium-romanum/cli/comparator.rb +83 -0
  51. data/lib/calendarium-romanum/cli/date_parser.rb +30 -0
  52. data/lib/calendarium-romanum/cli/dumper.rb +68 -0
  53. data/lib/calendarium-romanum/cli/helper.rb +23 -0
  54. data/lib/calendarium-romanum/cli/querier.rb +73 -0
  55. data/lib/calendarium-romanum/cr.rb +16 -0
  56. data/lib/calendarium-romanum/data.rb +40 -8
  57. data/lib/calendarium-romanum/day.rb +187 -32
  58. data/lib/calendarium-romanum/enum.rb +41 -24
  59. data/lib/calendarium-romanum/enums.rb +127 -43
  60. data/lib/calendarium-romanum/errors.rb +1 -1
  61. data/lib/calendarium-romanum/ordinalizer.rb +10 -1
  62. data/lib/calendarium-romanum/perpetual_calendar.rb +58 -7
  63. data/lib/calendarium-romanum/rank.rb +39 -8
  64. data/lib/calendarium-romanum/rank_predicates.rb +43 -0
  65. data/lib/calendarium-romanum/sanctorale.rb +213 -23
  66. data/lib/calendarium-romanum/sanctorale_factory.rb +74 -3
  67. data/lib/calendarium-romanum/sanctorale_loader.rb +180 -0
  68. data/lib/calendarium-romanum/sanctorale_writer.rb +124 -0
  69. data/lib/calendarium-romanum/temporale.rb +222 -42
  70. data/lib/calendarium-romanum/temporale/celebration_factory.rb +68 -9
  71. data/lib/calendarium-romanum/temporale/date_helper.rb +85 -0
  72. data/lib/calendarium-romanum/temporale/dates.rb +52 -59
  73. data/lib/calendarium-romanum/temporale/easter_table.rb +27 -0
  74. data/lib/calendarium-romanum/temporale/extensions.rb +15 -0
  75. data/lib/calendarium-romanum/temporale/extensions/christ_eternal_priest.rb +16 -3
  76. data/lib/calendarium-romanum/temporale/extensions/dedication_before_all_saints.rb +73 -0
  77. data/lib/calendarium-romanum/transfers.rb +84 -24
  78. data/lib/calendarium-romanum/util.rb +21 -23
  79. data/lib/calendarium-romanum/version.rb +3 -2
  80. data/liturgical_law/1969_normae_universales.md +568 -0
  81. data/liturgical_law/1977_decretum_de_celebratione_baptismatis_domini.md +58 -0
  82. data/liturgical_law/1990_decretum_de_variatione_inducenda.md +67 -0
  83. data/liturgical_law/1998_notificatio_de_occurrentia.md +57 -0
  84. data/liturgical_law/2002_normae_universales.md +946 -0
  85. data/liturgical_law/2006_notification.md +37 -0
  86. data/liturgical_law/2012_declarationes.md +38 -0
  87. data/liturgical_law/2020_dubia_de_calendario_2022.md +100 -0
  88. data/liturgical_law/README.md +74 -0
  89. metadata +61 -38
  90. data/lib/calendarium-romanum/sanctoraleloader.rb +0 -122
  91. data/spec/abstract_date_spec.rb +0 -62
  92. data/spec/calendar_spec.rb +0 -559
  93. data/spec/celebration_factory_spec.rb +0 -16
  94. data/spec/celebration_spec.rb +0 -43
  95. data/spec/cli_spec.rb +0 -155
  96. data/spec/colour_spec.rb +0 -17
  97. data/spec/data_spec.rb +0 -23
  98. data/spec/date_parser_spec.rb +0 -68
  99. data/spec/date_spec.rb +0 -61
  100. data/spec/dates_spec.rb +0 -45
  101. data/spec/day_spec.rb +0 -108
  102. data/spec/enum_spec.rb +0 -51
  103. data/spec/i18n_spec.rb +0 -58
  104. data/spec/ordinalizer_spec.rb +0 -36
  105. data/spec/perpetual_calendar_spec.rb +0 -91
  106. data/spec/rank_spec.rb +0 -57
  107. data/spec/readme_spec.rb +0 -56
  108. data/spec/sanctorale_factory_spec.rb +0 -42
  109. data/spec/sanctorale_spec.rb +0 -191
  110. data/spec/sanctoraleloader_spec.rb +0 -176
  111. data/spec/season_spec.rb +0 -17
  112. data/spec/spec_helper.rb +0 -46
  113. data/spec/temporale_spec.rb +0 -572
@@ -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,74 @@
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
23
+ end
24
+
25
+ # @!method dup
26
+ # Returns a copy of the receiver with properly copied internal
27
+ # data structures, i.e. a copy which can be safely modified without
28
+ # danger of unintentionally modifying the original instance.
29
+ # @return [Sanctorale]
30
+
31
+ # @api private
32
+ def initialize_dup(other)
33
+ @days = other.days.dup
34
+ @solemnities = other.solemnities.dup
35
+ @symbols = other.symbols.dup
36
+ @metadata = Marshal.load Marshal.dump other.metadata # deep copy
10
37
  end
11
38
 
39
+ # Content subset - only {Celebration}s in the rank(s) of solemnity.
40
+ #
41
+ # @return [Hash<AbstractDate=>Celebration>]
12
42
  attr_reader :solemnities
13
43
 
44
+ # Sanctorale metadata.
45
+ #
46
+ # Data files may contain YAML front matter.
47
+ # If provided, it's loaded by {SanctoraleLoader} and
48
+ # stored in this property.
49
+ # All data files bundled in the gem (see {Data}) have YAML
50
+ # front matter which is a Hash with a few standardized keys.
51
+ # While YAML also supports top-level content of other types,
52
+ # sanctorale data authors should stick to the convention
53
+ # of using Hash as the top-level data structure of their
54
+ # front matters.
55
+ #
56
+ # @return [Hash, nil]
57
+ # @since 0.7.0
58
+ attr_accessor :metadata
59
+
60
+ # Adds a new {Celebration}
61
+ #
62
+ # @param month [Integer]
63
+ # @param day [Integer]
64
+ # @param celebration [Celebration]
65
+ # @return [void]
66
+ # @raise [ArgumentError]
67
+ # when performing the operation would break the object's invariant
14
68
  def add(month, day, celebration)
15
69
  date = AbstractDate.new(month, day)
16
- unless @days.has_key? date
17
- @days[date] = []
18
- end
19
70
 
20
- if celebration.solemnity?
21
- @solemnities[date] = celebration
22
- end
23
-
24
- unless @days[date].empty?
71
+ unless @days[date].nil? || @days[date].empty?
25
72
  present = @days[date][0]
26
73
  if present.rank != Ranks::MEMORIAL_OPTIONAL
27
74
  raise ArgumentError.new("On #{date} there is already a #{present.rank}. No more celebrations can be added.")
@@ -30,13 +77,54 @@ module CalendariumRomanum
30
77
  end
31
78
  end
32
79
 
80
+ unless celebration.symbol.nil?
81
+ if @symbols.include? celebration.symbol
82
+ raise ArgumentError.new("Attempted to add Celebration with duplicate symbol #{celebration.symbol.inspect}")
83
+ end
84
+
85
+ @symbols << celebration.symbol
86
+ end
87
+
88
+ unless @days.has_key? date
89
+ @days[date] = []
90
+ end
91
+
92
+ if celebration.solemnity?
93
+ @solemnities[date] = celebration
94
+ end
95
+
33
96
  @days[date] << celebration
34
97
  end
35
98
 
36
- # replaces content of the given day by given celebrations
37
- def replace(month, day, celebrations)
99
+ # Replaces content of the given day by given {Celebration}s
100
+ #
101
+ # @param month [Integer]
102
+ # @param day [Integer]
103
+ # @param celebrations [Array<Celebration>]
104
+ # @param symbol_uniqueness [true|false]
105
+ # allows disabling symbol uniqueness check.
106
+ # Internal feature, not intended for use by client code.
107
+ # @return [void]
108
+ # @raise [ArgumentError]
109
+ # when performing the operation would break the object's invariant
110
+ def replace(month, day, celebrations, symbol_uniqueness: true)
38
111
  date = AbstractDate.new(month, day)
39
112
 
113
+ symbols_without_day = @symbols
114
+ unless @days[date].nil?
115
+ old_symbols = @days[date].collect(&:symbol).compact
116
+ symbols_without_day = @symbols - old_symbols
117
+ end
118
+
119
+ new_symbols = celebrations.collect(&:symbol).compact
120
+ duplicate = symbols_without_day.intersection new_symbols
121
+ if symbol_uniqueness && !duplicate.empty?
122
+ raise ArgumentError.new("Attempted to add Celebrations with duplicate symbols #{duplicate.to_a.inspect}")
123
+ end
124
+
125
+ @symbols = symbols_without_day
126
+ @symbols.merge new_symbols
127
+
40
128
  if celebrations.first.solemnity?
41
129
  @solemnities[date] = celebrations.first
42
130
  elsif @solemnities.has_key? date
@@ -46,17 +134,48 @@ module CalendariumRomanum
46
134
  @days[date] = celebrations.dup
47
135
  end
48
136
 
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
137
+ # Updates the receiver with {Celebration}s from another instance.
138
+ #
139
+ # For each date contained in +other+ the content of +self+
140
+ # is _replaced_ by that of +other+.
141
+ #
142
+ # @param other [Sanctorale]
143
+ # @return [void]
144
+ # @raise (see #replace)
145
+ def update(other)
146
+ other.each_day do |date, celebrations|
147
+ replace date.month, date.day, celebrations, symbol_uniqueness: false
53
148
  end
149
+ rebuild_symbols
150
+ end
151
+
152
+ # Returns a new copy containing {Celebration}s from both self and +other+.
153
+ #
154
+ # @param other [Sanctorale]
155
+ # @return [Sanctorale]
156
+ # @since 0.9.0
157
+ def merge(other)
158
+ dup.tap {|dupped| dupped.update other }
54
159
  end
55
160
 
56
- # returns an Array with one or more Celebrations
57
- # scheduled for the given day
161
+ # Retrieves {Celebration}s for the given date
58
162
  #
59
- # expected arguments: Date or two Integers (month, day)
163
+ # @param date [AbstractDate, Date]
164
+ # @return [Array<Celebration>] (may be empty)
165
+ # @since 0.6.0
166
+ def [](date)
167
+ adate = date.is_a?(AbstractDate) ? date : AbstractDate.from_date(date)
168
+ @days[adate] || []
169
+ end
170
+
171
+ # Retrieves {Celebration}s for the given date
172
+ #
173
+ # @overload get(date)
174
+ # @param date[AbstractDate, Date]
175
+ # @overload get(month, day)
176
+ # @param month [Integer]
177
+ # @param day [Integer]
178
+ # @return (see #[])
60
179
  def get(*args)
61
180
  if args.size == 1 && args[0].is_a?(Date)
62
181
  month = args[0].month
@@ -66,31 +185,102 @@ module CalendariumRomanum
66
185
  end
67
186
 
68
187
  date = AbstractDate.new(month, day)
69
- @days[date] || []
188
+ self[date]
70
189
  end
71
190
 
72
- # for each day for which an entry is available
73
- # yields an AbstractDate and an Array of Celebrations
191
+ # Enumerates dates for which any {Celebration}s are available
192
+ #
193
+ # @yield [AbstractDate, Array<Celebration>] the array is never empty
194
+ # @return [void, Enumerator] if called without a block, returns +Enumerator+
74
195
  def each_day
196
+ return to_enum(__method__) unless block_given?
197
+
75
198
  @days.each_pair do |date, celebrations|
76
199
  yield date, celebrations
77
200
  end
78
201
  end
79
202
 
80
- # returns count of the _days_ with celebrations filled
203
+ # Returns count of _days_ with {Celebration}s filled
204
+ #
205
+ # @return [Integer]
81
206
  def size
82
207
  @days.size
83
208
  end
84
209
 
210
+ # It is empty if it doesn't contain any {Celebration}
211
+ #
212
+ # @return [Boolean]
85
213
  def empty?
86
214
  @days.empty?
87
215
  end
88
216
 
217
+ # Freezes the instance
89
218
  def freeze
90
219
  @days.freeze
91
220
  @days.values.each(&:freeze)
92
221
  @solemnities.freeze
93
222
  super
94
223
  end
224
+
225
+ # @return [Boolean]
226
+ # @since 0.6.0
227
+ def ==(b)
228
+ self.class == b.class &&
229
+ days == b.days
230
+ end
231
+
232
+ # Does this instance provide celebration identified by symbol +symbol+?
233
+ #
234
+ # @param symbol [Symbol]
235
+ # @return [Boolean]
236
+ # @since 0.9.0
237
+ def provides_celebration?(symbol)
238
+ @symbols.include? symbol
239
+ end
240
+
241
+ # If the instance contains a {Celebration} identified by the specified symbol,
242
+ # returns it's date and the {Celebration} itself, nil otherwise.
243
+ #
244
+ # @param symbol [Symbol]
245
+ # @return [Array<AbstractDate, Celebration>, nil]
246
+ # @since 0.9.0
247
+ def by_symbol(symbol)
248
+ return nil unless provides_celebration? symbol
249
+
250
+ @days.each_pair do |date, celebrations|
251
+ found = celebrations.find {|c| c.symbol == symbol }
252
+ return [date, found] if found
253
+ end
254
+
255
+ # reaching this point would mean that contents of @symbols and @days are not consistent
256
+ raise 'this point should never be reached'
257
+ end
258
+
259
+ protected
260
+
261
+ attr_reader :days, :symbols
262
+
263
+ # Builds the registry of celebration symbols anew,
264
+ # raises error if any duplicates are found.
265
+ def rebuild_symbols
266
+ @symbols = Set.new
267
+ duplicates = []
268
+
269
+ @days.each_pair do |date,celebrations|
270
+ celebrations.each do |celebration|
271
+ if @symbols.include?(celebration.symbol) &&
272
+ !duplicates.include?(celebration.symbol) &&
273
+ !celebration.symbol.nil?
274
+ duplicates << celebration.symbol
275
+ end
276
+
277
+ @symbols << celebration.symbol
278
+ end
279
+ end
280
+
281
+ unless duplicates.empty?
282
+ raise ArgumentError.new("Duplicate celebration symbols: #{duplicates.inspect}")
283
+ end
284
+ end
95
285
  end
96
286
  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
+ # 'data/czech-cs.txt',
46
+ # 'data/czech-cechy-cs.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