mirror of
https://github.com/derfenix/photocatalog.git
synced 2026-03-11 21:35:34 +03:00
Compare commits
12 Commits
v0.1.1
...
v2.0.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ae91523d8 | |||
| 535c790c39 | |||
| 73aa5c4ac2 | |||
| 077f920986 | |||
| 77443e2369 | |||
| 9acb554dd4 | |||
| e3f8f7b8c8 | |||
| 62ca9f2378 | |||
| f73d612666 | |||
| e7c515c718 | |||
| 754aecd69a | |||
| 70f32b799c |
28
.github/workflows/go.yml
vendored
Normal file
28
.github/workflows/go.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# This workflow will build a golang project
|
||||||
|
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
|
||||||
|
|
||||||
|
name: Go
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "master" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "master" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v4
|
||||||
|
with:
|
||||||
|
go-version: '1.22'
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: go build -v ./...
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go test -v ./...
|
||||||
75
README.md
75
README.md
@@ -1,25 +1,23 @@
|
|||||||
# Simple photo cataloguer
|
[](https://github.com/derfenix/photocatalog/actions/workflows/go.yml)
|
||||||
|
|
||||||
|
# Effortless Photo Organizer
|
||||||
|
|
||||||
Just copy/hardlink photos (or video, or any other files) from one place to
|
Just copy/hardlink photos (or video, or any other files) from one place to
|
||||||
another, separating them in sub-directories like `$ROOT/year/month/day/`.
|
another, separating them in sub-directories like `$ROOT/year/month/day/`.
|
||||||
|
|
||||||
### TL;DR
|
### TL;DR
|
||||||
|
|
||||||
I have a smartphone, I have a Syncthing ~~uugh... SmartThing~~ and all photos
|
I use a smartphone along with Syncthing to seamlessly sync all my photos to my PC without any manual effort. However, there's a catch: I can't keep all my photos in the synced folder indefinitely. If I clear my phone's memory, the photos on my PC get deleted as well. To avoid this, I need to remember to copy the files to another location before cleaning up my phone.
|
||||||
from smartphone nicely synced to my PC without my attention. But I can't just
|
|
||||||
keep all photos in synced folder: if I'll clean my phone memory - all photos
|
|
||||||
from pc will be cleaned too. I need to not forget copy files in another
|
|
||||||
place before cleaning phone's memory. Also, I can't just drop all photos in
|
|
||||||
one dir - I will not find anything there later, and a folder with thousands
|
|
||||||
photos looks like a bad idea from either side.
|
|
||||||
So I create this tool in one evening. All it does - copy (or create hardlinks for)
|
|
||||||
files from one place to another, creating basic date-aware directories
|
|
||||||
structure for that files.
|
|
||||||
|
|
||||||
|
Simply dumping all my photos into one folder isn't a solution either — finding anything later would be a nightmare, and a folder with thousands of unsorted photos is far from ideal.
|
||||||
|
|
||||||
|
To address these issues, I created this tool in just one evening. Its primary purpose is to copy (or create hardlinks for) files from one location to another, while organizing them into a simple, date-based directory structure.
|
||||||
|
|
||||||
|
This tool was built for personal use and has been serving me well for quite some time without any problems. However, if you encounter any issues, feel free to report them — I’d be happy to help.
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
```bash
|
```bash
|
||||||
go install github.com/derfenix/photocatalog
|
go install github.com/derfenix/photocatalog/v2@latest
|
||||||
```
|
```
|
||||||
Optionally you could copy created binary from the GO's bin path to
|
Optionally you could copy created binary from the GO's bin path to
|
||||||
system or user $PATH, e.g. /usr/local/bin/.
|
system or user $PATH, e.g. /usr/local/bin/.
|
||||||
@@ -27,44 +25,63 @@ system or user $PATH, e.g. /usr/local/bin/.
|
|||||||
sudo cp ${GOPATH}/bin/photocatalog /usr/local/bin/photocatalog
|
sudo cp ${GOPATH}/bin/photocatalog /usr/local/bin/photocatalog
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Migrating from v0.*
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
## Organization modes
|
||||||
|
|
||||||
|
Next organization modes supported:
|
||||||
|
|
||||||
|
- **copy** — copy files to target root. Make COW (using syscall) if FS supports it.
|
||||||
|
- **hardlink** — create hardlink to the source file instead of copying.
|
||||||
|
The best choice if source and target are in same partition for compatibility
|
||||||
|
and resource usage, but we can't chmod target files, because of original file mode will
|
||||||
|
be changed too.
|
||||||
|
- **move** — moves original files to new place.
|
||||||
|
- **symlink** — create a symlink at the target for the source files.
|
||||||
|
|
||||||
## Supported formats
|
## Supported formats
|
||||||
At this moment supported jpeg files with filled exif data or any other
|
At this moment supported jpeg and tiff files with filled exif data and any other
|
||||||
files but with names matching pattern `yyymmdd_HHMMSS.ext`. Such
|
files but with names matching pattern `yyymmdd_HHMMSS.ext` with optional suffixes after a timestamp.
|
||||||
names format applied by android's camera software (I guess all cams
|
Such names format applied by the Android's camera software (I guess all cams
|
||||||
use this format, fix me if I'm wrong).
|
use this format, fix me if I'm wrong).
|
||||||
|
|
||||||
There is no support for changing names format without modifying source code
|
Jpeg/Tiff files without modification date if exif will be fallen back to the name parsing.
|
||||||
at this time.
|
|
||||||
|
No able to change names format without modifying source code for now. Just because
|
||||||
|
I have reasons to believe that this format is the most popular for the application use cases.
|
||||||
|
But let me know if you need different timestamp formats support.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
### One-shot
|
### One-shot
|
||||||
#### Copy files (make a COW if fs supports it)
|
#### Copy files
|
||||||
```bash
|
```bash
|
||||||
photocalog -mode copy -target ./photos/ ./sync/photos/*
|
photocalog -mode copy -target ./photos/ -source ./sync/photos/
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Create hardlinks (only withing one disk partition)
|
#### Create hardlinks
|
||||||
```bash
|
```bash
|
||||||
photocalog -mode hardlink -target ./photos/ ./sync/photos/*
|
photocalog -mode hardlink -target ./photos/ -source ./sync/photos/
|
||||||
```
|
```
|
||||||
or
|
or
|
||||||
```bash
|
```bash
|
||||||
photocalog -target ./photos/ ./sync/photos/*
|
photocalog -target ./photos/ -source ./sync/photos/*
|
||||||
```
|
```
|
||||||
|
|
||||||
### Monitor
|
### Watch mode
|
||||||
#### Copy files (make a COW if fs supports it)
|
#### Copy files
|
||||||
```bash
|
```bash
|
||||||
photocalog -mode copy -target ./photos -monitor ./sync/photos/*
|
photocalog -mode copy -target ./photos -watch -source ./sync/photos/
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Create hardlinks (only withing one disk partition)
|
#### Create hardlinks
|
||||||
```bash
|
```bash
|
||||||
photocalog -mode hardlink -target ./photos/ -monitor ./sync/photos/
|
photocalog -mode hardlink -target ./photos/ -watch -source ./sync/photos/
|
||||||
```
|
```
|
||||||
or
|
or
|
||||||
```bash
|
```bash
|
||||||
photocalog -target ./photos/ -monitor ./sync/photos/
|
photocalog -target ./photos/ -watch -source ./sync/photos/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install and run monitor service
|
## Install and run monitor service
|
||||||
@@ -89,6 +106,6 @@ under corresponding sub-dir.
|
|||||||
|
|
||||||
### Why this tool was created if there is awesome XXX tool?
|
### Why this tool was created if there is awesome XXX tool?
|
||||||
I had two good reasons:
|
I had two good reasons:
|
||||||
1. I wanted
|
1. I wish
|
||||||
2. I can
|
2. I can
|
||||||
|
|
||||||
|
|||||||
66
application/application.go
Normal file
66
application/application.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/derfenix/photocatalog/v2/internal/organizer"
|
||||||
|
"github.com/derfenix/photocatalog/v2/internal/organizer/modes"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Application struct {
|
||||||
|
config Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApplication(config Config) (Application, error) {
|
||||||
|
if err := config.Validate(); err != nil {
|
||||||
|
return Application{}, fmt.Errorf("invalid config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Application{config: config}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Application) Start(ctx context.Context, wg *sync.WaitGroup) error {
|
||||||
|
var mode organizer.Mode
|
||||||
|
|
||||||
|
switch a.config.Mode {
|
||||||
|
case ModeCopy:
|
||||||
|
mode = modes.Copy{}
|
||||||
|
case ModeHardlink:
|
||||||
|
mode = modes.HardLink{}
|
||||||
|
case ModeMove:
|
||||||
|
mode = modes.Move{}
|
||||||
|
case ModeSymlink:
|
||||||
|
mode = modes.SymLink{}
|
||||||
|
default:
|
||||||
|
mode = modes.HardLink{}
|
||||||
|
}
|
||||||
|
|
||||||
|
org := organizer.NewOrganizer(mode, a.config.SourceDir, a.config.TargetDir).
|
||||||
|
WithDirMode(os.FileMode(a.config.DirMode)).
|
||||||
|
WithFileMode(os.FileMode(a.config.FileMode)).
|
||||||
|
WithErrLogger(func(err error) {
|
||||||
|
log.Println(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
if a.config.Overwrite {
|
||||||
|
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 err := org.Watch(ctx, wg); err != nil {
|
||||||
|
return fmt.Errorf("initialize watch: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
46
application/config.go
Normal file
46
application/config.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Mode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeCopy Mode = "copy"
|
||||||
|
ModeHardlink Mode = "hardlink"
|
||||||
|
ModeSymlink Mode = "symlink"
|
||||||
|
ModeMove Mode = "move"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
SourceDir string
|
||||||
|
TargetDir string
|
||||||
|
Mode Mode
|
||||||
|
Overwrite bool
|
||||||
|
DirMode uint64
|
||||||
|
FileMode uint64
|
||||||
|
Watch bool
|
||||||
|
SkipFullSync bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
if c.SourceDir == "" {
|
||||||
|
return fmt.Errorf("source dir is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.TargetDir == "" {
|
||||||
|
return fmt.Errorf("target dir is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains([]Mode{ModeHardlink, ModeSymlink, ModeMove, ModeCopy}, c.Mode) {
|
||||||
|
return fmt.Errorf("invalid mode %s", c.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.SkipFullSync && !c.Watch {
|
||||||
|
return fmt.Errorf("skip full sync and watch disabled — nothing to do")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/fsnotify/fsnotify"
|
|
||||||
|
|
||||||
"github.com/derfenix/photocatalog/pkg/manager"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
mode := flag.String("mode", "hardlink", "Manage mode: copy or hardlink")
|
|
||||||
target := flag.String("target", "./", "Root directory to organize files in")
|
|
||||||
monitor := flag.String("monitor", "", "Monitor specified folder for new files")
|
|
||||||
updateMtime := flag.Bool("update_mtime", false, "Update mtime on organized files to be equal to the files creation date")
|
|
||||||
|
|
||||||
flag.Parse()
|
|
||||||
args := flag.Args()
|
|
||||||
log.Println("Using", *target, "as target and", *mode, "as mode")
|
|
||||||
|
|
||||||
var manageMode manager.ManageMode
|
|
||||||
switch *mode {
|
|
||||||
case "copy":
|
|
||||||
manageMode = manager.Copy
|
|
||||||
case "hardlink":
|
|
||||||
manageMode = manager.Hardlink
|
|
||||||
default:
|
|
||||||
log.Fatalf("Invalid mode %s", *mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
mgr, err := manager.NewManager(*target, manageMode, *updateMtime)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf(err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
if *monitor == "" {
|
|
||||||
processFiles(args, mgr)
|
|
||||||
} else {
|
|
||||||
startMonitoring(*monitor, mgr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func processFiles(args []string, manager *manager.Manager) {
|
|
||||||
var manageErr error
|
|
||||||
var gotErrors bool
|
|
||||||
|
|
||||||
if len(args) > 0 {
|
|
||||||
var err error
|
|
||||||
if len(args) == 1 && strings.HasSuffix(args[0], "/") {
|
|
||||||
args, err = filepath.Glob(args[0] + "*")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Println("Processing", len(args), "files")
|
|
||||||
for _, f := range args {
|
|
||||||
manageErr = manager.Manage(f)
|
|
||||||
if manageErr != nil {
|
|
||||||
log.Println(manageErr)
|
|
||||||
gotErrors = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Println("No input files")
|
|
||||||
}
|
|
||||||
|
|
||||||
if gotErrors {
|
|
||||||
log.Println("All files processed, got errors")
|
|
||||||
} else {
|
|
||||||
log.Println("All files processed without errors")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func startMonitoring(monitor string, manager *manager.Manager) {
|
|
||||||
var manageErr error
|
|
||||||
|
|
||||||
if !path.IsAbs(monitor) {
|
|
||||||
cwd, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to get CWD: %s", err.Error())
|
|
||||||
}
|
|
||||||
monitor = path.Join(cwd, monitor)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Monitoring", monitor)
|
|
||||||
|
|
||||||
watcher, err := fsnotify.NewWatcher()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
closeErr := watcher.Close()
|
|
||||||
if closeErr != nil {
|
|
||||||
log.Println(closeErr)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
done := make(chan os.Signal)
|
|
||||||
signal.Notify(done, os.Interrupt, syscall.SIGTERM)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case event, ok := <-watcher.Events:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if event.Op == fsnotify.Create {
|
|
||||||
if strings.HasSuffix(event.Name, "tmp") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
manageErr = manager.Manage(event.Name)
|
|
||||||
if manageErr != nil {
|
|
||||||
log.Println(manageErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case wErr, ok := <-watcher.Errors:
|
|
||||||
log.Println("error:", wErr)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
err = watcher.Add(monitor)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sig := <-done
|
|
||||||
log.Println("Monitoring stopped with", sig, "signal")
|
|
||||||
}
|
|
||||||
10
go.mod
10
go.mod
@@ -1,10 +1,10 @@
|
|||||||
module github.com/derfenix/photocatalog
|
module github.com/derfenix/photocatalog/v2
|
||||||
|
|
||||||
go 1.12
|
go 1.22
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/fsnotify/fsnotify v1.4.7
|
github.com/fsnotify/fsnotify v1.8.0
|
||||||
github.com/pkg/errors v0.8.1
|
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
|
||||||
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa // indirect
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.28.0 // indirect
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -1,8 +1,10 @@
|
|||||||
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
|
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||||
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa h1:KIDDMLT1O0Nr7TSxp8xM5tJcdn8tgyAONntO829og1M=
|
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa h1:KIDDMLT1O0Nr7TSxp8xM5tJcdn8tgyAONntO829og1M=
|
||||||
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
|||||||
53
internal/metadata/default.go
Normal file
53
internal/metadata/default.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const timeFormat = "20060102_150405"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrBadPrefix = errors.New("bad prefix")
|
||||||
|
ErrBadNameFormat = errors.New("bad name format")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Default struct {
|
||||||
|
TimeFormat string
|
||||||
|
Prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Default) Extract(fp string, _ io.Reader) (Metadata, error) {
|
||||||
|
format := d.TimeFormat
|
||||||
|
if format == "" {
|
||||||
|
format = timeFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.Prefix != "" && !strings.HasPrefix(fp, d.Prefix) {
|
||||||
|
return Metadata{}, fmt.Errorf("%w: expect a prefix %s, got %s", ErrBadPrefix, d.Prefix, fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
fp = filepath.Base(fp)
|
||||||
|
|
||||||
|
leftLimit := len(d.Prefix)
|
||||||
|
rightLimit := leftLimit + len(format)
|
||||||
|
|
||||||
|
if len(fp) < rightLimit {
|
||||||
|
return Metadata{}, fmt.Errorf("%w: too short", ErrBadNameFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := time.Parse(format, fp[leftLimit:rightLimit])
|
||||||
|
if err != nil {
|
||||||
|
return Metadata{}, fmt.Errorf("parse time: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := Metadata{
|
||||||
|
Created: created,
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
123
internal/metadata/default_test.go
Normal file
123
internal/metadata/default_test.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package metadata_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
. "github.com/derfenix/photocatalog/v2/internal/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefault_Extract(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const timeFormat = "20060102_150405"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filePath string
|
||||||
|
prefix string
|
||||||
|
timeFormat string
|
||||||
|
want Metadata
|
||||||
|
wantErr bool
|
||||||
|
wantErrType error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple",
|
||||||
|
filePath: "20241108_160834.jpg",
|
||||||
|
timeFormat: timeFormat,
|
||||||
|
want: Metadata{
|
||||||
|
Created: time.Date(2024, 11, 8, 16, 8, 34, 0, time.UTC),
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple in subdirectory",
|
||||||
|
filePath: "foo/20241108_160834.jpg",
|
||||||
|
timeFormat: timeFormat,
|
||||||
|
want: Metadata{
|
||||||
|
Created: time.Date(2024, 11, 8, 16, 8, 34, 0, time.UTC),
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple with suffix",
|
||||||
|
filePath: "20241108_160834_(1).jpg",
|
||||||
|
timeFormat: timeFormat,
|
||||||
|
want: Metadata{
|
||||||
|
Created: time.Date(2024, 11, 8, 16, 8, 34, 0, time.UTC),
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prefix",
|
||||||
|
filePath: "foo_20241108_160834.jpg",
|
||||||
|
timeFormat: timeFormat,
|
||||||
|
prefix: "foo_",
|
||||||
|
want: Metadata{
|
||||||
|
Created: time.Date(2024, 11, 8, 16, 8, 34, 0, time.UTC),
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad prefix",
|
||||||
|
filePath: "foo_20241108_160834.jpg",
|
||||||
|
timeFormat: timeFormat,
|
||||||
|
prefix: "bar_",
|
||||||
|
want: Metadata{
|
||||||
|
Created: time.Time{},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
wantErrType: ErrBadPrefix,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no prefix",
|
||||||
|
filePath: "20241108_160834.jpg",
|
||||||
|
timeFormat: timeFormat,
|
||||||
|
prefix: "bar_",
|
||||||
|
want: Metadata{
|
||||||
|
Created: time.Time{},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
wantErrType: ErrBadPrefix,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unexpected prefix",
|
||||||
|
filePath: "foo_20241108_160834.jpg",
|
||||||
|
timeFormat: timeFormat,
|
||||||
|
prefix: "",
|
||||||
|
want: Metadata{
|
||||||
|
Created: time.Time{},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := &Default{
|
||||||
|
TimeFormat: tt.timeFormat,
|
||||||
|
Prefix: tt.prefix,
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := d.Extract(tt.filePath, nil)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("Extract() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantErr && tt.wantErrType != nil {
|
||||||
|
if !errors.Is(err, tt.wantErrType) {
|
||||||
|
t.Errorf("Extract() errorType = %v, wantErrType %v", err, tt.wantErrType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("Extract() got = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
28
internal/metadata/exif.go
Normal file
28
internal/metadata/exif.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/rwcarlsen/goexif/exif"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Exif struct{}
|
||||||
|
|
||||||
|
func (j Exif) Extract(_ string, data io.Reader) (Metadata, error) {
|
||||||
|
decode, err := exif.Decode(data)
|
||||||
|
if err != nil {
|
||||||
|
return Metadata{}, fmt.Errorf("decode exif: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := Metadata{}
|
||||||
|
|
||||||
|
created, err := decode.DateTime()
|
||||||
|
if err != nil {
|
||||||
|
return Metadata{}, fmt.Errorf("parse datetime: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.Created = created
|
||||||
|
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
9
internal/metadata/metadata.go
Normal file
9
internal/metadata/metadata.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Metadata struct {
|
||||||
|
Created time.Time
|
||||||
|
}
|
||||||
65
internal/organizer/modes/copy.go
Normal file
65
internal/organizer/modes/copy.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package modes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
var cowDisabled = atomic.Bool{}
|
||||||
|
|
||||||
|
type Copy struct{}
|
||||||
|
|
||||||
|
func (c Copy) PlaceIt(sourcePath, targetPath string, mode os.FileMode) error {
|
||||||
|
targetFile, err := os.OpenFile(targetPath, os.O_TRUNC|os.O_WRONLY|os.O_CREATE, mode)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open target file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = targetFile.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
sourceFile, err := os.OpenFile(sourcePath, os.O_RDONLY, os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open source file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = sourceFile.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Try to do a COW.
|
||||||
|
if runtime.GOOS == "linux" && !cowDisabled.Load() {
|
||||||
|
if err := unix.IoctlFileClone(int(targetFile.Fd()), int(sourceFile.Fd())); err == nil {
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
log.Println(fmt.Errorf("COW attempt for %s failed: %w", targetPath, err))
|
||||||
|
log.Println("Disabling COW until restart")
|
||||||
|
cowDisabled.Store(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copySize, err := io.Copy(targetFile, sourceFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("copy source file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, err := sourceFile.Stat()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("stat source file failed:", err)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if stat.Size() != copySize {
|
||||||
|
log.Printf("copy source file size not equal target file size: source %d != %d copied\n", stat.Size(), copySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
47
internal/organizer/modes/copy_test.go
Normal file
47
internal/organizer/modes/copy_test.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package modes_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/derfenix/photocatalog/v2/internal/organizer/modes"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCopy_PlaceIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const testDataDir = "copy"
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.RemoveAll(fmt.Sprintf("./testdata/%s/target/", testDataDir)); err != nil {
|
||||||
|
t.Errorf("error removing ./testdata/%s/target/: %v", testDataDir, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := os.Mkdir(fmt.Sprintf("./testdata/%s/target/", testDataDir), 0777); err != nil {
|
||||||
|
t.Errorf("error creating ./testdata/%s/target/: %v", testDataDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
source := fmt.Sprintf("./testdata/%s/source/foo.txt", testDataDir)
|
||||||
|
target := fmt.Sprintf("./testdata/%s/target/foo.txt", testDataDir)
|
||||||
|
|
||||||
|
err := Copy{}.PlaceIt(source, target, 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("place file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetData, err := os.ReadFile(target)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("read target file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceData, err := os.ReadFile(source)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("read source file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(targetData) != string(sourceData) {
|
||||||
|
t.Error("copy file contents missmatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
21
internal/organizer/modes/hardlink.go
Normal file
21
internal/organizer/modes/hardlink.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package modes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HardLink struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HardLink) PlaceIt(sourcePath, targetPath string, mode os.FileMode) error {
|
||||||
|
if err := os.Link(sourcePath, targetPath); err != nil {
|
||||||
|
if os.IsExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("create hard link: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
47
internal/organizer/modes/hardlink_test.go
Normal file
47
internal/organizer/modes/hardlink_test.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package modes_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/derfenix/photocatalog/v2/internal/organizer/modes"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHardLink_PlaceIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const testDataDir = "hardlink"
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.RemoveAll(fmt.Sprintf("./testdata/%s/target/", testDataDir)); err != nil {
|
||||||
|
t.Errorf("error removing ./testdata/%s/target/: %v", testDataDir, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := os.Mkdir(fmt.Sprintf("./testdata/%s/target/", testDataDir), 0777); err != nil {
|
||||||
|
t.Errorf("error creating ./testdata/%s/target/: %v", testDataDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
source := fmt.Sprintf("./testdata/%s/source/foo.txt", testDataDir)
|
||||||
|
target := fmt.Sprintf("./testdata/%s/target/foo.txt", testDataDir)
|
||||||
|
|
||||||
|
err := HardLink{}.PlaceIt(source, target, 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("place file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetData, err := os.ReadFile(target)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("read target file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceData, err := os.ReadFile(source)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("read source file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(targetData) != string(sourceData) {
|
||||||
|
t.Error("copy file contents missmatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
21
internal/organizer/modes/move.go
Normal file
21
internal/organizer/modes/move.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package modes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Move struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Move) PlaceIt(sourcePath, targetPath string, mode os.FileMode) error {
|
||||||
|
if err := os.Rename(sourcePath, targetPath); err != nil {
|
||||||
|
return fmt.Errorf("rename %s to %s: %w", sourcePath, targetPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chmod(targetPath, mode); err != nil {
|
||||||
|
return fmt.Errorf("chmod hard link: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
53
internal/organizer/modes/move_test.go
Normal file
53
internal/organizer/modes/move_test.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package modes_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/derfenix/photocatalog/v2/internal/organizer/modes"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMove_PlaceIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const testDataDir = "move"
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.RemoveAll(fmt.Sprintf("./testdata/%s/target/", testDataDir)); err != nil {
|
||||||
|
t.Errorf("error removing ./testdata/%s/target/: %v", testDataDir, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := os.Mkdir(fmt.Sprintf("./testdata/%s/target/", testDataDir), 0777); err != nil {
|
||||||
|
t.Errorf("error creating ./testdata/%s/target/: %v", testDataDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
source := fmt.Sprintf("./testdata/%s/source/foo.txt", testDataDir)
|
||||||
|
target := fmt.Sprintf("./testdata/%s/target/foo.txt", testDataDir)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := (&Move{}).PlaceIt(target, source, 0644); err != nil {
|
||||||
|
t.Errorf("error placing back target: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
sourceData, err := os.ReadFile(source)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("read source file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = Move{}.PlaceIt(source, target, 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("place file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetData, err := os.ReadFile(target)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("read target file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(targetData) != string(sourceData) {
|
||||||
|
t.Error("copy file contents missmatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
21
internal/organizer/modes/symlink.go
Normal file
21
internal/organizer/modes/symlink.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package modes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SymLink struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SymLink) PlaceIt(sourcePath, targetPath string, _ os.FileMode) error {
|
||||||
|
if err := os.Symlink(sourcePath, targetPath); err != nil {
|
||||||
|
if os.IsExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("create symlink: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
42
internal/organizer/modes/symlink_test.go
Normal file
42
internal/organizer/modes/symlink_test.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package modes_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/derfenix/photocatalog/v2/internal/organizer/modes"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSymLink_PlaceIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const testDataDir = "symlink"
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.RemoveAll(fmt.Sprintf("./testdata/%s/target/", testDataDir)); err != nil {
|
||||||
|
t.Errorf("error removing ./testdata/%s/target/: %v", testDataDir, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := os.Mkdir(fmt.Sprintf("./testdata/%s/target/", testDataDir), 0777); err != nil {
|
||||||
|
t.Errorf("error creating ./testdata/%s/target/: %v", testDataDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
source := fmt.Sprintf("./testdata/%s/source/foo.txt", testDataDir)
|
||||||
|
target := fmt.Sprintf("./testdata/%s/target/foo.txt", testDataDir)
|
||||||
|
|
||||||
|
err := SymLink{}.PlaceIt(source, target, 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("place file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
linkedFilePath, err := os.Readlink(target)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("read target file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if linkedFilePath != source {
|
||||||
|
t.Errorf("linked file path is %s, want %s", linkedFilePath, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
internal/organizer/modes/testdata/copy/source/foo.txt
vendored
Normal file
17
internal/organizer/modes/testdata/copy/source/foo.txt
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Elit culpa hendrerit assum aliquam placerat cupiditat placerat excepteur doming ad incidunt magna eirmod, delenit nam at aliquip iure cupiditat qui congue consectetuer placerat mollit enim fugiat consequat aliquip delenit nam feugait vero aliquyam vel vel. Euismod adipiscing anim officia nibh molestie amet pariatur. Te te nulla pariatur ut option doming excepteur gubergren justo dolore sadipscing tempor stet. Tation nonummy nam nulla sint odio at sint feugait cum esse. Lorem adipiscing excepteur praesent nisi facilisis, clita invidunt molestie vulputate in tempor vulputate eiusmod tincidunt nobis. Iusto iure exerci.
|
||||||
|
|
||||||
|
Gubergren nobis ut illum dignissim at aliquam feugiat tation velit illum consequat hendrerit. Feugait facilisis nibh odio sint at adipiscing at nibh incidunt quod cupiditat ullamcorper dolore ex aute ullamco commodi rebum rebum sunt ea enim fugiat facilisi exercitation feugait eos blandit nihil molestie et facilisi aute incidunt nisl illum. Assum consequat assum at et voluptate nobis sed nobis sunt id eros consectetuer, culpa tation deserunt praesent aliquam erat stet zzril voluptate nibh, et fugiat commodi quod tation stet sadipscing lobortis officia quod ex imperdiet doming. Excepteur augue nobis.
|
||||||
|
|
||||||
|
Sanctus possim feugiat nostrud amet illum eros, eu ipsum dignissim ea tincidunt tincidunt accusam delenit placerat vel facilisi volutpat amet. Wisi dolore zzril pariatur odio cum incidunt esse duo liber aliquam voluptate eos autem est. Congue kasd vero hendrerit adipiscing rebum elitr, laboris mollit takimata nonummy id esse commodo excepteur non possim quis zzril. Consectetuer accumsan volutpat.
|
||||||
|
|
||||||
|
Eum nibh zzril vulputate consectetur mazim voluptate commodo sea augue tempor. Fugiat voluptua tempor nonumy. Congue eirmod voluptua nulla. Nonumy consectetuer voluptate.
|
||||||
|
|
||||||
|
Consequat eum tempor stet cupiditat quis in nobis. Veniam lorem sit veniam eu amet option no molestie dolores option cum reprehenderit assum tempor. Gubergren dolore wisi ut voluptua amet eum incidunt commodi eos justo quis lorem aliquyam velit praesent ullamco tempor anim volutpat velit velit aliquam cum facilisis iusto erat veniam option. Rebum nobis ea excepteur ex sea consequat blandit, excepteur et qui mazim lorem eos augue duo nulla deserunt ullamco sadipscing sed. Eos takimata justo incidunt sea vulputate blandit tempor aliqua reprehenderit aliquid sit aliqua takimata. Ullamco takimata elitr dolore ipsum takimata. Consequat aute aliquid.
|
||||||
|
|
||||||
|
Placerat sunt ut delenit aliquid enim hendrerit. Vel rebum et ad dolor luptatum veniam, volutpat soluta euismod facilisis lobortis placerat eos dignissim excepteur reprehenderit facer autem nobis officia facilisis, quis duo augue dolor autem placerat obcaecat ipsum volutpat pariatur. Ullamco elitr exerci at ad nostrud. Suscipit eu dolores sint molestie proident eleifend aute sadipscing anim. Iusto aliquid aliquam.
|
||||||
|
|
||||||
|
Iriure elitr sit nihil sanctus soluta autem et, nisi dolores erat option blandit soluta aliquip nihil placerat cum clita diam duis sunt sunt tation vel consequat deserunt doming gubergren euismod liber ad esse augue aliquyam ad facilisis. Anim dignissim fugiat. Aliquam dolores, obcaecat aliquam nobis quod euismod consectetur iusto wisi aute mollit eirmod eirmod nonumy, qui officia in eros hendrerit nonummy soluta option aliquip. Feugait laboris duis accumsan proident velit stet invidunt kasd consetetur nobis fugiat invidunt cupiditat et excepteur nonumy feugait iusto consectetur eos cillum. Eos at commodo facilisi. Facer facilisi exercitation.
|
||||||
|
|
||||||
|
Soluta takimata labore sunt erat cum option eu deserunt eu consectetur, soluta option cum euismod aliquid illum commodo obcaecat aliquyam erat eleifend blandit, mazim exercitation consequat duis vel imperdiet consetetur ullamco doming. In elit duis. Duo praesent lorem.
|
||||||
|
|
||||||
|
Commodi molestie reprehenderit ipsum enim, zzril vulputate in laboris voluptua nibh suscipit, autem facilisi invidunt commodi iusto mollit aliquip nam culpa consetetur dolore elitr, fugiat luptatum nisl amet elitr facilisis te quod ad kasd wisi reprehenderit invidunt. Facer ut quis nam dignissim. Option suscipit duis dolor consequat takimata te amet accumsan obcaecat autem congue ad obcaecat velit. Imperdiet id laborum sea. Hendrerit nisi eros.
|
||||||
17
internal/organizer/modes/testdata/hardlink/source/foo.txt
vendored
Normal file
17
internal/organizer/modes/testdata/hardlink/source/foo.txt
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Elit culpa hendrerit assum aliquam placerat cupiditat placerat excepteur doming ad incidunt magna eirmod, delenit nam at aliquip iure cupiditat qui congue consectetuer placerat mollit enim fugiat consequat aliquip delenit nam feugait vero aliquyam vel vel. Euismod adipiscing anim officia nibh molestie amet pariatur. Te te nulla pariatur ut option doming excepteur gubergren justo dolore sadipscing tempor stet. Tation nonummy nam nulla sint odio at sint feugait cum esse. Lorem adipiscing excepteur praesent nisi facilisis, clita invidunt molestie vulputate in tempor vulputate eiusmod tincidunt nobis. Iusto iure exerci.
|
||||||
|
|
||||||
|
Gubergren nobis ut illum dignissim at aliquam feugiat tation velit illum consequat hendrerit. Feugait facilisis nibh odio sint at adipiscing at nibh incidunt quod cupiditat ullamcorper dolore ex aute ullamco commodi rebum rebum sunt ea enim fugiat facilisi exercitation feugait eos blandit nihil molestie et facilisi aute incidunt nisl illum. Assum consequat assum at et voluptate nobis sed nobis sunt id eros consectetuer, culpa tation deserunt praesent aliquam erat stet zzril voluptate nibh, et fugiat commodi quod tation stet sadipscing lobortis officia quod ex imperdiet doming. Excepteur augue nobis.
|
||||||
|
|
||||||
|
Sanctus possim feugiat nostrud amet illum eros, eu ipsum dignissim ea tincidunt tincidunt accusam delenit placerat vel facilisi volutpat amet. Wisi dolore zzril pariatur odio cum incidunt esse duo liber aliquam voluptate eos autem est. Congue kasd vero hendrerit adipiscing rebum elitr, laboris mollit takimata nonummy id esse commodo excepteur non possim quis zzril. Consectetuer accumsan volutpat.
|
||||||
|
|
||||||
|
Eum nibh zzril vulputate consectetur mazim voluptate commodo sea augue tempor. Fugiat voluptua tempor nonumy. Congue eirmod voluptua nulla. Nonumy consectetuer voluptate.
|
||||||
|
|
||||||
|
Consequat eum tempor stet cupiditat quis in nobis. Veniam lorem sit veniam eu amet option no molestie dolores option cum reprehenderit assum tempor. Gubergren dolore wisi ut voluptua amet eum incidunt commodi eos justo quis lorem aliquyam velit praesent ullamco tempor anim volutpat velit velit aliquam cum facilisis iusto erat veniam option. Rebum nobis ea excepteur ex sea consequat blandit, excepteur et qui mazim lorem eos augue duo nulla deserunt ullamco sadipscing sed. Eos takimata justo incidunt sea vulputate blandit tempor aliqua reprehenderit aliquid sit aliqua takimata. Ullamco takimata elitr dolore ipsum takimata. Consequat aute aliquid.
|
||||||
|
|
||||||
|
Placerat sunt ut delenit aliquid enim hendrerit. Vel rebum et ad dolor luptatum veniam, volutpat soluta euismod facilisis lobortis placerat eos dignissim excepteur reprehenderit facer autem nobis officia facilisis, quis duo augue dolor autem placerat obcaecat ipsum volutpat pariatur. Ullamco elitr exerci at ad nostrud. Suscipit eu dolores sint molestie proident eleifend aute sadipscing anim. Iusto aliquid aliquam.
|
||||||
|
|
||||||
|
Iriure elitr sit nihil sanctus soluta autem et, nisi dolores erat option blandit soluta aliquip nihil placerat cum clita diam duis sunt sunt tation vel consequat deserunt doming gubergren euismod liber ad esse augue aliquyam ad facilisis. Anim dignissim fugiat. Aliquam dolores, obcaecat aliquam nobis quod euismod consectetur iusto wisi aute mollit eirmod eirmod nonumy, qui officia in eros hendrerit nonummy soluta option aliquip. Feugait laboris duis accumsan proident velit stet invidunt kasd consetetur nobis fugiat invidunt cupiditat et excepteur nonumy feugait iusto consectetur eos cillum. Eos at commodo facilisi. Facer facilisi exercitation.
|
||||||
|
|
||||||
|
Soluta takimata labore sunt erat cum option eu deserunt eu consectetur, soluta option cum euismod aliquid illum commodo obcaecat aliquyam erat eleifend blandit, mazim exercitation consequat duis vel imperdiet consetetur ullamco doming. In elit duis. Duo praesent lorem.
|
||||||
|
|
||||||
|
Commodi molestie reprehenderit ipsum enim, zzril vulputate in laboris voluptua nibh suscipit, autem facilisi invidunt commodi iusto mollit aliquip nam culpa consetetur dolore elitr, fugiat luptatum nisl amet elitr facilisis te quod ad kasd wisi reprehenderit invidunt. Facer ut quis nam dignissim. Option suscipit duis dolor consequat takimata te amet accumsan obcaecat autem congue ad obcaecat velit. Imperdiet id laborum sea. Hendrerit nisi eros.
|
||||||
17
internal/organizer/modes/testdata/move/source/foo.txt
vendored
Normal file
17
internal/organizer/modes/testdata/move/source/foo.txt
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Elit culpa hendrerit assum aliquam placerat cupiditat placerat excepteur doming ad incidunt magna eirmod, delenit nam at aliquip iure cupiditat qui congue consectetuer placerat mollit enim fugiat consequat aliquip delenit nam feugait vero aliquyam vel vel. Euismod adipiscing anim officia nibh molestie amet pariatur. Te te nulla pariatur ut option doming excepteur gubergren justo dolore sadipscing tempor stet. Tation nonummy nam nulla sint odio at sint feugait cum esse. Lorem adipiscing excepteur praesent nisi facilisis, clita invidunt molestie vulputate in tempor vulputate eiusmod tincidunt nobis. Iusto iure exerci.
|
||||||
|
|
||||||
|
Gubergren nobis ut illum dignissim at aliquam feugiat tation velit illum consequat hendrerit. Feugait facilisis nibh odio sint at adipiscing at nibh incidunt quod cupiditat ullamcorper dolore ex aute ullamco commodi rebum rebum sunt ea enim fugiat facilisi exercitation feugait eos blandit nihil molestie et facilisi aute incidunt nisl illum. Assum consequat assum at et voluptate nobis sed nobis sunt id eros consectetuer, culpa tation deserunt praesent aliquam erat stet zzril voluptate nibh, et fugiat commodi quod tation stet sadipscing lobortis officia quod ex imperdiet doming. Excepteur augue nobis.
|
||||||
|
|
||||||
|
Sanctus possim feugiat nostrud amet illum eros, eu ipsum dignissim ea tincidunt tincidunt accusam delenit placerat vel facilisi volutpat amet. Wisi dolore zzril pariatur odio cum incidunt esse duo liber aliquam voluptate eos autem est. Congue kasd vero hendrerit adipiscing rebum elitr, laboris mollit takimata nonummy id esse commodo excepteur non possim quis zzril. Consectetuer accumsan volutpat.
|
||||||
|
|
||||||
|
Eum nibh zzril vulputate consectetur mazim voluptate commodo sea augue tempor. Fugiat voluptua tempor nonumy. Congue eirmod voluptua nulla. Nonumy consectetuer voluptate.
|
||||||
|
|
||||||
|
Consequat eum tempor stet cupiditat quis in nobis. Veniam lorem sit veniam eu amet option no molestie dolores option cum reprehenderit assum tempor. Gubergren dolore wisi ut voluptua amet eum incidunt commodi eos justo quis lorem aliquyam velit praesent ullamco tempor anim volutpat velit velit aliquam cum facilisis iusto erat veniam option. Rebum nobis ea excepteur ex sea consequat blandit, excepteur et qui mazim lorem eos augue duo nulla deserunt ullamco sadipscing sed. Eos takimata justo incidunt sea vulputate blandit tempor aliqua reprehenderit aliquid sit aliqua takimata. Ullamco takimata elitr dolore ipsum takimata. Consequat aute aliquid.
|
||||||
|
|
||||||
|
Placerat sunt ut delenit aliquid enim hendrerit. Vel rebum et ad dolor luptatum veniam, volutpat soluta euismod facilisis lobortis placerat eos dignissim excepteur reprehenderit facer autem nobis officia facilisis, quis duo augue dolor autem placerat obcaecat ipsum volutpat pariatur. Ullamco elitr exerci at ad nostrud. Suscipit eu dolores sint molestie proident eleifend aute sadipscing anim. Iusto aliquid aliquam.
|
||||||
|
|
||||||
|
Iriure elitr sit nihil sanctus soluta autem et, nisi dolores erat option blandit soluta aliquip nihil placerat cum clita diam duis sunt sunt tation vel consequat deserunt doming gubergren euismod liber ad esse augue aliquyam ad facilisis. Anim dignissim fugiat. Aliquam dolores, obcaecat aliquam nobis quod euismod consectetur iusto wisi aute mollit eirmod eirmod nonumy, qui officia in eros hendrerit nonummy soluta option aliquip. Feugait laboris duis accumsan proident velit stet invidunt kasd consetetur nobis fugiat invidunt cupiditat et excepteur nonumy feugait iusto consectetur eos cillum. Eos at commodo facilisi. Facer facilisi exercitation.
|
||||||
|
|
||||||
|
Soluta takimata labore sunt erat cum option eu deserunt eu consectetur, soluta option cum euismod aliquid illum commodo obcaecat aliquyam erat eleifend blandit, mazim exercitation consequat duis vel imperdiet consetetur ullamco doming. In elit duis. Duo praesent lorem.
|
||||||
|
|
||||||
|
Commodi molestie reprehenderit ipsum enim, zzril vulputate in laboris voluptua nibh suscipit, autem facilisi invidunt commodi iusto mollit aliquip nam culpa consetetur dolore elitr, fugiat luptatum nisl amet elitr facilisis te quod ad kasd wisi reprehenderit invidunt. Facer ut quis nam dignissim. Option suscipit duis dolor consequat takimata te amet accumsan obcaecat autem congue ad obcaecat velit. Imperdiet id laborum sea. Hendrerit nisi eros.
|
||||||
17
internal/organizer/modes/testdata/symlink/source/foo.txt
vendored
Normal file
17
internal/organizer/modes/testdata/symlink/source/foo.txt
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Elit culpa hendrerit assum aliquam placerat cupiditat placerat excepteur doming ad incidunt magna eirmod, delenit nam at aliquip iure cupiditat qui congue consectetuer placerat mollit enim fugiat consequat aliquip delenit nam feugait vero aliquyam vel vel. Euismod adipiscing anim officia nibh molestie amet pariatur. Te te nulla pariatur ut option doming excepteur gubergren justo dolore sadipscing tempor stet. Tation nonummy nam nulla sint odio at sint feugait cum esse. Lorem adipiscing excepteur praesent nisi facilisis, clita invidunt molestie vulputate in tempor vulputate eiusmod tincidunt nobis. Iusto iure exerci.
|
||||||
|
|
||||||
|
Gubergren nobis ut illum dignissim at aliquam feugiat tation velit illum consequat hendrerit. Feugait facilisis nibh odio sint at adipiscing at nibh incidunt quod cupiditat ullamcorper dolore ex aute ullamco commodi rebum rebum sunt ea enim fugiat facilisi exercitation feugait eos blandit nihil molestie et facilisi aute incidunt nisl illum. Assum consequat assum at et voluptate nobis sed nobis sunt id eros consectetuer, culpa tation deserunt praesent aliquam erat stet zzril voluptate nibh, et fugiat commodi quod tation stet sadipscing lobortis officia quod ex imperdiet doming. Excepteur augue nobis.
|
||||||
|
|
||||||
|
Sanctus possim feugiat nostrud amet illum eros, eu ipsum dignissim ea tincidunt tincidunt accusam delenit placerat vel facilisi volutpat amet. Wisi dolore zzril pariatur odio cum incidunt esse duo liber aliquam voluptate eos autem est. Congue kasd vero hendrerit adipiscing rebum elitr, laboris mollit takimata nonummy id esse commodo excepteur non possim quis zzril. Consectetuer accumsan volutpat.
|
||||||
|
|
||||||
|
Eum nibh zzril vulputate consectetur mazim voluptate commodo sea augue tempor. Fugiat voluptua tempor nonumy. Congue eirmod voluptua nulla. Nonumy consectetuer voluptate.
|
||||||
|
|
||||||
|
Consequat eum tempor stet cupiditat quis in nobis. Veniam lorem sit veniam eu amet option no molestie dolores option cum reprehenderit assum tempor. Gubergren dolore wisi ut voluptua amet eum incidunt commodi eos justo quis lorem aliquyam velit praesent ullamco tempor anim volutpat velit velit aliquam cum facilisis iusto erat veniam option. Rebum nobis ea excepteur ex sea consequat blandit, excepteur et qui mazim lorem eos augue duo nulla deserunt ullamco sadipscing sed. Eos takimata justo incidunt sea vulputate blandit tempor aliqua reprehenderit aliquid sit aliqua takimata. Ullamco takimata elitr dolore ipsum takimata. Consequat aute aliquid.
|
||||||
|
|
||||||
|
Placerat sunt ut delenit aliquid enim hendrerit. Vel rebum et ad dolor luptatum veniam, volutpat soluta euismod facilisis lobortis placerat eos dignissim excepteur reprehenderit facer autem nobis officia facilisis, quis duo augue dolor autem placerat obcaecat ipsum volutpat pariatur. Ullamco elitr exerci at ad nostrud. Suscipit eu dolores sint molestie proident eleifend aute sadipscing anim. Iusto aliquid aliquam.
|
||||||
|
|
||||||
|
Iriure elitr sit nihil sanctus soluta autem et, nisi dolores erat option blandit soluta aliquip nihil placerat cum clita diam duis sunt sunt tation vel consequat deserunt doming gubergren euismod liber ad esse augue aliquyam ad facilisis. Anim dignissim fugiat. Aliquam dolores, obcaecat aliquam nobis quod euismod consectetur iusto wisi aute mollit eirmod eirmod nonumy, qui officia in eros hendrerit nonummy soluta option aliquip. Feugait laboris duis accumsan proident velit stet invidunt kasd consetetur nobis fugiat invidunt cupiditat et excepteur nonumy feugait iusto consectetur eos cillum. Eos at commodo facilisi. Facer facilisi exercitation.
|
||||||
|
|
||||||
|
Soluta takimata labore sunt erat cum option eu deserunt eu consectetur, soluta option cum euismod aliquid illum commodo obcaecat aliquyam erat eleifend blandit, mazim exercitation consequat duis vel imperdiet consetetur ullamco doming. In elit duis. Duo praesent lorem.
|
||||||
|
|
||||||
|
Commodi molestie reprehenderit ipsum enim, zzril vulputate in laboris voluptua nibh suscipit, autem facilisi invidunt commodi iusto mollit aliquip nam culpa consetetur dolore elitr, fugiat luptatum nisl amet elitr facilisis te quod ad kasd wisi reprehenderit invidunt. Facer ut quis nam dignissim. Option suscipit duis dolor consequat takimata te amet accumsan obcaecat autem congue ad obcaecat velit. Imperdiet id laborum sea. Hendrerit nisi eros.
|
||||||
317
internal/organizer/organizer.go
Normal file
317
internal/organizer/organizer.go
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
package organizer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
|
||||||
|
"github.com/derfenix/photocatalog/v2/internal/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultDirMode = 0o777
|
||||||
|
defaultFileMode = 0o644
|
||||||
|
)
|
||||||
|
|
||||||
|
type MetaExtractor interface {
|
||||||
|
Extract(_ string, data io.Reader) (metadata.Metadata, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mode interface {
|
||||||
|
PlaceIt(sourcePath, targetPath string, mode os.FileMode) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrganizer(mode Mode, source, target string) *Organizer {
|
||||||
|
return &Organizer{
|
||||||
|
mode: mode,
|
||||||
|
sourceDir: source,
|
||||||
|
targetDir: target,
|
||||||
|
|
||||||
|
dirMode: defaultDirMode,
|
||||||
|
fileMode: defaultFileMode,
|
||||||
|
|
||||||
|
metaExtractors: map[string]MetaExtractor{
|
||||||
|
"": &metadata.Default{},
|
||||||
|
"jpg": metadata.Exif{},
|
||||||
|
"jpeg": metadata.Exif{},
|
||||||
|
"tiff": metadata.Exif{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Organizer struct {
|
||||||
|
mode Mode
|
||||||
|
|
||||||
|
sourceDir string
|
||||||
|
targetDir string
|
||||||
|
|
||||||
|
overwrite bool
|
||||||
|
dirMode os.FileMode
|
||||||
|
fileMode os.FileMode
|
||||||
|
errLogger func(error)
|
||||||
|
|
||||||
|
metaExtractors map[string]MetaExtractor
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Organizer) WithOverwrite() *Organizer {
|
||||||
|
o.overwrite = true
|
||||||
|
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Organizer) WithDirMode(mode os.FileMode) *Organizer {
|
||||||
|
o.dirMode = mode
|
||||||
|
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Organizer) WithFileMode(mode os.FileMode) *Organizer {
|
||||||
|
o.fileMode = mode
|
||||||
|
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Organizer) WithErrLogger(f func(error)) *Organizer {
|
||||||
|
o.errLogger = f
|
||||||
|
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Organizer) logErr(err error) {
|
||||||
|
if o.errLogger != nil {
|
||||||
|
o.errLogger(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Organizer) Watch(ctx context.Context, wg *sync.WaitGroup) error {
|
||||||
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("new watcher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := watcher.Add(o.sourceDir); err != nil {
|
||||||
|
return fmt.Errorf("add source dir to watcher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all subfolders to the watcher.
|
||||||
|
err = filepath.WalkDir(o.sourceDir, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if path == o.sourceDir {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.IsDir() {
|
||||||
|
if err := watcher.Add(path); err != nil {
|
||||||
|
o.logErr(fmt.Errorf("add the directory %s to watcher: %w", path, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add subdirs to watcher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
|
||||||
|
if err := watcher.Close(); err != nil {
|
||||||
|
o.logErr(fmt.Errorf("close watcher: %w", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event := <-watcher.Events:
|
||||||
|
if event.Op == fsnotify.Write {
|
||||||
|
stat, err := os.Stat(event.Name)
|
||||||
|
if err != nil {
|
||||||
|
o.logErr(fmt.Errorf("stat %s: %w", event.Name, err))
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new directories to the watcher.
|
||||||
|
if stat.IsDir() {
|
||||||
|
if err := watcher.Add(event.Name); err != nil {
|
||||||
|
o.logErr(fmt.Errorf("watch dir: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := o.processFile(event.Name); err != nil {
|
||||||
|
o.logErr(fmt.Errorf("process file %s: %w", event.Name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Organizer) FullSync(ctx context.Context) error {
|
||||||
|
err := filepath.WalkDir(o.sourceDir, func(path string, info fs.DirEntry, err error) error {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := o.processFile(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("walking source dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Organizer) getMetaForPath(fp string) (metadata.Metadata, error) {
|
||||||
|
file, err := os.OpenFile(fp, os.O_RDONLY, os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
return metadata.Metadata{}, fmt.Errorf("open file: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = file.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
meta, err := o.getMetadata(fp, file)
|
||||||
|
if err != nil {
|
||||||
|
return metadata.Metadata{}, fmt.Errorf("get metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Organizer) getMetadata(fp string, data io.Reader) (metadata.Metadata, error) {
|
||||||
|
ext := strings.ToLower(filepath.Ext(fp))
|
||||||
|
|
||||||
|
if strings.HasPrefix(ext, ".") {
|
||||||
|
ext = ext[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
extractor, ok := o.metaExtractors[ext]
|
||||||
|
if !ok {
|
||||||
|
extractor = o.metaExtractors[""]
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := extractor.Extract(fp, data)
|
||||||
|
if err != nil || meta.Created.IsZero() {
|
||||||
|
// Fallback to default extractor.
|
||||||
|
extractor = o.metaExtractors[""]
|
||||||
|
|
||||||
|
meta, err = extractor.Extract(fp, data)
|
||||||
|
if err != nil {
|
||||||
|
return metadata.Metadata{}, fmt.Errorf("extract metadata: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Organizer) processFile(sourcePath string) error {
|
||||||
|
meta, err := o.getMetaForPath(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetPath, err := o.BuildTargetPath(sourcePath, meta)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build target path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pathExists(targetPath) && !o.overwrite {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := o.ensureTargetPath(filepath.Dir(targetPath)); err != nil {
|
||||||
|
return fmt.Errorf("ensure target path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := o.mode.PlaceIt(sourcePath, targetPath, o.fileMode); err != nil {
|
||||||
|
return fmt.Errorf("place file to new path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("File %s placed at %s", sourcePath, targetPath)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Organizer) BuildTargetPath(sourcePath string, meta metadata.Metadata) (string, error) {
|
||||||
|
sourcePath, err := filepath.Rel(o.sourceDir, sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get a relative path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
target := filepath.Join(
|
||||||
|
o.targetDir,
|
||||||
|
strconv.Itoa(meta.Created.Year()),
|
||||||
|
strconv.Itoa(int(meta.Created.Month())),
|
||||||
|
strconv.Itoa(meta.Created.Day()),
|
||||||
|
sourcePath,
|
||||||
|
)
|
||||||
|
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Organizer) ensureTargetPath(targetPath string) error {
|
||||||
|
if pathExists(targetPath) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath, err := filepath.Rel(o.targetDir, targetPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get a relative path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := o.targetDir
|
||||||
|
|
||||||
|
for _, part := range strings.Split(relPath, string(filepath.Separator)) {
|
||||||
|
dir = filepath.Join(dir, part)
|
||||||
|
|
||||||
|
if err := os.Mkdir(dir, o.dirMode); err != nil && !os.IsExist(err) {
|
||||||
|
return fmt.Errorf("create target directory path at %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
152
internal/organizer/organizer_test.go
Normal file
152
internal/organizer/organizer_test.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package organizer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/derfenix/photocatalog/v2/internal/metadata"
|
||||||
|
"github.com/derfenix/photocatalog/v2/internal/organizer/modes"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOrganizer_FullSync(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
source := "./testdata/fullsync/source"
|
||||||
|
target := "./testdata/fullsync/target"
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.RemoveAll(target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := os.Mkdir(target, 0777); err != nil {
|
||||||
|
t.Fatalf("create target dir %s failed: %v", target, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
org := NewOrganizer(modes.HardLink{}, source, target)
|
||||||
|
if err := org.FullSync(ctx); err != nil {
|
||||||
|
t.Fatalf("full sync failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := filepath.WalkDir(source, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceFile, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read source file %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := (&metadata.Default{}).Extract(path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("extract metadata from %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetPath, err := org.BuildTargetPath(path, meta)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build target file %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetFile, err := os.ReadFile(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read target file %s: %v", targetPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(sourceFile, targetFile) {
|
||||||
|
return fmt.Errorf("target file content missmatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("walk dir failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizer_Watch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
source := "./testdata/watcher/source"
|
||||||
|
target := "./testdata/watcher/target"
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.RemoveAll(target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := os.Mkdir(target, 0777); err != nil {
|
||||||
|
t.Fatalf("create target dir %s failed: %v", target, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
|
||||||
|
org := NewOrganizer(&modes.HardLink{}, source, target)
|
||||||
|
if err := org.Watch(ctx, &wg); err != nil {
|
||||||
|
t.Fatalf("watch failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonEmpty, err := checkEmpty(t, target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("check empty failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if nonEmpty {
|
||||||
|
t.Fatal("target dir should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(filepath.Join(source, "20241108_160834.txt"), []byte("test"), 0777)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("file write failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
|
||||||
|
nonEmpty, err = checkEmpty(t, target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("check empty failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !nonEmpty {
|
||||||
|
t.Fatal("target dir should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkEmpty(t *testing.T, target string) (bool, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var nonEmpty bool
|
||||||
|
err := filepath.WalkDir(target, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if path == target {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nonEmpty = true
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("walk dir failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nonEmpty, err
|
||||||
|
}
|
||||||
17
internal/organizer/testdata/fullsync/source/20241108_160834.txt
vendored
Normal file
17
internal/organizer/testdata/fullsync/source/20241108_160834.txt
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Elit culpa hendrerit assum aliquam placerat cupiditat placerat excepteur doming ad incidunt magna eirmod, delenit nam at aliquip iure cupiditat qui congue consectetuer placerat mollit enim fugiat consequat aliquip delenit nam feugait vero aliquyam vel vel. Euismod adipiscing anim officia nibh molestie amet pariatur. Te te nulla pariatur ut option doming excepteur gubergren justo dolore sadipscing tempor stet. Tation nonummy nam nulla sint odio at sint feugait cum esse. Lorem adipiscing excepteur praesent nisi facilisis, clita invidunt molestie vulputate in tempor vulputate eiusmod tincidunt nobis. Iusto iure exerci.
|
||||||
|
|
||||||
|
Gubergren nobis ut illum dignissim at aliquam feugiat tation velit illum consequat hendrerit. Feugait facilisis nibh odio sint at adipiscing at nibh incidunt quod cupiditat ullamcorper dolore ex aute ullamco commodi rebum rebum sunt ea enim fugiat facilisi exercitation feugait eos blandit nihil molestie et facilisi aute incidunt nisl illum. Assum consequat assum at et voluptate nobis sed nobis sunt id eros consectetuer, culpa tation deserunt praesent aliquam erat stet zzril voluptate nibh, et fugiat commodi quod tation stet sadipscing lobortis officia quod ex imperdiet doming. Excepteur augue nobis.
|
||||||
|
|
||||||
|
Sanctus possim feugiat nostrud amet illum eros, eu ipsum dignissim ea tincidunt tincidunt accusam delenit placerat vel facilisi volutpat amet. Wisi dolore zzril pariatur odio cum incidunt esse duo liber aliquam voluptate eos autem est. Congue kasd vero hendrerit adipiscing rebum elitr, laboris mollit takimata nonummy id esse commodo excepteur non possim quis zzril. Consectetuer accumsan volutpat.
|
||||||
|
|
||||||
|
Eum nibh zzril vulputate consectetur mazim voluptate commodo sea augue tempor. Fugiat voluptua tempor nonumy. Congue eirmod voluptua nulla. Nonumy consectetuer voluptate.
|
||||||
|
|
||||||
|
Consequat eum tempor stet cupiditat quis in nobis. Veniam lorem sit veniam eu amet option no molestie dolores option cum reprehenderit assum tempor. Gubergren dolore wisi ut voluptua amet eum incidunt commodi eos justo quis lorem aliquyam velit praesent ullamco tempor anim volutpat velit velit aliquam cum facilisis iusto erat veniam option. Rebum nobis ea excepteur ex sea consequat blandit, excepteur et qui mazim lorem eos augue duo nulla deserunt ullamco sadipscing sed. Eos takimata justo incidunt sea vulputate blandit tempor aliqua reprehenderit aliquid sit aliqua takimata. Ullamco takimata elitr dolore ipsum takimata. Consequat aute aliquid.
|
||||||
|
|
||||||
|
Placerat sunt ut delenit aliquid enim hendrerit. Vel rebum et ad dolor luptatum veniam, volutpat soluta euismod facilisis lobortis placerat eos dignissim excepteur reprehenderit facer autem nobis officia facilisis, quis duo augue dolor autem placerat obcaecat ipsum volutpat pariatur. Ullamco elitr exerci at ad nostrud. Suscipit eu dolores sint molestie proident eleifend aute sadipscing anim. Iusto aliquid aliquam.
|
||||||
|
|
||||||
|
Iriure elitr sit nihil sanctus soluta autem et, nisi dolores erat option blandit soluta aliquip nihil placerat cum clita diam duis sunt sunt tation vel consequat deserunt doming gubergren euismod liber ad esse augue aliquyam ad facilisis. Anim dignissim fugiat. Aliquam dolores, obcaecat aliquam nobis quod euismod consectetur iusto wisi aute mollit eirmod eirmod nonumy, qui officia in eros hendrerit nonummy soluta option aliquip. Feugait laboris duis accumsan proident velit stet invidunt kasd consetetur nobis fugiat invidunt cupiditat et excepteur nonumy feugait iusto consectetur eos cillum. Eos at commodo facilisi. Facer facilisi exercitation.
|
||||||
|
|
||||||
|
Soluta takimata labore sunt erat cum option eu deserunt eu consectetur, soluta option cum euismod aliquid illum commodo obcaecat aliquyam erat eleifend blandit, mazim exercitation consequat duis vel imperdiet consetetur ullamco doming. In elit duis. Duo praesent lorem.
|
||||||
|
|
||||||
|
Commodi molestie reprehenderit ipsum enim, zzril vulputate in laboris voluptua nibh suscipit, autem facilisi invidunt commodi iusto mollit aliquip nam culpa consetetur dolore elitr, fugiat luptatum nisl amet elitr facilisis te quod ad kasd wisi reprehenderit invidunt. Facer ut quis nam dignissim. Option suscipit duis dolor consequat takimata te amet accumsan obcaecat autem congue ad obcaecat velit. Imperdiet id laborum sea. Hendrerit nisi eros.
|
||||||
1
internal/organizer/testdata/watcher/source/20241108_160834.txt
vendored
Normal file
1
internal/organizer/testdata/watcher/source/20241108_160834.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
test
|
||||||
69
main.go
Normal file
69
main.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/derfenix/photocatalog/v2/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := loadCfg()
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
app, err := application.NewApplication(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg := &sync.WaitGroup{}
|
||||||
|
|
||||||
|
if err := app.Start(ctx, wg); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCfg() application.Config {
|
||||||
|
cfg := application.Config{}
|
||||||
|
|
||||||
|
flag.StringVar(&cfg.SourceDir, "source", "", "Source directory")
|
||||||
|
flag.StringVar(&cfg.TargetDir, "target", "", "Target directory")
|
||||||
|
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.SkipFullSync, "skip-full-sync", false, "Skip full sync at startup")
|
||||||
|
|
||||||
|
var dirMode string
|
||||||
|
var fileMode string
|
||||||
|
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
|
||||||
|
flag.StringVar(&mode, "mode", "hardlink", "Mode")
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg.Mode = application.Mode(mode)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package manager
|
|
||||||
|
|
||||||
//go:generate stringer -type ManageMode
|
|
||||||
|
|
||||||
type ManageMode uint8
|
|
||||||
|
|
||||||
const (
|
|
||||||
Copy ManageMode = iota
|
|
||||||
Hardlink
|
|
||||||
)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
// Code generated by "stringer -type ManageMode"; DO NOT EDIT.
|
|
||||||
|
|
||||||
package manager
|
|
||||||
|
|
||||||
import "strconv"
|
|
||||||
|
|
||||||
func _() {
|
|
||||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
|
||||||
// Re-run the stringer command to generate them again.
|
|
||||||
var x [1]struct{}
|
|
||||||
_ = x[Copy-0]
|
|
||||||
_ = x[Hardlink-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
const _ManageMode_name = "CopyHardlink"
|
|
||||||
|
|
||||||
var _ManageMode_index = [...]uint8{0, 4, 12}
|
|
||||||
|
|
||||||
func (i ManageMode) String() string {
|
|
||||||
if i >= ManageMode(len(_ManageMode_index)-1) {
|
|
||||||
return "ManageMode(" + strconv.FormatInt(int64(i), 10) + ")"
|
|
||||||
}
|
|
||||||
return _ManageMode_name[_ManageMode_index[i]:_ManageMode_index[i+1]]
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
package manager
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
|
|
||||||
"github.com/derfenix/photocatalog/pkg/metadata"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Manager struct {
|
|
||||||
TargetPath string
|
|
||||||
Mode ManageMode
|
|
||||||
updateMtime bool
|
|
||||||
|
|
||||||
processor func(fp, targetDir string) (string, error)
|
|
||||||
extractorsCache map[string]metadata.Extractor
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewManager(target string, mode ManageMode, updateMtime bool) (*Manager, error) {
|
|
||||||
manager := Manager{
|
|
||||||
TargetPath: target,
|
|
||||||
Mode: mode,
|
|
||||||
processor: nil,
|
|
||||||
updateMtime: updateMtime,
|
|
||||||
}
|
|
||||||
if err := manager.initProcessor(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
manager.extractorsCache = map[string]metadata.Extractor{}
|
|
||||||
return &manager, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) buildTarget(meta *metadata.Metadata) (string, error) {
|
|
||||||
dir, err := m.dirPathFromMeta(meta)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
dirPath := path.Join(m.TargetPath, dir)
|
|
||||||
err = os.MkdirAll(dirPath, os.FileMode(0770))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return dirPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) dirPathFromMeta(meta *metadata.Metadata) (string, error) {
|
|
||||||
t := meta.Time
|
|
||||||
year := t.Format("2006")
|
|
||||||
month := t.Format("01")
|
|
||||||
day := t.Format("02")
|
|
||||||
return path.Join(year, month, day), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) getMetadataExtractor(fp string) metadata.Extractor {
|
|
||||||
switch strings.ToLower(path.Ext(fp)) {
|
|
||||||
case ".jpeg", ".jpg":
|
|
||||||
if _, ok := m.extractorsCache["jpeg"]; !ok {
|
|
||||||
m.extractorsCache["jpeg"] = metadata.NewJpegExtractor()
|
|
||||||
}
|
|
||||||
return m.extractorsCache["jpeg"]
|
|
||||||
default:
|
|
||||||
if _, ok := m.extractorsCache["default"]; !ok {
|
|
||||||
m.extractorsCache["default"] = metadata.NewDefaultExtractor()
|
|
||||||
}
|
|
||||||
return m.extractorsCache["default"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) initProcessor() error {
|
|
||||||
switch m.Mode {
|
|
||||||
case Copy:
|
|
||||||
m.processor = func(fp, targetDir string) (string, error) {
|
|
||||||
_, fn := path.Split(fp)
|
|
||||||
target := path.Join(targetDir, fn)
|
|
||||||
cmd := exec.Command("cp", "-f", "--reflink=auto", fp, target)
|
|
||||||
return target, cmd.Run()
|
|
||||||
}
|
|
||||||
case Hardlink:
|
|
||||||
m.processor = func(fp, targetDir string) (string, error) {
|
|
||||||
_, fn := path.Split(fp)
|
|
||||||
target := path.Join(targetDir, fn)
|
|
||||||
cmd := exec.Command("ln", "-f", fp, target)
|
|
||||||
return target, cmd.Run()
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("failed to init processor: invalid Mode value")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) Manage(fp string) error {
|
|
||||||
if m.processor == nil {
|
|
||||||
return fmt.Errorf("no processor initialized")
|
|
||||||
}
|
|
||||||
// Skip hidden files
|
|
||||||
if strings.HasPrefix(path.Base(fp), ".") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("processing", fp)
|
|
||||||
|
|
||||||
extractor := m.getMetadataExtractor(fp)
|
|
||||||
if extractor == nil {
|
|
||||||
return fmt.Errorf("failed to get md extractor for %s", fp)
|
|
||||||
}
|
|
||||||
|
|
||||||
md, err := extractor.Extract(fp)
|
|
||||||
if err != nil {
|
|
||||||
return errors.WithMessagef(err, "failed to extract md from %s", fp)
|
|
||||||
}
|
|
||||||
|
|
||||||
targetDir, err := m.buildTarget(&md)
|
|
||||||
if err != nil {
|
|
||||||
return errors.WithMessagef(err, "failed to create dir for %s", fp)
|
|
||||||
}
|
|
||||||
|
|
||||||
target, err := m.processor(fp, targetDir)
|
|
||||||
if err != nil {
|
|
||||||
return errors.WithMessagef(err, "failed to process %s to %s", fp, targetDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.updateMtime {
|
|
||||||
err = os.Chtimes(target, time.Now(), md.Time)
|
|
||||||
if err != nil {
|
|
||||||
return errors.WithMessage(err, "failed to update mtime/atime")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.Mode == Hardlink {
|
|
||||||
log.Println(fp, "linked to", target)
|
|
||||||
} else if m.Mode == Copy {
|
|
||||||
log.Println(fp, "copied to", target)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("success")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package metadata
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Metadata contains meta data for the files have to be processed
|
|
||||||
type Metadata struct {
|
|
||||||
Time time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extractor interface for Metadata extractors
|
|
||||||
type Extractor interface {
|
|
||||||
Extract(string) (Metadata, error)
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package metadata
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultTimeLayout = "20060102_150405"
|
|
||||||
|
|
||||||
// DefaultExtractor extract metadata from all file types, not covered by special extractors
|
|
||||||
//
|
|
||||||
// Gets the meta data from the file's name
|
|
||||||
type DefaultExtractor struct {
|
|
||||||
Layout string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewDefaultExtractor returns new DefaultExtractor's instance
|
|
||||||
func NewDefaultExtractor() *DefaultExtractor {
|
|
||||||
return &DefaultExtractor{Layout: defaultTimeLayout}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewDefaultExtractorWithLayout returns DefaultExtractor with custom time layout
|
|
||||||
func NewDefaultExtractorWithLayout(l string) *DefaultExtractor {
|
|
||||||
return &DefaultExtractor{Layout: l}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract returns Metadata from specified filename using its name to parse Time
|
|
||||||
func (d *DefaultExtractor) Extract(fp string) (Metadata, error) {
|
|
||||||
_, fName := path.Split(fp)
|
|
||||||
|
|
||||||
// Remove extension
|
|
||||||
fName = strings.Replace(fName, path.Ext(fName), "", 1)
|
|
||||||
|
|
||||||
// If there more than one photo in one second, cameras append ~N to the end of file name (before extension)
|
|
||||||
if strings.ContainsRune(fName, '~') {
|
|
||||||
fName = fName[:strings.IndexRune(fName, '~')]
|
|
||||||
}
|
|
||||||
|
|
||||||
t, err := time.ParseInLocation(d.Layout, fName, time.Local)
|
|
||||||
if err != nil {
|
|
||||||
return Metadata{}, err
|
|
||||||
}
|
|
||||||
return Metadata{Time: t}, nil
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package metadata
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDefaultExtractor_Extract(t *testing.T) {
|
|
||||||
local, err := time.LoadLocation("Europe/Moscow")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
time.Local = local
|
|
||||||
|
|
||||||
type args struct {
|
|
||||||
fp string
|
|
||||||
}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
args args
|
|
||||||
want Metadata
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Normal",
|
|
||||||
args: args{"20190321_120325.jpg"},
|
|
||||||
want: Metadata{
|
|
||||||
Time: func() time.Time {
|
|
||||||
tm, err := time.Parse(time.RFC3339, "2019-03-21T12:03:25+03:00")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return tm
|
|
||||||
}(),
|
|
||||||
},
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Name with tilda",
|
|
||||||
args: args{"20190321_120325~2.jpg"},
|
|
||||||
want: Metadata{
|
|
||||||
Time: func() time.Time {
|
|
||||||
tm, err := time.Parse(time.RFC3339, "2019-03-21T12:03:25+03:00")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return tm
|
|
||||||
}(),
|
|
||||||
},
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
d := NewDefaultExtractor()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, err := d.Extract(tt.args.fp)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf("Extract() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, tt.want) {
|
|
||||||
t.Errorf("Extract() got = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package metadata
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/rwcarlsen/goexif/exif"
|
|
||||||
)
|
|
||||||
|
|
||||||
// JpegExtractor meta data extractor for the jpeg files
|
|
||||||
type JpegExtractor struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewJpegExtractor returns new JpegExtractor
|
|
||||||
func NewJpegExtractor() *JpegExtractor {
|
|
||||||
return &JpegExtractor{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract returns Metadata from specified jpeg file reading its exif data
|
|
||||||
//
|
|
||||||
// TODO: Fallback to default extractor on exif reading/parsing error
|
|
||||||
func (j *JpegExtractor) Extract(fp string) (Metadata, error) {
|
|
||||||
f, err := os.Open(fp)
|
|
||||||
if err != nil {
|
|
||||||
return Metadata{}, err
|
|
||||||
}
|
|
||||||
x, err := exif.Decode(f)
|
|
||||||
if err != nil {
|
|
||||||
return Metadata{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
time, err := x.DateTime()
|
|
||||||
if err != nil {
|
|
||||||
return Metadata{}, err
|
|
||||||
}
|
|
||||||
meta := Metadata{
|
|
||||||
Time: time,
|
|
||||||
}
|
|
||||||
|
|
||||||
return meta, nil
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user