├── yaml ├── invalid.yaml ├── test_config.yaml └── config.yaml ├── .gitignore ├── examples ├── logo.png ├── systemd │ ├── switchboard.service │ └── README.md ├── yaml │ └── README.md ├── launchctl │ └── README.md └── regex │ └── README.md ├── .github ├── FUNDING.yml └── workflows │ ├── codeql.yml │ ├── tests.yml │ ├── release.yml │ └── hb-clone-count.yml ├── Dockerfile ├── count.json ├── cmd └── main.go ├── watcher ├── poller.go ├── queue.go ├── poller_test.go ├── watcher_linux_test.go ├── helpers.go ├── queue_test.go ├── watcher_test.go ├── watcher.go └── watcher_linux.go ├── LICENSE ├── go.mod ├── Makefile ├── event ├── event.go └── event_test.go ├── utils ├── utils.go └── utils_test.go ├── .goreleaser.yml ├── go.sum ├── cli └── watch.go └── README.md /yaml/invalid.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | .DS_Store 3 | /dist 4 | -------------------------------------------------------------------------------- /examples/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cian911/switchboard/HEAD/examples/logo.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [Cian911] 2 | ko_fi: cian911 3 | custom: [https://www.buymeacoffee.com/cian_911] 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | COPY switchboard /usr/local/bin/switchboard 4 | 5 | ENTRYPOINT ["switchboard"] 6 | -------------------------------------------------------------------------------- /count.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": "Bad credentials", 3 | "documentation_url": "https://docs.github.com/rest" 4 | } 5 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/cian911/switchboard/cli" 5 | ) 6 | 7 | func main() { 8 | cli.Watch() 9 | } 10 | -------------------------------------------------------------------------------- /yaml/test_config.yaml: -------------------------------------------------------------------------------- 1 | pollingInterval: 10 2 | watchers: 3 | - path: "/home/cian/Documents/input" 4 | destination: "/home/cian/Documents/output" 5 | ext: ".txt" 6 | -------------------------------------------------------------------------------- /yaml/config.yaml: -------------------------------------------------------------------------------- 1 | pollingInterval: 10 2 | watchers: 3 | - path: "/home/cian/Downloads" 4 | destination: "/home/cian/Documents" 5 | ext: ".txt" 6 | pattern: "(?i)(financial-report-[a-z]+-[0-9]+.txt)" 7 | - path: "/home/cian/Downloads" 8 | destination: "/home/cian/Videos" 9 | ext: ".mp4" 10 | -------------------------------------------------------------------------------- /examples/systemd/switchboard.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=SWITCHBOARD demo service 3 | After=network.target 4 | StartLimitIntervalSec=0 5 | 6 | [Service] 7 | Type=simple 8 | Restart=always 9 | RestartSec=1 10 | User=pi 11 | ExecStart=/usr/bin/local/switchboard -c config.yaml 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /examples/yaml/README.md: -------------------------------------------------------------------------------- 1 | # Yaml Config 2 | 3 | Switchboard can also with a config.yaml file. See below for an example configuration. 4 | 5 | ```bash 6 | watchers: 7 | - path: "/home/user/input" 8 | destination: "/home/user/output" 9 | ext: ".txt" 10 | - path: "/home/user/downloads" 11 | destination: "/home/user/movies" 12 | ext: ".mp4" 13 | ``` 14 | 15 | With the content above, create a `config.yaml` file. You can then pass it as a flag to switchboard like so: 16 | 17 | ```bash 18 | switchboard watch --config config.yaml 19 | 20 | ### 21 | Using config file: yaml/config.yaml 22 | 2022/01/04 22:53:15 Observing 23 | ``` 24 | -------------------------------------------------------------------------------- /watcher/poller.go: -------------------------------------------------------------------------------- 1 | package watcher 2 | 3 | import ( 4 | "log" 5 | "time" 6 | ) 7 | 8 | // Poll polls the queue for valid events given an interval (in seconds) 9 | func (pw *PathWatcher) Poll(interval int) { 10 | go func() { 11 | ticker := time.NewTicker(time.Duration(interval) * time.Second) 12 | for { 13 | select { 14 | case <-ticker.C: 15 | log.Printf("Polling... - Queue Size: %d\n", pw.Queue.Size()) 16 | 17 | for hsh, ev := range pw.Queue.Queue { 18 | timeDiff := ev.Timestamp.Sub(time.Now()) 19 | if timeDiff < (time.Duration(-interval) * time.Second) { 20 | pw.Notify(ev.Path, ev.Operation) 21 | pw.Queue.Remove(hsh) 22 | } 23 | } 24 | } 25 | } 26 | }() 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | schedule: 9 | - cron: '26 17 * * 6' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ 'go' ] 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v2 28 | 29 | # Initializes the CodeQL tools for scanning. 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v1 32 | with: 33 | languages: ${{ matrix.language }} 34 | - name: Autobuild 35 | uses: github/codeql-action/autobuild@v1 36 | - name: Perform CodeQL Analysis 37 | uses: github/codeql-action/analyze@v1 38 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test Suite 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, ready_for_review, reopened] 6 | paths: 7 | - "cmd/**" 8 | - "event/**" 9 | - "utils/**" 10 | - "watcher/**" 11 | - "go.*" 12 | - ".github/workflows/test.yml" 13 | 14 | jobs: 15 | tests: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | with: 21 | fetch-depth: 0 22 | - name: Setup 23 | uses: actions/setup-go@v2 24 | with: 25 | go-version: '~1.23' 26 | - name: Install Dependencies 27 | run: | 28 | go install github.com/rakyll/gotest@latest 29 | go install golang.org/x/lint/golint@latest 30 | - name: Lint 31 | run: | 32 | make lint-all 33 | - name: Format 34 | run: | 35 | make test-format-all 36 | - name: Test 37 | run: | 38 | make test-all 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | permissions: 9 | contents: write 10 | packages: write 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | with: 19 | fetch-depth: 0 20 | - name: Setup QEMU 21 | uses: docker/setup-qemu-action@v1 22 | - name: Docker Login 23 | uses: docker/login-action@v1 24 | with: 25 | registry: ghcr.io 26 | username: ${{ github.repository_owner }} 27 | password: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} 28 | - name: Go Setup 29 | uses: actions/setup-go@v2 30 | with: 31 | go-version: 1.23 32 | - name: Run Release 33 | uses: goreleaser/goreleaser-action@v6 34 | with: 35 | version: latest 36 | args: release --clean 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} 39 | ARTIFACTORY_NAME_SECRET: ${{ secrets.ARTIFACTORY_NAME_SECRET }} 40 | -------------------------------------------------------------------------------- /.github/workflows/hb-clone-count.yml: -------------------------------------------------------------------------------- 1 | name: Homebrew Repo Clone Count 2 | 3 | on: 4 | schedule: 5 | - cron: "0 */8 * * *" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | persist-credentials: false 15 | fetch-depth: 0 16 | - name: Parse clone count using REST API 17 | run: | 18 | curl --user "Cian911:${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}" \ 19 | -H "Accept: application/vnd.github.v3+json" \ 20 | https://api.github.com/repos/Cian911/homebrew-switchboard/traffic/clones \ 21 | > count.json 22 | 23 | - name: Add to git repo 24 | run: | 25 | git add . 26 | git config --local user.name "GitHub Action" 27 | git config --local user.email "action@github.com" 28 | git commit -m "BOT: Update count.json" -a 29 | 30 | - name: Push 31 | uses: ad-m/github-push-action@master 32 | with: 33 | github_token: ${{ secrets.GITHUB_TOKEN }} 34 | branch: master 35 | -------------------------------------------------------------------------------- /examples/systemd/README.md: -------------------------------------------------------------------------------- 1 | # SystemD 2 | 3 | Below outlines an example `systemd` configuration for switchboard. 4 | 5 | ``` 6 | [Unit] 7 | Description=SWITCHBOARD demo service 8 | After=network.target 9 | StartLimitIntervalSec=0 10 | 11 | [Service] 12 | Type=simple 13 | Restart=always 14 | RestartSec=1 15 | User=pi 16 | ExecStart=switchboard watch --config /home/pi/config.yaml 17 | StandardOutput=append:/var/log/switchboard/log1.log 18 | StandardError=append:/var/log/switchboard/error1.log 19 | 20 | 21 | [Install] 22 | WantedBy=multi-user.target 23 | ``` 24 | 25 | Copy the above example and create a file called `switchboard.service` in `/etc/systemd/system` folder. 26 | 27 | !!! info 28 | You may need to create the log and error files ahead of time. 29 | 30 | ### Start, Stop, Status and Reload Service 31 | 32 | You can stop, start, and reload the service using any of the following commands. 33 | 34 | - Start 35 | `systemctl start switchboard` 36 | 37 | - Reload 38 | `systemctl daemon-reload switchboard` 39 | 40 | - Stop 41 | `systemctl stop switchboard` 42 | 43 | - Status 44 | `systemctl status switchboard` 45 | 46 | 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Cian Gallagher 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cian911/switchboard 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/fsnotify/fsnotify v1.7.0 7 | github.com/spf13/cobra v1.8.1 8 | github.com/spf13/viper v1.19.0 9 | ) 10 | 11 | require ( 12 | github.com/hashicorp/hcl v1.0.0 // indirect 13 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 14 | github.com/magiconair/properties v1.8.7 // indirect 15 | github.com/mitchellh/mapstructure v1.5.0 // indirect 16 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 17 | github.com/sagikazarmark/locafero v0.6.0 // indirect 18 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 19 | github.com/sourcegraph/conc v0.3.0 // indirect 20 | github.com/spf13/afero v1.11.0 // indirect 21 | github.com/spf13/cast v1.7.0 // indirect 22 | github.com/spf13/pflag v1.0.5 // indirect 23 | github.com/subosito/gotenv v1.6.0 // indirect 24 | go.uber.org/multierr v1.11.0 // indirect 25 | golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect 26 | golang.org/x/sys v0.24.0 // indirect 27 | golang.org/x/text v0.17.0 // indirect 28 | gopkg.in/ini.v1 v1.67.0 // indirect 29 | gopkg.in/yaml.v3 v3.0.1 // indirect 30 | ) 31 | 32 | replace github.com/fsnotify/fsnotify v1.7.0 => github.com/cian911/fsnotify v1.7.5 33 | -------------------------------------------------------------------------------- /watcher/queue.go: -------------------------------------------------------------------------------- 1 | package watcher 2 | 3 | import ( 4 | "crypto/md5" 5 | "fmt" 6 | 7 | "github.com/cian911/switchboard/event" 8 | ) 9 | 10 | // Q holds the Queue 11 | type Q struct { 12 | Queue map[string]event.Event 13 | } 14 | 15 | // NewQueue create a new Q object 16 | func NewQueue() *Q { 17 | return &Q{ 18 | Queue: make(map[string]event.Event), 19 | } 20 | } 21 | 22 | // Add adds to the queue 23 | func (q *Q) Add(ev event.Event) { 24 | q.Queue[Hash(ev)] = ev 25 | } 26 | 27 | // Retrieve get an item from the queue given a valid hash 28 | func (q *Q) Retrieve(hash string) event.Event { 29 | return q.Queue[hash] 30 | } 31 | 32 | // Remove removes an item from the queue 33 | func (q *Q) Remove(hash string) { 34 | delete(q.Queue, hash) 35 | } 36 | 37 | // Size returns the size of the queue 38 | func (q *Q) Size() int { 39 | return len(q.Queue) 40 | } 41 | 42 | // Empty returns a bool indicating if the queue is empty or not 43 | func (q *Q) Empty() bool { 44 | return len(q.Queue) == 0 45 | } 46 | 47 | // Hash returns a md5 hash composed of an event File, Path, and Ext 48 | func Hash(ev event.Event) string { 49 | data := []byte(fmt.Sprintf("%s%s%s", ev.File, ev.Path, ev.Ext)) 50 | return fmt.Sprintf("%x", md5.Sum(data)) 51 | } 52 | -------------------------------------------------------------------------------- /examples/launchctl/README.md: -------------------------------------------------------------------------------- 1 | # launchctl 2 | 3 | Below outlines an example for MacOS users using `launchctl`. launchctl will ensure switchboard is started and left running in the background, so you can get on with what you need to do. 4 | 5 | Create the following file `sudo touch /Library/LaunchDaemons/switchboard.plist` and copy and paste the contents below. Note, you should make any changes necessary to the arguments list, depending on how you want to use the tool. 6 | 7 | ```bash 8 | 9 | 11 | 12 | 13 | Label 14 | switchboard 15 | ServiceDescription 16 | File system watcher 17 | ProgramArguments 18 | 19 | /usr/local/bin/switchboard 20 | watch 21 | --config 22 | /path_to_your_config_file/config.yaml 23 | 24 | RunAtLoad 25 | 26 | 27 | 28 | ``` 29 | 30 | Then, run the following commands to load, start, and list the running service and ensuring it has started. 31 | 32 | ```bash 33 | sudo launchctl load /Library/LaunchDaemons/switchboard.plist 34 | sudo launchctl start switchboard 35 | 36 | sudo launchctl list | grep switchboard 37 | ``` 38 | -------------------------------------------------------------------------------- /watcher/poller_test.go: -------------------------------------------------------------------------------- 1 | package watcher 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/cian911/switchboard/event" 8 | ) 9 | 10 | var ( 11 | pollInterval = 1 12 | ) 13 | 14 | func TestPoller(t *testing.T) { 15 | t.Run("It successfully notifies of a new event", func(t *testing.T) { 16 | pw := TestPathWatcher() 17 | pw.Poll(pollInterval) 18 | 19 | ev := eventSetup(t) 20 | pw.Queue.Add(*ev) 21 | 22 | if pw.Queue.Size() != 1 { 23 | t.Errorf("Queue size did not increase. want=%d, got=%d", 1, pw.Queue.Size()) 24 | } 25 | <-time.After(3 * time.Second) 26 | 27 | if pw.Queue.Size() != 0 { 28 | t.Errorf("Queue size did not decrease. want=%d, got=%d", 0, pw.Queue.Size()) 29 | } 30 | }) 31 | 32 | t.Run("It successfully notifies on a new dir event", func(t *testing.T) { 33 | pw := TestPathWatcher() 34 | pw.Poll(pollInterval) 35 | 36 | ev := setupNewDirEvent(t) 37 | pw.Queue.Add(*ev) 38 | 39 | if pw.Queue.Size() != 1 { 40 | t.Errorf("Queue size did not increase. want=%d, got=%d", 1, pw.Queue.Size()) 41 | } 42 | <-time.After(3 * time.Second) 43 | 44 | if pw.Queue.Size() != 0 { 45 | t.Errorf("Queue size did not decrease. want=%d, got=%d", 0, pw.Queue.Size()) 46 | } 47 | }) 48 | } 49 | 50 | func setupNewDirEvent(t *testing.T) *event.Event { 51 | path := t.TempDir() 52 | 53 | return &event.Event{ 54 | Path: path, 55 | Destination: t.TempDir(), 56 | Ext: ".txt", 57 | Operation: "CREATE", 58 | Timestamp: time.Now(), 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /examples/regex/README.md: -------------------------------------------------------------------------------- 1 | ### Regex 2 | 3 | As of `v1.0.0` switchboard now supports regex patterns. 4 | 5 | ```sh 6 | Run the switchboard application passing in the path, destination, and file type you'd like to watch for. 7 | 8 | Usage: 9 | watch [flags] 10 | 11 | Flags: 12 | --config string Pass an optional config file containing multiple paths to watch. 13 | -d, --destination string Path you want files to be relocated. 14 | -e, --ext string File type you want to watch for. 15 | -h, --help help for watch 16 | -p, --path string Path you want to watch. 17 | --poll int Specify a polling time in seconds. (default 60) 18 | -r, --regex-pattern string Pass a regex pattern to watch for any files matching this pattern. 19 | ``` 20 | 21 | Below is an example of using regex patterns with switchboard in your `config.yaml` file. 22 | 23 | ```yaml 24 | pollingInterval: 10 25 | watchers: 26 | - path: "/home/cian/Downloads" 27 | destination: "/home/cian/Documents" 28 | ext: ".txt" 29 | - path: "/home/cian/Downloads" 30 | destination: "/home/cian/Documents/Reports" 31 | ext: ".txt" 32 | pattern: "(?i)(financial-report-[a-z]+-[0-9]+.txt)" 33 | - path: "/home/cian/Downloads" 34 | destination: "/home/cian/Videos" 35 | ext: ".mp4" 36 | ``` 37 | 38 | Or you can pass a regex pattern via the cli like so. 39 | 40 | ```bash 41 | switchboard watch -p /home/john/Downloads -d /home/john/Documents -e .csv -r "(?i)(financial-report-[a-z]+-[0-9]+.txt)" 42 | ``` 43 | 44 | 45 | -------------------------------------------------------------------------------- /watcher/watcher_linux_test.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | // +build linux 3 | 4 | package watcher 5 | 6 | import ( 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/cian911/switchboard/utils" 12 | ) 13 | 14 | func TestObserve(t *testing.T) { 15 | t.Run("CLOSE_WRITE", func(t *testing.T) { 16 | HelperPath = t.TempDir() 17 | HelperDestination = t.TempDir() 18 | HelperExt = ".txt" 19 | 20 | pw, pc := TestProducerConsumer() 21 | pw.Register(&pc) 22 | 23 | go pw.Observe(1) 24 | <-time.After(1 * time.Second) 25 | // Fire event 26 | h, err := os.Create(HelperPath + "/sample2.txt") 27 | if err != nil { 28 | t.Fatalf("Failed to create file in testdir: %v", err) 29 | } 30 | h.Close() 31 | os.OpenFile(HelperPath+"/sample.txt", 0, os.FileMode(int(0777))) 32 | <-time.After(3 * time.Second) 33 | 34 | files, _ := utils.ScanFilesInDir(HelperDestination) 35 | 36 | if len(files) != 1 { 37 | t.Errorf("CLOSE_WRITE event was not processed - want: %d, got: %d", 1, len(files)) 38 | } 39 | }) 40 | 41 | t.Run("CREATE", func(t *testing.T) { 42 | HelperPath = t.TempDir() 43 | HelperDestination = t.TempDir() 44 | HelperExt = ".txt" 45 | 46 | pw, pc := TestProducerConsumer() 47 | pw.Register(&pc) 48 | 49 | go pw.Observe(1) 50 | <-time.After(3 * time.Second) 51 | // Fire event 52 | os.Create(HelperPath + "/sample2.txt") 53 | <-time.After(3 * time.Second) 54 | 55 | files, _ := utils.ScanFilesInDir(HelperDestination) 56 | 57 | if len(files) != 1 { 58 | t.Errorf("CREATE event was not processed - want: %d, got: %d", 1, len(files)) 59 | } 60 | }) 61 | 62 | t.Run("WRITE", func(t *testing.T) { 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build build-arm build-debian run 2 | .PHONY: test-all test-watcher test-watcher-observe test-event test-utils test-cmd lint-all vet-all 3 | 4 | VERSION := test-build 5 | BUILD := $$(git log -1 --pretty=%h) 6 | BUILD_TIME := $$(date -u +"%Y%m%d.%H%M%S") 7 | 8 | build: 9 | @go build \ 10 | -ldflags "-X main.Version=${VERSION} -X main.Build=${BUILD} -X main.BuildTime=${BUILD_TIME}" \ 11 | -o ./bin/switchboard ./cmd 12 | 13 | build-debian: 14 | @GOOS=linux GOARCH=amd64 go build \ 15 | -ldflags "-X main.Version=${VERSION} -X main.Build=${BUILD} -X main.BuildTime=${BUILD_TIME}" \ 16 | -o ./bin/switchboard ./cmd 17 | 18 | build-arm: 19 | @GOOS=linux GOARCH=arm GOARM=5 go build \ 20 | -ldflags "-X main.Version=${VERSION} -X main.Build=${BUILD} -X main.BuildTime=${BUILD_TIME}" \ 21 | -o ./bin/switchboard ./cmd 22 | run: 23 | @go run ./cmd/main.go 24 | 25 | test-watcher: 26 | @gotest -v ./watcher 27 | 28 | test-watcher-observe: 29 | @gotest -v ./watcher -test.run TestObserve 30 | 31 | test-event: 32 | @gotest -v ./event 33 | 34 | test-utils: 35 | @gotest -v ./utils 36 | 37 | test-cmd: 38 | @gotest -v ./cmd 39 | 40 | test-all: test-all test-watcher test-watcher-observe test-event test-utils test-cmd 41 | 42 | lint-watcher: 43 | @golint ./watcher 44 | 45 | lint-event: 46 | @golint ./event 47 | 48 | lint-utils: 49 | @golint ./utils 50 | 51 | lint-cmd: 52 | @golint ./cmd 53 | 54 | lint-all: lint-watcher lint-event lint-utils lint-cmd 55 | 56 | vet-watcher: 57 | @go vet ./watcher/watcher.go 58 | @go vet ./watcher/watcher_test.go 59 | 60 | vet-event: 61 | @go vet ./event/event.go 62 | @go vet ./event/event_test.go 63 | 64 | vet-utils: 65 | @go vet ./utils/utils.go 66 | @go vet ./utils/utils_test.go 67 | 68 | vet-cmd: 69 | @go vet ./cmd/main.go 70 | @go vet ./cmd/watch.go 71 | 72 | vet-all: vet-watcher vet-event vet-utils vet-cmd 73 | 74 | test-format-all: 75 | @gofmt -l -d . 76 | -------------------------------------------------------------------------------- /watcher/helpers.go: -------------------------------------------------------------------------------- 1 | package watcher 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | "github.com/cian911/switchboard/event" 9 | "github.com/cian911/switchboard/utils" 10 | ) 11 | 12 | var ( 13 | HelperPath string 14 | HelperDestination string 15 | HelperFile string 16 | HelperExt string 17 | HelperOperation string 18 | HelperPattern string 19 | ) 20 | 21 | // TestEventSetup sets up a new event for testing purposes 22 | func TestEventSetup(t *testing.T) *event.Event { 23 | path := t.TempDir() 24 | _, err := os.CreateTemp(path, HelperFile) 25 | if err != nil { 26 | t.Fatalf("Unable to create temp file: %v", err) 27 | } 28 | 29 | return &event.Event{ 30 | File: HelperFile, 31 | Path: path, 32 | Destination: t.TempDir(), 33 | Ext: HelperExt, 34 | Operation: HelperOperation, 35 | Timestamp: time.Now(), 36 | } 37 | } 38 | 39 | // TestSimulateMultipleEvents takes a list of operations as args 40 | // ["CREATE", "WRITE", "CLOSE_WRITE"] 41 | // and returns them as a list of events 42 | func TestSimulateMultipleEvents(operationList []string, t *testing.T) []event.Event { 43 | eventList := []event.Event{} 44 | 45 | for _, op := range operationList { 46 | HelperOperation = op 47 | eventList = append(eventList, *TestEventSetup(t)) 48 | } 49 | 50 | return eventList 51 | } 52 | 53 | // TestProducerConsumer returns a Producer/Consumer struct for testing 54 | func TestProducerConsumer() (Producer, Consumer) { 55 | var pw Producer = &PathWatcher{ 56 | Path: HelperPath, 57 | Queue: NewQueue(), 58 | } 59 | 60 | pattern, _ := utils.ValidateRegexPattern(HelperPattern) 61 | 62 | var pc Consumer = &PathConsumer{ 63 | Path: HelperPath, 64 | Destination: HelperDestination, 65 | Ext: HelperExt, 66 | Pattern: *pattern, 67 | } 68 | 69 | return pw, pc 70 | } 71 | 72 | // TestPathWatcher returns a test watcher 73 | func TestPathWatcher() *PathWatcher { 74 | return &PathWatcher{ 75 | Queue: NewQueue(), 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /watcher/queue_test.go: -------------------------------------------------------------------------------- 1 | package watcher 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "time" 7 | 8 | "github.com/cian911/switchboard/event" 9 | ) 10 | 11 | var ( 12 | gFile = "sample.txt" 13 | gPath = "/var/sample.txt" 14 | gExt = ".txt" 15 | ) 16 | 17 | func TestQueue(t *testing.T) { 18 | t.Run("It adds one event to the queue", func(t *testing.T) { 19 | q := setupQueue() 20 | ev := testEvent(gFile, gPath, gExt) 21 | 22 | q.Add(*ev) 23 | 24 | if q.Size() != 1 { 25 | t.Errorf("Could size did not increase as expected. want=%d, got=%d", 1, q.Size()) 26 | } 27 | }) 28 | 29 | t.Run("It updates the event in the queue", func(t *testing.T) { 30 | q := setupQueue() 31 | ev := testEvent(gFile, gPath, gExt) 32 | 33 | q.Add(*ev) 34 | q.Add(*ev) 35 | q.Add(*ev) 36 | 37 | if q.Size() != 1 { 38 | // Queue size should not increase 39 | t.Errorf("Could size did not increase as expected. want=%d, got=%d", 1, q.Size()) 40 | } 41 | }) 42 | 43 | t.Run("It gets an item from the queue", func(t *testing.T) { 44 | q := setupQueue() 45 | ev := testEvent(gFile, gPath, gExt) 46 | 47 | hash := Hash(*ev) 48 | q.Add(*ev) 49 | e := q.Retrieve(hash) 50 | 51 | if !reflect.DeepEqual(ev, &e) { 52 | t.Errorf("Events are not the same. want=%v, got=%v", ev, e) 53 | } 54 | }) 55 | 56 | t.Run("It removes an item from the queue", func(t *testing.T) { 57 | q := setupQueue() 58 | ev := testEvent(gFile, gPath, gExt) 59 | 60 | hash := Hash(*ev) 61 | q.Add(*ev) 62 | q.Remove(hash) 63 | 64 | if q.Size() != 0 { 65 | t.Errorf("Could size did not increase as expected. want=%d, got=%d", 0, q.Size()) 66 | } 67 | }) 68 | 69 | t.Run("It returns a unique hash for a given event", func(t *testing.T) { 70 | ev1 := testEvent(gFile, gPath, gExt) 71 | ev2 := testEvent("sample2.txt", "/var/sample2.txt", ".txt") 72 | 73 | h1 := Hash(*ev1) 74 | h2 := Hash(*ev2) 75 | 76 | if h1 == h2 { 77 | t.Errorf("Hashes are the same when they shouldn't be. want=%s, got=%s", h1, h2) 78 | } 79 | }) 80 | } 81 | 82 | func setupQueue() *Q { 83 | return NewQueue() 84 | } 85 | 86 | func testEvent(file, path, ext string) *event.Event { 87 | return &event.Event{ 88 | File: file, 89 | Path: path, 90 | Ext: ext, 91 | Timestamp: time.Now(), 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /event/event.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "time" 10 | 11 | "github.com/cian911/switchboard/utils" 12 | ) 13 | 14 | var validOperations = map[string]bool{ 15 | "CREATE": true, 16 | "WRITE": true, 17 | "CLOSE_WRITE": true, 18 | } 19 | 20 | // Event is a struct that holds the information for a file event 21 | type Event struct { 22 | // File is the name of the file 23 | File string 24 | // Path is the path to the file 25 | Path string 26 | // Destination is the path to the destination 27 | Destination string 28 | // Ext is the file extension 29 | Ext string 30 | // Operation is the operation that was performed 31 | Operation string 32 | // IsDir is the new create vent a directory 33 | IsDir bool 34 | // Timestamp in unix time epoch 35 | Timestamp time.Time 36 | } 37 | 38 | // New creates and returns a new event struct 39 | func New(file, path, dest, ext string) *Event { 40 | return &Event{ 41 | File: file, 42 | Path: path, 43 | Destination: dest, 44 | Ext: ext, 45 | Timestamp: time.Now(), 46 | } 47 | } 48 | 49 | // Move moves the file to the destination 50 | func (e *Event) Move(path, file string) error { 51 | log.Printf("Moving e.Path: %s to %s/%s\n", path, e.Destination, e.File) 52 | 53 | sourcePath := filepath.Join(path, file) 54 | destPath := filepath.Join(e.Destination, e.File) 55 | 56 | inputFile, err := os.Open(sourcePath) 57 | if err != nil { 58 | return fmt.Errorf("Couldn't open source file: %s", err) 59 | } 60 | outputFile, err := os.Create(destPath) 61 | if err != nil { 62 | inputFile.Close() 63 | return fmt.Errorf("Couldn't open dest file: %s", err) 64 | } 65 | defer outputFile.Close() 66 | _, err = io.Copy(outputFile, inputFile) 67 | inputFile.Close() 68 | if err != nil { 69 | return fmt.Errorf("Writing to output file failed: %s", err) 70 | } 71 | // The copy was successful, so now delete the original file 72 | err = os.Remove(sourcePath) 73 | if err != nil { 74 | return fmt.Errorf("Failed removing original file: %s", err) 75 | } 76 | return nil 77 | } 78 | 79 | // IsValidEvent checks if the event operation and file extension is valid 80 | func (e *Event) IsValidEvent(ext string) bool { 81 | if ext == e.Ext && validOperations[e.Operation] { 82 | return true 83 | } 84 | 85 | return false 86 | } 87 | 88 | // IsNewDirEvent returns a bool if the given path is a directory or not 89 | func (e *Event) IsNewDirEvent() bool { 90 | if e.Ext == "" && utils.ValidatePath(e.Path) && utils.IsDir(e.Path) { 91 | return true 92 | } 93 | 94 | return false 95 | } 96 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | ) 10 | 11 | // ExtractFileExt returns the file extension of a file 12 | func ExtractFileExt(path string) string { 13 | // If the path is a directory, returns empty string 14 | if ValidatePath(path) && IsDir(path) { 15 | return "" 16 | } 17 | 18 | return filepath.Ext(strings.Trim(path, "'")) 19 | } 20 | 21 | // ExtractPathWithoutExt file path without the extension 22 | func ExtractPathWithoutExt(path string) string { 23 | return path[:len(path)-len(filepath.Ext(path))] 24 | } 25 | 26 | // CompareFilePaths two filepaths and return a bool 27 | func CompareFilePaths(p1, p2 string) bool { 28 | if ExtractPathWithoutExt(p1) == p2 { 29 | return true 30 | } 31 | 32 | return false 33 | } 34 | 35 | // ValidatePath checks if a path exists 36 | func ValidatePath(path string) bool { 37 | if path == "" { 38 | return false 39 | } 40 | 41 | if _, err := os.Stat(path); os.IsNotExist(err) { 42 | log.Printf("EVENT: Path does not exist: %s", path) 43 | return false 44 | } 45 | 46 | return true 47 | } 48 | 49 | // ValidateFileExt checks if a file extension is valid 50 | func ValidateFileExt(ext string) bool { 51 | if ext == "" || ext[0:1] != "." { 52 | return false 53 | } 54 | 55 | return true 56 | } 57 | 58 | // ScanFilesInDir scans and returns a map of all files in a given directory. 59 | // The returned map is a key value of the filename, and if a directory. 60 | // im_a_dir -> true 61 | // sample.txt -> false 62 | func ScanFilesInDir(path string) (map[string]bool, error) { 63 | files, err := os.ReadDir(path) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | // "sample.txt" -> false 69 | // "folder1" -> true 70 | fileList := make(map[string]bool) 71 | for _, file := range files { 72 | fileList[file.Name()] = file.IsDir() 73 | } 74 | 75 | return fileList, nil 76 | } 77 | 78 | // IsDir returns a boolean if the given path is a directory 79 | func IsDir(path string) bool { 80 | fileInfo, err := os.Stat(path) 81 | if err != nil { 82 | log.Printf("Could not find path: %v", err) 83 | return false 84 | } 85 | 86 | if fileInfo.IsDir() { 87 | return true 88 | } 89 | 90 | return false 91 | } 92 | 93 | // ValidateRegexPattern takes a regex pattern string as arg 94 | // and returns an error if invalid 95 | func ValidateRegexPattern(pattern string) (*regexp.Regexp, error) { 96 | r, err := regexp.Compile(pattern) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | return r, nil 102 | } 103 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | before: 3 | hooks: 4 | - go mod download 5 | builds: 6 | - 7 | main: ./cmd/main.go 8 | ldflags: 9 | - -s -w 10 | - -X main.Version={{ .Version }} 11 | - -X main.Build={{ .Commit }} 12 | - -X main.BuildDate={{ .Date }} 13 | - -X main.License={{ .Date }} 14 | goos: 15 | - linux 16 | - windows 17 | - darwin 18 | goarch: 19 | - amd64 20 | - arm 21 | - arm64 22 | - 386 23 | - riscv64 24 | goarm: 25 | - 5 26 | - 6 27 | - 7 28 | dockers: 29 | - 30 | image_templates: ["ghcr.io/cian911/{{ .ProjectName }}:{{ .Version }}"] 31 | dockerfile: Dockerfile 32 | use: buildx 33 | build_flag_templates: 34 | - --platform=linux/amd64 35 | - --label=org.opencontainers.image.title={{ .ProjectName }} 36 | - --label=org.opencontainers.image.description={{ .ProjectName }} 37 | - --label=org.opencontainers.image.url=https://github.com/cian911/{{ .ProjectName }} 38 | - --label=org.opencontainers.image.source=https://github.com/cian911/{{ .ProjectName }} 39 | - --label=org.opencontainers.image.version={{ .Version }} 40 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 41 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 42 | - --label=org.opencontainers.image.licenses=GPL-3.0 43 | archives: 44 | - 45 | id: switchboard 46 | checksum: 47 | name_template: 'checksums.txt' 48 | snapshot: 49 | name_template: "{{ .Tag }}" 50 | changelog: 51 | sort: asc 52 | use: github 53 | filters: 54 | exclude: 55 | - '^docs:' 56 | - '^test:' 57 | - '^typo|TYPO' 58 | - typo 59 | - Merge pull request 60 | - Merge remote-tracking branch 61 | - Merge branch 62 | groups: 63 | - title: 'New Features' 64 | regexp: "^.*FEAT|WATCHER|CLI|EVENT|UTILS|CMD[(\\w)]*:+.*$" 65 | order: 0 66 | - title: 'Fixes' 67 | order: 10 68 | regexp: "^.*FIX|CHORE|BUGFIX|EXAMPLES|BUG[(\\w)]*:+.*$" 69 | - title: 'Workflow Updates' 70 | regexp: "^.*ACTIONS|ACTION[(\\w)]*:+.*$" 71 | order: 20 72 | - title: 'Other things' 73 | order: 999 74 | nfpms: 75 | - 76 | vendor: Cian911 77 | formats: 78 | - deb 79 | - rpm 80 | - apk 81 | brews: 82 | - 83 | goarm: 6 84 | repository: 85 | owner: Cian911 86 | name: homebrew-switchboard 87 | directory: Formula 88 | commit_author: 89 | name: Cian911 90 | email: cian@ciangallagher.net 91 | homepage: "https://github.com/Cian911/switchboard" 92 | description: | 93 | Automated file organisation and routing for all your machines. 94 | 95 | artifactories: 96 | - name: swb 97 | mode: binary 98 | target: 'https://switchboard.jfrog.io/artifactory/switchboard-debian/{{ .ProjectName }}/{{ .Version }}/{{ .Os }}/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}' 99 | username: cian911-go 100 | -------------------------------------------------------------------------------- /event/event_test.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "os" 8 | "testing" 9 | ) 10 | 11 | var ( 12 | event = &Event{ 13 | File: "readme.txt", 14 | Path: "/input", 15 | Destination: "/output", 16 | Ext: ".txt", 17 | Operation: "CREATE", 18 | } 19 | 20 | ext = ".txt" 21 | file = "sample.txt" 22 | ) 23 | 24 | func TestEvent(t *testing.T) { 25 | t.Run("It returns true when event is valid", func(t *testing.T) { 26 | want := true 27 | got := event.IsValidEvent(ext) 28 | 29 | if want != got { 30 | t.Errorf("event extension is not valid, when it should have been: want=%t, got=%t", want, got) 31 | } 32 | }) 33 | 34 | t.Run("It returns false when event is valid", func(t *testing.T) { 35 | want := false 36 | got := event.IsValidEvent(".mp4") 37 | 38 | if want != got { 39 | t.Errorf("event extension is not valid, when it should have been: want=%t, got=%t", want, got) 40 | } 41 | }) 42 | 43 | t.Run("It moves file from one dir to another dir", func(t *testing.T) { 44 | event := eventSetup(t) 45 | // event := helpers.TestEventSetup(t) 46 | event.Move(event.Path, "") 47 | 48 | // If the file does not exist, log an error 49 | if _, err := os.Stat(fmt.Sprintf("%s/%s", event.Destination, event.File)); errors.Is(err, os.ErrNotExist) { 50 | t.Fatalf("Failed to move from %s/%s to %s/%s: %v : %v", event.Path, event.File, event.Destination, event.File, err, *event) 51 | } 52 | 53 | // If the file still exists in the source directory, log an error 54 | if _, err := os.Stat(fmt.Sprintf("%s/%s", event.Path, event.File)); !errors.Is(err, os.ErrNotExist) { 55 | t.Fatalf("Failed to delete file from %s/%s to %s/%s after Move: %v : %v", event.Path, event.File, event.Destination, event.File, err, *event) 56 | } 57 | }) 58 | 59 | t.Run("It moves file from one dir to another dir with valid destPath", func(t *testing.T) { 60 | event := eventSetup(t) 61 | event.Move(fmt.Sprintf("%s/", event.Path), "") 62 | 63 | // If the file does not exist, log an error 64 | if _, err := os.Stat(fmt.Sprintf("%s/%s", event.Destination, event.File)); errors.Is(err, os.ErrNotExist) { 65 | t.Fatalf("Failed to move from %s/%s to %s/%s: %v : %v", event.Path, event.File, event.Destination, event.File, err, *event) 66 | } 67 | 68 | // If the file still exists in the source directory, log an error 69 | if _, err := os.Stat(fmt.Sprintf("%s/%s", event.Path, event.File)); !errors.Is(err, os.ErrNotExist) { 70 | t.Fatalf("Failed to delete file from %s/%s to %s/%s after Move: %v : %v", event.Path, event.File, event.Destination, event.File, err, *event) 71 | } 72 | }) 73 | 74 | t.Run("It does not move file from one dir to another dir", func(t *testing.T) { 75 | event := eventSetup(t) 76 | event.Destination = "/abcdefg" 77 | err := event.Move(event.Path, "") 78 | 79 | if err == nil { 80 | log.Fatal("event.Move() should have thrown error but didn't.") 81 | } 82 | }) 83 | 84 | t.Run("It determines if the event is a new dir", func(t *testing.T) { 85 | event := eventSetup(t) 86 | event.File = "input" 87 | event.Ext = "" 88 | 89 | want := true 90 | got := event.IsNewDirEvent() 91 | 92 | if want != got { 93 | t.Errorf("Wanted new dir event but didn't get it: want=%t, got=%t", want, got) 94 | } 95 | }) 96 | } 97 | 98 | func eventSetup(t *testing.T) *Event { 99 | path := t.TempDir() 100 | _, err := os.CreateTemp(path, file) 101 | if err != nil { 102 | t.Fatalf("Unable to create temp file: %v", err) 103 | } 104 | 105 | return &Event{ 106 | File: file, 107 | Path: path, 108 | Destination: t.TempDir(), 109 | Ext: ext, 110 | Operation: "CREATE", 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /watcher/watcher_test.go: -------------------------------------------------------------------------------- 1 | package watcher 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/cian911/switchboard/event" 10 | "github.com/cian911/switchboard/utils" 11 | ) 12 | 13 | const ( 14 | path = "/tmp" 15 | destination = "/test" 16 | ) 17 | 18 | var ( 19 | e = &event.Event{ 20 | File: "readme.txt", 21 | Path: "/input", 22 | Destination: "/output", 23 | Ext: ".txt", 24 | Operation: "CREATE", 25 | } 26 | 27 | ext = ".txt" 28 | file = "sample.txt" 29 | ) 30 | 31 | func TestWatcher(t *testing.T) { 32 | t.Run("It registers a consumer", func(t *testing.T) { 33 | pw, pc := setup(path, destination, ext, "") 34 | 35 | pw.Register(&pc) 36 | 37 | if len(pw.(*PathWatcher).Consumers) != 1 { 38 | t.Fatalf("Consumer was not registered when it should have been. want=%d, got=%d", 1, len(pw.(*PathWatcher).Consumers)) 39 | } 40 | }) 41 | 42 | t.Run("It unregisters a consumer", func(t *testing.T) { 43 | pw, pc := setup(path, destination, ext, "") 44 | 45 | pw.Register(&pc) 46 | pw.Unregister(&pc) 47 | 48 | if len(pw.(*PathWatcher).Consumers) != 0 { 49 | t.Fatalf("Consumer was not unregistered when it should have been. want=%d, got=%d", 0, len(pw.(*PathWatcher).Consumers)) 50 | } 51 | }) 52 | 53 | t.Run("It processes a new dir event", func(t *testing.T) { 54 | ev := eventSetup(t) 55 | ev.Path = t.TempDir() 56 | ev.File = utils.ExtractFileExt(ev.Path) 57 | 58 | pw, pc := setup(ev.Path, ev.Destination, ev.Ext, "") 59 | pw.Register(&pc) 60 | pw.Unregister(&pc) 61 | 62 | for i := 1; i <= 3; i++ { 63 | file := createTempFile(ev.Path, ".txt", t) 64 | defer os.Remove(file.Name()) 65 | } 66 | 67 | pc.Receive(ev.Path, "CREATE") 68 | 69 | // Scan dest dir for how many files it contains 70 | // if want == got, all files have been moved successfully 71 | filesInDir, err := utils.ScanFilesInDir(ev.Destination) 72 | if err != nil { 73 | t.Fatalf("Could not scan all files in destination dir: %v", err) 74 | } 75 | 76 | want := 3 77 | got := len(filesInDir) 78 | 79 | if want != got { 80 | t.Fatalf("want: %d != got: %d - debug - files: %v, event: %v", want, got, filesInDir, ev) 81 | } 82 | }) 83 | 84 | t.Run("It pocesses a pattern matched event", func(t *testing.T) { 85 | ev := eventSetup(t) 86 | ev.File = utils.ExtractFileExt(ev.Path) 87 | 88 | pw, pc := setup(ev.Path, ev.Destination, ev.Ext, "[0-9]+.txt") 89 | pw.Register(&pc) 90 | pw.Unregister(&pc) 91 | 92 | for i := 1; i <= 3; i++ { 93 | // Create 3 temp files 94 | file := createTempFile(ev.Path, ".txt", t) 95 | defer os.Remove(file.Name()) 96 | } 97 | 98 | pc.Receive(ev.Path, "CREATE") 99 | 100 | // Scan dest dir for how many files it contains 101 | // if want == got, all files have been moved successfully 102 | filesInDir, err := utils.ScanFilesInDir(ev.Destination) 103 | t.Logf("Files in Dir: %v", filesInDir) 104 | if err != nil { 105 | t.Fatalf("Could not scan all files in destination dir: %v", err) 106 | } 107 | 108 | want := 3 109 | got := len(filesInDir) 110 | 111 | if want != got { 112 | t.Fatalf("want: %d != got: %d - debug - files: %v, event: %v", want, got, filesInDir, ev) 113 | } 114 | }) 115 | } 116 | 117 | func setup(p, d, e, rp string) (Producer, Consumer) { 118 | var pw Producer = &PathWatcher{ 119 | Path: p, 120 | } 121 | 122 | regexPattern, _ := utils.ValidateRegexPattern(rp) 123 | 124 | var pc Consumer = &PathConsumer{ 125 | Path: p, 126 | Destination: d, 127 | Ext: e, 128 | Pattern: *regexPattern, 129 | } 130 | 131 | return pw, pc 132 | } 133 | 134 | func eventSetup(t *testing.T) *event.Event { 135 | path := t.TempDir() 136 | _, err := os.CreateTemp(path, file) 137 | if err != nil { 138 | t.Fatalf("Unable to create temp file: %v", err) 139 | } 140 | 141 | return &event.Event{ 142 | File: file, 143 | Path: path, 144 | Destination: t.TempDir(), 145 | Ext: ext, 146 | Operation: "CREATE", 147 | Timestamp: time.Now(), 148 | } 149 | } 150 | 151 | func createTempFile(path, ext string, t *testing.T) *os.File { 152 | file, err := os.CreateTemp(path, fmt.Sprintf("*%s", ext)) 153 | if err != nil { 154 | t.Fatalf("Could not create temp file: %v", err) 155 | } 156 | 157 | return file 158 | } 159 | -------------------------------------------------------------------------------- /utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | const ( 10 | filePath = "/home/test/file.mp4" 11 | dirPath = "/home/test" 12 | diffDirPath = "/home/test.movies/" 13 | ) 14 | 15 | func TestUtils(t *testing.T) { 16 | t.Run("It tests ExtractFileExt()", func(t *testing.T) { 17 | tempDir := setupTempDir("'Author- [0.5] Version - Title (azw3 epub mobi)'", t) 18 | defer os.RemoveAll(tempDir) 19 | 20 | tests := []struct { 21 | input string 22 | expectedOutput string 23 | }{ 24 | {"/home/test/movie.mp4", ".mp4"}, 25 | {"/home/test/movie.maaaap4.aaa.mp4", ".mp4"}, 26 | {"/home/test/", ""}, 27 | {"/home/test", ""}, 28 | {"/home/test/movie.mp4/", ""}, 29 | {"/home/test/'movie.mp4'", ".mp4"}, 30 | {"/home/test/movie.mp4.part", ".part"}, 31 | {"/home/test/Some weird folder name ([0.5] epub) sample.mp4", ".mp4"}, 32 | {tempDir, ""}, 33 | } 34 | 35 | for _, tt := range tests { 36 | got := ExtractFileExt(tt.input) 37 | 38 | if got != tt.expectedOutput { 39 | t.Errorf("Failed extracting file extention: got=%s, want=%s", got, tt.expectedOutput) 40 | } 41 | } 42 | }) 43 | 44 | t.Run("It tests ValidatePath()", func(t *testing.T) { 45 | tempDir := setupTempDir("'Author - [0.5] - Title (azw3 epub mobi)'", t) 46 | defer os.RemoveAll(tempDir) 47 | 48 | tests := []struct { 49 | input string 50 | expectedOutput bool 51 | }{ 52 | {"", false}, 53 | {"/abba/asdas/asda", false}, 54 | {t.TempDir(), true}, 55 | {tempDir, true}, 56 | } 57 | 58 | for _, tt := range tests { 59 | got := ValidatePath(tt.input) 60 | 61 | if got != tt.expectedOutput { 62 | t.Errorf("Failed extracting file extention: got=%t, want=%t: input: %s", got, tt.expectedOutput, tt.input) 63 | } 64 | } 65 | }) 66 | 67 | t.Run("It tests ValidateFileExt()", func(t *testing.T) { 68 | tests := []struct { 69 | input string 70 | expectedOutput bool 71 | }{ 72 | {"", false}, 73 | {"file", false}, 74 | {"file...txt", false}, 75 | {".txt", true}, 76 | } 77 | 78 | for _, tt := range tests { 79 | got := ValidateFileExt(tt.input) 80 | 81 | if got != tt.expectedOutput { 82 | t.Errorf("Failed extracting file extention: got=%t, want=%t", got, tt.expectedOutput) 83 | } 84 | } 85 | }) 86 | 87 | t.Run("It tests ScanFilesInDir()", func(t *testing.T) { 88 | tempDir := setupTempDir("'Author - [0.5] - Title (azw3 epub mobi)'", t) 89 | // TODO: Make this more testable.. 90 | _ = setupTempFile("'Author - [0.5] - Title.azw3'", tempDir, t) 91 | _ = setupTempFile("'Author - [0.5] - Title.epub'", tempDir, t) 92 | 93 | tests := []struct { 94 | expectedOutput int 95 | }{ 96 | {2}, 97 | } 98 | 99 | for _, tt := range tests { 100 | got, err := ScanFilesInDir(tempDir) 101 | if err != nil { 102 | t.Fatalf("Could not scan files in dir: %v", err) 103 | } 104 | 105 | if len(got) != tt.expectedOutput { 106 | t.Errorf("Failed scanning files in dir: got=%v, want=%d", got, tt.expectedOutput) 107 | } 108 | } 109 | }) 110 | 111 | t.Run("It tests IsDir()", func(t *testing.T) { 112 | tempDir := setupTempDir("'Author- [0.5] Version - Title (azw3 epub mobi)'", t) 113 | defer os.RemoveAll(tempDir) 114 | 115 | tests := []struct { 116 | input string 117 | expectedOutput bool 118 | }{ 119 | {"/home/test/movie.mp4", false}, 120 | {"/home/test/movie.maaaap4.aaa.mp4", false}, 121 | {"/home/test/", false}, 122 | {"/home/test", false}, 123 | {"/home/test/Some weird folder name ([0.5] epub) sample.mp4", false}, 124 | {tempDir, true}, 125 | {"/tmp", true}, 126 | } 127 | 128 | for _, tt := range tests { 129 | got := IsDir(tt.input) 130 | 131 | if got != tt.expectedOutput { 132 | t.Errorf("%s should be a dir but returned failed. want=%t, got=%t", tt.input, tt.expectedOutput, got) 133 | } 134 | } 135 | }) 136 | 137 | t.Run("It tests ValidateRegexPattern()", func(t *testing.T) { 138 | tests := []struct { 139 | pattern string 140 | expectedOutput bool 141 | }{ 142 | {`[0-9]`, true}, 143 | {`S[0-9]+EP[0-9]+`, true}, 144 | {`\\\\?m)^[0-9]{2}$`, false}, 145 | } 146 | 147 | for _, tt := range tests { 148 | _, err := ValidateRegexPattern(tt.pattern) 149 | 150 | if err != nil && tt.expectedOutput != false { 151 | t.Errorf("%s failed validation - Not a validate regex pattern. want=%t, got=%t", tt.pattern, tt.expectedOutput, err) 152 | } 153 | } 154 | }) 155 | } 156 | 157 | func setupTempDir(name string, t *testing.T) string { 158 | tempDir, err := ioutil.TempDir("", name) 159 | if err != nil { 160 | t.Errorf("Could not create temp dir: %v", err) 161 | } 162 | 163 | return tempDir 164 | } 165 | 166 | func setupTempFile(name, dir string, t *testing.T) *os.File { 167 | file, err := os.CreateTemp(dir, name) 168 | if err != nil { 169 | t.Fatalf("Unable to create temp file: %v", err) 170 | } 171 | 172 | return file 173 | } 174 | -------------------------------------------------------------------------------- /watcher/watcher.go: -------------------------------------------------------------------------------- 1 | //go:build !linux 2 | // +build !linux 3 | 4 | package watcher 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | "regexp" 12 | "time" 13 | 14 | "github.com/fsnotify/fsnotify" 15 | 16 | "github.com/cian911/switchboard/event" 17 | "github.com/cian911/switchboard/utils" 18 | ) 19 | 20 | // Producer interface for the watcher 21 | // Must implement Register(), Unregister(), and Observe(), and notify() 22 | type Producer interface { 23 | // Register a consumer to the producer 24 | Register(consumer *Consumer) 25 | // Unregister a consumer from the producer 26 | Unregister(consumer *Consumer) 27 | // Notify consumers of an event 28 | Notify(path, event string) 29 | // Observe the producer 30 | Observe(pollInterval int) 31 | } 32 | 33 | // Consumer interface 34 | // Must implement Receive(), and Process() methods 35 | type Consumer interface { 36 | // Receive an event from the producer 37 | Receive(path, event string) 38 | // Process an event 39 | Process(e *event.Event) 40 | // Process a dir event 41 | ProcessDirEvent(e *event.Event) 42 | } 43 | 44 | // PathWatcher is a producer that watches a path for events 45 | type PathWatcher struct { 46 | // List of consumers 47 | Consumers []*Consumer 48 | // Queue 49 | Queue *Q 50 | // Watcher instance 51 | Watcher fsnotify.Watcher 52 | // Path to watch 53 | Path string 54 | } 55 | 56 | // PathConsumer is a consumer that consumes events from a path 57 | // and moves them to a destination 58 | type PathConsumer struct { 59 | // Path to watch 60 | Path string 61 | // Destination to move files to 62 | Destination string 63 | // File extenstion 64 | Ext string 65 | // Regex Pattern 66 | Pattern regexp.Regexp 67 | } 68 | 69 | // Receive takes a path and an event operation, determines its validity 70 | // and passes it to be processed it if valid 71 | func (pc *PathConsumer) Receive(path, ev string) { 72 | e := &event.Event{ 73 | File: filepath.Base(path), 74 | Path: path, 75 | Destination: pc.Destination, 76 | Ext: utils.ExtractFileExt(path), 77 | Timestamp: time.Now(), 78 | Operation: ev, 79 | } 80 | 81 | if !e.IsNewDirEvent() && e.Ext != pc.Ext && filepath.Dir(path) != pc.Path { 82 | log.Printf("Not processing event - %v - %v", e, pc) 83 | // Do not process event for consumers not watching file 84 | return 85 | } 86 | 87 | if e.IsNewDirEvent() { 88 | pc.ProcessDirEvent(e) 89 | } else if &pc.Pattern != nil && len(pc.Pattern.String()) != 0 { 90 | match := validateRegexEventMatch(pc, e) 91 | 92 | if match { 93 | pc.Process(e) 94 | } 95 | } else if e.IsValidEvent(pc.Ext) { 96 | pc.Process(e) 97 | } 98 | } 99 | 100 | // Process takes an event and moves it to the destination 101 | func (pc *PathConsumer) Process(e *event.Event) { 102 | err := e.Move(e.Path, "") 103 | if err != nil { 104 | log.Printf("Unable to move file from { %s } to { %s }: %v\n", e.Path, e.Destination, err) 105 | } else { 106 | log.Println("Event has been processed.") 107 | } 108 | } 109 | 110 | // ProcessDirEvent takes an event and scans files ext 111 | func (pc *PathConsumer) ProcessDirEvent(e *event.Event) { 112 | files, err := utils.ScanFilesInDir(e.Path) 113 | if err != nil { 114 | log.Fatalf("Unable to scan files in dir event: error: %v, path: %s", err, e.Path) 115 | } 116 | 117 | for file := range files { 118 | if utils.ExtractFileExt(file) == pc.Ext { 119 | ev := event.New(file, e.Path, e.Destination, pc.Ext) 120 | err = ev.Move(ev.Path, "/"+file) 121 | if err != nil { 122 | log.Printf("Unable to move file: %s from path: %s to dest: %s: %v", file, ev.Path, ev.Destination, err) 123 | } 124 | } 125 | } 126 | } 127 | 128 | // AddPath adds a path to the watcher 129 | func (pw *PathWatcher) AddPath(path string) { 130 | pw.Watcher.Add(path) 131 | } 132 | 133 | // Register a consumer to the producer 134 | func (pw *PathWatcher) Register(consumer *Consumer) { 135 | pw.Consumers = append(pw.Consumers, consumer) 136 | } 137 | 138 | // Unregister a consumer from the producer 139 | func (pw *PathWatcher) Unregister(consumer *Consumer) { 140 | for i, cons := range pw.Consumers { 141 | if cons == consumer { 142 | pw.Consumers[i] = pw.Consumers[len(pw.Consumers)-1] 143 | pw.Consumers = pw.Consumers[:len(pw.Consumers)-1] 144 | } 145 | } 146 | } 147 | 148 | // Observe the producer 149 | func (pw *PathWatcher) Observe(pollInterval int) { 150 | pw.Queue = NewQueue() 151 | pw.Poll(pollInterval) 152 | 153 | watcher, err := fsnotify.NewWatcher() 154 | if err != nil { 155 | log.Fatalf("Could not create new watcher: %v", err) 156 | } 157 | 158 | defer watcher.Close() 159 | 160 | // fsnotify doesnt support recursive folders, so we can here 161 | if err := filepath.Walk(pw.Path, func(path string, info os.FileInfo, err error) error { 162 | if err != nil { 163 | log.Fatalf("Error walking path structure. Please ensure to use absolute path: %v", err) 164 | } 165 | 166 | if info.Mode().IsDir() { 167 | watcher.Add(path) 168 | } 169 | 170 | return nil 171 | }); err != nil { 172 | log.Fatalf("Could not parse recursive path: %v", err) 173 | } 174 | 175 | done := make(chan bool) 176 | 177 | go func() { 178 | for { 179 | select { 180 | case event := <-watcher.Events: 181 | if event.Op.String() == "CREATE" && utils.IsDir(event.Name) { 182 | watcher.Add(event.Name) 183 | } else if event.Op.String() == "CREATE" || event.Op.String() == "WRITE" { 184 | ev := newEvent(event.Name, event.Op.String()) 185 | pw.Queue.Add(*ev) 186 | } 187 | case err := <-watcher.Errors: 188 | log.Printf("Watcher encountered an error when observing %s: %v", pw.Path, err) 189 | } 190 | } 191 | }() 192 | 193 | <-done 194 | } 195 | 196 | // Notify consumers of an event 197 | func (pw *PathWatcher) Notify(path, event string) { 198 | for _, cons := range pw.Consumers { 199 | (*cons).Receive(path, event) 200 | } 201 | } 202 | 203 | func newEvent(path, ev string) *event.Event { 204 | return &event.Event{ 205 | File: filepath.Base(path), 206 | Path: path, 207 | Ext: utils.ExtractFileExt(path), 208 | Timestamp: time.Now(), 209 | Operation: ev, 210 | } 211 | } 212 | 213 | func validateRegexEventMatch(pc *PathConsumer, event *event.Event) bool { 214 | p := fmt.Sprintf(`%s/%s`, event.Path, event.File) 215 | match := pc.Pattern.Match([]byte(p)) 216 | 217 | if match { 218 | log.Println("Regex Pattern matched") 219 | return true 220 | } 221 | 222 | log.Println("Regex did not match") 223 | return false 224 | } 225 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cian911/fsnotify v1.7.5 h1:yjZhpM5Q9nAVt5wz+pYfPuVW+QbQytYkmUIbIPucDRE= 2 | github.com/cian911/fsnotify v1.7.5/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 7 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 9 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 10 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 11 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 12 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 13 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 14 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 15 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 16 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 17 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 18 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 19 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 20 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 21 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 22 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 23 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 24 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= 25 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 26 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 27 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 28 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 29 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 30 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 31 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 32 | github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= 33 | github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= 34 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 35 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 36 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 37 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 38 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 39 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 40 | github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= 41 | github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 42 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 43 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 44 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 45 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 46 | github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= 47 | github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 48 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 49 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 50 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 51 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 52 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 53 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 54 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 55 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 56 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 57 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 58 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 59 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 60 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 61 | golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= 62 | golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= 63 | golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= 64 | golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 65 | golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= 66 | golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 67 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 68 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 69 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 70 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 71 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 72 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 73 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 74 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 75 | -------------------------------------------------------------------------------- /cli/watch.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "regexp" 7 | "runtime" 8 | 9 | "github.com/cian911/switchboard/utils" 10 | "github.com/cian911/switchboard/watcher" 11 | "github.com/fsnotify/fsnotify" 12 | "github.com/spf13/cobra" 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | const ( 17 | shortDesc = "Run switchboard application." 18 | longDesc = "Run the switchboard application passing in the path, destination, and file type you'd like to watch for." 19 | ) 20 | 21 | var ( 22 | configFile string 23 | ws Watchers 24 | regexPattern *regexp.Regexp 25 | regexErr error 26 | ) 27 | 28 | // Watchers is a struct that contains a list of watchers. 29 | // in yaml format 30 | type Watchers struct { 31 | // Watchers is a list of watchers 32 | Watchers []Watcher `yaml:"watchers,mapstructure"` 33 | PollingInterval int `yaml:pollingInterval` 34 | } 35 | 36 | // Watcher is a struct that contains a path, destination, and file extention and event operation. 37 | // in yaml format 38 | type Watcher struct { 39 | // Path is the path you want to watch 40 | Path string `yaml:"path"` 41 | // Destination is the path you want files to be relocated 42 | Destination string `yaml:"destination"` 43 | // Ext is the file extention you want to watch for 44 | Ext string `yaml:"ext"` 45 | // Operation is the event operation you want to watch for 46 | // CREATE, MODIFY, REMOVE, CHMOD, WRITE etc. 47 | Operation string `yaml:"operation"` 48 | // Pattern is the regex pattern to match against events 49 | Pattern string `yaml:"pattern"` 50 | } 51 | 52 | // Watch is the main function that runs the watcher. 53 | func Watch() { 54 | var runCmd = &cobra.Command{ 55 | Use: "watch", 56 | Short: shortDesc, 57 | Long: longDesc, 58 | Run: func(cmd *cobra.Command, args []string) { 59 | if viper.ConfigFileUsed() != "" && ws.Watchers != nil { 60 | registerMultiConsumers() 61 | } else { 62 | validateFlags() 63 | registerSingleConsumer() 64 | } 65 | }, 66 | } 67 | 68 | initCmd(*runCmd) 69 | } 70 | 71 | func initCmd(runCmd cobra.Command) { 72 | cobra.OnInitialize(initConfig) 73 | 74 | runCmd.PersistentFlags().StringP("path", "p", "", "Path you want to watch.") 75 | runCmd.PersistentFlags().StringP("destination", "d", "", "Path you want files to be relocated.") 76 | runCmd.PersistentFlags().StringP("ext", "e", "", "File type you want to watch for.") 77 | 78 | if runtime.GOOS == "linux" { 79 | // Set the default poll interval to lower value on linux envs 80 | runCmd.PersistentFlags().IntP("poll", "", 1, "Specify a polling time in seconds.") 81 | } else { 82 | runCmd.PersistentFlags().IntP("poll", "", 60, "Specify a polling time in seconds.") 83 | } 84 | runCmd.PersistentFlags().StringVar(&configFile, "config", "", "Pass an optional config file containing multiple paths to watch.") 85 | runCmd.PersistentFlags().StringP("regex-pattern", "r", "", "Pass a regex pattern to watch for any files mathcing this pattern.") 86 | 87 | viper.BindPFlag("path", runCmd.PersistentFlags().Lookup("path")) 88 | viper.BindPFlag("destination", runCmd.PersistentFlags().Lookup("destination")) 89 | viper.BindPFlag("ext", runCmd.PersistentFlags().Lookup("ext")) 90 | viper.BindPFlag("poll", runCmd.PersistentFlags().Lookup("poll")) 91 | viper.BindPFlag("regex-pattern", runCmd.PersistentFlags().Lookup("regex-pattern")) 92 | 93 | var rootCmd = &cobra.Command{} 94 | rootCmd.AddCommand(&runCmd) 95 | rootCmd.Execute() 96 | } 97 | 98 | func initConfig() { 99 | if configFile != "" { 100 | viper.SetConfigFile(configFile) 101 | 102 | if err := viper.ReadInConfig(); err == nil { 103 | fmt.Println("Using config file:", viper.ConfigFileUsed()) 104 | 105 | err := viper.Unmarshal(&ws) 106 | 107 | if err != nil { 108 | log.Fatalf("Unable to decode config file. Please check that it is in correct format: %v", err) 109 | } 110 | 111 | if ws.Watchers == nil || len(ws.Watchers) == 0 { 112 | log.Fatalf("Unable to decode config file. Please check that it is in the correct format.") 113 | } 114 | } 115 | } 116 | 117 | } 118 | 119 | func validateFlags() { 120 | if !utils.ValidatePath(viper.GetString("path")) { 121 | log.Fatalf("Path cannot be found. Does the path exist?: %s", viper.GetString("path")) 122 | } 123 | 124 | if !utils.ValidatePath(viper.GetString("destination")) { 125 | log.Fatalf("Destination cannot be found. Does the path exist?: %s", viper.GetString("destination")) 126 | } 127 | 128 | if !utils.ValidateFileExt(viper.GetString("ext")) && len(viper.GetString("regex-pattern")) == 0 { 129 | log.Fatalf("Ext is not valid. A file extention should contain a '.': %s", viper.GetString("ext")) 130 | } 131 | 132 | if len(viper.GetString("regex-pattern")) > 0 { 133 | // Validate regex pattern 134 | regexPattern, regexErr = utils.ValidateRegexPattern(viper.GetString("regex-pattern")) 135 | 136 | if regexErr != nil { 137 | log.Fatalf("Regex pattern is not valid. Please check it again: %v", regexErr) 138 | } 139 | } else { 140 | regexPattern, _ = utils.ValidateRegexPattern("") 141 | } 142 | } 143 | 144 | func registerMultiConsumers() { 145 | watch, _ := fsnotify.NewWatcher() 146 | var pw watcher.Producer = &watcher.PathWatcher{ 147 | Watcher: *watch, 148 | } 149 | 150 | for i, v := range ws.Watchers { 151 | if i == 0 { 152 | // Register the path and create the watcher 153 | pw.(*watcher.PathWatcher).Path = v.Path 154 | } else { 155 | // Add paths to this watcher, so as we don't spawn multiple 156 | // watcher instances. 157 | pw.(*watcher.PathWatcher).AddPath(v.Path) 158 | } 159 | 160 | regexPattern, regexErr = utils.ValidateRegexPattern(v.Pattern) 161 | 162 | if regexErr != nil { 163 | log.Fatalf("Regex pattern is not valid. Please check it again: %v", regexErr) 164 | } 165 | 166 | var pc watcher.Consumer = &watcher.PathConsumer{ 167 | Path: v.Path, 168 | Destination: v.Destination, 169 | Ext: v.Ext, 170 | Pattern: *regexPattern, 171 | } 172 | 173 | pw.Register(&pc) 174 | } 175 | 176 | pi := viper.GetInt("poll") 177 | 178 | if &ws.PollingInterval != nil && ws.PollingInterval != 0 { 179 | pi = ws.PollingInterval 180 | } 181 | 182 | log.Println("Observing") 183 | pw.Observe(pi) 184 | } 185 | 186 | func registerSingleConsumer() { 187 | var pw watcher.Producer = &watcher.PathWatcher{ 188 | Path: viper.GetString("path"), 189 | } 190 | 191 | var pc watcher.Consumer = &watcher.PathConsumer{ 192 | Path: viper.GetString("path"), 193 | Destination: viper.GetString("destination"), 194 | Ext: viper.GetString("ext"), 195 | Pattern: *regexPattern, 196 | } 197 | 198 | pw.Register(&pc) 199 | 200 | log.Println("Observing") 201 | pw.Observe(viper.GetInt("poll")) 202 | } 203 | -------------------------------------------------------------------------------- /watcher/watcher_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | // +build linux 3 | 4 | package watcher 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | "regexp" 12 | "time" 13 | 14 | "github.com/fsnotify/fsnotify" 15 | 16 | "github.com/cian911/switchboard/event" 17 | "github.com/cian911/switchboard/utils" 18 | ) 19 | 20 | // Monitor for IN_CLOSE_WRITE events on these file exts 21 | // A create event should immediatly follow 22 | var specialWatchedFileExts = map[string]bool{ 23 | ".part": true, 24 | } 25 | 26 | // Producer interface for the watcher 27 | // Must implement Register(), Unregister(), and Observe(), and notify() 28 | type Producer interface { 29 | // Register a consumer to the producer 30 | Register(consumer *Consumer) 31 | // Unregister a consumer from the producer 32 | Unregister(consumer *Consumer) 33 | // Notify consumers of an event 34 | Notify(path, event string) 35 | // Observe the producer 36 | Observe(pollInterval int) 37 | } 38 | 39 | // Consumer interface 40 | // Must implement Receive(), and Process() methods 41 | type Consumer interface { 42 | // Receive an event from the producer 43 | Receive(path, event string) 44 | // Process an event 45 | Process(e *event.Event) 46 | // Process a dir event 47 | ProcessDirEvent(e *event.Event) 48 | } 49 | 50 | // PathWatcher is a producer that watches a path for events 51 | type PathWatcher struct { 52 | // List of consumers 53 | Consumers []*Consumer 54 | // Queue 55 | Queue *Q 56 | // Watcher instance 57 | Watcher fsnotify.Watcher 58 | // Path to watch 59 | Path string 60 | } 61 | 62 | // PathConsumer is a consumer that consumes events from a path 63 | // and moves them to a destination 64 | type PathConsumer struct { 65 | // Path to watch 66 | Path string 67 | // Destination to move files to 68 | Destination string 69 | // File extenstion 70 | Ext string 71 | // Regex Pattern 72 | Pattern regexp.Regexp 73 | } 74 | 75 | // Receive takes a path and an event operation, determines its validity 76 | // and passes it to be processed it if valid 77 | func (pc *PathConsumer) Receive(path, ev string) { 78 | e := &event.Event{ 79 | File: filepath.Base(path), 80 | Path: path, 81 | Destination: pc.Destination, 82 | Ext: utils.ExtractFileExt(path), 83 | Timestamp: time.Now(), 84 | Operation: ev, 85 | } 86 | 87 | if !e.IsNewDirEvent() && e.Ext != pc.Ext && filepath.Dir(path) != pc.Path { 88 | log.Printf("Not processing event - %v - %v", e, pc) 89 | // Do not process event for consumers not watching file 90 | return 91 | } 92 | 93 | if e.IsNewDirEvent() { 94 | pc.ProcessDirEvent(e) 95 | } else if &pc.Pattern != nil && len(pc.Pattern.String()) != 0 { 96 | match := validateRegexEventMatch(pc, e) 97 | 98 | if match { 99 | pc.Process(e) 100 | } 101 | } else if e.IsValidEvent(pc.Ext) { 102 | pc.Process(e) 103 | } 104 | } 105 | 106 | // Process takes an event and moves it to the destination 107 | func (pc *PathConsumer) Process(e *event.Event) { 108 | err := e.Move(e.Path, "") 109 | if err != nil { 110 | log.Printf("Unable to move file from { %s } to { %s }: %v\n", e.Path, e.Destination, err) 111 | } else { 112 | log.Println("Event has been processed.") 113 | } 114 | } 115 | 116 | // ProcessDirEvent takes an event and scans files ext 117 | func (pc *PathConsumer) ProcessDirEvent(e *event.Event) { 118 | files, err := utils.ScanFilesInDir(e.Path) 119 | if err != nil { 120 | log.Fatalf("Unable to scan files in dir event: error: %v, path: %s", err, e.Path) 121 | } 122 | 123 | for file := range files { 124 | if utils.ExtractFileExt(file) == pc.Ext { 125 | ev := event.New(file, e.Path, e.Destination, pc.Ext) 126 | err = ev.Move(ev.Path, "/"+file) 127 | if err != nil { 128 | log.Printf("Unable to move file: %s from path: %s to dest: %s: %v", file, ev.Path, ev.Destination, err) 129 | } 130 | } 131 | } 132 | } 133 | 134 | // AddPath adds a path to the watcher 135 | func (pw *PathWatcher) AddPath(path string) { 136 | pw.Watcher.Add(path) 137 | } 138 | 139 | // Register a consumer to the producer 140 | func (pw *PathWatcher) Register(consumer *Consumer) { 141 | pw.Consumers = append(pw.Consumers, consumer) 142 | } 143 | 144 | // Unregister a consumer from the producer 145 | func (pw *PathWatcher) Unregister(consumer *Consumer) { 146 | for i, cons := range pw.Consumers { 147 | if cons == consumer { 148 | pw.Consumers[i] = pw.Consumers[len(pw.Consumers)-1] 149 | pw.Consumers = pw.Consumers[:len(pw.Consumers)-1] 150 | } 151 | } 152 | } 153 | 154 | // Observe the producer 155 | func (pw *PathWatcher) Observe(pollInterval int) { 156 | pw.Queue = NewQueue() 157 | pw.Poll(pollInterval) 158 | 159 | watcher, err := fsnotify.NewWatcher() 160 | if err != nil { 161 | log.Fatalf("Could not create new watcher: %v", err) 162 | } 163 | 164 | defer watcher.Close() 165 | 166 | // fsnotify doesnt support recursive folders, so we can here 167 | if err := filepath.Walk(pw.Path, func(path string, info os.FileInfo, err error) error { 168 | if err != nil { 169 | log.Fatalf("Error walking path structure. Please ensure to use absolute path: %v", err) 170 | } 171 | 172 | if info.Mode().IsDir() { 173 | watcher.Add(path) 174 | } 175 | 176 | return nil 177 | }); err != nil { 178 | log.Fatalf("Could not parse recursive path: %v", err) 179 | } 180 | 181 | done := make(chan bool) 182 | 183 | go func() { 184 | for { 185 | select { 186 | case event := <-watcher.Events: 187 | if event.Op.String() == "CREATE" && utils.IsDir(event.Name) { 188 | watcher.Add(event.Name) 189 | } else if event.Op.String() == "CLOSE_WRITE" { 190 | ev := newEvent(event.Name, event.Op.String()) 191 | 192 | if specialWatchedFileExts[ev.Ext] { 193 | // If the file is in the special file list 194 | // add it to the queue and wait for it to be finished 195 | log.Println("Adding CLOSE_WRITE event to queue.") 196 | pw.Queue.Add(*ev) 197 | } else { 198 | // Otherwise process the event immediatly 199 | log.Printf("Notifying consumers: %v\n", ev) 200 | pw.Notify(ev.Path, ev.Operation) 201 | } 202 | } else if event.Op.String() == "CREATE" { 203 | createEvent := newEvent(event.Name, event.Op.String()) 204 | // Add the event to the queue and let the poller handle it 205 | pw.Queue.Add(*createEvent) 206 | } 207 | case err := <-watcher.Errors: 208 | log.Printf("Watcher encountered an error when observing %s: %v", pw.Path, err) 209 | } 210 | } 211 | }() 212 | 213 | <-done 214 | } 215 | 216 | func validateRegexEventMatch(pc *PathConsumer, event *event.Event) bool { 217 | p := fmt.Sprintf(`%s/%s`, event.Path, event.File) 218 | match := pc.Pattern.Match([]byte(p)) 219 | 220 | if match { 221 | log.Println("Regex Pattern matched") 222 | return true 223 | } 224 | 225 | log.Println("Regex did not match") 226 | return false 227 | } 228 | 229 | // Notify consumers of an event 230 | func (pw *PathWatcher) Notify(path, event string) { 231 | for _, cons := range pw.Consumers { 232 | (*cons).Receive(path, event) 233 | } 234 | } 235 | 236 | func newEvent(path, ev string) *event.Event { 237 | return &event.Event{ 238 | File: filepath.Base(path), 239 | Path: path, 240 | Ext: utils.ExtractFileExt(path), 241 | Timestamp: time.Now(), 242 | Operation: ev, 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Switchboard 2 | ![GitHub Actions Status](https://github.com/Cian911/switchboard/workflows/Release/badge.svg) ![GitHub Actions Status](https://github.com/Cian911/switchboard/workflows/Test%20Suite/badge.svg) [![Go Report Card](https://goreportcard.com/badge/github.com/cian911/switchboard)](https://goreportcard.com/report/github.com/cian911/switchboard) ![Homebrew Downloads](https://img.shields.io/badge/dynamic/json?color=success&label=Downloads&query=count&url=https://github.com/Cian911/switchboard/blob/master/count.json?raw=True&logo=homebrew) ![Downloads](https://img.shields.io/github/downloads/Cian911/switchboard/total.svg) [![GitHub go.mod Go version of a Go module](https://img.shields.io/github/go-mod/go-version/Cian911/switchboard.svg)](https://github.com/Cian911/switchboard) [![GoDoc reference example](https://img.shields.io/badge/godoc-reference-blue.svg)](https://pkg.go.dev/github.com/cian911/switchboard) [![GitHub stars](https://badgen.net/github/stars/Cian911/switchboard)](https://GitHub.com/Cian911/switchboard/starazers/) [![GitHub forks](https://badgen.net/github/forks/Cian911/switchboard/)](https://GitHub.com/Cian911/switchboard/network/) 3 | 4 |

5 | Gomerge logo 6 |

7 | 8 | ### Description 9 | Do you ever get annoyed that your Downloads folder gets cluttered with all types of files? Do you wish you could automatically organise them into seperate, organised folders? Switchboard is a tool to help simplfy file organization on your machine/s. 10 | 11 | Switchboard works by monitoring a directory you provide (or list of directories), and uses file system notifications to move a matched file to the appropriate destination directory of your choosing. 12 | 13 | See the video below as example. Here, I give switchboard a path to watch, a destination where I want matched files to move to, and the file extension of the type of files I want to move. 14 | 15 | ### Pro 16 | 17 | As of version `v1.0.0` we have released a pro version which has a ton more features and functionality. Head over to [https://goswitchboard.io/pro](https://goswitchboard.io/pro) for more info. 18 | 19 | **Switchboard Pro** gives you extra features and support over the free open-source version. 20 | 21 | Purchasing a **pro** or **enterprise** license for **Switchboard Pro** helps us to continue working on both the pro and free version of the software, and bring more features to **_YOU_**! 22 | 23 | - [x] Support for **prioritising specific file events** over others. 24 | - [x] **Regex support** so you can watch for any file name or type you choose. 25 | - [x] Support for archival file extractions, **.zip/.rar et al**. 26 | - [x] Support for **optional file removal**. 27 | - [x] Product support should you run into any issues. 28 | - [x] Access to product roadmap. 29 | - [x] Priority feature requests. 30 | 31 | --- 32 | 33 | [![asciicast](https://asciinema.org/a/OwbnYltbn0jcSAGzfdmujwklJ.svg)](https://asciinema.org/a/OwbnYltbn0jcSAGzfdmujwklJ) 34 | 35 | You can also visit https://goswitchboard.io/ for all your documentation needs, news, and updates! 36 | 37 | 38 | ### Installation 39 | 40 | You can install switchboard pre-compiled binary in a number of ways. 41 | 42 | ##### Homebrew 43 | 44 | ```sh 45 | brew tap Cian911/switchboard 46 | brew install switchboard 47 | 48 | // Check everything is working as it should be 49 | switchboard -h 50 | ``` 51 | 52 | You can also upgrade the version of `switchboard` you already have installed by doing the following. 53 | 54 | ```sh 55 | brew upgrade switchboard 56 | ``` 57 | 58 | ##### Docker 59 | 60 | ```sh 61 | docker pull ghcr.io/cian911/switchboard:${VERSION} 62 | 63 | docker run -d -v ${SRC} -v ${DEST} ghcr.io/cian911/switchboard:${VERSION} watch -h 64 | ``` 65 | 66 | ##### Go Install 67 | 68 | ```sh 69 | go install github.com/Cian911/switchboard@${VERSION} 70 | ``` 71 | 72 | ##### Manually 73 | 74 | You can download the pre-compiled binary for your specific OS type from the [OSS releases page](https://github.com/Cian911/switchboard/releases). You will need to copy these and extract the binary, then move it to you local bin directory. See the example below for extracting a zipped version. 75 | 76 | ```sh 77 | curl https://github.com/Cian911/switchboard/releases/download/${VERSION}/${PACKAGE_NAME} -o ${PACKAGE_NAME} 78 | sudo tar -xvf ${PACKAGE_NAME} -C /usr/local/bin/ 79 | sudo chmod +x /usr/local/bin/switchboard 80 | ``` 81 | 82 | ### Quick Start 83 | 84 | Using switchboard is pretty easy. Below lists the set of commands and flags you can pass in. 85 | 86 | ```sh 87 | Run the switchboard application passing in the path, destination, and file type you'd like to watch for. 88 | 89 | Usage: 90 | watch [flags] 91 | 92 | Flags: 93 | --config string Pass an optional config file containing multiple paths to watch. 94 | -d, --destination string Path you want files to be relocated. 95 | -e, --ext string File type you want to watch for. 96 | -h, --help help for watch 97 | -p, --path string Path you want to watch. 98 | --poll int Specify a polling time in seconds. (default 60) 99 | -r, --regex-pattern string Pass a regex pattern to watch for any files matching this pattern. 100 | ``` 101 | 102 | To get started quickly, you can run the following command, passing in the path, destination, and file extenstion you want to watch for. See the example below. 103 | 104 | ```sh 105 | switchboard watch -p /home/user/Downloads -d /home/user/Movies -e .mp4 106 | ``` 107 | 108 | > We highly recommend using absolute file paths over relative file paths. Always include the `.` when passing the file extension to switchboard. 109 | 110 | And that's it! Once ran, switchboard will start observing the user downloads folder for mp4 files added. Once it receives a new create event with the correct file extension, it will move the file to the users movies folder. 111 | 112 | ### Important Notes 113 | 114 | ##### Polling 115 | 116 | We set a high polling time on switchboard as in some operating systems we don't get file closed notifications. Therefore switchboard implements a polling solution to check for when a file was last written to. If the file falls outside the time since last polled, the file is assumed to be closed and will be moved to the destination directory. This obviously is not ideal, as we can't guarentee that a file is _actually_ closed. Therefore the option is there to set the polling interval yourself. In some cases, a higher polling time might be necessary. 117 | 118 | ##### Polling & Linux 119 | As of release `v1.0.0` we now support `IN_CLOSE_WRITE` events in _linux_ systems. For context, this event tells us when a process has finished writing to a file (something we don't get on OSX & Windows). This means we do not need to use polling for linux systems (though we do for _some_ circumstances) however the functionaity still exists should you wish to use it. 120 | 121 | ##### Absolute File Path 122 | 123 | As you might have noticed in the example above, we passed in the absolute file path. While relative file paths will work too, they have not been tested in all OS systems. Therefore we strongly recommend you use absolute file paths when running switchboard. 124 | 125 | ##### File Extenstion 126 | 127 | You may have also noticed in the above example, we used `.mp4` including the prefixed `.`. This is important, as switchboard will not match file extenstions correctly if the given `--ext` flag does not contain the `.`. 128 | --------------------------------------------------------------------------------