diff --git a/cmd/alias.go b/cmd/alias.go
index 72fa137..76f0931 100644
--- a/cmd/alias.go
+++ b/cmd/alias.go
@@ -12,46 +12,38 @@ import (
var aliasList bool
var aliasCmd = &cobra.Command{
- Use: "alias [name] [command]",
- Short: "Manage aliases",
- Args: cobra.MaximumNArgs(2),
+ 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
+ w := cmd.OutOrStdout()
+
+ if aliasList || len(args) == 0 {
+ if aliases, err := d.ListAliases(); err == nil {
+ output.PrintAliases(w, aliases, jsonFlag)
}
- 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
+ if a, err := d.GetAlias(args[0]); err != nil {
+ fmt.Fprintln(os.Stderr, "alias not found:", args[0])
+ } else {
+ fmt.Println(a.Command)
}
- 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
+ fmt.Fprintln(os.Stderr, "failed to set alias:", err)
+ } else {
+ output.PrintAction(w, "Alias set", args[0], false)
}
- output.PrintSuccess(cmd.OutOrStdout(), "Alias '%s' set", args[0])
},
}
func init() {
rootCmd.AddCommand(aliasCmd)
- aliasCmd.Flags().BoolVar(&aliasList, "list", false, "list all aliases")
+ aliasCmd.Flags().BoolVar(&aliasList, "list", false, "")
}
diff --git a/cmd/create.go b/cmd/create.go
index 55b479d..80c77f6 100644
--- a/cmd/create.go
+++ b/cmd/create.go
@@ -6,63 +6,61 @@ import (
"axolotl/output"
"fmt"
"os"
+ "slices"
+ "strings"
"github.com/spf13/cobra"
)
-var createType, createStatus, createPrio, createNamespace string = "issue", "open", "", ""
-var createDue, createContent string
-var createTags, createRels []string
+var cDue, cContent, cDummy string
+var cTags, cRels []string
var createCmd = &cobra.Command{
- Use: "create
",
- Short: "Create a new node",
- Args: cobra.ExactArgs(1),
+ Use: "create ", 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"
+
+ if !slices.ContainsFunc(cTags, func(e string) bool { return strings.HasPrefix(e, "_type::") }) {
+ cTags = append(cTags, "_type::issue")
}
+ if slices.Contains(cTags, "_type::issue") && !slices.ContainsFunc(cTags, func(e string) bool { return strings.HasPrefix(e, "_status::") }) {
+ cTags = append(cTags, "_status::open")
+ }
+ if !slices.ContainsFunc(cTags, func(e string) bool { return strings.HasPrefix(e, "_namespace::") }) {
+ cTags = append(cTags, "_namespace::" + db.GetCurrentUser())
+ }
+
rels := make(map[models.RelType][]string)
- for _, r := range createRels {
- relType, target, err := db.ParseRelFlag(r)
+ for _, r := range cRels {
+ rt, tgt, err := db.ParseRelFlag(r)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
- rels[relType] = append(rels[relType], target)
+ rels[rt] = append(rels[rt], tgt)
}
- 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 {
+
+ if n, err := d.CreateNode(db.CreateParams{Title: args[0], Content: cContent, DueDate: cDue, Tags: cTags, Rels: rels}); err != nil {
fmt.Fprintln(os.Stderr, "failed to create:", err)
- return
+ } else {
+ output.PrintNode(cmd.OutOrStdout(), n, jsonFlag)
}
- 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)")
+ f := createCmd.Flags()
+ f.StringVar(&cDummy, "type", "issue", "")
+ f.StringVar(&cDummy, "status", "", "")
+ f.StringVar(&cDummy, "prio", "", "")
+ f.StringVar(&cDummy, "namespace", "", "")
+ f.StringVar(&cDue, "due", "", "")
+ f.StringVar(&cContent, "content", "", "")
+ f.StringArrayVar(&cTags, "tag", nil, "")
+ f.StringArrayVar(&cRels, "rel", nil, "")
}
diff --git a/cmd/delete.go b/cmd/delete.go
index 67b1526..a27fe88 100644
--- a/cmd/delete.go
+++ b/cmd/delete.go
@@ -11,43 +11,39 @@ import (
"github.com/spf13/cobra"
)
-var deleteForce bool
-
+var dForce bool
var deleteCmd = &cobra.Command{
- Use: "delete ",
- Short: "Delete a node",
- Args: cobra.ExactArgs(1),
+ Use: "delete ", 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)
+ n, err := d.NodeByID(args[0])
if err != nil {
- fmt.Fprintln(os.Stderr, " node not found:", id)
+ fmt.Fprintln(os.Stderr, " node not found:", args[0])
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.")
+
+ if !dForce {
+ fmt.Printf("Delete %s '%s'? [y/N]: ", n.GetProperty("type"), n.Title)
+ r, _ := bufio.NewReader(os.Stdin).ReadString('\n')
+ if r = strings.TrimSpace(strings.ToLower(r)); r != "y" && r != "yes" {
+ fmt.Println("Cancelled.")
return
}
}
- if err := d.DeleteNode(id); err != nil {
- fmt.Fprintln(os.Stderr, " failed to delete:", err)
- return
+
+ if err := d.DeleteNode(args[0]); err != nil {
+ fmt.Fprintln(os.Stderr, "failed to delete: ", err)
+ } else {
+ output.PrintAction(cmd.OutOrStdout(), "Deleted", args[0], true)
}
- output.PrintDeleted(cmd.OutOrStdout(), id)
},
}
func init() {
rootCmd.AddCommand(deleteCmd)
- deleteCmd.Flags().BoolVarP(&deleteForce, "force", "f", false, "skip confirmation")
+ deleteCmd.Flags().BoolVarP(&dForce, "force", "f", false, "")
}
diff --git a/cmd/edit.go b/cmd/edit.go
index 927bc3e..7dac8a9 100644
--- a/cmd/edit.go
+++ b/cmd/edit.go
@@ -11,21 +11,19 @@ import (
)
var editCmd = &cobra.Command{
- Use: "edit ",
- Short: "Edit node content in $EDITOR",
- Args: cobra.ExactArgs(1),
+ Use: "edit ", 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)
+ n, err := d.NodeByID(args[0])
if err != nil {
- fmt.Fprintln(os.Stderr, "node not found:", id)
+ fmt.Fprintln(os.Stderr, "node not found:", args[0])
return
}
+
tmp, err := os.CreateTemp("", "ax-*.md")
if err != nil {
fmt.Fprintln(os.Stderr, "failed to create temp file:", err)
@@ -40,24 +38,22 @@ var editCmd = &cobra.Command{
editor = "vi"
}
c := exec.Command(editor, tmp.Name())
- c.Stdin = os.Stdin
- c.Stdout = os.Stdout
- c.Stderr = os.Stderr
+ c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, 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 {
+
+ if content, err := os.ReadFile(tmp.Name()); err == nil {
+ if err := d.UpdateNode(args[0], db.UpdateParams{Content: string(content)}); err != nil {
+ fmt.Fprintln(os.Stderr, "failed to update:", err)
+ return
+ }
+ n, _ = d.NodeByID(args[0])
+ output.PrintNode(cmd.OutOrStdout(), n, jsonFlag)
+ } else {
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)
},
}
diff --git a/cmd/inbox.go b/cmd/inbox.go
index b6230dc..fae6530 100644
--- a/cmd/inbox.go
+++ b/cmd/inbox.go
@@ -3,23 +3,23 @@ package cmd
import (
"axolotl/db"
"axolotl/output"
+ "fmt"
+ "os"
"github.com/spf13/cobra"
)
var inboxCmd = &cobra.Command{
- Use: "inbox",
- Short: "Show your inbox",
+ 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 {
+ fmt.Fprintln(os.Stderr, err)
return
}
- output.PrintNodes(cmd.OutOrStdout(), nodes, jsonFlag)
+ if nodes, err := d.ListNodes(db.ListFilter{TagPrefixes: []string{"_inbox::" + db.GetCurrentUser()}}); err == nil {
+ output.PrintNodes(cmd.OutOrStdout(), nodes, jsonFlag)
+ }
},
}
diff --git a/cmd/init.go b/cmd/init.go
index e3c409f..1e2657f 100644
--- a/cmd/init.go
+++ b/cmd/init.go
@@ -11,28 +11,23 @@ import (
)
var initCmd = &cobra.Command{
- Use: "init [path]",
- Short: "Initialize a new database",
- Args: cobra.MaximumNArgs(1),
+ Use: "init [path]", Short: "Initialize a new database", Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
- path := "."
+ p := "."
if len(args) > 0 {
- path = args[0]
+ p = args[0]
}
- dbPath := filepath.Join(path, ".ax.db")
+ dbPath := filepath.Join(p, ".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 {
+ if err := db.Init(dbPath); err != nil {
fmt.Fprintln(os.Stderr, "failed to initialize:", err)
os.Exit(1)
}
- output.PrintCreated(cmd.OutOrStdout(), dbPath)
+ output.PrintAction(cmd.OutOrStdout(), "Created", dbPath, false)
},
}
-func init() {
- rootCmd.AddCommand(initCmd)
-}
+func init() { rootCmd.AddCommand(initCmd) }
diff --git a/cmd/list.go b/cmd/list.go
index 047664f..d450dce 100644
--- a/cmd/list.go
+++ b/cmd/list.go
@@ -9,40 +9,32 @@ import (
"github.com/spf13/cobra"
)
-var listType, listStatus, listPrio, listNamespace, listTag, listInbox, listAssignee string
+var lDummy, lAssignee string
+var lTags []string
var listCmd = &cobra.Command{
- Use: "list",
- Short: "List nodes",
+ 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
+ if nodes, err := d.ListNodes(db.ListFilter{TagPrefixes: lTags, Assignee: lAssignee}); err == nil {
+ output.PrintNodes(cmd.OutOrStdout(), nodes, jsonFlag)
+ } else {
+ fmt.Fprintln(os.Stderr, "err: %v", err)
}
- 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")
+ f := listCmd.Flags()
+ f.StringVar(&lDummy, "type", "", "")
+ f.StringVar(&lDummy, "status", "", "")
+ f.StringVar(&lDummy, "prio", "", "")
+ f.StringVar(&lDummy, "namespace", "", "")
+ f.StringVar(&lAssignee, "assignee", "", "")
+ f.StringArrayVar(&lTags, "tag", nil, "")
}
diff --git a/cmd/root.go b/cmd/root.go
index 622bd15..48f78fd 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -2,23 +2,46 @@ package cmd
import (
"os"
+ "strings"
"github.com/spf13/cobra"
)
var jsonFlag bool
-
-var rootCmd = &cobra.Command{
- Use: "ax",
- Short: "The axolotl issue tracker",
-}
+var rootCmd = &cobra.Command{Use: "ax", Short: "The axolotl issue tracker"}
func Execute() {
+ rootCmd.SetArgs(transformArgs(os.Args[1:]))
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
-func init() {
- rootCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, "output as JSON")
+func transformArgs(args []string) []string {
+ aliases := map[string]string{
+ "--status": "_status",
+ "--prio": "_prio",
+ "--type": "_type",
+ "--namespace": "_namespace",
+ }
+ result := []string{}
+
+ for i := 0; i < len(args); i++ {
+ if idx := strings.Index(args[i], "="); idx != -1 {
+ if prop, ok := aliases[args[i][:idx]]; ok {
+ result = append(result, "--tag", prop+"::"+args[i][idx+1:])
+ continue
+ }
+ }
+ if prop, ok := aliases[args[i]]; ok && i+1 < len(args) {
+ result, i = append(result, "--tag", prop+"::"+args[i+1]), i+1
+ continue
+ }
+ result = append(result, args[i])
+ }
+ return result
+}
+
+func init() {
+ rootCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, "")
}
diff --git a/cmd/show.go b/cmd/show.go
index c2670b3..51e6c3f 100644
--- a/cmd/show.go
+++ b/cmd/show.go
@@ -10,21 +10,18 @@ import (
)
var showCmd = &cobra.Command{
- Use: "show ",
- Short: "Show node details",
- Args: cobra.ExactArgs(1),
+ Use: "show ", 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 {
+ if n, err := d.NodeByID(args[0]); err == nil {
+ output.PrintNode(cmd.OutOrStdout(), n, jsonFlag)
+ } else {
fmt.Fprintln(os.Stderr, "node not found:", args[0])
- return
}
- output.PrintNode(cmd.OutOrStdout(), n, jsonFlag)
},
}
diff --git a/cmd/update.go b/cmd/update.go
index 2db8302..e784699 100644
--- a/cmd/update.go
+++ b/cmd/update.go
@@ -6,45 +6,45 @@ import (
"axolotl/output"
"fmt"
"os"
+ "slices"
+ "strings"
"github.com/spf13/cobra"
)
-var updateTitle, updateContent, updateStatus, updatePrio, updateDue string
-var updateClearDue bool
-var updateAddTags, updateRemoveTags, updateAddRels, updateRemoveRels []string
+var (
+ uTitle, uContent, uDue, dummy string
+ uClearDue bool
+ uAddTags, uRmTags, uAddRels, uRmRels []string
+)
var updateCmd = &cobra.Command{
- Use: "update ",
- Short: "Update a node",
- Args: cobra.ExactArgs(1),
+ Use: "update ", 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, rmRels := make(map[models.RelType][]string), make(map[models.RelType][]string)
+
+ parseRel := func(src []string, dst map[models.RelType][]string) bool {
+ for _, r := range src {
+ rt, tgt, err := db.ParseRelFlag(r)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return false
+ }
+ dst[rt] = append(dst[rt], tgt)
}
- addRels[relType] = append(addRels[relType], target)
+ return true
}
- 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 !parseRel(uAddRels, addRels) || !parseRel(uRmRels, rmRels) {
+ return
}
- if updateStatus == "done" {
- ok, blockers, err := d.CanClose(id)
+
+ if slices.Contains(uAddTags, "_status::done") {
+ ok, blockers, err := d.CanClose(args[0])
if err != nil {
fmt.Fprintln(os.Stderr, "failed to check blockers:", err)
return
@@ -53,42 +53,39 @@ var updateCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "cannot close: blocked by %v\n", blockers)
return
}
+ uRmTags = append(uRmTags, "_status::open")
+ } else if slices.Contains(uAddTags, "_status::open") {
+ uRmTags = append(uRmTags, "_status::done")
}
- 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 {
+ if slices.ContainsFunc(uAddTags, func(e string) bool { return strings.HasPrefix(e, "_prio") }) {
+ uRmTags = append(uRmTags, "_prio::low", "_prio::medium", "_prio::high")
+ }
+
+ uParams := db.UpdateParams{Title: uTitle, Content: uContent, DueDate: uDue, ClearDue: uClearDue,
+ AddTags: uAddTags, RemoveTags: uRmTags, AddRels: addRels, RemoveRels: rmRels}
+ if err := d.UpdateNode(args[0], uParams); err != nil {
fmt.Fprintln(os.Stderr, "failed to update:", err)
return
}
- n, err := d.NodeByID(id)
- if err != nil {
+ if n, err := d.NodeByID(args[0]); err == nil {
+ output.PrintNode(cmd.OutOrStdout(), n, jsonFlag)
+ } else {
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)")
+ f := updateCmd.Flags()
+ f.StringVar(&uTitle, "title", "", "")
+ f.StringVar(&uContent, "content", "", "")
+ f.StringVar(&uDue, "due", "", "")
+ f.BoolVar(&uClearDue, "clear-due", false, "")
+ f.StringVar(&dummy, "status", "", "")
+ f.StringVar(&dummy, "prio", "", "")
+ f.StringArrayVar(&uAddTags, "tag", nil, "")
+ f.StringArrayVar(&uRmTags, "tag-remove", nil, "")
+ f.StringArrayVar(&uAddRels, "rel", nil, "")
+ f.StringArrayVar(&uRmRels, "rel-remove", nil, "")
}
diff --git a/db/alias.go b/db/alias.go
index 9592d3c..c691368 100644
--- a/db/alias.go
+++ b/db/alias.go
@@ -2,35 +2,25 @@ package db
import "errors"
-type Alias struct {
- Name string
- Command string
-}
+type Alias struct{ Name, Command string }
-func (db *DB) GetAlias(name string) (*Alias, error) {
+func (db *DB) GetAlias(n 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
+ err := db.QueryRow("SELECT name, command FROM aliases WHERE name = ?", n).Scan(&a.Name, &a.Command)
+ return a, err
}
-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,
- )
+func (db *DB) SetAlias(n, c string) error {
+ _, err := db.Exec("INSERT INTO aliases (name, command) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET command = excluded.command", n, c)
return err
}
-func (db *DB) DeleteAlias(name string) error {
- res, err := db.Exec("DELETE FROM aliases WHERE name = ?", name)
+func (db *DB) DeleteAlias(n string) error {
+ res, err := db.Exec("DELETE FROM aliases WHERE name = ?", n)
if err != nil {
return err
}
- n, _ := res.RowsAffected()
- if n == 0 {
+ if a, _ := res.RowsAffected(); a == 0 {
return errors.New("alias not found")
}
return nil
@@ -42,7 +32,6 @@ func (db *DB) ListAliases() ([]*Alias, error) {
return nil, err
}
defer rows.Close()
-
var aliases []*Alias
for rows.Next() {
a := &Alias{}
diff --git a/db/db.go b/db/db.go
index 4da7f60..7de1c5a 100644
--- a/db/db.go
+++ b/db/db.go
@@ -8,8 +8,6 @@ import (
"os/user"
"path/filepath"
- "axolotl/models"
-
_ "modernc.org/sqlite"
)
@@ -18,38 +16,16 @@ type DB struct {
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)`,
-}
+var (
+ database *DB
+ 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 {
@@ -60,25 +36,22 @@ func GetDB() (*DB, error) {
return nil, err
}
for {
- path := filepath.Join(dir, ".ax.db")
- if _, err := os.Stat(path); err == nil {
- database, err = Open(path)
- if err != nil {
+ if _, err := os.Stat(filepath.Join(dir, ".ax.db")); err == nil {
+ if database, err = Open(filepath.Join(dir, ".ax.db")); err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
return database, nil
}
- parent := filepath.Dir(dir)
- if parent == dir {
+ if parent := filepath.Dir(dir); parent == dir {
return nil, errors.New("no .ax.db found (run 'ax init' first)")
+ } else {
+ dir = parent
}
- path = parent
}
}
-func Init(path string) (error) {
- dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0755); err != nil {
+func Init(path string) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
var err error
@@ -87,19 +60,16 @@ func Init(path string) (error) {
}
func Open(path string) (*DB, error) {
- database, err := sql.Open("sqlite", path)
+ db, 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 {
+ for _, q := range append([]string{"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=ON"}, migrations...) {
+ if _, err := db.Exec(q); err != nil {
return nil, err
}
}
- return &DB{DB: database, path: path}, nil
+ return &DB{DB: db, path: path}, nil
}
func GetCurrentUser() string {
@@ -111,44 +81,3 @@ func GetCurrentUser() string {
}
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
index 69ca6f1..340028f 100644
--- a/db/node.go
+++ b/db/node.go
@@ -10,37 +10,45 @@ import (
"time"
)
-func generateID() string {
- const chars = "abcdefghijklmnopqrstuvwxyz"
+func genID() string {
b := make([]byte, 5)
for i := range b {
- b[i] = chars[rand.Intn(26)]
+ b[i] = "abcdefghijklmnopqrstuvwxyz"[rand.Intn(26)]
}
return string(b)
}
+func ParseRelFlag(s string) (models.RelType, string, error) {
+ if p := strings.SplitN(s, ":", 2); len(p) == 2 {
+ return models.RelType(p[0]), p[1], nil
+ }
+ return "", "", fmt.Errorf("invalid relation format: %s (expected type:id)", s)
+}
+
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 {
+ id := genID()
+ e, _ := db.NodeExists(id)
+ if !e {
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
+ Title, Content, DueDate string
+ Tags []string
+ Rels map[models.RelType][]string
+}
+type UpdateParams struct {
+ Title, Content, DueDate string
+ ClearDue bool
+ AddTags, RemoveTags []string
+ AddRels, RemoveRels map[models.RelType][]string
+}
+type ListFilter struct {
+ TagPrefixes []string
+ Assignee string
}
func (db *DB) CreateNode(p CreateParams) (*models.Node, error) {
@@ -49,78 +57,35 @@ func (db *DB) CreateNode(p CreateParams) (*models.Node, error) {
return nil, err
}
defer tx.Rollback()
+ now, id := time.Now().UTC().Format(time.RFC3339), db.generateUniqueID()
- 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,
- )
+ _, 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 {
+ for _, t := range append(p.Tags, parse.Mentions(p.Title+" "+p.Content)...) {
+ if !strings.HasPrefix(t, "_") && strings.HasPrefix(t, "@") {
+ if _, err = tx.Exec("INSERT INTO tags (node_id, tag) VALUES (?, ?)", id, "_inbox::"+t[1:]); err != nil {
+ return nil, err
+ }
+ } else if _, err := tx.Exec("INSERT INTO tags (node_id, tag) VALUES (?, ?)", id, t); 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 {
+ for rt, tgts := range p.Rels {
+ for _, tgt := range tgts {
+ if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", id, tgt, rt); 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 {
@@ -128,231 +93,197 @@ func (db *DB) UpdateNode(id string, p UpdateParams) error {
}
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 {
+ upd := func(col, val string) error {
+ _, err := tx.Exec("UPDATE nodes SET "+col+" = ? WHERE id = ?", val, id)
return err
}
-
+ //TODO: does it make sense to check for emtpy sting? should it be possible to set the content to ""?
if p.Title != "" {
- _, err = tx.Exec("UPDATE nodes SET title = ? WHERE id = ?", p.Title, id)
- if err != nil {
+ if err := upd("title", p.Title); err != nil {
return err
}
}
if p.Content != "" {
- _, err = tx.Exec("UPDATE nodes SET content = ? WHERE id = ?", p.Content, id)
- if err != nil {
+ if err := upd("content", p.Content); err != nil {
return err
}
}
if p.DueDate != "" {
- _, err = tx.Exec("UPDATE nodes SET due_date = ? WHERE id = ?", p.DueDate, id)
- if err != nil {
+ if err := upd("due_date", p.DueDate); err != nil {
return err
}
}
if p.ClearDue {
- _, err = tx.Exec("UPDATE nodes SET due_date = NULL WHERE id = ?", id)
- if err != nil {
+ if _, err := tx.Exec("UPDATE nodes SET due_date = NULL WHERE id = ?", id); err != nil {
return err
}
}
- for _, tag := range p.AddTags {
- tx.Exec("INSERT OR IGNORE INTO tags (node_id, tag) VALUES (?, ?)", id, tag)
+ for _, t := range p.RemoveTags {
+ tx.Exec("DELETE FROM tags WHERE node_id = ? AND tag = ?", id, t)
}
- for _, tag := range p.RemoveTags {
- tx.Exec("DELETE FROM tags WHERE node_id = ? AND tag = ?", id, tag)
+ for _, t := range p.AddTags {
+ tx.Exec("INSERT OR IGNORE INTO tags (node_id, tag) VALUES (?, ?)", id, t)
}
-
- 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 rt, tgts := range p.RemoveRels {
+ for _, tgt := range tgts {
+ tx.Exec("DELETE FROM rels WHERE from_id = ? AND to_id = ? AND rel_type = ?", id, tgt, rt)
}
}
- 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)
+ for rt, tgts := range p.AddRels {
+ for _, tgt := range tgts {
+ tx.Exec("INSERT OR IGNORE INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", id, tgt, rt)
}
}
+ if _, err = tx.Exec("UPDATE nodes SET updated_at = ? WHERE id = ?", time.Now().UTC().Format(time.RFC3339), id); err != nil {
+ return err
+ }
return tx.Commit()
}
func (db *DB) DeleteNode(id string) error {
+ //TODO: check if this delete propagates?
_, 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) NodeExists(id string) (bool, error) {
+ var e bool
+ err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM nodes WHERE id = ?)", id).Scan(&e)
+ return e, err
+}
+
+func (db *DB) NodeByID(id string) (*models.Node, error) {
+ n := &models.Node{Relations: make(map[string][]string)}
+ q := db.QueryRow("SELECT id, title, COALESCE(content, ''), COALESCE(due_date, ''), created_at, updated_at FROM nodes WHERE id = ?", id)
+ if err := q.Scan(&n.ID, &n.Title, &n.Content, &n.DueDate, &n.CreatedAt, &n.UpdatedAt); err != nil {
+ return nil, err
+ }
+
+ if rows, err := db.Query("SELECT tag FROM tags WHERE node_id = ?", id); err == nil {
+ defer rows.Close()
+ for rows.Next() {
+ var tag string
+ rows.Scan(&tag)
+ n.Tags = append(n.Tags, tag)
+ }
+ } else {
+ return nil, err
+ }
+
+ if rows, err := db.Query("SELECT to_id, rel_type FROM rels WHERE from_id = ?", id); err == nil {
+ defer rows.Close()
+ for rows.Next() {
+ var toID, relType string
+ rows.Scan(&toID, &relType)
+ n.Relations[relType] = append(n.Relations[relType], toID)
+ }
+ } else {
+ return nil, err
+ }
+ return n, nil
}
func (db *DB) ListNodes(f ListFilter) ([]*models.Node, error) {
- q := "SELECT DISTINCT n.id FROM nodes n"
- args := []interface{}{}
- joins := []string{}
- conds := []string{}
+ q, args, joins, conds := "SELECT DISTINCT n.id FROM nodes n", []any{}, []string{}, []string{}
+ if len(f.TagPrefixes) == 0 {
+ f.TagPrefixes = append(f.TagPrefixes, "")
+ }
- 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))
+ joins = append(joins, "JOIN tags t_tag ON n.id = t_tag.node_id")
+ cond := ""
+ for _, t := range f.TagPrefixes {
+ cond += "t_tag.tag LIKE ? || '%' OR "
+ args = append(args, t)
}
+ conds = append(conds, "SUM(CASE WHEN "+cond[:len(cond)-4]+" THEN 1 ELSE 0 END) >= ?")
+ args = append(args, len(f.TagPrefixes))
+
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)
+ joins, conds, args = append(joins, "JOIN rels r_assign ON n.id = r_assign.from_id"), append(conds, "r_assign.to_id = ? AND r_assign.rel_type = ?"), append(args, f.Assignee, models.RelAssignee)
}
if len(joins) > 0 {
- q += " " + strings.Join(joins, " ")
+ q += " " + strings.Join(joins, " ") + " "
}
+ q += "GROUP BY n.id"
if len(conds) > 0 {
- q += " WHERE " + strings.Join(conds, " AND ")
+ q += " HAVING " + strings.Join(conds, " AND ")
}
- q += " ORDER BY n.created_at DESC"
- rows, err := db.Query(q, args...)
+ rows, err := db.Query(q+" ORDER BY n.created_at DESC", 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 {
+ if n, err := db.NodeByID(id); err == nil {
+ nodes = append(nodes, n)
+ } else {
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,
- )
+ 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 {
+ var bID, tag string
+ if err := rows.Scan(&bID); 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)
+ if err := db.QueryRow("SELECT tag FROM tags WHERE node_id = ? AND tag LIKE '_status::%'", bID).Scan(&tag); err == sql.ErrNoRows {
continue
- }
- if err != nil {
+ } else if err != nil {
return false, nil, err
}
if strings.HasSuffix(tag, "::open") {
- blocking = append(blocking, blockerID)
+ blocking = append(blocking, bID)
}
}
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"))
+ //TODO: Seperate sql query for performance
+ nodes, err := db.ListNodes(ListFilter{TagPrefixes: []string{"_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)
+ users := make([]string, len(nodes))
+ for i, n := range nodes {
+ users[i] = n.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,
- )
+ 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 {
+ if n, err := db.NodeByID(id); err == nil {
+ nodes = append(nodes, n)
+ } else {
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
index 05b510b..b343018 100644
--- a/db/rel.go
+++ b/db/rel.go
@@ -2,56 +2,33 @@ 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,
- )
+func (db *DB) AddRel(f, t string, r models.RelType) error {
+ _, err := db.Exec("INSERT OR IGNORE INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", f, t, r)
+ return err
+}
+func (db *DB) RemoveRel(f, t string, r models.RelType) error {
+ _, err := db.Exec("DELETE FROM rels WHERE from_id = ? AND to_id = ? AND rel_type = ?", f, t, r)
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,
- )
+func getIDs(db *DB, q, id string, r models.RelType) ([]string, error) {
+ rows, err := db.Query(q, id, r)
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)
+ var i string
+ rows.Scan(&i)
+ ids = append(ids, i)
}
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
+func (db *DB) GetRelated(id string, r models.RelType) ([]string, error) {
+ return getIDs(db, "SELECT to_id FROM rels WHERE from_id = ? AND rel_type = ?", id, r)
+}
+func (db *DB) GetIncomingRels(id string, r models.RelType) ([]string, error) {
+ return getIDs(db, "SELECT from_id FROM rels WHERE to_id = ? AND rel_type = ?", id, r)
}
diff --git a/models/node.go b/models/node.go
index a4546e2..72e0642 100644
--- a/models/node.go
+++ b/models/node.go
@@ -23,40 +23,13 @@ const (
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
+func (n *Node) GetProperty(k string) string {
+ for _, t := range n.Tags {
+ if strings.HasPrefix(t, "_") {
+ if p := strings.SplitN(t[1:], "::", 2); len(p) == 2 && p[0] == k {
+ return p[1]
+ }
}
}
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
index 1f433f2..df23b53 100644
--- a/output/output.go
+++ b/output/output.go
@@ -12,160 +12,106 @@ import (
"github.com/fatih/color"
)
+type RenderMap map[string]struct {
+ s string
+ l string
+ c *color.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)
+ cPrimary = color.New(color.FgCyan)
+ cSecond = color.New(color.FgMagenta)
+ cDim = color.New(color.FgHiBlack)
+ cText = color.New(color.FgWhite)
+ cTitle = color.New(color.FgWhite, color.Bold)
+ cGood = color.New(color.FgGreen)
+ cWarn = color.New(color.FgYellow)
+ cBad = color.New(color.FgRed)
+
+ typeRM = RenderMap{
+ "issue": {" ", "\uf188 issue", cSecond},
+ "note": {"\uf15c", "\uf15c note", cPrimary},
+ "user": {"\uf007", "\uf007 user", cGood},
+ "namespace": {"\uf07b", "\uf07b namespace", cWarn},
+ "": {" ", "n/a", cDim},
+ }
+ statusRM = RenderMap{
+ "open": {"●", "● open", cPrimary},
+ "done": {"○", "○ done", cDim},
+ "": {"—", "n/a", cDim},
+ }
+ prioRM = RenderMap{
+ "high": {"\uf0e7", "high", cBad},
+ "medium": {"\uf0e7", "medium", cWarn},
+ "low": {" ", "low", cDim},
+ "": {" ", "n/a", cDim},
+ }
+ relIcons = map[string]string{"blocks": "\uf068", "subtask": "\uf0da", "related": "\uf0c1", "assignee": "\uf007"}
+ prioRanks = map[string]int{"high": 3, "medium": 2, "low": 1}
+ statusRanks = map[string]int{"open": 2, "": 1, "done": 0}
)
-type iconSet struct {
- Issue, Note, User, Namespace string
- Blocks, Subtask, Related string
- Assignee, Created string
- Tag, Calendar string
- Check, Cross string
+const (
+ iconCalendar = "\uf133"
+ iconCheck = "\uf00c"
+ iconCross = "\uf00d"
+ iconNamespace = "\uf07b"
+)
+
+func render(rm RenderMap, key string, short bool) string {
+ v, ok := rm[key]
+ if !ok {
+ v, ok = rm[""]
+ if !ok {
+ return ""
+ }
+ }
+ if short {
+ return v.c.Sprint(v.s)
+ }
+ return v.c.Sprint(v.l)
}
-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 getDisplayTags(n *models.Node) []string {
+ var tags []string
+ for _, t := range n.Tags {
+ if !strings.HasPrefix(t, "_") {
+ tags = append(tags, t)
+ }
}
+ return tags
}
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."))
+ fmt.Fprintln(w, cDim.Sprint("No results."))
return nil
}
+ fmt.Fprintln(w)
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)
+ return statusRanks[si] > statusRanks[sj]
}
- pi, pj := nodes[i].GetProperty("prio"), nodes[j].GetProperty("prio")
- return prioRank(pi) > prioRank(pj)
+ return prioRanks[nodes[i].GetProperty("prio")] > prioRanks[nodes[j].GetProperty("prio")]
})
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")+"]"),
+ fmt.Fprintf(w, " %s %s %s %s %s %s",
+ cDim.Sprint(n.ID),
+ render(prioRM, n.GetProperty("prio"), true),
+ render(statusRM, n.GetProperty("status"), true),
+ render(typeRM, n.GetProperty("type"), true),
+ cTitle.Sprint(truncate(n.Title, 35)),
+ cDim.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.Fprintf(w, " %s", cPrimary.Sprint("#"+strings.Join(tags, " #")))
}
fmt.Fprintln(w)
}
@@ -173,121 +119,53 @@ func PrintNodes(w io.Writer, nodes []*models.Node, jsonOut bool) error {
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")))
+ fmt.Fprintf(w, " %s %s %s\n", render(typeRM, n.GetProperty("type"), false), cDim.Sprint(n.ID), cTitle.Sprint(n.Title))
+ fmt.Fprintln(w, cDim.Sprint(" ───────────────────────────────"))
+ fmt.Fprintf(w, " Status: %s\n", render(statusRM, n.GetProperty("status"), false))
+ fmt.Fprintf(w, " Priority: %s\n", render(prioRM, n.GetProperty("prio"), false))
+ fmt.Fprintf(w, " Namespace: %s\n", cWarn.Sprint(n.GetProperty("namespace")))
if n.DueDate != "" {
- fmt.Fprintf(w, " Due: %s %s\n", icons.Calendar, n.DueDate)
+ fmt.Fprintf(w, " Due: %s %s\n", iconCalendar, n.DueDate)
}
- fmt.Fprintf(w, " Created: %s\n", dimColor.Sprint(n.CreatedAt))
+ fmt.Fprintf(w, " Created: %s\n", cDim.Sprint(n.CreatedAt))
+ fmt.Fprintf(w, " Updated: %s\n", cDim.Sprint(n.UpdatedAt))
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)
+ fmt.Fprintln(w, "\n"+cPrimary.Sprint(" Content:"))
+ for i, line := range strings.Split(n.Content, "\n") {
+ if i > 5 {
+ fmt.Fprintf(w, "%s ...\n", cDim.Sprint(" │ "))
+ break
+ }
+ fmt.Fprintf(w, "%s%s\n", cDim.Sprint(" │ "), cText.Sprint(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 tags := getDisplayTags(n); len(tags) > 0 {
+ fmt.Fprintf(w, "\n tags: %s\n\n", cPrimary.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
+ if db, err := db.GetDB(); err == nil {
+ if len(n.Relations) > 0 {
+ for relType, ids := range n.Relations {
+ fmt.Fprintf(w, " %s\n", string(relType))
+ if relIcon, ok := relIcons[string(relType)]; ok && relType != "created" {
+ for _, id := range ids {
+ node, err := db.NodeByID(id)
+ if err == nil {
+ fmt.Fprintf(w, " %s %s\n", relIcon, node.Title)
+ }
+ }
+ }
}
- 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, " "))
}
+ } else {
+ fmt.Fprintf(w, "failed to attach to db: %v", err)
}
fmt.Fprintln(w)
@@ -299,30 +177,27 @@ func PrintAliases(w io.Writer, aliases []*db.Alias, jsonOut bool) error {
return json.NewEncoder(w).Encode(aliases)
}
if len(aliases) == 0 {
- fmt.Fprintln(w, dimColor.Sprint(" No aliases defined."))
+ fmt.Fprintln(w, cDim.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.Fprintf(w, " %s %s\n", cPrimary.Sprint(a.Name), cDim.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 PrintAction(w io.Writer, action, detail string, isError bool) {
+ if isError {
+ fmt.Fprintln(w, cBad.Sprint(iconCross+" "+action+" ")+cDim.Sprint(detail))
+ return
+ }
+ icon := iconCheck
+ if action == "Created" {
+ icon = iconNamespace
+ }
+ fmt.Fprintln(w, cGood.Sprint(icon+" "+action+" ")+cDim.Sprint(detail))
}
func truncate(s string, max int) string {
diff --git a/parse/mentions.go b/parse/mentions.go
index d6da3db..7f5dab6 100644
--- a/parse/mentions.go
+++ b/parse/mentions.go
@@ -6,11 +6,11 @@ import (
"slices"
)
-var mentionRegex = regexp.MustCompile(`@([a-z0-9_]+)`)
+var r = regexp.MustCompile(`@([a-z0-9_]+)`)
-func Mentions(text string) []string {
+func Mentions(t string) []string {
seen := make(map[string]bool)
- for _, m := range mentionRegex.FindAllStringSubmatch(text, -1) {
+ for _, m := range r.FindAllStringSubmatch(t, -1) {
seen[m[1]] = true
}
return slices.Collect(maps.Keys(seen))