package Mnemosyne::Webhook;
use strict;
use warnings;
use utf8;
use DateTime;
use Mojo::JSON qw(encode_json);
use Mnemosyne::Task;
use Mnemosyne::Digest;
use Mnemosyne::Schedule;
use Mnemosyne::Telegram;
use Carp qw(croak);
# In-memory wizard sessions, keyed by chat_id.
# Lost on restart — documented trade-off; the wizard is short-lived.
# Structure: { step => '...', class => '...', data => {}, wizard_msg_id => $id }
my %SESSIONS;
# -----------------------------------------------------------------------
# Main entry point (called by mnemosyne-bot for every inbound update)
# -----------------------------------------------------------------------
# handle_update($class, $update, $db, $config, $telegram) → nothing
# Caller has already validated the secret-token header.
sub handle_update {
my ($class, $update, $db, $config, $telegram) = @_;
my $chat_id = _chat_id($update) or return;
# Gate 2: chat whitelist
unless (_allowed($chat_id, $config)) {
# Respond 200 to Telegram so it stops retrying, but do nothing.
return;
}
warn "DBG full_update: " . encode_json($update) . "\n";
# callback_query is checked first: Telegram occasionally sends a stub
# top-level "message" key alongside callback_query updates, so existence
# of "message" alone is not a reliable discriminator.
my $upd_type = exists $update->{callback_query} ? 'callback_query'
: exists $update->{message} ? 'message'
: 'unknown';
warn "DBG update type=$upd_type chat=$chat_id\n";
if (my $msg = $update->{message}) {
_handle_message($msg, $chat_id, $db, $config, $telegram);
} elsif (my $cbq = $update->{callback_query}) {
_handle_callback($cbq, $chat_id, $db, $config, $telegram);
}
}
# -----------------------------------------------------------------------
# Message routing
# -----------------------------------------------------------------------
sub _handle_message {
my ($msg, $chat_id, $db, $config, $telegram) = @_;
my $text = $msg->{text} // '';
# Non-text messages (photos, stickers, voice, accidental empty sends, etc.)
return unless length $text;
# Strip bot username suffix from commands (e.g. /help@MnemosyneBot)
$text =~ s{^(/\w+)@\w+}{$1};
# Wizard in progress — /cancel always escapes; other commands are blocked
if (exists $SESSIONS{$chat_id} && $SESSIONS{$chat_id}{step} ne 'done') {
warn "DBG handle_message_in_session: step=$SESSIONS{$chat_id}{step} text=" . substr($text, 0, 60) . "\n";
if ($text =~ /^\/cancel/i) {
_cmd_cancel($chat_id, $telegram);
} elsif ($text =~ /^\//) {
$telegram->send_message($chat_id,
"A task wizard is in progress. Use /cancel to abort it first.");
} else {
_wizard_text($text, $chat_id, $msg, $db, $config, $telegram);
}
return;
}
my ($cmd, $args) = $text =~ /^(\/\w+)\s*(.*)/s ? ($1, $2) : ('', $text);
if ($cmd eq '/today' || $cmd eq '/glance') { _cmd_today ($chat_id, $db, $config, $telegram) }
elsif ($cmd eq '/list') { _cmd_list ($chat_id, $args, $db, $config, $telegram) }
elsif ($cmd eq '/add') { _cmd_add ($chat_id, $args, $db, $config, $telegram) }
elsif ($cmd eq '/done') { _cmd_done ($chat_id, $args, $db, $config, $telegram) }
elsif ($cmd eq '/edit') { _cmd_edit ($chat_id, $args, $db, $config, $telegram) }
elsif ($cmd eq '/disable') { _cmd_disable($chat_id, $args, $db, $config, $telegram) }
elsif ($cmd eq '/delete') { _cmd_delete ($chat_id, $args, $db, $config, $telegram) }
elsif ($cmd eq '/settime') { _cmd_settime($chat_id, $args, $db, $config, $telegram) }
elsif ($cmd eq '/cancel') { _cmd_cancel ($chat_id, $telegram) }
elsif ($cmd eq '/help') { _cmd_help ($chat_id, $telegram) }
elsif ($cmd) { $telegram->send_message($chat_id, "Unknown command. /help for the list.") }
}
# -----------------------------------------------------------------------
# Callback routing
# -----------------------------------------------------------------------
sub _handle_callback {
my ($cbq, $chat_id, $db, $config, $telegram) = @_;
my $data = $cbq->{data} // '';
my $cbq_id = $cbq->{id};
warn "DBG handle_callback: data=$data\n";
my $msg = $cbq->{message} // {};
my $msg_id = $msg->{message_id};
if ($data =~ /^done:(\d+)$/) {
_cb_mark_done($1, $cbq_id, $chat_id, $msg_id, $msg, $db, $telegram);
} elsif ($data =~ /^undo:(\d+)$/) {
_cb_undo($1, $cbq_id, $chat_id, $msg_id, $msg, $db, $telegram);
} elsif ($data =~ /^confirm_delete:(\d+)$/) {
_cb_confirm_delete($1, $cbq_id, $chat_id, $msg_id, $db, $telegram);
} elsif ($data eq 'cancel') {
$telegram->answer_callback_query($cbq_id);
$telegram->edit_reply_markup($chat_id, $msg_id, undef);
} elsif ($data =~ /^add_/) {
_wizard_callback($data, $cbq_id, $chat_id, $msg_id, $db, $config, $telegram);
} else {
$telegram->answer_callback_query($cbq_id, text => 'Unknown action');
}
}
# -----------------------------------------------------------------------
# Command handlers
# -----------------------------------------------------------------------
sub _cmd_today {
my ($chat_id, $db, $config, $telegram) = @_;
my $today = _today($db);
my $digest = Mnemosyne::Digest->build($db, $today);
my $parts = Mnemosyne::Digest->render_telegram($digest);
for my $part (@$parts) {
my $kb = @{ $part->{tasks} }
? Mnemosyne::Telegram->mark_done_keyboard($part->{tasks})
: undef;
$telegram->send_message($chat_id, $part->{text},
reply_markup => $kb);
}
}
sub _cmd_list {
my ($chat_id, $args, $db, $config, $telegram) = @_;
$args =~ s/^\s+|\s+$//g;
my %filters;
if ($args =~ /^(monthly_date|monthly_weekday|every_n_period|interval|floating)$/i) {
$filters{class} = lc $args;
} elsif ($args =~ /^(high|medium|low)$/i) {
$filters{priority} = lc $args;
} elsif ($args =~ /^(inactive|all)$/i) {
$filters{active} = 0;
}
my $dbh = $db->dbh;
my $tasks = Mnemosyne::Task->list($dbh, %filters);
unless (@$tasks) {
$telegram->send_message($chat_id, "No tasks found.");
return;
}
my $today = _today($db);
my $text = "Tasks" . ($args ? " ($args)" : '') . "\n\n";
my @actionable;
for my $task (@$tasks) {
my $r = Mnemosyne::Schedule->resolve($task, $today);
my $st = $r->{status};
my $icon = $st eq 'overdue' ? "\x{1F534}"
: $st eq 'due' ? "\x{1F4C5}"
: $st eq 'upcoming' ? "\x{1F440}"
: "\x{2022}";
$text .= "$icon " . _esc($task->{title}) . "";
$text .= " (id $task->{id})\n";
push @actionable, { id => $task->{id}, title => $task->{title} }
if $task->{active} && $st ne 'not_relevant' && $st ne 'inactive';
}
my $kb = @actionable ? Mnemosyne::Telegram->mark_done_keyboard(\@actionable) : undef;
$telegram->send_message($chat_id, $text, reply_markup => $kb);
}
sub _cmd_add {
my ($chat_id, $args, $db, $config, $telegram) = @_;
# Clear any stale session
delete $SESSIONS{$chat_id};
$SESSIONS{$chat_id} = { step => 'await_title', data => {} };
my $res = $telegram->send_message($chat_id,
"New task — what's the title?",
reply_markup => Mnemosyne::Telegram->skip_keyboard());
$SESSIONS{$chat_id}{wizard_msg_id} = $res->{result}{message_id};
}
sub _cmd_done {
my ($chat_id, $args, $db, $config, $telegram) = @_;
$args =~ s/^\s+|\s+$//g;
my $dbh = $db->dbh;
my $task;
if ($args =~ /^\d+$/) {
$task = Mnemosyne::Task->get($dbh, $args);
} else {
my $results = Mnemosyne::Task->search($dbh, $args);
if (@$results == 1) {
$task = $results->[0];
} elsif (@$results > 1) {
my $list = join("\n", map { "• " . _esc($_->{title}) . " (id $_->{id})" } @$results);
$telegram->send_message($chat_id,
"Multiple matches — use /done :\n\n$list");
return;
}
}
unless ($task) {
$telegram->send_message($chat_id, "Task not found: $args");
return;
}
my $cid = Mnemosyne::Task->complete($dbh, $task->{id});
$telegram->send_message($chat_id,
"\x{2714} Done: " . _esc($task->{title}) . "",
reply_markup => Mnemosyne::Telegram->undo_keyboard($cid));
}
sub _cmd_edit {
my ($chat_id, $args, $db, $config, $telegram) = @_;
$args =~ s/^\s+|\s+$//g;
unless ($args =~ /^(\d+)\s+(\w+)\s+(.+)$/s) {
$telegram->send_message($chat_id,
"Usage: /edit \n"
. "Fields: title, notes, day_of_month, weekday, ordinal, "
. "interval_n, period_unit, anchor_date, interval_days, priority");
return;
}
my ($id, $field, $value) = ($1, $2, $3);
$value =~ s/^\s+|\s+$//g;
my $dbh = $db->dbh;
my $updated = eval { Mnemosyne::Task->update($dbh, $id, { $field => $value }) };
if ($@) {
$telegram->send_message($chat_id, "Error: " . _esc($@));
return;
}
unless ($updated) {
$telegram->send_message($chat_id, "Task $id not found.");
return;
}
$telegram->send_message($chat_id,
"\x{2714} Updated " . _esc($updated->{title}) . "");
}
sub _cmd_disable {
my ($chat_id, $args, $db, $config, $telegram) = @_;
$args =~ s/^\s+|\s+$//g;
my $dbh = $db->dbh;
my $task = _resolve_task($dbh, $args);
unless ($task) {
$telegram->send_message($chat_id, "Task not found: $args");
return;
}
Mnemosyne::Task->disable($dbh, $task->{id});
$telegram->send_message($chat_id,
"\x{1F6AB} Disabled: " . _esc($task->{title}) . "");
}
sub _cmd_delete {
my ($chat_id, $args, $db, $config, $telegram) = @_;
$args =~ s/^\s+|\s+$//g;
my $dbh = $db->dbh;
my $task = _resolve_task($dbh, $args);
unless ($task) {
$telegram->send_message($chat_id, "Task not found: $args");
return;
}
$telegram->send_message($chat_id,
"Delete " . _esc($task->{title}) . "?\nThis cannot be undone.",
reply_markup => Mnemosyne::Telegram->confirm_delete_keyboard($task->{id}));
}
sub _cmd_settime {
my ($chat_id, $args, $db, $config, $telegram) = @_;
$args =~ s/^\s+|\s+$//g;
unless ($args =~ /^([01]\d|2[0-3]):([0-5]\d)$/) {
$telegram->send_message($chat_id, "Usage: /settime HH:MM (24-hour)");
return;
}
$db->config_set('digest_time', $args);
$telegram->send_message($chat_id, "\x{23F0} Digest time set to $args.");
}
sub _cmd_cancel {
my ($chat_id, $telegram) = @_;
if (delete $SESSIONS{$chat_id}) {
$telegram->send_message($chat_id, "Cancelled.");
} else {
$telegram->send_message($chat_id, "Nothing in progress.");
}
}
sub _cmd_help {
my ($chat_id, $telegram) = @_;
$telegram->send_message($chat_id, <<'END');
Mnemosyne commands
/today — Day at a Glance digest
/list [class|priority|inactive] — browse tasks
/add — create a new task (guided)
/done <id|title> — mark a task complete
/edit <id> <field> <value> — update a field
/disable <id|title> — hide without deleting
/delete <id|title> — permanently delete
/settime HH:MM — change digest delivery time
/cancel — cancel in-progress wizard
/help — this message
END
}
# -----------------------------------------------------------------------
# Callback handlers
# -----------------------------------------------------------------------
sub _cb_mark_done {
my ($task_id, $cbq_id, $chat_id, $msg_id, $msg, $db, $telegram) = @_;
my $task = Mnemosyne::Task->get($db->dbh, $task_id);
unless ($task) {
$telegram->answer_callback_query($cbq_id, text => 'Task not found');
return;
}
my $cid = Mnemosyne::Task->complete($db->dbh, $task_id);
$telegram->answer_callback_query($cbq_id, text => "\x{2714} Done");
# Replace just the tapped button with an Undo button
my $new_kb = _swap_done_button($msg->{reply_markup}, $task_id, $cid);
$telegram->edit_reply_markup($chat_id, $msg_id, $new_kb);
}
sub _cb_undo {
my ($cid, $cbq_id, $chat_id, $msg_id, $msg, $db, $telegram) = @_;
Mnemosyne::Task->undo_completion($db->dbh, $cid);
$telegram->answer_callback_query($cbq_id, text => "\x{21A9} Undone");
# Restore the original Mark Done button for the task
my ($task_id) = $db->dbh->selectrow_array(
'SELECT task_id FROM completions WHERE id = ?', undef, $cid
);
# completion is gone at this point; we reconstruct from the button label
my $new_kb = _swap_undo_button($msg->{reply_markup}, $cid, $task_id);
$telegram->edit_reply_markup($chat_id, $msg_id, $new_kb);
}
sub _cb_confirm_delete {
my ($task_id, $cbq_id, $chat_id, $msg_id, $db, $telegram) = @_;
my $task = Mnemosyne::Task->get($db->dbh, $task_id);
Mnemosyne::Task->delete($db->dbh, $task_id);
$telegram->answer_callback_query($cbq_id, text => 'Deleted');
my $title = $task ? _esc($task->{title}) : "task $task_id";
$telegram->edit_message($chat_id, $msg_id,
"\x{1F5D1} Deleted: $title",
reply_markup => undef);
}
# -----------------------------------------------------------------------
# /add wizard — text input steps
# -----------------------------------------------------------------------
sub _wizard_text {
my ($text, $chat_id, $msg, $db, $config, $telegram) = @_;
my $sess = $SESSIONS{$chat_id} or return;
my $step = $sess->{step};
my $wmid = $sess->{wizard_msg_id};
if ($step eq 'await_title') {
$text =~ s/^\s+|\s+$//g;
unless (length $text) {
$telegram->send_message($chat_id, "Title can't be empty. Try again.");
return;
}
$sess->{data}{title} = $text;
$sess->{step} = 'await_class';
$telegram->edit_message($chat_id, $wmid,
"Task: " . _esc($text) . "\nChoose a class:",
reply_markup => Mnemosyne::Telegram->class_keyboard());
} elsif ($step eq 'await_dom') {
# day_of_month text input
$text =~ s/^\s+|\s+$//g;
unless ($text =~ /^\d+$/ && $text >= 1 && $text <= 31) {
$telegram->send_message($chat_id, "Enter a number 1–31.");
return;
}
$sess->{data}{day_of_month} = $text + 0;
_wizard_ask_notes($chat_id, $wmid, $sess, $telegram);
} elsif ($step eq 'await_interval_n') {
$text =~ s/^\s+|\s+$//g;
unless ($text =~ /^\d+$/ && $text > 0) {
$telegram->send_message($chat_id, "Enter a positive integer.");
return;
}
$sess->{data}{interval_n} = $text + 0;
$sess->{step} = 'await_unit';
$telegram->edit_message($chat_id, $wmid,
_wizard_summary($sess) . "\nChoose a period unit:",
reply_markup => Mnemosyne::Telegram->period_unit_keyboard());
} elsif ($step eq 'await_anchor') {
$text =~ s/^\s+|\s+$//g;
unless ($text =~ /^\d{4}-\d{2}-\d{2}$/) {
$telegram->send_message($chat_id, "Use YYYY-MM-DD format.");
return;
}
$sess->{data}{anchor_date} = $text;
_wizard_ask_notes($chat_id, $wmid, $sess, $telegram);
} elsif ($step eq 'await_interval_days') {
$text =~ s/^\s+|\s+$//g;
unless ($text =~ /^\d+$/ && $text > 0) {
$telegram->send_message($chat_id, "Enter a positive integer (days).");
return;
}
$sess->{data}{interval_days} = $text + 0;
_wizard_ask_notes($chat_id, $wmid, $sess, $telegram);
} elsif ($step eq 'await_notes') {
$text =~ s/^\s+|\s+$//g;
$sess->{data}{notes} = $text if length $text;
_wizard_confirm($chat_id, $wmid, $sess, $telegram);
} else {
# Button-only steps (await_class, await_weekday, etc.) land here if
# the user types instead of tapping. Prompt them back to the buttons.
$telegram->send_message($chat_id,
"Please use the buttons above to continue, or /cancel to abort.");
}
}
# -----------------------------------------------------------------------
# /add wizard — callback steps
# -----------------------------------------------------------------------
sub _wizard_callback {
my ($data, $cbq_id, $chat_id, $msg_id, $db, $config, $telegram) = @_;
my $sess = $SESSIONS{$chat_id};
warn "DBG wizard_callback: data=$data sess=" . ($sess ? $sess->{step} : 'NONE') . "\n";
# If no session but wizard callback arrives, ignore gracefully
unless ($sess) {
$telegram->answer_callback_query($cbq_id, text => 'Session expired. Use /add to start again.');
return;
}
$telegram->answer_callback_query($cbq_id);
my $wmid = $sess->{wizard_msg_id} // $msg_id;
if ($data =~ /^add_class:(.+)$/) {
my $tc = $1;
$sess->{class} = $tc;
$sess->{data}{class} = $tc;
_wizard_next_after_class($tc, $chat_id, $wmid, $sess, $telegram);
} elsif ($data =~ /^add_weekday:(\d)$/) {
$sess->{data}{weekday} = $1 + 0;
$sess->{step} = 'await_ordinal';
$telegram->edit_message($chat_id, $wmid,
_wizard_summary($sess) . "\nWhich occurrence of the month?",
reply_markup => Mnemosyne::Telegram->ordinal_keyboard());
} elsif ($data =~ /^add_ordinal:(-?\d+)$/) {
$sess->{data}{ordinal} = $1 + 0;
_wizard_ask_notes($chat_id, $wmid, $sess, $telegram);
} elsif ($data =~ /^add_unit:(\w+)$/) {
$sess->{data}{period_unit} = $1;
$sess->{step} = 'await_anchor';
$telegram->edit_message($chat_id, $wmid,
_wizard_summary($sess) . "\nEnter an anchor date (YYYY-MM-DD):",
reply_markup => Mnemosyne::Telegram->skip_keyboard());
} elsif ($data =~ /^add_priority:(\w+)$/) {
$sess->{data}{priority} = $1;
_wizard_ask_notes($chat_id, $wmid, $sess, $telegram);
} elsif ($data eq 'add_skip') {
_wizard_handle_skip($chat_id, $wmid, $sess, $telegram);
} elsif ($data eq 'add_save') {
_wizard_save($chat_id, $wmid, $sess, $db, $telegram);
} elsif ($data eq 'add_cancel') {
delete $SESSIONS{$chat_id};
$telegram->edit_message($chat_id, $wmid, "Cancelled.", reply_markup => undef);
}
}
sub _wizard_next_after_class {
my ($tc, $chat_id, $wmid, $sess, $telegram) = @_;
if ($tc eq 'monthly_date') {
$sess->{step} = 'await_dom';
$telegram->edit_message($chat_id, $wmid,
_wizard_summary($sess) . "\nWhich day of the month? (1–31):",
reply_markup => Mnemosyne::Telegram->skip_keyboard());
} elsif ($tc eq 'monthly_weekday') {
$sess->{step} = 'await_weekday';
$telegram->edit_message($chat_id, $wmid,
_wizard_summary($sess) . "\nWhich weekday?",
reply_markup => Mnemosyne::Telegram->weekday_keyboard());
} elsif ($tc eq 'every_n_period') {
$sess->{step} = 'await_interval_n';
$telegram->edit_message($chat_id, $wmid,
_wizard_summary($sess) . "\nEvery how many periods? (e.g. 2):",
reply_markup => Mnemosyne::Telegram->skip_keyboard());
} elsif ($tc eq 'interval') {
$sess->{step} = 'await_interval_days';
$telegram->edit_message($chat_id, $wmid,
_wizard_summary($sess) . "\nRepeat every how many days?",
reply_markup => Mnemosyne::Telegram->skip_keyboard());
} elsif ($tc eq 'floating') {
$sess->{step} = 'await_priority';
$telegram->edit_message($chat_id, $wmid,
_wizard_summary($sess) . "\nChoose a priority:",
reply_markup => Mnemosyne::Telegram->priority_keyboard());
}
}
sub _wizard_handle_skip {
my ($chat_id, $wmid, $sess, $telegram) = @_;
my $step = $sess->{step};
if ($step eq 'await_title') {
$telegram->send_message($chat_id, "Title is required — can't skip.");
} elsif ($step eq 'await_notes') {
_wizard_confirm($chat_id, $wmid, $sess, $telegram);
} else {
$telegram->send_message($chat_id, "Can't skip this step.");
}
}
sub _wizard_ask_notes {
my ($chat_id, $wmid, $sess, $telegram) = @_;
$sess->{step} = 'await_notes';
$telegram->edit_message($chat_id, $wmid,
_wizard_summary($sess) . "\nAny notes? (optional)",
reply_markup => Mnemosyne::Telegram->skip_keyboard());
}
sub _wizard_confirm {
my ($chat_id, $wmid, $sess, $telegram) = @_;
$sess->{step} = 'await_confirm';
$telegram->edit_message($chat_id, $wmid,
_wizard_summary($sess) . "\n\nSave this task?",
reply_markup => Mnemosyne::Telegram->save_cancel_keyboard());
}
sub _wizard_save {
my ($chat_id, $wmid, $sess, $db, $telegram) = @_;
my $data = $sess->{data};
my $task = eval { Mnemosyne::Task->create($db->dbh, $data) };
if ($@) {
(my $err = $@) =~ s/ at .*//s;
$telegram->edit_message($chat_id, $wmid,
"\x{274C} Error: " . _esc($err) . "\n\n/add to try again.",
reply_markup => undef);
delete $SESSIONS{$chat_id};
return;
}
delete $SESSIONS{$chat_id};
$telegram->edit_message($chat_id, $wmid,
"\x{2714} Created: " . _esc($task->{title}) . " (id $task->{id})",
reply_markup => undef);
}
# Summary line shown at the top of each wizard edit
sub _wizard_summary {
my ($sess) = @_;
my $d = $sess->{data};
my $s = "New task\n";
$s .= "Title: " . _esc($d->{title}) . "\n" if $d->{title};
$s .= "Class: $d->{class}\n" if $d->{class};
return $s;
}
# -----------------------------------------------------------------------
# Keyboard reconstruction helpers
# -----------------------------------------------------------------------
# Replace a done: button with undo: in the existing keyboard
sub _swap_done_button {
my ($markup, $task_id, $cid) = @_;
return undef unless $markup && $markup->{inline_keyboard};
my @new_rows;
for my $row (@{ $markup->{inline_keyboard} }) {
my @new_row;
for my $btn (@$row) {
if (($btn->{callback_data} // '') eq "done:$task_id") {
push @new_row, {
text => "\x{2714} Done",
callback_data => "undo:$cid",
};
} else {
push @new_row, $btn;
}
}
push @new_rows, \@new_row;
}
return { inline_keyboard => \@new_rows };
}
# Replace an undo: button with done:
sub _swap_undo_button {
my ($markup, $cid, $task_id) = @_;
return undef unless $markup && $markup->{inline_keyboard};
# If we don't know the task_id (completion already deleted), remove the button
my @new_rows;
for my $row (@{ $markup->{inline_keyboard} }) {
my @new_row;
for my $btn (@$row) {
if (($btn->{callback_data} // '') eq "undo:$cid") {
next unless defined $task_id;
push @new_row, {
text => "\x{2713} Mark done",
callback_data => "done:$task_id",
};
} else {
push @new_row, $btn;
}
}
push @new_rows, \@new_row if @new_row;
}
return { inline_keyboard => \@new_rows };
}
# -----------------------------------------------------------------------
# Utilities
# -----------------------------------------------------------------------
sub _chat_id {
my ($update) = @_;
return $update->{message}{chat}{id}
// $update->{callback_query}{message}{chat}{id}
// undef;
}
sub _allowed {
my ($chat_id, $config) = @_;
my %allowed = map { $_ => 1 } $config->allowed_chat_ids;
return $allowed{$chat_id};
}
sub _today {
my ($db) = @_;
my $tz = $db->config_get('timezone') // 'UTC';
my $now = eval { DateTime->now(time_zone => $tz) } // DateTime->now;
return $now->truncate(to => 'day');
}
sub _resolve_task {
my ($dbh, $arg) = @_;
if ($arg =~ /^\d+$/) {
return Mnemosyne::Task->get($dbh, $arg);
}
my $results = Mnemosyne::Task->search($dbh, $arg);
return @$results == 1 ? $results->[0] : undef;
}
sub _esc {
my ($s) = @_;
$s =~ s/&/&/g;
$s =~ s/</g;
$s =~ s/>/>/g;
return $s;
}
1;