package gui import ( "encoding/json" "fmt" "html/template" "log" "net" "net/http" "strings" "verstak/internal/core/actions" "verstak/internal/core/files" "verstak/internal/core/notes" "verstak/internal/core/nodes" "verstak/internal/core/plugins" "verstak/internal/core/search" "verstak/internal/core/storage" "verstak/internal/core/worklog" ) // Server is the GUI HTTP server bound to a vault. type Server struct { db *storage.DB vaultRoot string nodes *nodes.Repository files *files.Service notes *notes.Service actions *actions.Service worklog *worklog.Service search *search.Service plugins *plugins.Manager srv *http.Server listener net.Listener port int } // NewServer creates a GUI server for the given vault. func NewServer(db *storage.DB, vaultRoot string) *Server { nodeRepo := nodes.NewRepository(db) fileSvc := files.NewService(db, vaultRoot) noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc) actionSvc := actions.NewService(db) workSvc := worklog.NewService(db) srchSvc := search.NewService(db) pluginMgr := plugins.NewManager(vaultRoot) pluginMgr.Discover() return &Server{ db: db, vaultRoot: vaultRoot, nodes: nodeRepo, files: fileSvc, notes: noteSvc, actions: actionSvc, worklog: workSvc, search: srchSvc, plugins: pluginMgr, } } // Start binds on a free port and returns the base URL. func (s *Server) Start() (string, error) { mux := http.NewServeMux() mux.HandleFunc("/api/nodes", s.handleNodes) mux.HandleFunc("/api/nodes/from-template", s.handleNodeFromTemplate) mux.HandleFunc("/api/nodes/", s.handleNodeDetail) mux.HandleFunc("/api/notes/", s.handleNotes) mux.HandleFunc("/api/files/", s.handleFiles) mux.HandleFunc("/api/actions/", s.handleActions) mux.HandleFunc("/api/worklog/", s.handleWorklog) mux.HandleFunc("/api/templates", s.handleTemplates) mux.HandleFunc("/api/search", s.handleSearch) mux.HandleFunc("/", s.handleStatic) s.srv = &http.Server{Handler: mux} ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return "", err } s.listener = ln s.port = ln.Addr().(*net.TCPAddr).Port go func() { if e := s.srv.Serve(ln); e != nil && e != http.ErrServerClosed { log.Printf("GUI: %v", e) } }() return fmt.Sprintf("http://127.0.0.1:%d", s.port), nil } // Stop shuts down the server. func (s *Server) Stop() error { if s.srv != nil { return s.srv.Close() } return nil } // Addr returns the base URL. func (s *Server) Addr() string { return fmt.Sprintf("http://127.0.0.1:%d", s.port) } // --- handlers --- func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" && r.URL.Path != "/index.html" { http.NotFound(w, r) return } t := template.Must(template.New("idx").Parse(indexHTML)) w.Header().Set("Content-Type", "text/html; charset=utf-8") t.Execute(w, nil) } func jsonOK(w http.ResponseWriter, v interface{}) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) } func jsonErr(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}) } // GET /api/nodes[?parent=ID§ion=X] POST /api/nodes func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": parent := r.URL.Query().Get("parent") section := r.URL.Query().Get("section") var list interface{} var err error if parent == "" { list, err = s.nodes.ListRoots(false, section) } else { list, err = s.nodes.ListChildren(parent, false) } if err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, list) case "POST": var req struct { ParentID string `json:"parent_id"` Type string `json:"type"` Title string `json:"title"` Section string `json:"section"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, "bad json") return } n, err := s.nodes.Create(req.ParentID, req.Type, req.Title, req.Section) if err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, n) default: jsonErr(w, 405, "method not allowed") } } // POST /api/nodes/from-template — create node tree from a template. func (s *Server) handleNodeFromTemplate(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { jsonErr(w, 405, "method not allowed") return } var req struct { ParentID string `json:"parent_id"` Type string `json:"type"` Title string `json:"title"` Section string `json:"section"` Template string `json:"template"` } json.NewDecoder(r.Body).Decode(&req) // Find the template. var tmpl *plugins.TemplateDefinition for _, t := range s.plugins.Templates() { if t.Name == req.Template { tmpl = &t break } } if tmpl == nil { jsonErr(w, 404, "template not found") return } // Create root node. root, err := s.nodes.Create(req.ParentID, tmpl.RootType, req.Title, req.Section) if err != nil { jsonErr(w, 500, err.Error()) return } // Create children recursively. var createTree func(parentID string, nodes []plugins.TreeNode) error createTree = func(parentID string, nodes []plugins.TreeNode) error { for _, tn := range nodes { child, err := s.nodes.Create(parentID, tn.Type, tn.Title, "") if err != nil { return err } if len(tn.Children) > 0 { if err := createTree(child.ID, tn.Children); err != nil { return err } } } return nil } if err := createTree(root.ID, tmpl.Tree); err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, root) } // GET/PUT/DELETE /api/nodes/{id} func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/api/nodes/") switch r.Method { case "GET": n, err := s.nodes.GetActive(id) if err != nil { jsonErr(w, 404, "not found") return } children, _ := s.nodes.ListChildren(id, false) fl, _ := s.files.ListByNode(id) meta, _ := s.nodes.MetaList(id) jsonOK(w, map[string]interface{}{ "node": n, "children": children, "files": fl, "meta": meta, }) case "PUT": var req struct { Title string `json:"title"` ParentID string `json:"parent_id"` Sort int `json:"sort_order"` } json.NewDecoder(r.Body).Decode(&req) if req.Title != "" { s.nodes.UpdateTitle(id, req.Title) } if req.ParentID != "" { s.nodes.Move(id, req.ParentID, req.Sort) } jsonOK(w, map[string]string{"status": "ok"}) case "DELETE": s.nodes.SoftDelete(id) jsonOK(w, map[string]string{"status": "deleted"}) default: jsonErr(w, 405, "method not allowed") } } // POST /api/notes/{parentID} PUT/GET /api/notes/{nodeID} func (s *Server) handleNotes(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/notes/") switch r.Method { case "GET": rec, err := s.notes.Load(path) if err != nil { jsonErr(w, 404, "not found") return } content, _ := s.notes.Read(path) jsonOK(w, map[string]interface{}{"record": rec, "content": content}) case "POST": var req struct { Title string `json:"title"` Section string `json:"section"` } json.NewDecoder(r.Body).Decode(&req) n, _, err := s.notes.Create(path, req.Title, req.Section) if err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, n) case "PUT": var req struct{ Content string `json:"content"` } json.NewDecoder(r.Body).Decode(&req) if err := s.notes.Save(path, req.Content); err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, map[string]string{"status": "saved"}) default: jsonErr(w, 405, "method not allowed") } } // GET/DELETE /api/files/{id} func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/api/files/") switch r.Method { case "GET": rec, err := s.files.Get(id) if err != nil { jsonErr(w, 404, err.Error()) return } jsonOK(w, rec) case "DELETE": if err := s.files.DeleteToTrash(id); err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, map[string]string{"status": "trashed"}) default: jsonErr(w, 405, "method not allowed") } } // GET/POST/DELETE /api/actions/{id} GET /api/actions?node=ID func (s *Server) handleActions(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/actions/") switch r.Method { case "GET": if path != "" { rec, err := s.actions.Get(path) if err != nil { jsonErr(w, 404, err.Error()) return } jsonOK(w, rec) return } nodeID := r.URL.Query().Get("node") if nodeID == "" { jsonOK(w, []interface{}{}) return } list, err := s.actions.ListByNode(nodeID) if err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, list) case "POST": var req struct { NodeID string `json:"node_id"` Kind string `json:"kind"` Title string `json:"title"` Command string `json:"command"` URL string `json:"url"` WorkingDir string `json:"working_dir"` Args []string `json:"args"` Confirm bool `json:"confirm"` Capture bool `json:"capture"` } json.NewDecoder(r.Body).Decode(&req) rec, err := s.actions.Create(req.NodeID, req.Kind, req.Title, req.Command, req.WorkingDir, req.URL, req.Args, req.Confirm, req.Capture) if err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, rec) case "DELETE": if path == "" { jsonErr(w, 400, "id required") return } if err := s.actions.Delete(path); err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, map[string]string{"status": "deleted"}) default: jsonErr(w, 405, "method not allowed") } } // GET/POST/DELETE /api/worklog/{id} GET /api/worklog?node=ID func (s *Server) handleWorklog(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/worklog/") switch r.Method { case "GET": if path != "" { e, err := s.worklog.Get(path) if err != nil { jsonErr(w, 404, err.Error()) return } jsonOK(w, e) return } nodeID := r.URL.Query().Get("node") if nodeID == "" { jsonOK(w, []interface{}{}) return } list, err := s.worklog.ListByNode(nodeID) if err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, list) case "POST": var req struct { NodeID string `json:"node_id"` Summary string `json:"summary"` Details string `json:"details"` Minutes int `json:"minutes"` Approximate bool `json:"approximate"` Billable bool `json:"billable"` } json.NewDecoder(r.Body).Decode(&req) e, err := s.worklog.Add(req.NodeID, req.Summary, req.Details, req.Minutes, req.Approximate, req.Billable) if err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, e) case "DELETE": if path == "" { jsonErr(w, 400, "id required") return } if err := s.worklog.Delete(path); err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, map[string]string{"status": "deleted"}) default: jsonErr(w, 405, "method not allowed") } } // GET /api/search?q=... — FTS5 search across node titles + note content. func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") if len(q) < 2 { jsonOK(w, []interface{}{}) return } // Try FTS5 first, fall back to LIKE on node titles. results, err := s.search.Search(q) if err != nil || len(results) == 0 { // Fallback: search node titles directly. roots, _ := s.nodes.ListRoots(false, "") ql := strings.ToLower(q) for _, n := range roots { if strings.Contains(strings.ToLower(n.Title), ql) { results = append(results, search.Result{ NodeID: n.ID, Title: n.Title, Type: n.Type, }) } } } jsonOK(w, results) } // GET /api/templates — list all templates from active plugins. func (s *Server) handleTemplates(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { jsonErr(w, 405, "method not allowed") return } jsonOK(w, s.plugins.Templates()) }