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.



104
105
106
107
108
109
# File 'lib/cocina_display/dates/date.rb', line 104

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

Instance Attribute Details

#cocinaObject (readonly)

Returns the value of attribute cocina.



93
94
95
# File 'lib/cocina_display/dates/date.rb', line 93

def cocina
  @cocina
end

#dateObject (readonly)

Returns the value of attribute date.



93
94
95
# File 'lib/cocina_display/dates/date.rb', line 93

def date
  @date
end

#encodingString?

The encoding name of this date, if specified.

Examples:

“iso8601”

Returns:

  • (String, nil)


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

def encoding
  @encoding
end

#typeString?

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

Returns:

  • (String, nil)


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

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, :unknown].



370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
# File 'lib/cocina_display/dates/date.rb', line 370

def format_date(date, precision, allowed_precisions)
  precision = allowed_precisions.first unless allowed_precisions.include?(precision)
  case precision
  when :unknown
    "Unknown"
  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)


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
62
63
64
65
66
# File 'lib/cocina_display/dates/date.rb', line 21

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 ||= [
      UndeclaredEdtfFormat,
      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)


81
82
83
84
85
86
87
88
89
90
91
# File 'lib/cocina_display/dates/date.rb', line 81

def self.normalize_to_edtf(value)
  unless value
    notifier&.notify("Invalid date value: #{value}")
    return
  end

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

  sanitized
end

.notifierObject



14
15
16
# File 'lib/cocina_display/dates/date.rb', line 14

def self.notifier
  CocinaDisplay.notifier
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



73
74
75
# File 'lib/cocina_display/dates/date.rb', line 73

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.



112
113
114
# File 'lib/cocina_display/dates/date.rb', line 112

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)


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

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)


342
343
344
345
346
# File 'lib/cocina_display/dates/date.rb', line 342

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)


283
284
285
286
287
288
289
# File 'lib/cocina_display/dates/date.rb', line 283

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, :unknown], 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, :unknown])

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

  • 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)


297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
# File 'lib/cocina_display/dates/date.rb', line 297

def decoded_value(allowed_precisions: [:day, :month, :year, :decade, :century, :unknown], 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

#encoding?Boolean

Was an encoding declared for this date?

Returns:

  • (Boolean)


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

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)


162
163
164
# File 'lib/cocina_display/dates/date.rb', line 162

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

#inferred?Boolean

Was the date marked as inferred?

Returns:

  • (Boolean)


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

def inferred?
  qualifier == "inferred"
end

#labelString

Label used to group the date for display.

Returns:

  • (String)


130
131
132
# File 'lib/cocina_display/dates/date.rb', line 130

def label
  cocina["displayLabel"].presence || type_label
end

#parsable?Boolean

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

Returns:

  • (Boolean)


193
194
195
# File 'lib/cocina_display/dates/date.rb', line 193

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

#parsed_date?Boolean

Did we successfully parse a date from the Cocina data?

Returns:

  • (Boolean)


199
200
201
# File 'lib/cocina_display/dates/date.rb', line 199

def parsed_date?
  date.present?
end

#precisionSymbol

How precise is the parsed date information?

Returns:

  • (Symbol)

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



205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/cocina_display/dates/date.rb', line 205

def precision
  return :unknown unless date_range || date
  if date_range.is_a? EDTF::Century
    return :century
  elsif date_range.is_a? EDTF::Decade
    return :decade
  end

  case date
  when EDTF::Season
    :month
  when EDTF::Unknown
    :unknown
  when 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)


187
188
189
# File 'lib/cocina_display/dates/date.rb', line 187

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

#qualified?Boolean

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

Returns:

  • (Boolean)


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

def qualified?
  qualifier.present?
end

#qualified_valueObject

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



324
325
326
327
328
329
330
331
332
333
334
335
336
337
# File 'lib/cocina_display/dates/date.rb', line 324

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)


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

def qualifier
  cocina["qualifier"]
end

#questionable?Boolean

Was the date marked as approximate?

Returns:

  • (Boolean)


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

def questionable?
  qualifier == "questionable"
end

#sort_keyString

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

Returns:

  • (String)


238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/cocina_display/dates/date.rb', line 238

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)


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

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)


351
352
353
354
355
356
357
358
# File 'lib/cocina_display/dates/date.rb', line 351

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

#to_sString

The string representation of the date for display.

Returns:

  • (String)


124
125
126
# File 'lib/cocina_display/dates/date.rb', line 124

def to_s
  qualified_value
end

#valueString

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

Returns:

  • (String)


118
119
120
# File 'lib/cocina_display/dates/date.rb', line 118

def value
  cocina["value"]
end