Class: CocinaDisplay::Dates::Date

Inherits:
Object
  • Object
show all
Includes:
Comparable
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.



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

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.



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

def cocina
  @cocina
end

#dateObject (readonly)

Returns the value of attribute date.



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

def date
  @date
end

#encodingString?

The encoding name of this date, if specified.

Examples:

“iso8601”

Returns:

  • (String, nil)


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

def encoding
  @encoding
end

#typeString?

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

Returns:

  • (String, nil)


100
101
102
# File 'lib/cocina_display/dates/date.rb', line 100

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].



380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
# File 'lib/cocina_display/dates/date.rb', line 380

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 %-d, %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)


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
67
68
69
# File 'lib/cocina_display/dates/date.rb', line 24

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)


84
85
86
87
88
89
90
91
92
93
94
# File 'lib/cocina_display/dates/date.rb', line 84

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



17
18
19
# File 'lib/cocina_display/dates/date.rb', line 17

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



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

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

Instance Method Details

#<=>(other) ⇒ Integer?

Note:

Also supports ‘date1.between?(date2, date3)` via Comparable.

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

Returns:

  • (Integer, nil)


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

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)


184
185
186
# File 'lib/cocina_display/dates/date.rb', line 184

def approximate?
  qualifier == "approximate"
end

#as_rangeRange<Date>?

Note:

Output has day precision, using the first day/month if unspecified.

Note:

If the range is open-ended, uses today’s date as the end date.

Note:

EDTF::Sets can be disjoint ranges, but this method will return the full span, unlike #to_a.

Range of CocinaDisplay::Dates::Dates between earliest possible date and latest possible date.

Returns:

  • (Range<Date>, nil)


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

def as_range
  return unless earliest_date || latest_date

  start = earliest_date || latest_date
  stop = latest_date || ::Date.today

  start..stop
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)


299
300
301
302
303
304
305
# File 'lib/cocina_display/dates/date.rb', line 299

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]) ⇒ 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].

Returns:

  • (String)


311
312
313
314
315
316
317
318
319
320
321
322
323
324
# File 'lib/cocina_display/dates/date.rb', line 311

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

#earliest_dateDate

Earliest possible date encoded in data, respecting unspecified/imprecise info.

Returns:



413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
# File 'lib/cocina_display/dates/date.rb', line 413

def earliest_date
  return nil if date.nil?

  case date_range
  when EDTF::Unknown
    nil
  when EDTF::Epoch, EDTF::Interval, EDTF::Season
    date_range.min
  when EDTF::Set
    date_range.to_a.first
  else
    d = date.dup
    d = d.change(month: 1, day: 1) if date.precision == :year
    d = d.change(day: 1) if date.precision == :month
    d = d.change(month: 1) if date.unspecified.unspecified? :month
    d = d.change(day: 1) if date.unspecified.unspecified? :day
    d
  end
end

#encoding?Boolean

Was an encoding declared for this date?

Returns:

  • (Boolean)


164
165
166
# File 'lib/cocina_display/dates/date.rb', line 164

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)


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

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

#inferred?Boolean

Was the date marked as inferred?

Returns:

  • (Boolean)


190
191
192
# File 'lib/cocina_display/dates/date.rb', line 190

def inferred?
  qualifier == "inferred"
end

#known?Boolean

Does this represent a known date?

Returns:

  • (Boolean)


158
159
160
# File 'lib/cocina_display/dates/date.rb', line 158

def known?
  !date.is_a?(EDTF::Unknown)
end

#labelString

Label used to group the date for display.

Returns:

  • (String)


140
141
142
# File 'lib/cocina_display/dates/date.rb', line 140

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

#latest_dateDate

Latest possible date encoded in data, respecting unspecified/imprecise info.

Returns:



435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
# File 'lib/cocina_display/dates/date.rb', line 435

def latest_date
  return nil if date.nil?

  case date_range
  when EDTF::Unknown
    nil
  when EDTF::Epoch, EDTF::Interval, EDTF::Season
    date_range.max
  when EDTF::Set
    date_range.to_a.last.change(month: 12, day: 31)
  else
    d = date.dup
    d = d.change(month: 12, day: 31) if date.precision == :year
    d = d.change(day: days_in_month(date.month, date.year)) if date.precision == :month
    d = d.change(month: 12) if date.unspecified.unspecified? :month
    d = d.change(day: days_in_month(date.month, date.year)) if date.unspecified.unspecified? :day
    d
  end
end

#parsable?Boolean

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

Returns:

  • (Boolean)


209
210
211
# File 'lib/cocina_display/dates/date.rb', line 209

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

#parsed_date?Boolean

Did we successfully parse a date from the Cocina data?

Returns:

  • (Boolean)


215
216
217
# File 'lib/cocina_display/dates/date.rb', line 215

def parsed_date?
  date.present?
end

#precisionSymbol

How precise is the parsed date information?

Returns:

  • (Symbol)

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



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
# File 'lib/cocina_display/dates/date.rb', line 221

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)


203
204
205
# File 'lib/cocina_display/dates/date.rb', line 203

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

#qualified?Boolean

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

Returns:

  • (Boolean)


152
153
154
# File 'lib/cocina_display/dates/date.rb', line 152

def qualified?
  qualifier.present?
end

#qualified_valueObject

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



329
330
331
332
333
334
335
336
337
338
339
340
341
342
# File 'lib/cocina_display/dates/date.rb', line 329

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)


146
147
148
# File 'lib/cocina_display/dates/date.rb', line 146

def qualifier
  cocina["qualifier"]
end

#questionable?Boolean

Was the date marked as approximate?

Returns:

  • (Boolean)


196
197
198
# File 'lib/cocina_display/dates/date.rb', line 196

def questionable?
  qualifier == "questionable"
end

#sort_keyString

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

Returns:

  • (String)


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
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
# File 'lib/cocina_display/dates/date.rb', line 254

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)


171
172
173
# File 'lib/cocina_display/dates/date.rb', line 171

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

#to_aArray<Date>

Note:

Output dates will have the same precision as the input date (e.g. year vs day).

Note:

If the range is open-ended, uses today’s date as the end date.

Note:

EDTF::Sets can be disjoint ranges; unlike #as_range this method will respect any gaps.

Array of all individual CocinaDisplay::Dates::Dates that are described by the data.

Returns:



363
364
365
366
367
368
369
370
# File 'lib/cocina_display/dates/date.rb', line 363

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. Uses the raw value if the date was not encoded or couldn’t be parsed.

Returns:

  • (String)


130
131
132
133
134
135
136
# File 'lib/cocina_display/dates/date.rb', line 130

def to_s
  if !parsed_date? || (!encoding? && value !~ /^-?\d+$/ && value !~ /^[\dXxu?-]{4}$/)
    value.strip
  else
    qualified_value
  end
end

#valueString

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

Returns:

  • (String)


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

def value
  cocina["value"]
end