NAME

Time::Keeper::Natural - the words people use for a time, read in every locale

VERSION

Version 0.01

SYNOPSIS

use Time::Keeper::Natural;

# the clock at Thursday 15 October 2026, 09:00 in London
my $n = Time::Keeper::Natural->new(zone => 'Europe/London', locale => 'en-GB');
$n->parse('tomorrow');                          # 2026-10-16T00:00:00+01:00
$n->parse('in 3 days');                         # 2026-10-18T09:00:00+01:00
$n->parse('next Tuesday at 3 pm');              # 2026-10-20T15:00:00+01:00
$n->parse('end of last month');                 # 2026-09-30T23:59:59.999999999+01:00
$n->parse('in two weeks');                      # 2026-10-29T09:00:00+00:00
$n->parse_duration('1 hour, 30 minutes');       # PT1H30M
$n->in_locale('fr')->parse('il y a 3 heures');  # 2026-10-15T06:00:00+01:00
$n->in_locale('ja')->parse('来月末');            # 2026-11-30T23:59:59.999999999+00:00
$n->try_parse('sometime');                      # undef

my $m = $n->match('15 October');
$m->assumed;                                    # ('time', 'year', 'zone')

Time::Keeper::Natural->parse('tomorrow', zone => 'Asia/Tokyo');

It reads relative phrases ("RELATIVE TIME"), durations ("DURATIONS"), dates and times in the locale's own formats, alone or on a day ("ABSOLUTE DATES AND TIMES"), and, where a locale has a word table, "end of", "start of", "at" and "on" ("WORDS"). A method that cannot read a text dies saying what it could not read.

DESCRIPTION

A natural phrase for a time, "next Tuesday at 3 pm", "in two weeks", "end of last month", read into a Time::Keeper value, a Time::Keeper::Date or a Time::Keeper::Duration, relative to a moment called the anchor, in the locale's own words.

What it promises comes in two tiers, and coverage says which tier a locale is in.

What it reads in every locale

Whatever Time::Keeper writes in a locale, this module reads back in that locale: the relative time format_relative writes ("3 hours ago", "yesterday", "il y a 3 heures"), the durations Duration->format writes ("1 hour, 30 minutes", "1:05:09"), and the locale's own date and time formats and skeletons, in all 1,121 locales of CLDR, with digits in any script. The test suite proves it by writing each of these and reading it back.

What it reads where it has the words

CLDR has no words for "end of", "start of", "at", "on" or "the". Those come from a small word table the distribution ships per locale, English first. coverage answers words for a locale with a table and data for one without, which reads everything in the first tier and none of this.

WHAT IT DOES NOT READ

Free text.

A text is read whole or not at all: "let's meet next Tuesday" dies. Finding a date somewhere inside a sentence is another job, with its own guesses.

Ranges, recurrence and vague words.

"from 3 to 5", "every Tuesday", "sometime next week", "soon".

Idioms CLDR does not have.

"a fortnight", "tonight", "EOD", "the 15th", "Tuesday of next week"; and a spelling the locale's rules do not write ("twentyone").

Holidays and business days.

"the day after Thanksgiving", "three working days from now".

What a language's grammar keeps out of reach.

Where "end of" wants a case CLDR does not have (German, Greek and Russian genitives, Hindi's oblique case), the edges read with month names, years and dates only; each table says what it cannot do in its own header.

Three choices a reader will look for, each made once: a bare hour in a locale whose clock has twelve hours dies unless bare_hour says which; a period word alone is the period's first moment ("next month" is the 1st); and "next Tuesday" is the first Tuesday after today, never today.

The word tables other than English's are first drafts, marked unreviewed in their files, and await a native reader.

OPTIONS

Every method takes the options as name and value pairs, either on new or, in the class form, after the text. An unknown or repeated option dies by name.

now

A Time::Keeper value, the anchor that "tomorrow" and "in 3 days" are counted from. Without it the anchor is Time::Keeper->now, which reads $Time::Keeper::CLOCK, so a test pins it with local.

zone

The zone of the result, any zone Time::Keeper takes; an anchor given as now is read in it. Without it the result is in the anchor's zone, which is UTC when the anchor is the clock.

locale

The locale whose words are read and the result carries, any code Time::Keeper knows; an anchor given as now is read in it, which also decides its week's first day. Without it the anchor's locale is used, English when the anchor is the clock.

bare_hour

What a bare hour with no half of the day means in a locale whose clock has twelve hours: am, pm, or next, the first reading after the anchor on that day. Without it such an hour dies. In a locale whose clock has twenty-four hours a bare hour is the hour.

prefer

future or past: when a phrase names a date with no year, or a weekday with no direction, which side of the anchor to take. Without it the anchor's year is taken and a bare weekday is the coming one.

METHODS

new

my $n = Time::Keeper::Natural->new(zone => 'Europe/London', locale => 'fr');
my $m = $n->new(bare_hour => 'pm');

A parser with the options given. Called on a parser, a new one with that parser's options and these on top; an option given as undef is dropped. A parser is immutable.

parse

my $t = $n->parse('next Tuesday at 3 pm');
my $t = Time::Keeper::Natural->parse('tomorrow', zone => 'Asia/Tokyo');

The Time::Keeper value the whole text names, or a death saying what it could not read ("ERRORS"). A phrase that names a day and no time is the day's first moment, as today is. The value is in the parser's zone and locale.

try_parse

As parse, but undef where parse would die, with $@ left as it was.

parse_date

The Time::Keeper::Date of the moment the text names: "next Tuesday" is a date, and "in 3 hours" is the date of that moment.

try_parse_date

As parse_date, with undef for a death.

parse_duration

The Time::Keeper::Duration the text names, "2 weeks", "1 hour, 30 minutes", "1:05:09", "PT90M" ("DURATIONS"); a text that names a moment rather than a length dies.

try_parse_duration

As parse_duration, with undef for a death.

match

my $m = $n->match('15 October');
$m->value;      # 2026-10-15T00:00:00+01:00
$m->assumed;    # ('time', 'year', 'zone')

What parse would read, as a Time::Keeper::Natural::Match: the value, the precision the text named, and what was assumed to make it a value. A text parse would die on gives undef, never a death, whatever the reason.

match_date

As match, the value a Time::Keeper::Date and the kind date.

match_duration

As match for what parse_duration reads, the kind duration and the precision none.

coverage

Time::Keeper::Natural->coverage('en-AU');   # words, through en
Time::Keeper::Natural->coverage('fr');      # data, until French has a table

words for a locale with a word table in its chain, data for one without, which reads everything in the first tier and none of "WORDS". An unknown locale dies.

locales

The codes of the locales with a word table of their own.

in_locale

my $fr = $n->in_locale('fr');

A new parser reading that locale's words, the other options kept.

in_zone

A new parser whose results are in that zone, the other options kept.

now

The anchor: the now given, in the zone given if there is one, else the clock's reading in the zone and locale given. Read afresh on every call, so a clock that moves is followed.

zone

The zone results are in: the option, else the anchor's.

locale

The locale whose words are read: the option as its canonical code, else the anchor's.

bare_hour

The option, or undef.

prefer

The option, or undef.

RELATIVE TIME

# the clock pinned to Thursday 2026-10-15T09:00:00 Europe/London
$n->parse('tomorrow');                 # 2026-10-16T00:00:00+01:00
$n->parse('now');                      # 2026-10-15T09:00:00+01:00
$n->parse('in 3 days');                # 2026-10-18T09:00:00+01:00
$n->parse('3 hours ago');              # 2026-10-15T06:00:00+01:00
$n->parse('in 2 weeks and 3 days');    # 2026-11-01T09:00:00+00:00
$n->parse('next Tuesday');             # 2026-10-20T00:00:00+01:00
$n->parse('last month');               # 2026-09-01T00:00:00+01:00
$n->parse('this week');                # the week's first day, by the locale
$n->in_locale('fr')->parse('après-demain');
$n->in_locale('de')->parse('vor 2 Wochen');

The words are the locale's own, as "relative_names" in Time::Keeper::Locale gives them and "format_relative" in Time::Keeper writes them, so whatever format_relative writes in a locale reads back in it. Their meaning:

A period word is the start of its period.

"today" is the day's first moment, "this week" the first moment of the locale's week (Sunday in en-US, Monday in en-GB), "next month" the first moment of next month, "last year" 1 January of last year, "this quarter" the quarter's first day. "now" is the anchor itself.

A weekday is a day.

"next Tuesday" is the first Tuesday strictly after the anchor, "last Tuesday" the first strictly before, "this Tuesday" the Tuesday of the anchor's locale week, which may be past; "in 2 Tuesdays" the second Tuesday after the anchor. Each is the day's first moment.

A count of units is added to the anchor.

"in 3 days" and "3 days ago" keep the anchor's time of day; "in 3 hours" is three hours of elapsed time. Years, quarters, months, weeks and days are counted on the calendar, as format_relative counts them, so a day across a clock change is still a day; hours, minutes and seconds are counted on the clock.

A chain adds its parts in one direction.

"in 2 weeks and 3 days", "1 hour, 30 minutes and 10 seconds ago", "dans 2 semaines et 3 jours", "in 2 Wochen und 3 Tagen": the first or the last part carries the direction ("in", "ago"), the others are bare counts, joined by the locale's list words or by nothing. Months are added before days, as plus adds them. A chain that points both ways dies; one with no direction is a duration, which parse_duration reads.

A count never dies for a wrong plural: "in 1 days" is one day. A day that lands in a clock change that skipped it moves forward to the first moment that existed. Where CLDR's data gives one string to both directions (its Arabic short month pattern for a few months ago carries the word for "in"), the direction whose marker the text carries wins, so the text means what a reader would take it to mean rather than what the faulty pattern was for.

ABSOLUTE DATES AND TIMES

# the clock pinned to Thursday 2026-10-15T09:00:00 Europe/London, en-GB
$n->parse('2026-10-20T15:00:00Z');       # 2026-10-20T16:00:00+01:00, in the parser's zone
$n->parse('15 October 2026');            # 2026-10-15T00:00:00+01:00
$n->parse('15 October');                 # the same, the year assumed
$n->parse('15/10/2026');                 # the locale's short date
$n->parse('October 2026');               # 2026-10-01T00:00:00+01:00
$n->parse('15:30');                      # 2026-10-15T15:30:00+01:00, today
$n->parse('3 pm');                       # 2026-10-15T15:00:00+01:00
$n->parse('3 in the afternoon');         # the same
$n->parse('noon');                       # 2026-10-15T12:00:00+01:00
$n->parse('next Tuesday 3 pm');          # 2026-10-20T15:00:00+01:00
$n->parse('tomorrow 15:30');             # 2026-10-16T15:30:00+01:00
$n->parse('15 October 9 am');            # 2026-10-15T09:00:00+01:00
$n->in_locale('en-US')->parse('Oct 20'); # 2026-10-20T00:00:00+01:00
$n->parse('Oct 20');                     # dies: en-GB writes 20 Oct

A date or a time is read through the locale's own formats, so the order of day and month is the locale's and never a guess. The forms tried, in turn: what "parse" in Time::Keeper reads (ISO 8601, the RFC dates, @epoch), in the parser's zone when the text names none; the locale's four date-and-time, four date and four time formats; every skeleton of its available_formats, with and without zero padding and, for a time, with and without its spaces ("3pm"); a bare hour; and noon or midnight. A date with no year takes the anchor's year (prefer moves one that is past or to come to the other side; today stands); a date with no time is the day's first moment; a time with no date is on the anchor's date. A weekday that does not fit the date dies saying what day it was.

A bare hour is the hour in a locale whose clock has twenty-four; in one whose clock has twelve it dies unless bare_hour says am, pm or next (the first reading after the anchor that day, else the later one). "3 in the afternoon" places the hour by the locale's day-period rules.

A day and a time combine either way round: a relative day or an absolute date, and a time, joined by a space ("next Tuesday 3 pm", "3 pm tomorrow", "15 October 9 am", "明日15:30"). The time replaces the day's; a time on a phrase that already names one ("in 3 hours 5 pm") dies.

WORDS

$n->parse('end of last month');          # 2026-09-30T23:59:59.999999999+01:00
$n->parse('start of next week');         # 2026-10-19T00:00:00+01:00
$n->parse('the end of the year');        # 2026-12-31T23:59:59.999999999+00:00
$n->parse('end of October');             # 2026-10-31T23:59:59.999999999+00:00
$n->parse('end of today');               # 2026-10-15T23:59:59.999999999+01:00
$n->parse('tomorrow at 3 pm');           # 2026-10-16T15:00:00+01:00
$n->parse('at 3 pm');                    # 2026-10-15T15:00:00+01:00
$n->parse('on Tuesday at 9 am');         # 2026-10-20T09:00:00+01:00
$n->parse('Tuesday');                    # 2026-10-20T00:00:00+01:00

CLDR has no words for the start or the end of a period, for the "at" between a day and a time, for "on" before a day, or for "the". A locale gets them from a small table the distribution ships, words/<code>.txt beside the module (Time::Keeper::Natural::Words), English first; a locale whose chain has no table reads everything else and none of these, and coverage says which.

Word tables ship for: ar, bn, de, el, en, es, fr, hi, it, ja, ko, nl, pt, ru, th, zh.

Every table but English's is unreviewed, written as the first draft of each language and marked so in its file; each carries the phrases its test lines prove and says what its grammar keeps out of reach. A language that puts "end" after the period ("来月末", "다음 달 말", "অক্টোবরের শেষ") is written as a frame, and an article a contraction swallowed ("fin du mois dernier") is put back before giving up. A language whose "end of" wants a case CLDR does not have (German's genitive, Russian's) reads its edges with month names, years and dates only. The table's keys, the whole of them: start_of, end_of, at, on, skip (words dropped where a part begins) and and (a chain joiner, where the locale's list pattern has none).

"start of" and "end of" take any period the parser reads: a period word ("last month", "next week", "today"), a weekday ("next Tuesday"), a date ("15 October"), a month ("October", "October 2026"), a year ("2026"), or a bare unit name ("the month", "the year") for the current one. The result is the period's first moment or its last nanosecond, by the locale's week. A phrase that names a moment ("end of in 3 hours") dies: a moment has no end.

A bare weekday name is the coming one, today included ("Thursday" on a Thursday is today), or the past one under prefer => 'past'. This is the one place a name is given a direction, because "Tuesday at 3 pm" in a message means the coming Tuesday; match reports it as assumed.

NUMBER WORDS

$n->parse('in two weeks');                       # 2026-10-29T09:00:00+00:00
$n->parse('twenty one days ago');                # 2026-09-24T09:00:00+01:00
$n->parse('in a week');                          # the article is one
$n->in_locale('fr')->parse('dans deux semaines');
$n->in_locale('de')->parse('vor einundzwanzig Tagen');
$n->parse_duration('two hours and thirty minutes');
$n->parse('the third Friday of next month');     # 2026-11-20T00:00:00+00:00
$n->parse('the last Friday of October');         # 2026-10-30T00:00:00+00:00

Where a count is read as digits it is also read as a word, 0 to 99, in the locales whose CLDR data has spellout rules, about ninety of them (Time::Keeper::Natural::Numbers); every form those rules write is read, with hyphens or spaces, and no other. English's "a" and "an" are one.

"the third Friday of next month" counts a weekday in a month: an ordinal word, 1 to 5, or the word table's "last", then a weekday name, the table's "of", and anything that names a month ("next month", "October", "October 2026"). A month with no fifth Friday dies saying so, and so does a week or a year in place of the month. This needs the table's of and last keys, so it reads where a locale has a word table.

DURATIONS

$n->parse_duration('1 hour, 30 minutes');          # PT1H30M
$n->parse_duration('1 hr 30 min');                 # PT1H30M
$n->parse_duration('1:05:09');                     # PT1H5M9S
$n->parse_duration('2 weeks');                     # P14D, a duration's days
$n->parse_duration('1 year, 2 months and 3 days'); # P1Y2M3D
$n->parse_duration('PT90M');                       # PT1H30M
$n->in_locale('de')->parse_duration('1 Std., 30 Min.');
$n->in_locale('ar')->parse_duration('ساعتان و30 دقيقة');

Whatever Duration->format writes in a locale and a style, parse_duration reads back in it. Three forms are tried in turn: the ISO 8601 form Duration->parse reads; the locale's digital clock ("1:05:09", "1.05.09" in Finnish), where two parts are hours and minutes, as the digital style writes them, never minutes and seconds; and the locale's unit words in any width, joined by its list words, by commas or by nothing, in any order ("30 minutes and 1 hour"). A unit given twice dies. Where one word serves two units in a width (English's narrow "m" is months and minutes), the chain decides: a part is read as the nearest unit below the one before it, and a lone "30m" is minutes.

Parts are unsigned, as Duration->format writes them: a text with a sign dies naming it. Milliseconds and microseconds become nanoseconds. parse of a duration dies saying parse_duration reads it, and parse_duration of a moment dies saying it is a time.

THE MATCH

my $m = $n->match('next Tuesday at 3 pm');
$m->value;        # 2026-10-20T15:00:00+01:00
$m->precision;    # hour
$m->assumed;      # ('zone')
$n->match('Tuesday')->assumed;        # ('weekday next', 'time', 'zone')
$n->match('nonsense');                # undef

parse gives a value and leaves the caller unable to tell what the text said from what was filled in. match gives both; see Time::Keeper::Natural::Match for the list of what can be assumed, which is fixed.

ERRORS

Every method that reads a text either returns what it names or dies. A death from reading has one shape:

Time::Keeper::Natural->parse: cannot read 'next week zzz' at 'zzz': no such word in en

The text, the first token that could not be read, and the reason. The reasons are a fixed set, and the parser refuses to give any other: the text is empty, no such word in <locale>, one direction a phrase, a duration, not a time; parse_duration reads it, a time, not a duration, <unit> is given twice, a sign; a duration is unsigned, a bare hour; say am, pm or pass bare_hour, <date> was a <weekday>, a time on a time, a moment, not a period, nothing left after '<word>', an ordinal weekday is counted in a month, not a <unit>, no month has a <nth> <day>, <yyyy-mm> has no <nth> <day>, and coverage of <locale> is data; '<phrase>' needs its words, given when a locale with no word table is handed English's "end of" or "at": the text may be right, and the locale has no words for it. An option that is wrong dies from new, naming the option. The try_ forms give undef for any of these and leave $@ alone.

TESTING

local $Time::Keeper::CLOCK = Time::Keeper->parse('2026-10-15T09:00:00Z');

Pins the clock the anchor is read from, for one block; see "TESTING" in Time::Keeper. Every test in this distribution does so.

SEE ALSO

Time::Keeper, Time::Keeper::Locale, Time::Keeper::Duration, Time::Keeper::Natural::Match, Time::Keeper::Natural::Words, Time::Keeper::Natural::Numbers, Time::Keeper::Natural::Lexicon.

Date::Parse and Time::ParseDate read English, by rules of thumb. chrono (JavaScript) and dateparser (Python) find dates inside free text.

CLDR's date fields and spellout rules, which this module reads backwards: https://cldr.unicode.org/.

AUTHOR

LNATION <email@lnation.org>

BUGS

Please report any bugs or feature requests to bug-time-keeper-natural at rt.cpan.org, or through the web interface at https://rt.cpan.org/NoAuth/ReportBug.html?Queue=Time-Keeper-Natural.

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)

The number words and the locale data are from the Unicode Common Locale Data Repository, Copyright (c) 1991-2026 Unicode, Inc., under the Unicode License v3.