├── 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 |   [](https://goreportcard.com/report/github.com/cian911/switchboard)   [](https://github.com/Cian911/switchboard) [](https://pkg.go.dev/github.com/cian911/switchboard) [](https://GitHub.com/Cian911/switchboard/starazers/) [](https://GitHub.com/Cian911/switchboard/network/)
3 |
4 |
5 |
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 | [](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 |
--------------------------------------------------------------------------------