package scanner import ( "context" "log" "os" "path/filepath" "strings" "time" "donniemarko/internal/note" ) type ChangeType int const ( Unchanged ChangeType = iota Created Modified Deleted ) type ChangeHandler interface { HandleCreate(path string) error HandleModify(path string) error HandleDelete(path string) error } type Change struct { Type ChangeType Path string ModTime time.Time } type ScannerService struct { RootDir string Interval time.Duration LastStates map[string]time.Time handler ChangeHandler } func NewScanner(path string) *ScannerService { return &ScannerService{ RootDir: filepath.Clean(path), Interval: 5 * time.Second, LastStates: make(map[string]time.Time), } } // SeedExisting primes the scanner with already-indexed notes so the first scan can detect deletions. func (s *ScannerService) SeedExisting(notes []*note.Note) { for _, n := range notes { if n == nil || n.Path == "" { continue } path := n.Path if !filepath.IsAbs(path) { path = filepath.Join(s.RootDir, path) } s.LastStates[path] = n.UpdatedAt } } func (s *ScannerService) SetHandler(handler ChangeHandler) { s.handler = handler } // Scan walks the root folder and update the states of each notes if // it has changed since the last time a scan occured func (s *ScannerService) Scan() ([]Change, error) { var changes []Change currentStates := make(map[string]time.Time) // Walk filesystem filepath.Walk(s.RootDir, func(path string, info os.FileInfo, err error) error { // skip the root dir itself if s.RootDir == path { return nil } // ignore anything that isn't a note if !isValidNoteFile(path, info) { return nil } currentStates[path] = info.ModTime() lastMod, existed := s.LastStates[path] if !existed { // create the note if it didn't exist yet // s.handler.HandleCreate(path) changes = append(changes, Change{Type: Created, Path: path, ModTime: lastMod}) } else if info.ModTime().After(lastMod) { changes = append(changes, Change{Type: Modified, Path: path, ModTime: info.ModTime()}) } return nil }) // Check for deletions for path := range s.LastStates { if _, exists := currentStates[path]; !exists { changes = append(changes, Change{Type: Deleted, Path: path}) } } s.LastStates = currentStates return changes, nil } // Monitor rescan the root folder at each new tick and handle state modifications func (s *ScannerService) Monitor(ctx context.Context) error { ticker := time.NewTicker(s.Interval) defer ticker.Stop() applyChanges := func(changes []Change) { for _, change := range changes { var err error switch change.Type { case Created: err = s.handler.HandleCreate(change.Path) case Modified: err = s.handler.HandleModify(change.Path) case Deleted: err = s.handler.HandleDelete(change.Path) } if err != nil { log.Printf("handler error for %s: %v", change.Path, err) } } } changes, err := s.Scan() if err != nil { log.Printf("scan error: %v", err) } else { applyChanges(changes) } for { select { case <-ticker.C: changes, err := s.Scan() if err != nil { log.Printf("scan error: %v", err) continue } applyChanges(changes) case <-ctx.Done(): return ctx.Err() } } } func isValidNoteFile(path string, info os.FileInfo) bool { // ignore temp and backup files for _, nono := range []string{".#", "~"} { if strings.Contains(path, nono) { return false } } // ignore files that are not markdown if info.IsDir() || filepath.Ext(path) != ".md" { return false } // ignore empty folder if info.IsDir() { files, err := os.ReadDir(path) if err != nil { log.Println(err.Error()) } if len(files) == 0 { return false } } return true }