mnemosyne/lib/Mnemosyne/Task.pm

221 lines
7.6 KiB
Perl
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package Mnemosyne::Task;
use strict;
use warnings;
use Carp qw(croak);
# Class-level CRUD for the tasks table.
# All methods take $dbh (a DBI handle) as their first non-class argument so
# they can be called from any context without needing a full DB object.
# -----------------------------------------------------------------------
# Validation tables
# -----------------------------------------------------------------------
my %REQUIRED_FIELDS = (
monthly_date => [qw(day_of_month)],
monthly_weekday => [qw(weekday ordinal)],
every_n_period => [qw(interval_n period_unit anchor_date)],
interval => [qw(interval_days)],
floating => [qw(priority)],
);
my %VALID_PRIORITY = map { $_ => 1 } qw(high medium low);
my %VALID_UNIT = map { $_ => 1 } qw(day week month);
# -----------------------------------------------------------------------
# Public interface
# -----------------------------------------------------------------------
# create($class, $dbh, \%fields) → task hashref or dies
sub create {
my ($class, $dbh, $f) = @_;
my $tc = $f->{class} or croak "class is required";
my $title = $f->{title} // '';
$title =~ s/^\s+|\s+$//g;
croak "title is required" unless length $title;
croak "Unknown task class: $tc" unless exists $REQUIRED_FIELDS{$tc};
for my $field (@{ $REQUIRED_FIELDS{$tc} }) {
croak "Field '$field' required for $tc"
unless defined $f->{$field} && length $f->{$field};
}
_validate_class_fields($tc, $f);
$dbh->do(q{
INSERT INTO tasks
(title, notes, class, active,
day_of_month, weekday, ordinal,
interval_n, period_unit, anchor_date,
interval_days, priority)
VALUES (?,?,?,?, ?,?,?, ?,?,?, ?,?)
}, undef,
$title,
$f->{notes} // '',
$tc,
exists $f->{active} ? ($f->{active} ? 1 : 0) : 1,
$f->{day_of_month}, $f->{weekday}, $f->{ordinal},
$f->{interval_n}, $f->{period_unit}, $f->{anchor_date},
$f->{interval_days}, $f->{priority},
);
return get($class, $dbh, $dbh->last_insert_id(undef, undef, 'tasks', undef));
}
# get($class, $dbh, $id) → hashref or undef
sub get {
my ($class, $dbh, $id) = @_;
return $dbh->selectrow_hashref('SELECT * FROM tasks WHERE id = ?', undef, $id);
}
# list($class, $dbh, %filters) → arrayref of hashrefs
# Filters: active (default 1), class, priority
sub list {
my ($class, $dbh, %f) = @_;
my (@where, @bind);
my $active = exists $f{active} ? ($f{active} ? 1 : 0) : 1;
push @where, 'active = ?'; push @bind, $active;
if (defined $f{class}) { push @where, 'class = ?'; push @bind, $f{class} }
if (defined $f{priority}) { push @where, 'priority = ?'; push @bind, $f{priority} }
my $sql = 'SELECT * FROM tasks WHERE ' . join(' AND ', @where) . ' ORDER BY title';
return $dbh->selectall_arrayref($sql, { Slice => {} }, @bind);
}
# update($class, $dbh, $id, \%fields) → updated task hashref or undef if not found
# Only columns present in %fields are changed; updated_at is always refreshed.
sub update {
my ($class, $dbh, $id, $f) = @_;
my @cols = grep { exists $f->{$_} } qw(
title notes active
day_of_month weekday ordinal
interval_n period_unit anchor_date
interval_days priority
);
return unless @cols;
if (exists $f->{title}) {
$f->{title} =~ s/^\s+|\s+$//g;
croak "title cannot be empty" unless length $f->{title};
}
my $set = join(', ', map { "$_ = ?" } @cols)
. ", updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now')";
$dbh->do("UPDATE tasks SET $set WHERE id = ?", undef, @{$f}{@cols}, $id);
return get($class, $dbh, $id);
}
# delete($class, $dbh, $id) — hard delete; completions cascade
sub delete { ## no critic (Subroutines::ProhibitBuiltinHomonyms)
my ($class, $dbh, $id) = @_;
$dbh->do('DELETE FROM tasks WHERE id = ?', undef, $id);
}
# disable($class, $dbh, $id) — set active=0
sub disable {
my ($class, $dbh, $id) = @_;
$dbh->do(
"UPDATE tasks SET active=0, updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id=?",
undef, $id
);
}
# complete($class, $dbh, $id) → completion_id or undef
# For floating tasks, also sets active=0 (archives the task).
sub complete {
my ($class, $dbh, $id) = @_;
my $task = get($class, $dbh, $id);
return undef unless $task;
$dbh->do('INSERT INTO completions (task_id) VALUES (?)', undef, $id);
my $cid = $dbh->last_insert_id(undef, undef, 'completions', undef);
disable($class, $dbh, $id) if $task->{class} eq 'floating';
return $cid;
}
# undo_completion($class, $dbh, $completion_id)
# Removes one completion row. If it was a floating task's only completion
# (which archived it), re-activates the task.
sub undo_completion {
my ($class, $dbh, $cid) = @_;
my ($task_id) = $dbh->selectrow_array(
'SELECT task_id FROM completions WHERE id = ?', undef, $cid
);
return unless $task_id;
$dbh->do('DELETE FROM completions WHERE id = ?', undef, $cid);
# Re-activate an archived floating task if it now has no completions
my $task = get($class, $dbh, $task_id);
if ($task && $task->{class} eq 'floating' && !$task->{active}) {
my ($remaining) = $dbh->selectrow_array(
'SELECT COUNT(*) FROM completions WHERE task_id = ?', undef, $task_id
);
if ($remaining == 0) {
$dbh->do(
"UPDATE tasks SET active=1, updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id=?",
undef, $task_id
);
}
}
}
# last_completion($class, $dbh, $task_id) → hashref (id, task_id, completed_at) or undef
sub last_completion {
my ($class, $dbh, $id) = @_;
return $dbh->selectrow_hashref(
'SELECT * FROM completions WHERE task_id = ? ORDER BY completed_at DESC LIMIT 1',
undef, $id
);
}
# search($class, $dbh, $query) → arrayref of active task hashrefs whose title
# contains $query (case-insensitive).
sub search {
my ($class, $dbh, $q) = @_;
return $dbh->selectall_arrayref(
"SELECT * FROM tasks WHERE active=1 AND LOWER(title) LIKE ? ORDER BY title",
{ Slice => {} }, '%' . lc($q) . '%'
);
}
# -----------------------------------------------------------------------
# Internal validation
# -----------------------------------------------------------------------
sub _validate_class_fields {
my ($tc, $f) = @_;
if ($tc eq 'monthly_date') {
my $d = $f->{day_of_month};
croak "day_of_month must be 131" unless $d =~ /^\d+$/ && $d >= 1 && $d <= 31;
} elsif ($tc eq 'monthly_weekday') {
my ($wd, $ord) = ($f->{weekday}, $f->{ordinal});
croak "weekday must be 17" unless $wd =~ /^\d+$/ && $wd >= 1 && $wd <= 7;
croak "ordinal must be 14 or -1" unless $ord =~ /^-?\d+$/ && ($ord == -1 || ($ord >= 1 && $ord <= 4));
} elsif ($tc eq 'every_n_period') {
croak "interval_n must be a positive integer"
unless $f->{interval_n} =~ /^\d+$/ && $f->{interval_n} > 0;
croak "period_unit must be day/week/month"
unless $VALID_UNIT{ $f->{period_unit} };
croak "anchor_date must be YYYY-MM-DD"
unless $f->{anchor_date} =~ /^\d{4}-\d{2}-\d{2}$/;
} elsif ($tc eq 'interval') {
croak "interval_days must be a positive integer"
unless $f->{interval_days} =~ /^\d+$/ && $f->{interval_days} > 0;
} elsif ($tc eq 'floating') {
croak "priority must be high/medium/low"
unless $VALID_PRIORITY{ $f->{priority} };
}
}
1;