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;