463 lines
12 KiB
Go
463 lines
12 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/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())
|
|
}
|