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:
@@ -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()
|
||||
}
|
||||
@@ -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(¤tVersion)
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package engine
|
||||
|
||||
type Opal struct {
|
||||
ws *WorkingSet
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user