mnemosyne/lib/Mnemosyne/DB.pm

138 lines
4.2 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
);
}
sub DESTROY {
my ($self) = @_;
$self->{_dbh}->disconnect if $self->{_dbh};
}
1;