250 lines
6.2 KiB
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)
|
|
}
|