161 lines
4.9 KiB
Perl
161 lines
4.9 KiB
Perl
package Mnemosyne::DB;
|
|
use strict;
|
|
use warnings;
|
|
use DBI;
|
|
use File::Basename qw(dirname);
|
|
use File::Spec;
|
|
use Carp qw(croak);
|
|
|
|
# Opens (and initialises if new) the Mnemosyne SQLite database.
|
|
# Usage:
|
|
# my $db = Mnemosyne::DB->new('/var/lib/mnemosyne/mnemosyne.db');
|
|
# my $dbh = $db->dbh;
|
|
|
|
sub new {
|
|
my ($class, $path) = @_;
|
|
croak "Database path required" unless defined $path;
|
|
|
|
# Ensure parent directory exists
|
|
my $dir = dirname($path);
|
|
unless (-d $dir) {
|
|
require File::Path;
|
|
File::Path::make_path($dir) or croak "Cannot create DB directory '$dir': $!";
|
|
}
|
|
|
|
my $dbh = DBI->connect(
|
|
"dbi:SQLite:dbname=$path", '', '',
|
|
{
|
|
RaiseError => 1,
|
|
AutoCommit => 1,
|
|
sqlite_unicode => 1,
|
|
}
|
|
) or croak "Cannot connect to SQLite '$path': " . $DBI::errstr;
|
|
|
|
my $self = bless { _dbh => $dbh, _path => $path }, $class;
|
|
$self->_configure;
|
|
$self->_apply_schema;
|
|
return $self;
|
|
}
|
|
|
|
sub dbh { $_[0]->{_dbh} }
|
|
|
|
sub _configure {
|
|
my ($self) = @_;
|
|
my $dbh = $self->{_dbh};
|
|
$dbh->do('PRAGMA foreign_keys = ON');
|
|
$dbh->do('PRAGMA journal_mode = WAL');
|
|
$dbh->do('PRAGMA synchronous = NORMAL'); # safe with WAL, faster than FULL
|
|
}
|
|
|
|
sub _apply_schema {
|
|
my ($self) = @_;
|
|
|
|
# Schema DDL is kept here alongside the canonical share/schema.sql.
|
|
# share/schema.sql is the human-readable reference; this is what runs at startup.
|
|
# Both must be kept in sync when the schema changes.
|
|
my @statements = (
|
|
q{
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
notes TEXT,
|
|
class TEXT NOT NULL
|
|
CHECK(class IN ('monthly_date','monthly_weekday',
|
|
'every_n_period','interval','floating')),
|
|
active INTEGER NOT NULL DEFAULT 1,
|
|
day_of_month INTEGER,
|
|
weekday INTEGER,
|
|
ordinal INTEGER,
|
|
interval_n INTEGER,
|
|
period_unit TEXT CHECK(period_unit IN ('day','week','month') OR period_unit IS NULL),
|
|
anchor_date TEXT,
|
|
interval_days INTEGER,
|
|
priority TEXT CHECK(priority IN ('high','medium','low') OR priority IS NULL),
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
)},
|
|
q{
|
|
CREATE TABLE IF NOT EXISTS completions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
completed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
)},
|
|
q{CREATE INDEX IF NOT EXISTS idx_completions_task_id ON completions(task_id)},
|
|
q{
|
|
CREATE TABLE IF NOT EXISTS config (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
)},
|
|
);
|
|
|
|
my @config_defaults = (
|
|
[ 'digest_time', '06:30' ],
|
|
[ 'timezone', 'UTC' ],
|
|
[ 'last_digest_sent', '' ],
|
|
[ 'upcoming_horizon', '7' ],
|
|
[ 'medium_float_days', '3' ],
|
|
);
|
|
|
|
my $dbh = $self->{_dbh};
|
|
$dbh->begin_work;
|
|
eval {
|
|
$dbh->do($_) for @statements;
|
|
my $ins = $dbh->prepare('INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)');
|
|
$ins->execute(@$_) for @config_defaults;
|
|
$dbh->commit;
|
|
};
|
|
if ($@) {
|
|
$dbh->rollback;
|
|
croak "Schema initialisation failed: $@";
|
|
}
|
|
}
|
|
|
|
# --- Config table helpers ---
|
|
|
|
sub config_get {
|
|
my ($self, $key) = @_;
|
|
my ($val) = $self->{_dbh}->selectrow_array(
|
|
'SELECT value FROM config WHERE key = ?', undef, $key
|
|
);
|
|
return $val;
|
|
}
|
|
|
|
sub config_set {
|
|
my ($self, $key, $value) = @_;
|
|
$self->{_dbh}->do(
|
|
'INSERT INTO config (key, value) VALUES (?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
|
undef, $key, $value
|
|
);
|
|
}
|
|
|
|
# --- Task / completion queries used by Digest ---
|
|
|
|
# Returns arrayref of all active task hashrefs, ordered by title.
|
|
sub active_tasks {
|
|
my ($self) = @_;
|
|
return $self->{_dbh}->selectall_arrayref(
|
|
'SELECT * FROM tasks WHERE active = 1 ORDER BY title',
|
|
{ Slice => {} }
|
|
);
|
|
}
|
|
|
|
# Returns { task_id => last_completed_at_ISO_string } for every task that has
|
|
# at least one completion row.
|
|
sub last_completions {
|
|
my ($self) = @_;
|
|
my $rows = $self->{_dbh}->selectall_arrayref(
|
|
'SELECT task_id, MAX(completed_at) AS completed_at
|
|
FROM completions GROUP BY task_id',
|
|
{ Slice => {} }
|
|
);
|
|
return { map { $_->{task_id} => $_->{completed_at} } @$rows };
|
|
}
|
|
|
|
sub DESTROY {
|
|
my ($self) = @_;
|
|
$self->{_dbh}->disconnect if $self->{_dbh};
|
|
}
|
|
|
|
1;
|