192 lines
4.3 KiB
Go
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
|
|
}
|