diff --git a/cmd/add.go b/cmd/add.go index b5a67d6..1fa6a06 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -1,18 +1,15 @@ package cmd import ( - "axolotl/models" "axolotl/output" "axolotl/service" "fmt" "os" - "slices" - "strings" "github.com/spf13/cobra" ) -var cDue, cContent string +var cDue, cContent, cStatus, cPrio, cType, cNamespace, cAssignee string var cTags, cRels []string var addCmd = &cobra.Command{ @@ -20,61 +17,51 @@ var addCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { svc, err := service.GetNodeService(cfg) if err != nil { - fmt.Fprintln(os.Stderr, "failed to create:", err) + fmt.Fprintln(os.Stderr, err) + return } - // default relations - if !slices.ContainsFunc(cRels, func(e string) bool { return strings.HasPrefix(e, "in_namespace:") }) { - cRels = append(cRels, "in_namespace:"+cfg.GetUser()) + input := service.AddInput{ + Title: args[0], + Content: cContent, + DueDate: cDue, + Type: cType, + Status: cStatus, + Priority: cPrio, + Namespace: cNamespace, + Assignee: cAssignee, + Tags: cTags, } - // parse relations - rels := make(map[models.RelType][]string) for _, r := range cRels { - rel, err := parseRelFlag(svc, r) + ri, err := parseRelInput(r) if err != nil { fmt.Fprintln(os.Stderr, err) return } - rels[rel.Type] = append(rels[rel.Type], rel.Target) + input.Rels = append(input.Rels, ri) } - // create - n, err := svc.Create(args[0], cContent, cDue, nil, rels) + n, err := svc.Add(input) if err != nil { fmt.Fprintln(os.Stderr, "failed to create:", err) 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) }, } func init() { rootCmd.AddCommand(addCmd) - addPropertyFlags(addCmd) - addCmd.Flags().Set("type", "issue") f := addCmd.Flags() - f.StringVar(&cDue, "due", "", "") - f.StringVar(&cContent, "content", "", "") - f.StringArrayVar(&cTags, "tag", nil, "") - f.StringArrayVar(&cRels, "rel", nil, "") + f.StringVar(&cType, "type", "issue", "node type (issue, note, …)") + f.StringVar(&cStatus, "status", "", "initial status (open, done)") + f.StringVar(&cPrio, "prio", "", "priority (high, medium, low)") + f.StringVar(&cNamespace, "namespace", "", "namespace name or ID") + f.StringVar(&cAssignee, "assignee", "", "assignee username or ID") + f.StringVar(&cDue, "due", "", "due date") + f.StringVar(&cContent, "content", "", "node body") + f.StringArrayVar(&cTags, "tag", nil, "custom tags") + f.StringArrayVar(&cRels, "rel", nil, "additional relations (type:target)") } diff --git a/cmd/edit.go b/cmd/edit.go index 19ca001..38e4777 100644 --- a/cmd/edit.go +++ b/cmd/edit.go @@ -45,17 +45,18 @@ var editCmd = &cobra.Command{ return } - if content, err := os.ReadFile(tmp.Name()); err == nil { - n.Content = string(content) - if err := svc.Update(n); err != nil { - fmt.Fprintln(os.Stderr, "failed to update:", err) - return - } - n, _ = svc.GetByID(args[0]) - output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) - } else { + content, err := os.ReadFile(tmp.Name()) + if err != nil { fmt.Fprintln(os.Stderr, "failed to read temp file:", err) + return } + s := string(content) + n, err = svc.Update(args[0], service.UpdateInput{Content: &s}) + if err != nil { + fmt.Fprintln(os.Stderr, "failed to update:", err) + return + } + output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) }, } diff --git a/cmd/list.go b/cmd/list.go index 6b44673..cc0ae6d 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -10,6 +10,7 @@ import ( ) var lTags, lRels []string +var lStatus, lPrio, lType, lNamespace, lAssignee, lMention string var listCmd = &cobra.Command{ Use: "list", Short: "List nodes", @@ -20,21 +21,26 @@ var listCmd = &cobra.Command{ return } - opts := []service.ListOption{} - if len(lTags) > 0 { - opts = append(opts, service.WithTags(lTags...)) + filter := service.ListFilter{ + Tags: lTags, + Status: lStatus, + Priority: lPrio, + Type: lType, + Namespace: lNamespace, + Assignee: lAssignee, + Mention: lMention, } - for _, relStr := range lRels { - rel, err := parseRelFlag(svc, relStr) + for _, r := range lRels { + ri, err := parseRelInput(r) if err != nil { fmt.Fprintf(os.Stderr, "failed to parse relation flag: %v", err) return } - opts = append(opts, service.WithRels(rel)) + filter.Rels = append(filter.Rels, ri) } - if nodes, err := svc.List(opts...); err == nil { + if nodes, err := svc.List(filter); err == nil { output.PrintNodes(cmd.OutOrStdout(), svc, nodes, jsonFlag) } else { fmt.Fprintf(os.Stderr, "err: %v\n", err) @@ -44,9 +50,13 @@ var listCmd = &cobra.Command{ func init() { rootCmd.AddCommand(listCmd) - addPropertyFlags(listCmd) f := listCmd.Flags() - //TODO: assignee/ mention flags? - f.StringArrayVar(&lTags, "tag", nil, "") - f.StringArrayVar(&lRels, "rel", nil, "") + f.StringArrayVar(&lTags, "tag", nil, "filter by tag") + f.StringArrayVar(&lRels, "rel", nil, "filter by relation (type:target)") + f.StringVar(&lStatus, "status", "", "filter by status") + f.StringVar(&lPrio, "prio", "", "filter by priority") + f.StringVar(&lType, "type", "", "filter by type") + f.StringVar(&lNamespace, "namespace", "", "filter by namespace") + f.StringVar(&lAssignee, "assignee", "", "filter by assignee") + f.StringVar(&lMention, "mention", "", "filter by mention") } diff --git a/cmd/rel.go b/cmd/rel.go index af58132..f2b0a3f 100644 --- a/cmd/rel.go +++ b/cmd/rel.go @@ -7,13 +7,10 @@ import ( "strings" ) -func parseRelFlag(svc service.NodeService, s string) (*models.Rel, error) { +// parseRelInput parses a "type:target" string into a RelInput. +func parseRelInput(s string) (service.RelInput, error) { if p := strings.SplitN(s, ":", 2); len(p) == 2 { - return &models.Rel{Type: models.RelType(p[0]), Target: p[1]}, nil + return service.RelInput{Type: models.RelType(p[0]), Target: p[1]}, nil } - - // name resolution for rels - //TODO: - - return &models.Rel{}, fmt.Errorf("invalid relation format: %s (expected type:id)", s) + return service.RelInput{}, fmt.Errorf("invalid relation format: %s (expected type:target)", s) } diff --git a/cmd/root.go b/cmd/root.go index 1f1be53..8095273 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -21,7 +21,6 @@ func Execute() { os.Exit(1) } registerAliasCommands() - rootCmd.SetArgs(transformArgs(os.Args[1:])) if err := rootCmd.Execute(); err != nil { os.Exit(1) } @@ -31,15 +30,6 @@ 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") - cmd.Flags().String("assignee", "", "node assignee") - cmd.Flags().String("mention", "", "node mention") -} - func registerAliasCommands() { rootCmd.AddGroup(&cobra.Group{ID: "aliases", Title: "Aliases:"}) aliases, _ := cfg.ListAliases() @@ -70,7 +60,7 @@ func registerAliasCommands() { } expanded = append(expanded, replaced) } - rootCmd.SetArgs(transformArgs(expanded)) + rootCmd.SetArgs(expanded) if err := rootCmd.Execute(); err != nil { os.Exit(1) } @@ -78,48 +68,3 @@ func registerAliasCommands() { }) } } - -func transformArgs(args []string) []string { - tagAliases := map[string]string{ - "--status": "_status", - "--prio": "_prio", - "--type": "_type", - } - relAliases := map[string]string{ - "--namespace": "in_namespace", - "--assignee": "assignee", - "--mention": "mentions", - } - result := []string{} - - for i := 0; i < len(args); i++ { - if idx := strings.Index(args[i], "="); idx != -1 { - flag := args[i][:idx] - val := args[i][idx+1:] - if prop, ok := tagAliases[flag]; ok { - result = append(result, "--tag", prop+"::"+val) - continue - } - if prop, ok := relAliases[flag]; ok { - result = append(result, "--rel", prop+":"+val) - continue - } - } - - flag := args[i] - if i+1 < len(args) { - if prop, ok := tagAliases[flag]; ok { - result = append(result, "--tag", prop+"::"+args[i+1]) - i++ - continue - } - if prop, ok := relAliases[flag]; ok { - result = append(result, "--rel", prop+":"+args[i+1]) - i++ - continue - } - } - result = append(result, args[i]) - } - return result -} diff --git a/cmd/update.go b/cmd/update.go index eb70ff4..e86879d 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -1,20 +1,19 @@ package cmd import ( - "axolotl/models" "axolotl/output" "axolotl/service" "fmt" "os" - "slices" "github.com/spf13/cobra" ) var ( - uTitle, uContent, uDue string - uClearDue bool - uAddTags, uRmTags, uAddRels, uRmRels []string + uTitle, uContent, uDue string + uClearDue bool + uStatus, uPrio, uType, uNamespace, uAssignee string + uAddTags, uRmTags, uAddRels, uRmRels []string ) var updateCmd = &cobra.Command{ @@ -26,106 +25,80 @@ var updateCmd = &cobra.Command{ return } - node, err := svc.GetByID(args[0]) - if err != nil { - fmt.Fprintln(os.Stderr, "node not found:", args[0]) - return + input := service.UpdateInput{ + AddTags: uAddTags, + RemoveTags: uRmTags, } - // 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 { - rel, err := parseRelFlag(svc, r) - if err != nil { - fmt.Fprintln(os.Stderr, err) - return false - } - dst[rel.Type] = append(dst[rel.Type], rel.Target) - } - return true - } - if !parseRel(uAddRels, addRels) || !parseRel(uRmRels, rmRels) { - 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 { - fmt.Fprintln(os.Stderr, "failed to check blockers:", err) - os.Exit(1) - } - if !ok { - fmt.Fprintf(os.Stderr, "cannot close: blocked by %v\n", blockers) - os.Exit(1) - } - } - - // update main fields if cmd.Flags().Changed("title") { - node.Title = uTitle + input.Title = &uTitle } if cmd.Flags().Changed("content") { - node.Content = uContent + input.Content = &uContent } if cmd.Flags().Changed("due") { - node.DueDate = uDue + input.DueDate = &uDue } if uClearDue { - node.DueDate = "" + empty := "" + input.DueDate = &empty + } + if cmd.Flags().Changed("status") { + input.Status = &uStatus + } + if cmd.Flags().Changed("prio") { + input.Priority = &uPrio + } + if cmd.Flags().Changed("type") { + input.Type = &uType + } + if cmd.Flags().Changed("namespace") { + input.Namespace = &uNamespace + } + if cmd.Flags().Changed("assignee") { + input.Assignee = &uAssignee } - // udpate tags - for _, t := range uRmTags { - if err := node.RemoveTag(t); err != nil { - fmt.Fprintln(os.Stderr, "failed to remove tag:", err) + for _, r := range uAddRels { + ri, err := parseRelInput(r) + if err != nil { + fmt.Fprintln(os.Stderr, err) return } + input.AddRels = append(input.AddRels, ri) } - for _, t := range uAddTags { - node.AddTag(t) + for _, r := range uRmRels { + ri, err := parseRelInput(r) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + input.RemoveRels = append(input.RemoveRels, ri) } - // update relations - for rt, tgts := range rmRels { - for _, tgt := range tgts { - 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 { - node.AddRelation(rt, tgt) - } - } - - // persist update - if err := svc.Update(node); err != nil { - fmt.Fprintln(os.Stderr, "failed to update:", err) - return - } - if n, err := svc.GetByID(args[0]); err == nil { - output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) - } else { - fmt.Fprintln(os.Stderr, "failed to fetch node:", err) + n, err := svc.Update(args[0], input) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) } + output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) }, } func init() { rootCmd.AddCommand(updateCmd) - addPropertyFlags(updateCmd) f := updateCmd.Flags() - f.StringVar(&uTitle, "title", "", "") - f.StringVar(&uContent, "content", "", "") - f.StringVar(&uDue, "due", "", "") - f.BoolVar(&uClearDue, "clear-due", false, "") - f.StringArrayVar(&uAddTags, "tag", nil, "") - f.StringArrayVar(&uRmTags, "tag-remove", nil, "") - f.StringArrayVar(&uAddRels, "rel", nil, "") - f.StringArrayVar(&uRmRels, "rel-remove", nil, "") + f.StringVar(&uTitle, "title", "", "new title") + f.StringVar(&uContent, "content", "", "new content") + f.StringVar(&uDue, "due", "", "due date") + f.BoolVar(&uClearDue, "clear-due", false, "clear due date") + f.StringVar(&uStatus, "status", "", "status (open, done)") + f.StringVar(&uPrio, "prio", "", "priority (high, medium, low)") + f.StringVar(&uType, "type", "", "node type") + f.StringVar(&uNamespace, "namespace", "", "namespace name or ID") + f.StringVar(&uAssignee, "assignee", "", "assignee username or ID") + f.StringArrayVar(&uAddTags, "tag", nil, "add tags") + f.StringArrayVar(&uRmTags, "tag-remove", nil, "remove tags") + f.StringArrayVar(&uAddRels, "rel", nil, "add relations (type:target)") + f.StringArrayVar(&uRmRels, "rel-remove", nil, "remove relations (type:target)") } diff --git a/service/config_file.go b/service/config_file.go index ee0cf65..86f7736 100644 --- a/service/config_file.go +++ b/service/config_file.go @@ -16,8 +16,8 @@ type fileConfig struct { } var defaultAliases = []*Alias{ - {Name: "mine", Command: "list --assignee $me --tag _type::issue --tag _status::open", Description: "Show open issues assigned to you"}, - {Name: "due", Command: "list --tag _type::issue --tag _status::open", Description: "Show open issues"}, + {Name: "mine", Command: "list --assignee $me --type issue --status open", Description: "Show open issues assigned to you"}, + {Name: "due", Command: "list --type issue --status open", Description: "Show open issues"}, {Name: "inbox", Command: "list --mention $me", Description: "Show your inbox"}, } diff --git a/service/node_service.go b/service/node_service.go index 46eaeca..4de3e82 100644 --- a/service/node_service.go +++ b/service/node_service.go @@ -5,14 +5,82 @@ import ( "axolotl/store" ) +// NodeService is the single entry point for all node operations. +// All data-model integrity rules are enforced here; callers cannot produce +// invalid state by interacting with this interface alone. 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 + // Query GetByID(id string) (*models.Node, error) - List(opts ...ListOption) ([]*models.Node, error) - Exists(id string) (bool, error) - CanClose(id string) (bool, []string, error) + List(filter ListFilter) ([]*models.Node, error) + + // Lifecycle + Add(input AddInput) (*models.Node, error) + Update(id string, input UpdateInput) (*models.Node, error) + Delete(id string) error + + // User management + AddUser(name string) (*models.Node, error) + ListUsers() ([]*models.Node, error) +} + +// AddInput describes a new node to create. +type AddInput struct { + Title string + Content string + DueDate string + Type string // default: "issue" + Status string // default: "open" when Type is "issue" + Priority string + // Namespace is a namespace name or node ID. Defaults to the current user. + Namespace string + // Assignee is a username or node ID. + Assignee string + // Tags are arbitrary user-defined labels (not system properties). + Tags []string + // Rels are additional typed edges (e.g. blocks, subtask, related). + Rels []RelInput +} + +// UpdateInput describes changes to apply to an existing node. +// Nil pointer fields mean "no change". +type UpdateInput struct { + Title *string + Content *string + DueDate *string // nil = no change; pointer to "" = clear due date + // Status "done" is rejected when the node has open blockers. + Status *string + Priority *string + Type *string + // Namespace replaces the current namespace. + Namespace *string + // Assignee replaces the current assignee. + Assignee *string + AddTags []string + RemoveTags []string + AddRels []RelInput + RemoveRels []RelInput +} + +// ListFilter specifies which nodes to return. Empty fields are ignored. +type ListFilter struct { + Tags []string + Status string + Priority string + Type string + // Namespace filters by namespace name or node ID. + Namespace string + // Assignee filters by username or node ID. + Assignee string + // Mention filters to nodes that mention the given username or node ID. + Mention string + // Rels are additional relation filters (e.g. blocks:someID). + Rels []RelInput +} + +// RelInput is a typed, directed edge with a target that may be a name or node ID. +type RelInput struct { + Type models.RelType + Target string // name or node ID; the service resolves names } func InitNodeService(path string) error { @@ -26,18 +94,3 @@ func GetNodeService(cfg Config) (NodeService, error) { } return &nodeServiceImpl{store: st, userID: cfg.GetUser()}, nil } - -type listFilter struct { - tagPrefixes []string - relPrefixes []*models.Rel -} - -type ListOption func(*listFilter) - -func WithTags(prefixes ...string) ListOption { - return func(f *listFilter) { f.tagPrefixes = append(f.tagPrefixes, prefixes...) } -} - -func WithRels(prefixes ...*models.Rel) ListOption { - return func(f *listFilter) { f.relPrefixes = append(f.relPrefixes, prefixes...) } -} diff --git a/service/node_service_impl.go b/service/node_service_impl.go index 023b8bb..785c30b 100644 --- a/service/node_service_impl.go +++ b/service/node_service_impl.go @@ -3,6 +3,7 @@ package service import ( "axolotl/models" "axolotl/store" + "fmt" "maps" "regexp" "slices" @@ -24,79 +25,94 @@ func mentions(t string) []string { return slices.Collect(maps.Keys(seen)) } +// --- Query --- + func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) { return s.store.GetNode(id) } -func (s *nodeServiceImpl) Exists(id string) (bool, error) { - return s.store.NodeExists(id) -} - -func (s *nodeServiceImpl) Delete(id string) error { - return s.store.DeleteNode(id) -} - -func (s *nodeServiceImpl) CanClose(id string) (bool, []string, error) { - node, err := s.store.GetNode(id) - if err != nil { - return false, nil, err +func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) { + // Build tag prefixes from both semantic fields and raw tags. + tagPrefixes := append([]string{}, filter.Tags...) + if filter.Status != "" { + tagPrefixes = append(tagPrefixes, "_status::"+filter.Status) } - blockerIDs := node.Relations()[string(models.RelBlocks)] - var blocking []string - for _, bID := range blockerIDs { - blocker, err := s.store.GetNode(bID) - if err != nil { - return false, nil, err + if filter.Priority != "" { + tagPrefixes = append(tagPrefixes, "_prio::"+filter.Priority) + } + if filter.Type != "" { + tagPrefixes = append(tagPrefixes, "_type::"+filter.Type) + } + + // Build rel filters, resolving names to node IDs (read-only, no auto-creation). + type relEntry struct { + relType models.RelType + name string + } + namedRels := []relEntry{ + {models.RelAssignee, filter.Assignee}, + {models.RelInNamespace, filter.Namespace}, + {models.RelMentions, filter.Mention}, + } + var relFilters []*models.Rel + for _, e := range namedRels { + if e.name == "" { + continue } - if blocker.GetProperty("status") == "open" { - blocking = append(blocking, bID) - } - } - return len(blocking) == 0, blocking, nil -} - -func (s *nodeServiceImpl) List(opts ...ListOption) ([]*models.Node, error) { - f := &listFilter{} - for _, opt := range opts { - opt(f) - } - - // Resolve rel filter targets from names to node IDs (read-only, no auto-creation). - resolvedRels := make([]*models.Rel, 0, len(f.relPrefixes)) - for _, rel := range f.relPrefixes { - resolvedID, ok := s.lookupRelTarget(rel.Type, rel.Target) + id, ok := s.lookupRelTarget(e.relType, e.name) if !ok { - return nil, nil // target doesn't exist; no nodes can match + return nil, nil // named target doesn't exist; no nodes can match } - resolvedRels = append(resolvedRels, &models.Rel{Type: rel.Type, Target: resolvedID}) + relFilters = append(relFilters, &models.Rel{Type: e.relType, Target: id}) + } + for _, ri := range filter.Rels { + id, ok := s.lookupRelTarget(ri.Type, ri.Target) + if !ok { + return nil, nil + } + relFilters = append(relFilters, &models.Rel{Type: ri.Type, Target: id}) } - return s.store.FindNodes(f.tagPrefixes, resolvedRels) + return s.store.FindNodes(tagPrefixes, relFilters) } -// lookupRelTarget resolves a rel filter target (name or ID) to a node ID without -// creating anything. Returns (id, false) when the target doesn't exist in the store. -func (s *nodeServiceImpl) lookupRelTarget(relType models.RelType, target string) (string, bool) { - if exists, _ := s.store.NodeExists(target); exists { - return target, true - } - var nodeType string - switch relType { - case models.RelAssignee, models.RelCreated, models.RelMentions: - nodeType = "user" - case models.RelInNamespace: - nodeType = "namespace" - default: - return target, true // other rel types expect a raw node ID - } - id, err := s.resolveIDByNameAndType(s.store, target, nodeType) - if err != nil || id == "" { - return "", false - } - return id, true -} +// --- Lifecycle --- + +func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) { + // Apply defaults. + nodeType := input.Type + if nodeType == "" { + nodeType = "issue" + } + status := input.Status + if status == "" && nodeType == "issue" { + status = "open" + } + + // Build initial tag set from semantic fields. + tags := []string{"_type::" + nodeType} + if status != "" { + tags = append(tags, "_status::"+status) + } + if input.Priority != "" { + tags = append(tags, "_prio::"+input.Priority) + } + tags = append(tags, input.Tags...) + + // Build initial relation map from semantic fields. + rels := make(map[models.RelType][]string) + if input.Namespace != "" { + rels[models.RelInNamespace] = []string{input.Namespace} + } else { + rels[models.RelInNamespace] = []string{s.userID} // default: creator's namespace + } + if input.Assignee != "" { + rels[models.RelAssignee] = []string{input.Assignee} + } + for _, ri := range input.Rels { + rels[ri.Type] = append(rels[ri.Type], ri.Target) + } -func (s *nodeServiceImpl) Create(title, content, dueDate string, tags []string, rels map[models.RelType][]string) (*models.Node, error) { id, err := s.store.GenerateID() if err != nil { return nil, err @@ -104,11 +120,11 @@ func (s *nodeServiceImpl) Create(title, content, dueDate string, tags []string, err = s.store.Transaction(func(st store.Store) error { now := time.Now().UTC().Format(time.RFC3339) - if err := st.AddNode(id, title, content, dueDate, now, now); err != nil { + if err := st.AddNode(id, input.Title, input.Content, input.DueDate, now, now); err != nil { return err } - for _, m := range mentions(title + " " + content) { + for _, m := range mentions(input.Title + " " + input.Content) { userID, err := s.resolveUserRef(st, m) if err != nil { return err @@ -130,19 +146,11 @@ func (s *nodeServiceImpl) Create(title, content, dueDate string, tags []string, if rt == models.RelCreated { hasCreated = true } - if rt == models.RelAssignee || rt == models.RelCreated { - var err error - if tgt, err = s.resolveUserRef(st, tgt); err != nil { - return err - } + resolved, err := s.resolveRelTarget(st, RelInput{Type: rt, Target: tgt}) + if err != nil { + return err } - if rt == models.RelInNamespace { - var err error - if tgt, err = s.resolveNamespaceRef(st, tgt); err != nil { - return err - } - } - if err := st.AddEdge(id, tgt, rt); err != nil { + if err := st.AddEdge(id, resolved, rt); err != nil { return err } } @@ -166,117 +174,240 @@ func (s *nodeServiceImpl) Create(title, content, dueDate string, tags []string, return s.store.GetNode(id) } -func (s *nodeServiceImpl) Update(node *models.Node) error { - current, err := s.store.GetNode(node.ID) - if err != nil { - return err +func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, error) { + // Enforce blocking constraint before allowing status=done. + if input.Status != nil && *input.Status == "done" { + if err := s.checkBlockers(id); err != nil { + return nil, err + } } - return s.store.Transaction(func(st store.Store) error { - // Update scalar fields if changed. - if node.Title != current.Title || node.Content != current.Content || node.DueDate != current.DueDate { - updatedAt := time.Now().UTC().Format(time.RFC3339) - if err := st.UpdateNode(node.ID, node.Title, node.Content, node.DueDate, updatedAt); err != nil { - return err + err := s.store.Transaction(func(st store.Store) error { + current, err := st.GetNode(id) + if err != nil { + return err + } + + // Determine final scalar values. + title, content, dueDate := current.Title, current.Content, current.DueDate + if input.Title != nil { + title = *input.Title + } + if input.Content != nil { + content = *input.Content + } + if input.DueDate != nil { + dueDate = *input.DueDate + } + + now := time.Now().UTC().Format(time.RFC3339) + if err := st.UpdateNode(id, title, content, dueDate, now); err != nil { + return err + } + + // Compute new tag set using the model's AddTag/RemoveTag to preserve + // property-prefix replacement semantics. + tmp := models.NewNode() + for _, t := range current.Tags() { + tmp.AddTag(t) + } + if input.Type != nil { + tmp.AddTag("_type::" + *input.Type) + } + if input.Status != nil { + tmp.AddTag("_status::" + *input.Status) + } + if input.Priority != nil { + tmp.AddTag("_prio::" + *input.Priority) + } + for _, t := range input.AddTags { + tmp.AddTag(t) + } + for _, t := range input.RemoveTags { + tmp.RemoveTag(t) //nolint: the error is only for _type:: removal, which is intentionally prevented + } + + // Sync tags to store. + currentTags, newTags := current.Tags(), tmp.Tags() + for _, t := range currentTags { + if !slices.Contains(newTags, t) { + if err := st.RemoveTag(id, t); err != nil { + return err + } + } + } + for _, t := range newTags { + if !slices.Contains(currentTags, t) { + if err := st.AddTag(id, t); err != nil { + return err + } } } // Sync mention edges when title or content changed. - if node.Title != current.Title || node.Content != current.Content { - newMentions := mentions(node.Title + " " + node.Content) - - existingMentionIDs := make(map[string]bool) - for _, uid := range current.Relations()[string(models.RelMentions)] { - existingMentionIDs[uid] = true - } - - mentionedUserIDs := make(map[string]bool) - for _, m := range newMentions { - userID, err := s.resolveUserRef(st, m) - if err != nil { - return err - } - mentionedUserIDs[userID] = true - if !existingMentionIDs[userID] { - if err := st.AddEdge(node.ID, userID, models.RelMentions); err != nil { - return err - } - } - } - for uid := range existingMentionIDs { - if !mentionedUserIDs[uid] { - if err := st.RemoveEdge(node.ID, uid, models.RelMentions); err != nil { - return err - } - } + if input.Title != nil || input.Content != nil { + if err := s.syncMentions(st, id, current, title, content); err != nil { + return err } } - // Sync tags. - currentTags := current.Tags() - nodeTags := node.Tags() - for _, t := range currentTags { - if !slices.Contains(nodeTags, t) { - if err := st.RemoveTag(node.ID, t); err != nil { - return err - } - } + // Build relation additions, including structured fields. + var addRels []RelInput + if input.Namespace != nil { + addRels = append(addRels, RelInput{Type: models.RelInNamespace, Target: *input.Namespace}) } - for _, t := range nodeTags { - if !slices.Contains(currentTags, t) { - if err := st.AddTag(node.ID, t); err != nil { - return err - } - } + if input.Assignee != nil { + addRels = append(addRels, RelInput{Type: models.RelAssignee, Target: *input.Assignee}) } + addRels = append(addRels, input.AddRels...) - // Sync edges (excluding mention edges, already handled above). currentRels := current.Relations() - nodeRels := node.Relations() - for rt, tgts := range currentRels { - if rt == string(models.RelMentions) { - continue + for _, ri := range addRels { + resolved, err := s.resolveRelTarget(st, ri) + if err != nil { + return err } - for _, tgt := range tgts { - if nodeRels[rt] == nil || !slices.Contains(nodeRels[rt], tgt) { - if err := st.RemoveEdge(node.ID, tgt, models.RelType(rt)); err != nil { + // Single-value relations replace the previous target. + if ri.Type == models.RelAssignee || ri.Type == models.RelInNamespace { + for _, oldTgt := range currentRels[string(ri.Type)] { + if err := st.RemoveEdge(id, oldTgt, ri.Type); err != nil { return err } } } + if err := st.AddEdge(id, resolved, ri.Type); err != nil { + return err + } } - for rt, tgts := range nodeRels { - if rt == string(models.RelMentions) { - continue + + for _, ri := range input.RemoveRels { + resolved, err := s.resolveRelTarget(st, ri) + if err != nil { + return err } - for _, tgt := range tgts { - 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 - if resolvedTgt, err = s.resolveUserRef(st, tgt); err != nil { - return err - } - } - if models.RelType(rt) == models.RelInNamespace { - var err error - if resolvedTgt, err = s.resolveNamespaceRef(st, tgt); err != nil { - return err - } - } - if err := st.AddEdge(node.ID, resolvedTgt, models.RelType(rt)); err != nil { - return err - } - } + if err := st.RemoveEdge(id, resolved, ri.Type); err != nil { + return err } } return nil }) + if err != nil { + return nil, err + } + return s.store.GetNode(id) } -// resolveIDByNameAndType finds a node by title and _type tag. -// Loads all nodes of that type and filters in-memory (few users/namespaces expected). +func (s *nodeServiceImpl) Delete(id string) error { + return s.store.DeleteNode(id) +} + +// --- User management --- + +func (s *nodeServiceImpl) AddUser(name string) (*models.Node, error) { + var id string + err := s.store.Transaction(func(st store.Store) error { + var err error + id, err = s.ensureUser(st, name) + return err + }) + if err != nil { + return nil, err + } + return s.store.GetNode(id) +} + +func (s *nodeServiceImpl) ListUsers() ([]*models.Node, error) { + return s.store.FindNodes([]string{"_type::user"}, nil) +} + +// --- Internal helpers --- + +func (s *nodeServiceImpl) checkBlockers(id string) error { + node, err := s.store.GetNode(id) + if err != nil { + return err + } + var blocking []string + for _, bID := range node.Relations()[string(models.RelBlocks)] { + blocker, err := s.store.GetNode(bID) + if err != nil { + return err + } + if blocker.GetProperty("status") == "open" { + blocking = append(blocking, bID) + } + } + if len(blocking) > 0 { + return fmt.Errorf("cannot close: blocked by %v", blocking) + } + return nil +} + +func (s *nodeServiceImpl) syncMentions(st store.Store, id string, current *models.Node, newTitle, newContent string) error { + existingMentionIDs := make(map[string]bool) + for _, uid := range current.Relations()[string(models.RelMentions)] { + existingMentionIDs[uid] = true + } + mentionedUserIDs := make(map[string]bool) + for _, m := range mentions(newTitle + " " + newContent) { + userID, err := s.resolveUserRef(st, m) + if err != nil { + return err + } + mentionedUserIDs[userID] = true + if !existingMentionIDs[userID] { + if err := st.AddEdge(id, userID, models.RelMentions); err != nil { + return err + } + } + } + for uid := range existingMentionIDs { + if !mentionedUserIDs[uid] { + if err := st.RemoveEdge(id, uid, models.RelMentions); err != nil { + return err + } + } + } + return nil +} + +// resolveRelTarget resolves a RelInput target to a node ID, auto-creating users +// and namespaces as needed. Use only inside a transaction. +func (s *nodeServiceImpl) resolveRelTarget(st store.Store, ri RelInput) (string, error) { + switch ri.Type { + case models.RelAssignee, models.RelCreated, models.RelMentions: + return s.resolveUserRef(st, ri.Target) + case models.RelInNamespace: + return s.resolveNamespaceRef(st, ri.Target) + default: + return ri.Target, nil // blocks/subtask/related expect raw node IDs + } +} + +// lookupRelTarget resolves a filter target to a node ID without creating anything. +// Returns ("", false) when the target doesn't exist. +func (s *nodeServiceImpl) lookupRelTarget(relType models.RelType, target string) (string, bool) { + if exists, _ := s.store.NodeExists(target); exists { + return target, true + } + var nodeType string + switch relType { + case models.RelAssignee, models.RelCreated, models.RelMentions: + nodeType = "user" + case models.RelInNamespace: + nodeType = "namespace" + default: + return target, true + } + id, err := s.resolveIDByNameAndType(s.store, target, nodeType) + if err != nil || id == "" { + return "", false + } + return id, true +} + +// resolveIDByNameAndType finds a node by title and _type tag without creating it. func (s *nodeServiceImpl) resolveIDByNameAndType(st store.Store, title, nodeType string) (string, error) { nodes, err := st.FindNodes([]string{"_type::" + nodeType}, nil) if err != nil { @@ -305,7 +436,6 @@ func (s *nodeServiceImpl) ensureUser(st store.Store, username string) (string, e if userID != "" { return userID, nil } - id, err := st.GenerateID() if err != nil { return "", err @@ -335,7 +465,6 @@ func (s *nodeServiceImpl) ensureNamespace(st store.Store, name string) (string, if nsID != "" { return nsID, nil } - id, err := st.GenerateID() if err != nil { return "", err @@ -359,3 +488,4 @@ func (s *nodeServiceImpl) ensureNamespace(st store.Store, name string) (string, } return id, nil } +