verstak/internal/gui/server.go

250 lines
6.2 KiB
Go

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)
}