verstak/internal/gui/server.go

389 lines
9.9 KiB
Go

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/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
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)
return &Server{
db: db, vaultRoot: vaultRoot,
nodes: nodeRepo, files: fileSvc, notes: noteSvc, actions: actionSvc,
worklog: workSvc, search: srchSvc,
}
}
// 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/", 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/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&section=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")
}
}
// 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)
}