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
+35
View File
@@ -0,0 +1,35 @@
# Opal task manager
This is the counterpart to jade, where we track tasks.
## Syntax
`opal [<filter>] [<command>] [<modifier>]`
### <filter>
Filters the Task to run a command on. Adressing a set of subtasks.
`opal +home -garden status:pending list` - lists all tasks with the +home tag and status pending, but excludes +garden tags. This is a compound filter with implicit AND.
### <command>
`add` - Creates a new task
`done` - Completes a task
`list` - Lists task
`count` - Counts number of tasks
### <modifier>
Modifies atributes of tasks.
## Task
A task has multiple atributes:
`status` - pending, completed, deleted, recurring
`description` - One line summary
`start` - the most recent time at which this task was started (a task with no start key is not active)
`end` - if present, the time at which this task was completed or deleted
`due` - Use a due date to specify the exact date by which a task must be completed.`created` - Time task created
`schedueled` - represents the earliest opportunity to work on a task. If a task has a scheduled date, then once that date passes, the task is considered ready. Tasks with no scheduled is considered always ready.
`wait` - a wait date for a task, which has the effect of hiding the task from you until that date.
`until` - the point at which to mark task as deleted. an expiration date.
## Recurrence
A task can be recurring. Then we have a template task and instances of that task.
`opal add Change sheets due:sun recur:1w` - A task to be done every sunday.
A recurring task is given a status of recurring which hides it from view. The recurring task you create is called the template task, from which recurring tasks instances are created. So the template remains hidden, and the recurring instances that spawn from it are the tasks that you will see and complete.
## Storage
Sqlite store.
+3
View File
@@ -0,0 +1,3 @@
package cmd
// TODO: Implement add command
+3
View File
@@ -0,0 +1,3 @@
package cmd
// TODO: Implement count command
+3
View File
@@ -0,0 +1,3 @@
package cmd
// TODO: Implement done command
+3
View File
@@ -0,0 +1,3 @@
package cmd
// TODO: Implement list command
+27
View File
@@ -0,0 +1,27 @@
module git.jnss.me/joakim/opal
go 1.25.5
require github.com/google/uuid v1.6.0
require (
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.33 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.28.0 // indirect
)
+47
View File
@@ -0,0 +1,47 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+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
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"fmt"
"os"
"git.jnss.me/joakim/opal/internal/engine"
)
func main() {
// Initialize database
if err := engine.InitDB(); err != nil {
fmt.Fprintf(os.Stderr, "Error initializing database: %v\n", err)
os.Exit(1)
}
defer engine.CloseDB()
// Load config
if _, err := engine.LoadConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
fmt.Println("Opal task manager initialized successfully!")
fmt.Println("Database and configuration ready.")
}