From dadd3d9e1341a553a635165fa76ea7577138fa6a Mon Sep 17 00:00:00 2001 From: Elias Kohout Date: Sun, 29 Mar 2026 23:16:44 +0200 Subject: [PATCH] refactor: add tag and relation methods to node to enforce integrity --- cmd/add.go | 53 ++++++---- cmd/alias.go | 1 - cmd/del.go | 7 +- cmd/edit.go | 1 + cmd/list.go | 22 +++-- cmd/rel.go | 11 ++- cmd/root.go | 70 ++++++------- cmd/show.go | 1 + cmd/update.go | 45 ++++----- models/node.go | 173 ++++++++++++++++++++++++++++++--- models/rel_type.go | 5 + output/output.go | 8 +- service/mentions.go | 17 ---- service/node_service.go | 15 +-- service/node_service_sqlite.go | 63 +++++------- 15 files changed, 313 insertions(+), 179 deletions(-) delete mode 100644 service/mentions.go diff --git a/cmd/add.go b/cmd/add.go index b0c009f..09dcaf4 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -18,38 +18,53 @@ var cTags, cRels []string var addCmd = &cobra.Command{ Use: "add ", Short: "Create a new node", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - 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") + svc, err := service.GetNodeService(cfg) + if err != nil { + fmt.Fprintln(os.Stderr, "failed to create:", err) } + // default relations + if !slices.ContainsFunc(cRels, func(e string) bool { return strings.HasPrefix(e, "_namespace::") }) { + cRels = append(cRels, "_namespace::"+cfg.GetUser()) + } + + // parse relations rels := make(map[models.RelType][]string) - relNamespace := false for _, r := range cRels { - rt, tgt, err := parseRelFlag(r) + rel, err := parseRelFlag(svc, r) if err != nil { fmt.Fprintln(os.Stderr, err) return } - if rt == models.RelInNamespace { - relNamespace = true - } - rels[rt] = append(rels[rt], tgt) - } - if !relNamespace { - rels[models.RelInNamespace] = append(rels[models.RelInNamespace], cfg.GetUser()) + rels[rel.Type] = append(rels[rel.Type], rel.Target) } - svc, err := service.GetNodeService(cfg) + // create + n, err := svc.Create(args[0], cContent, cDue, nil, rels) if err != nil { fmt.Fprintln(os.Stderr, "failed to create:", err) - } else 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(), svc, n, jsonFlag) + return } + + for _, t := range cTags { + n.AddTag(t) + } + + // default tags + if !slices.ContainsFunc(cTags, func(e string) bool { return strings.HasPrefix(e, "_type::") }) { + n.AddTag("_type::issue") + } + if n.HasTag("_type::issue") && !slices.ContainsFunc(cTags, func(e string) bool { return strings.HasPrefix(e, "_status::") }) { + n.AddTag("_status::open") + } + + // persist tags added above + if err := svc.Update(n); err != nil { + fmt.Fprintln(os.Stderr, "failed to update with tags:", err) + return + } + + output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) }, } diff --git a/cmd/alias.go b/cmd/alias.go index 3fb7a10..8742f8d 100644 --- a/cmd/alias.go +++ b/cmd/alias.go @@ -15,7 +15,6 @@ var aliasCmd = &cobra.Command{ Use: "alias [name] [command]", Short: "Manage aliases", Args: cobra.MaximumNArgs(2), Run: func(cmd *cobra.Command, args []string) { w := cmd.OutOrStdout() - if len(args) == 0 { if aliases, err := cfg.ListAliases(); err == nil { output.PrintAliases(w, aliases, jsonFlag) diff --git a/cmd/del.go b/cmd/del.go index 40a4e26..9bb1b36 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -17,12 +17,13 @@ var delCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { svc, err := service.GetNodeService(cfg) if err != nil { - fmt.Fprintln(os.Stderr, "failed to create service: %v", err) + fmt.Fprintln(os.Stderr, err) return } + n, err := svc.GetByID(args[0]) if err != nil { - fmt.Fprintln(os.Stderr, " node not found:", args[0]) + fmt.Fprintf(os.Stderr, "node not found: %s", args[0]) return } @@ -36,7 +37,7 @@ var delCmd = &cobra.Command{ } if err := svc.Delete(args[0]); err != nil { - fmt.Fprintln(os.Stderr, "failed to delete: ", err) + fmt.Fprintf(os.Stderr, "failed to delete: %v", err) } else { output.PrintAction(cmd.OutOrStdout(), "Deleted", args[0], true) } diff --git a/cmd/edit.go b/cmd/edit.go index dbe9bad..19ca001 100644 --- a/cmd/edit.go +++ b/cmd/edit.go @@ -18,6 +18,7 @@ var editCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, err) return } + n, err := svc.GetByID(args[0]) if err != nil { fmt.Fprintln(os.Stderr, "node not found:", args[0]) diff --git a/cmd/list.go b/cmd/list.go index d364bc5..6b44673 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -9,9 +9,7 @@ import ( "github.com/spf13/cobra" ) -var lAssignee string -var lTags []string -var lMention string +var lTags, lRels []string var listCmd = &cobra.Command{ Use: "list", Short: "List nodes", @@ -21,15 +19,19 @@ var listCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, err) return } + opts := []service.ListOption{} if len(lTags) > 0 { opts = append(opts, service.WithTags(lTags...)) } - if lAssignee != "" { - opts = append(opts, service.WithAssignee(lAssignee)) - } - if lMention != "" { - opts = append(opts, service.WithMentions(lMention)) + + for _, relStr := range lRels { + rel, err := parseRelFlag(svc, relStr) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to parse relation flag: %v", err) + return + } + opts = append(opts, service.WithRels(rel)) } if nodes, err := svc.List(opts...); err == nil { @@ -44,7 +46,7 @@ func init() { rootCmd.AddCommand(listCmd) addPropertyFlags(listCmd) f := listCmd.Flags() - f.StringVar(&lAssignee, "assignee", "", "") + //TODO: assignee/ mention flags? f.StringArrayVar(&lTags, "tag", nil, "") - f.StringVar(&lMention, "mention", "", "") + f.StringArrayVar(&lRels, "rel", nil, "") } diff --git a/cmd/rel.go b/cmd/rel.go index e96f5ca..af58132 100644 --- a/cmd/rel.go +++ b/cmd/rel.go @@ -2,13 +2,18 @@ package cmd import ( "axolotl/models" + "axolotl/service" "fmt" "strings" ) -func parseRelFlag(s string) (models.RelType, string, error) { +func parseRelFlag(svc service.NodeService, s string) (*models.Rel, error) { if p := strings.SplitN(s, ":", 2); len(p) == 2 { - return models.RelType(p[0]), p[1], nil + return &models.Rel{Type: models.RelType(p[0]), Target: p[1]}, nil } - return "", "", fmt.Errorf("invalid relation format: %s (expected type:id)", s) + + // name resolution for rels + //TODO: + + return &models.Rel{}, fmt.Errorf("invalid relation format: %s (expected type:id)", s) } diff --git a/cmd/root.go b/cmd/root.go index 786c5fe..ef14a86 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,35 +27,15 @@ func Execute() { } } -func expandAlias(alias *service.Alias, args []string, currentUser string) []string { - cmd := alias.Command - cmd = strings.ReplaceAll(cmd, "$me", currentUser) +func init() { + rootCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, "") +} - parts := strings.Fields(cmd) - var result []string - - for _, part := range parts { - if part == "$@" { - result = append(result, args...) - continue - } - - hasCatchAll := strings.Contains(part, "$@") - replaced := part - - if hasCatchAll { - replaced = strings.ReplaceAll(replaced, "$@", strings.Join(args, " ")) - } - - for i := len(args) - 1; i >= 0; i-- { - placeholder := fmt.Sprintf("$%d", i+1) - replaced = strings.ReplaceAll(replaced, placeholder, args[i]) - } - - result = append(result, replaced) - } - - return result +func addPropertyFlags(cmd *cobra.Command) { + cmd.Flags().String("type", "", "node type") + cmd.Flags().String("status", "", "node status") + cmd.Flags().String("prio", "", "node priority") + cmd.Flags().String("namespace", "", "node namespace") } func registerAliasCommands() { @@ -67,8 +47,27 @@ func registerAliasCommands() { Short: a.Description, GroupID: "aliases", DisableFlagParsing: true, - Run: func(cmd *cobra.Command, args []string) { - expanded := expandAlias(a, args, cfg.GetUser()) + Run: func(ccmd *cobra.Command, args []string) { + acmd := a.Command + acmd = strings.ReplaceAll(acmd, "$me", cfg.GetUser()) + parts := strings.Fields(acmd) + var expanded []string + for _, part := range parts { + if part == "$@" { + expanded = append(expanded, args...) + continue + } + hasCatchAll := strings.Contains(part, "$@") + replaced := part + if hasCatchAll { + replaced = strings.ReplaceAll(replaced, "$@", strings.Join(args, " ")) + } + for i := len(args) - 1; i >= 0; i-- { + placeholder := fmt.Sprintf("$%d", i+1) + replaced = strings.ReplaceAll(replaced, placeholder, args[i]) + } + expanded = append(expanded, replaced) + } rootCmd.SetArgs(transformArgs(expanded)) if err := rootCmd.Execute(); err != nil { os.Exit(1) @@ -102,14 +101,3 @@ func transformArgs(args []string) []string { } return result } - -func init() { - rootCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, "") -} - -func addPropertyFlags(cmd *cobra.Command) { - cmd.Flags().String("type", "", "node type") - cmd.Flags().String("status", "", "node status") - cmd.Flags().String("prio", "", "node priority") - cmd.Flags().String("namespace", "", "node namespace") -} diff --git a/cmd/show.go b/cmd/show.go index fbe619b..8d8c767 100644 --- a/cmd/show.go +++ b/cmd/show.go @@ -17,6 +17,7 @@ var showCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, err) return } + if n, err := svc.GetByID(args[0]); err == nil { output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) } else { diff --git a/cmd/update.go b/cmd/update.go index 3b3b4d8..0a26d74 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -7,7 +7,6 @@ import ( "fmt" "os" "slices" - "strings" "github.com/spf13/cobra" ) @@ -33,15 +32,16 @@ var updateCmd = &cobra.Command{ return } + // parse relations 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 := parseRelFlag(r) + rel, err := parseRelFlag(svc, r) if err != nil { fmt.Fprintln(os.Stderr, err) return false } - dst[rt] = append(dst[rt], tgt) + dst[rel.Type] = append(dst[rel.Type], rel.Target) } return true } @@ -49,6 +49,8 @@ var updateCmd = &cobra.Command{ return } + // enforce blocking of tasks + //TODO: mabye part of the backend? if slices.Contains(uAddTags, "_status::done") { ok, blockers, err := svc.CanClose(args[0]) if err != nil { @@ -59,21 +61,9 @@ 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") - } - - for _, prefix := range []string{"_type::", "_status::", "_prio::", "_namespace::"} { - if slices.ContainsFunc(uAddTags, func(e string) bool { return strings.HasPrefix(e, prefix) }) { - for _, existing := range node.Tags { - if strings.HasPrefix(existing, prefix) { - uRmTags = append(uRmTags, existing) - } - } - } } + // update main fields if cmd.Flags().Changed("title") { node.Title = uTitle } @@ -87,28 +77,33 @@ var updateCmd = &cobra.Command{ node.DueDate = "" } + // udpate tags 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) + if err := node.RemoveTag(t); err != nil { + fmt.Fprintln(os.Stderr, "failed to remove tag:", err) + return } } + for _, t := range uAddTags { + node.AddTag(t) + } + // update relations 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 }) + if err := node.RemoveRelation(rt, tgt); err != nil { + fmt.Fprintln(os.Stderr, "failed to remove relation:", err) + return + } } } 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) - } + node.AddRelation(rt, tgt) } } + // persist update if err := svc.Update(node); err != nil { fmt.Fprintln(os.Stderr, "failed to update:", err) return diff --git a/models/node.go b/models/node.go index 5b8ee8c..0d0a6f5 100644 --- a/models/node.go +++ b/models/node.go @@ -1,20 +1,171 @@ package models -import "strings" +import ( + "encoding/json" + "fmt" + "strings" +) type Node struct { - ID string `json:"id"` - Title string `json:"title"` - Content string `json:"content,omitempty"` - DueDate string `json:"due_date,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Tags []string `json:"tags,omitempty"` - Relations map[string][]string `json:"relations,omitempty"` + ID string `json:"id"` + Title string `json:"title"` + Content string `json:"content,omitempty"` + DueDate string `json:"due_date,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + tags []string + relations map[string][]string +} + +func NewNode() *Node { + return &Node{ + relations: make(map[string][]string), + } +} + +var PropertyPrefixes = []string{"_type::", "_status::", "_prio::"} + +func (n *Node) MarshalJSON() ([]byte, error) { + type Alias Node + return json.Marshal(&struct { + *Alias + Tags []string `json:"tags,omitempty"` + Relations map[string][]string `json:"relations,omitempty"` + }{ + Alias: (*Alias)(n), + Tags: n.tags, + Relations: n.relations, + }) +} + +func (n *Node) UnmarshalJSON(data []byte) error { + type Alias Node + aux := &struct { + *Alias + Tags []string `json:"tags,omitempty"` + Relations map[string][]string `json:"relations,omitempty"` + }{ + Alias: (*Alias)(n), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + n.tags = aux.Tags + if aux.Relations != nil { + n.relations = aux.Relations + } else { + n.relations = make(map[string][]string) + } + return nil +} + +func (n *Node) Tags() []string { + res := make([]string, len(n.tags)) + copy(res, n.tags) + return res +} + +func (n *Node) Relations() map[string][]string { + res := make(map[string][]string) + for k, v := range n.relations { + cv := make([]string, len(v)) + copy(cv, v) + res[k] = cv + } + return res +} + +func (n *Node) HasTag(tag string) bool { + for _, t := range n.tags { + if t == tag { + return true + } + } + return false +} + +func (n *Node) AddTag(tag string) { + if tag == "" { + return + } + // check if it's a property tag + for _, prefix := range PropertyPrefixes { + if strings.HasPrefix(tag, prefix) { + // remove existing tags with this prefix + var newTags []string + for _, t := range n.tags { + if !strings.HasPrefix(t, prefix) { + newTags = append(newTags, t) + } + } + n.tags = newTags + break + } + } + if !n.HasTag(tag) { + n.tags = append(n.tags, tag) + } +} + +func (n *Node) RemoveTag(tag string) error { + if strings.HasPrefix(tag, "_type::") { + return fmt.Errorf("cannot remove _type tag") + } + var newTags []string + for _, t := range n.tags { + if t != tag { + newTags = append(newTags, t) + } + } + n.tags = newTags + return nil +} + +func (n *Node) HasRelation(relType RelType, target string) bool { + for _, tgt := range n.relations[string(relType)] { + if tgt == target { + return true + } + } + return false +} + +func (n *Node) AddRelation(relType RelType, target string) { + if n.relations == nil { + n.relations = make(map[string][]string) + } + if relType == RelAssignee || relType == RelCreated { + n.relations[string(relType)] = []string{target} + return + } + if !n.HasRelation(relType, target) { + n.relations[string(relType)] = append(n.relations[string(relType)], target) + } +} + +func (n *Node) RemoveRelation(relType RelType, target string) error { + if relType == RelCreated { + return fmt.Errorf("cannot remove created relation") + } + if n.relations == nil { + return nil + } + var newTgts []string + for _, tgt := range n.relations[string(relType)] { + if tgt != target { + newTgts = append(newTgts, tgt) + } + } + if len(newTgts) == 0 { + delete(n.relations, string(relType)) + } else { + n.relations[string(relType)] = newTgts + } + return nil } func (n *Node) GetProperty(k string) string { - for _, t := range n.Tags { + 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] @@ -26,7 +177,7 @@ func (n *Node) GetProperty(k string) string { func (n *Node) GetDisplayTags() []string { var tags []string - for _, t := range n.Tags { + for _, t := range n.tags { if !strings.HasPrefix(t, "_") { tags = append(tags, t) } diff --git a/models/rel_type.go b/models/rel_type.go index bd59e8d..086dfe1 100644 --- a/models/rel_type.go +++ b/models/rel_type.go @@ -2,6 +2,11 @@ package models type RelType string +type Rel struct { + Type RelType + Target string +} + const ( RelBlocks RelType = "blocks" RelSubtask RelType = "subtask" diff --git a/output/output.go b/output/output.go index eb7b2f3..c944afa 100644 --- a/output/output.go +++ b/output/output.go @@ -77,7 +77,8 @@ func PrintNodes(w io.Writer, svc service.NodeService, nodes []*models.Node, json }) for _, n := range nodes { - ns_rel_node_ids := n.Relations[string(models.RelInNamespace)] + n_rels := n.Relations() + ns_rel_node_ids := n_rels[string(models.RelInNamespace)] ns_rel_node_titles := make([]string, 0, len(ns_rel_node_ids)) for _, id := range ns_rel_node_ids { ns_rel_node, err := svc.GetByID(id) @@ -123,8 +124,9 @@ func PrintNode(w io.Writer, svc service.NodeService, n *models.Node, jsonOut boo fmt.Fprintf(w, "\n tags: %s\n", cPrimary.Sprint(strings.Join(tags, " • "))) } - for relType := range n.Relations { - rel_node_ids := n.Relations[string(relType)] + n_rels := n.Relations() + for relType := range n_rels { + rel_node_ids := n_rels[string(relType)] if len(rel_node_ids) > 0 { fmt.Fprintf(w, "\n %s\n", string(relType)) } diff --git a/service/mentions.go b/service/mentions.go deleted file mode 100644 index 09d4bdd..0000000 --- a/service/mentions.go +++ /dev/null @@ -1,17 +0,0 @@ -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 index e68c56f..c20230c 100644 --- a/service/node_service.go +++ b/service/node_service.go @@ -25,21 +25,16 @@ func GetNodeService(cfg Config) (NodeService, error) { } type listFilter struct { - tagPrefixes []string - assignee string - mentionsUser string + tagPrefixes []string + relPrefixes []*models.Rel } type ListOption func(*listFilter) func WithTags(prefixes ...string) ListOption { - return func(f *listFilter) { f.tagPrefixes = prefixes } + return func(f *listFilter) { f.tagPrefixes = append(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 } +func WithRels(prefixes ...*models.Rel) ListOption { + return func(f *listFilter) { f.relPrefixes = append(f.relPrefixes, prefixes...) } } diff --git a/service/node_service_sqlite.go b/service/node_service_sqlite.go index 338bfd3..f17fb48 100644 --- a/service/node_service_sqlite.go +++ b/service/node_service_sqlite.go @@ -5,9 +5,11 @@ import ( "database/sql" "errors" "fmt" + "maps" "math/rand" "os" "path/filepath" + "regexp" "slices" "strings" "time" @@ -27,6 +29,16 @@ var migrations = []string{ `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 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)) +} + func InitSqliteDB(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err @@ -67,7 +79,7 @@ func GetSqliteDB(cfg Config) (*sql.DB, error) { } func (s *sqliteNodeService) GetByID(id string) (*models.Node, error) { - n := &models.Node{Relations: make(map[string][]string)} + n := models.NewNode() 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 @@ -78,7 +90,7 @@ func (s *sqliteNodeService) GetByID(id string) (*models.Node, error) { for rows.Next() { var tag string rows.Scan(&tag) - n.Tags = append(n.Tags, tag) + n.AddTag(tag) } } else { return nil, err @@ -89,7 +101,7 @@ func (s *sqliteNodeService) GetByID(id string) (*models.Node, error) { for rows.Next() { var toID, relType string rows.Scan(&toID, &relType) - n.Relations[relType] = append(n.Relations[relType], toID) + n.AddRelation(models.RelType(relType), toID) } } else { return nil, err @@ -299,27 +311,31 @@ func (s *sqliteNodeService) Update(node *models.Node) error { } } - for _, t := range current.Tags { - if !slices.Contains(node.Tags, t) { + currentTags := current.Tags() + nodeTags := node.Tags() + for _, t := range currentTags { + if !slices.Contains(nodeTags, 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) { + for _, t := range nodeTags { + if !slices.Contains(currentTags, t) { tx.Exec("INSERT OR IGNORE INTO tags (node_id, tag) VALUES (?, ?)", node.ID, t) } } - for rt, tgts := range current.Relations { + currentRels := current.Relations() + nodeRels := node.Relations() + for rt, tgts := range currentRels { for _, tgt := range tgts { - if node.Relations[rt] == nil || !slices.Contains(node.Relations[rt], tgt) { + if nodeRels[rt] == nil || !slices.Contains(nodeRels[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 rt, tgts := range nodeRels { for _, tgt := range tgts { - if current.Relations[rt] == nil || !slices.Contains(current.Relations[rt], tgt) { + if currentRels[rt] == nil || !slices.Contains(currentRels[rt], tgt) { resolvedTgt := tgt if models.RelType(rt) == models.RelAssignee || models.RelType(rt) == models.RelCreated { var err error @@ -402,31 +418,6 @@ func (s *sqliteNodeService) List(opts ...ListOption) ([]*models.Node, error) { 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, " ") + " " }