feat(todo): quick add reads on before a day, durations, every !30m and !1pm
`on` (German `am`) before a day is taken with it, as `at` before a time; `for 2h`, `for 1h30m`, `for 90 min`, `für 1,5 Std.` set the date's duration where there is a time, and stay text without one; every `!30m` counts, each once, sorted, a task without a date due at the latest; and `!1pm`, `!13:00`, `!tomorrow 9am`, `!Monday 8:30` are reminders at a time of their own, a time without a day on the task's day, else the next to come. Todoist's newsletter's four examples parse whole (ADR 0033, D18). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
3c68a22a5f
commit
d40eca6e50
4 changed files with 422 additions and 24 deletions
|
|
@ -9,9 +9,17 @@ enum QuickAddKind() {
|
||||||
label,
|
label,
|
||||||
deadline,
|
deadline,
|
||||||
priority,
|
priority,
|
||||||
|
|
||||||
|
/// `!30m`: so long before the task's time.
|
||||||
reminder,
|
reminder,
|
||||||
|
|
||||||
|
/// `!1pm`, `!tomorrow 9am`: at a time of its own (D18).
|
||||||
|
reminderTime,
|
||||||
date,
|
date,
|
||||||
time,
|
time,
|
||||||
|
|
||||||
|
/// `for 2h` (D18).
|
||||||
|
duration,
|
||||||
repeat,
|
repeat,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,6 +49,9 @@ final class const ParsedTask({
|
||||||
|
|
||||||
/// Reminders, minutes before the task's time, as [TaskContent.reminders].
|
/// Reminders, minutes before the task's time, as [TaskContent.reminders].
|
||||||
final List<int> reminders = const [],
|
final List<int> reminders = const [],
|
||||||
|
|
||||||
|
/// Reminders at times of their own, as [TaskContent.reminderTimes].
|
||||||
|
final List<ReminderTime> reminderTimes = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
/// A project's or a label's ID and the name it is typed by.
|
/// A project's or a label's ID and the name it is typed by.
|
||||||
|
|
@ -50,9 +61,12 @@ typedef NamedId = ({String id, String name});
|
||||||
/// `@label` by their names (the longest matching, the case aside; an
|
/// `@label` by their names (the longest matching, the case aside; an
|
||||||
/// unknown label to be made, an unknown project left as text), `{deadline}`,
|
/// unknown label to be made, an unknown project left as text), `{deadline}`,
|
||||||
/// `!!1` and `p1` to `p4`, `!30m` (due that long from now, or reminded that
|
/// `!!1` and `p1` to `p4`, `!30m` (due that long from now, or reminded that
|
||||||
/// long before its time), and dates, times and recurrences in [words]. Only
|
/// long before its time), `!1pm` and `!tomorrow 9am` (reminded then, D18),
|
||||||
/// the first of each kind counts. A token whose text, in lower case, is
|
/// and dates (`on` before one taken with it), times, durations (`for 2h`,
|
||||||
/// among [ignored] (tapped off by the person) stays text.
|
/// with a time alone) and recurrences in [words]. Only the first of each
|
||||||
|
/// kind counts, but for reminders and labels, each of which counts once. A
|
||||||
|
/// token whose text, in lower case, is among [ignored] (tapped off by the
|
||||||
|
/// person) stays text.
|
||||||
ParsedTask parseQuickAdd(
|
ParsedTask parseQuickAdd(
|
||||||
String text, {
|
String text, {
|
||||||
required DateTime now,
|
required DateTime now,
|
||||||
|
|
@ -82,21 +96,25 @@ class _Parser(
|
||||||
Day? _day;
|
Day? _day;
|
||||||
int? _time;
|
int? _time;
|
||||||
Repeat? _repeat;
|
Repeat? _repeat;
|
||||||
int? _reminder;
|
Duration? _duration;
|
||||||
QuickAddToken? _reminderToken;
|
QuickAddToken? _durationToken;
|
||||||
|
|
||||||
|
/// `!30m`'s minutes, each with its token.
|
||||||
|
final _reminders = <(int, QuickAddToken)>[];
|
||||||
|
|
||||||
|
/// `!1pm`'s day where it names one, and time.
|
||||||
|
final _reminderTimes = <(Day?, int)>[];
|
||||||
|
|
||||||
bool _has(QuickAddKind kind) => _tokens.any((token) => token.kind == kind);
|
bool _has(QuickAddKind kind) => _tokens.any((token) => token.kind == kind);
|
||||||
|
|
||||||
bool _isIgnored(int start, int end) =>
|
bool _isIgnored(int start, int end) =>
|
||||||
_ignored.contains(_text.substring(start, end).toLowerCase());
|
_ignored.contains(_text.substring(start, end).toLowerCase());
|
||||||
|
|
||||||
void _take(QuickAddKind kind, int start, int end) {
|
QuickAddToken _take(QuickAddKind kind, int start, int end) {
|
||||||
final token = QuickAddToken(kind, start, end);
|
final token = QuickAddToken(kind, start, end);
|
||||||
_tokens.add(token);
|
_tokens.add(token);
|
||||||
if (kind == QuickAddKind.reminder) {
|
|
||||||
_reminderToken = token;
|
|
||||||
}
|
|
||||||
_rest = _rest.replaceRange(start, end, ' ' * (end - start));
|
_rest = _rest.replaceRange(start, end, ' ' * (end - start));
|
||||||
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _boundary(String text, int at) =>
|
static bool _boundary(String text, int at) =>
|
||||||
|
|
@ -104,6 +122,8 @@ class _Parser(
|
||||||
|
|
||||||
ParsedTask parse(List<NamedId> projects, List<NamedId> labels) {
|
ParsedTask parse(List<NamedId> projects, List<NamedId> labels) {
|
||||||
_symbols(projects, labels);
|
_symbols(projects, labels);
|
||||||
|
_reminderTimesIn();
|
||||||
|
_durationIn();
|
||||||
_phrases();
|
_phrases();
|
||||||
return _result();
|
return _result();
|
||||||
}
|
}
|
||||||
|
|
@ -183,10 +203,97 @@ class _Parser(
|
||||||
for (final match in RegExp(
|
for (final match in RegExp(
|
||||||
r'(?<=^|\s)!(\d{1,4})(m|min|h)(?=\s|$)',
|
r'(?<=^|\s)!(\d{1,4})(m|min|h)(?=\s|$)',
|
||||||
).allMatches(_text)) {
|
).allMatches(_text)) {
|
||||||
if (_reminder == null && !_isIgnored(match.start, match.end)) {
|
if (!_isIgnored(match.start, match.end)) {
|
||||||
final amount = int.parse(match.group(1)!);
|
final amount = int.parse(match.group(1)!);
|
||||||
_reminder = match.group(2) == 'h' ? amount * 60 : amount;
|
_reminders.add((
|
||||||
_take(QuickAddKind.reminder, match.start, match.end);
|
match.group(2) == 'h' ? amount * 60 : amount,
|
||||||
|
_take(QuickAddKind.reminder, match.start, match.end),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `!` before a time, or before a day and a time (D18): `!1pm`,
|
||||||
|
/// `!13:00`, `!tomorrow 9am`, `!Monday at 8:30`.
|
||||||
|
void _reminderTimesIn() {
|
||||||
|
final words = _wordsIn(_rest);
|
||||||
|
for (var i = 0; i < words.length; i++) {
|
||||||
|
final word = words[i];
|
||||||
|
if (!word.text.startsWith('!') || word.text.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final stripped = [
|
||||||
|
...words.take(i),
|
||||||
|
_Word(word.text.substring(1), word.start + 1, word.end),
|
||||||
|
...words.skip(i + 1),
|
||||||
|
];
|
||||||
|
(Day?, int, int)? found;
|
||||||
|
if (_timeAt(stripped, i) case (final time, final length)) {
|
||||||
|
found = (null, time, length);
|
||||||
|
} else if (_dateAt(stripped, i) case (final day, final length)
|
||||||
|
when i + length < words.length) {
|
||||||
|
if (_timeAt(stripped, i + length) case (final time, final more)) {
|
||||||
|
found = (day, time, length + more);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found case (final day, final time, final length)) {
|
||||||
|
final end = words[i + length - 1].end;
|
||||||
|
if (!_isIgnored(word.start, end)) {
|
||||||
|
_reminderTimes.add((day, time));
|
||||||
|
_take(QuickAddKind.reminderTime, word.start, end);
|
||||||
|
i += length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first duration (D18): `for 2h`, `for 1h30m`, `for 90 min`, `für
|
||||||
|
/// 1,5 Std.`, a day at most; a decimal comma or point taken, which the
|
||||||
|
/// words alone would split.
|
||||||
|
void _durationIn() {
|
||||||
|
final leads = {for (final words in _words) ...words.forWords};
|
||||||
|
final units = <String, int>{
|
||||||
|
for (final words in _words.reversed) ...words.durationUnits,
|
||||||
|
};
|
||||||
|
if (leads.isEmpty || units.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String either(Iterable<String> options) =>
|
||||||
|
(options.toList()..sort((a, b) => b.length.compareTo(a.length)))
|
||||||
|
.map(RegExp.escape)
|
||||||
|
.join('|');
|
||||||
|
final unit =
|
||||||
|
'(${either(units.keys)})'
|
||||||
|
r'\.?';
|
||||||
|
final pattern = RegExp(
|
||||||
|
r'(?<=^|\s)(?:'
|
||||||
|
'${either(leads)}'
|
||||||
|
r')\s+(\d+(?:[.,]\d+)?)\s*'
|
||||||
|
'$unit'
|
||||||
|
r'(?:\s*(\d+)\s*'
|
||||||
|
'$unit'
|
||||||
|
r')?(?=$|[\s,;!?])',
|
||||||
|
caseSensitive: false,
|
||||||
|
unicode: true,
|
||||||
|
);
|
||||||
|
for (final match in pattern.allMatches(_rest)) {
|
||||||
|
if (_isIgnored(match.start, match.end)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
double minutesOf(String amount, String name) =>
|
||||||
|
double.parse(amount.replaceAll(',', '.')) *
|
||||||
|
units[name.toLowerCase()]!;
|
||||||
|
final minutes =
|
||||||
|
(minutesOf(match.group(1)!, match.group(2)!) +
|
||||||
|
switch ((match.group(3), match.group(4))) {
|
||||||
|
(final amount?, final name?) => minutesOf(amount, name),
|
||||||
|
_ => 0,
|
||||||
|
})
|
||||||
|
.round();
|
||||||
|
if (minutes > 0 && minutes <= 24 * 60) {
|
||||||
|
_duration = Duration(minutes: minutes);
|
||||||
|
_durationToken = _take(QuickAddKind.duration, match.start, match.end);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -387,9 +494,15 @@ class _Parser(
|
||||||
|
|
||||||
/// A day at [i]: today, tomorrow, a weekday, `next` one, the weekend, `in`
|
/// A day at [i]: today, tomorrow, a weekday, `next` one, the weekend, `in`
|
||||||
/// a span, a month's day (either way round, with or without its year),
|
/// a span, a month's day (either way round, with or without its year),
|
||||||
/// `2026-12-24` or `24.12.` (`24.12.2026`).
|
/// `2026-12-24` or `24.12.` (`24.12.2026`); maybe after `on` (D18).
|
||||||
(Day, int)? _dateAt(List<_Word> words, int i) {
|
(Day, int)? _dateAt(List<_Word> words, int i) {
|
||||||
final word = words[i].text;
|
final word = words[i].text;
|
||||||
|
if (_in((words) => words.on, word) && i + 1 < words.length) {
|
||||||
|
return switch (_dateAt(words, i + 1)) {
|
||||||
|
(final day, final length) => (day, length + 1),
|
||||||
|
null => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
String? after(int n) => i + n < words.length ? words[i + n].text : null;
|
String? after(int n) => i + n < words.length ? words[i + n].text : null;
|
||||||
if (_in((words) => words.today, word)) {
|
if (_in((words) => words.today, word)) {
|
||||||
return (_today, 1);
|
return (_today, 1);
|
||||||
|
|
@ -546,26 +659,42 @@ class _Parser(
|
||||||
var day = _day;
|
var day = _day;
|
||||||
var time = _time;
|
var time = _time;
|
||||||
var reminders = const <int>[];
|
var reminders = const <int>[];
|
||||||
if (_reminder case final reminder?) {
|
final before = {for (final (minutes, _) in _reminders) minutes};
|
||||||
|
if (before.isNotEmpty) {
|
||||||
if (time != null) {
|
if (time != null) {
|
||||||
reminders = [reminder];
|
reminders = before.toList()..sort();
|
||||||
} else if (day == null) {
|
} else if (day == null) {
|
||||||
final due = _now.add(Duration(minutes: reminder));
|
// Due at the latest, each reminding that long from now.
|
||||||
|
final latest = before.reduce((a, b) => a > b ? a : b);
|
||||||
|
final due = _now.add(Duration(minutes: latest));
|
||||||
day = Day.of(due);
|
day = Day.of(due);
|
||||||
time = due.hour * 60 + due.minute;
|
time = due.hour * 60 + due.minute;
|
||||||
reminders = [0];
|
reminders = [for (final minutes in before) latest - minutes]..sort();
|
||||||
} else {
|
} else {
|
||||||
// A day without a time has nothing to be reminded before.
|
// A day without a time has nothing to be reminded before.
|
||||||
_tokens.remove(_reminderToken);
|
for (final (_, token) in _reminders) {
|
||||||
|
_tokens.remove(token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (time == null && _durationToken != null) {
|
||||||
|
// A duration lasts from a time; without one it is text (D18).
|
||||||
|
_tokens.remove(_durationToken);
|
||||||
|
}
|
||||||
|
final nowMinutes = _now.hour * 60 + _now.minute;
|
||||||
|
Day next(int time) => time > nowMinutes ? _today : _today.plus(1);
|
||||||
if (day == null && time != null) {
|
if (day == null && time != null) {
|
||||||
final nowMinutes = _now.hour * 60 + _now.minute;
|
day = next(time);
|
||||||
day = time > nowMinutes ? _today : _today.plus(1);
|
|
||||||
}
|
}
|
||||||
if (_repeat case final repeat?) {
|
if (_repeat case final repeat?) {
|
||||||
day = repeat.firstFrom(day ?? _today);
|
day = repeat.firstFrom(day ?? _today);
|
||||||
}
|
}
|
||||||
|
// A reminder's time without its day: the task's day, else the next to
|
||||||
|
// come (D18).
|
||||||
|
final reminderTimes = {
|
||||||
|
for (final (named, at) in _reminderTimes)
|
||||||
|
ReminderTime(named ?? day ?? next(at), at),
|
||||||
|
}.toList()..sort();
|
||||||
_tokens.sort((a, b) => a.start.compareTo(b.start));
|
_tokens.sort((a, b) => a.start.compareTo(b.start));
|
||||||
final title = StringBuffer();
|
final title = StringBuffer();
|
||||||
var from = 0;
|
var from = 0;
|
||||||
|
|
@ -582,8 +711,16 @@ class _Parser(
|
||||||
newLabels: List.unmodifiable(_newLabels),
|
newLabels: List.unmodifiable(_newLabels),
|
||||||
priority: _priority,
|
priority: _priority,
|
||||||
deadline: _deadline,
|
deadline: _deadline,
|
||||||
date: day == null ? null : TaskDate(day, time: time, repeat: _repeat),
|
date: day == null
|
||||||
|
? null
|
||||||
|
: TaskDate(
|
||||||
|
day,
|
||||||
|
time: time,
|
||||||
|
duration: time == null ? null : _duration,
|
||||||
|
repeat: _repeat,
|
||||||
|
),
|
||||||
reminders: reminders,
|
reminders: reminders,
|
||||||
|
reminderTimes: reminderTimes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||||
/// The words quick add reads dates, times and recurrences in, one table a
|
/// The words quick add reads dates, times and recurrences in, one table a
|
||||||
/// language (ADR 0033, D15): a new app language adds a table here, not a
|
/// language (ADR 0033, D15): a new app language adds a table here, not a
|
||||||
/// parser. Quick add reads the app's language and English together; the
|
/// parser. Quick add reads the app's language and English together; the
|
||||||
/// symbols (`#`, `@`, `{}`, `!!`, `p1`, `!30m`) hold in every one. Every
|
/// symbols (`#`, `@`, `{}`, `!!`, `p1`, `!30m`, `!` before a time) hold
|
||||||
|
/// in every one. Every
|
||||||
/// word is lower case, as the text is compared folded to it.
|
/// word is lower case, as the text is compared folded to it.
|
||||||
final class const QuickAddWords({
|
final class const QuickAddWords({
|
||||||
/// Today, and the words for it.
|
/// Today, and the words for it.
|
||||||
|
|
@ -49,6 +50,15 @@ final class const QuickAddWords({
|
||||||
/// What may stand before a time: `at 7pm`.
|
/// What may stand before a time: `at 7pm`.
|
||||||
required final Set<String> at,
|
required final Set<String> at,
|
||||||
|
|
||||||
|
/// What may stand before a day: `on Monday`, `on 1st March`.
|
||||||
|
final Set<String> on = const {},
|
||||||
|
|
||||||
|
/// What starts a duration: `for 2h`.
|
||||||
|
final Set<String> forWords = const {},
|
||||||
|
|
||||||
|
/// A duration's units, each to its minutes: `h` to 60, `min` to 1.
|
||||||
|
final Map<String, int> durationUnits = const {},
|
||||||
|
|
||||||
/// What joins weekdays: `mon and fri`.
|
/// What joins weekdays: `mon and fri`.
|
||||||
required final Set<String> and,
|
required final Set<String> and,
|
||||||
|
|
||||||
|
|
@ -154,6 +164,20 @@ final class const QuickAddWords({
|
||||||
next: {'next'},
|
next: {'next'},
|
||||||
inWords: {'in'},
|
inWords: {'in'},
|
||||||
at: {'at'},
|
at: {'at'},
|
||||||
|
on: {'on'},
|
||||||
|
forWords: {'for'},
|
||||||
|
durationUnits: {
|
||||||
|
'h': 60,
|
||||||
|
'hr': 60,
|
||||||
|
'hrs': 60,
|
||||||
|
'hour': 60,
|
||||||
|
'hours': 60,
|
||||||
|
'm': 1,
|
||||||
|
'min': 1,
|
||||||
|
'mins': 1,
|
||||||
|
'minute': 1,
|
||||||
|
'minutes': 1,
|
||||||
|
},
|
||||||
and: {'and', '&'},
|
and: {'and', '&'},
|
||||||
meridiem: {'am': 0, 'pm': 12},
|
meridiem: {'am': 0, 'pm': 12},
|
||||||
daySuffixes: {'st', 'nd', 'rd', 'th'},
|
daySuffixes: {'st', 'nd', 'rd', 'th'},
|
||||||
|
|
@ -235,6 +259,17 @@ final class const QuickAddWords({
|
||||||
next: {'nächsten', 'nächste', 'nächster', 'kommenden', 'kommende'},
|
next: {'nächsten', 'nächste', 'nächster', 'kommenden', 'kommende'},
|
||||||
inWords: {'in'},
|
inWords: {'in'},
|
||||||
at: {'um'},
|
at: {'um'},
|
||||||
|
on: {'am'},
|
||||||
|
forWords: {'für'},
|
||||||
|
durationUnits: {
|
||||||
|
'h': 60,
|
||||||
|
'std': 60,
|
||||||
|
'stunde': 60,
|
||||||
|
'stunden': 60,
|
||||||
|
'min': 1,
|
||||||
|
'minute': 1,
|
||||||
|
'minuten': 1,
|
||||||
|
},
|
||||||
and: {'und'},
|
and: {'und'},
|
||||||
oClock: {'uhr'},
|
oClock: {'uhr'},
|
||||||
daySuffixes: {'.'},
|
daySuffixes: {'.'},
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,9 @@ import 'package:material_ui/material_ui.dart';
|
||||||
/// It shows the title alone at first, its chips once typing starts, and its
|
/// It shows the title alone at first, its chips once typing starts, and its
|
||||||
/// description from its + or the down arrow (the developer's, 1 Nov, D15).
|
/// description from its + or the down arrow (the developer's, 1 Nov, D15).
|
||||||
/// What the title says is read as it is typed (`parseQuickAdd`): `#project`,
|
/// What the title says is read as it is typed (`parseQuickAdd`): `#project`,
|
||||||
/// `@label`, `{deadline}`, `!!1`, `p1`, `!30m`, dates, times and
|
/// `@label`, `{deadline}`, `!!1`, `p1`, `!30m` (as many as typed), `!1pm`,
|
||||||
/// recurrences in the app's language and English, each highlighted and
|
/// dates, times, durations and recurrences in the app's language and
|
||||||
|
/// English, each highlighted and
|
||||||
/// shown on its chip; a tap on one keeps it as text, and a chip picked
|
/// shown on its chip; a tap on one keeps it as text, and a chip picked
|
||||||
/// takes its token out of the title. As `#` or `@` is typed, the projects
|
/// takes its token out of the title. As `#` or `@` is typed, the projects
|
||||||
/// or labels it may mean are offered below the title, one tapped
|
/// or labels it may mean are offered below the title, one tapped
|
||||||
|
|
@ -320,6 +321,7 @@ class _QuickAddState() extends State<_QuickAdd> {
|
||||||
priority: parsed.priority ?? _priority,
|
priority: parsed.priority ?? _priority,
|
||||||
labelIds: {..._labelIds, ...parsed.labelIds, ...made}.toList(),
|
labelIds: {..._labelIds, ...parsed.labelIds, ...made}.toList(),
|
||||||
reminders: parsed.reminders,
|
reminders: parsed.reminders,
|
||||||
|
reminderTimes: parsed.reminderTimes,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} on KeysMissing {
|
} on KeysMissing {
|
||||||
|
|
@ -473,6 +475,7 @@ class _QuickAddState() extends State<_QuickAdd> {
|
||||||
QuickAddKind.date,
|
QuickAddKind.date,
|
||||||
QuickAddKind.time,
|
QuickAddKind.time,
|
||||||
QuickAddKind.repeat,
|
QuickAddKind.repeat,
|
||||||
|
QuickAddKind.duration,
|
||||||
QuickAddKind.reminder,
|
QuickAddKind.reminder,
|
||||||
});
|
});
|
||||||
setState(() => _date = date);
|
setState(() => _date = date);
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,229 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('on before a day, durations, reminders (D18)', () {
|
||||||
|
const de = [QuickAddWords.de, QuickAddWords.en];
|
||||||
|
|
||||||
|
test(
|
||||||
|
'on before a day is taken with it; on before nothing it reads stays',
|
||||||
|
() {
|
||||||
|
final added = parse('Pay rent on Friday');
|
||||||
|
expect(added.title, 'Pay rent');
|
||||||
|
expect(added.date?.day, const Day(2026, 11, 6));
|
||||||
|
expect(parse('Trip on 7 Nov').date?.day, const Day(2026, 11, 7));
|
||||||
|
expect(parse('Trip on 7 Nov').title, 'Trip');
|
||||||
|
expect(parse('Work on report').title, 'Work on report');
|
||||||
|
expect(parse('Work on report').date, isNull);
|
||||||
|
expect(parse('Move on').title, 'Move on');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('am before a day and um before a time, in German; am after a '
|
||||||
|
'number still the morning', () {
|
||||||
|
final added = parse('Zahnarzt am Montag um 9 Uhr', words: de);
|
||||||
|
expect(added.title, 'Zahnarzt');
|
||||||
|
expect(added.date, const TaskDate(Day(2026, 11, 2), time: 9 * 60));
|
||||||
|
expect(
|
||||||
|
parse('Feier am 24.12.', words: de).date?.day,
|
||||||
|
const Day(2026, 12, 24),
|
||||||
|
);
|
||||||
|
expect(parse('Feier am 24.12.', words: de).title, 'Feier');
|
||||||
|
expect(parse('Steuer jedes Jahr am 1. März', words: de).title, 'Steuer');
|
||||||
|
expect(
|
||||||
|
parse('Anrufen morgen um 9 Uhr', words: de).date,
|
||||||
|
TaskDate(today.plus(1), time: 9 * 60),
|
||||||
|
);
|
||||||
|
expect(parse('Anrufen morgen um 9 Uhr', words: de).title, 'Anrufen');
|
||||||
|
expect(parse('Call 9 am', words: de).date?.time, 9 * 60);
|
||||||
|
expect(
|
||||||
|
parse('Treffen am Bahnhof', words: de).title,
|
||||||
|
'Treffen am Bahnhof',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('for and a duration, with a time', () {
|
||||||
|
Duration? lasts(String text, [List<QuickAddWords>? words]) =>
|
||||||
|
parse(text, words: words ?? const [QuickAddWords.en]).date?.duration;
|
||||||
|
expect(lasts('Meet at 3pm for 2h'), const Duration(hours: 2));
|
||||||
|
expect(lasts('Meet at 3pm for 30m'), const Duration(minutes: 30));
|
||||||
|
expect(lasts('Meet at 3pm for 1h30m'), const Duration(minutes: 90));
|
||||||
|
expect(lasts('Meet at 3pm for 1h 30m'), const Duration(minutes: 90));
|
||||||
|
expect(lasts('Meet at 3pm for 90 min'), const Duration(minutes: 90));
|
||||||
|
expect(lasts('Meet at 3pm for 2 hours'), const Duration(hours: 2));
|
||||||
|
expect(lasts('Meet at 3pm for 1.5 hours'), const Duration(minutes: 90));
|
||||||
|
expect(
|
||||||
|
lasts('Treffen um 15 Uhr für 2 Std', de),
|
||||||
|
const Duration(hours: 2),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
lasts('Treffen um 15 Uhr für 30 Min', de),
|
||||||
|
const Duration(minutes: 30),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
lasts('Treffen um 15 Uhr für 1,5 Std', de),
|
||||||
|
const Duration(minutes: 90),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
lasts('Treffen um 15 Uhr für 1,5 Std.', de),
|
||||||
|
const Duration(minutes: 90),
|
||||||
|
);
|
||||||
|
expect(parse('Meet for 2h at 3pm').title, 'Meet');
|
||||||
|
expect(
|
||||||
|
parse('Treffen um 15 Uhr für 1,5 Std.', words: de).title,
|
||||||
|
'Treffen',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a duration without a time stays text, as one that is none', () {
|
||||||
|
expect(parse('Read for 2h').title, 'Read for 2h');
|
||||||
|
expect(parse('Read for 2h').tokens, isEmpty);
|
||||||
|
final day = parse('Read tomorrow for 2h');
|
||||||
|
expect(day.title, 'Read for 2h');
|
||||||
|
expect(day.date, TaskDate(today.plus(1)));
|
||||||
|
expect(parse('Cook for 4 people at 6pm').date?.duration, isNull);
|
||||||
|
expect(parse('Cook for 4 people at 6pm').title, 'Cook for 4 people');
|
||||||
|
expect(
|
||||||
|
parse('Sleep at 10pm for 30h').date?.duration,
|
||||||
|
isNull,
|
||||||
|
reason: 'longer than a day',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every !30m counts, each once, the nearest to the time last', () {
|
||||||
|
const text = 'Meeting 3pm !30m !1h !30m';
|
||||||
|
final added = parse(text);
|
||||||
|
expect(added.title, 'Meeting');
|
||||||
|
expect(added.reminders, [30, 60]);
|
||||||
|
expect(
|
||||||
|
[
|
||||||
|
for (final token in added.tokens)
|
||||||
|
if (token.kind == QuickAddKind.reminder)
|
||||||
|
text.substring(token.start, token.end),
|
||||||
|
],
|
||||||
|
['!30m', '!1h', '!30m'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('several !30m without a date: due at the latest, each reminding '
|
||||||
|
'that long from now', () {
|
||||||
|
final added = parse('Leave !30m !1h');
|
||||||
|
expect(added.date, TaskDate(today, time: 11 * 60 + 15));
|
||||||
|
expect(added.reminders, [0, 30]);
|
||||||
|
expect(parse('Pay tomorrow !30m !1h').title, 'Pay !30m !1h');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('! before a time: a reminder at it, on the task’s day, else the '
|
||||||
|
'next to come', () {
|
||||||
|
final at = parse('Call !1pm');
|
||||||
|
expect(at.title, 'Call');
|
||||||
|
expect(at.date, isNull);
|
||||||
|
expect(at.reminderTimes, [ReminderTime(today, 13 * 60)]);
|
||||||
|
expect(parse('Call !13:00').reminderTimes, [
|
||||||
|
ReminderTime(today, 13 * 60),
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
parse('Call !9am').reminderTimes,
|
||||||
|
[ReminderTime(today.plus(1), 9 * 60)],
|
||||||
|
reason: 'past already today',
|
||||||
|
);
|
||||||
|
expect(parse('Call Friday !9am').reminderTimes, [
|
||||||
|
const ReminderTime(Day(2026, 11, 6), 9 * 60),
|
||||||
|
]);
|
||||||
|
final both = parse('Dentist Friday 3pm !1pm !30m');
|
||||||
|
expect(both.date, const TaskDate(Day(2026, 11, 6), time: 15 * 60));
|
||||||
|
expect(both.reminders, [30]);
|
||||||
|
expect(both.reminderTimes, [
|
||||||
|
const ReminderTime(Day(2026, 11, 6), 13 * 60),
|
||||||
|
]);
|
||||||
|
expect(both.title, 'Dentist');
|
||||||
|
expect(
|
||||||
|
parse('Standup every weekday 9am !8:30').reminderTimes,
|
||||||
|
[const ReminderTime(Day(2026, 11, 2), 8 * 60 + 30)],
|
||||||
|
reason: "on the recurrence's first day",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('! before a day and a time: a reminder then', () {
|
||||||
|
const text = 'Pay !tomorrow 9am';
|
||||||
|
final added = parse(text);
|
||||||
|
expect(added.title, 'Pay');
|
||||||
|
expect(added.date, isNull);
|
||||||
|
expect(added.reminderTimes, [ReminderTime(today.plus(1), 9 * 60)]);
|
||||||
|
expect(
|
||||||
|
[
|
||||||
|
for (final token in added.tokens)
|
||||||
|
(token.kind, text.substring(token.start, token.end)),
|
||||||
|
],
|
||||||
|
[(QuickAddKind.reminderTime, '!tomorrow 9am')],
|
||||||
|
);
|
||||||
|
expect(parse('Pay !Monday 8:30').reminderTimes, [
|
||||||
|
const ReminderTime(Day(2026, 11, 2), 8 * 60 + 30),
|
||||||
|
]);
|
||||||
|
expect(parse('Zahlen !morgen um 9 Uhr', words: de).reminderTimes, [
|
||||||
|
ReminderTime(today.plus(1), 9 * 60),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a ! before no time, or tapped off, stays text', () {
|
||||||
|
expect(parse('Pay !tomorrow').title, 'Pay !tomorrow');
|
||||||
|
expect(parse('Pay !tomorrow').date, isNull);
|
||||||
|
expect(parse('Wow !9').title, 'Wow !9');
|
||||||
|
expect(parse('Wow !soon').reminderTimes, isEmpty);
|
||||||
|
final kept = parse('Call !1pm !2pm', ignored: {'!1pm'});
|
||||||
|
expect(kept.title, 'Call !1pm');
|
||||||
|
expect(kept.reminderTimes, [ReminderTime(today, 14 * 60)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
group("Todoist's newsletter's four", () {
|
||||||
|
test('Check work emails every weekday at 9am !30m', () {
|
||||||
|
final added = parse('Check work emails every weekday at 9am !30m');
|
||||||
|
expect(added.title, 'Check work emails');
|
||||||
|
expect(
|
||||||
|
added.date,
|
||||||
|
const TaskDate(
|
||||||
|
Day(2026, 11, 2),
|
||||||
|
time: 9 * 60,
|
||||||
|
repeat: Repeat(RepeatUnit.week, weekdays: {1, 2, 3, 4, 5}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(added.reminders, [30]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Prepare document on Monday at 10am for 2h', () {
|
||||||
|
final added = parse('Prepare document on Monday at 10am for 2h');
|
||||||
|
expect(added.title, 'Prepare document');
|
||||||
|
expect(
|
||||||
|
added.date,
|
||||||
|
const TaskDate(
|
||||||
|
Day(2026, 11, 2),
|
||||||
|
time: 10 * 60,
|
||||||
|
duration: Duration(hours: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Stop watching that viral cat video !1pm !2pm !3pm !4pm', () {
|
||||||
|
final added = parse(
|
||||||
|
'Stop watching that viral cat video !1pm !2pm !3pm !4pm',
|
||||||
|
);
|
||||||
|
expect(added.title, 'Stop watching that viral cat video');
|
||||||
|
expect(added.date, isNull);
|
||||||
|
expect(added.reminderTimes, [
|
||||||
|
for (final hour in [13, 14, 15, 16]) ReminderTime(today, hour * 60),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('File my tax return every year on 1st March', () {
|
||||||
|
final added = parse('File my tax return every year on 1st March');
|
||||||
|
expect(added.title, 'File my tax return');
|
||||||
|
expect(
|
||||||
|
added.date,
|
||||||
|
const TaskDate(Day(2027, 3, 1), repeat: Repeat(RepeatUnit.year)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('tokens', () {
|
group('tokens', () {
|
||||||
test('each says where it is in the text and what it is', () {
|
test('each says where it is in the text and what it is', () {
|
||||||
const text = 'Call #Shopping tomorrow p1 @errand';
|
const text = 'Call #Shopping tomorrow p1 @errand';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue