73 Commits

Author SHA1 Message Date
f0bef5fa2d Update nix flake 2025-01-07 21:23:09 +03:00
065a56e621 Update nix flake 2025-01-07 21:15:14 +03:00
e71bb647b8 Update nix flake 2025-01-07 21:07:34 +03:00
3ab4a04da0 Update nix flake 2025-01-07 21:07:07 +03:00
ffe9bfe466 Update nix flake 2025-01-07 21:02:49 +03:00
a1cafdfb8b Update nix flake 2025-01-07 20:58:30 +03:00
d20a64206d Update nix flake 2025-01-07 20:58:04 +03:00
7fd6d66a12 Update nix flake 2025-01-07 20:55:31 +03:00
ba2f5da3ac Update nix flake 2025-01-07 20:52:49 +03:00
d8dffed10a Update nix flake 2025-01-07 20:52:17 +03:00
8b3e7bf289 Update nix flake 2025-01-07 20:51:37 +03:00
d5a88cdad6 Update nix flake 2025-01-07 20:49:53 +03:00
f4f10bb8fa Update nix flake 2025-01-07 20:49:35 +03:00
a247c7e77a Update nix flake 2025-01-07 20:48:23 +03:00
a3754d901d Update nix flake 2025-01-07 20:45:36 +03:00
471f17e546 Update nix flake 2025-01-07 20:39:24 +03:00
4f050bcae7 Update nix flake 2025-01-07 20:36:56 +03:00
cb6492dfab Update nix flake 2025-01-07 20:36:37 +03:00
8e9e075e39 Update nix flake 2025-01-07 20:36:20 +03:00
3258452b5b Update nix flake 2025-01-07 20:35:50 +03:00
cfc318e14b Update nix flake 2025-01-07 20:32:54 +03:00
a650096ced Update nix flake 2025-01-07 20:26:49 +03:00
bd32223557 Update nix flake 2025-01-07 20:26:27 +03:00
7793eb9dec Update nix flake 2025-01-07 20:25:57 +03:00
6724d80c15 Update nix flake 2025-01-07 18:41:30 +03:00
b32c74d058 Update nix flake 2025-01-07 18:41:12 +03:00
4b2d705906 Update nix flake 2025-01-07 18:33:11 +03:00
4f19d006d0 Update nix flake 2025-01-07 18:32:10 +03:00
08feb65d86 Update nix flake 2025-01-07 18:27:55 +03:00
66a198cf91 Update nix flake 2025-01-07 18:26:56 +03:00
d2db0a66ad Update nix flake 2025-01-07 18:26:18 +03:00
ae55a0b71a Update nix flake 2025-01-07 18:24:56 +03:00
5e383bb8df Update nix flake 2025-01-07 18:23:56 +03:00
2526c0f0cb Update nix flake 2025-01-07 18:23:05 +03:00
5de7ba17d8 Update nix flake 2025-01-07 18:21:45 +03:00
6a7f2d04f0 Update nix flake 2025-01-07 17:50:16 +03:00
9b9129d5fc Update nix flake 2025-01-07 17:48:52 +03:00
d53b050966 Update nix flake 2025-01-07 17:46:13 +03:00
80b4942d0e Update nix flake 2025-01-07 17:45:18 +03:00
d179279d26 Update nix flake 2025-01-07 17:44:50 +03:00
bce5d42ac1 Update nix flake 2025-01-07 17:42:37 +03:00
87e82d13c8 Update nix flake 2025-01-07 17:42:01 +03:00
882d596aa7 Update nix flake 2025-01-07 17:38:23 +03:00
b0cd8bdefa Update nix flake 2025-01-07 17:36:05 +03:00
a8b2a94b09 Update nix flake 2025-01-07 17:34:28 +03:00
83202087a5 Update nix flake 2025-01-07 17:33:23 +03:00
ee44aaceab Update nix flake 2025-01-07 16:52:52 +03:00
db015efba6 Update nix flake 2025-01-07 16:51:29 +03:00
bd3619137f Add nix flake 2025-01-07 15:35:16 +03:00
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
bdd3eee69f Update README.md 2025-01-04 19:39:59 +03:00
8e315ff557 Update README.md 2025-01-04 19:37:23 +03:00
3ae91523d8 Update README.md 2025-01-04 04:06:18 +03:00
535c790c39 Add pipeline status badge 2025-01-04 04:04:46 +03:00
73aa5c4ac2 Update README.md 2025-01-04 04:01:36 +03:00
077f920986 Create go.yml for workflows 2025-01-04 03:56:18 +03:00
77443e2369 Update README.md 2025-01-04 03:53:32 +03:00
9acb554dd4 Cleanup, add ability to skip initial full sync 2025-01-04 03:31:33 +03:00
e3f8f7b8c8 Update README.md, fix cow 2025-01-04 03:30:55 +03:00
62ca9f2378 Update README.md 2025-01-04 02:31:44 +03:00
f73d612666 Restore COW on copy for linux 2025-01-04 02:28:26 +03:00
e7c515c718 Full application rewrite, v2 version introduced 2025-01-04 01:52:01 +03:00
754aecd69a Full application rewrite 2025-01-04 01:49:48 +03:00
70f32b799c Update README.md 2021-05-21 19:57:40 +03:00
2aaaca0db0 Improve service file and service installation script 2019-09-10 15:00:26 +03:00
39 changed files with 1619 additions and 564 deletions

28
.github/workflows/go.yml vendored Normal file
View 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 ./...

162
README.md
View File

@@ -1,94 +1,130 @@
# Simple photo cataloguer
# Effortless Photo Organizer
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/`.
[![Go](https://github.com/derfenix/photocatalog/actions/workflows/go.yml/badge.svg)](https://github.com/derfenix/photocatalog/actions/workflows/go.yml)
### TL;DR
A simple tool to organize your photos, videos, or other files by copying or hardlinking them into a date-based directory structure like `$ROOT/year/month/day/`.
I have a smartphone, I have a Syncthing ~~uugh... SmartThing~~ and all photos
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.
## 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.
Dumping everything into one folder wasn't an option — finding anything later would be a nightmare.
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
Install the tool via `go`:
## Installing
```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
system or user $PATH, e.g. /usr/local/bin/.
Optionally, copy the binary to a directory in your system or user's `$PATH` (e.g., `/usr/local/bin`):
```bash
sudo cp ${GOPATH}/bin/photocatalog /usr/local/bin/photocatalog
```
## Supported formats
At this moment supported jpeg files with filled exif data or any other
files but with names matching pattern `yyymmdd_HHMMSS.ext`. Such
names format applied by android's camera software (I guess all cams
use this format, fix me if I'm wrong).
## Organization Modes
There is no support for changing names format without modifying source code
at this time.
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 (via FICLONE ioctl call).
- **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.
- **symlink** — Creates symbolic links at the target pointing to the source files.
## Supported Formats
- **JPEG and TIFF files** with valid EXIF metadata.
- Files named in the format `yyyymmdd_HHMMSS.ext` (optionally with suffixes after the timestamp) (e.g., `20230101_123456.jpg`). This format is common in Android cameras and other devices.
If a file lacks EXIF data, the tool falls back to parsing the filename.
Currently, the timestamp format is not customizable. Let me know if support for additional formats is required.
## Usage
### One-shot
#### Copy files (make a COW if fs supports it)
```bash
photocalog -mode copy -target ./photos/ ./sync/photos/*
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)
```
#### Create hardlinks (only withing one disk partition)
```bash
photocalog -mode hardlink -target ./photos/ ./sync/photos/*
```
or
```bash
photocalog -target ./photos/ ./sync/photos/*
`-skip-full-sync` and `-watch` are not compatible.
`-source` and `-target` are required.
## Examples
### One-Time Run
#### Copy Files
```shell
photocatalog -mode copy -target ./photos/ -source ./sync/photos/
```
### Monitor
#### Copy files (make a COW if fs supports it)
```bash
photocalog -mode copy -target ./photos -monitor ./sync/photos/*
#### Create Hardlinks
```shell
photocatalog -mode hardlink -target ./photos/ -source ./sync/photos/
```
#### Create hardlinks (only withing one disk partition)
```bash
photocalog -mode hardlink -target ./photos/ -monitor ./sync/photos/
```
or
```bash
photocalog -target ./photos/ -monitor ./sync/photos/
### Watch Mode
Enable continuous monitoring of a source directory:
#### Copy Files
```shell
photocatalog -mode copy -target ./photos -watch -source ./sync/photos/
```
## Install and run monitor service
#### Create Hardlinks
```shell
photocatalog -mode hardlink -target ./photos/ -watch -source ./sync/photos/
```
### Systemd
```bash
## Running as a Service
### Systemd Setup
Install and configure the service:
```shell
sh ./init/install_service.sh systemd
```
This command will install unit file, create stub for its config and open
editor to allow you edit configuration. Config file stored at
`$HOME/.config/photocatalog`.
Then enable and start service
```bash
This will:
1. Install a systemd unit file.
2. Create a configuration stub at `$HOME/.config/photocatalog`.
3. Open the config file for editing.
Enable and start the service:
```shell
systemctl --user enable --now photocatalog
```
That's all. Now, if any file will be placed in directory, specified as `MONITOR`
in config file, this file will be copied or hardlinked into the target dir
under corresponding sub-dir.
Now, files added to the monitored directory (`MONITOR` in the config) will automatically be organized into the target directory under the corresponding subdirectories.
## FAQ
### Why this tool was created if there is awesome XXX tool?
I had two good reasons:
1. I wanted
2. I can
### Why did you create this tool when awesome tool XXX already exists?
Two reasons:
1. I wanted to.
2. I could.

View 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("ERROR:", err.Error())
})
if a.config.Overwrite {
org = org.WithOverwrite()
}
if a.config.Watch {
if err := org.Watch(ctx, wg); err != nil {
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
}

48
application/config.go Normal file
View File

@@ -0,0 +1,48 @@
package application
import (
"fmt"
"slices"
)
type Mode string
const (
ModeCopy Mode = "copy"
ModeHardlink Mode = "hardlink"
ModeSymlink Mode = "symlink"
ModeMove Mode = "move"
)
var SupportedModes = []Mode{ModeHardlink, ModeSymlink, ModeMove, ModeCopy}
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(SupportedModes, c.Mode) {
return fmt.Errorf("invalid mode %s, supported modes: %s", c.Mode, SupportedModes)
}
if c.SkipFullSync && !c.Watch {
return fmt.Errorf("skip full sync and watch disabled — nothing to do")
}
return nil
}

View File

@@ -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")
}

26
flake.lock generated Normal file
View File

@@ -0,0 +1,26 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1736061677,
"narHash": "sha256-DjkQPnkAfd7eB522PwnkGhOMuT9QVCZspDpJJYyOj60=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "cbd8ec4de4469333c82ff40d057350c30e9f7d36",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-24.11",
"type": "indirect"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

129
flake.nix Normal file
View File

@@ -0,0 +1,129 @@
{
description = "Photo/video organization tool";
inputs.nixpkgs.url = "nixpkgs/nixos-24.11";
outputs = { self, nixpkgs }:
let
lastModifiedDate = self.lastModifiedDate or self.lastModified or "19700101";
version = "2.0.0";
supportedSystems = [ "x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin" ];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; });
in
{
packages = forAllSystems (system:
let
pkgs = nixpkgsFor.${system};
in
{
photocatalog = pkgs.buildGoModule {
pname = "photocatalog";
inherit version;
src = ./.;
vendorHash = "sha256-dj11SRRoB8ZbkcQs75HPI0DpW4c5jzY0N8MD1wKpw+4=";
};
}
);
nixosModules.photocatalog = { config, lib, pkgs, ... }:
with lib;
{
options.photocatalog = {
enable = lib.mkEnableOption "Enable photocatalog";
syncs = mkOption {
default = {};
description = ''
Organization paths with its own params.
'';
example = {
};
type = types.attrsOf (types.submodule ({ name, ... }: {
# freeformType = settingsFormat.type;
options = {
source = mkOption {
type = types.str;
default = name;
description = ''
Source folder path.
'';
};
target = mkOption {
type = types.str;
description = ''
Target folder path.
'';
};
overwrite = mkOption {
type = types.bool;
default = false;
description = ''
Overwrite files, existing in target.
'';
};
watch = mkOption {
type = types.bool;
default = true;
description = ''
Watch for new files in source path.
'';
};
skipFullSync = mkOption {
type = types.bool;
default = false;
description = ''
Do not make full sync.
'';
};
mode = mkOption {
type = types.str;
default = "hardlink";
description = ''
Organization mode, one of [ hardlink symlink move copy ].
'';
};
};
}));
};
};
config = lib.mkIf config.photocatalog.enable {
environment.systemPackages = [ self.packages.${pkgs.system}.photocatalog ];
systemd.user.services = lib.mapAttrs' (name: sync: nameValuePair
("photocatalog${lib.replaceStrings ["/"] ["-"] sync.source}")
{
after = [ "local-fs.target" ];
path = [
self.packages.${pkgs.system}.photocatalog
];
wantedBy = [
"default.target"
];
preStart = if !sync.skipFullSync then ''
mkdir -p ${sync.target}
photocatalog -source ${sync.source} -target ${sync.target} -mode ${sync.mode}
'' else null;
script = "photocatalog -source ${sync.source} -target ${sync.target} -skip-full-sync -watch -mode ${sync.mode}";
serviceConfig = {
Type="simple";
Restart="no";
};
}
) config.photocatalog.syncs;
};
};
devShells = forAllSystems (system:
let
pkgs = nixpkgsFor.${system};
in
{
default = pkgs.mkShell {
buildInputs = with pkgs; [ go gopls gotools go-tools ];
};
});
defaultPackage = forAllSystems (system: self.packages.${system}.photocatalog);
};
}

10
go.mod
View File

@@ -1,10 +1,10 @@
module github.com/derfenix/photocatalog
module github.com/derfenix/photocatalog/v2
go 1.12
go 1.22
require (
github.com/fsnotify/fsnotify v1.4.7
github.com/pkg/errors v0.8.1
github.com/fsnotify/fsnotify v1.8.0
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
View File

@@ -1,8 +1,10 @@
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/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
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/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/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=

View File

@@ -1,19 +1,20 @@
#!/usr/bin/env bash
INIT=${1:-systemd}
INIT="${1:-systemd}"
CONFIG_PATH="${XDG_CONFIG_HOME:-$HOME/.config}"
SETTINGS_PATH="${CONFIG_PATH}/photocatalog"
SYSTEMD_UNIT_PATH="${CONFIG_PATH}/systemd/user/"
if "${INIT}" == "systemd"
if [[ "${INIT}" == "systemd" ]]
then
cp ./init/systemd/photocatalog.service $SYSTEMD_UNIT_PATH/photocatalog.service
if test ! -f "${SETTINGS_PATH}"
then
echo "TARGET=<specify target dir>\nMONITOR=<specify dir to monitor>\nMODE=hardlink" > "${SETTINGS_PATH}"
${EDITOR} "${SETTINGS_PATH}"
echo "TARGET=<specify target dir>\nMONITOR=<specify dir to monitor>\nMODE=hardlink\n" > "${SETTINGS_PATH}"
echo "UPDATECTIME=true\n" >> "${SETTINGS_PATH}"
${EDITOR:-vi} "${SETTINGS_PATH}"
exit $?
else
exit 0

View File

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

View 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
}

View 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
View 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
}

View File

@@ -0,0 +1,9 @@
package metadata
import (
"time"
)
type Metadata struct {
Created time.Time
}

View 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
}

View 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")
}
}

View File

@@ -0,0 +1,38 @@
package modes
import (
"log"
"os"
"sync/atomic"
)
var hardLinkNotSupported = atomic.Bool{}
type HardLink struct {
}
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 os.IsExist(err) {
return nil
}
log.Println("Create hardlink failed:", err.Error())
hardLinkNotSupported.Store(true)
return h.fallBack(sourcePath, targetPath, mode)
}
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

@@ -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")
}
}

View 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
}

View 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")
}
}

View 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
}

View 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)
}
}

View 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.

View 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.

View 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.

View 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.

View File

@@ -0,0 +1,326 @@
package organizer
import (
"context"
"fmt"
"io"
"io/fs"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"github.com/fsnotify/fsnotify"
"github.com/derfenix/photocatalog/v2/internal/metadata"
)
const (
defaultDirMode = 0o774
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))
}
syscall.Sync()
}()
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("add the directory %s to watcher: %w", event.Name, err))
}
continue
}
go func() {
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 {
log.Printf("Process file `%s` failed: %s", path, err.Error())
return nil
}
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 metadatas: %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 o.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 o.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 (o *Organizer) pathExists(path string) bool {
_, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return false
}
o.logErr(fmt.Errorf("pathExists stat %s: %w", path, err))
return true
}
return true
}

View 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
}

View 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.

View File

@@ -0,0 +1 @@
test

96
main.go Normal file
View File

@@ -0,0 +1,96 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os/signal"
"slices"
"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{
DirMode: 0774,
FileMode: 0644,
}
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", 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.Func("dir-mode", "Mode bits for directories can be created while syncing", func(s string) error {
var err error
cfg.DirMode, err = strconv.ParseUint(s, 8, 32)
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 {
if s == "" {
cfg.Mode = application.ModeHardlink
}
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()
// Legacy fallback
if cfg.SourceDir == "" {
log.Println("Source directory not specified. May be using old systemd unit file.")
cfg.SourceDir = flag.Arg(0)
}
return cfg
}

View File

@@ -1,10 +0,0 @@
package manager
//go:generate stringer -type ManageMode
type ManageMode uint8
const (
Copy ManageMode = iota
Hardlink
)

View File

@@ -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]]
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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
}