221 lines
7.6 KiB
Perl
221 lines
7.6 KiB
Perl
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 1–31" unless $d =~ /^\d+$/ && $d >= 1 && $d <= 31;
|
||
|
||
} elsif ($tc eq 'monthly_weekday') {
|
||
my ($wd, $ord) = ($f->{weekday}, $f->{ordinal});
|
||
croak "weekday must be 1–7" unless $wd =~ /^\d+$/ && $wd >= 1 && $wd <= 7;
|
||
croak "ordinal must be 1–4 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;
|