Implement opal-task Phase 1: Database foundation

- Add SQLite database with schema for tasks, tags, and working_set
- Implement config management with Viper (opal.yml)
- Create Task struct with proper types (*time.Time, Priority int)
- Add database migration system
- Implement recurrence pattern parsing (1d, 1w, 1m, 1y)
- Setup project structure with cmd/ and internal/engine/
- Add dependencies: sqlite3, uuid, cobra, viper, color
This commit is contained in:
2026-01-04 14:41:16 +01:00
parent 1d87d93172
commit 9b5261b34c
14 changed files with 579 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
package engine
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/viper"
)
type Config struct {
DefaultFilter string `mapstructure:"default_filter"`
DefaultSort string `mapstructure:"default_sort"`
ColorOutput bool `mapstructure:"color_output"`
}
var globalConfig *Config
// GetConfigDir returns the configuration directory path
func GetConfigDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(home, ".config", "jade"), nil
}
// GetDBPath returns the path to the SQLite database
func GetDBPath() (string, error) {
configDir, err := GetConfigDir()
if err != nil {
return "", err
}
return filepath.Join(configDir, "opal.db"), nil
}
// GetConfigPath returns the path to the config file
func GetConfigPath() (string, error) {
configDir, err := GetConfigDir()
if err != nil {
return "", err
}
return filepath.Join(configDir, "opal.yml"), nil
}
// LoadConfig loads the configuration from file or creates default
func LoadConfig() (*Config, error) {
if globalConfig != nil {
return globalConfig, nil
}
configDir, err := GetConfigDir()
if err != nil {
return nil, err
}
// Ensure config directory exists
if err := os.MkdirAll(configDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create config directory: %w", err)
}
configPath, err := GetConfigPath()
if err != nil {
return nil, err
}
v := viper.New()
v.SetConfigFile(configPath)
v.SetConfigType("yaml")
// Set defaults
v.SetDefault("default_filter", "status:pending")
v.SetDefault("default_sort", "due,priority")
v.SetDefault("color_output", true)
// Try to read existing config
err = v.ReadInConfig()
if err != nil {
// Config doesn't exist, create it with defaults
// Write config with defaults (ignoring the error type check for now)
if err := v.WriteConfigAs(configPath); err != nil {
return nil, fmt.Errorf("failed to create config file: %w", err)
}
// Try reading again
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read newly created config: %w", err)
}
}
cfg := &Config{}
if err := v.Unmarshal(cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
globalConfig = cfg
return cfg, nil
}
// SaveConfig saves the configuration to file
func SaveConfig(cfg *Config) error {
configPath, err := GetConfigPath()
if err != nil {
return err
}
v := viper.New()
v.SetConfigFile(configPath)
v.SetConfigType("yaml")
v.Set("default_filter", cfg.DefaultFilter)
v.Set("default_sort", cfg.DefaultSort)
v.Set("color_output", cfg.ColorOutput)
return v.WriteConfig()
}
// GetConfig returns the loaded config or loads it if not already loaded
func GetConfig() (*Config, error) {
if globalConfig != nil {
return globalConfig, nil
}
return LoadConfig()
}
+169
View File
@@ -0,0 +1,169 @@
package engine
import (
"database/sql"
"fmt"
_ "github.com/mattn/go-sqlite3"
)
var db *sql.DB
// InitDB initializes the database connection and runs migrations
func InitDB() error {
if db != nil {
return nil // Already initialized
}
dbPath, err := GetDBPath()
if err != nil {
return fmt.Errorf("failed to get database path: %w", err)
}
// Open database connection
database, err := sql.Open("sqlite3", dbPath)
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
// Enable foreign keys
if _, err := database.Exec("PRAGMA foreign_keys = ON"); err != nil {
return fmt.Errorf("failed to enable foreign keys: %w", err)
}
db = database
// Run migrations
if err := runMigrations(); err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
return nil
}
// GetDB returns the database connection
func GetDB() *sql.DB {
return db
}
// CloseDB closes the database connection
func CloseDB() error {
if db != nil {
return db.Close()
}
return nil
}
// runMigrations runs database migrations
func runMigrations() error {
// Create schema_version table if it doesn't exist
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
)
`)
if err != nil {
return fmt.Errorf("failed to create schema_version table: %w", err)
}
// Get current schema version
var currentVersion int
err = db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_version").Scan(&currentVersion)
if err != nil {
return fmt.Errorf("failed to get current schema version: %w", err)
}
// Define migrations
migrations := []struct {
version int
sql string
}{
{
version: 1,
sql: `
CREATE TABLE tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE NOT NULL,
status INTEGER NOT NULL DEFAULT 80,
description TEXT NOT NULL,
project TEXT,
priority INTEGER DEFAULT 1,
created INTEGER NOT NULL,
modified INTEGER NOT NULL,
start INTEGER,
end INTEGER,
due INTEGER,
scheduled INTEGER,
wait INTEGER,
until_date INTEGER,
recurrence_duration INTEGER,
parent_uuid TEXT,
FOREIGN KEY (parent_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE
);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_uuid ON tasks(uuid);
CREATE INDEX idx_tasks_parent ON tasks(parent_uuid);
CREATE INDEX idx_tasks_due ON tasks(due);
CREATE INDEX idx_tasks_project ON tasks(project);
CREATE TABLE tags (
task_id INTEGER NOT NULL,
tag TEXT NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, tag)
);
CREATE INDEX idx_tags_tag ON tags(tag);
CREATE TABLE working_set (
display_id INTEGER PRIMARY KEY,
task_uuid TEXT NOT NULL,
FOREIGN KEY (task_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE
);
`,
},
}
// Apply pending migrations
for _, migration := range migrations {
if migration.version > currentVersion {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction for migration %d: %w", migration.version, err)
}
// Execute migration SQL
if _, err := tx.Exec(migration.sql); err != nil {
tx.Rollback()
return fmt.Errorf("failed to execute migration %d: %w", migration.version, err)
}
// Record migration
if _, err := tx.Exec(
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
migration.version,
getCurrentTimestamp(),
); err != nil {
tx.Rollback()
return fmt.Errorf("failed to record migration %d: %w", migration.version, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit migration %d: %w", migration.version, err)
}
}
}
return nil
}
// getCurrentTimestamp returns the current Unix timestamp
func getCurrentTimestamp() int64 {
return timeNow().Unix()
}
+5
View File
@@ -0,0 +1,5 @@
package engine
type Opal struct {
ws *WorkingSet
}
+69
View File
@@ -0,0 +1,69 @@
package engine
import (
"fmt"
"strconv"
"time"
)
// ParseRecurrencePattern converts "1w", "2d", "1m" to time.Duration
func ParseRecurrencePattern(pattern string) (time.Duration, error) {
if len(pattern) < 2 {
return 0, fmt.Errorf("invalid recurrence pattern: %s", pattern)
}
numStr := pattern[:len(pattern)-1]
unit := pattern[len(pattern)-1]
num, err := strconv.Atoi(numStr)
if err != nil {
return 0, fmt.Errorf("invalid number in pattern: %s", pattern)
}
switch unit {
case 'd':
return time.Duration(num) * 24 * time.Hour, nil
case 'w':
return time.Duration(num) * 7 * 24 * time.Hour, nil
case 'm':
// Approximate: 30 days
return time.Duration(num) * 30 * 24 * time.Hour, nil
case 'y':
// Approximate: 365 days
return time.Duration(num) * 365 * 24 * time.Hour, nil
default:
return 0, fmt.Errorf("unknown unit: %c (use d/w/m/y)", unit)
}
}
// FormatRecurrenceDuration converts time.Duration back to "1w", "2d" format
func FormatRecurrenceDuration(d time.Duration) string {
days := int(d.Hours() / 24)
if days%365 == 0 && days/365 > 0 {
return fmt.Sprintf("%dy", days/365)
}
if days%30 == 0 && days/30 > 0 {
return fmt.Sprintf("%dm", days/30)
}
if days%7 == 0 && days/7 > 0 {
return fmt.Sprintf("%dw", days/7)
}
return fmt.Sprintf("%dd", days)
}
// CalculateNextDue calculates next due date based on current and recurrence
func CalculateNextDue(currentDue time.Time, recurrence time.Duration) time.Time {
return currentDue.Add(recurrence)
}
// SpawnNextInstance creates a new task instance from completed recurring task
// This will be implemented after we have the CRUD operations
func SpawnNextInstance(completedInstance *Task) error {
if completedInstance.ParentUUID == nil {
return fmt.Errorf("task is not a recurring instance")
}
// TODO: Implement after GetTask is available
return fmt.Errorf("not implemented yet")
}
+57
View File
@@ -0,0 +1,57 @@
package engine
import (
"time"
"github.com/google/uuid"
)
type Status byte
const (
StatusPending Status = 'P'
StatusCompleted Status = 'C'
StatusDeleted Status = 'D'
StatusRecurring Status = 'R'
)
type Priority int
const (
PriorityLow Priority = 0
PriorityDefault Priority = 1
PriorityMedium Priority = 2
PriorityHigh Priority = 3
)
type Task struct {
// Identity
UUID uuid.UUID
ID int
// Core fields
Status Status
Description string
Project *string
Priority Priority
// Timestamps
Created time.Time
Modified time.Time
Start *time.Time
End *time.Time
Due *time.Time
Scheduled *time.Time
Wait *time.Time
Until *time.Time
// Recurrence (parent-child approach)
RecurrenceDuration *time.Duration
ParentUUID *uuid.UUID
// Derived fields (not stored in DB)
Tags []string
}
// timeNow returns current time (allows mocking in tests)
var timeNow = time.Now
+9
View File
@@ -0,0 +1,9 @@
package engine
// WorkingSet is a mapping from small integers to task uuids for all pending tasks.
// The small integers are meant to be stable, easily-typed identifiers for users to interact with
// important tasks.
type WorkingSet struct {
byUUID map[string]*Task
byID []string
}