move src file to seperate direcotry
This commit is contained in:
84
src/cmd/add.go
Normal file
84
src/cmd/add.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"axolotl/models"
|
||||
"axolotl/output"
|
||||
"axolotl/service"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var cDue, cContent, cType, cStatus, cPrio, cNamespace, cAssignee string
|
||||
var cTags, cRels []string
|
||||
|
||||
var addCmd = &cobra.Command{
|
||||
Use: "add <title>", Short: "Create a new node", Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
svc, err := service.GetNodeService(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
|
||||
input := service.AddInput{
|
||||
Title: args[0],
|
||||
Content: cContent,
|
||||
DueDate: cDue,
|
||||
}
|
||||
|
||||
// --tag is an alias for --rel with no target.
|
||||
for _, tag := range cTags {
|
||||
input.Rels = append(input.Rels, service.RelInput{Type: models.RelType(tag), Target: ""})
|
||||
}
|
||||
|
||||
// Shorthand flags expand to property rels or edge rels.
|
||||
if cType != "" {
|
||||
input.Rels = append(input.Rels, service.RelInput{Type: models.RelType("_type::" + cType), Target: ""})
|
||||
}
|
||||
if cStatus != "" {
|
||||
input.Rels = append(input.Rels, service.RelInput{Type: models.RelType("_status::" + cStatus), Target: ""})
|
||||
}
|
||||
if cPrio != "" {
|
||||
input.Rels = append(input.Rels, service.RelInput{Type: models.RelType("_prio::" + cPrio), Target: ""})
|
||||
}
|
||||
if cNamespace != "" {
|
||||
input.Rels = append(input.Rels, service.RelInput{Type: models.RelInNamespace, Target: cNamespace})
|
||||
}
|
||||
if cAssignee != "" {
|
||||
input.Rels = append(input.Rels, service.RelInput{Type: models.RelAssignee, Target: cAssignee})
|
||||
}
|
||||
|
||||
for _, r := range cRels {
|
||||
ri, err := parseRelInput(r)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
input.Rels = append(input.Rels, ri)
|
||||
}
|
||||
|
||||
n, err := svc.Add(input)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "failed to create:", err)
|
||||
return
|
||||
}
|
||||
|
||||
output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(addCmd)
|
||||
f := addCmd.Flags()
|
||||
f.StringVar(&cType, "type", "", "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, "label tag (alias for --rel tagname)")
|
||||
f.StringArrayVar(&cRels, "rel", nil, "relation (prefix::value or relname:target)")
|
||||
}
|
||||
56
src/cmd/alias.go
Normal file
56
src/cmd/alias.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"axolotl/output"
|
||||
"axolotl/service"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
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) {
|
||||
w := cmd.OutOrStdout()
|
||||
if len(args) == 0 {
|
||||
if aliases, err := cfg.ListAliases(); err == nil {
|
||||
output.PrintAliases(w, aliases, jsonFlag)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(args) == 1 {
|
||||
a, err := cfg.GetAlias(args[0])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "alias not found:", args[0])
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(a.Command)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var aliasDelCmd = &cobra.Command{
|
||||
Use: "del <name>", 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)
|
||||
os.Exit(1)
|
||||
}
|
||||
output.PrintAction(cmd.OutOrStdout(), "Alias deleted", args[0], false)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(aliasCmd)
|
||||
aliasCmd.AddCommand(aliasDelCmd)
|
||||
aliasCmd.Flags().StringVar(&aliasDesc, "desc", "", "description for the alias")
|
||||
}
|
||||
50
src/cmd/del.go
Normal file
50
src/cmd/del.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"axolotl/output"
|
||||
"axolotl/service"
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var dForce bool
|
||||
var delCmd = &cobra.Command{
|
||||
Use: "del <id>", Short: "Delete a node", Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
svc, err := service.GetNodeService(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
|
||||
n, err := svc.GetByID(args[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "node not found: %s\n", args[0])
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !dForce {
|
||||
fmt.Printf("Delete %s '%s'? [y/N]: ", n.GetProperty("type"), n.Title)
|
||||
r, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if r = strings.TrimSpace(strings.ToLower(r)); r != "y" && r != "yes" {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.Delete(args[0]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to delete: %v", err)
|
||||
} else {
|
||||
output.PrintAction(cmd.OutOrStdout(), "Deleted", args[0], true)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(delCmd)
|
||||
delCmd.Flags().BoolVarP(&dForce, "force", "f", false, "")
|
||||
}
|
||||
65
src/cmd/edit.go
Normal file
65
src/cmd/edit.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"axolotl/output"
|
||||
"axolotl/service"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var editCmd = &cobra.Command{
|
||||
Use: "edit <id>", Short: "Edit node content in $EDITOR", Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
svc, err := service.GetNodeService(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
|
||||
n, err := svc.GetByID(args[0])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "node not found:", args[0])
|
||||
return
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("", "ax-*.md")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "failed to create temp file:", err)
|
||||
return
|
||||
}
|
||||
tmp.WriteString(n.Content)
|
||||
tmp.Close()
|
||||
defer os.Remove(tmp.Name())
|
||||
|
||||
editor := os.Getenv("EDITOR")
|
||||
if editor == "" {
|
||||
editor = "vi"
|
||||
}
|
||||
c := exec.Command(editor, tmp.Name())
|
||||
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
if err := c.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "editor failed:", err)
|
||||
return
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(tmp.Name())
|
||||
if err != nil {
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(editCmd)
|
||||
}
|
||||
33
src/cmd/init.go
Normal file
33
src/cmd/init.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"axolotl/output"
|
||||
"axolotl/service"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var initCmd = &cobra.Command{
|
||||
Use: "init [path]", Short: "Initialize a new database", Args: cobra.MaximumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
p := "."
|
||||
if len(args) > 0 {
|
||||
p = args[0]
|
||||
}
|
||||
dbPath := filepath.Join(p, ".ax.db")
|
||||
if _, err := os.Stat(dbPath); err == nil {
|
||||
fmt.Fprintln(os.Stderr, "database already exists:", dbPath)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := service.InitNodeService(dbPath); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "failed to initialize:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
output.PrintAction(cmd.OutOrStdout(), "Created", dbPath, false)
|
||||
},
|
||||
}
|
||||
|
||||
func init() { rootCmd.AddCommand(initCmd) }
|
||||
80
src/cmd/list.go
Normal file
80
src/cmd/list.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"axolotl/models"
|
||||
"axolotl/output"
|
||||
"axolotl/service"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var lTags, lRels []string
|
||||
var lStatus, lPrio, lType, lNamespace, lAssignee, lMention string
|
||||
|
||||
var listCmd = &cobra.Command{
|
||||
Use: "list", Short: "List nodes",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
svc, err := service.GetNodeService(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
|
||||
var filter service.ListFilter
|
||||
|
||||
// --tag is an alias for a label filter with no target.
|
||||
for _, tag := range lTags {
|
||||
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType(tag), Target: ""})
|
||||
}
|
||||
|
||||
// Shorthand flags expand to property filters or edge filters.
|
||||
if lStatus != "" {
|
||||
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType("_status::" + lStatus), Target: ""})
|
||||
}
|
||||
if lPrio != "" {
|
||||
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType("_prio::" + lPrio), Target: ""})
|
||||
}
|
||||
if lType != "" {
|
||||
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType("_type::" + lType), Target: ""})
|
||||
}
|
||||
if lNamespace != "" {
|
||||
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelInNamespace, Target: lNamespace})
|
||||
}
|
||||
if lAssignee != "" {
|
||||
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: lAssignee})
|
||||
}
|
||||
if lMention != "" {
|
||||
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelMentions, Target: lMention})
|
||||
}
|
||||
|
||||
for _, r := range lRels {
|
||||
ri, err := parseRelInput(r)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to parse relation flag: %v", err)
|
||||
return
|
||||
}
|
||||
filter.Rels = append(filter.Rels, ri)
|
||||
}
|
||||
|
||||
if nodes, err := svc.List(filter); err == nil {
|
||||
output.PrintNodes(cmd.OutOrStdout(), svc, nodes, jsonFlag)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "err: %v\n", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(listCmd)
|
||||
f := listCmd.Flags()
|
||||
f.StringArrayVar(&lTags, "tag", nil, "filter by label tag")
|
||||
f.StringArrayVar(&lRels, "rel", nil, "filter by relation (prefix::value or relname: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")
|
||||
}
|
||||
84
src/cmd/login.go
Normal file
84
src/cmd/login.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"axolotl/service"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var loginCmd = &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Authenticate with the remote server via OIDC",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
rc, ok := cfg.GetRemoteConfig()
|
||||
if !ok {
|
||||
fmt.Fprintln(os.Stderr, "no remote server configured; set remote.host in your config")
|
||||
os.Exit(1)
|
||||
}
|
||||
base := fmt.Sprintf("http://%s:%d", rc.Host, rc.Port)
|
||||
|
||||
resp, err := http.Post(base+"/auth/start", "application/json", nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to contact server: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var start struct {
|
||||
URL string `json:"url"`
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&start)
|
||||
resp.Body.Close()
|
||||
|
||||
if start.URL == "" {
|
||||
fmt.Fprintln(os.Stderr, "server did not return an auth URL; is OIDC configured on the server?")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Open this URL in your browser:\n\n %s\n\nWaiting for login...\n", start.URL)
|
||||
|
||||
deadline := time.Now().Add(5 * time.Minute)
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
resp, err := http.Get(fmt.Sprintf("%s/auth/poll?session_id=%s", base, start.SessionID))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode == http.StatusAccepted {
|
||||
resp.Body.Close()
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
fmt.Fprintln(os.Stderr, "login failed")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Token string `json:"token"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
resp.Body.Close()
|
||||
|
||||
if err := service.SaveSession(&service.Session{Token: result.Token}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to save session: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Logged in as %s\n", result.Username)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, "login timed out")
|
||||
os.Exit(1)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(loginCmd)
|
||||
}
|
||||
26
src/cmd/rel.go
Normal file
26
src/cmd/rel.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"axolotl/models"
|
||||
"axolotl/service"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseRelInput parses a rel string into a RelInput.
|
||||
//
|
||||
// Formats:
|
||||
// - "prefix::value" → property rel with no target (tag)
|
||||
// - "relname:target" → edge rel with a target node
|
||||
// - "tagname" → simple label rel with no target (alias for --tag)
|
||||
func parseRelInput(s string) (service.RelInput, error) {
|
||||
if strings.Contains(s, "::") {
|
||||
// Property: name::value — no target node.
|
||||
return service.RelInput{Type: models.RelType(s), Target: ""}, nil
|
||||
}
|
||||
if idx := strings.Index(s, ":"); idx >= 0 {
|
||||
// Edge rel: relname:target.
|
||||
return service.RelInput{Type: models.RelType(s[:idx]), Target: s[idx+1:]}, nil
|
||||
}
|
||||
// Simple label tag — no target node.
|
||||
return service.RelInput{Type: models.RelType(s), Target: ""}, nil
|
||||
}
|
||||
86
src/cmd/root.go
Normal file
86
src/cmd/root.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"axolotl/service"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
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.LoadConfigFile()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "failed to load config:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
registerAliasCommands()
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, "")
|
||||
}
|
||||
|
||||
func registerAliasCommands() {
|
||||
rootCmd.AddGroup(&cobra.Group{ID: "aliases", Title: "Aliases:"})
|
||||
aliases, _ := cfg.ListAliases()
|
||||
for _, a := range aliases {
|
||||
rootCmd.AddCommand(&cobra.Command{
|
||||
Use: a.Name,
|
||||
Short: a.Description,
|
||||
GroupID: "aliases",
|
||||
DisableFlagParsing: true,
|
||||
Run: func(ccmd *cobra.Command, args []string) {
|
||||
acmd := a.Command
|
||||
acmd = strings.ReplaceAll(acmd, "$me", cfg.GetUser())
|
||||
parts := strings.Fields(acmd)
|
||||
var expanded []string
|
||||
usedArgs := make([]bool, len(args))
|
||||
for _, part := range parts {
|
||||
if part == "$@" {
|
||||
expanded = append(expanded, args...)
|
||||
for i := range usedArgs {
|
||||
usedArgs[i] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
hasCatchAll := strings.Contains(part, "$@")
|
||||
replaced := part
|
||||
if hasCatchAll {
|
||||
replaced = strings.ReplaceAll(replaced, "$@", strings.Join(args, " "))
|
||||
for i := range usedArgs {
|
||||
usedArgs[i] = true
|
||||
}
|
||||
}
|
||||
for i := len(args) - 1; i >= 0; i-- {
|
||||
placeholder := fmt.Sprintf("$%d", i+1)
|
||||
if strings.Contains(replaced, placeholder) {
|
||||
replaced = strings.ReplaceAll(replaced, placeholder, args[i])
|
||||
usedArgs[i] = true
|
||||
}
|
||||
}
|
||||
expanded = append(expanded, replaced)
|
||||
}
|
||||
// Forward any unconsumed args (e.g. --json flag).
|
||||
for i, arg := range args {
|
||||
if !usedArgs[i] {
|
||||
expanded = append(expanded, arg)
|
||||
}
|
||||
}
|
||||
rootCmd.SetArgs(expanded)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
40
src/cmd/serve.go
Normal file
40
src/cmd/serve.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"axolotl/serve"
|
||||
"axolotl/service"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var serveCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start the JSON API server",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
sc := cfg.GetServerConfig()
|
||||
addr := fmt.Sprintf("%s:%d", sc.Host, sc.Port)
|
||||
|
||||
var oidcCfg *service.OIDCConfig
|
||||
if oc, ok := cfg.GetOIDCConfig(); ok {
|
||||
oidcCfg = &oc
|
||||
}
|
||||
|
||||
handler, err := serve.New(service.GetNodeServiceForUser, oidcCfg)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "listening on %s\n", addr)
|
||||
if err := http.ListenAndServe(addr, handler); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
}
|
||||
32
src/cmd/show.go
Normal file
32
src/cmd/show.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"axolotl/output"
|
||||
"axolotl/service"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var showCmd = &cobra.Command{
|
||||
Use: "show <id>", Short: "Show node details", Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
svc, err := service.GetNodeService(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
|
||||
n, err := svc.GetByID(args[0])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "node not found:", args[0])
|
||||
os.Exit(1)
|
||||
}
|
||||
output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(showCmd)
|
||||
}
|
||||
113
src/cmd/update.go
Normal file
113
src/cmd/update.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"axolotl/models"
|
||||
"axolotl/output"
|
||||
"axolotl/service"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
uTitle, uContent, uDue string
|
||||
uClearDue bool
|
||||
uStatus, uPrio, uType string
|
||||
uNamespace, uAssignee string
|
||||
uAddTags, uRmTags, uAddRels, uRmRels []string
|
||||
)
|
||||
|
||||
var updateCmd = &cobra.Command{
|
||||
Use: "update <id>", Short: "Update a node", Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
svc, err := service.GetNodeService(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
|
||||
var input service.UpdateInput
|
||||
|
||||
if cmd.Flags().Changed("title") {
|
||||
input.Title = &uTitle
|
||||
}
|
||||
if cmd.Flags().Changed("content") {
|
||||
input.Content = &uContent
|
||||
}
|
||||
if cmd.Flags().Changed("due") {
|
||||
input.DueDate = &uDue
|
||||
}
|
||||
if uClearDue {
|
||||
empty := ""
|
||||
input.DueDate = &empty
|
||||
}
|
||||
|
||||
// --tag / --tag-remove are aliases for --rel / --rel-remove with no target.
|
||||
for _, tag := range uAddTags {
|
||||
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelType(tag), Target: ""})
|
||||
}
|
||||
for _, tag := range uRmTags {
|
||||
input.RemoveRels = append(input.RemoveRels, service.RelInput{Type: models.RelType(tag), Target: ""})
|
||||
}
|
||||
|
||||
// Shorthand flags expand to property rels or edge rels.
|
||||
if cmd.Flags().Changed("type") {
|
||||
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelType("_type::" + uType), Target: ""})
|
||||
}
|
||||
if cmd.Flags().Changed("status") {
|
||||
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelType("_status::" + uStatus), Target: ""})
|
||||
}
|
||||
if cmd.Flags().Changed("prio") {
|
||||
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelType("_prio::" + uPrio), Target: ""})
|
||||
}
|
||||
if cmd.Flags().Changed("namespace") {
|
||||
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelInNamespace, Target: uNamespace})
|
||||
}
|
||||
if cmd.Flags().Changed("assignee") {
|
||||
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelAssignee, Target: uAssignee})
|
||||
}
|
||||
|
||||
for _, r := range uAddRels {
|
||||
ri, err := parseRelInput(r)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
input.AddRels = append(input.AddRels, ri)
|
||||
}
|
||||
for _, r := range uRmRels {
|
||||
ri, err := parseRelInput(r)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
input.RemoveRels = append(input.RemoveRels, ri)
|
||||
}
|
||||
|
||||
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)
|
||||
f := updateCmd.Flags()
|
||||
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 label tag (alias for --rel tagname)")
|
||||
f.StringArrayVar(&uRmTags, "tag-remove", nil, "remove label tag")
|
||||
f.StringArrayVar(&uAddRels, "rel", nil, "add relation (prefix::value or relname:target)")
|
||||
f.StringArrayVar(&uRmRels, "rel-remove", nil, "remove relation (prefix::value or relname:target)")
|
||||
}
|
||||
1030
src/e2e_test.go
Normal file
1030
src/e2e_test.go
Normal file
File diff suppressed because it is too large
Load Diff
31
src/go.mod
Normal file
31
src/go.mod
Normal file
@@ -0,0 +1,31 @@
|
||||
module axolotl
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
modernc.org/sqlite v1.29.10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
modernc.org/gc/v3 v3.1.2 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/strutil v1.2.1 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
73
src/go.sum
Normal file
73
src/go.sum
Normal file
@@ -0,0 +1,73 @@
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg=
|
||||
modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
7
src/main.go
Normal file
7
src/main.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "axolotl/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
||||
103
src/models/node.go
Normal file
103
src/models/node.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"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"`
|
||||
}
|
||||
|
||||
func NewNode() *Node {
|
||||
return &Node{
|
||||
Relations: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Node) AddTag(tag string) {
|
||||
if tag == "" {
|
||||
return
|
||||
}
|
||||
// If it's a property (name::value format), replace any existing tag with the same prefix.
|
||||
if idx := strings.Index(tag, "::"); idx >= 0 {
|
||||
prefix := tag[:idx+2]
|
||||
var newTags []string
|
||||
for _, t := range n.Tags {
|
||||
if !strings.HasPrefix(t, prefix) {
|
||||
newTags = append(newTags, t)
|
||||
}
|
||||
}
|
||||
n.Tags = newTags
|
||||
}
|
||||
if !slices.Contains(n.Tags, tag) {
|
||||
n.Tags = append(n.Tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Node) RemoveTag(tag string) {
|
||||
var newTags []string
|
||||
for _, t := range n.Tags {
|
||||
if t != tag {
|
||||
newTags = append(newTags, t)
|
||||
}
|
||||
}
|
||||
n.Tags = newTags
|
||||
}
|
||||
|
||||
func (n *Node) AddRelation(relType RelType, target string) {
|
||||
if n.Relations == nil {
|
||||
n.Relations = make(map[string][]string)
|
||||
}
|
||||
if relType == RelAssignee || relType == RelCreated || relType == RelInNamespace {
|
||||
n.Relations[string(relType)] = []string{target}
|
||||
return
|
||||
}
|
||||
if !slices.Contains(n.Relations[string(relType)], target) {
|
||||
n.Relations[string(relType)] = append(n.Relations[string(relType)], target)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Node) RemoveRelation(relType RelType, target string) {
|
||||
if n.Relations == nil {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Node) GetProperty(k string) string {
|
||||
prefix := "_" + k + "::"
|
||||
for _, t := range n.Tags {
|
||||
if strings.HasPrefix(t, prefix) {
|
||||
return strings.TrimPrefix(t, prefix)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (n *Node) GetDisplayTags() []string {
|
||||
var tags []string
|
||||
for _, t := range n.Tags {
|
||||
if !strings.HasPrefix(t, "_") {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
24
src/models/rel_type.go
Normal file
24
src/models/rel_type.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
type RelType string
|
||||
|
||||
type Rel struct {
|
||||
Type RelType
|
||||
Target string
|
||||
}
|
||||
|
||||
const (
|
||||
RelBlocks RelType = "blocks"
|
||||
RelSubtask RelType = "subtask"
|
||||
RelRelated RelType = "related"
|
||||
RelCreated RelType = "created"
|
||||
RelAssignee RelType = "assignee"
|
||||
RelInNamespace RelType = "in_namespace"
|
||||
RelMentions RelType = "mentions"
|
||||
|
||||
// Permission rels (subject → object). Levels are inclusive and transitive.
|
||||
RelCanRead RelType = "can_read" // level 1: visible in list/show
|
||||
RelCanCreateRel RelType = "can_create_rel" // level 2: can create relations between nodes
|
||||
RelCanWrite RelType = "can_write" // level 3: can update/delete
|
||||
RelHasOwnership RelType = "has_ownership" // level 4: sole owner; deletion cascades to owned nodes
|
||||
)
|
||||
209
src/output/output.go
Normal file
209
src/output/output.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"axolotl/models"
|
||||
"axolotl/service"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
type RenderMap map[string]struct {
|
||||
s string
|
||||
l string
|
||||
c *color.Color
|
||||
}
|
||||
|
||||
var (
|
||||
cPrimary = color.New(color.FgCyan)
|
||||
cSecond = color.New(color.FgMagenta)
|
||||
cDim = color.New(color.FgHiBlack)
|
||||
cText = color.New(color.FgWhite)
|
||||
cTitle = color.New(color.FgWhite, color.Bold)
|
||||
cGood = color.New(color.FgGreen)
|
||||
cWarn = color.New(color.FgYellow)
|
||||
cBad = color.New(color.FgRed)
|
||||
|
||||
typeRM = RenderMap{
|
||||
"issue": {" ", "\uf188 issue", cSecond},
|
||||
"note": {"\uf15c", "\uf15c note", cPrimary},
|
||||
"user": {"\uf007", "\uf007 user", cGood},
|
||||
"namespace": {"\uf07b", "\uf07b namespace", cWarn},
|
||||
"": {" ", "n/a", cDim},
|
||||
}
|
||||
statusRM = RenderMap{
|
||||
"open": {"●", "● open", cPrimary},
|
||||
"done": {"○", "○ done", cDim},
|
||||
"": {"—", "n/a", cDim},
|
||||
}
|
||||
prioRM = RenderMap{
|
||||
"high": {"\uf0e7", "high", cBad},
|
||||
"medium": {"\uf0e7", "medium", cWarn},
|
||||
"low": {" ", "low", cDim},
|
||||
"": {" ", "n/a", cDim},
|
||||
}
|
||||
relIcons = map[string]string{"blocks": "\uf068", "subtask": "\uf0da", "related": "\uf0c1", "assignee": "\uf007", "in_namespace": "\uf07b"}
|
||||
prioRanks = map[string]int{"high": 3, "medium": 2, "low": 1}
|
||||
statusRanks = map[string]int{"open": 2, "": 1, "done": 0}
|
||||
)
|
||||
|
||||
const (
|
||||
iconCalendar = "\uf133"
|
||||
iconCheck = "\uf00c"
|
||||
iconCross = "\uf00d"
|
||||
iconNamespace = "\uf07b"
|
||||
)
|
||||
|
||||
func PrintNodes(w io.Writer, svc service.NodeService, nodes []*models.Node, jsonOut bool) error {
|
||||
if jsonOut {
|
||||
return json.NewEncoder(w).Encode(nodes)
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
fmt.Fprintln(w, cDim.Sprint("No results."))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
sort.Slice(nodes, func(i, j int) bool {
|
||||
si, sj := nodes[i].GetProperty("status"), nodes[j].GetProperty("status")
|
||||
if si != sj {
|
||||
return statusRanks[si] > statusRanks[sj]
|
||||
}
|
||||
return prioRanks[nodes[i].GetProperty("prio")] > prioRanks[nodes[j].GetProperty("prio")]
|
||||
})
|
||||
|
||||
for _, n := range nodes {
|
||||
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)
|
||||
if err != nil {
|
||||
ns_rel_node_titles = append(ns_rel_node_titles, id)
|
||||
continue
|
||||
}
|
||||
ns_rel_node_titles = append(ns_rel_node_titles, ns_rel_node.Title)
|
||||
}
|
||||
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("["+strings.Join(ns_rel_node_titles, ",")+"]"),
|
||||
)
|
||||
tags := n.GetDisplayTags()
|
||||
if len(tags) > 0 {
|
||||
fmt.Fprintf(w, " %s", cPrimary.Sprint("#"+strings.Join(tags, " #")))
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
return nil
|
||||
}
|
||||
|
||||
func PrintNode(w io.Writer, svc service.NodeService, n *models.Node, jsonOut bool) error {
|
||||
if jsonOut {
|
||||
return json.NewEncoder(w).Encode(n)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s %s %s\n", render(typeRM, n.GetProperty("type"), false), cDim.Sprint(n.ID), cTitle.Sprint(n.Title))
|
||||
fmt.Fprintln(w, cDim.Sprint(" ───────────────────────────────"))
|
||||
fmt.Fprintf(w, " Status: %s\n", render(statusRM, n.GetProperty("status"), false))
|
||||
fmt.Fprintf(w, " Priority: %s\n", render(prioRM, n.GetProperty("prio"), false))
|
||||
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, " • ")))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
for _, id := range rel_node_ids {
|
||||
rel_node, err := svc.GetByID(id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, " %s %s\n", relIcons[relType], cDim.Sprint(id))
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, " %s %s\n", relIcons[relType], rel_node.Title)
|
||||
}
|
||||
}
|
||||
|
||||
if n.Content != "" {
|
||||
fmt.Fprintln(w, "\n"+cPrimary.Sprint(" Content:"))
|
||||
for i, line := range strings.Split(n.Content, "\n") {
|
||||
if i > 5 {
|
||||
fmt.Fprintf(w, "%s ...\n", cDim.Sprint(" │ "))
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(w, "%s%s\n", cDim.Sprint(" │ "), cText.Sprint(line))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
return nil
|
||||
}
|
||||
|
||||
func PrintAliases(w io.Writer, aliases []*service.Alias, jsonOut bool) error {
|
||||
if jsonOut {
|
||||
return json.NewEncoder(w).Encode(aliases)
|
||||
}
|
||||
if len(aliases) == 0 {
|
||||
fmt.Fprintln(w, cDim.Sprint("No aliases defined."))
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func PrintAction(w io.Writer, action, detail string, isError bool) {
|
||||
if isError {
|
||||
fmt.Fprintln(w, cBad.Sprint(iconCross+" "+action+" ")+cDim.Sprint(detail))
|
||||
return
|
||||
}
|
||||
icon := iconCheck
|
||||
if action == "Created" {
|
||||
icon = iconNamespace
|
||||
}
|
||||
fmt.Fprintln(w, cGood.Sprint(icon+" "+action+" ")+cDim.Sprint(detail))
|
||||
}
|
||||
|
||||
func render(rm RenderMap, key string, short bool) string {
|
||||
v, ok := rm[key]
|
||||
if !ok {
|
||||
v, ok = rm[""]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if short {
|
||||
return v.c.Sprint(v.s)
|
||||
}
|
||||
return v.c.Sprint(v.l)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max-1] + "…"
|
||||
}
|
||||
210
src/serve/auth.go
Normal file
210
src/serve/auth.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"axolotl/service"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// pendingLogin tracks an in-progress authorization code flow.
|
||||
type pendingLogin struct {
|
||||
verifier string
|
||||
state string
|
||||
created time.Time
|
||||
serverToken string // set by callback when complete; empty while pending
|
||||
}
|
||||
|
||||
// authHandler owns the OIDC provider connection, the pending login store,
|
||||
// and the active server-side session map.
|
||||
type authHandler struct {
|
||||
mu sync.Mutex
|
||||
pending map[string]*pendingLogin // loginID → pending state
|
||||
sessions map[string]string // serverToken → username
|
||||
|
||||
cfg service.OIDCConfig
|
||||
provider *oidc.Provider
|
||||
oauth2 oauth2.Config
|
||||
}
|
||||
|
||||
func newAuthHandler(cfg service.OIDCConfig) (*authHandler, error) {
|
||||
if cfg.PublicURL == "" {
|
||||
return nil, fmt.Errorf("oidc.public_url must be set to the externally reachable base URL of this server")
|
||||
}
|
||||
provider, err := oidc.NewProvider(context.Background(), cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OIDC provider: %w", err)
|
||||
}
|
||||
h := &authHandler{
|
||||
pending: make(map[string]*pendingLogin),
|
||||
sessions: make(map[string]string),
|
||||
cfg: cfg,
|
||||
provider: provider,
|
||||
oauth2: oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
RedirectURL: cfg.PublicURL + "/auth/callback",
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email", "offline_access"},
|
||||
},
|
||||
}
|
||||
go h.cleanup()
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (h *authHandler) cleanup() {
|
||||
for range time.Tick(5 * time.Minute) {
|
||||
h.mu.Lock()
|
||||
for id, p := range h.pending {
|
||||
if time.Since(p.created) > 15*time.Minute {
|
||||
delete(h.pending, id)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// lookupSession returns the username for a server-issued token, or "".
|
||||
func (h *authHandler) lookupSession(token string) string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.sessions[token]
|
||||
}
|
||||
|
||||
// POST /auth/start → {url, session_id}
|
||||
func (h *authHandler) start(w http.ResponseWriter, r *http.Request) {
|
||||
loginID := randomToken(16)
|
||||
verifier := randomToken(32)
|
||||
state := randomToken(16)
|
||||
|
||||
authURL := h.oauth2.AuthCodeURL(state,
|
||||
oauth2.SetAuthURLParam("code_challenge", pkceChallenge(verifier)),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
||||
)
|
||||
|
||||
h.mu.Lock()
|
||||
h.pending[loginID] = &pendingLogin{
|
||||
verifier: verifier,
|
||||
state: state,
|
||||
created: time.Now(),
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
writeJSON(w, map[string]string{"url": authURL, "session_id": loginID})
|
||||
}
|
||||
|
||||
// GET /auth/callback — OIDC provider redirects here after user authenticates.
|
||||
func (h *authHandler) callback(w http.ResponseWriter, r *http.Request) {
|
||||
stateParam := r.URL.Query().Get("state")
|
||||
code := r.URL.Query().Get("code")
|
||||
|
||||
h.mu.Lock()
|
||||
var loginID string
|
||||
var pending *pendingLogin
|
||||
for id, p := range h.pending {
|
||||
if p.state == stateParam {
|
||||
loginID, pending = id, p
|
||||
break
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if pending == nil {
|
||||
http.Error(w, "invalid or expired state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.oauth2.Exchange(r.Context(), code,
|
||||
oauth2.SetAuthURLParam("code_verifier", pending.verifier),
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "token exchange failed: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username, err := h.extractUsername(r.Context(), token)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to identify user: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
serverToken := randomToken(32)
|
||||
|
||||
h.mu.Lock()
|
||||
h.sessions[serverToken] = username
|
||||
if p := h.pending[loginID]; p != nil {
|
||||
p.serverToken = serverToken
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
fmt.Fprintln(w, "Login successful! You can close this tab.")
|
||||
}
|
||||
|
||||
// GET /auth/poll?session_id=...
|
||||
// Returns 202 while pending, 200 {token, username} when done, 404 if expired.
|
||||
func (h *authHandler) poll(w http.ResponseWriter, r *http.Request) {
|
||||
loginID := r.URL.Query().Get("session_id")
|
||||
|
||||
h.mu.Lock()
|
||||
p := h.pending[loginID]
|
||||
h.mu.Unlock()
|
||||
|
||||
if p == nil {
|
||||
writeError(w, http.StatusNotFound, "session not found or expired")
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
serverToken := p.serverToken
|
||||
if serverToken != "" {
|
||||
delete(h.pending, loginID) // consume once delivered
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if serverToken == "" {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
|
||||
username := h.lookupSession(serverToken)
|
||||
writeJSON(w, map[string]string{"token": serverToken, "username": username})
|
||||
}
|
||||
|
||||
func (h *authHandler) extractUsername(ctx context.Context, token *oauth2.Token) (string, error) {
|
||||
rawID, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no id_token in response")
|
||||
}
|
||||
idToken, err := h.provider.Verifier(&oidc.Config{ClientID: h.cfg.ClientID}).Verify(ctx, rawID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var claims map[string]any
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return "", err
|
||||
}
|
||||
user, _ := claims[h.cfg.UserClaim].(string)
|
||||
if user == "" {
|
||||
return "", fmt.Errorf("claim %q not found in token", h.cfg.UserClaim)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func randomToken(n int) string {
|
||||
b := make([]byte, n)
|
||||
rand.Read(b) //nolint:errcheck
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func pkceChallenge(verifier string) string {
|
||||
h := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(h[:])
|
||||
}
|
||||
41
src/serve/oidc.go
Normal file
41
src/serve/oidc.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userContextKey contextKey = "ax_user"
|
||||
|
||||
// withSessionAuth wraps a handler with ax session token authentication.
|
||||
// Auth endpoints (/auth/*) are passed through without a token check.
|
||||
// All other requests must supply Authorization: Bearer <server_token>.
|
||||
func withSessionAuth(ah *authHandler, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/auth/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
writeError(w, http.StatusUnauthorized, "Bearer token required")
|
||||
return
|
||||
}
|
||||
token := strings.TrimPrefix(auth, "Bearer ")
|
||||
username := ah.lookupSession(token)
|
||||
if username == "" {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired session; run 'ax login'")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userContextKey, username)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func userFromContext(r *http.Request) string {
|
||||
v, _ := r.Context().Value(userContextKey).(string)
|
||||
return v
|
||||
}
|
||||
209
src/serve/server.go
Normal file
209
src/serve/server.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"axolotl/models"
|
||||
"axolotl/service"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// New returns an HTTP handler that exposes NodeService as a JSON API.
|
||||
// When oidcCfg is non-nil, every request must carry a valid Bearer token;
|
||||
// the authenticated username is derived from the token claim configured in
|
||||
// OIDCConfig.UserClaim. Without OIDC, the X-Ax-User header is used instead.
|
||||
func New(newSvc func(user string) (service.NodeService, error), oidcCfg *service.OIDCConfig) (http.Handler, error) {
|
||||
s := &server{newSvc: newSvc}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /nodes", s.listNodes)
|
||||
mux.HandleFunc("POST /nodes", s.addNode)
|
||||
mux.HandleFunc("GET /nodes/{id}", s.getNode)
|
||||
mux.HandleFunc("PATCH /nodes/{id}", s.updateNode)
|
||||
mux.HandleFunc("DELETE /nodes/{id}", s.deleteNode)
|
||||
mux.HandleFunc("GET /users", s.listUsers)
|
||||
mux.HandleFunc("POST /users", s.addUser)
|
||||
if oidcCfg != nil {
|
||||
ah, err := newAuthHandler(*oidcCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mux.HandleFunc("POST /auth/start", ah.start)
|
||||
mux.HandleFunc("GET /auth/callback", ah.callback)
|
||||
mux.HandleFunc("GET /auth/poll", ah.poll)
|
||||
return withSessionAuth(ah, mux), nil
|
||||
}
|
||||
return mux, nil
|
||||
}
|
||||
|
||||
type server struct {
|
||||
newSvc func(user string) (service.NodeService, error)
|
||||
}
|
||||
|
||||
func (s *server) svc(w http.ResponseWriter, r *http.Request) (service.NodeService, bool) {
|
||||
user := userFromContext(r)
|
||||
if user == "" {
|
||||
user = r.Header.Get("X-Ax-User")
|
||||
}
|
||||
if user == "" {
|
||||
writeError(w, http.StatusUnauthorized, "X-Ax-User header required")
|
||||
return nil, false
|
||||
}
|
||||
svc, err := s.newSvc(user)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return nil, false
|
||||
}
|
||||
return svc, true
|
||||
}
|
||||
|
||||
func (s *server) listNodes(w http.ResponseWriter, r *http.Request) {
|
||||
svc, ok := s.svc(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
var filter service.ListFilter
|
||||
for _, tag := range q["tag"] {
|
||||
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType(tag)})
|
||||
}
|
||||
for _, rel := range q["rel"] {
|
||||
filter.Rels = append(filter.Rels, parseRel(rel))
|
||||
}
|
||||
for k, prefix := range map[string]string{"type": "_type::", "status": "_status::", "prio": "_prio::"} {
|
||||
if v := q.Get(k); v != "" {
|
||||
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType(prefix + v)})
|
||||
}
|
||||
}
|
||||
if v := q.Get("namespace"); v != "" {
|
||||
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelInNamespace, Target: v})
|
||||
}
|
||||
if v := q.Get("assignee"); v != "" {
|
||||
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: v})
|
||||
}
|
||||
if v := q.Get("mention"); v != "" {
|
||||
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelMentions, Target: v})
|
||||
}
|
||||
nodes, err := svc.List(filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, nodes)
|
||||
}
|
||||
|
||||
func (s *server) addNode(w http.ResponseWriter, r *http.Request) {
|
||||
svc, ok := s.svc(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input service.AddInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
n, err := svc.Add(input)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, n)
|
||||
}
|
||||
|
||||
func (s *server) getNode(w http.ResponseWriter, r *http.Request) {
|
||||
svc, ok := s.svc(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := svc.GetByID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, n)
|
||||
}
|
||||
|
||||
func (s *server) updateNode(w http.ResponseWriter, r *http.Request) {
|
||||
svc, ok := s.svc(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input service.UpdateInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
n, err := svc.Update(r.PathValue("id"), input)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, n)
|
||||
}
|
||||
|
||||
func (s *server) deleteNode(w http.ResponseWriter, r *http.Request) {
|
||||
svc, ok := s.svc(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := svc.Delete(r.PathValue("id")); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *server) listUsers(w http.ResponseWriter, r *http.Request) {
|
||||
svc, ok := s.svc(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
users, err := svc.ListUsers()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, users)
|
||||
}
|
||||
|
||||
func (s *server) addUser(w http.ResponseWriter, r *http.Request) {
|
||||
svc, ok := s.svc(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
n, err := svc.AddUser(body.Name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, n)
|
||||
}
|
||||
|
||||
func parseRel(s string) service.RelInput {
|
||||
if strings.Contains(s, "::") {
|
||||
return service.RelInput{Type: models.RelType(s)}
|
||||
}
|
||||
if idx := strings.Index(s, ":"); idx >= 0 {
|
||||
return service.RelInput{Type: models.RelType(s[:idx]), Target: s[idx+1:]}
|
||||
}
|
||||
return service.RelInput{Type: models.RelType(s)}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
136
src/service/api_client.go
Normal file
136
src/service/api_client.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"axolotl/models"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type apiClient struct {
|
||||
base string
|
||||
user string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func (c *apiClient) User() string { return c.user }
|
||||
|
||||
func (c *apiClient) do(method, path string, body any) (*http.Response, error) {
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequest(method, c.base+path, &buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.setAuth(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return c.http.Do(req)
|
||||
}
|
||||
|
||||
// setAuth attaches either a Bearer token (when a session exists) or the
|
||||
// X-Ax-User header (no session / non-OIDC servers).
|
||||
func (c *apiClient) setAuth(req *http.Request) error {
|
||||
sess, err := LoadSession()
|
||||
if err != nil || sess == nil || sess.Token == "" {
|
||||
req.Header.Set("X-Ax-User", c.user)
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+sess.Token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiDecode[T any](resp *http.Response) (T, error) {
|
||||
var v T
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
var e struct{ Error string }
|
||||
json.NewDecoder(resp.Body).Decode(&e)
|
||||
return v, fmt.Errorf("%s", e.Error)
|
||||
}
|
||||
return v, json.NewDecoder(resp.Body).Decode(&v)
|
||||
}
|
||||
|
||||
func (c *apiClient) GetByID(id string) (*models.Node, error) {
|
||||
resp, err := c.do("GET", "/nodes/"+id, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return apiDecode[*models.Node](resp)
|
||||
}
|
||||
|
||||
func (c *apiClient) List(filter ListFilter) ([]*models.Node, error) {
|
||||
q := url.Values{}
|
||||
for _, r := range filter.Rels {
|
||||
if r.Target == "" {
|
||||
q.Add("rel", string(r.Type))
|
||||
} else {
|
||||
q.Add("rel", string(r.Type)+":"+r.Target)
|
||||
}
|
||||
}
|
||||
path := "/nodes"
|
||||
if len(q) > 0 {
|
||||
path += "?" + q.Encode()
|
||||
}
|
||||
resp, err := c.do("GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return apiDecode[[]*models.Node](resp)
|
||||
}
|
||||
|
||||
func (c *apiClient) Add(input AddInput) (*models.Node, error) {
|
||||
resp, err := c.do("POST", "/nodes", input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return apiDecode[*models.Node](resp)
|
||||
}
|
||||
|
||||
func (c *apiClient) Update(id string, input UpdateInput) (*models.Node, error) {
|
||||
resp, err := c.do("PATCH", "/nodes/"+id, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return apiDecode[*models.Node](resp)
|
||||
}
|
||||
|
||||
func (c *apiClient) Delete(id string) error {
|
||||
resp, err := c.do("DELETE", "/nodes/"+id, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
var e struct{ Error string }
|
||||
json.NewDecoder(resp.Body).Decode(&e)
|
||||
return fmt.Errorf("%s", e.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *apiClient) ListUsers() ([]*models.Node, error) {
|
||||
resp, err := c.do("GET", "/users", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return apiDecode[[]*models.Node](resp)
|
||||
}
|
||||
|
||||
func (c *apiClient) AddUser(name string) (*models.Node, error) {
|
||||
resp, err := c.do("POST", "/users", map[string]string{"name": name})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return apiDecode[*models.Node](resp)
|
||||
}
|
||||
|
||||
37
src/service/config.go
Normal file
37
src/service/config.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package service
|
||||
|
||||
type Alias struct {
|
||||
Name string `json:"name"`
|
||||
Command string `json:"command"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
type OIDCConfig struct {
|
||||
Issuer string `json:"issuer"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
// PublicURL is the externally reachable base URL of this server, used to
|
||||
// construct the OIDC redirect URI (e.g. "https://ax.example.com:7000").
|
||||
PublicURL string `json:"public_url"`
|
||||
UserClaim string `json:"user_claim"` // default "preferred_username"
|
||||
}
|
||||
|
||||
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)
|
||||
GetServerConfig() ServerConfig
|
||||
// GetRemoteConfig returns the remote server address and whether remote mode is enabled.
|
||||
GetRemoteConfig() (ServerConfig, bool)
|
||||
// GetOIDCConfig returns the OIDC configuration and whether OIDC is enabled.
|
||||
GetOIDCConfig() (OIDCConfig, bool)
|
||||
Save() error
|
||||
}
|
||||
189
src/service/config_file.go
Normal file
189
src/service/config_file.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type fileConfig struct {
|
||||
path string
|
||||
User string `json:"user"`
|
||||
UserAliases []*Alias `json:"aliases"`
|
||||
Serve ServerConfig `json:"serve"`
|
||||
Remote ServerConfig `json:"remote"`
|
||||
OIDC OIDCConfig `json:"oidc"`
|
||||
}
|
||||
|
||||
var defaultAliases = []*Alias{
|
||||
{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"},
|
||||
}
|
||||
|
||||
func LoadConfigFile() (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) GetOIDCConfig() (OIDCConfig, bool) {
|
||||
if c.OIDC.Issuer == "" {
|
||||
return OIDCConfig{}, false
|
||||
}
|
||||
cfg := c.OIDC
|
||||
if cfg.UserClaim == "" {
|
||||
cfg.UserClaim = "preferred_username"
|
||||
}
|
||||
return cfg, true
|
||||
}
|
||||
|
||||
func (c *fileConfig) GetRemoteConfig() (ServerConfig, bool) {
|
||||
if c.Remote.Host == "" {
|
||||
return ServerConfig{}, false
|
||||
}
|
||||
port := c.Remote.Port
|
||||
if port == 0 {
|
||||
port = 7000
|
||||
}
|
||||
return ServerConfig{Host: c.Remote.Host, Port: port}, true
|
||||
}
|
||||
|
||||
func (c *fileConfig) GetServerConfig() ServerConfig {
|
||||
host := c.Serve.Host
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
port := c.Serve.Port
|
||||
if port == 0 {
|
||||
port = 7000
|
||||
}
|
||||
return ServerConfig{Host: host, Port: port}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
100
src/service/node_service.go
Normal file
100
src/service/node_service.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"axolotl/models"
|
||||
"axolotl/store"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Every NodeService instance is bound to a specific user (see User()).
|
||||
// GetNodeService returns an error when no user is configured.
|
||||
type NodeService interface {
|
||||
// User returns the name/ID of the user this service instance acts on behalf of.
|
||||
User() string
|
||||
|
||||
// Query
|
||||
GetByID(id string) (*models.Node, 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.
|
||||
// Rels may contain tag rels (Target == ""), property rels (Target == "",
|
||||
// Type is "prefix::value"), and edge rels (Target is a node name or ID).
|
||||
// The service applies defaults (type=issue, status=open for issues) and validates.
|
||||
type AddInput struct {
|
||||
Title string
|
||||
Content string
|
||||
DueDate string
|
||||
Rels []RelInput
|
||||
}
|
||||
|
||||
// UpdateInput describes changes to apply to an existing node.
|
||||
// AddRels and RemoveRels accept both tag rels (Target == "") and edge rels.
|
||||
// Setting _status::done in AddRels is rejected when the node has open blockers.
|
||||
// Adding assignee or in_namespace rels replaces the previous single target.
|
||||
type UpdateInput struct {
|
||||
Title *string
|
||||
Content *string
|
||||
DueDate *string // nil = no change; pointer to "" = clear due date
|
||||
AddRels []RelInput
|
||||
RemoveRels []RelInput
|
||||
}
|
||||
|
||||
// ListFilter specifies which nodes to return. Empty slices are ignored.
|
||||
// Tag filters (Target == "") match by rel_name prefix.
|
||||
// Edge filters (Target != "") are resolved to node IDs.
|
||||
type ListFilter struct {
|
||||
Rels []RelInput
|
||||
}
|
||||
|
||||
// RelInput is a typed, directed rel with a target that may be a name or node ID.
|
||||
// Target == "" means this is a tag or property rel (no target node).
|
||||
type RelInput struct {
|
||||
Type models.RelType
|
||||
Target string // name or node ID; the service resolves names. Empty = tag rel.
|
||||
}
|
||||
|
||||
func InitNodeService(path string) error {
|
||||
return store.InitSQLiteStore(path)
|
||||
}
|
||||
|
||||
func GetNodeService(cfg Config) (NodeService, error) {
|
||||
user := cfg.GetUser()
|
||||
if user == "" {
|
||||
return nil, fmt.Errorf("no user configured: run 'ax user set <username>' first")
|
||||
}
|
||||
if rc, ok := cfg.GetRemoteConfig(); ok {
|
||||
base := fmt.Sprintf("http://%s:%d", rc.Host, rc.Port)
|
||||
return &apiClient{base: base, user: user, http: &http.Client{}}, nil
|
||||
}
|
||||
st, err := store.FindAndOpenSQLiteStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &nodeServiceImpl{store: st, userID: user}, nil
|
||||
}
|
||||
|
||||
func GetNodeServiceForUser(user string) (NodeService, error) {
|
||||
if user == "" {
|
||||
return nil, fmt.Errorf("user is required")
|
||||
}
|
||||
st, err := store.FindOrInitSQLiteStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &nodeServiceImpl{store: st, userID: user}, nil
|
||||
}
|
||||
834
src/service/node_service_impl.go
Normal file
834
src/service/node_service_impl.go
Normal file
@@ -0,0 +1,834 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"axolotl/models"
|
||||
"axolotl/store"
|
||||
"fmt"
|
||||
"maps"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type nodeServiceImpl struct {
|
||||
store store.Store
|
||||
userID string
|
||||
}
|
||||
|
||||
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 (s *nodeServiceImpl) User() string { return s.userID }
|
||||
|
||||
// --- Permission model ---
|
||||
//
|
||||
// Four levels (inclusive: higher includes lower):
|
||||
// 1 can_read – visible in list/show
|
||||
// 2 can_create_rel – can create non-permission relations between nodes
|
||||
// 3 can_write – can update/delete a node
|
||||
// 4 has_ownership – sole owner; deletion cascades to owned nodes
|
||||
//
|
||||
// Permissions are transitive: if A has level L on B, and B has level M on C,
|
||||
// then A has level min(L, M) on C. Computed by BFS from the user's own node.
|
||||
// Users have self-ownership (has_ownership → self), so BFS starts at level 4.
|
||||
//
|
||||
// Rules for adding edge rels in Add/Update:
|
||||
// Non-perm rel A → B : need can_create_rel on A, can_read on B
|
||||
// Perm rel A --perm_P→ B : need perm_P on B (resource owner grants to any subject)
|
||||
// Ownership A --has_ownership→ B : need has_ownership on B + can_create_rel on A
|
||||
// → also removes existing ownership rels pointing to B
|
||||
//
|
||||
// Field/tag changes and rel removals require can_write on the node.
|
||||
|
||||
const (
|
||||
permRead = 1
|
||||
permCreateRel = 2
|
||||
permWrite = 3
|
||||
permOwnership = 4
|
||||
)
|
||||
|
||||
// isReferenceRel returns true for rels that point to "identity" nodes (users, namespaces).
|
||||
// For these rels, the target only needs can_read (not can_create_rel), because users and
|
||||
// namespaces are globally readable and any node can reference them.
|
||||
func isReferenceRel(t models.RelType) bool {
|
||||
switch t {
|
||||
case models.RelAssignee, models.RelCreated, models.RelMentions, models.RelInNamespace:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// permRelLevels maps permission rel types to their numeric level.
|
||||
var permRelLevels = map[models.RelType]int{
|
||||
models.RelCanRead: permRead,
|
||||
models.RelCanCreateRel: permCreateRel,
|
||||
models.RelCanWrite: permWrite,
|
||||
models.RelHasOwnership: permOwnership,
|
||||
}
|
||||
|
||||
type permContext struct {
|
||||
levels map[string]int
|
||||
}
|
||||
|
||||
func (pc *permContext) level(nodeID string) int { return pc.levels[nodeID] }
|
||||
func (pc *permContext) canRead(nodeID string) bool { return pc.levels[nodeID] >= permRead }
|
||||
func (pc *permContext) canCreateRel(nodeID string) bool { return pc.levels[nodeID] >= permCreateRel }
|
||||
func (pc *permContext) canWrite(nodeID string) bool { return pc.levels[nodeID] >= permWrite }
|
||||
func (pc *permContext) hasOwnership(nodeID string) bool { return pc.levels[nodeID] >= permOwnership }
|
||||
|
||||
// getPermContext builds a permContext by BFS from the current user's node,
|
||||
// following permission rels and taking the minimum level along each path.
|
||||
// User and namespace nodes are made globally readable after the BFS.
|
||||
// If the user node doesn't exist yet, returns an empty permContext (no access);
|
||||
// Add operations still work because unresolved targets skip the permission check.
|
||||
func (s *nodeServiceImpl) getPermContext() (*permContext, error) {
|
||||
userNodeID, err := s.resolveIDByNameAndType(s.store, s.userID, "user")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pc := &permContext{levels: make(map[string]int)}
|
||||
if userNodeID == "" {
|
||||
return pc, nil // user not bootstrapped yet; Add will auto-create user node
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
nodeID string
|
||||
level int
|
||||
}
|
||||
// Start at the user's own node at ownership level (users have self-ownership).
|
||||
queue := []entry{{userNodeID, permOwnership}}
|
||||
for len(queue) > 0 {
|
||||
curr := queue[0]
|
||||
queue = queue[1:]
|
||||
if pc.levels[curr.nodeID] >= curr.level {
|
||||
continue // already reached at a higher or equal level
|
||||
}
|
||||
pc.levels[curr.nodeID] = curr.level
|
||||
node, err := s.store.GetNode(curr.nodeID)
|
||||
if err != nil {
|
||||
continue // node may have been deleted; skip
|
||||
}
|
||||
for relType, pLevel := range permRelLevels {
|
||||
for _, tgt := range node.Relations[string(relType)] {
|
||||
eff := curr.level
|
||||
if pLevel < eff {
|
||||
eff = pLevel
|
||||
}
|
||||
if eff > pc.levels[tgt] {
|
||||
queue = append(queue, entry{tgt, eff})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User and namespace nodes are globally readable (they represent identities,
|
||||
// and anyone can reference or assign to them).
|
||||
for _, nodeType := range []string{"user", "namespace"} {
|
||||
nodes, _ := s.store.FindNodes([]*models.Rel{{Type: models.RelType("_type::" + nodeType), Target: ""}})
|
||||
for _, n := range nodes {
|
||||
if pc.levels[n.ID] < permRead {
|
||||
pc.levels[n.ID] = permRead
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pc, nil
|
||||
}
|
||||
|
||||
// --- Validation ---
|
||||
|
||||
var (
|
||||
validTypes = map[string]bool{"issue": true, "note": true, "user": true, "namespace": true}
|
||||
validStatuses = map[string]bool{"open": true, "done": true}
|
||||
validPrios = map[string]bool{"high": true, "medium": true, "low": true}
|
||||
)
|
||||
|
||||
// validateRels checks that any _ -prefixed rel names are known system properties
|
||||
// and that their values are valid. Users may not define custom _ -prefixed rels.
|
||||
func validateRels(rels []RelInput) error {
|
||||
for _, r := range rels {
|
||||
name := string(r.Type)
|
||||
if !strings.HasPrefix(name, "_") {
|
||||
continue
|
||||
}
|
||||
if v, ok := strings.CutPrefix(name, "_type::"); ok {
|
||||
if !validTypes[v] {
|
||||
return fmt.Errorf("invalid type %q: must be one of issue, note, user, namespace", v)
|
||||
}
|
||||
} else if v, ok := strings.CutPrefix(name, "_status::"); ok {
|
||||
if !validStatuses[v] {
|
||||
return fmt.Errorf("invalid status %q: must be one of open, done", v)
|
||||
}
|
||||
} else if v, ok := strings.CutPrefix(name, "_prio::"); ok {
|
||||
if !validPrios[v] {
|
||||
return fmt.Errorf("invalid priority %q: must be one of high, medium, low", v)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("invalid relation %q: custom _ prefix not allowed", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Query ---
|
||||
|
||||
func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) {
|
||||
n, err := s.store.GetNode(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pc, err := s.getPermContext()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !pc.canRead(id) {
|
||||
return nil, fmt.Errorf("permission denied: no read access to node %s", id)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) {
|
||||
var storeFilters []*models.Rel
|
||||
for _, ri := range filter.Rels {
|
||||
if ri.Target == "" {
|
||||
storeFilters = append(storeFilters, &models.Rel{Type: ri.Type, Target: ""})
|
||||
} else {
|
||||
id, ok := s.lookupRelTarget(ri.Type, ri.Target)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
storeFilters = append(storeFilters, &models.Rel{Type: ri.Type, Target: id})
|
||||
}
|
||||
}
|
||||
nodes, err := s.store.FindNodes(storeFilters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pc, err := s.getPermContext()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []*models.Node
|
||||
for _, n := range nodes {
|
||||
if pc.canRead(n.ID) {
|
||||
result = append(result, n)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
|
||||
// Build tag set from tag rels (Target == ""), applying property-replacement semantics.
|
||||
tmp := models.NewNode()
|
||||
for _, r := range input.Rels {
|
||||
if r.Target == "" {
|
||||
tmp.AddTag(string(r.Type))
|
||||
}
|
||||
}
|
||||
|
||||
// Apply defaults.
|
||||
if tmp.GetProperty("type") == "" {
|
||||
tmp.AddTag("_type::issue")
|
||||
}
|
||||
if tmp.GetProperty("type") == "issue" && tmp.GetProperty("status") == "" {
|
||||
tmp.AddTag("_status::open")
|
||||
}
|
||||
|
||||
// Validate all rels (including the resolved default tags).
|
||||
tagRels := make([]RelInput, len(tmp.Tags))
|
||||
for i, t := range tmp.Tags {
|
||||
tagRels[i] = RelInput{Type: models.RelType(t)}
|
||||
}
|
||||
if err := validateRels(append(tagRels, input.Rels...)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Permission checks for edge rels.
|
||||
pc, err := s.getPermContext()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ri := range input.Rels {
|
||||
if ri.Target == "" {
|
||||
continue // tag rel, no target to check
|
||||
}
|
||||
targetID, found := s.lookupRelTarget(ri.Type, ri.Target)
|
||||
if !found {
|
||||
continue // will be auto-created; skip check
|
||||
}
|
||||
permLevel, isPerm := permRelLevels[ri.Type]
|
||||
switch {
|
||||
case ri.Type == models.RelHasOwnership:
|
||||
if !pc.hasOwnership(targetID) {
|
||||
return nil, fmt.Errorf("permission denied: no ownership of %q to transfer", ri.Target)
|
||||
}
|
||||
case isPerm:
|
||||
if pc.level(targetID) < permLevel {
|
||||
return nil, fmt.Errorf("permission denied: cannot grant %s on %q", ri.Type, ri.Target)
|
||||
}
|
||||
default:
|
||||
// Non-perm rel: source is the new node (creator gets ownership = can_create_rel).
|
||||
// Target: reference rels (assignee/mentions/in_namespace) need can_read; others need can_create_rel.
|
||||
if isReferenceRel(ri.Type) {
|
||||
if !pc.canRead(targetID) {
|
||||
return nil, fmt.Errorf("permission denied: no read access to %q", ri.Target)
|
||||
}
|
||||
} else {
|
||||
if !pc.canCreateRel(targetID) {
|
||||
return nil, fmt.Errorf("permission denied: no create_rel access to %q", ri.Target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasNamespace := false
|
||||
for _, ri := range input.Rels {
|
||||
if ri.Type == models.RelInNamespace && ri.Target != "" {
|
||||
hasNamespace = true
|
||||
}
|
||||
}
|
||||
|
||||
id, err := s.store.GenerateID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.store.Transaction(func(st store.Store) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if err := st.AddNode(id, input.Title, input.Content, input.DueDate, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Store tag rels.
|
||||
for _, t := range tmp.Tags {
|
||||
if err := st.AddRel(id, t, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Mentions.
|
||||
for _, m := range mentions(input.Title + " " + input.Content) {
|
||||
userID, err := s.resolveUserRef(st, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.AddRel(id, string(models.RelMentions), userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Edge rels.
|
||||
hasCreated := false
|
||||
for _, ri := range input.Rels {
|
||||
if ri.Target == "" {
|
||||
continue // already stored as tag
|
||||
}
|
||||
if ri.Type == models.RelCreated {
|
||||
hasCreated = true
|
||||
}
|
||||
resolved, err := s.resolveRelTarget(st, ri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ri.Type == models.RelHasOwnership {
|
||||
// Ownership transfer: remove existing owner of the target.
|
||||
existingOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: resolved}})
|
||||
for _, owner := range existingOwners {
|
||||
st.RemoveRel(owner.ID, string(models.RelHasOwnership), resolved) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
if err := st.AddRel(id, string(ri.Type), resolved); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Default namespace.
|
||||
if !hasNamespace {
|
||||
nsID, err := s.resolveNamespaceRef(st, s.userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.AddRel(id, string(models.RelInNamespace), nsID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Default created.
|
||||
if !hasCreated {
|
||||
userID, err := s.resolveUserRef(st, s.userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.AddRel(id, string(models.RelCreated), userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Grant creator ownership of the new node.
|
||||
creatorID, err := s.resolveUserRef(st, s.userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.AddRel(creatorID, string(models.RelHasOwnership), id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Namespace bootstrap: when creating a namespace node directly, apply the
|
||||
// same setup as ensureNamespace — self in_namespace and creator ownership.
|
||||
if tmp.GetProperty("type") == "namespace" {
|
||||
if !hasNamespace {
|
||||
// Replace the default namespace rel (user's ns) with self-reference.
|
||||
userNsID, _ := s.resolveIDByNameAndType(st, s.userID, "namespace")
|
||||
if userNsID != "" {
|
||||
if err := st.RemoveRel(id, string(models.RelInNamespace), userNsID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Creator already gets ownership via the block above; nothing more to do.
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.GetNode(id)
|
||||
}
|
||||
|
||||
func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, error) {
|
||||
// Validate rels before doing any I/O.
|
||||
if err := validateRels(input.AddRels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// --- Permission checks ---
|
||||
pc, err := s.getPermContext()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Field/tag changes and rel removals require can_write on the node.
|
||||
needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil
|
||||
for _, ri := range input.AddRels {
|
||||
if ri.Target == "" {
|
||||
needsWrite = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(input.RemoveRels) > 0 {
|
||||
needsWrite = true
|
||||
}
|
||||
if needsWrite && !pc.canWrite(id) {
|
||||
return nil, fmt.Errorf("permission denied: no write access to node %s", id)
|
||||
}
|
||||
|
||||
// Check each edge rel being added.
|
||||
for _, ri := range input.AddRels {
|
||||
if ri.Target == "" {
|
||||
continue // tag — handled above
|
||||
}
|
||||
permLevel, isPerm := permRelLevels[ri.Type]
|
||||
targetID, found := s.lookupRelTarget(ri.Type, ri.Target)
|
||||
switch {
|
||||
case ri.Type == models.RelHasOwnership:
|
||||
if !found {
|
||||
return nil, fmt.Errorf("ownership target %q not found", ri.Target)
|
||||
}
|
||||
if !pc.hasOwnership(targetID) {
|
||||
return nil, fmt.Errorf("permission denied: no ownership of %q to transfer", ri.Target)
|
||||
}
|
||||
if !pc.canCreateRel(id) {
|
||||
return nil, fmt.Errorf("permission denied: no create_rel access to node %s", id)
|
||||
}
|
||||
case isPerm:
|
||||
// Perm rel: need perm_P on target; no check on source.
|
||||
if found && pc.level(targetID) < permLevel {
|
||||
return nil, fmt.Errorf("permission denied: insufficient permission on %q to grant %s", ri.Target, ri.Type)
|
||||
}
|
||||
default:
|
||||
// Non-perm rel: need can_create_rel on source.
|
||||
// Target: reference rels (assignee/mentions/in_namespace) need can_read; others need can_create_rel.
|
||||
if !pc.canCreateRel(id) {
|
||||
return nil, fmt.Errorf("permission denied: no create_rel access to node %s", id)
|
||||
}
|
||||
if found {
|
||||
if isReferenceRel(ri.Type) {
|
||||
if !pc.canRead(targetID) {
|
||||
return nil, fmt.Errorf("permission denied: no read access to %s target %q", ri.Type, ri.Target)
|
||||
}
|
||||
} else {
|
||||
if !pc.canCreateRel(targetID) {
|
||||
return nil, fmt.Errorf("permission denied: no create_rel access to %s target %q", ri.Type, ri.Target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce blocking constraint before allowing status=done.
|
||||
for _, r := range input.AddRels {
|
||||
if r.Target == "" && string(r.Type) == "_status::done" {
|
||||
if err := s.checkBlockers(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
err = s.store.Transaction(func(st store.Store) error {
|
||||
current, err := st.GetNode(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
for _, r := range input.AddRels {
|
||||
if r.Target == "" {
|
||||
tmp.AddTag(string(r.Type))
|
||||
}
|
||||
}
|
||||
for _, r := range input.RemoveRels {
|
||||
if r.Target == "" {
|
||||
tmp.RemoveTag(string(r.Type))
|
||||
}
|
||||
}
|
||||
|
||||
currentTags, newTags := current.Tags, tmp.Tags
|
||||
for _, t := range currentTags {
|
||||
if !slices.Contains(newTags, t) {
|
||||
if err := st.RemoveRel(id, t, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, t := range newTags {
|
||||
if !slices.Contains(currentTags, t) {
|
||||
if err := st.AddRel(id, t, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync mention edges when title or content changed.
|
||||
if input.Title != nil || input.Content != nil {
|
||||
if err := s.syncMentions(st, id, current, title, content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
currentRels := current.Relations
|
||||
for _, ri := range input.AddRels {
|
||||
if ri.Target == "" {
|
||||
continue // already handled as tag
|
||||
}
|
||||
resolved, err := s.resolveRelTarget(st, ri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 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.RemoveRel(id, string(ri.Type), oldTgt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ownership transfer: enforce single-owner constraint.
|
||||
if ri.Type == models.RelHasOwnership {
|
||||
existingOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: resolved}})
|
||||
for _, owner := range existingOwners {
|
||||
st.RemoveRel(owner.ID, string(models.RelHasOwnership), resolved) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
if err := st.AddRel(id, string(ri.Type), resolved); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, ri := range input.RemoveRels {
|
||||
if ri.Target == "" {
|
||||
continue // already handled as tag
|
||||
}
|
||||
resolved, err := s.resolveRelTarget(st, ri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.RemoveRel(id, string(ri.Type), resolved); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.GetNode(id)
|
||||
}
|
||||
|
||||
func (s *nodeServiceImpl) Delete(id string) error {
|
||||
pc, err := s.getPermContext()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !pc.canWrite(id) {
|
||||
return fmt.Errorf("permission denied: no write access to node %s", id)
|
||||
}
|
||||
return s.store.Transaction(func(st store.Store) error {
|
||||
return s.cascadeDelete(st, id, make(map[string]bool))
|
||||
})
|
||||
}
|
||||
|
||||
// cascadeDelete deletes id and all nodes it owns (recursively).
|
||||
// visited prevents infinite loops from ownership cycles.
|
||||
func (s *nodeServiceImpl) cascadeDelete(st store.Store, id string, visited map[string]bool) error {
|
||||
if visited[id] {
|
||||
return nil
|
||||
}
|
||||
visited[id] = true
|
||||
node, err := st.GetNode(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Capture owned node IDs before deleting (DeleteNode cascades the rels).
|
||||
ownedIDs := make([]string, len(node.Relations[string(models.RelHasOwnership)]))
|
||||
copy(ownedIDs, node.Relations[string(models.RelHasOwnership)])
|
||||
if err := st.DeleteNode(id); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ownedID := range ownedIDs {
|
||||
if ownedID == id {
|
||||
continue // skip self-ownership
|
||||
}
|
||||
s.cascadeDelete(st, ownedID, visited) //nolint:errcheck — node may already be gone
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- 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([]*models.Rel{{Type: "_type::user", Target: ""}})
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
func (s *nodeServiceImpl) checkBlockers(id string) error {
|
||||
blockers, err := s.store.FindNodes([]*models.Rel{{Type: models.RelBlocks, Target: id}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var blocking []string
|
||||
for _, b := range blockers {
|
||||
if b.GetProperty("status") == "open" {
|
||||
blocking = append(blocking, b.ID)
|
||||
}
|
||||
}
|
||||
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.AddRel(id, string(models.RelMentions), userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for uid := range existingMentionIDs {
|
||||
if !mentionedUserIDs[uid] {
|
||||
if err := st.RemoveRel(id, string(models.RelMentions), uid); 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:
|
||||
// Permission rels and all other edge rels expect raw node IDs.
|
||||
return ri.Target, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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:
|
||||
// Permission rels and other edge rels use raw node IDs.
|
||||
return "", false
|
||||
}
|
||||
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 property without creating it.
|
||||
func (s *nodeServiceImpl) resolveIDByNameAndType(st store.Store, title, nodeType string) (string, error) {
|
||||
nodes, err := st.FindNodes([]*models.Rel{{Type: models.RelType("_type::" + nodeType), Target: ""}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, n := range nodes {
|
||||
if n.Title == title {
|
||||
return n.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (s *nodeServiceImpl) resolveUserRef(st store.Store, ref string) (string, error) {
|
||||
if exists, _ := st.NodeExists(ref); exists {
|
||||
return ref, nil
|
||||
}
|
||||
return s.ensureUser(st, ref)
|
||||
}
|
||||
|
||||
func (s *nodeServiceImpl) ensureUser(st store.Store, username string) (string, error) {
|
||||
userID, err := s.resolveIDByNameAndType(st, username, "user")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if userID != "" {
|
||||
return userID, nil
|
||||
}
|
||||
id, err := st.GenerateID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if err := st.AddNode(id, username, "", "", now, now); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := st.AddRel(id, "_type::user", ""); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Users have self-ownership by default.
|
||||
if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *nodeServiceImpl) resolveNamespaceRef(st store.Store, ref string) (string, error) {
|
||||
if exists, _ := st.NodeExists(ref); exists {
|
||||
return ref, nil
|
||||
}
|
||||
return s.ensureNamespace(st, ref)
|
||||
}
|
||||
|
||||
func (s *nodeServiceImpl) ensureNamespace(st store.Store, name string) (string, error) {
|
||||
nsID, err := s.resolveIDByNameAndType(st, name, "namespace")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if nsID != "" {
|
||||
return nsID, nil
|
||||
}
|
||||
id, err := st.GenerateID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if err := st.AddNode(id, name, "", "", now, now); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := st.AddRel(id, "_type::namespace", ""); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
userID, err := s.resolveUserRef(st, s.userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := st.AddRel(id, string(models.RelCreated), userID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Creator owns the namespace.
|
||||
if err := st.AddRel(userID, string(models.RelHasOwnership), id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
67
src/service/session.go
Normal file
67
src/service/session.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Session holds the server-issued token returned by POST /auth/poll.
|
||||
// The ax server owns the full OIDC flow; the client only needs this token.
|
||||
type Session struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func sessionPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".config", "ax", "session.json"), nil
|
||||
}
|
||||
|
||||
func LoadSession() (*Session, error) {
|
||||
path, err := sessionPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var s Session
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func SaveSession(s *Session) error {
|
||||
path, err := sessionPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
func ClearSession() error {
|
||||
path, err := sessionPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.Remove(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
386
src/store/sqlite.go
Normal file
386
src/store/sqlite.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"axolotl/models"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var migrations = []string{
|
||||
`CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT, due_date TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
|
||||
`CREATE TABLE IF NOT EXISTS rels (from_id TEXT NOT NULL, rel_name TEXT NOT NULL, to_id TEXT NOT NULL DEFAULT '', PRIMARY KEY (from_id, rel_name, to_id), FOREIGN KEY (from_id) REFERENCES nodes(id) ON DELETE CASCADE)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_rels_from ON rels(from_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_rels_to ON rels(to_id)`,
|
||||
}
|
||||
|
||||
// querier abstracts *sql.DB and *sql.Tx so SQL helpers work for both.
|
||||
type querier interface {
|
||||
Exec(query string, args ...any) (sql.Result, error)
|
||||
Query(query string, args ...any) (*sql.Rows, error)
|
||||
QueryRow(query string, args ...any) *sql.Row
|
||||
}
|
||||
|
||||
// SQLiteStore is the top-level Store backed by a SQLite database file.
|
||||
type SQLiteStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// txStore wraps an active transaction. Its Transaction method is a no-op
|
||||
// passthrough so nested calls reuse the same transaction.
|
||||
type txStore struct {
|
||||
db *sql.DB
|
||||
tx *sql.Tx
|
||||
}
|
||||
|
||||
// InitSQLiteStore creates the database file and applies the schema.
|
||||
// It is idempotent (uses CREATE TABLE IF NOT EXISTS).
|
||||
func InitSQLiteStore(path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
pragmas := []string{"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=ON"}
|
||||
for _, q := range append(pragmas, migrations...) {
|
||||
if _, err := db.Exec(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindAndOpenSQLiteStore opens the SQLite database. If the AX_DB_PATH environment
|
||||
// variable is set, it uses that path directly. Otherwise, it walks up from the
|
||||
// current working directory to find an .ax.db file.
|
||||
func FindAndOpenSQLiteStore() (Store, error) {
|
||||
if dbpath := os.Getenv("AX_DB_PATH"); dbpath != "" {
|
||||
return NewSQLiteStore(dbpath)
|
||||
}
|
||||
dir, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
dbpath := filepath.Join(dir, ".ax.db")
|
||||
if _, err := os.Stat(dbpath); err == nil {
|
||||
return NewSQLiteStore(dbpath)
|
||||
}
|
||||
if parent := filepath.Dir(dir); parent == dir {
|
||||
return nil, errors.New("no .ax.db found (run 'ax init' first)")
|
||||
} else {
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FindOrInitSQLiteStore is like FindAndOpenSQLiteStore but intended for server
|
||||
// mode: if no .ax.db is found it creates and initialises one in the current
|
||||
// working directory instead of returning an error.
|
||||
func FindOrInitSQLiteStore() (Store, error) {
|
||||
if dbpath := os.Getenv("AX_DB_PATH"); dbpath != "" {
|
||||
if err := InitSQLiteStore(dbpath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewSQLiteStore(dbpath)
|
||||
}
|
||||
dir, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
dbpath := filepath.Join(dir, ".ax.db")
|
||||
if _, err := os.Stat(dbpath); err == nil {
|
||||
return NewSQLiteStore(dbpath)
|
||||
}
|
||||
if parent := filepath.Dir(dir); parent == dir {
|
||||
break
|
||||
} else {
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
// Not found — create and initialise in CWD.
|
||||
cwd, _ := filepath.Abs(".")
|
||||
dbpath := filepath.Join(cwd, ".ax.db")
|
||||
if err := InitSQLiteStore(dbpath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewSQLiteStore(dbpath)
|
||||
}
|
||||
|
||||
// NewSQLiteStore opens a SQLite database at the given path, runs a one-time
|
||||
// schema migration if needed, then applies per-connection PRAGMAs.
|
||||
func NewSQLiteStore(path string) (Store, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
// FK must be OFF during migration (table drops/renames).
|
||||
for _, q := range []string{"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=OFF"} {
|
||||
if _, err := db.Exec(q); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := migrateSchema(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("schema migration failed: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &SQLiteStore{db: db}, nil
|
||||
}
|
||||
|
||||
// migrateSchema migrates from the legacy two-table (tags + rels) schema to the
|
||||
// unified rels-only schema. It is a no-op when migration has already been applied.
|
||||
func migrateSchema(db *sql.DB) error {
|
||||
var tagsExists int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='tags'").Scan(&tagsExists); err != nil {
|
||||
return err
|
||||
}
|
||||
if tagsExists == 0 {
|
||||
return nil // already on new schema
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`CREATE TABLE rels_new (from_id TEXT NOT NULL, rel_name TEXT NOT NULL, to_id TEXT NOT NULL DEFAULT '', PRIMARY KEY (from_id, rel_name, to_id), FOREIGN KEY (from_id) REFERENCES nodes(id) ON DELETE CASCADE)`,
|
||||
`INSERT OR IGNORE INTO rels_new (from_id, rel_name, to_id) SELECT from_id, rel_type, to_id FROM rels`,
|
||||
`INSERT OR IGNORE INTO rels_new (from_id, rel_name, to_id) SELECT node_id, tag, '' FROM tags`,
|
||||
`DROP TABLE rels`,
|
||||
`DROP TABLE tags`,
|
||||
`ALTER TABLE rels_new RENAME TO rels`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_rels_from ON rels(from_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_rels_to ON rels(to_id)`,
|
||||
} {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Transaction ---
|
||||
|
||||
func (s *SQLiteStore) Transaction(fn func(Store) error) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := fn(&txStore{db: s.db, tx: tx}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *txStore) Transaction(fn func(Store) error) error {
|
||||
return fn(s) // already in a transaction — reuse it
|
||||
}
|
||||
|
||||
// --- Node operations ---
|
||||
|
||||
func addNode(q querier, id, title, content, dueDate, createdAt, updatedAt string) error {
|
||||
var dd interface{}
|
||||
if dueDate != "" {
|
||||
dd = dueDate
|
||||
}
|
||||
_, err := q.Exec(
|
||||
"INSERT INTO nodes (id, title, content, due_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
id, title, content, dd, createdAt, updatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func getNode(q querier, id string) (*models.Node, error) {
|
||||
n := models.NewNode()
|
||||
err := q.QueryRow(
|
||||
"SELECT id, title, COALESCE(content, ''), COALESCE(due_date, ''), created_at, updated_at FROM nodes WHERE id = ?", id,
|
||||
).Scan(&n.ID, &n.Title, &n.Content, &n.DueDate, &n.CreatedAt, &n.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := q.Query("SELECT rel_name, to_id FROM rels WHERE from_id = ?", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var relName, toID string
|
||||
rows.Scan(&relName, &toID)
|
||||
if toID == "" {
|
||||
n.AddTag(relName)
|
||||
} else {
|
||||
n.AddRelation(models.RelType(relName), toID)
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func updateNode(q querier, id, title, content, dueDate, updatedAt string) error {
|
||||
var dd interface{}
|
||||
if dueDate != "" {
|
||||
dd = dueDate
|
||||
}
|
||||
_, err := q.Exec(
|
||||
"UPDATE nodes SET title = ?, content = ?, due_date = ?, updated_at = ? WHERE id = ?",
|
||||
title, content, dd, updatedAt, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func deleteNode(q querier, id string) error {
|
||||
_, err := q.Exec("DELETE FROM nodes WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func nodeExists(q querier, id string) (bool, error) {
|
||||
var e bool
|
||||
err := q.QueryRow("SELECT EXISTS(SELECT 1 FROM nodes WHERE id = ?)", id).Scan(&e)
|
||||
return e, err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) AddNode(id, title, content, dueDate, createdAt, updatedAt string) error {
|
||||
return addNode(s.db, id, title, content, dueDate, createdAt, updatedAt)
|
||||
}
|
||||
func (s *SQLiteStore) GetNode(id string) (*models.Node, error) { return getNode(s.db, id) }
|
||||
func (s *SQLiteStore) UpdateNode(id, title, content, dueDate, updatedAt string) error {
|
||||
return updateNode(s.db, id, title, content, dueDate, updatedAt)
|
||||
}
|
||||
func (s *SQLiteStore) DeleteNode(id string) error { return deleteNode(s.db, id) }
|
||||
func (s *SQLiteStore) NodeExists(id string) (bool, error) { return nodeExists(s.db, id) }
|
||||
|
||||
func (s *txStore) AddNode(id, title, content, dueDate, createdAt, updatedAt string) error {
|
||||
return addNode(s.tx, id, title, content, dueDate, createdAt, updatedAt)
|
||||
}
|
||||
func (s *txStore) GetNode(id string) (*models.Node, error) { return getNode(s.tx, id) }
|
||||
func (s *txStore) UpdateNode(id, title, content, dueDate, updatedAt string) error {
|
||||
return updateNode(s.tx, id, title, content, dueDate, updatedAt)
|
||||
}
|
||||
func (s *txStore) DeleteNode(id string) error { return deleteNode(s.tx, id) }
|
||||
func (s *txStore) NodeExists(id string) (bool, error) { return nodeExists(s.db, id) }
|
||||
|
||||
// --- ID generation ---
|
||||
|
||||
func genID() string {
|
||||
b := make([]byte, 5)
|
||||
for i := range b {
|
||||
b[i] = "abcdefghijklmnopqrstuvwxyz"[rand.Intn(26)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func generateID(q querier) (string, error) {
|
||||
for {
|
||||
id := genID()
|
||||
exists, err := nodeExists(q, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GenerateID() (string, error) { return generateID(s.db) }
|
||||
func (s *txStore) GenerateID() (string, error) { return generateID(s.db) }
|
||||
|
||||
// --- Rel operations ---
|
||||
|
||||
func addRel(q querier, nodeID, relName, toID string) error {
|
||||
_, err := q.Exec("INSERT OR IGNORE INTO rels (from_id, rel_name, to_id) VALUES (?, ?, ?)", nodeID, relName, toID)
|
||||
return err
|
||||
}
|
||||
|
||||
func removeRel(q querier, nodeID, relName, toID string) error {
|
||||
_, err := q.Exec("DELETE FROM rels WHERE from_id = ? AND rel_name = ? AND to_id = ?", nodeID, relName, toID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) AddRel(nodeID, relName, toID string) error {
|
||||
return addRel(s.db, nodeID, relName, toID)
|
||||
}
|
||||
func (s *SQLiteStore) RemoveRel(nodeID, relName, toID string) error {
|
||||
return removeRel(s.db, nodeID, relName, toID)
|
||||
}
|
||||
func (s *txStore) AddRel(nodeID, relName, toID string) error {
|
||||
return addRel(s.tx, nodeID, relName, toID)
|
||||
}
|
||||
func (s *txStore) RemoveRel(nodeID, relName, toID string) error {
|
||||
return removeRel(s.tx, nodeID, relName, toID)
|
||||
}
|
||||
|
||||
// --- FindNodes ---
|
||||
|
||||
func findNodes(q querier, filters []*models.Rel) ([]*models.Node, error) {
|
||||
query := "SELECT DISTINCT n.id FROM nodes n"
|
||||
var joins []string
|
||||
var args []any
|
||||
|
||||
for i, f := range filters {
|
||||
alias := fmt.Sprintf("r%d", i)
|
||||
if f.Target == "" {
|
||||
// Tag/property filter: match rels with empty to_id by rel_name prefix.
|
||||
joins = append(joins, fmt.Sprintf(
|
||||
"JOIN rels %s ON n.id = %s.from_id AND %s.to_id = '' AND %s.rel_name LIKE ? || '%%'",
|
||||
alias, alias, alias, alias,
|
||||
))
|
||||
args = append(args, string(f.Type))
|
||||
} else {
|
||||
// Edge filter: match rel by exact rel_name and to_id.
|
||||
joins = append(joins, fmt.Sprintf(
|
||||
"JOIN rels %s ON n.id = %s.from_id AND %s.rel_name = ? AND %s.to_id = ?",
|
||||
alias, alias, alias, alias,
|
||||
))
|
||||
args = append(args, string(f.Type), f.Target)
|
||||
}
|
||||
}
|
||||
|
||||
if len(joins) > 0 {
|
||||
query += " " + strings.Join(joins, " ")
|
||||
}
|
||||
query += " ORDER BY n.created_at DESC"
|
||||
|
||||
rows, err := q.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
var nodes []*models.Node
|
||||
for _, id := range ids {
|
||||
n, err := getNode(q, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) FindNodes(filters []*models.Rel) ([]*models.Node, error) {
|
||||
return findNodes(s.db, filters)
|
||||
}
|
||||
|
||||
func (s *txStore) FindNodes(filters []*models.Rel) ([]*models.Node, error) {
|
||||
return findNodes(s.tx, filters)
|
||||
}
|
||||
30
src/store/store.go
Normal file
30
src/store/store.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package store
|
||||
|
||||
import "axolotl/models"
|
||||
|
||||
// Store is a primitive graph persistence interface. It provides basic
|
||||
// operations for nodes and directed rels. No business logic lives here.
|
||||
// "Tag" rels are rels with an empty toID (e.g. "_type::issue" or "backend").
|
||||
type Store interface {
|
||||
// Nodes
|
||||
AddNode(id, title, content, dueDate, createdAt, updatedAt string) error
|
||||
GetNode(id string) (*models.Node, error) // returns node with tags and rels populated
|
||||
UpdateNode(id, title, content, dueDate, updatedAt string) error // empty dueDate stores NULL
|
||||
DeleteNode(id string) error
|
||||
NodeExists(id string) (bool, error)
|
||||
GenerateID() (string, error) // returns a random 5-char ID guaranteed unique in the store
|
||||
|
||||
// Rels: relName is the relation name; toID is empty for "tag" rels (properties/labels).
|
||||
AddRel(nodeID, relName, toID string) error
|
||||
RemoveRel(nodeID, relName, toID string) error
|
||||
|
||||
// FindNodes returns fully-populated nodes matching all given filters.
|
||||
// Filters with empty Target match nodes by rel_name prefix with empty toID (tag/property).
|
||||
// Filters with non-empty Target match nodes by exact rel_name and toID (edge).
|
||||
FindNodes(filters []*models.Rel) ([]*models.Node, error)
|
||||
|
||||
// Transaction runs fn inside an atomic transaction. If fn returns an error
|
||||
// the transaction is rolled back; otherwise it is committed.
|
||||
// Calls to Transaction inside fn reuse the same transaction (no nesting).
|
||||
Transaction(fn func(Store) error) error
|
||||
}
|
||||
Reference in New Issue
Block a user