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
3 changed files with 422 additions and 24 deletions
|
|
@ -9,9 +9,17 @@ enum QuickAddKind() {
|
|||
label,
|
||||
deadline,
|
||||
priority,
|
||||
|
||||
/// `!30m`: so long before the task's time.
|
||||
reminder,
|
||||
|
||||
/// `!1pm`, `!tomorrow 9am`: at a time of its own (D18).
|
||||
reminderTime,
|
||||
date,
|
||||
time,
|
||||
|
||||
/// `for 2h` (D18).
|
||||
duration,
|
||||
repeat,
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +49,9 @@ final class const ParsedTask({
|
|||
|
||||
/// Reminders, minutes before the task's time, as [TaskContent.reminders].
|
||||
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.
|
||||
|
|
@ -50,9 +61,12 @@ typedef NamedId = ({String id, String name});
|
|||
/// `@label` by their names (the longest matching, the case aside; an
|
||||
/// 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
|
||||
/// long before its time), and dates, times and recurrences in [words]. Only
|
||||
/// the first of each kind counts. A token whose text, in lower case, is
|
||||
/// among [ignored] (tapped off by the person) stays text.
|
||||
/// long before its time), `!1pm` and `!tomorrow 9am` (reminded then, D18),
|
||||
/// and dates (`on` before one taken with it), times, durations (`for 2h`,
|
||||
/// 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(
|
||||
String text, {
|
||||
required DateTime now,
|
||||
|
|
@ -82,21 +96,25 @@ class _Parser(
|
|||
Day? _day;
|
||||
int? _time;
|
||||
Repeat? _repeat;
|
||||
int? _reminder;
|
||||
QuickAddToken? _reminderToken;
|
||||
Duration? _duration;
|
||||
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 _isIgnored(int start, int end) =>
|
||||
_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);
|
||||
_tokens.add(token);
|
||||
if (kind == QuickAddKind.reminder) {
|
||||
_reminderToken = token;
|
||||
}
|
||||
_rest = _rest.replaceRange(start, end, ' ' * (end - start));
|
||||
return token;
|
||||
}
|
||||
|
||||
static bool _boundary(String text, int at) =>
|
||||
|
|
@ -104,6 +122,8 @@ class _Parser(
|
|||
|
||||
ParsedTask parse(List<NamedId> projects, List<NamedId> labels) {
|
||||
_symbols(projects, labels);
|
||||
_reminderTimesIn();
|
||||
_durationIn();
|
||||
_phrases();
|
||||
return _result();
|
||||
}
|
||||
|
|
@ -183,10 +203,97 @@ class _Parser(
|
|||
for (final match in RegExp(
|
||||
r'(?<=^|\s)!(\d{1,4})(m|min|h)(?=\s|$)',
|
||||
).allMatches(_text)) {
|
||||
if (_reminder == null && !_isIgnored(match.start, match.end)) {
|
||||
if (!_isIgnored(match.start, match.end)) {
|
||||
final amount = int.parse(match.group(1)!);
|
||||
_reminder = match.group(2) == 'h' ? amount * 60 : amount;
|
||||
_take(QuickAddKind.reminder, match.start, match.end);
|
||||
_reminders.add((
|
||||
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 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) {
|
||||
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;
|
||||
if (_in((words) => words.today, word)) {
|
||||
return (_today, 1);
|
||||
|
|
@ -546,26 +659,42 @@ class _Parser(
|
|||
var day = _day;
|
||||
var time = _time;
|
||||
var reminders = const <int>[];
|
||||
if (_reminder case final reminder?) {
|
||||
final before = {for (final (minutes, _) in _reminders) minutes};
|
||||
if (before.isNotEmpty) {
|
||||
if (time != null) {
|
||||
reminders = [reminder];
|
||||
reminders = before.toList()..sort();
|
||||
} 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);
|
||||
time = due.hour * 60 + due.minute;
|
||||
reminders = [0];
|
||||
reminders = [for (final minutes in before) latest - minutes]..sort();
|
||||
} else {
|
||||
// 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) {
|
||||
final nowMinutes = _now.hour * 60 + _now.minute;
|
||||
day = time > nowMinutes ? _today : _today.plus(1);
|
||||
day = next(time);
|
||||
}
|
||||
if (_repeat case final repeat?) {
|
||||
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));
|
||||
final title = StringBuffer();
|
||||
var from = 0;
|
||||
|
|
@ -582,8 +711,16 @@ class _Parser(
|
|||
newLabels: List.unmodifiable(_newLabels),
|
||||
priority: _priority,
|
||||
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,
|
||||
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
|
||||
/// 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
|
||||
/// 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.
|
||||
final class const QuickAddWords({
|
||||
/// Today, and the words for it.
|
||||
|
|
@ -49,6 +50,15 @@ final class const QuickAddWords({
|
|||
/// What may stand before a time: `at 7pm`.
|
||||
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`.
|
||||
required final Set<String> and,
|
||||
|
||||
|
|
@ -154,6 +164,20 @@ final class const QuickAddWords({
|
|||
next: {'next'},
|
||||
inWords: {'in'},
|
||||
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', '&'},
|
||||
meridiem: {'am': 0, 'pm': 12},
|
||||
daySuffixes: {'st', 'nd', 'rd', 'th'},
|
||||
|
|
@ -235,6 +259,17 @@ final class const QuickAddWords({
|
|||
next: {'nächsten', 'nächste', 'nächster', 'kommenden', 'kommende'},
|
||||
inWords: {'in'},
|
||||
at: {'um'},
|
||||
on: {'am'},
|
||||
forWords: {'für'},
|
||||
durationUnits: {
|
||||
'h': 60,
|
||||
'std': 60,
|
||||
'stunde': 60,
|
||||
'stunden': 60,
|
||||
'min': 1,
|
||||
'minute': 1,
|
||||
'minuten': 1,
|
||||
},
|
||||
and: {'und'},
|
||||
oClock: {'uhr'},
|
||||
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
|
||||
/// 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`,
|
||||
/// `@label`, `{deadline}`, `!!1`, `p1`, `!30m`, dates, times and
|
||||
/// recurrences in the app's language and English, each highlighted and
|
||||
/// `@label`, `{deadline}`, `!!1`, `p1`, `!30m` (as many as typed), `!1pm`,
|
||||
/// 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
|
||||
/// 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
|
||||
|
|
@ -320,6 +321,7 @@ class _QuickAddState() extends State<_QuickAdd> {
|
|||
priority: parsed.priority ?? _priority,
|
||||
labelIds: {..._labelIds, ...parsed.labelIds, ...made}.toList(),
|
||||
reminders: parsed.reminders,
|
||||
reminderTimes: parsed.reminderTimes,
|
||||
),
|
||||
);
|
||||
} on KeysMissing {
|
||||
|
|
@ -473,6 +475,7 @@ class _QuickAddState() extends State<_QuickAdd> {
|
|||
QuickAddKind.date,
|
||||
QuickAddKind.time,
|
||||
QuickAddKind.repeat,
|
||||
QuickAddKind.duration,
|
||||
QuickAddKind.reminder,
|
||||
});
|
||||
setState(() => _date = date);
|
||||
|
|
|
|||
Loading…
Reference in a new issue