calendarium-romanum 0.3.0 → 0.7.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (80) 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 +20 -0
  6. data/.yardopts +3 -0
  7. data/CHANGELOG.md +340 -0
  8. data/Gemfile +25 -0
  9. data/Gemfile.lock +86 -0
  10. data/README.md +515 -0
  11. data/Rakefile +9 -0
  12. data/bin/calendariumrom +4 -1
  13. data/calendarium-romanum.gemspec +26 -0
  14. data/config/locales/cs.yml +17 -1
  15. data/config/locales/en.yml +28 -14
  16. data/config/locales/es.yml +90 -0
  17. data/config/locales/fr.yml +90 -0
  18. data/config/locales/it.yml +18 -2
  19. data/config/locales/la.yml +17 -1
  20. data/data/README.md +43 -1
  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 +237 -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/universal-en.txt +214 -211
  33. data/data/universal-es.txt +243 -0
  34. data/data/universal-fr.txt +243 -0
  35. data/data/universal-it.txt +214 -211
  36. data/data/universal-la.txt +214 -210
  37. data/doc/data_readme.md +2 -0
  38. data/doc/images/class_diagram.png +0 -0
  39. data/doc/images/class_diagram.puml +44 -0
  40. data/doc/yard_readme.rdoc +76 -0
  41. data/lib/calendarium-romanum.rb +30 -21
  42. data/lib/calendarium-romanum/abstract_date.rb +12 -0
  43. data/lib/calendarium-romanum/calendar.rb +207 -52
  44. data/lib/calendarium-romanum/cli.rb +101 -52
  45. data/lib/calendarium-romanum/cr.rb +16 -0
  46. data/lib/calendarium-romanum/data.rb +46 -18
  47. data/lib/calendarium-romanum/day.rb +202 -20
  48. data/lib/calendarium-romanum/enum.rb +24 -5
  49. data/lib/calendarium-romanum/enums.rb +102 -36
  50. data/lib/calendarium-romanum/errors.rb +4 -0
  51. data/lib/calendarium-romanum/ordinalizer.rb +30 -6
  52. data/lib/calendarium-romanum/perpetual_calendar.rb +97 -0
  53. data/lib/calendarium-romanum/rank.rb +43 -6
  54. data/lib/calendarium-romanum/sanctorale.rb +170 -24
  55. data/lib/calendarium-romanum/sanctorale_factory.rb +74 -3
  56. data/lib/calendarium-romanum/sanctorale_loader.rb +176 -0
  57. data/lib/calendarium-romanum/temporale.rb +251 -119
  58. data/lib/calendarium-romanum/temporale/celebration_factory.rb +106 -0
  59. data/lib/calendarium-romanum/temporale/dates.rb +117 -36
  60. data/lib/calendarium-romanum/temporale/extensions/christ_eternal_priest.rb +18 -6
  61. data/lib/calendarium-romanum/transfers.rb +20 -1
  62. data/lib/calendarium-romanum/util.rb +36 -3
  63. data/lib/calendarium-romanum/version.rb +5 -1
  64. metadata +29 -21
  65. data/lib/calendarium-romanum/sanctoraleloader.rb +0 -115
  66. data/spec/abstract_date_spec.rb +0 -62
  67. data/spec/calendar_spec.rb +0 -352
  68. data/spec/cli_spec.rb +0 -26
  69. data/spec/data_spec.rb +0 -23
  70. data/spec/date_spec.rb +0 -61
  71. data/spec/dates_spec.rb +0 -45
  72. data/spec/enum_spec.rb +0 -51
  73. data/spec/i18n_spec.rb +0 -59
  74. data/spec/rank_spec.rb +0 -42
  75. data/spec/readme_spec.rb +0 -52
  76. data/spec/sanctorale_factory_spec.rb +0 -42
  77. data/spec/sanctorale_spec.rb +0 -167
  78. data/spec/sanctoraleloader_spec.rb +0 -171
  79. data/spec/spec_helper.rb +0 -35
  80. data/spec/temporale_spec.rb +0 -500
@@ -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 [Fixnum]
49
+ # @param day [Fixnum]
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,33 +63,96 @@ 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 [Fixnum]
88
+ # @param day [Fixnum]
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
43
117
  @solemnities.delete date
44
118
  end
45
119
 
46
- @days[date] = celebrations
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
136
+ end
137
+
138
+ # Retrieves {Celebration}s for the given date
139
+ #
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] || []
54
146
  end
55
147
 
56
- # returns an Array with one or more Celebrations
57
- # scheduled for the given day
148
+ # Retrieves {Celebration}s for the given date
58
149
  #
59
- # expected arguments: Date or two Integers (month, day)
150
+ # @overload get(date)
151
+ # @param date[AbstractDate, Date]
152
+ # @overload get(month, day)
153
+ # @param month [Fixnum]
154
+ # @param day [Fixnum]
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,24 +162,74 @@ 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 [Fixnum]
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
193
+
194
+ # Freezes the instance
195
+ def freeze
196
+ @days.freeze
197
+ @days.values.each(&:freeze)
198
+ @solemnities.freeze
199
+ super
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
+ duplicates << celebration.symbol
224
+ end
225
+
226
+ @symbols << celebration.symbol
227
+ end
228
+ end
229
+
230
+ unless duplicates.empty?
231
+ raise ArgumentError.new("Duplicate celebration symbols: #{duplicates.inspect}")
232
+ end
233
+ end
88
234
  end
89
235
  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,176 @@
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
+ 'f' => Ranks::FEAST_GENERAL,
19
+ 's' => Ranks::SOLEMNITY_GENERAL
20
+ }.freeze
21
+
22
+ # @api private
23
+ COLOUR_CODES = {
24
+ nil => Colours::WHITE, # default
25
+ 'w' => Colours::WHITE,
26
+ 'v' => Colours::VIOLET,
27
+ 'g' => Colours::GREEN,
28
+ 'r' => Colours::RED
29
+ }.freeze
30
+
31
+ # Load from an object which understands +#each_line+
32
+ #
33
+ # @param src [String, File, #each_line]
34
+ # source of the loaded data
35
+ # @param dest [Sanctorale, nil]
36
+ # objects to populate. If not provided, a new {Sanctorale}
37
+ # instance will be created
38
+ # @return [Sanctorale]
39
+ # @raise [InvalidDataError]
40
+ def load(src, dest = nil)
41
+ dest ||= Sanctorale.new
42
+
43
+ in_front_matter = false
44
+ front_matter = ''
45
+ month_section = nil
46
+ src.each_line.with_index(1) do |l, line_num|
47
+ # skip YAML front matter
48
+ if line_num == 1 && l.start_with?('---')
49
+ in_front_matter = true
50
+ front_matter += l
51
+ next
52
+ elsif in_front_matter
53
+ if l.start_with?('---')
54
+ in_front_matter = false
55
+ dest.metadata = YAML.load(front_matter).freeze
56
+ end
57
+
58
+ front_matter += l
59
+
60
+ next
61
+ end
62
+
63
+ # strip whitespace and comments
64
+ l.sub!(/#.*/, '')
65
+ l.strip!
66
+ next if l.empty?
67
+
68
+ # month section heading
69
+ n = l.match(/^=\s*(\d+)\s*$/)
70
+ unless n.nil?
71
+ month_section = n[1].to_i
72
+ unless month_section >= 1 && month_section <= 12
73
+ raise error("Invalid month #{month_section}", line_num)
74
+ end
75
+ next
76
+ end
77
+
78
+ begin
79
+ celebration = load_line l, month_section
80
+ rescue RangeError, RuntimeError => err
81
+ raise error(err.message, line_num)
82
+ end
83
+
84
+ dest.add(
85
+ celebration.date.month,
86
+ celebration.date.day,
87
+ celebration
88
+ )
89
+ end
90
+
91
+ dest
92
+ end
93
+
94
+ alias load_from_string load
95
+
96
+ # Load from a filesystem path
97
+ #
98
+ # @param filename [String]
99
+ # @param dest [Sanctorale, nil]
100
+ # @param encoding [String]
101
+ # @return (see #load)
102
+ # @raise (see #load)
103
+ def load_from_file(filename, dest = nil, encoding = 'utf-8')
104
+ load File.open(filename, 'r', encoding: encoding), dest
105
+ end
106
+
107
+ private
108
+
109
+ def line_regexp
110
+ @line_regexp ||=
111
+ begin
112
+ rank_letters = RANK_CODES.keys.compact.join('')
113
+ colour_letters = COLOUR_CODES.keys.compact.join('')
114
+
115
+ Regexp.new(
116
+ '^((?<month>\d+)\/)?(?<day>\d+)' + # date
117
+ '(\s+(?<rank_char>[' + rank_letters + '])?(?<rank_num>\d\.\d{1,2})?)?' + # rank (optional)
118
+ '(\s+(?<colour>[' + colour_letters + ']))?' + # colour (optional)
119
+ '(\s+(?<symbol>[\w]{2,}))?' + # symbol (optional)
120
+ '\s*:(?<title>.*)$', # title
121
+ Regexp::IGNORECASE
122
+ )
123
+ end
124
+ end
125
+
126
+ # parses a line containing celebration record,
127
+ # returns a single Celebration
128
+ def load_line(line, month_section = nil)
129
+ # celebration record
130
+ m = line.match(line_regexp)
131
+ if m.nil?
132
+ raise RuntimeError.new("Syntax error, line skipped '#{line}'")
133
+ end
134
+
135
+ month = (m[:month] || month_section).to_i
136
+ day = m[:day].to_i
137
+ rank_char = m[:rank_char]
138
+ rank_num = m[:rank_num]
139
+ colour = m[:colour]
140
+ symbol_str = m[:symbol]
141
+ title = m[:title]
142
+
143
+ rank = RANK_CODES[rank_char && rank_char.downcase]
144
+
145
+ if rank_num
146
+ rank_num = rank_num.to_f
147
+ rank_by_num = Ranks[rank_num]
148
+
149
+ if rank_by_num.nil?
150
+ raise RuntimeError.new("Invalid celebration rank code #{rank_num}")
151
+ elsif rank_char && (rank.priority.to_i != rank_by_num.priority.to_i)
152
+ raise RuntimeError.new("Invalid combination of rank letter #{rank_char.inspect} and number #{rank_num}.")
153
+ end
154
+
155
+ rank = rank_by_num
156
+ end
157
+
158
+ symbol = nil
159
+ if symbol_str
160
+ symbol = symbol_str.to_sym
161
+ end
162
+
163
+ Celebration.new(
164
+ title.strip,
165
+ rank,
166
+ COLOUR_CODES[colour && colour.downcase],
167
+ symbol,
168
+ AbstractDate.new(month, day)
169
+ )
170
+ end
171
+
172
+ def error(message, line_number)
173
+ InvalidDataError.new("L#{line_number}: #{message}")
174
+ end
175
+ end
176
+ end