package gui import ( "encoding/json" "fmt" "html/template" "log" "net" "net/http" "strings" "verstak/internal/core/files" "verstak/internal/core/notes" "verstak/internal/core/nodes" "verstak/internal/core/storage" ) // 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 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) return &Server{ db: db, vaultRoot: vaultRoot, nodes: nodeRepo, files: fileSvc, notes: noteSvc, } } // 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/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] POST /api/nodes func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": parent := r.URL.Query().Get("parent") var list interface{} var err error if parent == "" { list, err = s.nodes.ListRoots(false) } 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"` } 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) 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"` } json.NewDecoder(r.Body).Decode(&req) n, _, err := s.notes.Create(path, req.Title) 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 /api/search?q=... func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { q := strings.ToLower(r.URL.Query().Get("q")) if len(q) < 2 { jsonOK(w, []interface{}{}) return } roots, _ := s.nodes.ListRoots(false) var hits []map[string]interface{} for _, n := range roots { if strings.Contains(strings.ToLower(n.Title), q) { hits = append(hits, map[string]interface{}{"id": n.ID, "title": n.Title, "type": n.Type}) } } jsonOK(w, hits) }