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



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

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)


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

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)


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

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)


289
290
291
292
293
294
295
# File 'lib/cocina_display/dates/date.rb', line 289

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)


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

def decoded_value(allowed_precisions: [:day, :month, :year, :decade, :century, :unknown])
  if !parsed_date? || (!encoding? && value !~ /^-?\d+$/ && value !~ /^[\dXxu?-]{4}$/)
    return value.strip
  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

#earliest_dateDate

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

Returns:



406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
# File 'lib/cocina_display/dates/date.rb', line 406

def earliest_date
  return nil if date.nil?

  case date_range
  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)


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

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)


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

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

#inferred?Boolean

Was the date marked as inferred?

Returns:

  • (Boolean)


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

def inferred?
  qualifier == "inferred"
end

#known?Boolean

Does this represent a known date?

Returns:

  • (Boolean)


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

def known?
  !date.is_a?(EDTF::Unknown)
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

#latest_dateDate

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

Returns:



426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
# File 'lib/cocina_display/dates/date.rb', line 426

def latest_date
  return nil if date.nil?

  case date_range
  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)


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

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

#parsed_date?Boolean

Did we successfully parse a date from the Cocina data?

Returns:

  • (Boolean)


205
206
207
# File 'lib/cocina_display/dates/date.rb', line 205

def parsed_date?
  date.present?
end

#precisionSymbol

How precise is the parsed date information?

Returns:

  • (Symbol)

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



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/cocina_display/dates/date.rb', line 211

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)


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

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.



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

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)


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

def questionable?
  qualifier == "questionable"
end

#sort_keyString

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

Returns:

  • (String)


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
279
280
281
282
283
284
# File 'lib/cocina_display/dates/date.rb', line 244

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)


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

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:



356
357
358
359
360
361
362
363
# File 'lib/cocina_display/dates/date.rb', line 356

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