diff --git a/examples/go/filewatcher/README.md b/examples/go/filewatcher/README.md new file mode 100644 index 000000000..1d27d9ccb --- /dev/null +++ b/examples/go/filewatcher/README.md @@ -0,0 +1,172 @@ + +# Filewatcher + +A lightweight Golang CLI tool that monitors a directory for PDF file changes and sends normalized file events to an external API in real time. + +Designed to support downstream workflows such as AI analysis, embedding pipelines, and event logging. + +--- + +## Features + +* Real-time directory monitoring on macOS +* PDF-only file filtering +* Detects file lifecycle events: + + * `FILE_ADDED` + * `FILE_UPDATED` + * `FILE_REMOVED` +* Sends normalized events to an HTTP API +* macOS write-event debouncing +* Clean, extensible architecture for future integrations +* Verbose logging mode for debugging + +--- + +## Requirements + +* Go **1.22+** +* macOS (uses `fsnotify`, compatible with FSEvents) +* Network access to the target API + +--- + +## Installation + +Clone the repository: + +```bash +git clone https://github.com/deven96/ahnlich.git +cd examples/go/filewatcher +``` + +Install dependencies: + +```bash +go mod tidy +``` + +Build the binary: + +```bash +go build -o filewatcher +``` + +--- + +## Usage + +```bash +filewatcher --dir /path/to/watch --api-url http://localhost:8080/events +``` + +### Flags + +| Flag | Required | Description | +| ----------- | -------- | ----------------------------------- | +| `--dir` | Yes | Directory to monitor | +| `--api-url` | Yes | API endpoint to receive file events | +| `--verbose` | No | Enable verbose (debug) logging | + +### Example + +```bash +filewatcher \ + --dir /Users/michael/Documents \ + --api-url http://localhost:8080/file-events \ + --verbose +``` + +--- + +## Event Payload + +Each detected file event is sent as JSON: + +```json +{ + "type": "FILE_ADDED", + "file_name": "invoice.pdf", + "path": "/Users/michael/Documents/invoice.pdf", + "timestamp": "2025-01-01T12:00:00Z" +} +``` + +--- + +## Supported Events + +| Event Type | Description | +| -------------- | -------------------------- | +| `FILE_ADDED` | New PDF file detected | +| `FILE_UPDATED` | Existing PDF file modified | +| `FILE_REMOVED` | PDF file deleted | + +--- + +## Logging + +Example logs: + +```text +[INFO] Watching directory: /Users/chijooke/Documents +[INFO] FILE_ADDED: invoice.pdf +[INFO] Posted event to API +``` + +With `--verbose` enabled, additional debug-level logs are shown. + +--- + +## Error Handling + +* Directory not found → Program exits with a descriptive error +* API failure → Logged; watcher continues running +* Permission issues → Logged as warnings +* Non-PDF files → Ignored + +--- + +## Project Structure + +```text +filewatcher/ +├── cmd/ # CLI flag parsing +├── internal/ +│ ├── watcher/ # File system monitoring logic +│ ├── events/ # Normalized event definitions +│ ├── api/ # HTTP client +│ └── logger/ # Logging setup +├── main.go +└── README.md +``` + +--- + +## Roadmap / Future Enhancements + +* Background retry queue for failed API calls +* File hashing to validate updates +* Built-in AI analysis pipeline +* Database persistence +* Scheduled or batched processing +* Integration with embedding storage (e.g. Ahnlich) + +--- + +## Contributing + +Contributions are welcome. + +1. Fork the repository +2. Create a feature branch +3. Commit your changes with clear messages +4. Open a pull request + +Please keep pull requests focused and well-documented. + +--- + +## License + +MIT License diff --git a/examples/go/filewatcher/api/client.go b/examples/go/filewatcher/api/client.go new file mode 100644 index 000000000..a6541cecc --- /dev/null +++ b/examples/go/filewatcher/api/client.go @@ -0,0 +1,39 @@ +package api + +import ( + "bytes" + "encoding/json" + "go/ahnlich/events" + "net/http" + "time" +) + +type Client struct { + url string + client *http.Client +} + +func New(apiURL string) *Client { + return &Client{ + url: apiURL, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +func (c *Client) SendEvent(event events.FileEvent) error { + body, err := json.Marshal(event) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, c.url, bytes.NewBuffer(body)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + _, err = c.client.Do(req) + return err +} diff --git a/examples/go/filewatcher/cmd/root.go b/examples/go/filewatcher/cmd/root.go new file mode 100644 index 000000000..910f80813 --- /dev/null +++ b/examples/go/filewatcher/cmd/root.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "errors" + "flag" +) + +type Config struct { + Dir string + APIURL string + Verbose bool +} + +func ParseFlags() (*Config, error) { + cfg := &Config{} + + flag.StringVar(&cfg.Dir, "dir", "", "Directory to watch") + flag.StringVar(&cfg.APIURL, "api-url", "", "API endpoint") + flag.BoolVar(&cfg.Verbose, "verbose", false, "Verbose logging") + flag.Parse() + + if cfg.Dir == "" || cfg.APIURL == "" { + return nil, errors.New("--dir and --api-url are required") + } + + return cfg, nil +} diff --git a/examples/go/filewatcher/events/events.go b/examples/go/filewatcher/events/events.go new file mode 100644 index 000000000..e5453ff93 --- /dev/null +++ b/examples/go/filewatcher/events/events.go @@ -0,0 +1,18 @@ +package events + +import "time" + +type EventType string + +const ( + FileAdded EventType = "FILE_ADDED" + FileUpdated EventType = "FILE_UPDATED" + FileRemoved EventType = "FILE_REMOVED" +) + +type FileEvent struct { + Type EventType `json:"type"` + FileName string `json:"file_name"` + Path string `json:"path"` + Timestamp time.Time `json:"timestamp"` +} diff --git a/examples/go/filewatcher/filewatcher b/examples/go/filewatcher/filewatcher new file mode 100755 index 000000000..d62fbb59b Binary files /dev/null and b/examples/go/filewatcher/filewatcher differ diff --git a/examples/go/filewatcher/go.mod b/examples/go/filewatcher/go.mod new file mode 100644 index 000000000..9fa4e4689 --- /dev/null +++ b/examples/go/filewatcher/go.mod @@ -0,0 +1,10 @@ +module go/ahnlich + +go 1.24.5 + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.27.1 // indirect + golang.org/x/sys v0.13.0 // indirect +) diff --git a/examples/go/filewatcher/go.sum b/examples/go/filewatcher/go.sum new file mode 100644 index 000000000..78c8f5cd8 --- /dev/null +++ b/examples/go/filewatcher/go.sum @@ -0,0 +1,8 @@ +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/examples/go/filewatcher/internals/watcher/debounce.go b/examples/go/filewatcher/internals/watcher/debounce.go new file mode 100644 index 000000000..f93c10dca --- /dev/null +++ b/examples/go/filewatcher/internals/watcher/debounce.go @@ -0,0 +1,25 @@ +package watcher + +import "time" + +type Debouncer struct { + lastEvent map[string]time.Time + window time.Duration +} + +func NewDebouncer(window time.Duration) *Debouncer { + return &Debouncer{ + lastEvent: make(map[string]time.Time), + window: window, + } +} + +func (d *Debouncer) ShouldProcess(path string) bool { + now := time.Now() + last, ok := d.lastEvent[path] + if ok && now.Sub(last) < d.window { + return false + } + d.lastEvent[path] = now + return true +} diff --git a/examples/go/filewatcher/internals/watcher/watcher.go b/examples/go/filewatcher/internals/watcher/watcher.go new file mode 100644 index 000000000..8a8bc39b3 --- /dev/null +++ b/examples/go/filewatcher/internals/watcher/watcher.go @@ -0,0 +1,86 @@ +package watcher + +import ( + "go/ahnlich/api" + "go/ahnlich/events" + "path/filepath" + "strings" + "time" + + "github.com/fsnotify/fsnotify" + "go.uber.org/zap" +) + +type Watcher struct { + logger *zap.Logger + apiClient *api.Client + debouncer *Debouncer +} + +func New(logger *zap.Logger, apiClient *api.Client) *Watcher { + return &Watcher{ + logger: logger, + apiClient: apiClient, + debouncer: NewDebouncer(500 * time.Millisecond), + } +} + +func (w *Watcher) Watch(dir string) error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + defer watcher.Close() + + if err := watcher.Add(dir); err != nil { + return err + } + + w.logger.Info("Watching directory", zap.String("dir", dir)) + + for { + select { + case event := <-watcher.Events: + w.handleEvent(event) + case err := <-watcher.Errors: + w.logger.Warn("Watcher error", zap.Error(err)) + } + } +} + +func (w *Watcher) handleEvent(event fsnotify.Event) { + if !strings.HasSuffix(strings.ToLower(event.Name), ".pdf") { + w.logger.Info("Ignoring non-PDF file", zap.String("file", event.Name)) + return + } + + if !w.debouncer.ShouldProcess(event.Name) { + return + } + + var eventType events.EventType + + switch { + case event.Op&fsnotify.Create == fsnotify.Create: + eventType = events.FileAdded + case event.Op&fsnotify.Write == fsnotify.Write: + eventType = events.FileUpdated + case event.Op&fsnotify.Remove == fsnotify.Remove: + eventType = events.FileRemoved + default: + return + } + + fileEvent := events.FileEvent{ + Type: eventType, + FileName: filepath.Base(event.Name), + Path: event.Name, + Timestamp: time.Now(), + } + + w.logger.Info(string(eventType), zap.String("file", fileEvent.FileName)) + + if err := w.apiClient.SendEvent(fileEvent); err != nil { + w.logger.Warn("Failed to post event", zap.Error(err)) + } +} diff --git a/examples/go/filewatcher/logger/logger.go b/examples/go/filewatcher/logger/logger.go new file mode 100644 index 000000000..1d3d74fce --- /dev/null +++ b/examples/go/filewatcher/logger/logger.go @@ -0,0 +1,12 @@ +package logger + +import "go.uber.org/zap" + +func New(verbose bool) *zap.Logger { + if verbose { + logger, _ := zap.NewDevelopment() + return logger + } + logger, _ := zap.NewProduction() + return logger +} diff --git a/examples/go/filewatcher/main.go b/examples/go/filewatcher/main.go new file mode 100644 index 000000000..4e1209878 --- /dev/null +++ b/examples/go/filewatcher/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "go/ahnlich/api" + "go/ahnlich/cmd" + "go/ahnlich/internals/watcher" + "go/ahnlich/logger" + "log" + "os" + + "go.uber.org/zap" +) + +func main() { + cfg, err := cmd.ParseFlags() + if err != nil { + log.Fatal(err) + } + + logr := logger.New(cfg.Verbose) + defer logr.Sync() + + if _, err := os.Stat(cfg.Dir); err != nil { + logr.Fatal("Directory not found", zap.Error(err)) + } + + apiClient := api.New(cfg.APIURL) + w := watcher.New(logr, apiClient) + + if err := w.Watch(cfg.Dir); err != nil { + logr.Fatal("Watcher failed", zap.Error(err)) + } +}