Files
kanban_cpp/backend/sse_watcher.go
T

192 lines
4.3 KiB
Go

package main
import (
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/fsnotify/fsnotify"
)
// startBoardsWatcher launches a goroutine that watches dev/issues and
// dev/flows (recursively, one level deep — completed/ subdir) for .md
// changes and broadcasts ServerEvent messages via the hub. It also
// invalidates the relevant cardsCache so the next /cards GET reflects
// disk state.
//
// On fsnotify errors the watcher logs + retries every 30s.
func startBoardsWatcher(hub *Hub) {
go func() {
for {
if err := runBoardsWatcher(hub); err != nil {
log.Printf("sse_watcher: %v — retrying in 30s", err)
time.Sleep(30 * time.Second)
}
}
}()
}
func runBoardsWatcher(hub *Hub) error {
w, err := fsnotify.NewWatcher()
if err != nil {
return err
}
defer w.Close()
roots := map[string]string{
"issues": issuesDir(),
"flows": flowsDir(),
}
for board, dir := range roots {
if err := addRecursive(w, dir); err != nil {
log.Printf("sse_watcher: watch %s (%s): %v", board, dir, err)
} else {
log.Printf("sse_watcher: watching %s -> %s", board, dir)
}
}
for {
select {
case ev, ok := <-w.Events:
if !ok {
return nil
}
handleFsEvent(hub, ev)
case err, ok := <-w.Errors:
if !ok {
return nil
}
log.Printf("sse_watcher: fsnotify error: %v", err)
}
}
}
// addRecursive adds dir and its immediate subdirectories (e.g.
// dev/issues/completed/) to the watcher. We do NOT follow symlinks.
func addRecursive(w *fsnotify.Watcher, root string) error {
if err := w.Add(root); err != nil {
return err
}
entries, err := readDirNoSymlink(root)
if err != nil {
return nil // root added, subdirs best-effort
}
for _, name := range entries {
full := filepath.Join(root, name)
// best-effort, ignore errors on subdirs
_ = w.Add(full)
}
return nil
}
// handleFsEvent translates an fsnotify event into a ServerEvent (if
// applicable) and broadcasts it. Non-md files and skipped names
// (README/INDEX/...) are ignored.
func handleFsEvent(hub *Hub, ev fsnotify.Event) {
board, cardID, action := classifyEvent(ev)
if board == "" {
return
}
// Invalidate the right cache so the next GET re-scans disk.
switch board {
case "issues":
if issuesCache != nil {
issuesCache.invalidate()
}
case "flows":
if flowsCache != nil {
flowsCache.invalidate()
}
}
if hub == nil {
return
}
hub.Broadcast(ServerEvent{
Board: board,
CardID: cardID,
Action: action,
EventType: sseEventForAction(action),
})
}
// classifyEvent inspects a raw fsnotify event and returns
// (board, cardID, action) — board is "" if the event is irrelevant.
func classifyEvent(ev fsnotify.Event) (board, cardID, action string) {
name := filepath.Base(ev.Name)
if !strings.HasSuffix(strings.ToLower(name), ".md") {
return "", "", ""
}
if isSkippedMarkdown(name) {
return "", "", ""
}
// Determine board from the path.
lower := strings.ToLower(filepath.ToSlash(ev.Name))
switch {
case strings.Contains(lower, "/dev/issues/"):
board = "issues"
case strings.Contains(lower, "/dev/flows/"):
board = "flows"
default:
return "", "", ""
}
cardID = deriveIDFromFilename(name)
switch {
case ev.Op&fsnotify.Create != 0:
action = "created"
case ev.Op&fsnotify.Remove != 0:
action = "deleted"
case ev.Op&fsnotify.Rename != 0:
// Treat rename as updated — the file likely moved between
// canonical dir and completed/.
action = "updated"
case ev.Op&fsnotify.Write != 0:
action = "updated"
default:
return "", "", ""
}
return board, cardID, action
}
func sseEventForAction(action string) string {
switch action {
case "created":
return "card_added"
case "deleted":
return "card_removed"
default:
return "card_changed"
}
}
// readDirNoSymlink lists subdirectory names under root, skipping symlinks
// and hidden entries.
func readDirNoSymlink(root string) ([]string, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, err
}
out := []string{}
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
// Detect symlinks: lstat the full path.
full := filepath.Join(root, name)
info, err := os.Lstat(full)
if err != nil {
continue
}
if info.Mode()&os.ModeSymlink != 0 {
continue
}
out = append(out, name)
}
return out, nil
}