diff --git a/README.md b/README.md index fd84f91..cf18f15 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ single portable binary, built from ~1300 lines of Go code. - **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 +- **Alias system** - define custom command shortcuts with argument expansion - **Single binary** - no dependencies, portable `.ax.db` file ## Installation @@ -46,7 +46,7 @@ ax update abc12 --status done ax inbox # Define an alias -ax alias mywork "list --namespace myproject --status open" +ax alias mywork "list --namespace myproject --status open" --desc "My project tasks" ``` ## Commands @@ -101,8 +101,7 @@ Query and list nodes. | `--status` | Filter by status | | `--prio` | Filter by priority | | `--namespace` | Filter by namespace | -| `--tag` | Filter by tag | -| `--inbox` | Filter by inbox user | +| `--tag` | Filter by tag (repeatable) | | `--assignee` | Filter by assignee | ### `ax edit ` @@ -117,14 +116,38 @@ Delete a node. Prompts for confirmation unless `--force`. Show issues in current user's inbox (from @mentions). -### `ax alias [name] [command]` +### `ax alias [name] [command] [flags]` Manage aliases. ```bash -ax alias myinbox "list --inbox me" -ax alias --list -ax alias myinbox # show alias command +ax alias # list all aliases +ax alias mywork "list --tag work" # create alias +ax alias mywork # show alias command +ax alias mywork "list --tag work2" # update alias +ax alias delete mywork # delete alias +``` + +**Default aliases:** + +| Alias | Command | Description | +|-------|---------|-------------| +| `mine` | `list --assignee $me --tag _status::open` | Show open tasks assigned to you | +| `due` | `list --tag _status::open --tag _due` | Show open tasks with due dates | +| `new` | `create $@` | Create a new task | + +**Alias argument expansion:** + +| Variable | Expands to | +|----------|------------| +| `$me` | Current username | +| `$@` | All arguments | +| `$1`, `$2`, ... | Positional arguments | + +```bash +# Create alias with argument expansion +ax alias find "list --tag $1 --status $2" +ax find backend open # expands to: list --tag backend --status open ``` ## Relations @@ -210,6 +233,22 @@ Example output: } ``` +## Configuration + +`ax` stores user configuration in a JSON file. It searches for `.axconfig` in the +current directory and parent directories (like git finds `.git`), falling back to +`~/.config/ax/config.json`. + +**Config file format:** +```json +{ + "user": "alice", + "aliases": [ + {"name": "mywork", "command": "list --namespace myproject", "description": "My tasks"} + ] +} +``` + ## Database Location `ax` searches for `.ax.db` in the current directory and parent directories, diff --git a/cmd/alias.go b/cmd/alias.go index 76f0931..61e6289 100644 --- a/cmd/alias.go +++ b/cmd/alias.go @@ -1,41 +1,36 @@ package cmd import ( - "axolotl/db" "axolotl/output" + "axolotl/service" "fmt" "os" "github.com/spf13/cobra" ) -var aliasList bool +var aliasDesc string 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 - } w := cmd.OutOrStdout() - if aliasList || len(args) == 0 { - if aliases, err := d.ListAliases(); err == nil { + if len(args) == 0 { + if aliases, err := cfg.ListAliases(); err == nil { output.PrintAliases(w, aliases, jsonFlag) } return } if len(args) == 1 { - if a, err := d.GetAlias(args[0]); err != nil { + if a, err := cfg.GetAlias(args[0]); err != nil { fmt.Fprintln(os.Stderr, "alias not found:", args[0]) } else { fmt.Println(a.Command) } return } - if err := d.SetAlias(args[0], args[1]); err != nil { + if err := cfg.SetAlias(&service.Alias{Name: args[0], Command: args[1], Description: aliasDesc}); err != nil { fmt.Fprintln(os.Stderr, "failed to set alias:", err) } else { output.PrintAction(w, "Alias set", args[0], false) @@ -43,7 +38,19 @@ var aliasCmd = &cobra.Command{ }, } +var aliasDeleteCmd = &cobra.Command{ + Use: "delete ", Short: "Delete an alias", Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if err := cfg.DeleteAlias(args[0]); err != nil { + fmt.Fprintln(os.Stderr, err) + } else { + output.PrintAction(cmd.OutOrStdout(), "Alias deleted", args[0], false) + } + }, +} + func init() { rootCmd.AddCommand(aliasCmd) - aliasCmd.Flags().BoolVar(&aliasList, "list", false, "") + aliasCmd.AddCommand(aliasDeleteCmd) + aliasCmd.Flags().StringVar(&aliasDesc, "desc", "", "description for the alias") } diff --git a/cmd/create.go b/cmd/create.go index 2b943af..d697ffa 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -4,6 +4,7 @@ import ( "axolotl/db" "axolotl/models" "axolotl/output" + "axolotl/service" "fmt" "os" "slices" @@ -32,29 +33,24 @@ var createCmd = &cobra.Command{ } rels := make(map[models.RelType][]string) - relCreated, relNamespace := false, false + relNamespace := false for _, r := range cRels { - rt, tgt, err := db.ParseRelFlag(r) + rt, tgt, err := parseRelFlag(r) if err != nil { fmt.Fprintln(os.Stderr, err) return } - if rt == models.RelCreated { - relCreated = true - } if rt == models.RelInNamespace { relNamespace = true } rels[rt] = append(rels[rt], tgt) } - if !relCreated { - rels[models.RelCreated] = append(rels[models.RelCreated], db.GetCurrentUser()) - } if !relNamespace { - rels[models.RelInNamespace] = append(rels[models.RelInNamespace], db.GetCurrentUser()) + rels[models.RelInNamespace] = append(rels[models.RelInNamespace], cfg.GetUser()) } - if n, err := d.CreateNode(db.CreateParams{Title: args[0], Content: cContent, DueDate: cDue, Tags: cTags, Rels: rels}); err != nil { + svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser()) + if n, err := svc.Create(args[0], cContent, cDue, cTags, rels); err != nil { fmt.Fprintln(os.Stderr, "failed to create:", err) } else { output.PrintNode(cmd.OutOrStdout(), n, jsonFlag) diff --git a/cmd/delete.go b/cmd/delete.go index a27fe88..56e7ab6 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -3,6 +3,7 @@ package cmd import ( "axolotl/db" "axolotl/output" + "axolotl/service" "bufio" "fmt" "os" @@ -20,7 +21,8 @@ var deleteCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, err) return } - n, err := d.NodeByID(args[0]) + svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser()) + n, err := svc.GetByID(args[0]) if err != nil { fmt.Fprintln(os.Stderr, " node not found:", args[0]) return @@ -35,7 +37,7 @@ var deleteCmd = &cobra.Command{ } } - if err := d.DeleteNode(args[0]); err != nil { + if err := svc.Delete(args[0]); err != nil { fmt.Fprintln(os.Stderr, "failed to delete: ", err) } else { output.PrintAction(cmd.OutOrStdout(), "Deleted", args[0], true) diff --git a/cmd/edit.go b/cmd/edit.go index 2c2e695..3945c66 100644 --- a/cmd/edit.go +++ b/cmd/edit.go @@ -3,6 +3,7 @@ package cmd import ( "axolotl/db" "axolotl/output" + "axolotl/service" "fmt" "os" "os/exec" @@ -18,7 +19,8 @@ var editCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, err) return } - n, err := d.NodeByID(args[0]) + svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser()) + n, err := svc.GetByID(args[0]) if err != nil { fmt.Fprintln(os.Stderr, "node not found:", args[0]) return @@ -45,12 +47,12 @@ var editCmd = &cobra.Command{ } if content, err := os.ReadFile(tmp.Name()); err == nil { - c := string(content) - if err := d.UpdateNode(args[0], db.UpdateParams{Content: &c}); err != nil { + n.Content = string(content) + if err := svc.Update(n); err != nil { fmt.Fprintln(os.Stderr, "failed to update:", err) return } - n, _ = d.NodeByID(args[0]) + n, _ = svc.GetByID(args[0]) output.PrintNode(cmd.OutOrStdout(), n, jsonFlag) } else { fmt.Fprintln(os.Stderr, "failed to read temp file:", err) diff --git a/cmd/inbox.go b/cmd/inbox.go index 066b1f4..b8b7d37 100644 --- a/cmd/inbox.go +++ b/cmd/inbox.go @@ -3,6 +3,7 @@ package cmd import ( "axolotl/db" "axolotl/output" + "axolotl/service" "fmt" "os" @@ -17,7 +18,7 @@ var inboxCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, err) return } - userID, err := d.GetUserByUsername(db.GetCurrentUser()) + userID, err := d.GetUserByUsername(cfg.GetUser()) if err != nil { fmt.Fprintln(os.Stderr, err) return @@ -26,7 +27,8 @@ var inboxCmd = &cobra.Command{ output.PrintNodes(cmd.OutOrStdout(), nil, jsonFlag) return } - if nodes, err := d.ListNodes(db.ListFilter{MentionsUser: userID}); err == nil { + svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser()) + if nodes, err := svc.List(service.WithMentions(userID)); err == nil { output.PrintNodes(cmd.OutOrStdout(), nodes, jsonFlag) } }, diff --git a/cmd/list.go b/cmd/list.go index 6e02513..4148649 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -3,6 +3,7 @@ package cmd import ( "axolotl/db" "axolotl/output" + "axolotl/service" "fmt" "os" @@ -20,7 +21,8 @@ var listCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, err) return } - if nodes, err := d.ListNodes(db.ListFilter{TagPrefixes: lTags, Assignee: lAssignee}); err == nil { + svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser()) + if nodes, err := svc.List(service.WithTags(lTags...), service.WithAssignee(lAssignee)); err == nil { output.PrintNodes(cmd.OutOrStdout(), nodes, jsonFlag) } else { fmt.Fprintf(os.Stderr, "err: %v\n", err) diff --git a/cmd/rel.go b/cmd/rel.go new file mode 100644 index 0000000..e96f5ca --- /dev/null +++ b/cmd/rel.go @@ -0,0 +1,14 @@ +package cmd + +import ( + "axolotl/models" + "fmt" + "strings" +) + +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) +} diff --git a/cmd/root.go b/cmd/root.go index 48f78fd..7bea9ff 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,8 @@ package cmd import ( + "axolotl/service" + "fmt" "os" "strings" @@ -8,15 +10,43 @@ import ( ) var jsonFlag bool +var cfg service.Config var rootCmd = &cobra.Command{Use: "ax", Short: "The axolotl issue tracker"} func Execute() { + var err error + cfg, err = service.LoadConfig() + if err != nil { + fmt.Fprintln(os.Stderr, "failed to load config:", err) + os.Exit(1) + } + registerAliasCommands() rootCmd.SetArgs(transformArgs(os.Args[1:])) if err := rootCmd.Execute(); err != nil { os.Exit(1) } } +func registerAliasCommands() { + rootCmd.AddGroup(&cobra.Group{ID: "aliases", Title: "Aliases:"}) + aliases, _ := cfg.ListAliases() + for _, a := range aliases { + a := a + rootCmd.AddCommand(&cobra.Command{ + Use: a.Name, + Short: a.Description, + GroupID: "aliases", + Run: func(cmd *cobra.Command, args []string) { + expanded := service.ExpandAlias(a, args, cfg.GetUser()) + rootCmd.SetArgs(transformArgs(expanded)) + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } + }, + }) + } +} + func transformArgs(args []string) []string { aliases := map[string]string{ "--status": "_status", diff --git a/cmd/show.go b/cmd/show.go index 51e6c3f..8390fb8 100644 --- a/cmd/show.go +++ b/cmd/show.go @@ -3,6 +3,7 @@ package cmd import ( "axolotl/db" "axolotl/output" + "axolotl/service" "fmt" "os" @@ -17,7 +18,8 @@ var showCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, err) return } - if n, err := d.NodeByID(args[0]); err == nil { + svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser()) + if n, err := svc.GetByID(args[0]); err == nil { output.PrintNode(cmd.OutOrStdout(), n, jsonFlag) } else { fmt.Fprintln(os.Stderr, "node not found:", args[0]) diff --git a/cmd/update.go b/cmd/update.go index 3fd604b..9fe052e 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -4,6 +4,7 @@ import ( "axolotl/db" "axolotl/models" "axolotl/output" + "axolotl/service" "fmt" "os" "slices" @@ -26,11 +27,18 @@ var updateCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, err) return } - addRels, rmRels := make(map[models.RelType][]string), make(map[models.RelType][]string) + svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser()) + node, err := svc.GetByID(args[0]) + if err != nil { + fmt.Fprintln(os.Stderr, "node not found:", args[0]) + 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) + rt, tgt, err := parseRelFlag(r) if err != nil { fmt.Fprintln(os.Stderr, err) return false @@ -44,7 +52,7 @@ var updateCmd = &cobra.Command{ } if slices.Contains(uAddTags, "_status::done") { - ok, blockers, err := d.CanClose(args[0]) + ok, blockers, err := svc.CanClose(args[0]) if err != nil { fmt.Fprintln(os.Stderr, "failed to check blockers:", err) return @@ -61,22 +69,46 @@ var updateCmd = &cobra.Command{ uRmTags = append(uRmTags, "_prio::low", "_prio::medium", "_prio::high") } - uParams := db.UpdateParams{ClearDue: uClearDue, - AddTags: uAddTags, RemoveTags: uRmTags, AddRels: addRels, RemoveRels: rmRels} if cmd.Flags().Changed("title") { - uParams.Title = &uTitle + node.Title = uTitle } if cmd.Flags().Changed("content") { - uParams.Content = &uContent + node.Content = uContent } if cmd.Flags().Changed("due") { - uParams.DueDate = &uDue + node.DueDate = uDue } - if err := d.UpdateNode(args[0], uParams); err != nil { + if uClearDue { + node.DueDate = "" + } + + for _, t := range uRmTags { + node.Tags = slices.DeleteFunc(node.Tags, func(e string) bool { return e == t }) + } + for _, t := range uAddTags { + if !slices.Contains(node.Tags, t) { + node.Tags = append(node.Tags, t) + } + } + + for rt, tgts := range rmRels { + for _, tgt := range tgts { + node.Relations[string(rt)] = slices.DeleteFunc(node.Relations[string(rt)], func(e string) bool { return e == tgt }) + } + } + for rt, tgts := range addRels { + for _, tgt := range tgts { + if !slices.Contains(node.Relations[string(rt)], tgt) { + node.Relations[string(rt)] = append(node.Relations[string(rt)], tgt) + } + } + } + + if err := svc.Update(node); err != nil { fmt.Fprintln(os.Stderr, "failed to update:", err) return } - if n, err := d.NodeByID(args[0]); err == nil { + if n, err := svc.GetByID(args[0]); err == nil { output.PrintNode(cmd.OutOrStdout(), n, jsonFlag) } else { fmt.Fprintln(os.Stderr, "failed to fetch node:", err) diff --git a/db/alias.go b/db/alias.go deleted file mode 100644 index c691368..0000000 --- a/db/alias.go +++ /dev/null @@ -1,42 +0,0 @@ -package db - -import "errors" - -type Alias struct{ Name, Command string } - -func (db *DB) GetAlias(n string) (*Alias, error) { - a := &Alias{} - err := db.QueryRow("SELECT name, command FROM aliases WHERE name = ?", n).Scan(&a.Name, &a.Command) - return a, err -} - -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(n string) error { - res, err := db.Exec("DELETE FROM aliases WHERE name = ?", n) - if err != nil { - return err - } - if a, _ := res.RowsAffected(); a == 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 index 7de1c5a..3f0cda0 100644 --- a/db/db.go +++ b/db/db.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "os" - "os/user" "path/filepath" _ "modernc.org/sqlite" @@ -22,7 +21,6 @@ var ( `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)`, } ) @@ -72,12 +70,14 @@ func Open(path string) (*DB, error) { return &DB{DB: db, path: path}, nil } -func GetCurrentUser() string { - if u := os.Getenv("AX_USER"); u != "" { - return u +func (db *DB) GetUserByUsername(username string) (string, error) { + var id string + err := db.QueryRow(` + SELECT n.id FROM nodes n + JOIN tags t ON n.id = t.node_id + WHERE n.title = ? AND t.tag = '_type::user'`, username).Scan(&id) + if err == sql.ErrNoRows { + return "", nil } - if u, err := user.Current(); err == nil { - return u.Username - } - return "unknown" + return id, err } diff --git a/db/node.go b/db/node.go deleted file mode 100644 index 6960cc0..0000000 --- a/db/node.go +++ /dev/null @@ -1,431 +0,0 @@ -package db - -import ( - "axolotl/models" - "axolotl/parse" - "database/sql" - "fmt" - "math/rand" - "strings" - "time" -) - -func genID() string { - b := make([]byte, 5) - for i := range b { - 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 { - for { - id := genID() - e, _ := db.NodeExists(id) - if !e { - return id - } - } -} - -func (db *DB) ensureUser(tx *sql.Tx, username string) (string, error) { - var existingID string - err := tx.QueryRow(` - SELECT n.id FROM nodes n - JOIN tags t ON n.id = t.node_id - WHERE n.title = ? AND t.tag = '_type::user'`, username).Scan(&existingID) - if err == nil { - return existingID, nil - } - if err != sql.ErrNoRows { - return "", err - } - - id := db.generateUniqueID() - now := time.Now().UTC().Format(time.RFC3339) - if _, err := tx.Exec("INSERT INTO nodes (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)", - id, username, now, now); err != nil { - return "", err - } - if _, err := tx.Exec("INSERT INTO tags (node_id, tag) VALUES (?, '_type::user')", id); err != nil { - return "", err - } - return id, nil -} - -func (db *DB) resolveUserRef(tx *sql.Tx, ref string) (string, error) { - if exists, _ := db.NodeExists(ref); exists { - return ref, nil - } - return db.ensureUser(tx, ref) -} - -func (db *DB) GetUserByUsername(username string) (string, error) { - var id string - err := db.QueryRow(` - SELECT n.id FROM nodes n - JOIN tags t ON n.id = t.node_id - WHERE n.title = ? AND t.tag = '_type::user'`, username).Scan(&id) - if err == sql.ErrNoRows { - return "", nil - } - return id, err -} - -func (db *DB) ensureNamespace(tx *sql.Tx, name string) (string, error) { - var existingID string - err := tx.QueryRow(` - SELECT n.id FROM nodes n - JOIN tags t ON n.id = t.node_id - WHERE n.title = ? AND t.tag = '_type::namespace'`, name).Scan(&existingID) - if err == nil { - return existingID, nil - } - if err != sql.ErrNoRows { - return "", err - } - - id := db.generateUniqueID() - now := time.Now().UTC().Format(time.RFC3339) - if _, err := tx.Exec("INSERT INTO nodes (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)", - id, name, now, now); err != nil { - return "", err - } - if _, err := tx.Exec("INSERT INTO tags (node_id, tag) VALUES (?, '_type::namespace')", id); err != nil { - return "", err - } - if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", - id, id, models.RelInNamespace); err != nil { - return "", err - } - userID, err := db.resolveUserRef(tx, GetCurrentUser()) - if err != nil { - return "", err - } - if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", - id, userID, models.RelCreated); err != nil { - return "", err - } - return id, nil -} - -func (db *DB) resolveNamespaceRef(tx *sql.Tx, ref string) (string, error) { - if exists, _ := db.NodeExists(ref); exists { - return ref, nil - } - return db.ensureNamespace(tx, ref) -} - -type CreateParams struct { - 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 - MentionsUser string -} - -func (db *DB) CreateNode(p CreateParams) (*models.Node, error) { - tx, err := db.Begin() - if err != nil { - return nil, err - } - defer tx.Rollback() - now, id := time.Now().UTC().Format(time.RFC3339), 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 - } - - for _, m := range parse.Mentions(p.Title + " " + p.Content) { - userID, err := db.resolveUserRef(tx, m) - if err != nil { - return nil, err - } - if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", id, userID, models.RelMentions); err != nil { - return nil, err - } - } - - for _, t := range p.Tags { - if _, err := tx.Exec("INSERT INTO tags (node_id, tag) VALUES (?, ?)", id, t); err != nil { - return nil, err - } - } - for rt, tgts := range p.Rels { - for _, tgt := range tgts { - if rt == models.RelAssignee || rt == models.RelCreated { - var err error - if tgt, err = db.resolveUserRef(tx, tgt); err != nil { - return nil, err - } - } - if rt == models.RelInNamespace { - var err error - if tgt, err = db.resolveNamespaceRef(tx, tgt); err != nil { - return nil, err - } - } - if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", id, tgt, rt); err != nil { - return nil, err - } - } - } - if err := tx.Commit(); err != nil { - return nil, err - } - return db.NodeByID(id) -} - -func (db *DB) UpdateNode(id string, p UpdateParams) error { - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - - var currentTitle, currentContent string - err = tx.QueryRow("SELECT title, COALESCE(content, '') FROM nodes WHERE id = ?", id).Scan(¤tTitle, ¤tContent) - if err != nil { - return err - } - - upd := func(col, val string) error { - _, err := tx.Exec("UPDATE nodes SET "+col+" = ? WHERE id = ?", val, id) - return err - } - newTitle, newContent := currentTitle, currentContent - if p.Title != nil { - if err := upd("title", *p.Title); err != nil { - return err - } - newTitle = *p.Title - } - if p.Content != nil { - if err := upd("content", *p.Content); err != nil { - return err - } - newContent = *p.Content - } - if p.DueDate != nil { - if err := upd("due_date", *p.DueDate); err != nil { - return err - } - } - if p.ClearDue { - if _, err := tx.Exec("UPDATE nodes SET due_date = NULL WHERE id = ?", id); err != nil { - return err - } - } - - if p.Title != nil || p.Content != nil { - newMentions := parse.Mentions(newTitle + " " + newContent) - rows, err := tx.Query("SELECT to_id FROM rels WHERE from_id = ? AND rel_type = ?", id, models.RelMentions) - if err != nil { - return err - } - existingMentionIDs := make(map[string]bool) - for rows.Next() { - var uid string - if err := rows.Scan(&uid); err != nil { - rows.Close() - return err - } - existingMentionIDs[uid] = true - } - rows.Close() - - mentionedUserIDs := make(map[string]bool) - for _, m := range newMentions { - userID, err := db.resolveUserRef(tx, m) - if err != nil { - return err - } - mentionedUserIDs[userID] = true - if !existingMentionIDs[userID] { - if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", id, userID, models.RelMentions); err != nil { - return err - } - } - } - - for uid := range existingMentionIDs { - if !mentionedUserIDs[uid] { - if _, err := tx.Exec("DELETE FROM rels WHERE from_id = ? AND to_id = ? AND rel_type = ?", id, uid, models.RelMentions); err != nil { - return err - } - } - } - } - - for _, t := range p.RemoveTags { - tx.Exec("DELETE FROM tags WHERE node_id = ? AND tag = ?", id, t) - } - for _, t := range p.AddTags { - tx.Exec("INSERT OR IGNORE INTO tags (node_id, tag) VALUES (?, ?)", id, t) - } - 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 rt, tgts := range p.AddRels { - for _, tgt := range tgts { - if rt == models.RelAssignee || rt == models.RelCreated { - var err error - if tgt, err = db.resolveUserRef(tx, tgt); err != nil { - return err - } - } - if rt == models.RelInNamespace { - var err error - if tgt, err = db.resolveNamespaceRef(tx, tgt); err != nil { - return err - } - } - 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 { - _, err := db.Exec("DELETE FROM nodes WHERE id = ?", id) - return err -} - -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, args, joins, conds := "SELECT DISTINCT n.id FROM nodes n", []any{}, []string{}, []string{} - if len(f.TagPrefixes) == 0 { - f.TagPrefixes = append(f.TagPrefixes, "") - } - - 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) - } - if f.MentionsUser != "" { - joins = append(joins, "JOIN rels r_mentions ON n.id = r_mentions.from_id") - conds = append(conds, "r_mentions.to_id = ? AND r_mentions.rel_type = ?") - args = append(args, f.MentionsUser, models.RelMentions) - } - - if len(joins) > 0 { - q += " " + strings.Join(joins, " ") + " " - } - q += "GROUP BY n.id" - if len(conds) > 0 { - q += " HAVING " + strings.Join(conds, " AND ") - } - - 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 - } - if n, err := db.NodeByID(id); err == nil { - nodes = append(nodes, n) - } else { - return nil, err - } - } - 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 bID, tag string - if err := rows.Scan(&bID); err != nil { - return false, nil, err - } - if err := db.QueryRow("SELECT tag FROM tags WHERE node_id = ? AND tag LIKE '_status::%'", bID).Scan(&tag); err == sql.ErrNoRows { - continue - } else if err != nil { - return false, nil, err - } - if strings.HasSuffix(tag, "::open") { - blocking = append(blocking, bID) - } - } - return len(blocking) == 0, blocking, nil -} diff --git a/db/rel.go b/db/rel.go index 22bc98d..407fb41 100644 --- a/db/rel.go +++ b/db/rel.go @@ -34,13 +34,17 @@ func (db *DB) GetIncomingRels(id string, r models.RelType) ([]string, error) { } func (db *DB) GetRelNames(n *models.Node, r models.RelType) ([]string, error) { - result := make([]string, 0, len(n.Relations[string(r)])) - for _, id := range n.Relations[string(r)] { - node, err := db.NodeByID(id) - if err != nil { + ids := n.Relations[string(r)] + if len(ids) == 0 { + return nil, nil + } + result := make([]string, 0, len(ids)) + for _, id := range ids { + var title string + if err := db.QueryRow("SELECT title FROM nodes WHERE id = ?", id).Scan(&title); err != nil { return nil, err } - result = append(result, node.Title) + result = append(result, title) } return result, nil } diff --git a/output/output.go b/output/output.go index 02b95b7..1f28a2a 100644 --- a/output/output.go +++ b/output/output.go @@ -3,6 +3,7 @@ package output import ( "axolotl/db" "axolotl/models" + "axolotl/service" "encoding/json" "fmt" "io" @@ -169,7 +170,7 @@ func PrintNode(w io.Writer, n *models.Node, jsonOut bool) error { return nil } -func PrintAliases(w io.Writer, aliases []*db.Alias, jsonOut bool) error { +func PrintAliases(w io.Writer, aliases []*service.Alias, jsonOut bool) error { if jsonOut { return json.NewEncoder(w).Encode(aliases) } @@ -180,6 +181,9 @@ func PrintAliases(w io.Writer, aliases []*db.Alias, jsonOut bool) error { fmt.Fprintln(w) for _, a := range aliases { fmt.Fprintf(w, " %s %s\n", cPrimary.Sprint(a.Name), cDim.Sprint(a.Command)) + if a.Description != "" { + fmt.Fprintf(w, " %s\n", cDim.Sprint(a.Description)) + } } fmt.Fprintln(w) return nil diff --git a/parse/mentions.go b/parse/mentions.go deleted file mode 100644 index 7f5dab6..0000000 --- a/parse/mentions.go +++ /dev/null @@ -1,17 +0,0 @@ -package parse - -import ( - "maps" - "regexp" - "slices" -) - -var r = regexp.MustCompile(`@([a-z0-9_]+)`) - -func Mentions(t string) []string { - seen := make(map[string]bool) - for _, m := range r.FindAllStringSubmatch(t, -1) { - seen[m[1]] = true - } - return slices.Collect(maps.Keys(seen)) -} diff --git a/service/config.go b/service/config.go new file mode 100644 index 0000000..dd216e0 --- /dev/null +++ b/service/config.go @@ -0,0 +1,17 @@ +package service + +type Alias struct { + Name string `json:"name"` + Command string `json:"command"` + Description string `json:"description,omitempty"` +} + +type Config interface { + GetUser() string + SetUser(username string) error + GetAlias(name string) (*Alias, error) + SetAlias(alias *Alias) error + DeleteAlias(name string) error + ListAliases() ([]*Alias, error) + Save() error +} diff --git a/service/fileconfig.go b/service/fileconfig.go new file mode 100644 index 0000000..6e1bbbb --- /dev/null +++ b/service/fileconfig.go @@ -0,0 +1,165 @@ +package service + +import ( + "encoding/json" + "errors" + "os" + "os/user" + "path/filepath" + "slices" + "strings" +) + +type fileConfig struct { + path string + User string `json:"user"` + UserAliases []*Alias `json:"aliases"` +} + +var defaultAliases = []*Alias{ + {Name: "mine", Command: "list --assignee $me --tag _status::open", Description: "Show open tasks assigned to you"}, + {Name: "due", Command: "list --tag _status::open --tag _due", Description: "Show open tasks with due dates"}, + {Name: "new", Command: "create $@", Description: "Create a new task"}, +} + +func LoadConfig() (Config, error) { + path, err := findConfigPath() + if err != nil { + return nil, err + } + return loadConfig(path) +} + +func loadConfig(path string) (*fileConfig, error) { + fc := &fileConfig{path: path, UserAliases: []*Alias{}} + data, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) { + return nil, err + } + } else { + if err := json.Unmarshal(data, fc); err != nil { + return nil, err + } + } + return fc, nil +} + +func findConfigPath() (string, error) { + dir, err := filepath.Abs(".") + if err != nil { + return "", err + } + for { + p := filepath.Join(dir, ".axconfig") + if _, err := os.Stat(p); err == nil { + return p, nil + } + if parent := filepath.Dir(dir); parent == dir { + break + } else { + dir = parent + } + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "ax", "config.json"), nil +} + +func (c *fileConfig) GetUser() string { + if c.User != "" { + return c.User + } + if u := os.Getenv("AX_USER"); u != "" { + return u + } + if u, err := user.Current(); err == nil { + return u.Username + } + return "unknown" +} + +func (c *fileConfig) SetUser(username string) error { + c.User = username + return c.Save() +} + +func (c *fileConfig) GetAlias(name string) (*Alias, error) { + for _, a := range c.UserAliases { + if a.Name == name { + return a, nil + } + } + for _, a := range defaultAliases { + if a.Name == name { + return a, nil + } + } + return nil, errors.New("alias not found") +} + +func (c *fileConfig) SetAlias(alias *Alias) error { + for i, a := range c.UserAliases { + if a.Name == alias.Name { + c.UserAliases[i] = alias + return c.Save() + } + } + c.UserAliases = append(c.UserAliases, alias) + return c.Save() +} + +func (c *fileConfig) DeleteAlias(name string) error { + for i, a := range c.UserAliases { + if a.Name == name { + c.UserAliases = slices.Delete(c.UserAliases, i, i+1) + return c.Save() + } + } + for _, a := range defaultAliases { + if a.Name == name { + return errors.New("cannot delete default alias") + } + } + return errors.New("alias not found") +} + +func (c *fileConfig) ListAliases() ([]*Alias, error) { + seen := make(map[string]bool) + var result []*Alias + for _, a := range c.UserAliases { + result = append(result, a) + seen[a.Name] = true + } + for _, a := range defaultAliases { + if !seen[a.Name] { + result = append(result, a) + } + } + return result, nil +} + +func (c *fileConfig) Save() error { + if err := os.MkdirAll(filepath.Dir(c.path), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + return os.WriteFile(c.path, data, 0644) +} + +func ExpandAlias(alias *Alias, args []string, currentUser string) []string { + cmd := alias.Command + cmd = strings.ReplaceAll(cmd, "$me", currentUser) + if len(args) > 0 { + cmd = strings.ReplaceAll(cmd, "$@", strings.Join(args, " ")) + } + for i, arg := range args { + cmd = strings.ReplaceAll(cmd, "$"+string(rune('1'+i)), arg) + } + return strings.Fields(cmd) +} diff --git a/service/mentions.go b/service/mentions.go new file mode 100644 index 0000000..09d4bdd --- /dev/null +++ b/service/mentions.go @@ -0,0 +1,17 @@ +package service + +import ( + "maps" + "regexp" + "slices" +) + +var mentionRegex = regexp.MustCompile(`@([a-z0-9_]+)`) + +func mentions(t string) []string { + seen := make(map[string]bool) + for _, m := range mentionRegex.FindAllStringSubmatch(t, -1) { + seen[m[1]] = true + } + return slices.Collect(maps.Keys(seen)) +} diff --git a/service/node_service.go b/service/node_service.go new file mode 100644 index 0000000..d5d1588 --- /dev/null +++ b/service/node_service.go @@ -0,0 +1,33 @@ +package service + +import "axolotl/models" + +type NodeService interface { + Create(title, content, dueDate string, tags []string, rels map[models.RelType][]string) (*models.Node, error) + Update(node *models.Node) error + Delete(id string) error + GetByID(id string) (*models.Node, error) + List(opts ...ListOption) ([]*models.Node, error) + Exists(id string) (bool, error) + CanClose(id string) (bool, []string, error) +} + +type listFilter struct { + tagPrefixes []string + assignee string + mentionsUser string +} + +type ListOption func(*listFilter) + +func WithTags(prefixes ...string) ListOption { + return func(f *listFilter) { f.tagPrefixes = prefixes } +} + +func WithAssignee(userID string) ListOption { + return func(f *listFilter) { f.assignee = userID } +} + +func WithMentions(userID string) ListOption { + return func(f *listFilter) { f.mentionsUser = userID } +} diff --git a/service/node_service_sqlite.go b/service/node_service_sqlite.go new file mode 100644 index 0000000..887fc58 --- /dev/null +++ b/service/node_service_sqlite.go @@ -0,0 +1,506 @@ +package service + +import ( + "axolotl/models" + "database/sql" + "math/rand" + "slices" + "strings" + "time" +) + +type sqliteNodeService struct { + db *sql.DB + userID string +} + +func NewSQLiteNodeService(db *sql.DB, userID string) NodeService { + return &sqliteNodeService{db: db, userID: userID} +} + +func (s *sqliteNodeService) GetByID(id string) (*models.Node, error) { + n := &models.Node{Relations: make(map[string][]string)} + q := s.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 := s.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 := s.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 (s *sqliteNodeService) Exists(id string) (bool, error) { + var e bool + err := s.db.QueryRow("SELECT EXISTS(SELECT 1 FROM nodes WHERE id = ?)", id).Scan(&e) + return e, err +} + +func (s *sqliteNodeService) Delete(id string) error { + _, err := s.db.Exec("DELETE FROM nodes WHERE id = ?", id) + return err +} + +func (s *sqliteNodeService) CanClose(id string) (bool, []string, error) { + rows, err := s.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 bID, tag string + if err := rows.Scan(&bID); err != nil { + return false, nil, err + } + if err := s.db.QueryRow("SELECT tag FROM tags WHERE node_id = ? AND tag LIKE '_status::%'", bID).Scan(&tag); err == sql.ErrNoRows { + continue + } else if err != nil { + return false, nil, err + } + if strings.HasSuffix(tag, "::open") { + blocking = append(blocking, bID) + } + } + return len(blocking) == 0, blocking, nil +} + +func genID() string { + b := make([]byte, 5) + for i := range b { + b[i] = "abcdefghijklmnopqrstuvwxyz"[rand.Intn(26)] + } + return string(b) +} + +func (s *sqliteNodeService) generateUniqueID() string { + for { + id := genID() + if exists, _ := s.Exists(id); !exists { + return id + } + } +} + +func (s *sqliteNodeService) Create(title, content, dueDate string, tags []string, rels map[models.RelType][]string) (*models.Node, error) { + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + + now, id := time.Now().UTC().Format(time.RFC3339), s.generateUniqueID() + if _, err := tx.Exec("INSERT INTO nodes (id, title, content, due_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + id, title, content, dueDate, now, now); err != nil { + return nil, err + } + + for _, m := range mentions(title + " " + content) { + userID, err := s.resolveUserRef(tx, m) + if err != nil { + return nil, err + } + if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", id, userID, models.RelMentions); err != nil { + return nil, err + } + } + + for _, t := range tags { + if _, err := tx.Exec("INSERT INTO tags (node_id, tag) VALUES (?, ?)", id, t); err != nil { + return nil, err + } + } + + hasCreated := false + for rt, tgts := range rels { + for _, tgt := range tgts { + if rt == models.RelCreated { + hasCreated = true + } + if rt == models.RelAssignee || rt == models.RelCreated { + var err error + if tgt, err = s.resolveUserRef(tx, tgt); err != nil { + return nil, err + } + } + if rt == models.RelInNamespace { + var err error + if tgt, err = s.resolveNamespaceRef(tx, tgt); err != nil { + return nil, err + } + } + if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", id, tgt, rt); err != nil { + return nil, err + } + } + } + if !hasCreated { + userID, err := s.resolveUserRef(tx, s.userID) + if err != nil { + return nil, err + } + if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", id, userID, models.RelCreated); err != nil { + return nil, err + } + } + + if err := tx.Commit(); err != nil { + return nil, err + } + return s.GetByID(id) +} + +func (s *sqliteNodeService) Update(node *models.Node) error { + current, err := s.GetByID(node.ID) + if err != nil { + return err + } + + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + upd := func(col, val string) error { + _, err := tx.Exec("UPDATE nodes SET "+col+" = ? WHERE id = ?", val, node.ID) + return err + } + + newTitle, newContent := current.Title, current.Content + if node.Title != current.Title { + if err := upd("title", node.Title); err != nil { + return err + } + newTitle = node.Title + } + if node.Content != current.Content { + if err := upd("content", node.Content); err != nil { + return err + } + newContent = node.Content + } + if node.DueDate != current.DueDate { + if node.DueDate == "" { + if _, err := tx.Exec("UPDATE nodes SET due_date = NULL WHERE id = ?", node.ID); err != nil { + return err + } + } else { + if err := upd("due_date", node.DueDate); err != nil { + return err + } + } + } + + if node.Title != current.Title || node.Content != current.Content { + newMentions := mentions(newTitle + " " + newContent) + rows, err := tx.Query("SELECT to_id FROM rels WHERE from_id = ? AND rel_type = ?", node.ID, models.RelMentions) + if err != nil { + return err + } + existingMentionIDs := make(map[string]bool) + for rows.Next() { + var uid string + if err := rows.Scan(&uid); err != nil { + rows.Close() + return err + } + existingMentionIDs[uid] = true + } + rows.Close() + + mentionedUserIDs := make(map[string]bool) + for _, m := range newMentions { + userID, err := s.resolveUserRef(tx, m) + if err != nil { + return err + } + mentionedUserIDs[userID] = true + if !existingMentionIDs[userID] { + if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", node.ID, userID, models.RelMentions); err != nil { + return err + } + } + } + + for uid := range existingMentionIDs { + if !mentionedUserIDs[uid] { + if _, err := tx.Exec("DELETE FROM rels WHERE from_id = ? AND to_id = ? AND rel_type = ?", node.ID, uid, models.RelMentions); err != nil { + return err + } + } + } + } + + for _, t := range current.Tags { + if !slices.Contains(node.Tags, t) { + tx.Exec("DELETE FROM tags WHERE node_id = ? AND tag = ?", node.ID, t) + } + } + for _, t := range node.Tags { + if !slices.Contains(current.Tags, t) { + tx.Exec("INSERT OR IGNORE INTO tags (node_id, tag) VALUES (?, ?)", node.ID, t) + } + } + + for rt, tgts := range current.Relations { + for _, tgt := range tgts { + if node.Relations[rt] == nil || !slices.Contains(node.Relations[rt], tgt) { + tx.Exec("DELETE FROM rels WHERE from_id = ? AND to_id = ? AND rel_type = ?", node.ID, tgt, rt) + } + } + } + for rt, tgts := range node.Relations { + for _, tgt := range tgts { + if current.Relations[rt] == nil || !slices.Contains(current.Relations[rt], tgt) { + resolvedTgt := tgt + if models.RelType(rt) == models.RelAssignee || models.RelType(rt) == models.RelCreated { + var err error + if resolvedTgt, err = s.resolveUserRef(tx, tgt); err != nil { + return err + } + } + if models.RelType(rt) == models.RelInNamespace { + var err error + if resolvedTgt, err = s.resolveNamespaceRef(tx, tgt); err != nil { + return err + } + } + tx.Exec("INSERT OR IGNORE INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", node.ID, resolvedTgt, rt) + } + } + } + + if _, err := tx.Exec("UPDATE nodes SET updated_at = ? WHERE id = ?", time.Now().UTC().Format(time.RFC3339), node.ID); err != nil { + return err + } + return tx.Commit() +} + +func (s *sqliteNodeService) resolveUserIDByNameTx(tx *sql.Tx, username string) (string, error) { + var id string + var err error + if tx != nil { + err = tx.QueryRow(` + SELECT n.id FROM nodes n + JOIN tags t ON n.id = t.node_id + WHERE n.title = ? AND t.tag = '_type::user' + LIMIT 1 + `, username).Scan(&id) + } else { + err = s.db.QueryRow(` + SELECT n.id FROM nodes n + JOIN tags t ON n.id = t.node_id + WHERE n.title = ? AND t.tag = '_type::user' + LIMIT 1 + `, username).Scan(&id) + } + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", err + } + return id, nil +} + +func (s *sqliteNodeService) resolveUserIDByName(username string) (string, error) { + return s.resolveUserIDByNameTx(nil, username) +} + +func (s *sqliteNodeService) resolveNamespaceIDByNameTx(tx *sql.Tx, name string) (string, error) { + var id string + var err error + if tx != nil { + err = tx.QueryRow(` + SELECT n.id FROM nodes n + JOIN tags t ON n.id = t.node_id + WHERE n.title = ? AND t.tag = '_type::namespace' + LIMIT 1 + `, name).Scan(&id) + } else { + err = s.db.QueryRow(` + SELECT n.id FROM nodes n + JOIN tags t ON n.id = t.node_id + WHERE n.title = ? AND t.tag = '_type::namespace' + LIMIT 1 + `, name).Scan(&id) + } + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", err + } + return id, nil +} + +func (s *sqliteNodeService) resolveNamespaceIDByName(name string) (string, error) { + return s.resolveNamespaceIDByNameTx(nil, name) +} + +func (s *sqliteNodeService) List(opts ...ListOption) ([]*models.Node, error) { + f := &listFilter{} + for _, opt := range opts { + opt(f) + } + + q, joins, whereConds, havingConds := "SELECT DISTINCT n.id FROM nodes n", []string{}, []string{}, []string{} + var whereArgs, havingArgs []any + if len(f.tagPrefixes) == 0 { + f.tagPrefixes = append(f.tagPrefixes, "") + } + + 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 " + havingArgs = append(havingArgs, t) + } + havingConds = append(havingConds, "SUM(CASE WHEN "+cond[:len(cond)-4]+" THEN 1 ELSE 0 END) >= ?") + havingArgs = append(havingArgs, len(f.tagPrefixes)) + + if f.assignee != "" { + userID, err := s.resolveUserIDByName(f.assignee) + if err != nil { + return nil, err + } + if userID == "" { + return []*models.Node{}, nil + } + joins = append(joins, "JOIN rels r_assign ON n.id = r_assign.from_id") + whereConds = append(whereConds, "r_assign.to_id = ? AND r_assign.rel_type = ?") + whereArgs = append(whereArgs, userID, models.RelAssignee) + } + if f.mentionsUser != "" { + userID, err := s.resolveUserIDByName(f.mentionsUser) + if err != nil { + return nil, err + } + if userID == "" { + return []*models.Node{}, nil + } + joins = append(joins, "JOIN rels r_mentions ON n.id = r_mentions.from_id") + whereConds = append(whereConds, "r_mentions.to_id = ? AND r_mentions.rel_type = ?") + whereArgs = append(whereArgs, userID, models.RelMentions) + } + + if len(joins) > 0 { + q += " " + strings.Join(joins, " ") + " " + } + if len(whereConds) > 0 { + q += " WHERE " + strings.Join(whereConds, " AND ") + } + q += " GROUP BY n.id" + if len(havingConds) > 0 { + q += " HAVING " + strings.Join(havingConds, " AND ") + } + + args := append(whereArgs, havingArgs...) + rows, err := s.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 + } + if n, err := s.GetByID(id); err == nil { + nodes = append(nodes, n) + } else { + return nil, err + } + } + return nodes, nil +} + +func (s *sqliteNodeService) resolveUserRef(tx *sql.Tx, ref string) (string, error) { + if exists, _ := s.Exists(ref); exists { + return ref, nil + } + return s.ensureUser(tx, ref) +} + +func (s *sqliteNodeService) ensureUser(tx *sql.Tx, username string) (string, error) { + userID, err := s.resolveUserIDByNameTx(tx, username) + if err != nil { + return "", err + } + if userID != "" { + return userID, nil + } + + id := s.generateUniqueID() + now := time.Now().UTC().Format(time.RFC3339) + if _, err := tx.Exec("INSERT INTO nodes (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)", + id, username, now, now); err != nil { + return "", err + } + if _, err := tx.Exec("INSERT INTO tags (node_id, tag) VALUES (?, '_type::user')", id); err != nil { + return "", err + } + return id, nil +} + +func (s *sqliteNodeService) resolveNamespaceRef(tx *sql.Tx, ref string) (string, error) { + if exists, _ := s.Exists(ref); exists { + return ref, nil + } + return s.ensureNamespace(tx, ref) +} + +func (s *sqliteNodeService) ensureNamespace(tx *sql.Tx, name string) (string, error) { + nsID, err := s.resolveNamespaceIDByNameTx(tx, name) + if err != nil { + return "", err + } + if nsID != "" { + return nsID, nil + } + + id := s.generateUniqueID() + now := time.Now().UTC().Format(time.RFC3339) + if _, err := tx.Exec("INSERT INTO nodes (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)", + id, name, now, now); err != nil { + return "", err + } + if _, err := tx.Exec("INSERT INTO tags (node_id, tag) VALUES (?, '_type::namespace')", id); err != nil { + return "", err + } + if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", + id, id, models.RelInNamespace); err != nil { + return "", err + } + userID, err := s.resolveUserRef(tx, s.userID) + if err != nil { + return "", err + } + if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", + id, userID, models.RelCreated); err != nil { + return "", err + } + return id, nil +}