Class: CocinaDisplay::Dates::Date

Inherits:
Object
  • Object
show all
Defined in:
lib/cocina_display/dates/date.rb

Overview

A date to be converted to a Date object.

Constant Summary collapse

UNPARSABLE_VALUES =

List of values that we shouldn’t even attempt to parse.

["0000-00-00", "9999", "uuuu", "[uuuu]"].freeze
BCE_CHAR_SORT_MAP =

Used to sort BCE dates correctly in lexicographic order.

{"0" => "9", "1" => "8", "2" => "7", "3" => "6", "4" => "5", "5" => "4", "6" => "3", "7" => "2", "8" => "1", "9" => "0"}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(cocina) ⇒ Date

Returns a new instance of Date.



89
90
91
92
93
# File 'lib/cocina_display/dates/date.rb', line 89

def initialize(cocina)
  @cocina = cocina
  @date = self.class.parse_date(cocina["value"])
  @type = cocina["type"] unless ["start", "end"].include?(cocina["type"])
end

Instance Attribute Details

#cocinaObject (readonly)

Returns the value of attribute cocina.



83
84
85
# File 'lib/cocina_display/dates/date.rb', line 83

def cocina
  @cocina
end

#dateObject (readonly)

Returns the value of attribute date.



83
84
85
# File 'lib/cocina_display/dates/date.rb', line 83

def date
  @date
end

#typeString?

The type of this date, if any, such as “creation”, “publication”, etc.

Returns:

  • (String, nil)


87
88
89
# File 'lib/cocina_display/dates/date.rb', line 87

def type
  @type
end

Class Method Details

.format_date(date, precision, allowed_precisions) ⇒ Object

Note:

allowed_precisions should be ordered by granularity, with most specific first.

Returns the date in the format specified by the precision. Supports e.g. retrieving year precision when the actual date is more precise.

Parameters:

  • date (Date)

    The date to format.

  • precision (Symbol)

    The precision to format the date at, e.g. :month

  • allowed_precisions (Array<Symbol>)

    List of allowed precisions for the output. Options are [:day, :month, :year, :decade, :century].



346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
# File 'lib/cocina_display/dates/date.rb', line 346

def format_date(date, precision, allowed_precisions)
  precision = allowed_precisions.first unless allowed_precisions.include?(precision)

  case precision
  when :day
    date.strftime("%B %e, %Y")
  when :month
    date.strftime("%B %Y")
  when :year
    year = date.year
    if year < 1
      "#{year.abs + 1} BCE"
    # Any dates before the year 1000 are explicitly marked CE
    elsif year >= 1 && year < 1000
      "#{year} CE"
    else
      year.to_s
    end
  when :decade
    "#{EDTF::Decade.new(date.year).year}s"
  when :century
    if date.year.negative?
      "#{((date.year / 100).abs + 1).ordinalize} century BCE"
    else
      "#{((date.year / 100) + 1).ordinalize} century"
    end
  end
end

.from_cocina(cocina) ⇒ CocinaDisplay::Date

Construct a Date from parsed Cocina data.

Parameters:

  • cocina (Hash)

    Cocina date data

Returns:

  • (CocinaDisplay::Date)


17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/cocina_display/dates/date.rb', line 17

def self.from_cocina(cocina)
  # Create a DateRange instead if structuredValue(s) are present
  return DateRange.from_cocina(cocina) if cocina["structuredValue"].present?

  # If an encoding was declared, use it. Cocina validates this
  case cocina.dig("encoding", "code")
  when "w3cdtf"
    W3cdtfFormat.new(cocina)
  when "iso8601"
    Iso8601Format.new(cocina)
  when "marc"
    MarcFormat.new(cocina)
  when "edtf"
    EdtfFormat.new(cocina)
  else  # No declared encoding, or unknown encoding
    value = cocina["value"]

    # Don't bother with weird unparseable values
    date_class = UnparseableDate if value =~ /\p{Hebrew}/ || value =~ /^-/

    # Try to match against known date formats using their regexes
    # Order matters here; more specific formats should be checked first
    date_class ||= [
      MMDDYYYYFormat,
      MMDDYYFormat,
      YearRangeFormat,
      DecadeAsYearDashFormat,
      DecadeStringFormat,
      EmbeddedBCYearFormat,
      EmbeddedYearFormat,
      EmbeddedThreeDigitYearFormat,
      EmbeddedYearWithBracketsFormat,
      MysteryCenturyFormat,
      CenturyFormat,
      RomanNumeralCenturyFormat,
      RomanNumeralYearFormat,
      OneOrTwoDigitYearFormat
    ].find { |klass| klass.supports?(value) }

    # If no specific format matched, use the base class
    date_class ||= CocinaDisplay::Dates::Date

    date_class.new(cocina)
  end
end

.normalize_to_edtf(value) ⇒ String

Note:

This is the “fallback” version when no other parser matches.

Apply any encoding-specific munging or text extraction logic.

Parameters:

  • value (String)

    the date value to modify

Returns:

  • (String)


76
77
78
79
80
81
# File 'lib/cocina_display/dates/date.rb', line 76

def self.normalize_to_edtf(value)
  sanitized = value.gsub(/^[\[]+/, "").gsub(/[\.\]]+$/, "")
  sanitized = value.rjust(4, "0") if /^\d{3}$/.match?(value)

  sanitized
end

.parse_date(value) ⇒ Date?

Parse a string to a Date object according to the given encoding. Delegates to the parser subclass normalize_to_edtf method.

Parameters:

  • value (String)

    the date value to parse

Returns:

  • (Date)
  • (nil)

    if the date is blank or invalid



68
69
70
# File 'lib/cocina_display/dates/date.rb', line 68

def self.parse_date(value)
  ::Date.edtf(normalize_to_edtf(value))
end

Instance Method Details

#<=>(other) ⇒ Object

Compare this date to another CocinaDisplay::Dates::Date or CocinaDisplay::Dates::DateRange using its #sort_key.



96
97
98
# File 'lib/cocina_display/dates/date.rb', line 96

def <=>(other)
  sort_key <=> other.sort_key if other.is_a?(Date) || other.is_a?(DateRange)
end

#approximate?Boolean

Was the date marked as approximate?

Returns:

  • (Boolean)


148
149
150
# File 'lib/cocina_display/dates/date.rb', line 148

def approximate?
  qualifier == "approximate"
end

#as_rangeRange

Note:

Some encodings support disjoint sets of ranges, so this method could be less accurate than #to_a.

Range between earliest possible date and latest possible date.

Returns:

  • (Range)


318
319
320
321
322
# File 'lib/cocina_display/dates/date.rb', line 318

def as_range
  return unless earliest_date && latest_date

  earliest_date..latest_date
end

#base_valueString

Note:

This is important for uniqueness checks in Imprint display.

Value reduced to digits and hyphen. Used for comparison/deduping.

Returns:

  • (String)


259
260
261
262
263
264
265
# File 'lib/cocina_display/dates/date.rb', line 259

def base_value
  if value =~ /^\[?1\d{3}-\d{2}\??\]?$/
    return value.sub(/(\d{2})(\d{2})-(\d{2})/, '\1\2-\1\3')
  end

  value.gsub(/(?<![\d])(\d{1,3})([xu-]{1,3})/i) { "#{Regexp.last_match(1)}#{"0" * Regexp.last_match(2).length}" }.scan(/[\d-]/).join
end

#decoded_value(allowed_precisions: [:day, :month, :year, :decade, :century], ignore_unparseable: false, display_original_value: true) ⇒ String

Decoded version of the date with “BCE” or “CE”. Strips leading zeroes.

Parameters:

  • allowed_precisions (Array<Symbol>) (defaults to: [:day, :month, :year, :decade, :century])

    List of allowed precisions for the output. Defaults to [:day, :month, :year, :decade, :century].

  • ignore_unparseable (Boolean) (defaults to: false)

    Return nil instead of the original value if it couldn’t be parsed

  • display_original_value (Boolean) (defaults to: true)

    Return the original value if it was not encoded

Returns:

  • (String)


273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'lib/cocina_display/dates/date.rb', line 273

def decoded_value(allowed_precisions: [:day, :month, :year, :decade, :century], ignore_unparseable: false, display_original_value: true)
  return if ignore_unparseable && !parsed_date?
  return value.strip unless parsed_date?

  if display_original_value
    unless encoding?
      return value.strip unless value =~ /^-?\d+$/ || value =~ /^[\dXxu?-]{4}$/
    end
  end

  if date.is_a?(EDTF::Interval)
    range = [
      Date.format_date(date.min, date.min.precision, allowed_precisions),
      Date.format_date(date.max, date.max.precision, allowed_precisions)
    ].uniq.compact

    return value.strip if range.empty?

    range.join(" - ")
  else
    Date.format_date(date, precision, allowed_precisions) || value.strip
  end
end

#encodingString?

The encoding of this date, if specified.

Examples:

date.encoding #=> "iso8601"

Returns:

  • (String, nil)


122
123
124
# File 'lib/cocina_display/dates/date.rb', line 122

def encoding
  cocina.dig("encoding", "code")
end

#encoding?Boolean

Was an encoding declared for this date?

Returns:

  • (Boolean)


128
129
130
# File 'lib/cocina_display/dates/date.rb', line 128

def encoding?
  encoding.present?
end

#end?Boolean

Note:

The Cocina will mark end dates with “type”: “end”.

Is this the end date in a range?

Returns:

  • (Boolean)


142
143
144
# File 'lib/cocina_display/dates/date.rb', line 142

def end?
  cocina["type"] == "end"
end

#inferred?Boolean

Was the date marked as inferred?

Returns:

  • (Boolean)


154
155
156
# File 'lib/cocina_display/dates/date.rb', line 154

def inferred?
  qualifier == "inferred"
end

#parsable?Boolean

Is the value present and not a known unparsable value like “9999”?

Returns:

  • (Boolean)


173
174
175
# File 'lib/cocina_display/dates/date.rb', line 173

def parsable?
  value.present? && !UNPARSABLE_VALUES.include?(value)
end

#parsed_date?Boolean

Did we successfully parse a date from the Cocina data?

Returns:

  • (Boolean)


179
180
181
# File 'lib/cocina_display/dates/date.rb', line 179

def parsed_date?
  date.present?
end

#precisionSymbol

How precise is the parsed date information?

Returns:

  • (Symbol)

    :year, :month, :day, :decade, :century, or :unknown



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/cocina_display/dates/date.rb', line 185

def precision
  return :unknown unless date_range || date

  if date_range.is_a? EDTF::Century
    :century
  elsif date_range.is_a? EDTF::Decade
    :decade
  elsif date.is_a? EDTF::Season
    :month
  elsif date.is_a? EDTF::Interval
    date.precision
  else
    case date.precision
    when :month
      date.unspecified.unspecified?(:month) ? :year : :month
    when :day
      d = date.unspecified.unspecified?(:day) ? :month : :day
      date.unspecified.unspecified?(:month) ? :year : d
    else
      date.precision
    end
  end
end

#primary?Boolean

Note:

In MODS XML, this corresponds to the keyDate attribute.

Was the date marked as primary?

Returns:

  • (Boolean)


167
168
169
# File 'lib/cocina_display/dates/date.rb', line 167

def primary?
  cocina["status"] == "primary"
end

#qualified?Boolean

Does this date have a qualifier? E.g. “approximate”, “inferred”, etc.

Returns:

  • (Boolean)


114
115
116
# File 'lib/cocina_display/dates/date.rb', line 114

def qualified?
  qualifier.present?
end

#qualified_valueObject

Decoded date with “BCE” or “CE” and qualifier markers applied.



300
301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/cocina_display/dates/date.rb', line 300

def qualified_value
  qualified_format = case qualifier
  when "approximate"
    "[ca. %s]"
  when "questionable"
    "[%s?]"
  when "inferred"
    "[%s]"
  else
    "%s"
  end

  format(qualified_format, decoded_value)
end

#qualifierString?

The qualifier for this date, if any, such as “approximate”, “inferred”, etc.

Returns:

  • (String, nil)


108
109
110
# File 'lib/cocina_display/dates/date.rb', line 108

def qualifier
  cocina["qualifier"]
end

#questionable?Boolean

Was the date marked as approximate?

Returns:

  • (Boolean)


160
161
162
# File 'lib/cocina_display/dates/date.rb', line 160

def questionable?
  qualifier == "questionable"
end

#sort_keyString

Key used to sort this date. Respects BCE/CE ordering and precision.

Returns:

  • (String)


214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'lib/cocina_display/dates/date.rb', line 214

def sort_key
  # Even if not parsed, we might need to sort it for display later
  return "" unless parsed_date?

  # Use the start of an interval for sorting
  sort_date = date.is_a?(EDTF::Interval) ? date.from : date

  # Get the parsed year, month, and day values
  year, month, day = if sort_date.respond_to?(:values)
    sort_date.values
  else
    [sort_date.year, nil, nil]
  end

  # Format year into sortable string
  year_str = if year > 0
    # for CE dates, we can just pad them out to 4 digits and sort normally...
    year.to_s.rjust(4, "0")
  else
    #  ... but for BCE, because we're sorting lexically, we need to invert the digits (replacing 0 with 9, 1 with 8, etc.),
    #  we prefix it with a hyphen (which will sort before any digit) and the number of digits (also inverted) to get
    # it to sort correctly.
    inverted_year = year.abs.to_s.chars.map { |c| BCE_CHAR_SORT_MAP[c] }.join
    length_prefix = BCE_CHAR_SORT_MAP[inverted_year.to_s.length.to_s]
    "-#{length_prefix}#{inverted_year}"
  end

  # Format month and day into sortable strings, pad to 2 digits
  month_str = month ? month.to_s.rjust(2, "0") : "00"
  day_str = day ? day.to_s.rjust(2, "0") : "00"

  # Join into a sortable string; add hyphens so decade/century sort first
  case precision
  when :decade
    [year_str[0...-1], "-", month_str, day_str].join
  when :century
    [year_str[0...-2], "--", month_str, day_str].join
  else
    [year_str, month_str, day_str].join
  end
end

#start?Boolean

Note:

The Cocina will mark start dates with “type”: “start”.

Is this the start date in a range?

Returns:

  • (Boolean)


135
136
137
# File 'lib/cocina_display/dates/date.rb', line 135

def start?
  cocina["type"] == "start"
end

#to_aArray

Note:

Some encodings support disjoint sets of ranges, so this method could be more accurate than #as_range.

Array of all dates that fall into the range of possible dates in the data.

Returns:

  • (Array)


327
328
329
330
331
332
333
334
# File 'lib/cocina_display/dates/date.rb', line 327

def to_a
  case date
  when EDTF::Set
    date.to_a
  else
    as_range.to_a
  end
end

#valueString

The text representation of the date, as stored in Cocina.

Returns:

  • (String)


102
103
104
# File 'lib/cocina_display/dates/date.rb', line 102

def value
  cocina["value"]
end