1 /**
2  * Time string formatting and such.
3  *
4  * License:
5  *   This Source Code Form is subject to the terms of
6  *   the Mozilla Public License, v. 2.0. If a copy of
7  *   the MPL was not distributed with this file, You
8  *   can obtain one at http://mozilla.org/MPL/2.0/.
9  *
10  * Authors:
11  *   Vladimir Panteleev <vladimir@thecybershadow.net>
12  */
13 
14 module ae.utils.time;
15 
16 import std.algorithm;
17 import std.conv : text;
18 import std.datetime;
19 import std.format : formattedWrite;
20 import std.math : abs;
21 import std.string;
22 import std.typecons;
23 
24 import ae.utils.text;
25 import ae.utils.textout;
26 
27 // ***************************************************************************
28 
29 struct TimeFormats
30 {
31 static:
32 	const ATOM = `Y-m-d\TH:i:sP`;
33 	const COOKIE = `l, d-M-y H:i:s T`;
34 	const ISO8601 = `Y-m-d\TH:i:sO`;
35 	const RFC822 = `D, d M y H:i:s O`;
36 	const RFC850 = `l, d-M-y H:i:s T`;
37 	const RFC1036 = `D, d M y H:i:s O`;
38 	const RFC1123 = `D, d M Y H:i:s O`;
39 	const RFC2822 = `D, d M Y H:i:s O`;
40 	const RFC3339 = `Y-m-d\TH:i:sP`;
41 	const RSS = `D, d M Y H:i:s O`;
42 	const W3C = `Y-m-d\TH:i:sP`;
43 
44 	const HTML5DATE = `Y-m-d`;
45 
46 	/// Format produced by std.date.toString, e.g. "Tue Jun 07 13:23:19 GMT+0100 2011"
47 	const STD_DATE = `D M d H:i:s \G\M\TO Y`;
48 }
49 
50 const WeekdayShortNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
51 const WeekdayLongNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
52 const MonthShortNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
53 const MonthLongNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
54 
55 // ***************************************************************************
56 
57 /// We assume that no timezone will have a name longer than this.
58 /// If one does, it is truncated to this length.
59 enum MaxTimezoneNameLength = 256;
60 
61 private struct FormatContext(Char)
62 {
63 	SysTime t;
64 	DateTime dt;
65 	bool escaping;
66 }
67 
68 private void putToken(alias c, alias context, alias sink)()
69 {
70 	with (context)
71 	{
72 		void putOneDigit(uint i)
73 		{
74 			debug assert(i < 10);
75 			sink.put(cast(char)('0' + i));
76 		}
77 
78 		void putOneOrTwoDigits(uint i)
79 		{
80 			debug assert(i < 100);
81 			if (i >= 10)
82 			{
83 				sink.put(cast(char)('0' + (i / 10)));
84 				sink.put(cast(char)('0' + (i % 10)));
85 			}
86 			else
87 				sink.put(cast(char)('0' +  i      ));
88 		}
89 
90 		void putTimezoneName(string tzStr)
91 		{
92 			if (tzStr.length)
93 				sink.put(tzStr[0..min($, MaxTimezoneNameLength)]);
94 			else
95 		//	if (t.timezone.utcToTZ(t.stdTime) == t.stdTime)
96 		//		sink.put("UTC");
97 		//	else
98 			{
99 				enum fmt = 'C';
100 				putToken!(fmt, context, sink)();
101 			}
102 		}
103 
104 		if (escaping)
105 			sink.put(c), escaping = false;
106 		else
107 			switch (c)
108 			{
109 				// Day
110 				case 'd':
111 					sink.put(toDecFixed!2(dt.day));
112 					break;
113 				case 'D':
114 					sink.put(WeekdayShortNames[dt.dayOfWeek]);
115 					break;
116 				case 'j':
117 					putOneOrTwoDigits(dt.day);
118 					break;
119 				case 'l':
120 					sink.put(WeekdayLongNames[dt.dayOfWeek]);
121 					break;
122 				case 'N':
123 					putOneDigit((dt.dayOfWeek+6)%7 + 1);
124 					break;
125 				case 'S':
126 					switch (dt.day)
127 					{
128 						case 1:
129 						case 21:
130 						case 31:
131 							sink.put("st");
132 							break;
133 						case 2:
134 						case 22:
135 							sink.put("nd");
136 							break;
137 						case 3:
138 						case 23:
139 							sink.put("rd");
140 							break;
141 						default:
142 							sink.put("th");
143 					}
144 					break;
145 				case 'w':
146 					putOneDigit(cast(int)dt.dayOfWeek);
147 					break;
148 				case 'z':
149 					sink.put(text(dt.dayOfYear-1));
150 					break;
151 
152 				// Week
153 				case 'W':
154 					sink.put(toDecFixed!2(dt.isoWeek));
155 					break;
156 
157 				// Month
158 				case 'F':
159 					sink.put(MonthLongNames[dt.month-1]);
160 					break;
161 				case 'm':
162 					sink.put(toDecFixed!2(dt.month));
163 					break;
164 				case 'M':
165 					sink.put(MonthShortNames[dt.month-1]);
166 					break;
167 				case 'n':
168 					putOneOrTwoDigits(dt.month);
169 					break;
170 				case 't':
171 					putOneOrTwoDigits(dt.daysInMonth);
172 					break;
173 
174 				// Year
175 				case 'L':
176 					sink.put(dt.isLeapYear ? '1' : '0');
177 					break;
178 				// case 'o': TODO (ISO 8601 year number)
179 				case 'Y':
180 					sink.put(toDecFixed!4(cast(uint)dt.year)); // Hack? Assumes years are in 1000-9999 AD range
181 					break;
182 				case 'y':
183 					sink.put(toDecFixed!2(cast(uint)dt.year % 100));
184 					break;
185 
186 				// Time
187 				case 'a':
188 					sink.put(dt.hour < 12 ? "am" : "pm");
189 					break;
190 				case 'A':
191 					sink.put(dt.hour < 12 ? "AM" : "PM");
192 					break;
193 				// case 'B': TODO (Swatch Internet time)
194 				case 'g':
195 					putOneOrTwoDigits((dt.hour+11)%12 + 1);
196 					break;
197 				case 'G':
198 					putOneOrTwoDigits(dt.hour);
199 					break;
200 				case 'h':
201 					sink.put(toDecFixed!2(cast(uint)(dt.hour+11)%12 + 1));
202 					break;
203 				case 'H':
204 					sink.put(toDecFixed!2(dt.hour));
205 					break;
206 				case 'i':
207 					sink.put(toDecFixed!2(dt.minute));
208 					break;
209 				case 's':
210 					sink.put(toDecFixed!2(dt.second));
211 					break;
212 				case 'u':
213 					sink.put(toDecFixed!6(cast(uint)t.fracSecs.split!"usecs".usecs));
214 					break;
215 				case 'E': // not standard
216 					sink.put(toDecFixed!3(cast(uint)t.fracSecs.split!"msecs".msecs));
217 					break;
218 
219 				// Timezone
220 				case 'e':
221 					putTimezoneName(t.timezone.name);
222 					break;
223 				case 'I':
224 					sink.put(t.dstInEffect ? '1': '0');
225 					break;
226 				case 'O':
227 				{
228 					auto minutes = (t.timezone.utcToTZ(t.stdTime) - t.stdTime) / 10_000_000 / 60;
229 					sink.reference.formattedWrite("%+03d%02d", minutes/60, abs(minutes%60));
230 					break;
231 				}
232 				case 'P':
233 				{
234 					auto minutes = (t.timezone.utcToTZ(t.stdTime) - t.stdTime) / 10_000_000 / 60;
235 					sink.reference.formattedWrite("%+03d:%02d", minutes/60, abs(minutes%60));
236 					break;
237 				}
238 				case 'T':
239 					putTimezoneName(t.timezone.stdName);
240 					break;
241 				case 'Z':
242 					sink.putDecimal((t.timezone.utcToTZ(t.stdTime) - t.stdTime) / 10_000_000);
243 					break;
244 
245 				// Full date/time
246 				case 'c':
247 					sink.put(dt.toISOExtString());
248 					break;
249 				case 'r':
250 					putTime(sink, t, TimeFormats.RFC2822);
251 					break;
252 				case 'U':
253 					sink.putDecimal(t.toUnixTime());
254 					break;
255 
256 				// Escape next character
257 				case '\\':
258 					escaping = true;
259 					break;
260 
261 				// Other characters (whitespace, delimiters)
262 				default:
263 					put(sink, c);
264 			}
265 	}
266 }
267 
268 /// Format a SysTime using the format spec fmt.
269 /// This version generates specialized code for the given fmt.
270 string formatTime(string fmt)(SysTime t)
271 {
272 	enum maxSize = timeFormatSize(fmt);
273 	auto result = StringBuilder(maxSize);
274 	putTime!fmt(result, t);
275 	return result.get();
276 }
277 
278 /// ditto
279 void putTime(string fmt, S)(ref S sink, SysTime t)
280 	if (IsStringSink!S)
281 {
282 	putTimeImpl!fmt(sink, t);
283 }
284 
285 /// Format a SysTime using the format spec fmt.
286 /// This version parses fmt at runtime.
287 string formatTime(SysTime t, string fmt)
288 {
289 	auto result = StringBuilder(timeFormatSize(fmt));
290 	putTime(result, t, fmt);
291 	return result.get();
292 }
293 
294 /// ditto
295 deprecated string formatTime(string fmt, SysTime t = Clock.currTime())
296 {
297 	auto result = StringBuilder(48);
298 	putTime(result, fmt, t);
299 	return result.get();
300 }
301 
302 /// ditto
303 void putTime(S)(ref S sink, SysTime t, string fmt)
304 	if (IsStringSink!S)
305 {
306 	putTimeImpl!fmt(sink, t);
307 }
308 
309 deprecated alias format = formatTime;
310 
311 /// ditto
312 deprecated void putTime(S)(ref S sink, string fmt, SysTime t = Clock.currTime())
313 	if (IsStringSink!S)
314 {
315 	putTimeImpl!fmt(sink, t);
316 }
317 
318 void putTimeImpl(alias fmt, S)(ref S sink, SysTime t)
319 {
320 	FormatContext!(char) context;
321 	context.t = t;
322 	context.dt = cast(DateTime)t;
323 	foreach (c; CTIterate!fmt)
324 		putToken!(c, context, sink)();
325 }
326 
327 /// Calculate the maximum amount of characters needed to store a time in this format.
328 /// Can be evaluated at compile-time.
329 size_t timeFormatSize(string fmt)
330 {
331 	static size_t maxLength(in string[] names) { return reduce!max(map!`a.length`(WeekdayShortNames)); }
332 
333 	size_t size = 0;
334 	bool escaping = false;
335 	foreach (char c; fmt)
336 		if (escaping)
337 			size++, escaping = false;
338 		else
339 			switch (c)
340 			{
341 				case 'N':
342 				case 'w':
343 				case 'L':
344 				case 'I':
345 					size++;
346 					break;
347 				case 'd':
348 				case 'j':
349 				case 'S':
350 				case 'W':
351 				case 'm':
352 				case 'n':
353 				case 't':
354 				case 'y':
355 				case 'a':
356 				case 'A':
357 				case 'g':
358 				case 'G':
359 				case 'h':
360 				case 'H':
361 				case 'i':
362 				case 's':
363 					size += 2;
364 					break;
365 				case 'z':
366 				case 'E': // not standard
367 					size += 3;
368 					break;
369 				case 'Y':
370 					size += 4;
371 					break;
372 				case 'Z': // Timezone offset in seconds
373 				case 'O':
374 					size += 5;
375 					break;
376 				case 'u':
377 				case 'P':
378 					size += 6;
379 					break;
380 				case 'T':
381 					size += 32;
382 					break;
383 
384 				case 'D':
385 					size += maxLength(WeekdayShortNames);
386 					break;
387 				case 'l':
388 					size += maxLength(WeekdayLongNames);
389 					break;
390 				case 'F':
391 					size += maxLength(MonthLongNames);
392 					break;
393 				case 'M':
394 					size += maxLength(MonthShortNames);
395 					break;
396 
397 				case 'e': // Timezone name
398 					return MaxTimezoneNameLength;
399 
400 				// Full date/time
401 				case 'c':
402 					enum ISOExtLength = "-0004-01-05T00:00:02.052092+10:00".length;
403 					size += ISOExtLength;
404 					break;
405 				case 'r':
406 					size += timeFormatSize(TimeFormats.RFC2822);
407 					break;
408 				case 'U':
409 					size += DecimalSize!int;
410 					break;
411 
412 				// Escape next character
413 				case '\\':
414 					escaping = true;
415 					break;
416 
417 				// Other characters (whitespace, delimiters)
418 				default:
419 					size++;
420 			}
421 
422 	return size;
423 }
424 
425 static assert(timeFormatSize(TimeFormats.STD_DATE) == "Tue Jun 07 13:23:19 GMT+0100 2011".length);
426 
427 import std.exception : enforce;
428 import std.conv : to;
429 import std.ascii : isDigit, isWhite;
430 
431 private struct ParseContext(Char, bool checked)
432 {
433 	int year=0, month=1, day=1, hour=0, minute=0, second=0, usecs=0;
434 	int hour12 = 0; bool pm;
435 	Rebindable!(immutable(TimeZone)) tz;
436 	int dow = -1;
437 	Char[] t;
438 	bool escaping;
439 
440 	void need(size_t n)()
441 	{
442 		static if (checked)
443 			enforce(t.length >= n, "Not enough characters in date string");
444 	}
445 
446 	auto take(size_t n)()
447 	{
448 		need!n();
449 		auto result = t[0..n];
450 		t = t[n..$];
451 		return result;
452 	}
453 
454 	char takeOne()
455 	{
456 		need!1();
457 		auto result = t[0];
458 		t = t[1..$];
459 		return result;
460 	}
461 
462 	R takeNumber(size_t n, sizediff_t maxP = -1, R = int)()
463 	{
464 		enum max = maxP == -1 ? n : maxP;
465 		need!n();
466 		foreach (i, c; t[0..n])
467 			enforce((i==0 && c=='-') || isDigit(c) || isWhite(c), "Number expected");
468 		static if (n == max)
469 			enum i = n;
470 		else
471 		{
472 			auto i = n;
473 			while (i < max && (checked ? i < t.length : true) && isDigit(t[i]))
474 				i++;
475 		}
476 		auto s = t[0..i];
477 		t = t[i..$];
478 		return s.strip().to!R();
479 	}
480 
481 	int takeWord(in string[] words, string name)
482 	{
483 		foreach (idx, string word; words)
484 		{
485 			static if (checked)
486 				bool b = t.startsWith(word);
487 			else
488 				bool b = t[0..word.length] == word;
489 			if (b)
490 			{
491 				t = t[word.length..$];
492 				return cast(int)idx;
493 			}
494 		}
495 		throw new Exception(name ~ " expected");
496 	}
497 
498 	char peek()
499 	{
500 		need!1();
501 		return *t.ptr;
502 	}
503 }
504 
505 private void parseToken(alias c, alias context)()
506 {
507 	with (context)
508 	{
509 		// TODO: check if the compiler optimizes this check away
510 		// in the compile-time version. If not, "escaping" needs to
511 		// be moved into an alias parameter.
512 		if (escaping)
513 		{
514 			enforce(takeOne() == c, c ~ " expected");
515 			escaping = false;
516 			return;
517 		}
518 
519 		switch (c)
520 		{
521 			// Day
522 			case 'd':
523 				day = takeNumber!(2)();
524 				break;
525 			case 'D':
526 				dow = takeWord(WeekdayShortNames, "Weekday");
527 				break;
528 			case 'j':
529 				day = takeNumber!(1, 2);
530 				break;
531 			case 'l':
532 				dow = takeWord(WeekdayLongNames, "Weekday");
533 				break;
534 			case 'N':
535 				dow = takeNumber!1 % 7;
536 				break;
537 			case 'S': // ordinal suffix
538 				take!2;
539 				break;
540 			case 'w':
541 				dow = takeNumber!1;
542 				break;
543 			//case 'z': TODO
544 
545 			// Week
546 			//case 'W': TODO
547 
548 			// Month
549 			case 'F':
550 				month = takeWord(MonthLongNames, "Month") + 1;
551 				break;
552 			case 'm':
553 				month = takeNumber!2;
554 				break;
555 			case 'M':
556 				month = takeWord(MonthShortNames, "Month") + 1;
557 				break;
558 			case 'n':
559 				month = takeNumber!(1, 2);
560 				break;
561 			case 't':
562 				takeNumber!(1, 2); // TODO: validate DIM?
563 				break;
564 
565 			// Year
566 			case 'L':
567 				takeNumber!1; // TODO: validate leapness?
568 				break;
569 			// case 'o': TODO (ISO 8601 year number)
570 			case 'Y':
571 				year = takeNumber!4;
572 				break;
573 			case 'y':
574 				year = takeNumber!2;
575 				if (year > 50) // TODO: find correct logic for this
576 					year += 1900;
577 				else
578 					year += 2000;
579 				break;
580 
581 			// Time
582 			case 'a':
583 				pm = takeWord(["am", "pm"], "am/pm")==1;
584 				break;
585 			case 'A':
586 				pm = takeWord(["AM", "PM"], "AM/PM")==1;
587 				break;
588 			// case 'B': TODO (Swatch Internet time)
589 			case 'g':
590 				hour12 = takeNumber!(1, 2);
591 				break;
592 			case 'G':
593 				hour = takeNumber!(1, 2);
594 				break;
595 			case 'h':
596 				hour12 = takeNumber!2;
597 				break;
598 			case 'H':
599 				hour = takeNumber!2;
600 				break;
601 			case 'i':
602 				minute = takeNumber!2;
603 				break;
604 			case 's':
605 				second = takeNumber!2;
606 				break;
607 			case 'u':
608 				usecs = takeNumber!6;
609 				break;
610 			case 'E': // not standard
611 				usecs = 1000 * takeNumber!3;
612 				break;
613 
614 			// Timezone
615 			// case 'e': ???
616 			case 'I':
617 				takeNumber!1;
618 				break;
619 			case 'O':
620 			{
621 				if (peek() == 'Z')
622 				{
623 					t = t[1..$];
624 					tz = UTC();
625 				}
626 				else
627 				if (peek() == 'G')
628 				{
629 					enforce(take!3() == "GMT", "GMT expected");
630 					tz = UTC();
631 				}
632 				else
633 				{
634 					auto tzStr = take!5();
635 					enforce(tzStr[0]=='-' || tzStr[0]=='+', "-/+ expected");
636 					auto n = (to!int(tzStr[1..3]) * 60 + to!int(tzStr[3..5])) * (tzStr[0]=='-' ? -1 : 1);
637 					tz = new immutable(SimpleTimeZone)(minutes(n));
638 				}
639 				break;
640 			}
641 			case 'P':
642 			{
643 				auto tzStr = take!6();
644 				enforce(tzStr[0]=='-' || tzStr[0]=='+', "-/+ expected");
645 				enforce(tzStr[3]==':', ": expected");
646 				auto n = (to!int(tzStr[1..3]) * 60 + to!int(tzStr[4..6])) * (tzStr[0]=='-' ? -1 : 1);
647 				tz = new immutable(SimpleTimeZone)(minutes(n));
648 				break;
649 			}
650 			case 'T':
651 				tz = TimeZone.getTimeZone(t.idup);
652 				t = null;
653 				break;
654 			case 'Z':
655 			{
656 				// TODO: is this correct?
657 				auto n = takeNumber!(1, 6);
658 				tz = new immutable(SimpleTimeZone)(seconds(n));
659 				break;
660 			}
661 
662 			// Full date/time
663 			//case 'c': TODO
664 			//case 'r': TODO
665 			//case 'U': TODO
666 
667 			// Escape next character
668 			case '\\':
669 				escaping = true;
670 				break;
671 
672 			// Other characters (whitespace, delimiters)
673 			default:
674 			{
675 				enforce(t.length && t[0]==c, c~ " expected or unsupported format character");
676 				t = t[1..$];
677 			}
678 		}
679 	}
680 }
681 
682 import ae.utils.meta;
683 
684 private SysTime parseTimeImpl(alias fmt, bool checked, C)(C[] t)
685 {
686 	ParseContext!(C, checked) context;
687 	context.t = t;
688 
689 	foreach (c; CTIterate!fmt)
690 		parseToken!(c, context)();
691 
692 	enforce(context.t.length == 0, "Left-over characters: " ~ context.t);
693 
694 	SysTime result;
695 
696 	with (context)
697 	{
698 		if (hour12)
699 			hour = hour12%12 + (pm ? 12 : 0);
700 
701 		// Compatibility with both <=2.066 and >=2.067
702 		static if (__traits(hasMember, SysTime, "fracSecs"))
703 			auto frac = dur!"usecs"(usecs);
704 		else
705 			auto frac = FracSec.from!"usecs"(usecs);
706 
707 		result = SysTime(
708 			DateTime(year, month, day, hour, minute, second),
709 			frac,
710 			tz);
711 
712 		if (dow >= 0)
713 			enforce(result.dayOfWeek == dow, "Mismatching weekday");
714 	}
715 
716 	return result;
717 }
718 
719 /// Parse the given string into a SysTime, using the format spec fmt.
720 /// This version generates specialized code for the given fmt.
721 SysTime parseTime(string fmt, C)(C[] t)
722 {
723 	// Omit length checks if we know the input string is long enough
724 	enum maxLength = timeFormatSize(fmt);
725 	if (t.length < maxLength)
726 		return parseTimeImpl!(fmt, true )(t);
727 	else
728 		return parseTimeImpl!(fmt, false)(t);
729 }
730 
731 /// Parse the given string into a SysTime, using the format spec fmt.
732 /// This version parses fmt at runtime.
733 SysTime parseTimeUsing(C)(C[] t, in char[] fmt)
734 {
735 	return parseTimeImpl!(fmt, true)(t);
736 }
737 
738 deprecated SysTime parseTime(C)(const(char)[] fmt, C[] t)
739 {
740 	return t.parseTimeUsing(fmt);
741 }
742 
743 unittest
744 {
745 	const s0 = "Tue Jun 07 13:23:19 GMT+0100 2011";
746 	//enum t = s0.parseTime!(TimeFormats.STD_DATE); // https://d.puremagic.com/issues/show_bug.cgi?id=12042
747 	auto t = s0.parseTime!(TimeFormats.STD_DATE);
748 	auto s1 = t.formatTime(TimeFormats.STD_DATE);
749 	assert(s0 == s1, s0 ~ "/" ~ s1);
750 	auto t1 = s0.parseTimeUsing(TimeFormats.STD_DATE);
751 	assert(t == t1);
752 }
753 
754 unittest
755 {
756 	"Tue, 21 Nov 2006 21:19:46 +0000".parseTime!(TimeFormats.RFC2822);
757 	"Tue, 21 Nov 2006 21:19:46 +0000".parseTimeUsing(TimeFormats.RFC2822);
758 }
759 
760 // ***************************************************************************
761 
762 @property bool empty(Duration d)
763 {
764 	return !d.total!"hnsecs"();
765 }
766 
767 /// Workaround SysTime.fracSecs only being available in 2.067,
768 /// and SysTime.fracSec becoming deprecated in the same version.
769 static if (!is(typeof(SysTime.init.fracSecs)))
770 @property Duration fracSecs(SysTime s)
771 {
772 	enum hnsecsPerSecond = convert!("seconds", "hnsecs")(1);
773 	return hnsecs(s.stdTime % hnsecsPerSecond);
774 }
775 
776 /// As above, for Duration.split and Duration.get
777 static if (!is(typeof(Duration.init.split!())))
778 @property auto split(units...)(Duration d)
779 {
780 	static struct Result
781 	{
782 		mixin("long " ~ [units].join(", ") ~ ";");
783 	}
784 
785 	Result result;
786 	foreach (unit; units)
787 	{
788 		static if (is(typeof(d.get!unit))) // unit == "msecs" || unit == "usecs" || unit == "hnsecs" || unit == "nsecs")
789 			long value = d.get!unit();
790 		else
791 			long value = mixin("d.fracSec." ~ unit);
792 		mixin("result." ~ unit ~ " = value;");
793 	}
794 	return result;
795 }