commit 2d4cff717b82e2fd0245e33cf61f77da53d42f00 Author: Elias Kohout Date: Thu Mar 26 12:48:47 2026 +0000 init diff --git a/README.md b/README.md new file mode 100644 index 0000000..835153b --- /dev/null +++ b/README.md @@ -0,0 +1,229 @@ +# Axolotl + +CLI-native lightweight issue tracker for you and your agents. A SQLite-based +single portable binary, built from ~1300 lines of Go code. + +## Features + +- **Issues with dependencies** - blocks, subtask, related relations +- **Tagging system** - flexible tags with `_key::value` property pattern +- **Namespacing** - organize issues by project or team +- **Due dates** - track deadlines +- **Thread-safe** - WAL mode for concurrent access +- **Multiuser support** - @mentions and assignments, inbox per user +- **JSON output** - all commands support `--json` for agent integration +- **Alias system** - define custom command shortcuts +- **Single binary** - no dependencies, portable `.ax.db` file + +## Installation + +```bash +go build -o ax . +``` + +## Quick Start + +```bash +# Initialize a new database +ax init . + +# Create an issue +ax create "Implement feature X" --tag backend --prio high + +# Create with relations +ax create "Fix bug in auth" --rel blocks:abc12 + +# List open issues +ax list --status open + +# Show issue details +ax show abc12 + +# Update an issue +ax update abc12 --status done + +# View your inbox +ax inbox + +# Define an alias +ax alias mywork "list --namespace myproject --status open" +``` + +## Commands + +### `ax init [path]` + +Create a new `.ax.db` database in the specified directory (default: current). + +### `ax create [flags]` + +Create a new node. + +| Flag | Description | +|------|-------------| +| `--type` | Node type: `issue` (default), `note`, `user`, `namespace` | +| `--status` | Status: `open` (default), `done` | +| `--prio` | Priority: `high`, `medium`, `low` | +| `--namespace` | Namespace (default: current user) | +| `--tag` | Add tag (repeatable) | +| `--due` | Due date | +| `--content` | Content/body text | +| `--rel` | Add relation `type:id` (repeatable) | + +### `ax update <id> [flags]` + +Update a node. + +| Flag | Description | +|------|-------------| +| `--title` | New title | +| `--status` | New status | +| `--prio` | New priority | +| `--due` | New due date | +| `--clear-due` | Clear due date | +| `--content` | New content | +| `--tag` | Add tag (repeatable) | +| `--tag-remove` | Remove tag (repeatable) | +| `--rel` | Add relation `type:id` (repeatable) | +| `--rel-remove` | Remove relation `type:id` (repeatable) | + +### `ax show <id>` + +Display node details. + +### `ax list [flags]` + +Query and list nodes. + +| Flag | Description | +|------|-------------| +| `--type` | Filter by type | +| `--status` | Filter by status | +| `--prio` | Filter by priority | +| `--namespace` | Filter by namespace | +| `--tag` | Filter by tag | +| `--inbox` | Filter by inbox user | +| `--assignee` | Filter by assignee | + +### `ax edit <id>` + +Open node content in `$EDITOR`. + +### `ax delete <id> [-f|--force]` + +Delete a node. Prompts for confirmation unless `--force`. + +### `ax inbox` + +Show issues in current user's inbox (from @mentions). + +### `ax alias [name] [command]` + +Manage aliases. + +```bash +ax alias myinbox "list --inbox me" +ax alias --list +ax alias myinbox # show alias command +``` + +## Relations + +Relations connect nodes together: + +| Type | Direction | Behavior | +|------|-----------|----------| +| `blocks` | issue → issue | Prevents closing until blocker is done | +| `subtask` | issue → issue | Shows as tree under parent | +| `related` | any ↔ any | Shown in related section | +| `assignee` | issue → user | Adds to user's inbox | + +```bash +# Create subtask +ax create "Write tests" --rel subtask:abc12 + +# Block an issue +ax create "Fix login" --rel blocks:def34 + +# Assign to user +ax update abc12 --rel assignee:alice +``` + +## Tags and Properties + +Tags are flexible labels. Tags with pattern `_key::value` are properties: + +```bash +# Regular tag +ax create "Task" --tag backend + +# Property tags (set via flags) +ax create "Task" --type issue --status open --prio high +# Equivalent to: --tag _type::issue --tag _status::open --tag _prio::high +``` + +**Built-in properties:** + +| Property | Values | Required | +|----------|--------|----------| +| `_type` | `issue`, `note`, `user`, `namespace` | Yes (default: `issue`) | +| `_status` | `open`, `done` | No | +| `_prio` | `high`, `medium`, `low` | No | +| `_namespace` | any string | Yes (default: current user) | +| `_inbox` | username | No (auto-set from @mentions) | + +## Mentions and Inbox + +Use `@username` in title or content to automatically add to user's inbox: + +```bash +ax create "Review PR @alice" --content "@bob please check" +# Both alice and bob get this in their inbox +``` + +View inbox: +```bash +ax inbox # your inbox +AX_USER=alice ax inbox # alice's inbox +``` + +## JSON Output + +All commands support `--json` for machine-readable output: + +```bash +ax list --status open --json +ax show abc12 --json +``` + +Example output: +```json +{ + "id": "abc12", + "title": "Implement feature", + "content": "Description here", + "created_at": "2026-03-25T10:00:00Z", + "updated_at": "2026-03-25T10:00:00Z", + "tags": ["_type::issue", "_status::open", "backend"], + "relations": { + "blocks": ["def34"] + } +} +``` + +## Database Location + +`ax` searches for `.ax.db` in the current directory and parent directories, +similar to how git finds `.git`. This allows you to run commands from any +subdirectory. + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `AX_USER` | Override current username | +| `EDITOR` | Editor for `ax edit` (default: `vi`) | + +## License + +MIT diff --git a/cmd/alias.go b/cmd/alias.go new file mode 100644 index 0000000..72fa137 --- /dev/null +++ b/cmd/alias.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "axolotl/db" + "axolotl/output" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var aliasList bool + +var aliasCmd = &cobra.Command{ + Use: "alias [name] [command]", + Short: "Manage aliases", + Args: cobra.MaximumNArgs(2), + Run: func(cmd *cobra.Command, args []string) { + d, err := db.GetDB() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + if aliasList { + aliases, err := d.ListAliases() + if err != nil { + return + } + output.PrintAliases(cmd.OutOrStdout(), aliases, jsonFlag) + return + } + if len(args) == 0 { + aliases, _ := d.ListAliases() + output.PrintAliases(cmd.OutOrStdout(), aliases, jsonFlag) + return + } + if len(args) == 1 { + a, err := d.GetAlias(args[0]) + if err != nil { + fmt.Fprintln(os.Stderr, " alias not found:", args[0]) + return + } + fmt.Println(a.Command) + return + } + if err := d.SetAlias(args[0], args[1]); err != nil { + fmt.Fprintln(os.Stderr, " failed to set alias:", err) + return + } + output.PrintSuccess(cmd.OutOrStdout(), "Alias '%s' set", args[0]) + }, +} + +func init() { + rootCmd.AddCommand(aliasCmd) + aliasCmd.Flags().BoolVar(&aliasList, "list", false, "list all aliases") +} diff --git a/cmd/create.go b/cmd/create.go new file mode 100644 index 0000000..55b479d --- /dev/null +++ b/cmd/create.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "axolotl/db" + "axolotl/models" + "axolotl/output" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var createType, createStatus, createPrio, createNamespace string = "issue", "open", "", "" +var createDue, createContent string +var createTags, createRels []string + +var createCmd = &cobra.Command{ + Use: "create <title>", + Short: "Create a new node", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + d, err := db.GetDB() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + if createType == "issue" && createStatus == "" { + createStatus = "open" + } + rels := make(map[models.RelType][]string) + for _, r := range createRels { + relType, target, err := db.ParseRelFlag(r) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + rels[relType] = append(rels[relType], target) + } + n, err := d.CreateNode(db.CreateParams{ + Title: args[0], + Content: createContent, + DueDate: createDue, + Type: createType, + Status: createStatus, + Priority: createPrio, + Namespace: createNamespace, + Tags: createTags, + Rels: rels, + }) + if err != nil { + fmt.Fprintln(os.Stderr, "failed to create:", err) + return + } + output.PrintNode(cmd.OutOrStdout(), n, jsonFlag) + }, +} + +func init() { + rootCmd.AddCommand(createCmd) + createCmd.Flags().StringVar(&createType, "type", "issue", "node type (issue, note, user, namespace)") + createCmd.Flags().StringVar(&createStatus, "status", "", "status (open, done)") + createCmd.Flags().StringVar(&createPrio, "prio", "", "priority (high, medium, low)") + createCmd.Flags().StringVar(&createNamespace, "namespace", "", "namespace") + createCmd.Flags().StringVar(&createDue, "due", "", "due date") + createCmd.Flags().StringVar(&createContent, "content", "", "content") + createCmd.Flags().StringArrayVar(&createTags, "tag", nil, "tags (repeatable)") + createCmd.Flags().StringArrayVar(&createRels, "rel", nil, "relations (type:id, repeatable)") +} diff --git a/cmd/delete.go b/cmd/delete.go new file mode 100644 index 0000000..67b1526 --- /dev/null +++ b/cmd/delete.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "axolotl/db" + "axolotl/output" + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" +) + +var deleteForce bool + +var deleteCmd = &cobra.Command{ + Use: "delete <id>", + Short: "Delete a node", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + d, err := db.GetDB() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + id := args[0] + n, err := d.NodeByID(id) + if err != nil { + fmt.Fprintln(os.Stderr, " node not found:", id) + return + } + if !deleteForce { + fmt.Printf(" Delete %s '%s'? [y/N]: ", n.GetType(), n.Title) + reader := bufio.NewReader(os.Stdin) + resp, _ := reader.ReadString('\n') + resp = strings.TrimSpace(strings.ToLower(resp)) + if resp != "y" && resp != "yes" { + fmt.Println(" Cancelled.") + return + } + } + if err := d.DeleteNode(id); err != nil { + fmt.Fprintln(os.Stderr, " failed to delete:", err) + return + } + output.PrintDeleted(cmd.OutOrStdout(), id) + }, +} + +func init() { + rootCmd.AddCommand(deleteCmd) + deleteCmd.Flags().BoolVarP(&deleteForce, "force", "f", false, "skip confirmation") +} diff --git a/cmd/edit.go b/cmd/edit.go new file mode 100644 index 0000000..927bc3e --- /dev/null +++ b/cmd/edit.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "axolotl/db" + "axolotl/output" + "fmt" + "os" + "os/exec" + + "github.com/spf13/cobra" +) + +var editCmd = &cobra.Command{ + Use: "edit <id>", + Short: "Edit node content in $EDITOR", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + d, err := db.GetDB() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + id := args[0] + n, err := d.NodeByID(id) + if err != nil { + fmt.Fprintln(os.Stderr, "node not found:", id) + return + } + tmp, err := os.CreateTemp("", "ax-*.md") + if err != nil { + fmt.Fprintln(os.Stderr, "failed to create temp file:", err) + return + } + tmp.WriteString(n.Content) + tmp.Close() + defer os.Remove(tmp.Name()) + + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + c := exec.Command(editor, tmp.Name()) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if err := c.Run(); err != nil { + fmt.Fprintln(os.Stderr, "editor failed:", err) + return + } + content, err := os.ReadFile(tmp.Name()) + if err != nil { + fmt.Fprintln(os.Stderr, "failed to read temp file:", err) + return + } + if err := d.UpdateNode(id, db.UpdateParams{Content: string(content)}); err != nil { + fmt.Fprintln(os.Stderr, "failed to update:", err) + return + } + n, _ = d.NodeByID(id) + output.PrintNode(cmd.OutOrStdout(), n, jsonFlag) + }, +} + +func init() { + rootCmd.AddCommand(editCmd) +} diff --git a/cmd/inbox.go b/cmd/inbox.go new file mode 100644 index 0000000..b6230dc --- /dev/null +++ b/cmd/inbox.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "axolotl/db" + "axolotl/output" + + "github.com/spf13/cobra" +) + +var inboxCmd = &cobra.Command{ + Use: "inbox", + Short: "Show your inbox", + Run: func(cmd *cobra.Command, args []string) { + d, err := db.GetDB() + user := db.GetCurrentUser() + nodes, err := d.ListNodes(db.ListFilter{ + Inbox: user, + }) + if err != nil { + return + } + output.PrintNodes(cmd.OutOrStdout(), nodes, jsonFlag) + }, +} + +func init() { + rootCmd.AddCommand(inboxCmd) +} diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..e3c409f --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "axolotl/db" + "axolotl/output" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +var initCmd = &cobra.Command{ + Use: "init [path]", + Short: "Initialize a new database", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + path := "." + if len(args) > 0 { + path = args[0] + } + dbPath := filepath.Join(path, ".ax.db") + if _, err := os.Stat(dbPath); err == nil { + fmt.Fprintln(os.Stderr, "database already exists:", dbPath) + os.Exit(1) + } + err := db.Init(dbPath) + if err != nil { + fmt.Fprintln(os.Stderr, "failed to initialize:", err) + os.Exit(1) + } + output.PrintCreated(cmd.OutOrStdout(), dbPath) + }, +} + +func init() { + rootCmd.AddCommand(initCmd) +} diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..047664f --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "axolotl/db" + "axolotl/output" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var listType, listStatus, listPrio, listNamespace, listTag, listInbox, listAssignee string + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List nodes", + Run: func(cmd *cobra.Command, args []string) { + d, err := db.GetDB() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + nodes, err := d.ListNodes(db.ListFilter{ + Type: listType, + Status: listStatus, + Priority: listPrio, + Namespace: listNamespace, + Tag: listTag, + Inbox: listInbox, + Assignee: listAssignee, + }) + if err != nil { + return + } + output.PrintNodes(cmd.OutOrStdout(), nodes, jsonFlag) + }, +} + +func init() { + rootCmd.AddCommand(listCmd) + listCmd.Flags().StringVar(&listType, "type", "", "filter by type") + listCmd.Flags().StringVar(&listStatus, "status", "", "filter by status") + listCmd.Flags().StringVar(&listPrio, "prio", "", "filter by priority") + listCmd.Flags().StringVar(&listNamespace, "namespace", "", "filter by namespace") + listCmd.Flags().StringVar(&listTag, "tag", "", "filter by tag") + listCmd.Flags().StringVar(&listInbox, "inbox", "", "filter by inbox user") + listCmd.Flags().StringVar(&listAssignee, "assignee", "", "filter by assignee") +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..622bd15 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +var jsonFlag bool + +var rootCmd = &cobra.Command{ + Use: "ax", + Short: "The axolotl issue tracker", +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, "output as JSON") +} diff --git a/cmd/show.go b/cmd/show.go new file mode 100644 index 0000000..c2670b3 --- /dev/null +++ b/cmd/show.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "axolotl/db" + "axolotl/output" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var showCmd = &cobra.Command{ + Use: "show <id>", + Short: "Show node details", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + d, err := db.GetDB() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + n, err := d.NodeByID(args[0]) + if err != nil { + fmt.Fprintln(os.Stderr, "node not found:", args[0]) + return + } + output.PrintNode(cmd.OutOrStdout(), n, jsonFlag) + }, +} + +func init() { + rootCmd.AddCommand(showCmd) +} diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..2db8302 --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,94 @@ +package cmd + +import ( + "axolotl/db" + "axolotl/models" + "axolotl/output" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var updateTitle, updateContent, updateStatus, updatePrio, updateDue string +var updateClearDue bool +var updateAddTags, updateRemoveTags, updateAddRels, updateRemoveRels []string + +var updateCmd = &cobra.Command{ + Use: "update <id>", + Short: "Update a node", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + d, err := db.GetDB() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + id := args[0] + addRels := make(map[models.RelType][]string) + removeRels := make(map[models.RelType][]string) + for _, r := range updateAddRels { + relType, target, err := db.ParseRelFlag(r) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + addRels[relType] = append(addRels[relType], target) + } + for _, r := range updateRemoveRels { + relType, target, err := db.ParseRelFlag(r) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + removeRels[relType] = append(removeRels[relType], target) + } + if updateStatus == "done" { + ok, blockers, err := d.CanClose(id) + if err != nil { + fmt.Fprintln(os.Stderr, "failed to check blockers:", err) + return + } + if !ok { + fmt.Fprintf(os.Stderr, "cannot close: blocked by %v\n", blockers) + return + } + } + err = d.UpdateNode(id, db.UpdateParams{ + Title: updateTitle, + Content: updateContent, + DueDate: updateDue, + ClearDue: updateClearDue, + Status: updateStatus, + Priority: updatePrio, + AddTags: updateAddTags, + RemoveTags: updateRemoveTags, + AddRels: addRels, + RemoveRels: removeRels, + }) + if err != nil { + fmt.Fprintln(os.Stderr, "failed to update:", err) + return + } + n, err := d.NodeByID(id) + if err != nil { + fmt.Fprintln(os.Stderr, "failed to fetch node:", err) + return + } + output.PrintNode(cmd.OutOrStdout(), n, jsonFlag) + }, +} + +func init() { + rootCmd.AddCommand(updateCmd) + updateCmd.Flags().StringVar(&updateTitle, "title", "", "new title") + updateCmd.Flags().StringVar(&updateContent, "content", "", "new content") + updateCmd.Flags().StringVar(&updateStatus, "status", "", "status (open, done)") + updateCmd.Flags().StringVar(&updatePrio, "prio", "", "priority (high, medium, low)") + updateCmd.Flags().StringVar(&updateDue, "due", "", "due date") + updateCmd.Flags().BoolVar(&updateClearDue, "clear-due", false, "clear due date") + updateCmd.Flags().StringArrayVar(&updateAddTags, "tag", nil, "add tags (repeatable)") + updateCmd.Flags().StringArrayVar(&updateRemoveTags, "tag-remove", nil, "remove tags (repeatable)") + updateCmd.Flags().StringArrayVar(&updateAddRels, "rel", nil, "add relations (type:id, repeatable)") + updateCmd.Flags().StringArrayVar(&updateRemoveRels, "rel-remove", nil, "remove relations (type:id, repeatable)") +} diff --git a/db/alias.go b/db/alias.go new file mode 100644 index 0000000..9592d3c --- /dev/null +++ b/db/alias.go @@ -0,0 +1,53 @@ +package db + +import "errors" + +type Alias struct { + Name string + Command string +} + +func (db *DB) GetAlias(name string) (*Alias, error) { + a := &Alias{} + err := db.QueryRow("SELECT name, command FROM aliases WHERE name = ?", name).Scan(&a.Name, &a.Command) + if err != nil { + return nil, err + } + return a, nil +} + +func (db *DB) SetAlias(name, command string) error { + _, err := db.Exec( + "INSERT INTO aliases (name, command) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET command = excluded.command", + name, command, + ) + return err +} + +func (db *DB) DeleteAlias(name string) error { + res, err := db.Exec("DELETE FROM aliases WHERE name = ?", name) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return errors.New("alias not found") + } + return nil +} + +func (db *DB) ListAliases() ([]*Alias, error) { + rows, err := db.Query("SELECT name, command FROM aliases ORDER BY name") + if err != nil { + return nil, err + } + defer rows.Close() + + var aliases []*Alias + for rows.Next() { + a := &Alias{} + rows.Scan(&a.Name, &a.Command) + aliases = append(aliases, a) + } + return aliases, nil +} diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..4da7f60 --- /dev/null +++ b/db/db.go @@ -0,0 +1,154 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "os" + "os/user" + "path/filepath" + + "axolotl/models" + + _ "modernc.org/sqlite" +) + +type DB struct { + *sql.DB + path string +} + +var database *DB +var migrations = []string{ + `CREATE TABLE IF NOT EXISTS nodes ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + content TEXT, + due_date TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS tags ( + node_id TEXT NOT NULL, + tag TEXT NOT NULL, + PRIMARY KEY (node_id, tag), + FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS rels ( + from_id TEXT NOT NULL, + to_id TEXT NOT NULL, + rel_type TEXT NOT NULL, + PRIMARY KEY (from_id, to_id, rel_type), + FOREIGN KEY (from_id) REFERENCES nodes(id) ON DELETE CASCADE, + FOREIGN KEY (to_id) REFERENCES nodes(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS aliases ( + name TEXT PRIMARY KEY, + command TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag)`, + `CREATE INDEX IF NOT EXISTS idx_rels_from ON rels(from_id)`, + `CREATE INDEX IF NOT EXISTS idx_rels_to ON rels(to_id)`, +} + +func GetDB() (*DB, error) { + if database != nil { + return database, nil + } + dir, err := filepath.Abs(".") + if err != nil { + return nil, err + } + for { + path := filepath.Join(dir, ".ax.db") + if _, err := os.Stat(path); err == nil { + database, err = Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + return database, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return nil, errors.New("no .ax.db found (run 'ax init' first)") + } + path = parent + } +} + +func Init(path string) (error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + var err error + database, err = Open(path) + return err +} + +func Open(path string) (*DB, error) { + database, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + database.Exec("PRAGMA journal_mode=WAL") + database.Exec("PRAGMA busy_timeout=5000") + database.Exec("PRAGMA foreign_keys=ON") + for _, m := range migrations { + if _, err := database.Exec(m); err != nil { + return nil, err + } + } + return &DB{DB: database, path: path}, nil +} + +func GetCurrentUser() string { + if u := os.Getenv("AX_USER"); u != "" { + return u + } + if u, err := user.Current(); err == nil { + return u.Username + } + return "unknown" +} + +func (db *DB) NodeExists(id string) (bool, error) { + var exists bool + err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM nodes WHERE id = ?)", id).Scan(&exists) + return exists, err +} + +func (db *DB) NodeByID(id string) (*models.Node, error) { + n := &models.Node{Relations: make(map[string][]string)} + err := db.QueryRow( + "SELECT id, title, COALESCE(content, ''), COALESCE(due_date, ''), created_at, updated_at FROM nodes WHERE id = ?", + id, + ).Scan(&n.ID, &n.Title, &n.Content, &n.DueDate, &n.CreatedAt, &n.UpdatedAt) + if err != nil { + return nil, err + } + + rows, err := db.Query("SELECT tag FROM tags WHERE node_id = ?", id) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var tag string + rows.Scan(&tag) + n.Tags = append(n.Tags, tag) + } + + rows, err = db.Query("SELECT to_id, rel_type FROM rels WHERE from_id = ?", id) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var toID, relType string + rows.Scan(&toID, &relType) + n.Relations[relType] = append(n.Relations[relType], toID) + } + + return n, nil +} diff --git a/db/node.go b/db/node.go new file mode 100644 index 0000000..69ca6f1 --- /dev/null +++ b/db/node.go @@ -0,0 +1,358 @@ +package db + +import ( + "axolotl/models" + "axolotl/parse" + "database/sql" + "fmt" + "math/rand" + "strings" + "time" +) + +func generateID() string { + const chars = "abcdefghijklmnopqrstuvwxyz" + b := make([]byte, 5) + for i := range b { + b[i] = chars[rand.Intn(26)] + } + return string(b) +} + +func (db *DB) generateUniqueID() string { + //TODO: Check if all ids are reserved + for { + id := generateID() + var exists bool + db.QueryRow("SELECT EXISTS(SELECT 1 FROM nodes WHERE id = ?)", id).Scan(&exists) + if !exists { + return id + } + } +} + +type CreateParams struct { + Title string + Content string + DueDate string + Type string + Status string + Priority string + Namespace string + Tags []string + Rels map[models.RelType][]string +} + +func (db *DB) CreateNode(p CreateParams) (*models.Node, error) { + tx, err := db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + + now := time.Now().UTC().Format(time.RFC3339) + id := db.generateUniqueID() + + _, err = tx.Exec( + "INSERT INTO nodes (id, title, content, due_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + id, p.Title, p.Content, p.DueDate, now, now, + ) + if err != nil { + return nil, err + } + + tags := p.Tags + if p.Type != "" { + tags = append(tags, models.PropertyTag("type", p.Type)) + } else { + tags = append(tags, models.PropertyTag("type", "issue")) + } + if p.Status != "" { + tags = append(tags, models.PropertyTag("status", p.Status)) + } + if p.Priority != "" { + tags = append(tags, models.PropertyTag("prio", p.Priority)) + } + ns := p.Namespace + if ns == "" { + ns = GetCurrentUser() + } + tags = append(tags, models.PropertyTag("namespace", ns)) + + for _, tag := range tags { + if _, err := tx.Exec("INSERT INTO tags (node_id, tag) VALUES (?, ?)", id, tag); err != nil { + return nil, err + } + } + + for relType, targets := range p.Rels { + for _, target := range targets { + if _, err := tx.Exec( + "INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", + id, target, relType, + ); err != nil { + return nil, err + } + } + } + + allText := p.Title + " " + p.Content + for _, u := range parse.Mentions(allText) { + tx.Exec("INSERT INTO tags (node_id, tag) VALUES (?, ?)", id, models.PropertyTag("inbox", u)) + } + + if err := tx.Commit(); err != nil { + return nil, err + } + + return db.NodeByID(id) +} + +type UpdateParams struct { + Title string + Content string + DueDate string + ClearDue bool + Status string + Priority string + AddTags []string + RemoveTags []string + AddRels map[models.RelType][]string + RemoveRels map[models.RelType][]string +} + +func (db *DB) UpdateNode(id string, p UpdateParams) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + now := time.Now().UTC().Format(time.RFC3339) + _, err = tx.Exec("UPDATE nodes SET updated_at = ? WHERE id = ?", now, id) + if err != nil { + return err + } + + if p.Title != "" { + _, err = tx.Exec("UPDATE nodes SET title = ? WHERE id = ?", p.Title, id) + if err != nil { + return err + } + } + if p.Content != "" { + _, err = tx.Exec("UPDATE nodes SET content = ? WHERE id = ?", p.Content, id) + if err != nil { + return err + } + } + if p.DueDate != "" { + _, err = tx.Exec("UPDATE nodes SET due_date = ? WHERE id = ?", p.DueDate, id) + if err != nil { + return err + } + } + if p.ClearDue { + _, err = tx.Exec("UPDATE nodes SET due_date = NULL WHERE id = ?", id) + if err != nil { + return err + } + } + + for _, tag := range p.AddTags { + tx.Exec("INSERT OR IGNORE INTO tags (node_id, tag) VALUES (?, ?)", id, tag) + } + for _, tag := range p.RemoveTags { + tx.Exec("DELETE FROM tags WHERE node_id = ? AND tag = ?", id, tag) + } + + if p.Status != "" { + tx.Exec("DELETE FROM tags WHERE node_id = ? AND tag LIKE '_status::%'", id) + tx.Exec("INSERT INTO tags (node_id, tag) VALUES (?, ?)", id, models.PropertyTag("status", p.Status)) + } + if p.Priority != "" { + tx.Exec("DELETE FROM tags WHERE node_id = ? AND tag LIKE '_prio::%'", id) + tx.Exec("INSERT INTO tags (node_id, tag) VALUES (?, ?)", id, models.PropertyTag("prio", p.Priority)) + } + + for relType, targets := range p.AddRels { + for _, target := range targets { + tx.Exec("INSERT OR IGNORE INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", id, target, relType) + } + } + for relType, targets := range p.RemoveRels { + for _, target := range targets { + tx.Exec("DELETE FROM rels WHERE from_id = ? AND to_id = ? AND rel_type = ?", id, target, relType) + } + } + + return tx.Commit() +} + +func (db *DB) DeleteNode(id string) error { + _, err := db.Exec("DELETE FROM nodes WHERE id = ?", id) + return err +} + +type ListFilter struct { + Type string + Status string + Priority string + Namespace string + Tag string + Inbox string + Assignee string +} + +func (db *DB) ListNodes(f ListFilter) ([]*models.Node, error) { + q := "SELECT DISTINCT n.id FROM nodes n" + args := []interface{}{} + joins := []string{} + conds := []string{} + + if f.Type != "" { + joins = append(joins, "JOIN tags t_type ON n.id = t_type.node_id") + conds = append(conds, "t_type.tag = ?") + args = append(args, models.PropertyTag("type", f.Type)) + } + if f.Status != "" { + joins = append(joins, "JOIN tags t_status ON n.id = t_status.node_id") + conds = append(conds, "t_status.tag = ?") + args = append(args, models.PropertyTag("status", f.Status)) + } + if f.Priority != "" { + joins = append(joins, "JOIN tags t_prio ON n.id = t_prio.node_id") + conds = append(conds, "t_prio.tag = ?") + args = append(args, models.PropertyTag("prio", f.Priority)) + } + if f.Namespace != "" { + joins = append(joins, "JOIN tags t_ns ON n.id = t_ns.node_id") + conds = append(conds, "t_ns.tag = ?") + args = append(args, models.PropertyTag("namespace", f.Namespace)) + } + if f.Tag != "" { + joins = append(joins, "JOIN tags t_tag ON n.id = t_tag.node_id") + conds = append(conds, "t_tag.tag = ?") + args = append(args, f.Tag) + } + if f.Inbox != "" { + joins = append(joins, "JOIN tags t_inbox ON n.id = t_inbox.node_id") + conds = append(conds, "t_inbox.tag = ?") + args = append(args, models.PropertyTag("inbox", f.Inbox)) + } + if f.Assignee != "" { + joins = append(joins, "JOIN rels r_assign ON n.id = r_assign.from_id") + conds = append(conds, "r_assign.to_id = ? AND r_assign.rel_type = ?") + args = append(args, f.Assignee, models.RelAssignee) + } + + if len(joins) > 0 { + q += " " + strings.Join(joins, " ") + } + if len(conds) > 0 { + q += " WHERE " + strings.Join(conds, " AND ") + } + q += " ORDER BY n.created_at DESC" + + rows, err := db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var nodes []*models.Node + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + n, err := db.NodeByID(id) + if err != nil { + return nil, err + } + nodes = append(nodes, n) + } + return nodes, nil +} + +func (db *DB) CanClose(id string) (bool, []string, error) { + rows, err := db.Query( + "SELECT to_id FROM rels WHERE from_id = ? AND rel_type = ?", + id, models.RelBlocks, + ) + if err != nil { + return false, nil, err + } + defer rows.Close() + + var blocking []string + for rows.Next() { + var blockerID string + if err := rows.Scan(&blockerID); err != nil { + return false, nil, err + } + var tag string + err := db.QueryRow( + "SELECT tag FROM tags WHERE node_id = ? AND tag LIKE '_status::%'", + blockerID, + ).Scan(&tag) + if err == sql.ErrNoRows { + blocking = append(blocking, blockerID) + continue + } + if err != nil { + return false, nil, err + } + if strings.HasSuffix(tag, "::open") { + blocking = append(blocking, blockerID) + } + } + return len(blocking) == 0, blocking, nil +} + +func (db *DB) GetAllUsers() ([]string, error) { + rows, err := db.Query("SELECT n.id FROM nodes n JOIN tags t ON n.id = t.node_id WHERE t.tag = ?", models.PropertyTag("type", "user")) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []string + for rows.Next() { + var id string + rows.Scan(&id) + users = append(users, id) + } + return users, nil +} + +func (db *DB) GetSubtasks(parentID string) ([]*models.Node, error) { + rows, err := db.Query( + "SELECT to_id FROM rels WHERE from_id = ? AND rel_type = ?", + parentID, models.RelSubtask, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var nodes []*models.Node + for rows.Next() { + var id string + rows.Scan(&id) + n, err := db.NodeByID(id) + if err != nil { + return nil, err + } + nodes = append(nodes, n) + } + return nodes, nil +} + +func ParseRelFlag(s string) (models.RelType, string, error) { + parts := strings.SplitN(s, ":", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid relation format: %s (expected type:id)", s) + } + return models.RelType(parts[0]), parts[1], nil +} diff --git a/db/rel.go b/db/rel.go new file mode 100644 index 0000000..05b510b --- /dev/null +++ b/db/rel.go @@ -0,0 +1,57 @@ +package db + +import "axolotl/models" + +func (db *DB) AddRel(fromID, toID string, relType models.RelType) error { + _, err := db.Exec( + "INSERT OR IGNORE INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", + fromID, toID, relType, + ) + return err +} + +func (db *DB) RemoveRel(fromID, toID string, relType models.RelType) error { + _, err := db.Exec( + "DELETE FROM rels WHERE from_id = ? AND to_id = ? AND rel_type = ?", + fromID, toID, relType, + ) + return err +} + +func (db *DB) GetRelated(id string, relType models.RelType) ([]string, error) { + rows, err := db.Query( + "SELECT to_id FROM rels WHERE from_id = ? AND rel_type = ?", + id, relType, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []string + for rows.Next() { + var target string + rows.Scan(&target) + ids = append(ids, target) + } + return ids, nil +} + +func (db *DB) GetIncomingRels(id string, relType models.RelType) ([]string, error) { + rows, err := db.Query( + "SELECT from_id FROM rels WHERE to_id = ? AND rel_type = ?", + id, relType, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []string + for rows.Next() { + var source string + rows.Scan(&source) + ids = append(ids, source) + } + return ids, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2d35aa0 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module axolotl + +go 1.25.8 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.47.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f5b89e3 --- /dev/null +++ b/go.sum @@ -0,0 +1,35 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +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.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= diff --git a/main.go b/main.go new file mode 100644 index 0000000..be492e6 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "axolotl/cmd" + +func main() { + cmd.Execute() +} diff --git a/models/node.go b/models/node.go new file mode 100644 index 0000000..a4546e2 --- /dev/null +++ b/models/node.go @@ -0,0 +1,62 @@ +package models + +import "strings" + +type Node struct { + ID string `json:"id"` + Title string `json:"title"` + Content string `json:"content,omitempty"` + DueDate string `json:"due_date,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Tags []string `json:"tags,omitempty"` + Relations map[string][]string `json:"relations,omitempty"` +} + +type RelType string + +const ( + RelBlocks RelType = "blocks" + RelSubtask RelType = "subtask" + RelRelated RelType = "related" + RelCreated RelType = "created" + RelAssignee RelType = "assignee" +) + +func ParseTag(s string) (key, value string, isProperty bool) { + if strings.HasPrefix(s, "_") { + if parts := strings.SplitN(s[1:], "::", 2); len(parts) == 2 { + return parts[0], parts[1], true + } + } + return "", s, false +} + +func PropertyTag(key, value string) string { + return "_" + key + "::" + value +} + +func (n *Node) GetProperty(key string) string { + for _, tag := range n.Tags { + if k, v, ok := ParseTag(tag); ok && k == key { + return v + } + } + return "" +} + +func (n *Node) GetType() string { + if t := n.GetProperty("type"); t != "" { + return t + } + return "issue" +} + +func (n *Node) HasTag(tag string) bool { + for _, t := range n.Tags { + if t == tag { + return true + } + } + return false +} diff --git a/output/output.go b/output/output.go new file mode 100644 index 0000000..1f433f2 --- /dev/null +++ b/output/output.go @@ -0,0 +1,333 @@ +package output + +import ( + "axolotl/db" + "axolotl/models" + "encoding/json" + "fmt" + "io" + "sort" + "strings" + + "github.com/fatih/color" +) + +var ( + idColor = color.New(color.FgYellow) + titleColor = color.New(color.FgWhite, color.Bold) + statusOpen = color.New(color.FgYellow) + statusDone = color.New(color.FgHiBlack) + prioHigh = color.New(color.FgHiRed) + prioMedium = color.New(color.FgYellow) + prioLow = color.New(color.FgYellow, color.Faint) + tagColor = color.New(color.FgCyan) + nsColor = color.New(color.FgYellow) + dimColor = color.New(color.FgHiBlack) + contentColor = color.New(color.FgWhite) + labelColor = color.New(color.FgYellow) + typeIssue = color.New(color.FgMagenta) + typeNote = color.New(color.FgHiBlue) + typeUser = color.New(color.FgHiGreen) + typeNs = color.New(color.FgHiYellow) +) + +type iconSet struct { + Issue, Note, User, Namespace string + Blocks, Subtask, Related string + Assignee, Created string + Tag, Calendar string + Check, Cross string +} + +var icons = iconSet{ + Issue: "\uf188", // bug icon + Note: "\uf15c", // file-text + User: "\uf007", // user + Namespace: "\uf07b", // folder + Blocks: "\uf068", // minus + Subtask: "\uf0da", // caret-right + Related: "\uf0c1", // link + Assignee: "\uf007", // user + Created: "\uf007", // user + Tag: "\uf02b", // tag + Calendar: "\uf133", // calendar + Check: "\uf00c", // check + Cross: "\uf00d", // times +} + +func typeIcon(t string) string { + switch t { + case "issue": + return icons.Issue + case "note": + return icons.Note + case "user": + return icons.User + case "namespace": + return icons.Namespace + default: + return icons.Issue + } +} + +func typeIconColored(t string) string { + switch t { + case "issue": + return typeIssue.Sprint(icons.Issue) + case "note": + return typeNote.Sprint(icons.Note) + case "user": + return typeUser.Sprint(icons.User) + case "namespace": + return typeNs.Sprint(icons.Namespace) + default: + return typeIssue.Sprint(icons.Issue) + } +} + +func statusIcon(s string) string { + if s == "done" { + return icons.Check + } + return icons.Cross +} + +func statusIconColored(s string) string { + if s == "" { + return " " + } + if s == "done" { + return dimColor.Sprint("○") + } + return dimColor.Sprint("●") +} + +func statusColored(s string) string { + if s == "" { + return dimColor.Sprint("—") + } + if s == "done" { + return statusDone.Sprint("○ done") + } + return statusOpen.Sprint("● open") +} + +func prioColored(p string) string { + switch p { + case "high": + return prioHigh.Sprint("● high") + case "medium": + return prioMedium.Sprint("◆ medium") + case "low": + return prioLow.Sprint("○ low") + default: + return dimColor.Sprint("—") + } +} + +func PrintNodes(w io.Writer, nodes []*models.Node, jsonOut bool) error { + if jsonOut { + return json.NewEncoder(w).Encode(nodes) + } + fmt.Fprintln(w) + if len(nodes) == 0 { + fmt.Fprintln(w, dimColor.Sprint(" No results.")) + return nil + } + + sort.Slice(nodes, func(i, j int) bool { + si, sj := nodes[i].GetProperty("status"), nodes[j].GetProperty("status") + if si != sj { + return statusRank(si) > statusRank(sj) + } + pi, pj := nodes[i].GetProperty("prio"), nodes[j].GetProperty("prio") + return prioRank(pi) > prioRank(pj) + }) + + for _, n := range nodes { + tags := getDisplayTags(n) + nodeType := n.GetType() + var typeAndStatus string + if nodeType == "issue" { + typeAndStatus = statusIconColored(n.GetProperty("status")) + } else { + typeAndStatus = typeIconColored(nodeType) + " " + statusIconColored(n.GetProperty("status")) + } + fmt.Fprintf(w, " %s %s %s %s %s", + idColor.Sprintf("%s", n.ID), + prioColoredShort(n.GetProperty("prio")), + typeAndStatus, + titleColor.Sprint(truncate(n.Title, 35)), + dimColor.Sprint("["+n.GetProperty("namespace")+"]"), + ) + if len(tags) > 0 { + var hashTags []string + for _, t := range tags { + hashTags = append(hashTags, "#"+t) + } + fmt.Fprintf(w, " %s", tagColor.Sprint(strings.Join(hashTags, " "))) + } + fmt.Fprintln(w) + } + fmt.Fprintln(w) + return nil +} + +func prioColoredShort(p string) string { + switch p { + case "high": + return prioHigh.Sprint("\uf0e7 ") + case "medium": + return prioMedium.Sprint("\uf12a ") + case "low": + return prioLow.Sprint("\uf068 ") + default: + return " " + } +} + +func prioRank(p string) int { + switch p { + case "high": + return 3 + case "medium": + return 2 + case "low": + return 1 + default: + return 0 + } +} + +func statusRank(s string) int { + switch s { + case "open": + return 2 + case "done": + return 0 + default: + return 1 + } +} + +func getDisplayTags(n *models.Node) []string { + var tags []string + for _, t := range n.Tags { + if _, _, ok := models.ParseTag(t); !ok { + tags = append(tags, t) + } + } + return tags +} + +func PrintNode(w io.Writer, n *models.Node, jsonOut bool) error { + if jsonOut { + return json.NewEncoder(w).Encode(n) + } + icon := typeIcon(n.GetType()) + nodeType := strings.Title(n.GetType()) + + fmt.Fprintln(w) + fmt.Fprintf(w, " %s %s %s %s\n", + icon, + idColor.Sprint(n.ID), + titleColor.Sprint(n.Title), + dimColor.Sprint("["+nodeType+"]"), + ) + fmt.Fprintln(w, dimColor.Sprint(" ───────────────────────────")) + fmt.Fprintf(w, " Status: %s\n", statusColored(n.GetProperty("status"))) + fmt.Fprintf(w, " Priority: %s\n", prioColored(n.GetProperty("prio"))) + fmt.Fprintf(w, " Namespace: %s\n", nsColor.Sprint(n.GetProperty("namespace"))) + if n.DueDate != "" { + fmt.Fprintf(w, " Due: %s %s\n", icons.Calendar, n.DueDate) + } + fmt.Fprintf(w, " Created: %s\n", dimColor.Sprint(n.CreatedAt)) + + if n.Content != "" { + fmt.Fprintln(w) + fmt.Fprintln(w, labelColor.Sprint(" Content:")) + for _, line := range strings.Split(n.Content, "\n") { + fmt.Fprintf(w, dimColor.Sprint(" │ ")+contentColor.Sprint("%s\n"), line) + } + } + + if len(n.Tags) > 0 { + var tags []string + for _, t := range n.Tags { + if _, _, ok := models.ParseTag(t); !ok { + tags = append(tags, t) + } + } + if len(tags) > 0 { + fmt.Fprintln(w) + fmt.Fprintf(w, " Tags: %s\n", tagColor.Sprint(strings.Join(tags, " • "))) + } + } + + if len(n.Relations) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, labelColor.Sprint(" Relations:")) + for relType, ids := range n.Relations { + if relType == "created" { + continue + } + relIcon := "" + switch relType { + case "blocks": + relIcon = icons.Blocks + case "subtask": + relIcon = icons.Subtask + case "related": + relIcon = icons.Related + case "assignee": + relIcon = icons.Assignee + } + coloredIDs := make([]string, len(ids)) + for i, id := range ids { + coloredIDs[i] = idColor.Sprint(id) + } + fmt.Fprintf(w, " %s %s %s\n", relIcon, strings.Title(string(relType)), strings.Join(coloredIDs, " ")) + } + } + + fmt.Fprintln(w) + return nil +} + +func PrintAliases(w io.Writer, aliases []*db.Alias, jsonOut bool) error { + if jsonOut { + return json.NewEncoder(w).Encode(aliases) + } + if len(aliases) == 0 { + fmt.Fprintln(w, dimColor.Sprint(" No aliases defined.")) + return nil + } + fmt.Fprintln(w) + for _, a := range aliases { + fmt.Fprintf(w, " %s %s\n", + idColor.Sprint(a.Name), + dimColor.Sprint(a.Command), + ) + } + fmt.Fprintln(w) + return nil +} + +func PrintSuccess(w io.Writer, format string, args ...interface{}) { + fmt.Fprintf(w, icons.Check+" "+format+"\n", args...) +} + +func PrintDeleted(w io.Writer, id string) { + fmt.Fprintf(w, " "+icons.Cross+" Deleted %s\n", idColor.Sprint(id)) +} + +func PrintCreated(w io.Writer, dbPath string) { + fmt.Fprintf(w, " "+icons.Namespace+" Created %s\n", dimColor.Sprint(dbPath)) +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-1] + "…" +} diff --git a/parse/mentions.go b/parse/mentions.go new file mode 100644 index 0000000..d6da3db --- /dev/null +++ b/parse/mentions.go @@ -0,0 +1,17 @@ +package parse + +import ( + "maps" + "regexp" + "slices" +) + +var mentionRegex = regexp.MustCompile(`@([a-z0-9_]+)`) + +func Mentions(text string) []string { + seen := make(map[string]bool) + for _, m := range mentionRegex.FindAllStringSubmatch(text, -1) { + seen[m[1]] = true + } + return slices.Collect(maps.Keys(seen)) +}