diff --git a/controlled_access_terms.module b/controlled_access_terms.module index a5fb060..50372ef 100644 --- a/controlled_access_terms.module +++ b/controlled_access_terms.module @@ -73,6 +73,39 @@ function controlled_access_terms_jsonld_alter_normalized_array(EntityInterface $ } } +/** + * Update EDTF fields from the 2012 draft to match the 2018 spec. + */ +function controlled_access_terms_update_8003() { + $db = \Drupal::database(); + + // Find all the fields using edtf. + $config_factory = \Drupal::configFactory(); + foreach ($config_factory->listAll('field.storage.') as $field_storage_config_name) { + $field_storage_config = $config_factory->get($field_storage_config_name); + if ($field_storage_config->get('type') === 'edtf') { + + // Run through each update. Make sure 'unknown' is updated before 'u'. + $updates = [ + 'open' => '..', + 'unknown' => '', + 'y' => 'Y', + 'u' => 'X', + '?~' => '%', + '~?' => '%', + ]; + foreach ($updates as $old => $new) { + $db->update($field_storage_config->get('entity_type') . '__' . $field_storage_config->get('field_name')) + ->expression($field_storage_config->get('field_name') . '_value', 'replace(' . $field_storage_config->get('field_name') . '_value, :old, :new)', [ + ':old' => $old, + ':new' => $new, + ]) + ->execute(); + } + } + } +} + /** * Change fields using the EDTF Widget to the new EDTF Field Type. */ diff --git a/modules/controlled_access_terms_default_configuration/config/install/core.entity_view_display.taxonomy_term.corporate_body.default.yml b/modules/controlled_access_terms_default_configuration/config/install/core.entity_view_display.taxonomy_term.corporate_body.default.yml index 3133cd5..ae89e1a 100644 --- a/modules/controlled_access_terms_default_configuration/config/install/core.entity_view_display.taxonomy_term.corporate_body.default.yml +++ b/modules/controlled_access_terms_default_configuration/config/install/core.entity_view_display.taxonomy_term.corporate_body.default.yml @@ -45,7 +45,6 @@ content: date_order: big_endian month_format: mm day_format: dd - season_hemisphere: north third_party_settings: { } type: edtf_default region: content @@ -57,7 +56,6 @@ content: date_order: big_endian month_format: mm day_format: dd - season_hemisphere: north third_party_settings: { } type: edtf_default region: content diff --git a/modules/controlled_access_terms_default_configuration/config/install/core.entity_view_display.taxonomy_term.family.default.yml b/modules/controlled_access_terms_default_configuration/config/install/core.entity_view_display.taxonomy_term.family.default.yml index 05d4f07..5d90208 100644 --- a/modules/controlled_access_terms_default_configuration/config/install/core.entity_view_display.taxonomy_term.family.default.yml +++ b/modules/controlled_access_terms_default_configuration/config/install/core.entity_view_display.taxonomy_term.family.default.yml @@ -42,7 +42,6 @@ content: date_order: big_endian month_format: mm day_format: dd - season_hemisphere: north third_party_settings: { } type: edtf_default region: content @@ -54,7 +53,6 @@ content: date_order: big_endian month_format: mm day_format: dd - season_hemisphere: north third_party_settings: { } type: edtf_default region: content diff --git a/modules/controlled_access_terms_default_configuration/config/install/core.entity_view_display.taxonomy_term.person.default.yml b/modules/controlled_access_terms_default_configuration/config/install/core.entity_view_display.taxonomy_term.person.default.yml index 5f7245f..c71da8d 100644 --- a/modules/controlled_access_terms_default_configuration/config/install/core.entity_view_display.taxonomy_term.person.default.yml +++ b/modules/controlled_access_terms_default_configuration/config/install/core.entity_view_display.taxonomy_term.person.default.yml @@ -45,7 +45,6 @@ content: date_order: little_endian month_format: mmm day_format: dd - season_hemisphere: north third_party_settings: { } type: edtf_default region: content @@ -57,7 +56,6 @@ content: date_order: little_endian month_format: mmm day_format: dd - season_hemisphere: north third_party_settings: { } type: edtf_default region: content diff --git a/src/EDTFConverter.php b/src/EDTFConverter.php index 85a278e..11db6d2 100644 --- a/src/EDTFConverter.php +++ b/src/EDTFConverter.php @@ -9,40 +9,6 @@ */ class EDTFConverter extends CommonDataConverter { - /** - * Northern hemisphere season map. - * - * @var array - */ - private $seasonMapNorth = [ - // Spring => March. - '21' => '03', - // Summer => June. - '22' => '06', - // Autumn => September. - '23' => '09', - // Winter => December. - '24' => '12', - ]; - - /** - * Southern hemisphere season map. - * - * (Currently unused until a config for this is established.) - * - * @var array - */ - private $seasonMapSouth = [ - // Spring => September. - '21' => '03', - // Summer => December. - '22' => '06', - // Autumn => March. - '23' => '09', - // Winter => June. - '24' => '12', - ]; - /** * Converts an EDTF text field into an ISO 8601 timestamp string. * @@ -55,19 +21,11 @@ class EDTFConverter extends CommonDataConverter { * Returns the ISO 8601 timestamp. */ public static function datetimeIso8601Value(array $data) { - $date = explode('/', $data['value'])[0]; - // Strip approximations/uncertainty. - $date = str_replace(['?', '~'], '', $date); + // Take first possible date. + $date = preg_split('/(,|\.\.|\/)/', trim($data['value'], '{}[]'))[0]; - // Replace unspecified. - // Month/day. - $date = str_replace('-uu', '-01', $date); - // Zero-Year in decade/century. - $date = str_replace('u', '0', $date); - - // Seasons map. - return EDTFConverter::seasonsMap($date) . 'T00:00:00'; + return EDTFUtils::iso8601Value($date); } @@ -83,43 +41,8 @@ public static function datetimeIso8601Value(array $data) { * Returns the ISO 8601 date. */ public static function dateIso8601Value(array $data) { - $date = explode('/', $data['value'])[0]; - - // Strip approximations/uncertainty. - $date = str_replace(['?', '~'], '', $date); - - // Remove unspecified. - // Month/day. - $date = str_replace('-uu', '', $date); - // Zero-Year in decade/century. - $date = str_replace('u', '0', $date); - - // Seasons map. - return EDTFConverter::seasonsMap($date); - - } - - /** - * Converts a numeric season into a numeric month. - * - * @param string $date - * The date string to convert. - * - * @return string - * Returns the ISO 8601 date with the correct month. - */ - protected static function seasonsMap(string $date) { - $date_parts[] = explode('-', $date, 3); - // Digit Seasons. - if ((count($date_parts) > 1) && - in_array($date_parts[1], ['21', '22', '23', '24'])) { - // TODO: Make hemisphere seasons configurable. - $season_mapping = $seasonMapNorth; - $date_parts[1] = $season_mapping[$date_parts[1]]; - $date = implode('-', array_filter($date_parts)); - } - return $date; + return explode('T', EDTFConverter::datetimeIso8601Value($data))[0]; } diff --git a/src/EDTFUtils.php b/src/EDTFUtils.php new file mode 100644 index 0000000..b6113db --- /dev/null +++ b/src/EDTFUtils.php @@ -0,0 +1,358 @@ + ['mmm' => 'Jan', 'mmmm' => 'January'], + '02' => ['mmm' => 'Feb', 'mmmm' => 'February'], + '03' => ['mmm' => 'Mar', 'mmmm' => 'March'], + '04' => ['mmm' => 'Apr', 'mmmm' => 'April'], + '05' => ['mmm' => 'May', 'mmmm' => 'May'], + '06' => ['mmm' => 'Jun', 'mmmm' => 'June'], + '07' => ['mmm' => 'Jul', 'mmmm' => 'July'], + '08' => ['mmm' => 'Aug', 'mmmm' => 'August'], + '09' => ['mmm' => 'Sep', 'mmmm' => 'September'], + '10' => ['mmm' => 'Oct', 'mmmm' => 'October'], + '11' => ['mmm' => 'Nov', 'mmmm' => 'November'], + '12' => ['mmm' => 'Dec', 'mmmm' => 'December'], + '21' => ['mmm' => 'Spr', 'mmmm' => 'Spring'], + '22' => ['mmm' => 'Sum', 'mmmm' => 'Summer'], + '23' => ['mmm' => 'Aut', 'mmmm' => 'Autumn'], + '24' => ['mmm' => 'Win', 'mmmm' => 'Winter'], + '25' => ['mmm' => 'Spr', 'mmmm' => 'Spring - Northern Hemisphere'], + '26' => ['mmm' => 'Sum', 'mmmm' => 'Summer - Northern Hemisphere'], + '27' => ['mmm' => 'Aut', 'mmmm' => 'Autumn - Northern Hemisphere'], + '28' => ['mmm' => 'Win', 'mmmm' => 'Winter - Northern Hemisphere'], + '29' => ['mmm' => 'Spr', 'mmmm' => 'Spring - Southern Hemisphere'], + '30' => ['mmm' => 'Sum', 'mmmm' => 'Summer - Southern Hemisphere'], + '31' => ['mmm' => 'Aut', 'mmmm' => 'Autumn - Southern Hemisphere'], + '32' => ['mmm' => 'Win', 'mmmm' => 'Winter - Southern Hemisphere'], + '33' => ['mmm' => 'Q1', 'mmmm' => 'Quarter 1'], + '34' => ['mmm' => 'Q2', 'mmmm' => 'Quarter 2'], + '35' => ['mmm' => 'Q3', 'mmmm' => 'Quarter 3'], + '36' => ['mmm' => 'Q4', 'mmmm' => 'Quarter 4'], + // I'm making up the rest of these abbreviations + // because I can't find standardized ones. + '37' => ['mmm' => 'Quad1', 'mmmm' => 'Quadrimester 1'], + '38' => ['mmm' => 'Quad2', 'mmmm' => 'Quadrimester 2'], + '39' => ['mmm' => 'Quad3', 'mmmm' => 'Quadrimester 3'], + '40' => ['mmm' => 'Sem1', 'mmmm' => 'Semestral 1'], + '41' => ['mmm' => 'Sem2', 'mmmm' => 'Semestral 2'], + ]; + + const SEASONS_MAP = [ + // Northern Hemisphere bias for 21-24. + '21' => '03', + '22' => '06', + '23' => '09', + '24' => '12', + // Northern seasons. + '25' => '03', + '26' => '06', + '27' => '09', + '28' => '12', + // Southern seasons. + '29' => '09', + '30' => '12', + '31' => '03', + '32' => '06', + // Quarters. + '33' => '01', + '34' => '04', + '35' => '07', + '36' => '10', + // Quadrimesters. + '37' => '01', + '38' => '05', + '39' => '09', + // Semesters. + '40' => '01', + '41' => '07', + ]; + + /** + * Southern hemisphere season map. + * + * (Currently unused until a config for this is established.) + * + * @var array + */ + private $seasonMapSouth = [ + // Spring => September. + '21' => '03', + // Summer => December. + '22' => '06', + // Autumn => March. + '23' => '09', + // Winter => June. + '24' => '12', + ]; + + /** + * Validate an EDTF expression. + * + * @param string $edtf_text + * The datetime string. + * @param bool $intervals + * Are interval expressions permitted? + * @param bool $sets + * Are set expressions permitted? + * @param bool $strict + * Are only valid calendar dates permitted? + * + * @return array + * Array of error messages. Valid if empty. + */ + public static function validate($edtf_text, $intervals = TRUE, $sets = TRUE, $strict = FALSE) { + $msgs = []; + // Sets. + if ($sets) { + if (strpos($edtf_text, '[') !== FALSE || strpos($edtf_text, '{') !== FALSE) { + // Test for valid enclosing characters and valid characters inside. + $match = preg_match('/^([\[,\{])[\d,\-,X,Y,E,S,.]*([\],\}])$/', $edtf_text); + if (!$match || $match[1] !== $match[2]) { + $msgs[] = "The set is improperly encoded."; + } + // Test each date in set. + foreach (preg_split('/(,|\.\.)/', trim($edtf_text, '{}[]')) as $date) { + if (!empty($date)) { + $msgs = array_merge($msgs, self::validateDate($date, $strict)); + } + } + return $msgs; + } + } + // Intervals. + if ($intervals) { + if (strpos($edtf_text, 'T') !== FALSE) { + $msgs[] = "Date intervals cannot include times."; + } + foreach (explode('/', $$edtf_text) as $date) { + if (!empty($date) && !$date === '..') { + $msgs = array_merge($msgs, self::validateDate($date, $strict)); + } + } + return $msgs; + } + // Single date (we assume at this point). + return self::validateDate($edtf_text, $strict); + } + + /** + * Validate a single date. + * + * @param string $datetime_str + * The datetime string. + * @param bool $strict + * Are only valid calendar dates permitted? + * + * @return array + * Array of error messages. Valid if empty. + */ + public static function validateDate($datetime_str, $strict = FALSE) { + $msgs = []; + + list($date, $time) = explode('T', $datetime_str); + + preg_match(self::DATE_PARSE_REGEX, $date, $parsed_date); + + // "Something" is wrong with the provided date if it doesn't round-trip. + // Includes (non-exhaustive): + // - no invalid characters present, + // - two-digit months and days, and + // - capturing group qualifiers. + if ($date !== $parsed_date[self::FULL_MATCH]) { + $msgs[] = "Could not parse the date '$date'"; + } + + // Year. + if (strpos($parsed_date[self::YEAR_FULL], 'Y') === 0) { + if ($strict) { + $msgs[] = "Extended years are not supported with the 'strict dates' option enabled."; + } + // Expand exponents. + if (!empty($parsed_date[self::YEAR_EXPONENT])) { + $exponent = intval(substr($parsed_date[self::YEAR_EXPONENT], 1)); + $parsed_date[self::YEAR_BASE] = strval((10 ** $exponent) * intval($parsed_date[self::YEAR_BASE])); + $parsed_date[self::YEAR_BASE] = self::expandYear($parsed_date[self::YEAR_FULL], $parsed_date[self::YEAR_BASE], $parsed_date[self::YEAR_EXPONENT]); + } + } + elseif (strlen(ltrim($parsed_date[self::YEAR_BASE], '-')) > 4) { + $msgs[] = "Years longer than 4 digits must be prefixed with a 'Y'."; + } + elseif (strlen($parsed_date[self::YEAR_BASE]) < 4) { + $msgs[] = "Years must be at least 4 characters long."; + } + $strict_pattern = 'Y'; + + // Month. + if (!array_key_exists(self::MONTH, $parsed_date) && !empty($parsed_date[self::MONTH])) { + // Valid month values? + if (!array_key_exists($parsed_date[self::MONTH], self::MONTHS_MAP) && + strpos($parsed_date[self::MONTH], 'X') === FALSE) { + $msgs[] = "Provided month value '" . $parsed_date[self::MONTH] . "' is not valid."; + } + $strict_pattern = 'Y-m'; + } + + // Day. + if (!array_key_exists(self::DAY) && !empty($parsed_date[self::DAY])) { + // Valid day values? + if (strpos($parsed_date[self::DAY], 'X') === FALSE && + !in_array(intval($parsed_date[self::DAY]), range(1, 31))) { + $msgs[] = "Provided day value '" . $parsed_date[self::DAY] . "' is not valid."; + } + $strict_pattern = 'Y-m-d'; + } + // Time. + if (strpos($datetime_str, 'T') !== FALSE && empty($time)) { + $msgs[] = "Time not provided with time seperator (T)."; + } + + if ($time) { + if (!preg_match('/^-?(\d{4})(-\d{2}){2}T\d{2}(:\d{2}){2}(Z|(\+|-)\d{2}:\d{2})?$/', $datetime_str, $matches)) { + $msgs[] = "The date/time '$datetime_str' is invalid."; + } + $strict_pattern = 'Y-m-d\TH:i:s'; + if (count($matches) > 4) { + if ($matches[4] === 'Z') { + $strict_pattern .= '\Z'; + } + else { + $strict_pattern .= 'P'; + } + } + } + + if ($strict) { + // Assemble the parts again. + if ($time) { + $cleaned_datetime = $datetime_str; + } + else { + $cleaned_datetime = implode('-', [ + $parsed_date[self::YEAR_BASE], + $parsed_date[self::MONTH], + $parsed_date[self::DAY], + ]); + } + $datetime_obj = DateTime::createFromFormat('!' . $strict_pattern, $cleaned_datetime); + $errors = DateTime::getLastErrors(); + if (!$datetime_obj || + !empty($errors['warning_count']) || + // DateTime will create valid dates from Y-m without warning, + // so validate we still have what it was given. + !($cleaned_datetime === $datetime_obj->format($strict_pattern)) + ) { + $msgs[] = "Strictly speaking, the date (and/or time) '$datetime_str' is invalid."; + } + } + + return $msgs; + } + + /** + * Expand an exponent year. + * + * @param string $year_full + * The full year expression from the EDTF string. + * @param string $year_base + * The base expression from the EDTF string. + * @param string $year_exponent + * The exponent expression from the EDTF string. + * + * @return string + * The expanded year value. + */ + public static function expandYear($year_full, $year_base, $year_exponent) { + if (!empty($year_exponent)) { + $exponent = intval(substr($year_exponent, 1)); + return strval((10 ** $exponent) * intval($year_base)); + } + else { + return $year_base; + } + } + + /** + * Converts an EDTF string into an ISO 8601 timestamp string. + * + * @param string $edtf + * The array containing the 'value' element. + * + * @return string + * Returns the ISO 8601 timestamp. + */ + public static function iso8601Value(string $edtf) { + + $date_time = explode('T', $edtf); + + preg_match(EDTFUtils::DATE_PARSE_REGEX, $date_time[0], $parsed_date); + + $year = ''; + $month = ''; + $day = ''; + + $parsed_date[EDTFUtils::YEAR_BASE] = EDTFUtils::expandYear($parsed_date[EDTFUtils::YEAR_FULL], $parsed_date[EDTFUtils::YEAR_BASE], $parsed_date[EDTFUtils::YEAR_EXPONENT]); + + // Clean-up unspecified year/decade. + $year = str_replace('X', '0', $parsed_date[EDTFUtils::YEAR_BASE]); + + if (array_key_exists(EDTFUtils::MONTH, $parsed_date)) { + $month = str_replace('XX', '01', $parsed_date[EDTFUtils::MONTH]); + $month = str_replace('X', '0', $month); + + // ISO 8601 doesn't support seasonal notation yet. Swap them out. + if (array_key_exists($month, EDTFUtils::SEASONS_MAP)) { + $month = EDTFUtils::SEASONS_MAP[$month]; + } + } + + if (array_key_exists(EDTFUtils::DAY, $parsed_date)) { + $day = str_replace('XX', '01', $parsed_date[EDTFUtils::DAY]); + $day = str_replace('X', '0', $day); + } + + $formatted_date = implode('-', array_filter([$year, $month, $day])); + + // Time. + if (array_key_exists(1, $date_time) && !empty($date_time[1])) { + $formatted_date .= 'T' . $date_time[1]; + } + else { + $formatted_date .= 'T00:00:00'; + } + + return $formatted_date; + + } + +} diff --git a/src/Plugin/Field/FieldFormatter/EDTFFormatter.php b/src/Plugin/Field/FieldFormatter/EDTFFormatter.php index 0720c36..c46912b 100644 --- a/src/Plugin/Field/FieldFormatter/EDTFFormatter.php +++ b/src/Plugin/Field/FieldFormatter/EDTFFormatter.php @@ -5,6 +5,7 @@ use Drupal\Core\Field\FormatterBase; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Form\FormStateInterface; +use Drupal\controlled_access_terms\EDTFUtils; /** * Plugin implementation of the 'TextEDTFFormatter'. @@ -21,30 +22,6 @@ */ class EDTFFormatter extends FormatterBase { - /** - * Month/Season to text map. - * - * @var array - */ - private $MONTHS = [ - '01' => ['mmm' => 'Jan', 'mmmm' => 'January'], - '02' => ['mmm' => 'Feb', 'mmmm' => 'February'], - '03' => ['mmm' => 'Mar', 'mmmm' => 'March'], - '04' => ['mmm' => 'Apr', 'mmmm' => 'April'], - '05' => ['mmm' => 'May', 'mmmm' => 'May'], - '06' => ['mmm' => 'Jun', 'mmmm' => 'June'], - '07' => ['mmm' => 'Jul', 'mmmm' => 'July'], - '08' => ['mmm' => 'Aug', 'mmmm' => 'August'], - '09' => ['mmm' => 'Sep', 'mmmm' => 'September'], - '10' => ['mmm' => 'Oct', 'mmmm' => 'October'], - '11' => ['mmm' => 'Nov', 'mmmm' => 'November'], - '12' => ['mmm' => 'Dec', 'mmmm' => 'December'], - '21' => ['mmm' => 'Spr', 'mmmm' => 'Spring'], - '22' => ['mmm' => 'Sum', 'mmmm' => 'Summer'], - '23' => ['mmm' => 'Aut', 'mmmm' => 'Autumn'], - '24' => ['mmm' => 'Win', 'mmmm' => 'Winter'], - ]; - /** * Various delimiters. * @@ -57,38 +34,6 @@ class EDTFFormatter extends FormatterBase { 'space' => ' ', ]; - /** - * Northern hemisphere season map. - * - * @var array - */ - private $seasonMapNorth = [ - // Spring => March. - '21' => '03', - // Summer => June. - '22' => '06', - // Autumn => September. - '23' => '09', - // Winter => December. - '24' => '12', - ]; - - /** - * Southern hemisphere season map. - * - * @var array - */ - private $seasonMapSouth = [ - // Spring => September. - '21' => '03', - // Summer => December. - '22' => '06', - // Autumn => March. - '23' => '09', - // Winter => June. - '24' => '12', - ]; - /** * {@inheritdoc} */ @@ -96,14 +41,9 @@ public static function defaultSettings() { return [ // ISO 8601 bias. 'date_separator' => 'dash', - // ISO 8601 bias. 'date_order' => 'big_endian', - // ISO 8601 bias. 'month_format' => 'mm', - // ISO 8601 bias. 'day_format' => 'dd', - // Northern bias, sorry. - 'season_hemisphere' => 'north', ] + parent::defaultSettings(); } @@ -154,18 +94,6 @@ public function settingsForm(array $form, FormStateInterface $form_state) { 'd' => t('one-digit day of the month for days below 10, e.g. 2'), ], ]; - $form['season_hemisphere'] = [ - '#title' => t('Hemisphere Seasons'), - '#type' => 'select', - '#default_value' => $this->getSetting('season_hemisphere'), - '#description' => t("Seasons don't have digit months so we map them - to their respective equinox and solstice months. - Select a hemisphere to use for the mapping."), - '#options' => [ - 'north' => t('Northern Hemisphere'), - 'south' => t('Southern Hemisphere'), - ], - ]; return $form; } @@ -187,32 +115,73 @@ public function viewElements(FieldItemListInterface $items, $langcode) { foreach ($items as $delta => $item) { // Interval. - list($begin, $end) = explode('/', $item->value); + if (strpos($item->value, '/') !== FALSE) { + list($begin, $end) = explode('/', $item->value); - $formatted_begin = $this->formatDate($begin); + if (empty($begin) || $begin === '..') { + $formatted_begin = "open start"; + } + else { + $formatted_begin = $this->formatDate($begin); + } + + if (empty($end) || $end === '..') { + $formatted_end = "open end"; + } + else { + $formatted_end = $this->formatDate($end); + } - // End either empty or valid extended interval values (5.2.3.) - if (empty($end)) { - $element[$delta] = ['#markup' => $formatted_begin]; - } - elseif ($end === 'unknown' || $end === 'open') { $element[$delta] = [ '#markup' => t('@begin to @end', [ '@begin' => $formatted_begin, - '@end' => $end, + '@end' => $formatted_end, ]), ]; + continue; } - else { - $formatted_end = $this->formatDate($end); + // Sets. + if (strpos($item->value, '[') !== FALSE || strpos($item->value, '{') !== FALSE) { + $set_qualifier = (strpos($item->value, '[') !== FALSE) ? t('one of the dates:') : t('all of the dates:'); + foreach (explode(',', trim($item->value, '{}[] ')) as $date) { + $date_range = explode('..', $date); + switch (count($date_range)) { + case 1: + $formatted_dates[] = $this->formatDate($date); + break; + + case 2: + if (empty($date_range[0])) { + $formatted_dates[] = t('@date or some earlier date', [ + '@date' => $this->formatDate($date_range[1]), + ]); + } + elseif (empty($date_range[1])) { + $formatted_dates[] = t('@date or some later date', [ + '@date' => $this->formatDate($date_range[0]), + ]); + } + else { + $formatted_dates[] = t('@date_begin until @date_end', [ + '@date_begin' => $this->formatDate($date_range[0]), + '@date_end' => $this->formatDate($date_range[1]), + ]); + } + break; + } + } $element[$delta] = [ - '#markup' => t('@begin to @end', [ - '@begin' => $formatted_begin, - '@end' => $formatted_end, + '#markup' => t('@qualifier @list', [ + '@qualifier' => $set_qualifier, + '@list' => implode(', ', $formatted_dates), ]), ]; + continue; } + $element[$delta] = [ + '#markup' => $this->formatDate($item->value), + ]; } return $element; } @@ -227,95 +196,250 @@ public function viewElements(FieldItemListInterface $items, $langcode) { * The date in EDTF format. */ protected function formatDate($edtf_text) { - $settings = $this->getSettings(); - $cleaned_datetime = $edtf_text; - // TODO: Time? - $qualifiers_format = '%s'; - // Uncertainty. - if (!(strpos($edtf_text, '~') === FALSE)) { - $qualifiers_format = t('approximately'); - $qualifiers_format .= ' %s'; - } - if (!(strpos($edtf_text, '?') === FALSE)) { - $qualifiers_format = '%s '; - $qualifiers_format .= t('(uncertain)'); - } - $cleaned_datetime = str_replace(['?', '~'], '', $cleaned_datetime); - list($year, $month, $day) = explode('-', $cleaned_datetime, 3); + $date_time = explode('T', $edtf_text); - // Which unspecified, if any? - $which_unspecified = ''; - if (!(strpos($year, 'uu') === FALSE)) { - $which_unspecified = t('decade'); - } - if (!(strpos($year, 'u') === FALSE)) { - $which_unspecified = t('year'); + // Formatted versions of the date elements. + $year = ''; + $month = ''; + $day = ''; + + preg_match(EDTFUtils::DATE_PARSE_REGEX, $date_time[0], $parsed_date); + + $parsed_date[EDTFUtils::YEAR_BASE] = EDTFUtils::expandYear($parsed_date[EDTFUtils::YEAR_FULL], $parsed_date[EDTFUtils::YEAR_BASE], $parsed_date[EDTFUtils::YEAR_EXPONENT]); + $settings = $this->getSettings(); + + // Unspecified. + $unspecified = []; + if (strpos($parsed_date[EDTFUtils::YEAR_BASE], 'XXXX') !== FALSE) { + $unspecified[] = t('year'); } - if (!(strpos($month, 'u') === FALSE)) { - $which_unspecified = t('month'); - // No partial months. - $month = ''; + elseif (strpos($parsed_date[EDTFUtils::YEAR_BASE], 'XXX') !== FALSE) { + $unspecified[] = t('century'); } - if (!(strpos($day, 'u') === FALSE)) { - $which_unspecified = t('day'); - // No partial days. - $day = ''; + elseif (strpos($parsed_date[EDTFUtils::YEAR_BASE], 'XX') !== FALSE) { + $unspecified[] = t('decade'); } - // Add unspecified formatting if needed. - if (!empty($which_unspecified)) { - $qualifiers_format = t('an unspecified @part in', ['@part' => $which_unspecified]) . ' ' . $qualifiers_format; + elseif (strpos($parsed_date[EDTFUtils::YEAR_BASE], 'X') !== FALSE) { + $unspecified[] = t('year'); } - // Clean-up unspecified year/decade. - if (!(strpos($year, 'u') === FALSE)) { - $year = str_replace('u', '0', $year); - $year = t("the @year's", ['@year' => $year]); - } + $year = str_replace('X', '0', $parsed_date[EDTFUtils::YEAR_BASE]); - // Format the month. - if (!empty($month)) { - // IF 'mm', do nothing, it is already in this format. - if ($settings['month_format'] === 'mmm' || $settings['month_format'] === 'mmmm') { - $month = $this->MONTHS[$month][$settings['month_format']]; + if (array_key_exists(EDTFUtils::MONTH, $parsed_date)) { + if (strpos($parsed_date[EDTFUtils::MONTH], 'X') !== FALSE) { + $unspecified[] = t('month'); + // Month remains blank for output. } - // Digit Seasons. - elseif (in_array($month, ['21', '22', '23', '24'])) { - $season_mapping = ($settings['season_hemisphere'] === 'north' ? $this->seasonMapNorth : $this->seasonMapSouth); - $month = $season_mapping[$month]; + elseif ($settings['month_format'] === 'mmm' || $settings['month_format'] === 'mmmm') { + $month = EDTFUtils::MONTHS_MAP[$parsed_date[EDTFUtils::MONTH]][$settings['month_format']]; } - - if ($settings['month_format'] === 'm') { - $month = ltrim($month, ' 0'); + elseif ($settings['month_format'] === 'm') { + $month = ltrim($parsed_date[EDTFUtils::MONTH], ' 0'); + } + // IF 'mm', do nothing, it is already in this format. + else { + $month = $parsed_date[EDTFUtils::MONTH]; } } - // Format the day. - if (!empty($day)) { - if ($settings['day_format'] === 'd') { - $day = ltrim($day, ' 0'); + if (array_key_exists(EDTFUtils::DAY, $parsed_date)) { + if (strpos($parsed_date[EDTFUtils::DAY], 'X') !== FALSE) { + $unspecified[] = t('day'); + } + elseif ($settings['day_format'] === 'd') { + $day = ltrim($parsed_date[EDTFUtils::DAY], ' 0'); + } + else { + $day = $parsed_date[EDTFUtils::DAY]; } } - // Put the parts back together - // Big Endian by default. - $parts_in_order = [$year, $month, $day]; - + // Put the parts back together. if ($settings['date_order'] === 'little_endian') { $parts_in_order = [$day, $month, $year]; } elseif ($settings['date_order'] === 'middle_endian') { $parts_in_order = [$month, $day, $year]; - } // Big Endian by default + } + else { + // Big Endian by default. + $parts_in_order = [$year, $month, $day]; + } if ($settings['date_order'] === 'middle_endian' && !preg_match('/\d/', $month) && !empty(array_filter([$month, $day]))) { - $cleaned_datetime = "$month $day, $year"; + $formatted_date = "$month $day, $year"; } else { - $cleaned_datetime = implode($this->DELIMITERS[$settings['date_separator']], array_filter($parts_in_order)); + $formatted_date = implode($this->DELIMITERS[$settings['date_separator']], array_filter($parts_in_order)); + } + + // Time. + // TODO: Add time formatting options. + if (array_key_exists(1, $date_time) && !empty($date_time[1])) { + $formatted_date .= ' ' . $date_time[1]; } - return sprintf($qualifiers_format, $cleaned_datetime); + // Unspecified. + // Year = 1, Month = 2, Day = 4. + switch (count($unspecified)) { + case 1: + $formatted_date = t('unspecified @time_unit in @date', [ + '@time_unit' => $unspecified[0], + '@date' => $formatted_date, + ]); + break; + + case 2: + $formatted_date = t('unspecified @time_unit1 and @time_unit2 in @date', [ + '@time_unit1' => $unspecified[0], + '@time_unit2' => $unspecified[1], + '@date' => $formatted_date, + ]); + break; + + case 3: + $formatted_date = t('unspecified @time_unit1, @time_unit2, and @time_unit3 in @date', [ + '@time_unit1' => $unspecified[0], + '@time_unit2' => $unspecified[1], + '@time_unit2' => $unspecified[2], + '@date' => $formatted_date, + ]); + break; + } + + // Qualified. + // This is ugly and terrible, but I'm out of ideas for simplifying it. + $qualifiers = [ + 'uncertain' => [], + 'approximate' => [], + ]; + if (array_key_exists(EDTFUtils::QUALIFIER_YEAR, $parsed_date) && !empty($parsed_date[EDTFUtils::QUALIFIER_YEAR])) { + switch ($parsed_date[EDTFUtils::QUALIFIER_YEAR]) { + case '?': + $qualifiers['uncertain']['year'] = TRUE; + break; + + case '~': + $qualifiers['approximate']['year'] = TRUE; + break; + + case '%': + $qualifiers['uncertain']['year'] = TRUE; + $qualifiers['approximate']['year'] = TRUE; + break; + } + } + if (array_key_exists(EDTFUtils::QUALIFIER_YEAR_ONLY, $parsed_date) && !empty($parsed_date[EDTFUtils::QUALIFIER_YEAR_ONLY])) { + switch ($parsed_date[EDTFUtils::QUALIFIER_YEAR_ONLY]) { + case '?': + $qualifiers['uncertain']['year'] = TRUE; + break; + + case '~': + $qualifiers['approximate']['year'] = TRUE; + break; + + case '%': + $qualifiers['uncertain']['year'] = TRUE; + $qualifiers['approximate']['year'] = TRUE; + break; + } + } + if (array_key_exists(EDTFUtils::QUALIFIER_MONTH, $parsed_date) && !empty($parsed_date[EDTFUtils::QUALIFIER_MONTH])) { + switch ($parsed_date[EDTFUtils::QUALIFIER_MONTH]) { + case '?': + $qualifiers['uncertain']['year'] = TRUE; + $qualifiers['uncertain']['month'] = TRUE; + break; + + case '~': + $qualifiers['approximate']['year'] = TRUE; + $qualifiers['approximate']['month'] = TRUE; + break; + + case '%': + $qualifiers['uncertain']['year'] = TRUE; + $qualifiers['uncertain']['month'] = TRUE; + $qualifiers['approximate']['year'] = TRUE; + $qualifiers['approximate']['month'] = TRUE; + break; + } + } + if (array_key_exists(EDTFUtils::QUALIFIER_MONTH_ONLY, $parsed_date) && !empty($parsed_date[EDTFUtils::QUALIFIER_MONTH_ONLY])) { + switch ($parsed_date[EDTFUtils::QUALIFIER_MONTH_ONLY]) { + case '?': + $qualifiers['uncertain']['month'] = TRUE; + break; + + case '~': + $qualifiers['approximate']['month'] = TRUE; + break; + + case '%': + $qualifiers['uncertain']['month'] = TRUE; + $qualifiers['approximate']['month'] = TRUE; + break; + } + } + if (array_key_exists(EDTFUtils::QUALIFIER_DAY, $parsed_date) && !empty($parsed_date[EDTFUtils::QUALIFIER_DAY])) { + switch ($parsed_date[EDTFUtils::QUALIFIER_DAY]) { + case '?': + $qualifiers['uncertain']['year'] = TRUE; + $qualifiers['uncertain']['month'] = TRUE; + $qualifiers['uncertain']['day'] = TRUE; + break; + + case '~': + $qualifiers['approximate']['year'] = TRUE; + $qualifiers['approximate']['month'] = TRUE; + $qualifiers['approximate']['day'] = TRUE; + break; + + case '%': + $qualifiers['uncertain']['year'] = TRUE; + $qualifiers['uncertain']['month'] = TRUE; + $qualifiers['uncertain']['day'] = TRUE; + $qualifiers['approximate']['year'] = TRUE; + $qualifiers['approximate']['month'] = TRUE; + $qualifiers['approximate']['day'] = TRUE; + break; + } + } + if (array_key_exists(EDTFUtils::QUALIFIER_DAY_ONLY, $parsed_date) && !empty($parsed_date[EDTFUtils::QUALIFIER_DAY_ONLY])) { + switch ($parsed_date[EDTFUtils::QUALIFIER_DAY_ONLY]) { + case '?': + $qualifiers['uncertain']['day'] = TRUE; + break; + + case '~': + $qualifiers['approximate']['day'] = TRUE; + break; + + case '%': + $qualifiers['uncertain']['day'] = TRUE; + $qualifiers['approximate']['day'] = TRUE; + break; + } + } + $qualifier_parts = []; + foreach ($qualifiers as $qualifier => $parts) { + $keys = array_keys($parts); + switch (count($keys)) { + case 1: + case 2: + $qualifier_parts[] = implode(' ' . t('and') . ' ', $keys) . ' ' . $qualifier; + break; + + case 3: + $qualifier_parts[] = $qualifier; + break; + } + } + if (count($qualifier_parts) > 0) { + return $formatted_date . ' (' . implode('; ', $qualifier_parts) . ')'; + } + return $formatted_date; } } diff --git a/src/Plugin/Field/FieldType/ExtendedDateTimeFormat.php b/src/Plugin/Field/FieldType/ExtendedDateTimeFormat.php index 900e4d2..643021a 100644 --- a/src/Plugin/Field/FieldType/ExtendedDateTimeFormat.php +++ b/src/Plugin/Field/FieldType/ExtendedDateTimeFormat.php @@ -9,7 +9,7 @@ * * @FieldType( * id = "edtf", - * label = @Translation("EDTF, level 1"), + * label = @Translation("EDTF"), * module = "controlled_access_terms", * description = @Translation("Extended Date Time Format field"), * default_formatter = "edtf_default", diff --git a/src/Plugin/Field/FieldWidget/EDTFWidget.php b/src/Plugin/Field/FieldWidget/EDTFWidget.php index 8d59cca..2d5d914 100644 --- a/src/Plugin/Field/FieldWidget/EDTFWidget.php +++ b/src/Plugin/Field/FieldWidget/EDTFWidget.php @@ -2,18 +2,16 @@ namespace Drupal\controlled_access_terms\Plugin\Field\FieldWidget; -use Datetime; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\WidgetBase; use Drupal\Core\Form\FormStateInterface; +use Drupal\controlled_access_terms\EDTFUtils; /** * Plugin implementation of the 'edtf' widget. * - * Validates text values for compliance with EDTF 1.0, level 1. - * http://www.loc.gov/standards/datetime/pre-submission.html. - * - * // TODO: maybe some day support level 2. + * Validates text values for compliance with EDTF (2018). + * https://www.loc.gov/standards/datetime/edtf.html. * * @FieldWidget( * id = "edtf_default", @@ -32,6 +30,7 @@ public static function defaultSettings() { return [ 'strict_dates' => FALSE, 'intervals' => FALSE, + 'sets' => FALSE, ] + parent::defaultSettings(); } @@ -40,21 +39,19 @@ public static function defaultSettings() { */ public function settingsForm(array $form, FormStateInterface $form_state) { $description_string = $this->t( - 'Negative dates, and the level 1 features unspecified dates, - extended years, and seasons - are not supported with strict date checking.' + 'Most level 1 and 2 features are not supported with strict date checking.' ); $description_string .= '
'; $description_string .= $this->t( 'Uncertain/Approximate dates will have their markers removed before - checking. (For example, "1984~?" will be checked as "1984".)' + checking. (For example, "1984?", "1984~", and "1984%" will be checked as "1984".)' ); $element = parent::settingsForm($form, $form_state); $element['description'] = [ '#type' => 'markup', '#prefix' => '
', '#suffix' => '
', - '#markup' => $this->t('See Library of Congress EDTF Draft Submission for details on formatting options.', ['@locedtf' => 'http://www.loc.gov/standards/datetime/pre-submission.html']), + '#markup' => $this->t('See Library of Congress EDTF Specification for details on formatting options.', ['@locedtf' => 'https://www.loc.gov/standards/datetime/edtf.html']), ]; $element['strict_dates'] = [ '#type' => 'checkbox', @@ -67,6 +64,11 @@ public function settingsForm(array $form, FormStateInterface $form_state) { '#title' => $this->t('Permit date intervals.'), '#default_value' => $this->getSetting('intervals'), ]; + $element['sets'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Permit date sets. (Not recommended; make the field repeatable instead.)'), + '#default_value' => $this->getSetting('sets'), + ]; return $element; } @@ -85,6 +87,12 @@ public function settingsSummary() { else { $summary[] = t('Date intervals are not permitted'); } + if ($this->getSetting('sets')) { + $summary[] = t('Date sets permitted'); + } + else { + $summary[] = t('Date sets are not permitted'); + } return $summary; } @@ -115,134 +123,10 @@ public function validate($element, FormStateInterface $form_state) { $form_state->setValueForElement($element, ''); return; } - - // Intervals. - if ($this->getSetting('intervals')) { - if (strpos($value, 'T') !== FALSE) { - $form_state->setError($element, t("Date intervals cannot include times.")); - } - - list($begin, $end) = explode('/', $value); - // Begin. - $error_message = $this->dateValidation($begin); - if ($error_message) { - $form_state->setError($element, $error_message); - } - // End either empty or valid extended interval values (5.2.3.) - if (empty($end) || $end === 'unknown' || $end === 'open') { - return; - } - $error_message = $this->dateValidation($end); - if ($error_message) { - $form_state->setError($element, $error_message); - } - } - else { - $error_message = $this->dateValidation($value); - if ($error_message) { - $form_state->setError($element, $error_message); - } - } - } - - /** - * Validate a date. - * - * @param string $datetime_str - * The datetime string. - * - * @return bool|string - * False if valid or a string explaining the reason for invalidation. - */ - protected function dateValidation($datetime_str) { - - list($date, $time) = explode('T', $datetime_str); - - $date = trim($date); - $extended_year = (strpos($date, 'y') === 0 ? TRUE : FALSE); - if ($extended_year && $this->getSetting('strict_dates')) { - return "Extended years (5.2.4.) are not supported with the 'strict dates' option enabled."; - } - // Uncertainty characters on the end are valid Level 1 features (5.2.1.), - // pull them off to make checking the rest easier. - $date = rtrim($date, '?~'); - - // Negative year? That is fine, but remove it - // and the extended year indicator before exploding the date. - $date = ltrim($date, 'y-'); - - // Now to check the parts. - list($year, $month, $day) = explode('-', $date, 3); - - // Year. - if (!preg_match('/^\d\d(\d\d|\du|uu)$/', $year) && !$extended_year) { - return "The year '$year' is invalid. Please enter a four-digit year."; - } - elseif ($extended_year && !preg_match('/^\d{5,}$/', $year)) { - return "Invalid extended year. Please enter at least a four-digit year."; - } - $strict_pattern = 'Y'; - - // Month. - if (!empty($month) && !preg_match('/^(\d\d|\du|uu)$/', $month)) { - return "The month '$month' is invalid. Please enter a two-digit month."; - } - if (!empty($month)) { - if (strpos($year, 'u') !== FALSE && strpos($month, 'u') === FALSE) { - return "The month must either be blank or unspecified when the year is unspecified."; - } - $strict_pattern = 'Y-m'; - } - - // Day. - if (!empty($day) && !preg_match('/^(\d\d|\du|uu)$/', $day)) { - return "The day '$day' is invalid. Please enter a two-digit day."; - } - if (!empty($day)) { - if (strpos($month, 'u') !== FALSE && strpos($day, 'u') === FALSE) { - return "The day must either be blank or unspecified when the month is unspecified."; - } - $strict_pattern = 'Y-m-d'; + $errors = EDTFUtils::validate($value, $this->getSetting('intervals'), $this->getSetting('sets'), $this->getSetting('strict_dates')); + if (!empty($errors)) { + $form_state->setError($element, implode("\n", $errors)); } - - // Time. - if (strpos($datetime_str, 'T') !== FALSE && empty($time)) { - return "Time not provided with time seperator (T)."; - } - - if ($time) { - if (!preg_match('/^-?(\d{4})(-\d{2}){2}T\d{2}(:\d{2}){2}(Z|(\+|-)\d{2}:\d{2})?$/', $datetime_str, $matches)) { - return "The date/time '$datetime_str' is invalid. See EDTF 1.0, 5.1.2."; - } - drupal_set_message(print_r($matches, TRUE)); - $strict_pattern = 'Y-m-d\TH:i:s'; - if (count($matches) > 4) { - if ($matches[4] === 'Z') { - $strict_pattern .= '\Z'; - } - else { - $strict_pattern .= 'P'; - } - } - } - - if ($this->getSetting('strict_dates')) { - // Clean the date/time string to ensure it parses correctly. - $cleaned_datetime = str_replace('u', '1', $datetime_str); - $datetime_obj = DateTime::createFromFormat('!' . $strict_pattern, $cleaned_datetime); - $errors = DateTime::getLastErrors(); - if (!$datetime_obj || - !empty($errors['warning_count']) || - // DateTime will create valid dates from Y-m without warning, - // so validate we still have what it was given. - !($cleaned_datetime === $datetime_obj->format($strict_pattern)) - ) { - return "Strictly speaking, the date (and/or time) '$datetime_str' is invalid."; - } - - } - - return FALSE; } }