9 Commits

Author SHA1 Message Date
ff22423496 Refactoring 2025-01-07 04:15:50 +03:00
56e826e580 Refactoring, fix bug 2025-01-07 03:53:28 +03:00
e1670934a1 Refactoring 2025-01-07 03:45:27 +03:00
62daa023f5 Permanent fallback to copy on hardlink error 2025-01-07 03:42:12 +03:00
df4f2ebd99 Refactoring 2025-01-06 23:47:45 +03:00
bcaeba5ea6 Update README.md 2025-01-06 19:54:31 +03:00
9248a9a84d Update README.md, refactoring 2025-01-06 18:15:22 +03:00
f0a8abb380 Update README.md 2025-01-06 15:18:06 +03:00
fb1ab2f8b5 Update README.md 2025-01-04 22:50:22 +03:00
7 changed files with 122 additions and 44 deletions

View File

@@ -6,9 +6,10 @@ A simple tool to organize your photos, videos, or other files by copying or hard
## TL;DR ## TL;DR
I use a smartphone and Syncthing to automatically sync my photos to my PC. However, if I clean up my phone's memory, the synced photos on my PC are deleted as well. To avoid this, I needed a solution to back up and organize my photos without manual effort. I use a smartphone and Syncthing to automatically sync my photos to my PC. However, if I clean up my phone's memory, the synced photos on my PC are deleted as well.
Dumping everything into one folder wasn't an option — finding anything later would be a nightmare.
Dumping everything into one folder wasn't an option — finding anything later would be a nightmare. So, I built this tool in one evening to solve the problem. It has worked flawlessly for me and might help you too. If you encounter any issues, feel free to open a ticket — I'll do my best to assist. To avoid this, I needed a solution to back up and organize my photos without manual effort. So, I built this tool in one evening to solve the problem. It has worked flawlessly for me and might help you too. If you encounter any issues, feel free to open a ticket — I'll do my best to assist.
## Installation ## Installation
@@ -28,8 +29,9 @@ sudo cp ${GOPATH}/bin/photocatalog /usr/local/bin/photocatalog
The tool supports the following organization modes: The tool supports the following organization modes:
- **copy** — Copies files to the target directory. If the filesystem supports it, uses Copy-on-Write (COW) for efficiency. - **copy** — Copies files to the target directory. If the filesystem supports it, uses Copy-on-Write (COW) for efficiency (via FICLONE ioctl call).
- **hardlink** — Creates hardlinks to the source files, saving disk space. Ideal if the source and target are on the same partition, though file permissions remain linked to the original. - **hardlink** — Creates hardlinks to the source files, saving disk space. Ideal (and usable only) if the source and target are on the same partition,
though file permissions remain linked to the original. Fallback to copy on fail.
- **move** — Moves files from the source to the target directory. - **move** — Moves files from the source to the target directory.
- **symlink** — Creates symbolic links at the target pointing to the source files. - **symlink** — Creates symbolic links at the target pointing to the source files.
@@ -44,6 +46,34 @@ Currently, the timestamp format is not customizable. Let me know if support for
## Usage ## Usage
Arguments
```
-dir-mode string
Mode bits for directories can be created while syncing (default "0777")
-file-mode string
Mode bits for files created while syncing (not applicable for hardlink mode) (default "0644")
-mode string
Organazing mode (default "hardlink")
-overwrite
Overwrite existing files
-skip-full-sync
Skip full sync at startup
-source string
Source directory
-target string
Target directory
-watch
Watch for changes in the source directory (default true)
```
`-skip-full-sync` and `-watch` are not compatible.
`-source` and `-target` are required.
## Examples
### One-Time Run ### One-Time Run
#### Copy Files #### Copy Files

View File

@@ -43,24 +43,24 @@ func (a *Application) Start(ctx context.Context, wg *sync.WaitGroup) error {
WithDirMode(os.FileMode(a.config.DirMode)). WithDirMode(os.FileMode(a.config.DirMode)).
WithFileMode(os.FileMode(a.config.FileMode)). WithFileMode(os.FileMode(a.config.FileMode)).
WithErrLogger(func(err error) { WithErrLogger(func(err error) {
log.Println(err) log.Println("ERROR:", err.Error())
}) })
if a.config.Overwrite { if a.config.Overwrite {
org = org.WithOverwrite() org = org.WithOverwrite()
} }
if !a.config.SkipFullSync {
if err := org.FullSync(ctx); err != nil {
return fmt.Errorf("full sync: %w", err)
}
}
if a.config.Watch { if a.config.Watch {
if err := org.Watch(ctx, wg); err != nil { if err := org.Watch(ctx, wg); err != nil {
return fmt.Errorf("initialize watch: %w", err) return fmt.Errorf("initialize watch: %w", err)
} }
} }
if !a.config.SkipFullSync {
if err := org.FullSync(ctx); err != nil {
return fmt.Errorf("full sync: %w", err)
}
}
return nil return nil
} }

View File

@@ -14,6 +14,8 @@ const (
ModeMove Mode = "move" ModeMove Mode = "move"
) )
var SupportedModes = []Mode{ModeHardlink, ModeSymlink, ModeMove, ModeCopy}
type Config struct { type Config struct {
SourceDir string SourceDir string
TargetDir string TargetDir string
@@ -34,8 +36,8 @@ func (c *Config) Validate() error {
return fmt.Errorf("target dir is required") return fmt.Errorf("target dir is required")
} }
if !slices.Contains([]Mode{ModeHardlink, ModeSymlink, ModeMove, ModeCopy}, c.Mode) { if !slices.Contains(SupportedModes, c.Mode) {
return fmt.Errorf("invalid mode %s", c.Mode) return fmt.Errorf("invalid mode %s, supported modes: %s", c.Mode, SupportedModes)
} }
if c.SkipFullSync && !c.Watch { if c.SkipFullSync && !c.Watch {

View File

@@ -7,5 +7,5 @@ WantedBy=default.target
[Service] [Service]
Type=simple Type=simple
EnvironmentFile=/home/%u/.config/photocatalog EnvironmentFile=/home/%u/.config/photocatalog
ExecStart=photocatalog -mode $MODE -target $TARGET -monitor $MONITOR -update_mtime $UPDATECTIME ExecStart=photocatalog -mode $MODE -target $TARGET -watch -source $MONITOR -skip-full-sync
ExecStartPre=photocatalog -mode $MODE -target $TARGET ${MONITOR} ExecStartPre=photocatalog -mode $MODE -target $TARGET -source ${MONITOR}

View File

@@ -1,21 +1,38 @@
package modes package modes
import ( import (
"fmt" "log"
"os" "os"
"sync/atomic"
) )
var hardLinkNotSupported = atomic.Bool{}
type HardLink struct { type HardLink struct {
} }
func (h HardLink) PlaceIt(sourcePath, targetPath string, mode os.FileMode) error { func (h HardLink) PlaceIt(sourcePath, targetPath string, mode os.FileMode) error {
if hardLinkNotSupported.Load() {
return h.fallBack(sourcePath, targetPath, mode)
}
if err := os.Link(sourcePath, targetPath); err != nil { if err := os.Link(sourcePath, targetPath); err != nil {
if os.IsExist(err) { if os.IsExist(err) {
return nil return nil
} }
return fmt.Errorf("create hard link: %w", err) log.Println("Create hardlink failed:", err.Error())
hardLinkNotSupported.Store(true)
return h.fallBack(sourcePath, targetPath, mode)
} }
return nil return nil
} }
func (h HardLink) fallBack(sourcePath string, targetPath string, mode os.FileMode) error {
if copyErr := (Copy{}).PlaceIt(sourcePath, targetPath, mode); copyErr != nil {
return copyErr
}
return nil
}

View File

@@ -11,6 +11,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
@@ -130,6 +131,8 @@ func (o *Organizer) Watch(ctx context.Context, wg *sync.WaitGroup) error {
if err := watcher.Close(); err != nil { if err := watcher.Close(); err != nil {
o.logErr(fmt.Errorf("close watcher: %w", err)) o.logErr(fmt.Errorf("close watcher: %w", err))
} }
syscall.Sync()
}() }()
go func() { go func() {
@@ -149,15 +152,17 @@ func (o *Organizer) Watch(ctx context.Context, wg *sync.WaitGroup) error {
// Add new directories to the watcher. // Add new directories to the watcher.
if stat.IsDir() { if stat.IsDir() {
if err := watcher.Add(event.Name); err != nil { if err := watcher.Add(event.Name); err != nil {
o.logErr(fmt.Errorf("watch dir: %w", err)) o.logErr(fmt.Errorf("add the directory %s to watcher: %w", event.Name, err))
} }
continue continue
} }
if err := o.processFile(event.Name); err != nil { go func() {
o.logErr(fmt.Errorf("process file %s: %w", event.Name, err)) if err := o.processFile(event.Name); err != nil {
} o.logErr(fmt.Errorf("process file %s: %w", event.Name, err))
}
}()
} }
case <-ctx.Done(): case <-ctx.Done():
@@ -180,7 +185,9 @@ func (o *Organizer) FullSync(ctx context.Context) error {
} }
if err := o.processFile(path); err != nil { if err := o.processFile(path); err != nil {
return err log.Printf("Process file `%s` failed: %s", path, err.Error())
return nil
} }
return nil return nil
@@ -203,7 +210,7 @@ func (o *Organizer) getMetaForPath(fp string) (metadata.Metadata, error) {
meta, err := o.getMetadata(fp, file) meta, err := o.getMetadata(fp, file)
if err != nil { if err != nil {
return metadata.Metadata{}, fmt.Errorf("get metadata: %w", err) return metadata.Metadata{}, fmt.Errorf("get metadatas: %w", err)
} }
return meta, nil return meta, nil
@@ -246,7 +253,7 @@ func (o *Organizer) processFile(sourcePath string) error {
return fmt.Errorf("build target path: %w", err) return fmt.Errorf("build target path: %w", err)
} }
if pathExists(targetPath) && !o.overwrite { if o.pathExists(targetPath) && !o.overwrite {
return nil return nil
} }
@@ -281,7 +288,7 @@ func (o *Organizer) BuildTargetPath(sourcePath string, meta metadata.Metadata) (
} }
func (o *Organizer) ensureTargetPath(targetPath string) error { func (o *Organizer) ensureTargetPath(targetPath string) error {
if pathExists(targetPath) { if o.pathExists(targetPath) {
return nil return nil
} }
@@ -303,13 +310,15 @@ func (o *Organizer) ensureTargetPath(targetPath string) error {
return nil return nil
} }
func pathExists(path string) bool { func (o *Organizer) pathExists(path string) bool {
_, err := os.Stat(path) _, err := os.Stat(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return false return false
} }
o.logErr(fmt.Errorf("pathExists stat %s: %w", path, err))
return true return true
} }

56
main.go
View File

@@ -3,8 +3,10 @@ package main
import ( import (
"context" "context"
"flag" "flag"
"fmt"
"log" "log"
"os/signal" "os/signal"
"slices"
"strconv" "strconv"
"sync" "sync"
"syscall" "syscall"
@@ -38,31 +40,49 @@ func loadCfg() application.Config {
flag.StringVar(&cfg.SourceDir, "source", "", "Source directory") flag.StringVar(&cfg.SourceDir, "source", "", "Source directory")
flag.StringVar(&cfg.TargetDir, "target", "", "Target directory") flag.StringVar(&cfg.TargetDir, "target", "", "Target directory")
flag.BoolVar(&cfg.Overwrite, "overwrite", false, "Overwrite existing files") flag.BoolVar(&cfg.Overwrite, "overwrite", false, "Overwrite existing files")
flag.BoolVar(&cfg.Watch, "watch", true, "Watch for changes in the source directory") flag.BoolVar(&cfg.Watch, "watch", false, "Watch for changes in the source directory")
flag.BoolVar(&cfg.Watch, "monitor", false, "Watch for changes in the source directory") // Legacy option
flag.BoolVar(&cfg.SkipFullSync, "skip-full-sync", false, "Skip full sync at startup") flag.BoolVar(&cfg.SkipFullSync, "skip-full-sync", false, "Skip full sync at startup")
var dirMode string flag.Func("dir-mode", "Mode bits for directories can be created while syncing", func(s string) error {
var fileMode string var err error
flag.StringVar(&dirMode, "dir-mode", "0777", "Mode bits for directories can be created while syncing")
flag.StringVar(&fileMode, "file-mode", "0644", "Mode bits for files created while syncing (not applicable for hardlink mode)")
var mode string cfg.DirMode, err = strconv.ParseUint(s, 8, 32)
flag.StringVar(&mode, "mode", "hardlink", "Mode") if err != nil {
return err
}
return nil
})
flag.Func("file-mode", "Mode bits for files created while syncing (not applicable for hardlink mode)", func(s string) error {
var err error
cfg.FileMode, err = strconv.ParseUint(s, 8, 32)
if err != nil {
return err
}
return nil
})
flag.Func("mode", "Organizing mode", func(s string) error {
cfg.Mode = application.Mode(s)
if !slices.Contains(application.SupportedModes, cfg.Mode) {
return fmt.Errorf("invalid mode, supported modes: %s", application.SupportedModes)
}
return nil
})
flag.Parse() flag.Parse()
cfg.Mode = application.Mode(mode) // Legacy fallback
if cfg.SourceDir == "" {
log.Println("Source directory not specified. May be using old systemd unit file.")
var err error cfg.SourceDir = flag.Arg(0)
cfg.DirMode, err = strconv.ParseUint(dirMode, 8, 32)
if err != nil {
cfg.DirMode = 0o777
}
cfg.FileMode, err = strconv.ParseUint(fileMode, 8, 32)
if err != nil {
cfg.DirMode = 0o644
} }
return cfg return cfg