├── .gitignore
├── _config.yml
├── src
├── DebugStream.php
├── Language.php
└── TimeParser.php
├── .travis.yml
├── composer.json
├── phpunit.xml.dist
├── LICENSE.md
├── rules
├── chinese.json
├── spanish.json
├── german.json
├── french.json
├── english.json
└── russian.json
├── tests
└── TimeParserTest.php
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | vendor/
2 | composer.lock
3 |
--------------------------------------------------------------------------------
/_config.yml:
--------------------------------------------------------------------------------
1 | theme: jekyll-theme-tactile
2 |
--------------------------------------------------------------------------------
/src/DebugStream.php:
--------------------------------------------------------------------------------
1 | =5.6.0"
18 | },
19 | "require-dev": {
20 | "phpunit/phpunit": "^5.7||^6.5"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
14 |
15 |
16 | tests
17 |
18 |
19 |
20 |
21 |
22 | src
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/Language.php:
--------------------------------------------------------------------------------
1 | name = $name;
14 | $this->rules = $rules;
15 | $this->week_days = $week_days;
16 | $this->pronouns = $pronouns;
17 | $this->months = $months;
18 | $this->units = $units;
19 | }
20 |
21 | public function translatePronoun($pronoun) {
22 | if (isset($this->pronouns[$pronoun]))
23 | return $this->pronouns[$pronoun];
24 | else
25 | return $pronoun;
26 | }
27 |
28 | public function translateWeekDay($weekDay) {
29 | if (isset($this->week_days[$weekDay]))
30 | return $this->week_days[$weekDay];
31 | else
32 | return $weekDay;
33 | }
34 |
35 | public function translateMonth($month) {
36 | if (isset($this->months[$month]))
37 | return $this->months[$month];
38 | else
39 | return $month;
40 | }
41 |
42 | public function translateUnit($unit) {
43 | if (isset($this->units[$unit]))
44 | return $this->units[$unit];
45 | else
46 | return $unit;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/rules/chinese.json:
--------------------------------------------------------------------------------
1 | {
2 | "language": "Chinese",
3 | "rules": {
4 | "absolute": {
5 | "time": "~((?\\d{1,2})\\:(?\\d{2})(\\:(?\\d{2}))?)~",
6 | "weekday": "~(?这|下)(?星期一|星期二|星期三|星期四|星期五|星期六|星期天)~u",
7 | "year": "~((?这|下)|((?[[:digit:]]+)))年~u",
8 | "month": "~((?这|下)月|(?一月|二月|三月|四月|五月|六月|七月|八月|九月|十月|十一月|十二月))~u",
9 | "week": "~(?这|下)个星期~u"
10 | },
11 | "relative": {
12 | "hour": "~経由((?[[:digit:]]+)|(?[[:alpha:]]+))小时~u",
13 | "minute": "~経由((?[[:digit:]]+)|(?[[:alpha:]]+))分钟~u",
14 | "sec": "~経由((?[[:digit:]]+)|(?[[:alpha:]]+))秒钟~u",
15 | "year": "~経由((?[[:digit:]]+)|(?[[:alpha:]]+))年~u",
16 | "week": "~経由((?[[:digit:]]+)|(?[[:alpha:]]+))星期~u",
17 | "day": "~経由((?[[:digit:]]+)|(?[[:alpha:]]+))天~u",
18 | "month": "~経由((?[[:digit:]]+)|(?[[:alpha:]]+))月~u"
19 | }
20 | },
21 | "week_days": {
22 | "星期一": "monday",
23 | "星期二": "tuesday",
24 | "星期三": "wednesday",
25 | "星期四": "thursday",
26 | "星期五": "friday",
27 | "星期六": "saturday",
28 | "星期天": "sunday"
29 | },
30 | "pronouns": {
31 | "这": "this",
32 | "下": "next"
33 | },
34 | "months": {
35 | "一月": "january",
36 | "二月": "february",
37 | "三月": "march",
38 | "四月": "april",
39 | "五月": "may",
40 | "六月": "june",
41 | "七月": "july",
42 | "八月": "august",
43 | "九月": "september",
44 | "十月": "october",
45 | "十一月": "november",
46 | "十二月": "december"
47 | },
48 | "units": {
49 | "〇": 0,
50 | "一": 1,
51 | "二": 2,
52 | "三": 3,
53 | "四": 4,
54 | "五": 5,
55 | "六": 6,
56 | "七": 7,
57 | "八": 8,
58 | "九": 9,
59 | "十": 10,
60 | "十一": 11,
61 | "十二": 12,
62 | "十三": 13,
63 | "十四": 14,
64 | "十五": 15,
65 | "十六": 16,
66 | "十七": 17,
67 | "十八": 18,
68 | "十九": 19,
69 | "二十": 20,
70 | "零": 0,
71 | "壹": 1,
72 | "贰": 2,
73 | "叁": 3,
74 | "肆": 4,
75 | "伍": 5,
76 | "陆": 6,
77 | "柒": 7,
78 | "捌": 8,
79 | "玖": 9,
80 | "拾": 10
81 | }
82 | }
--------------------------------------------------------------------------------
/rules/spanish.json:
--------------------------------------------------------------------------------
1 | {
2 | "language": "Spanish",
3 | "rules": {
4 | "absolute": {
5 | "time": "~((?\\d{1,2})\\:(?\\d{2})(\\:(?\\d{2}))?)~u",
6 | "weekday": "~(en|el) (?este|próximo) (?lunes|martes|miércoles|jueves|viernes|sábado|domingo)~iu",
7 | "year": "~(en|el) ((?este|próximo)|((?[[:digit:]]+))) año~iu",
8 | "month": "~(en|el) ((?este|próximo) месяце|(?enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre))~iu",
9 | "week": "~(en|el) (?este|próximo) semana~iu"
10 | },
11 | "relative": {
12 | "hour": "~(en ((?[[:digit:]]+)|(?[[:alpha:]]+)) horas?)~u",
13 | "minute": "~(en ((?[[:digit:]]+)|(?[[:alpha:]]+)) minutos?)~u",
14 | "sec": "~(en ((?[[:digit:]]+)|(?[[:alpha:]]+)) segundos?)~u",
15 | "year": "~(en ((?[[:digit:]]+)|(?[[:alpha:]]+)) años?)~u",
16 | "week": "~(en ((?[[:digit:]]+)|(?[[:alpha:]]+)) semanas?)~u",
17 | "day": "~(en ((?[[:digit:]]+)|(?[[:alpha:]]+)) días?)~u",
18 | "month": "~(en ((?[[:digit:]]+)|(?[[:alpha:]]+)) mes(es)?)~u"
19 | }
20 | },
21 | "week_days": {
22 | "lunes": "monday",
23 | "martes": "tuesday",
24 | "miércoles": "wednesday",
25 | "jueves": "thursday",
26 | "viernes": "friday",
27 | "sábado": "saturday",
28 | "domingo": "sunday"
29 | },
30 | "pronouns": {
31 | "este": "this",
32 | "próximo": "next"
33 | },
34 | "months": {
35 | "enero": "january",
36 | "febrero": "february",
37 | "marzo": "march",
38 | "abril": "april",
39 | "mayo": "may",
40 | "junio": "june",
41 | "julio": "july",
42 | "agosto": "august",
43 | "septiembre": "september",
44 | "octubre": "october",
45 | "noviembre": "november",
46 | "diciembre": "december"
47 | },
48 | "units": {
49 | "uno": 1,
50 | "dos": 2,
51 | "tres": 3,
52 | "cuatro": 4,
53 | "cinco": 5,
54 | "seis": 6,
55 | "siete": 7,
56 | "ocho": 8,
57 | "nueve": 9,
58 | "diez": 10,
59 | "once": 11,
60 | "doce": 12,
61 | "trece": 13,
62 | "catorce": 14,
63 | "quince": 15,
64 | "dieciseis": 16,
65 | "diecisiete": 17,
66 | "dieciocho": 18,
67 | "diecinueve": 19,
68 | "veinte": 20
69 | }
70 | }
--------------------------------------------------------------------------------
/rules/german.json:
--------------------------------------------------------------------------------
1 | {
2 | "language": "German",
3 | "rules": {
4 | "absolute": {
5 | "time": "~((?\\d{1,2})\\:(?\\d{2})(\\:(?\\d{2}))?)~u",
6 | "weekday": "~(am ?)?(?diesen|nächsten) (?montag|dienstag|mittwoch|donnerstag|freitag|sonnabend|samstag|sonntag)~u",
7 | "year": "~((?dieses|nächstes)|((?[[:digit:]]+))) Jahr~iu",
8 | "month": "~(i[nm] ?)((?diesen|nächsten) Monat|(?januar|februar|märz|mai|juni|juli|oktober|dezember))~iu",
9 | "week": "~(?diese|nächste) Woche~u"
10 | },
11 | "relative": {
12 | "hour": "~(nach ((?[[:digit:]]+)|(?[[:alpha:]]+)) Uhr)~iu",
13 | "minute": "~(nach ((?[[:digit:]]+)|(?[[:alpha:]]+)) Minuten?)~iu",
14 | "sec": "~(nach ((?[[:digit:]]+)|(?[[:alpha:]]+)) Sekunden?)~iu",
15 | "year": "~(nach ((?[[:digit:]]+)|(?[[:alpha:]]+)) Jahren?)~iu",
16 | "week": "~(nach ((?[[:digit:]]+)|(?[[:alpha:]]+)) Wochen?)~iu",
17 | "day": "~(nach ((?[[:digit:]]+)|(?[[:alpha:]]+)) Tagen?)~iu",
18 | "month": "~(nach ((?[[:digit:]]+)|(?[[:alpha:]]+)) Monaten?)~iu"
19 | }
20 | },
21 | "week_days": {
22 | "montag": "monday",
23 | "dienstag": "tuesday",
24 | "mittwoch": "wednesday",
25 | "donnerstag": "thursday",
26 | "freitag": "friday",
27 | "sonnabend": "saturday",
28 | "samstag": "saturday",
29 | "sonntag": "sunday"
30 | },
31 | "pronouns": {
32 | "diesen": "this",
33 | "nächsten": "next"
34 | },
35 | "months": {
36 | "januar": "january",
37 | "februar": "february",
38 | "märz": "march",
39 | "april": "april",
40 | "mai": "may",
41 | "juni": "june",
42 | "juli": "july",
43 | "august": "august",
44 | "september": "september",
45 | "oktober": "october",
46 | "november": "november",
47 | "dezember": "december"
48 | },
49 | "units": {
50 | "null": 0,
51 | "eins": 1,
52 | "zwei": 2,
53 | "drei": 3,
54 | "vier": 4,
55 | "fünf": 5,
56 | "sechs": 6,
57 | "sieben": 7,
58 | "acht": 8,
59 | "neun": 9,
60 | "zehn": 10,
61 | "elf": 11,
62 | "zwölf": 12,
63 | "dreizehn": 13,
64 | "vierzehn": 14,
65 | "fünfzehn": 15,
66 | "sechzehn": 16,
67 | "siebzehn": 17,
68 | "achtzehn": 18,
69 | "neunzehn": 19,
70 | "zwanzig": 20
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/rules/french.json:
--------------------------------------------------------------------------------
1 | {
2 | "language": "French",
3 | "rules": {
4 | "absolute": {
5 | "time": "~((?\\d{1,2})\\:(?\\d{2})(\\:(?\\d{2}))?)~",
6 | "weekday": "~(en)? (?cette|ce|prochaine) (?lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche)~",
7 | "year": "~((?cette|ce|prochaine)|((?[[:digit:]]+))) année~u",
8 | "month": "~((?cette|ce|prochaine) mois|(?janvier|février|mars|avril|mai|juin|juillet|août|septembre|octobre|novembre|décembre))~u",
9 | "week": "~(?cette|ce|prochaine) semaine~u"
10 | },
11 | "relative": {
12 | "hour": "~((bout ?)?(de ?)?((?[[:digit:]]+)|(?[[:alpha:]]+)) heures?)~u",
13 | "minute": "~((bout ?)?(de ?)?((?[[:digit:]]+)|(?[[:alpha:]]+)) minutes?)~u",
14 | "sec": "~((bout ?)?(de ?)?((?[[:digit:]]+)|(?[[:alpha:]]+)) secondes?)~u",
15 | "year": "~((bout ?)?(de ?)?((?[[:digit:]]+)|(?[[:alpha:]]+)) années?)~u",
16 | "week": "~((bout ?)?(de ?)?((?[[:digit:]]+)|(?[[:alpha:]]+)) semaines?)~u",
17 | "day": "~((bout ?)?(de ?)?((?[[:digit:]]+)|(?[[:alpha:]]+)) jours?)~u",
18 | "month": "~((bout ?)?(de ?)?((?[[:digit:]]+)|(?[[:alpha:]]+)) mois?)~u"
19 | }
20 | },
21 | "week_days": {
22 | "lundi": "monday",
23 | "mardi": "tuesday",
24 | "mercredi": "wednesday",
25 | "jeudi": "thursday",
26 | "vendredi": "friday",
27 | "samedi": "saturday",
28 | "dimanche": "sunday"
29 | },
30 | "pronouns": {
31 | "ce": "this",
32 | "suivant": "next",
33 | "cette": "this",
34 | "prochaine": "next"
35 | },
36 | "months": {
37 | "janvier": "january",
38 | "février": "february",
39 | "mars": "march",
40 | "avril": "april",
41 | "mai": "may",
42 | "juin": "june",
43 | "juillet": "july",
44 | "août": "august",
45 | "septembre": "september",
46 | "octobre": "october",
47 | "novembre": "november",
48 | "décembre": "december"
49 | },
50 | "units": {
51 | "une": 1,
52 | "deux": 2,
53 | "trois": 3,
54 | "quatre": 4,
55 | "cinq": 5,
56 | "six": 6,
57 | "sept": 7,
58 | "huit": 8,
59 | "neuf": 9,
60 | "dix": 10,
61 | "onze": 11,
62 | "douze": 12,
63 | "treize": 13,
64 | "quatorze": 14,
65 | "quinze": 15,
66 | "seize": 16,
67 | "dix-sept": 17,
68 | "dix-huit": 18,
69 | "dix-neuf": 19,
70 | "vingt": 20
71 | }
72 | }
--------------------------------------------------------------------------------
/rules/english.json:
--------------------------------------------------------------------------------
1 | {
2 | "language": "English",
3 | "rules": {
4 | "absolute": {
5 | "month": "~in ((?this|next) month|(?january|february|march|april|may|june|july|august|september|october|november|december))~",
6 | "time": "~((at )?(?\\d{1,2})\\:(?\\d{2})(\\:(?\\d{2}))?)~",
7 | "weekday": "~(on )?(?next|this) (?monday|tuesday|wednesday|thursday|friday|saturday|sunday)~",
8 | "date": "~((?[[:digit:]]+)|(?[[:alpha:]]+)) (?january|february|march|april|may|june|july|august|september|october|november|december)( (?[[:digit:]]{4})( year)?)?~",
9 | "year": "~((?this|next)|((?[[:digit:]]+))) year(?!s)~u",
10 | "week": "~(?this|next) week~u"
11 | },
12 | "relative": {
13 | "hour": "~(in ((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) hours?)~u",
14 | "minute": "~(in ((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) minutes?)~u",
15 | "sec": "~(in ((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) seconds?)~u",
16 | "year": "~(in ((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) years?)~u",
17 | "week": "~(in ((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) weeks?)~u",
18 | "day": "~(in ((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) days?)~u",
19 | "month": "~(in ((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) months?)~u",
20 | "-$1 hour": "~((((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) )?hours? ago)~u",
21 | "-$1 minute": "~((((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) )?minutes? ago)~u",
22 | "-$1 sec": "~((((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) )?seconds? ago)~u",
23 | "-$1 year": "~((((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) )?years? ago)~u",
24 | "-$1 week": "~((((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) )?weeks? ago)~u",
25 | "-$1 day": "~((((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) )?days? ago)~u",
26 | "-$1 month": "~((((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) )?months? ago)~u",
27 | "+2 day": "~\\bday after tomorrow\\b~u",
28 | "+1 day": "~\\btomorrow\\b~u",
29 | "-2 day": "~\\bthe day before yesterday\\b~u",
30 | "-1 day": "~\\byesterday\\b~u"
31 | }
32 | },
33 | "pronouns": {
34 | },
35 | "week_days": {
36 | },
37 | "months": {
38 | },
39 | "units": {
40 | "an": 1,
41 | "a": 1,
42 | "one": 1,
43 | "two": 2,
44 | "three": 3,
45 | "four": 4,
46 | "five": 5,
47 | "six": 6,
48 | "seven": 7,
49 | "eight": 8,
50 | "nine": 9,
51 | "ten": 10,
52 | "eleven": 11,
53 | "twelve": 12,
54 | "thirteen": 13,
55 | "fourteen": 14,
56 | "fifteen": 15,
57 | "sixteen": 16,
58 | "seventeen": 17,
59 | "eighteen": 18,
60 | "nineteen": 19,
61 | "twenty": 20,
62 | "thirty": 30,
63 | "forty": 40,
64 | "fifty": 50
65 | }
66 | }
--------------------------------------------------------------------------------
/tests/TimeParserTest.php:
--------------------------------------------------------------------------------
1 | parse($string, true);
22 |
23 | $this->prepareDate($result, $expected, $midnight);
24 | $this->assertEquals($expected, $result);
25 | }
26 |
27 | public function dataProviderEnglish()
28 | {
29 | return [
30 | ['15 december 1977 year', '15 december 1977'],
31 | ['at 15:12:13', '15:12:13'],
32 | ['next monday', 'next monday', true],
33 | ['next year', '+1 year'],
34 | ['in february', 'february'],
35 | ['in 15 hours', '+15 hour'],
36 | ['in 10 minutes', '+10 minutes'],
37 | ['in 11 seconds', '+11 seconds'],
38 | ['in 5 years', '+5 years'],
39 | ['in 2 weeks', '+2 weeks'],
40 | ['in 1 day', '+1 day'],
41 | ['in 10 months', '+10 month'],
42 | ['tomorrow', '+1 day'],
43 | ['yesterday', '-1 day'],
44 | ['2 hours ago', '-2 hour'],
45 | ['10 years ago', '-10 year'],
46 | ['the string does not contain the date', false],
47 | ];
48 | }
49 |
50 | /**
51 | * @dataProvider dataProviderRussian()
52 | */
53 | public function testRussian($string, $expected, $midnight = false)
54 | {
55 | $result = self::$parsers['russian']->parse($string, true);
56 |
57 | $this->prepareDate($result, $expected, $midnight);
58 | $this->assertEquals($expected, $result);
59 | }
60 |
61 | public function dataProviderRussian()
62 | {
63 | return [
64 | ['15 декабря 1977 года', '15 december 1977'],
65 | ['в 15:12:13', '15:12:13'],
66 | ['в следующий понедельник', 'next monday', true],
67 | ['в следующем году', '+1 year'],
68 | ['в феврале', 'february'],
69 | ['через 15 часов', '+15 hour'],
70 | ['через 10 минут', '+10 minutes'],
71 | ['через 11 секунд', '+11 seconds'],
72 | ['через 5 лет', '+5 years'],
73 | ['через 2 недели', '+2 weeks'],
74 | ['через 1 день', '+1 day'],
75 | ['через 10 месяцев', '+10 month'],
76 | ['завтра', '+1 day'],
77 | ['вчера', '-1 day'],
78 | ['2 часа назад', '-2 hour'],
79 | ['10 лет назад', '-10 year'],
80 | ['строка не содержит дату', false],
81 | ];
82 | }
83 |
84 | protected function prepareDate(&$result, &$expected, $midnight)
85 | {
86 | $date = new DateTimeImmutable();
87 |
88 | if ($result !== false) {
89 | if ($midnight) {
90 | $result = $result->setTime(0, 0);
91 | }
92 |
93 | $result = $result->format('r');
94 | }
95 |
96 | if ($expected !== false) {
97 | $expected = $date->modify($expected)->format('r');
98 | }
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/rules/russian.json:
--------------------------------------------------------------------------------
1 | {
2 | "language": "Russian",
3 | "rules": {
4 | "absolute": {
5 | "date": "~((?[[:digit:]]+)|(?[[:alpha:]]+)) (?января|февраля|марта|апреля|мая|июня|июля|августа|сентября|октября|ноября|декабря)( (?[[:digit:]]{4})( года)?)?~u",
6 | "time": "~(в (?\\d{1,2})\\:(?\\d{2})(\\:(?\\d{2}))?)~u",
7 | "weekday": "~во? (?этот?|эту|следующий|следующее) (?понедельник|вторник|среду|четверг|пятницу|субботу|воскресенье|воскресение)~u",
8 | "year": "~в ((?этот?|этом|следующем)|((?[[:digit:]]+))(-?м)?) году~u",
9 | "month": "~в ((?этом|следующем) месяце|(?январе|феврале|марте|апреле|мае|июне|июле|августе|сентябре|октябре|ноябре|декабре))~u",
10 | "week": "~на (?этой|следующей) неделе~u"
11 | },
12 | "relative": {
13 | "hour": "~(через( ((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)))? час(а|ов)?)~u",
14 | "minute": "~(через( ((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)))? мин(ут[уы]?)?)~u",
15 | "sec": "~(через( ((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)))? сек(унд[уы]?)?)~u",
16 | "year": "~(через( ((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)))? (лет|года?))~u",
17 | "week": "~(через( ((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)))? недел[юиь])~u",
18 | "day": "~(через( ((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)))? (день|дн(я|ей)))~u",
19 | "month": "~(через( ((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)))? месяц(а|ев)?)~u",
20 | "-$1 hour": "~((((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) )?час(а|ов)? назад)~u",
21 | "-$1 minute": "~((((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) )?мин(ут[уы]?)? назад)~u",
22 | "-$1 sec": "~((((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) )?сек(унд[уы]?)? назад)~u",
23 | "-$1 year": "~((((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) )?(лет|года?) назад)~u",
24 | "-$1 week": "~((((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) )?недел[юиь] назад)~u",
25 | "-$1 day": "~((((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) )?(день|дн(я|ей)) назад)~u",
26 | "-$1 month": "~((((?[[:digit:]]+)|(?[[:alpha:][:space:]]+)) )?месяц(а|ев)? назад)~u",
27 | "+1 day": "~\\bзавтра\\b~u",
28 | "+2 day": "~\\bпослезавтра\\b~u",
29 | "-1 day": "~\\bвчера\\b~u",
30 | "-2 day": "~\\bпозавчера\\b~u"
31 | }
32 | },
33 | "week_days": {
34 | "понедельник": "monday",
35 | "вторник": "tuesday",
36 | "среду": "wednesday",
37 | "четверг": "thursday",
38 | "пятницу": "friday",
39 | "субботу": "saturday",
40 | "воскресенье": "sunday",
41 | "воскресение": "sunday"
42 | },
43 | "pronouns": {
44 | "этот": "this",
45 | "эту": "this",
46 | "следующий": "next",
47 | "следующую": "next",
48 | "следующем": "next"
49 | },
50 | "months": {
51 | "январе": "january",
52 | "января": "january",
53 | "феврале": "february",
54 | "февраля": "february",
55 | "марте": "march",
56 | "марта": "march",
57 | "апреле": "april",
58 | "апреля": "april",
59 | "мае": "may",
60 | "мая": "may",
61 | "июне": "june",
62 | "июня": "june",
63 | "июле": "july",
64 | "июля": "july",
65 | "августе": "august",
66 | "августа": "august",
67 | "сентябре": "september",
68 | "сентября": "september",
69 | "октябре": "october",
70 | "октября": "october",
71 | "ноябре": "november",
72 | "ноября": "november",
73 | "декабре": "december",
74 | "декабря": "december"
75 | },
76 | "units": {
77 | "один": 1,
78 | "одну": 1,
79 | "два": 2,
80 | "две": 2,
81 | "три": 3,
82 | "четыре": 4,
83 | "пять": 5,
84 | "шесть": 6,
85 | "семь": 7,
86 | "восемь": 8,
87 | "девять": 9,
88 | "десять": 10,
89 | "одиннадцать": 11,
90 | "двенадцать": 12,
91 | "тринадцать": 13,
92 | "четырнадцать": 14,
93 | "пятнадцать": 15,
94 | "шестнадцать": 16,
95 | "семнадцать": 17,
96 | "восемнадцать": 18,
97 | "девятнадцать": 19,
98 | "двадцать": 20,
99 | "тридцать": 30,
100 | "сорок": 40,
101 | "пятьдесят": 50
102 | }
103 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | TimeParser
2 | =====
3 | A parser for date and time written in natural language for PHP.
4 |
5 | [](https://packagist.org/packages/wapmorgan/time-parser) [](https://packagist.org/packages/wapmorgan/time-parser) [](https://packagist.org/packages/wapmorgan/time-parser) [](https://packagist.org/packages/wapmorgan/time-parser) [](https://packagist.org/packages/wapmorgan/time-parser) [](https://travis-ci.org/wapmorgan/TimeParser)
6 |
7 | 1. Installation
8 | 2. Usage
9 | 3. Languages support
10 | 5. ToDo
11 |
12 | ## Installation
13 | The preferred way to install package is via composer:
14 |
15 | ```bash
16 | composer require wapmorgan/time-parser
17 | ```
18 |
19 | ## Usage
20 | Parse some input from user and receive a `DateTime` object.
21 |
22 | 1. Create a Parser object
23 | ```php
24 | $parser = new wapmorgan\TimeParser\TimeParser('all');
25 | ```
26 |
27 | First argument is a language. Applicable values:
28 |
29 | * `'all'` (by default) - scan for all available languages. Use it when you can not predict user's preferred language.
30 | * `'russian'` - scan only as string written in one language.
31 | * `array('english', 'russian')` - scan as english and then the rest as russian.
32 |
33 | 2. Enable and disable parsing of alphabetic values.
34 | ```php
35 | // To enable alphabetic parsing.
36 | $parser->allowAlphabeticUnits();
37 | // To disable alphabetic parsing.
38 | $parser->disallowAlphabeticUnits();
39 | ```
40 |
41 | 3. Parse string and return a `DateTimeImmutable` object. If second argument is `true`, method will return `false` when no date&time strings found. If third parameter is provided, then it is filled with the string obtained after all the transformations.
42 | ```php
43 | $datetime = $parser->parse(fgets(STDIN));
44 | // next call returns false
45 | $datetime = $parser->parse('abc', true);
46 | // $result will contains "we will come "
47 | $datetime = $parser->parse('We will come in a week', true, $result);
48 | ```
49 | 4. For advanced parsing of alphabetic values is used built-in function. You can specify your own handler for this feature. Сurrently is used for russian and english languages only.
50 | ```
51 | use wapmorgan\TimeParser\TimeParser;
52 |
53 | // $string will contains alphabetic value for advanced parsing.
54 | // Ex.: for string "in twenty five minutes", it will contains "twenty five".
55 | // $lang will contains name of the parsed language.
56 | TimeParser::setWordsToNumberCallback(function($string, $lang) {
57 | // do some magic
58 | });
59 | ```
60 | 5. You can add your own custom language.
61 | ```php
62 | // $date must be an array with a structure like the existing rules.
63 | $parser->addLanguage($data);
64 | ```
65 |
66 | ## Languages support
67 | For this moment four languages supported: Russian, English, French and German. Two languages support is in progress: Chinese, Spanish.
68 | Their rules are in `rules` catalog so you can improve TimeParser by adding new language or by improving existing one.
69 |
70 | Languages with examples of strings containing date&time:
71 |
72 | | Language | Example |
73 | |----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
74 | | chinese | 15:12:13 下星期一 下年 二月 経由15小时 経由10分钟 経由11秒钟 経由5年 経由2星期 経由1天 経由10月 |
75 | | english | 15 december 1977 at 15:12:13 next monday next year in february in 15 hours in 10 minutes in 11 seconds in 5 years in 2 weeks in 1 day in 10 months |
76 | | french | 15:12:13 prochaine lundi prochaine année février bout de 15 heures bout de 10 minutes bout de 11 secondes bout de 5 années bout de 2 semaines bout de 1 jour bout de 10 mois |
77 | | german | 15:12:13 nächsten montag nächsten jahr im februar nach 15 uhr nach 10 minuten nach 11 secunden nach 5 jahre nach 2 wochen nach 1 tag nach 10 monate |
78 | | russian | 15 декабря 1977 года в 15:12:13 в следующий понедельник в следующем году в феврале через 15 часов через 10 минут через 11 секунд через 5 лет через 2 недели через 1 день через 10 месяцев |
79 | | spanish | 15:12:13 el próximo lunes en próximo año en febrero en 15 horas en 10 minutos en 11 segundos en 5 años en 2 semanas en 1 día en 10 meses |
80 |
81 | For developing reasons you may would like to see process of parsing. To do this call related methods:
82 |
83 | ```php
84 | TimeParser::enableDebug();
85 | // and
86 | TimeParser::disableDebug();
87 | ```
88 |
89 | ### Parsable substrings
90 | To understand, how it works, look at substrings separately:
91 |
92 | * **15 december 1977** - absolute date
93 | * **at 15:12:13** - absolute time
94 | * **next monday** or **this friday** - absolute date
95 | * **next year** or **2016 year** - absolute date
96 | * **in february** or **next month** - absolute date
97 | * **next week** - absolute date
98 | * **in 15 hours** - relative time
99 | * **in 10 minutes** - relative time
100 | * **in 11 seconds** - relative time
101 | * **in 2 weeks** - relative date
102 | * **in 1 day** - relative date
103 | * **in 10 months** - relative date
104 | * **через час** - relative date (russian, english only)
105 | * **in a hour** - relative date (russian, english only)
106 | * **через двадцать пять минут** - relative date (russian, english only)
107 | * **in twenty five minutes** - relative date (russian, english only)
108 | * **пять минут назад** - relative date (russian, english only)
109 | * **five minutes ago** - relative date (russian, english only)
110 | * **5 минут назад** - relative date (russian, english only)
111 | * **5 minutes ago** - relative date (russian, english only)
112 | * **вчера** - relative date (russian, english only)
113 | * **yesterday** - relative date (russian, english only)
114 |
115 | ## ToDo
116 |
117 | - [x] Tests.
118 | - [ ] Try to parse combinations: *in 5 hours and 2 minutes*.
119 | - [x] Try to parse alphabetic offsets: *in five hours* and *через пять часов*.
120 |
121 | ### Languages ToDo
122 |
123 | - [x] Chinese - check hieroglyphs.
124 | - [x] Spanish - check prepositions.
125 | - [ ] Portuguese
126 | - [ ] Arabic
127 | - [ ] Korean
128 |
--------------------------------------------------------------------------------
/src/TimeParser.php:
--------------------------------------------------------------------------------
1 | 1,
13 | 'february' => 2,
14 | 'march' => 3,
15 | 'april' => 4,
16 | 'may' => 5,
17 | 'june' => 6,
18 | 'july' => 7,
19 | 'august' => 8,
20 | 'september' => 9,
21 | 'october' => 10,
22 | 'november' => 11,
23 | 'december' => 12,
24 | );
25 |
26 | static private $debug = false;
27 | static private $wordsToNumber = null;
28 |
29 | static public function enableDebug() {
30 | self::$debug = true;
31 | }
32 |
33 | static public function disableDebug() {
34 | self::$debug = false;
35 | }
36 |
37 | static public function debugging() {
38 | return self::$debug;
39 | }
40 |
41 | static public function setWordsToNumberCallback(callable $callback) {
42 | self::$wordsToNumber = $callback;
43 | }
44 |
45 | public function __construct($languages = null) {
46 | $this->populateLanguageRules($languages);
47 | }
48 |
49 | public function allowAlphabeticUnits() {
50 | $this->allowAlphabeticUnits = true;
51 | }
52 |
53 | public function disallowAlphabeticUnits() {
54 | $this->allowAlphabeticUnits = false;
55 | }
56 |
57 | protected function populateLanguageRules($languages = null) {
58 | // collect rules
59 | $availableLanguages = array_map(function ($lang) {
60 | return strtolower(basename($lang, '.json'));
61 | }, glob(dirname(__FILE__).'/../rules/*.json'));
62 |
63 | if ($languages !== null && $languages !== 'all') {
64 | if (!is_array($languages)) {
65 | $languages = [$languages];
66 | }
67 |
68 | $availableLanguages = array_intersect($languages, $availableLanguages);
69 |
70 | if (empty($availableLanguages)) {
71 | throw new Exception(sprintf('Unknown language used: %s',
72 | implode(', ', array_diff($languages, $availableLanguages))
73 | ));
74 | }
75 | }
76 |
77 | foreach ($availableLanguages as $language) {
78 | $data = json_decode(file_get_contents(dirname(dirname(__FILE__)).DIRECTORY_SEPARATOR.'rules'.DIRECTORY_SEPARATOR.$language.'.json'), true);
79 |
80 | if (json_last_error() !== JSON_ERROR_NONE) {
81 | throw new Exception(json_last_error_msg());
82 | }
83 |
84 | $this->addLanguage($data);
85 | }
86 | }
87 |
88 | public function addLanguage(array $data)
89 | {
90 | if (!isset($data['language'])
91 | || !is_string($data['language'])
92 | || !preg_match('/^[a-z]+((\s|\/)[a-z]+)?$/ui', $data['language'])
93 | ) {
94 | throw new Exception('Wrong language name given');
95 | }
96 |
97 | if (!isset($data['rules']['absolute']) || !is_array($data['rules']['absolute'])) {
98 | throw new Exception('"rules.absolute" must be an array');
99 | }
100 |
101 | if (!isset($data['rules']['relative']) || !is_array($data['rules']['relative'])) {
102 | throw new Exception('"rules.relative" must be an array');
103 | }
104 |
105 | if (!isset($data['week_days']) || !is_array($data['week_days'])) {
106 | throw new Exception('"week_days" must be an array');
107 | }
108 |
109 | if (!isset($data['pronouns']) || !is_array($data['pronouns'])) {
110 | throw new Exception('"pronouns" must be an array');
111 | }
112 |
113 | if (!isset($data['months']) || !is_array($data['months'])) {
114 | throw new Exception('"months" must be an array');
115 | }
116 |
117 | if (!isset($data['units']) || !is_array($data['units'])) {
118 | throw new Exception('"months" must be an array');
119 | }
120 |
121 | $name = mb_strtolower($data['language']);
122 | $lang = new Language($data['language'], $data['rules'], $data['week_days'], $data['pronouns'], $data['months'], $data['units']);
123 |
124 | $this->languagesRules[$name] = $lang;
125 | }
126 |
127 | public function parse($string, $falseWhenNotChanged = false, &$result = null) {
128 | $string = self::prepareString($string);
129 | $datetime = $currentDatetime = new DateTimeImmutable();
130 |
131 | // apply rules
132 | foreach ($this->languagesRules as $name => $language) {
133 | foreach ($language->rules as $ruleType => $rules) {
134 | foreach ($rules as $ruleName => $ruleRegex) {
135 | if (self::match($ruleRegex, $string, $matches)) {
136 | DebugStream::show('Matched: '.$ruleRegex.PHP_EOL);
137 | if ($ruleType == 'absolute') {
138 | switch ($ruleName) {
139 | case 'date':
140 | $month = $language->translateMonth($matches['month'][0]);
141 | if (!empty($matches['year'][0]))
142 | $year = $matches['year'][0];
143 | else
144 | $year = $datetime->format('Y');
145 | if (!empty($matches['digit'][0])) {
146 | $day = $matches['digit'][0];
147 | DebugStream::show('Set date: '.$year.'-'.$month.'-'.$day.PHP_EOL);
148 | $datetime = $datetime->setDate((int)$year, self::$months[$month], (int)$day);
149 | } else if ($this->allowAlphabeticUnits) {
150 | $alpha = $language->translateUnit($matches['alpha'][0]);
151 | if (is_numeric($alpha)) {
152 | DebugStream::show('Set date: '.$year.'-'.$month.'-'.$alpha.PHP_EOL);
153 | $datetime = $datetime->setDate((int)$year, self::$months[$month], (int)$alpha);
154 | }
155 | // parse here alphabetic value
156 | }
157 | break;
158 | case 'time':
159 | if (!empty($matches['sec'])) {
160 | $datetime = $datetime->setTime((int)$matches['hour'][0], (int)$matches['min'][0], (int)$matches['sec'][0]);
161 | DebugStream::show('Set time: '.$matches['hour'][0].':'.$matches['min'][0].':'.$matches['sec'][0].PHP_EOL);
162 | } else {
163 | $datetime = $datetime->setTime((int)$matches['hour'][0], (int)$matches['min'][0]);
164 | DebugStream::show('Set time: '.$matches['hour'][0].':'.$matches['min'][0].PHP_EOL);
165 | }
166 | break;
167 | case 'weekday':
168 | if (!empty($matches['pronoun']) && ($pronoun = $language->translatePronoun($matches['pronoun'][0])) == 'next') {
169 | $weekday = $language->translateWeekDay($matches['weekday'][0]);
170 | $time = strtotime('next week '.$weekday);
171 | DebugStream::show('Set weekday: next '.$weekday.PHP_EOL);
172 | } else {
173 | $weekday = $language->translateWeekDay($matches['weekday'][0]);
174 | $time = strtotime('this week '.$weekday);
175 | DebugStream::show('Set weekday: this '.$weekday.PHP_EOL);
176 | }
177 | $date = explode('.', date('d.m.Y', $time));
178 | $datetime = $datetime->setDate((int)$date[2], (int)$date[1], (int)$date[0]);
179 | break;
180 | case 'year':
181 | if (!empty($matches['pronoun'][0])) {
182 | $pronoun = $language->translatePronoun($matches['pronoun'][0]);
183 | if ($pronoun == 'next') {
184 | $datetime = $datetime->modify('+1 year');
185 | DebugStream::show('Set year: +1'.PHP_EOL);
186 | }
187 | } else {
188 | $year = $matches['digit'][0];
189 | $datetime = $datetime->setDate((int)$year, (int)$datetime->format('m'), (int)$datetime->format('d'));
190 | DebugStream::show('Set year: '.$year.PHP_EOL);
191 | }
192 | break;
193 | case 'month':
194 | $pronoun = $language->translatePronoun($matches['pronoun'][0]);
195 | if ($pronoun == 'next') {
196 | $datetime = $datetime->modify('+1 month');
197 | DebugStream::show('Set month: +1'.PHP_EOL);
198 | } else {
199 | $month = $language->translateMonth($matches['month'][0]);
200 | DebugStream::show('Set month: '.$month.PHP_EOL);
201 | $datetime = $datetime->modify($month);
202 | }
203 | break;
204 | case 'week':
205 | $pronoun = $language->translatePronoun($matches['pronoun'][0]);
206 | if ($pronoun == 'next') {
207 | $datetime = $datetime->modify('+1 week');
208 | DebugStream::show('Set week: +1'.PHP_EOL);
209 | }
210 | break;
211 | }
212 | } else if ($ruleType == 'relative') {
213 | $digit = isset($matches['digit'][0]) ? $matches['digit'][0] : '';
214 | $alpha = isset($matches['alpha'][0]) ? $matches['alpha'][0] : '';
215 |
216 | if ($digit === '' && $alpha === '') {
217 | $digit = 1;
218 | }
219 |
220 | if ($alpha !== '' && $this->allowAlphabeticUnits) {
221 | $digit = $language->translateUnit($alpha);
222 |
223 | if (!is_numeric($digit)) {
224 | if (is_callable(self::$wordsToNumber)) {
225 | $digit = call_user_func(self::$wordsToNumber, $alpha, $name);
226 | } else {
227 | $alpha = strtr($alpha, $language->units);
228 | $parts = array_filter(array_map(
229 | function ($val) {
230 | return floatval($val);
231 | },
232 | preg_split('/[\s-]+/', $alpha)
233 | ));
234 |
235 | $digit = array_sum($parts);
236 | }
237 | }
238 | }
239 |
240 | if ($digit && is_numeric($digit)) {
241 | if (preg_match('/^[a-z]+$/', $ruleName)) {
242 | $modify = "+{$digit} {$ruleName}";
243 | } else {
244 | $modify = str_replace('$1', $digit, $ruleName);
245 | }
246 |
247 | if (preg_match('/^[\+\-]\d+ [a-z]+$/', $modify)) {
248 | DebugStream::show('Add offset: '.$modify.PHP_EOL);
249 | $datetime = $datetime->modify($modify);
250 | }
251 | }
252 | }
253 | }
254 | }
255 | }
256 | }
257 |
258 | $result = trim(preg_replace(['/^[\pZ\pC]+|[\pZ\pC]+$/u', '/[\pZ\pC]{2,}/u'], ['', ' '], $string));
259 |
260 | if ($datetime === $currentDatetime && $falseWhenNotChanged)
261 | return false;
262 | return $datetime;
263 | }
264 |
265 | static public function parseString($string, $languages = 'all', $allowAlphabeticUnits = false, $falseWhenNotChanged = false, &$result = null) {
266 | static $parsers = array();
267 |
268 | $key = is_array($languages) ? implode(',', $languages) : $languages;
269 |
270 | if (!isset($parsers[$key])) {
271 | $parsers[$key] = new self($languages);
272 | if ($allowAlphabeticUnits) $parsers[$key]->allowAlphabeticUnits();
273 | }
274 | return $parsers[$key]->parse($string, $falseWhenNotChanged, $result);
275 | }
276 |
277 | /**
278 | * @param string $regex Regular expression to match
279 | * @param string $string String to match. Will be changed if matched.
280 | * @param array $matches Matched data.
281 | * @return boolean true if match found
282 | */
283 | static private function match($regex, &$string, &$matches) {
284 | if (preg_match($regex, $string, $matches, PREG_OFFSET_CAPTURE)) {
285 | $string = substr($string, 0, $matches[0][1]).substr($string, $matches[0][1] + strlen($matches[0][0]));
286 | return true;
287 | } else {
288 | return false;
289 | }
290 | }
291 |
292 | static private function parseWithStrtotime($string) {
293 | $datetime = new DateTime;
294 | $time = strtotime($string);
295 | if ($time === false) {
296 | DebugStream::show('strtotime() failed'.PHP_EOL);
297 | return $datetime;
298 | } else {
299 | DebugStream::show('strtotime() returned: '.$time.PHP_EOL);
300 | $datetime->setTimestamp($time);
301 | return $datetime;
302 | }
303 | }
304 |
305 | static private function prepareString($string) {
306 | if (function_exists('mb_strtolower')) {
307 | if (($encoding = mb_detect_encoding($string)) != 'UTF-8')
308 | $string = mb_convert_encoding($string, 'UTF-8', $encoding);
309 | $string = mb_strtolower($string);
310 | } else
311 | $string = strtolower($string);
312 | $string = preg_replace('~[[:space:]]{1,}~', ' ', $string);
313 | return $string;
314 | }
315 | }
316 |
--------------------------------------------------------------------------------