From 9b5261b34c3ea2b8375c3f13cf379eb6d0293fb2 Mon Sep 17 00:00:00 2001 From: Joakim Date: Sun, 4 Jan 2026 14:41:16 +0100 Subject: [PATCH] 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 --- opal-task/README.md | 35 +++++ opal-task/cmd/add.go | 3 + opal-task/cmd/count.go | 3 + opal-task/cmd/done.go | 3 + opal-task/cmd/list.go | 3 + opal-task/go.mod | 27 ++++ opal-task/go.sum | 47 +++++++ opal-task/internal/engine/config.go | 123 +++++++++++++++++ opal-task/internal/engine/database.go | 169 ++++++++++++++++++++++++ opal-task/internal/engine/opal.go | 5 + opal-task/internal/engine/recurrence.go | 69 ++++++++++ opal-task/internal/engine/task.go | 57 ++++++++ opal-task/internal/engine/ws.go | 9 ++ opal-task/main.go | 26 ++++ 14 files changed, 579 insertions(+) create mode 100644 opal-task/README.md create mode 100644 opal-task/cmd/add.go create mode 100644 opal-task/cmd/count.go create mode 100644 opal-task/cmd/done.go create mode 100644 opal-task/cmd/list.go create mode 100644 opal-task/go.mod create mode 100644 opal-task/go.sum create mode 100644 opal-task/internal/engine/config.go create mode 100644 opal-task/internal/engine/database.go create mode 100644 opal-task/internal/engine/opal.go create mode 100644 opal-task/internal/engine/recurrence.go create mode 100644 opal-task/internal/engine/task.go create mode 100644 opal-task/internal/engine/ws.go create mode 100644 opal-task/main.go diff --git a/opal-task/README.md b/opal-task/README.md new file mode 100644 index 0000000..cc51c4a --- /dev/null +++ b/opal-task/README.md @@ -0,0 +1,35 @@ +# Opal task manager +This is the counterpart to jade, where we track tasks. +## Syntax +`opal [] [] []` + +### +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. +### +`add` - Creates a new task +`done` - Completes a task +`list` - Lists task +`count` - Counts number of tasks + +### +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. diff --git a/opal-task/cmd/add.go b/opal-task/cmd/add.go new file mode 100644 index 0000000..6f1ed40 --- /dev/null +++ b/opal-task/cmd/add.go @@ -0,0 +1,3 @@ +package cmd + +// TODO: Implement add command diff --git a/opal-task/cmd/count.go b/opal-task/cmd/count.go new file mode 100644 index 0000000..8a56eb9 --- /dev/null +++ b/opal-task/cmd/count.go @@ -0,0 +1,3 @@ +package cmd + +// TODO: Implement count command diff --git a/opal-task/cmd/done.go b/opal-task/cmd/done.go new file mode 100644 index 0000000..a1795b1 --- /dev/null +++ b/opal-task/cmd/done.go @@ -0,0 +1,3 @@ +package cmd + +// TODO: Implement done command diff --git a/opal-task/cmd/list.go b/opal-task/cmd/list.go new file mode 100644 index 0000000..8e59fb0 --- /dev/null +++ b/opal-task/cmd/list.go @@ -0,0 +1,3 @@ +package cmd + +// TODO: Implement list command diff --git a/opal-task/go.mod b/opal-task/go.mod new file mode 100644 index 0000000..5447f1f --- /dev/null +++ b/opal-task/go.mod @@ -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 +) diff --git a/opal-task/go.sum b/opal-task/go.sum new file mode 100644 index 0000000..51692a7 --- /dev/null +++ b/opal-task/go.sum @@ -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= diff --git a/opal-task/internal/engine/config.go b/opal-task/internal/engine/config.go new file mode 100644 index 0000000..4515c5f --- /dev/null +++ b/opal-task/internal/engine/config.go @@ -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() +} diff --git a/opal-task/internal/engine/database.go b/opal-task/internal/engine/database.go new file mode 100644 index 0000000..b5477d9 --- /dev/null +++ b/opal-task/internal/engine/database.go @@ -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() +} diff --git a/opal-task/internal/engine/opal.go b/opal-task/internal/engine/opal.go new file mode 100644 index 0000000..40ca1ec --- /dev/null +++ b/opal-task/internal/engine/opal.go @@ -0,0 +1,5 @@ +package engine + +type Opal struct { + ws *WorkingSet +} diff --git a/opal-task/internal/engine/recurrence.go b/opal-task/internal/engine/recurrence.go new file mode 100644 index 0000000..3cfaff5 --- /dev/null +++ b/opal-task/internal/engine/recurrence.go @@ -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") +} diff --git a/opal-task/internal/engine/task.go b/opal-task/internal/engine/task.go new file mode 100644 index 0000000..b579ff9 --- /dev/null +++ b/opal-task/internal/engine/task.go @@ -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 diff --git a/opal-task/internal/engine/ws.go b/opal-task/internal/engine/ws.go new file mode 100644 index 0000000..874cbd8 --- /dev/null +++ b/opal-task/internal/engine/ws.go @@ -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 +} diff --git a/opal-task/main.go b/opal-task/main.go new file mode 100644 index 0000000..58f5d2d --- /dev/null +++ b/opal-task/main.go @@ -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.") +}