Odyssey
DateConvert.i
1 <?php
2 /**
3  * @copyright HomeCu 05/2019
4  *
5  * Reference this class to validate and update the most used date string formats into
6  * (almost) any format compatible with PHP DateTime. This class came to life from a
7  * request to allow two digit year inputs for birthdate. Two digits . . . in what
8  * century? Think about that a second . . . combined with the IBM solution and
9  * some input options, this class proposes a solid reusable solution.
10  *
11  * Usage:
12  * $dc = new DateConvert();
13  * echo $dc->convertDateFromFormat([string $date], [optional string $requested_format], [optional int century);
14  *
15  * Supports slash, dash, or dot separators in American and European formats.
16  * $requested_format will default to Y-m-d SQL format if omitted.
17  * Four digit year will be used in any case (won't be overridden.)
18  * For two digit year input, century will override the "40 year algo"
19  * used by IBM, see comments at setYear().
20  */
22 {
23  /** @var string, default return format, may optionally be overridden by input */
24  protected $requested_format = 'Y-m-d';
25 
26  /**
27  * @var int, optional input param to set century.
28  * See comments at setYear(), this overrides default if provided and > 0.
29  */
30  protected $century = 0;
31 
32  /** @var array these are the current formats we support. @TODO construct in a loop? */
33  protected $formats = [
34  // MM/DD/YYYY M/DD/YYYY M/D/YYYY MM/D/YYYY
35  'm/d/Y', 'n/d/Y', 'n/j/Y', 'm/j/Y',
36  // MM/DD/YY M/DD/YY M/D/YY MM/D/YY
37  'm/d/y', 'n/d/y', 'n/j/y', 'm/j/y',
38  // MM.DD.YYYY M.DD.YYYY M.D.YYYY MM.D.YYYY
39  'm.d.Y', 'n.d.Y', 'n.j.Y', 'm.j.Y',
40  // MM.DD.YY M.DD.YY M.D.YY MM.D.YY
41  'm.d.y', 'n.d.y', 'n.j.y', 'm.j.y',
42  // MM-DD-YYYY M-DD-YYYY M-D-YYYY MM-D-YYYY
43  'm-d-Y', 'n-d-Y', 'n-j-Y', 'm-j-Y',
44  // MM-DD-YY M-DD-YY M-D-YY MM-D-YY
45  'm-d-y', 'n-d-y', 'n-j-y', 'm-j-y',
46  // YYYY/MM/DD YYYY/M/DD YYYY/M/D YYYY/MM/D
47  'Y/m/d', 'Y/n/d', 'Y/n/j', 'Y/m/j',
48  // YY/MM/DD YY/M/DD YY/M/D YY/MM/D
49  'y/m/d', 'y/n/d', 'y/n/j', 'y/m/j',
50  // YYYY.MM.DD YYYY.M.DD YYYY.M.D YYYY.MM.D
51  'Y.m.d', 'Y.n.d', 'Y.n.j', 'Y.m.j',
52  // YY.MM.DD YY.M.DD YY.M.D YY.MM.D
53  'y.m.d', 'y.n.d', 'y.n.j', 'y.m.j',
54  // MM.DD.YYYY M.DD.YYYY MM.D.YYYY M.D.YYYY
55  'm.d.Y', 'n.d.Y', 'm.j.Y', 'n.j.Y',
56  // MM.DD.YY M.DD.YY MM.D.YY M.D.YY
57  'm.d.y', 'n.d.y', 'm.j.y', 'n.j.y',
58  // YYYY-MM-DD YYYY-M-DD YYYY-M-D YYYY-MM-D
59  'Y-m-d', 'Y-n-d', 'Y-n-j', 'Y-m-j',
60  // YY-MM-DD YY-M-DD YY-M-D YY-MM-D
61  'y-m-d', 'y-n-d', 'y-n-j', 'y-m-j',
62  ];
63 
64  /**
65  * DateConvert constructor.
66  * Nothing to do
67  * @return void
68  */
69  public function __construct() {
70 
71  }
72 
73  /**
74  * Entry point, see calling consumers/clients/apps for examples.
75  * @param string $date
76  * @param string $requested_format
77  * @param int $century, overrides 40 year rule documented at setYear() below.
78  * @return string
79  * @throws Exception
80  */
81  public function ConvertDateFromFormat($date, $requested_format = '', $century = 0) {
82 
83  return $this
84  ->SetRequestedFormat($requested_format)
85  ->SetRequestedCentury($century)
86  ->FindValidFormat($date);
87  }
88 
89  /**
90  * Set the requested format into the class property.
91  * @param string $format
92  * @return $this
93  */
94  protected function SetRequestedFormat($format) {
95 
96  if (! $this->IsValidRequestedFormat($format)) {
97  return $this;
98  }
99 
100  $this->requested_format = $format;
101 
102  return $this;
103  }
104 
105  /**
106  * If there is an optional requested output format, validate it and return a
107  * message if it's not in the cool kids' club (formats property array.)
108  * @param string $format
109  * @return bool
110  */
111  protected function IsValidRequestedFormat($format = '') {
112 
113  if (empty($format) || ! in_array($format, $this->formats)) {
114  return false;
115  }
116 
117  return true;
118  }
119 
120  /**
121  * Set the optional requested century to override the 40 year rule documented at setYear() below.
122  * @param int century
123  * @return $this
124  */
125  protected function SetRequestedCentury($century = 0) {
126 
127  if (intval($century) > 0) {
128  $this->century = intval($century);
129  }
130 
131  return $this;
132  }
133 
134  /**
135  * Look up from a list of date formats and see if the input date string matches on
136  * any of them. This kind of sucks, should be built dynamically but it works
137  * (and allows us control over the date formats we want to support.)
138  * @param string $date
139  * @return string|false
140  * @throws Exception
141  */
142  protected function FindValidFormat($date) {
143 
144  foreach ($this->formats as $fmt) {
145 
146  if ($this->IsValidDate($fmt, $date)) {
147 
148  // Ensure we have a 4 digit year, even if two is input.
149  $date = $this->ConfirmFourDigitYear($fmt, $date);
150  // Once it's 4 digit, it will break DateTime if the year format is still 'y'
151  $fmt = preg_replace('/y/', 'Y', $fmt);
152 
153  return $this->CreateRequestedDate($fmt, $date);
154  }
155  }
156 
157  return false;
158  }
159 
160  /**
161  * Core date validation, leverage the DateTime object.
162  * @param string $format
163  * @param string $date
164  * @return bool
165  */
166  protected function IsValidDate($format, $date) {
167 
168  $obj = DateTime::createFromFormat($format, $date);
169  return $obj && $obj->format($format) == $date;
170  }
171 
172  /**
173  * Make sure our years are expressed in 4 digits, and hopefully in the right century. We're
174  * going to get a little messy now, like rubbing two sticks together messy. The DateTime
175  * class assumes we aren't going to do something dumb like ask it for a two digit year
176  * date. If asked, a string of 10/13/60 will return 10/13/2060. So what we have to do
177  * is deconstruct the date string old school and determine which century we need it
178  * in using the IBM algo. If that doesn't suffice, we also have the century param
179  * that will override the century calculation if needed.
180  *
181  * @param string $format
182  * @param string $date
183  * @return string
184  * @throws Exception
185  */
186  protected function ConfirmFourDigitYear($format, $date) {
187 
188  $date_arr = $this->SplitDateOnDelimiter($format, $date);
189 
190  if (strlen($date_arr['y']) >= 4) {
191  return $date;
192  }
193 
194  $y = $this->SetFourDigitYear($date_arr['y']);
195  // Breaks DateTime format if it's still a y after changing to 4 digits.
196  $format = preg_replace('/y/', 'Y', $format);
197  $obj = new DateTime;
198 
199  $obj->setDate($y, $date_arr['m'], $date_arr['d']);
200 
201  return $obj->format($format);
202  }
203 
204  /**
205  * The rubbing two sticks together part. Split up a date string and create an
206  * array associated with keys m, d, y. Why are we doing this? To ensure a two
207  * digit year gets **properly** assigned as a 4 digit year. DateTime alone
208  * would always return 2060 if provided the year "60" which in most cases
209  * means 1960.
210  * @param string $format
211  * @param string $date
212  * @return array
213  */
214  protected function SplitDateOnDelimiter($format, $date) {
215 
216  $arr = [];
217  $delim = $this->DetectDelimiter($format);
218 
219  $keys = explode($delim, $format);
220  $vals = explode($delim, $date);
221 
222  for ($i = 0; $i < count($keys); $i++) {
223 
224  if (in_array(strtolower($keys[$i]), ['m', 'n'])) {
225  $arr['m'] = $vals[$i];
226  }
227  if (in_array(strtolower($keys[$i]), ['d', 'j'])) {
228  $arr['d'] = $vals[$i];
229  }
230  if (strtolower($keys[$i]) == 'y') {
231  $arr['y'] = $vals[$i];
232  }
233  }
234 
235  return $arr;
236  }
237 
238  /**
239  * Extract the delimiter from the format string we found from the input date.
240  * @param $format
241  * @return string|null
242  */
243  protected function DetectDelimiter($format) {
244 
245  $supported = ['/', '.', '-'];
246 
247  foreach ($supported as $delim) {
248  if (preg_match("|$delim|", $format)) {
249  return $delim;
250  }
251  }
252 
253  return null;
254  }
255 
256  /**
257  * Set a four digit year from a two digit year. If you're parsing a four digit
258  * year, you shouldn't be here, it will have already returned the date, but
259  * including the guard pattern anyway. :-)
260  * @param int $year
261  * @return int|string
262  * @throws Exception
263  */
264  protected function SetFourDigitYear($year = 0) {
265 
266  $this->ValidateYearInput($year);
267 
268  if (strlen($year) >= 4) {
269  return $year;
270  }
271 
272  return $this->SetYear(intval($year));
273  }
274 
275  /**
276  * Validate year
277  * @param int $year
278  * @return string|null
279  * @throws Exception
280  */
281  protected function ValidateYearInput($year = 0) {
282 
283  if (intval($year) < 0) {
284  throw new Exception("Please enter a numeric year." . PHP_EOL);
285  }
286 
287  if (intval($year) > 99) {
288  throw new Exception("Please enter a two digit year." . PHP_EOL);
289  }
290 
291  return null;
292  }
293 
294  /**
295  * Set a four digit year from a two digit year based on an optional provided century or year
296  * alone. From IBM:
297  *
298  * "When you are moving or comparing 2-digit years to 4-digit years or centuries, or comparing
299  * 4-digit years or centuries to 2-digit years, ILE COBOL first converts the 2-digit year using
300  * a windowing algorithm. The default windowing algorithm used is as follows:
301  *
302  * "If a 2-digit year is moved to a 4-digit year, the century (1st 2 digits of the year) are
303  * chosen as follows: If the 2-digit year is greater than or equal to 40, the century used is
304  * 1900. In other words, 19 becomes the first 2 digits of the 4-digit year.
305  *
306  * If the 2-digit year is less than 40, the century used is 2000. In other words, 20 becomes
307  * the first 2 digits of the 4-digit year."
308  *
309  * https://www.ibm.com/support/knowledgecenter/en/ssw_ibm_i_72/rzase/yrconv.htm
310  *
311  * In our case we have added the optional param $century [int > 1000] so it can be customized
312  * by consumers (consumers being the apps calling this class.)
313  *
314  * @param int $year
315  * @return int
316  */
317  protected function SetYear($year = 0) {
318 
319  // PHP will automagically cast these, but in the interest of specificity . . .
320  $year = intval($year);
321  $century = intval($this->century);
322 
323  if ($century < 1) {
324  $century = ($year >= 40)? 1900 : 2000;
325  }
326 
327  // Probably fine but guard against typos or other abuse. We want
328  // a round century number. Nuke a sloppy century.
329  $century = intdiv($century, 100) * 100;
330 
331  return $year += $century;
332  }
333 
334  /**
335  * Leverage the DateTime object and create a date string formatted as requested from original.
336  * @param string $current_format
337  * @param string $date
338  * @return bool
339  */
340  protected function CreateRequestedDate($current_format, $date) {
341 
342  $obj = DateTime::createFromFormat($current_format, $date);
343  return $obj->format($this->requested_format);
344  }
345 
346 }
DetectDelimiter($format)
Definition: DateConvert.i:243
FindValidFormat($date)
Definition: DateConvert.i:142
ValidateYearInput($year=0)
Definition: DateConvert.i:281
SetFourDigitYear($year=0)
Definition: DateConvert.i:264
SetRequestedCentury($century=0)
Definition: DateConvert.i:125
ConfirmFourDigitYear($format, $date)
Definition: DateConvert.i:186
SplitDateOnDelimiter($format, $date)
Definition: DateConvert.i:214
SetYear($year=0)
Definition: DateConvert.i:317
IsValidDate($format, $date)
Definition: DateConvert.i:166
SetRequestedFormat($format)
Definition: DateConvert.i:94
ConvertDateFromFormat($date, $requested_format='', $century=0)
Definition: DateConvert.i:81
CreateRequestedDate($current_format, $date)
Definition: DateConvert.i:340
IsValidRequestedFormat($format='')
Definition: DateConvert.i:111