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;