├── .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 | [![Composer package](http://composer.network/badge/wapmorgan/time-parser)](https://packagist.org/packages/wapmorgan/time-parser) [![Latest Stable Version](https://poser.pugx.org/wapmorgan/time-parser/v/stable)](https://packagist.org/packages/wapmorgan/time-parser) [![Total Downloads](https://poser.pugx.org/wapmorgan/time-parser/downloads)](https://packagist.org/packages/wapmorgan/time-parser) [![Latest Unstable Version](https://poser.pugx.org/wapmorgan/time-parser/v/unstable)](https://packagist.org/packages/wapmorgan/time-parser) [![License](https://poser.pugx.org/wapmorgan/time-parser/license)](https://packagist.org/packages/wapmorgan/time-parser) [![Testing](https://travis-ci.org/wapmorgan/TimeParser.svg?branch=master)](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 | --------------------------------------------------------------------------------