From f907657c6f604355ac68e3bc84ed155727dec15e Mon Sep 17 00:00:00 2001 From: Elias Kohout Date: Sat, 28 Mar 2026 04:15:36 +0100 Subject: [PATCH] switch namespaces from tags to relations with auto-creation --- cmd/create.go | 11 ++++--- cmd/list.go | 2 +- db/node.go | 79 ++++++++++++++++++++++++++++++++++++------------ db/rel.go | 12 ++++++++ models/node.go | 21 ++++++++++--- output/output.go | 69 ++++++++++++++++++++---------------------- 6 files changed, 128 insertions(+), 66 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index ec3c6e9..2b943af 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -30,12 +30,9 @@ var createCmd = &cobra.Command{ if slices.Contains(cTags, "_type::issue") && !slices.ContainsFunc(cTags, func(e string) bool { return strings.HasPrefix(e, "_status::") }) { cTags = append(cTags, "_status::open") } - if !slices.ContainsFunc(cTags, func(e string) bool { return strings.HasPrefix(e, "_namespace::") }) { - cTags = append(cTags, "_namespace::"+db.GetCurrentUser()) - } rels := make(map[models.RelType][]string) - relCreated := false + relCreated, relNamespace := false, false for _, r := range cRels { rt, tgt, err := db.ParseRelFlag(r) if err != nil { @@ -45,11 +42,17 @@ var createCmd = &cobra.Command{ 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()) + } if n, err := d.CreateNode(db.CreateParams{Title: args[0], Content: cContent, DueDate: cDue, Tags: cTags, Rels: rels}); err != nil { fmt.Fprintln(os.Stderr, "failed to create:", err) diff --git a/cmd/list.go b/cmd/list.go index d450dce..6e02513 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -23,7 +23,7 @@ var listCmd = &cobra.Command{ if nodes, err := d.ListNodes(db.ListFilter{TagPrefixes: lTags, Assignee: lAssignee}); err == nil { output.PrintNodes(cmd.OutOrStdout(), nodes, jsonFlag) } else { - fmt.Fprintln(os.Stderr, "err: %v", err) + fmt.Fprintf(os.Stderr, "err: %v\n", err) } }, } diff --git a/db/node.go b/db/node.go index be27cb9..1ec7991 100644 --- a/db/node.go +++ b/db/node.go @@ -67,6 +67,50 @@ func (db *DB) resolveUserRef(tx *sql.Tx, ref string) (string, error) { return db.ensureUser(tx, ref) } +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 @@ -120,6 +164,12 @@ func (db *DB) CreateNode(p CreateParams) (*models.Node, error) { 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 } @@ -187,6 +237,12 @@ func (db *DB) UpdateNode(id string, p UpdateParams) error { 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) } } @@ -255,7 +311,9 @@ func (db *DB) ListNodes(f ListFilter) ([]*models.Node, error) { args = append(args, len(f.TagPrefixes)) if f.Assignee != "" { - joins, conds, args = append(joins, "JOIN rels r_assign ON n.id = r_assign.from_id"), append(conds, "r_assign.to_id = ? AND r_assign.rel_type = ?"), append(args, f.Assignee, models.RelAssignee) + joins = append(joins, "JOIN rels r_assign ON n.id = r_assign.from_id") + conds = append(conds, "r_assign.to_id = ? AND r_assign.rel_type = ?") + args = append(args, f.Assignee, models.RelAssignee) } if len(joins) > 0 { @@ -309,22 +367,3 @@ func (db *DB) CanClose(id string) (bool, []string, error) { } return len(blocking) == 0, blocking, nil } - -func (db *DB) GetSubtasks(parentID string) ([]*models.Node, error) { - rows, err := db.Query("SELECT to_id FROM rels WHERE from_id = ? AND rel_type = ?", parentID, models.RelSubtask) - if err != nil { - return nil, err - } - defer rows.Close() - var nodes []*models.Node - for rows.Next() { - var id string - rows.Scan(&id) - if n, err := db.NodeByID(id); err == nil { - nodes = append(nodes, n) - } else { - return nil, err - } - } - return nodes, nil -} diff --git a/db/rel.go b/db/rel.go index b343018..22bc98d 100644 --- a/db/rel.go +++ b/db/rel.go @@ -32,3 +32,15 @@ func (db *DB) GetRelated(id string, r models.RelType) ([]string, error) { func (db *DB) GetIncomingRels(id string, r models.RelType) ([]string, error) { return getIDs(db, "SELECT from_id FROM rels WHERE to_id = ? AND rel_type = ?", id, r) } + +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 { + return nil, err + } + result = append(result, node.Title) + } + return result, nil +} diff --git a/models/node.go b/models/node.go index 72e0642..2ee8aac 100644 --- a/models/node.go +++ b/models/node.go @@ -16,11 +16,12 @@ type Node struct { type RelType string const ( - RelBlocks RelType = "blocks" - RelSubtask RelType = "subtask" - RelRelated RelType = "related" - RelCreated RelType = "created" - RelAssignee RelType = "assignee" + RelBlocks RelType = "blocks" + RelSubtask RelType = "subtask" + RelRelated RelType = "related" + RelCreated RelType = "created" + RelAssignee RelType = "assignee" + RelInNamespace RelType = "in_namespace" ) func (n *Node) GetProperty(k string) string { @@ -33,3 +34,13 @@ func (n *Node) GetProperty(k string) string { } return "" } + +func (n *Node) GetDisplayTags() []string { + var tags []string + for _, t := range n.Tags { + if !strings.HasPrefix(t, "_") { + tags = append(tags, t) + } + } + return tags +} diff --git a/output/output.go b/output/output.go index 1ba188c..02b95b7 100644 --- a/output/output.go +++ b/output/output.go @@ -46,7 +46,7 @@ var ( "low": {" ", "low", cDim}, "": {" ", "n/a", cDim}, } - relIcons = map[string]string{"blocks": "\uf068", "subtask": "\uf0da", "related": "\uf0c1", "assignee": "\uf007"} + relIcons = map[string]string{"blocks": "\uf068", "subtask": "\uf0da", "related": "\uf0c1", "assignee": "\uf007", "in_namespace": "\uf07b"} prioRanks = map[string]int{"high": 3, "medium": 2, "low": 1} statusRanks = map[string]int{"open": 2, "": 1, "done": 0} ) @@ -72,16 +72,6 @@ func render(rm RenderMap, key string, short bool) string { return v.c.Sprint(v.l) } -func getDisplayTags(n *models.Node) []string { - var tags []string - for _, t := range n.Tags { - if !strings.HasPrefix(t, "_") { - tags = append(tags, t) - } - } - return tags -} - func PrintNodes(w io.Writer, nodes []*models.Node, jsonOut bool) error { if jsonOut { return json.NewEncoder(w).Encode(nodes) @@ -91,6 +81,11 @@ func PrintNodes(w io.Writer, nodes []*models.Node, jsonOut bool) error { return nil } + d, err := db.GetDB() + if err != nil { + return err + } + fmt.Fprintln(w) sort.Slice(nodes, func(i, j int) bool { si, sj := nodes[i].GetProperty("status"), nodes[j].GetProperty("status") @@ -101,15 +96,19 @@ func PrintNodes(w io.Writer, nodes []*models.Node, jsonOut bool) error { }) for _, n := range nodes { - tags := getDisplayTags(n) + ns_rels, err := d.GetRelNames(n, models.RelInNamespace) + if err != nil { + return err + } fmt.Fprintf(w, " %s %s %s %s %s %s", cDim.Sprint(n.ID), render(prioRM, n.GetProperty("prio"), true), render(statusRM, n.GetProperty("status"), true), render(typeRM, n.GetProperty("type"), true), cTitle.Sprint(truncate(n.Title, 80)), - cDim.Sprint("["+n.GetProperty("namespace")+"]"), + cDim.Sprint("["+strings.Join(ns_rels, ",")+"]"), ) + tags := n.GetDisplayTags() if len(tags) > 0 { fmt.Fprintf(w, " %s", cPrimary.Sprint("#"+strings.Join(tags, " #"))) } @@ -128,13 +127,33 @@ func PrintNode(w io.Writer, n *models.Node, jsonOut bool) error { fmt.Fprintln(w, cDim.Sprint(" ───────────────────────────────")) fmt.Fprintf(w, " Status: %s\n", render(statusRM, n.GetProperty("status"), false)) fmt.Fprintf(w, " Priority: %s\n", render(prioRM, n.GetProperty("prio"), false)) - fmt.Fprintf(w, " Namespace: %s\n", cWarn.Sprint(n.GetProperty("namespace"))) if n.DueDate != "" { fmt.Fprintf(w, " Due: %s %s\n", iconCalendar, n.DueDate) } fmt.Fprintf(w, " Created: %s\n", cDim.Sprint(n.CreatedAt)) fmt.Fprintf(w, " Updated: %s\n", cDim.Sprint(n.UpdatedAt)) + if tags := n.GetDisplayTags(); len(tags) > 0 { + fmt.Fprintf(w, "\n tags: %s\n", cPrimary.Sprint(strings.Join(tags, " • "))) + } + + if db, err := db.GetDB(); err != nil { + fmt.Fprintf(w, "failed to attach to db: %v", err) + } else { + for relType := range n.Relations { + names, err := db.GetRelNames(n, models.RelType(relType)) + if err != nil { + fmt.Fprintf(w, "err: %v", err) + } + if len(names) > 0 { + fmt.Fprintf(w, "\n %s\n", string(relType)) + } + for _, name := range names { + fmt.Fprintf(w, " %s %s\n", relIcons[relType], name) + } + } + } + if n.Content != "" { fmt.Fprintln(w, "\n"+cPrimary.Sprint(" Content:")) for i, line := range strings.Split(n.Content, "\n") { @@ -146,28 +165,6 @@ func PrintNode(w io.Writer, n *models.Node, jsonOut bool) error { } } - if tags := getDisplayTags(n); len(tags) > 0 { - fmt.Fprintf(w, "\n tags: %s\n", cPrimary.Sprint(strings.Join(tags, " • "))) - } - - if db, err := db.GetDB(); err == nil { - if len(n.Relations) > 0 { - for relType, ids := range n.Relations { - if relIcon, ok := relIcons[string(relType)]; ok { - fmt.Fprintf(w, "\n %s\n", string(relType)) - for _, id := range ids { - node, err := db.NodeByID(id) - if err == nil { - fmt.Fprintf(w, " %s %s\n", relIcon, node.Title) - } - } - } - } - } - } else { - fmt.Fprintf(w, "failed to attach to db: %v", err) - } - fmt.Fprintln(w) return nil }