mirror of
https://github.com/derfenix/photocatalog.git
synced 2026-03-11 21:35:34 +03:00
Compare commits
75 Commits
v2.0.0-alp
...
a56c582068
| Author | SHA1 | Date | |
|---|---|---|---|
| a56c582068 | |||
| 3be92806b1 | |||
| 65baf34032 | |||
| 986d16addf | |||
| 57eb22615d | |||
| aa6ba0c07c | |||
| f0bef5fa2d | |||
| 065a56e621 | |||
| e71bb647b8 | |||
| 3ab4a04da0 | |||
| ffe9bfe466 | |||
| a1cafdfb8b | |||
| d20a64206d | |||
| 7fd6d66a12 | |||
| ba2f5da3ac | |||
| d8dffed10a | |||
| 8b3e7bf289 | |||
| d5a88cdad6 | |||
| f4f10bb8fa | |||
| a247c7e77a | |||
| a3754d901d | |||
| 471f17e546 | |||
| 4f050bcae7 | |||
| cb6492dfab | |||
| 8e9e075e39 | |||
| 3258452b5b | |||
| cfc318e14b | |||
| a650096ced | |||
| bd32223557 | |||
| 7793eb9dec | |||
| 6724d80c15 | |||
| b32c74d058 | |||
| 4b2d705906 | |||
| 4f19d006d0 | |||
| 08feb65d86 | |||
| 66a198cf91 | |||
| d2db0a66ad | |||
| ae55a0b71a | |||
| 5e383bb8df | |||
| 2526c0f0cb | |||
| 5de7ba17d8 | |||
| 6a7f2d04f0 | |||
| 9b9129d5fc | |||
| d53b050966 | |||
| 80b4942d0e | |||
| d179279d26 | |||
| bce5d42ac1 | |||
| 87e82d13c8 | |||
| 882d596aa7 | |||
| b0cd8bdefa | |||
| a8b2a94b09 | |||
| 83202087a5 | |||
| ee44aaceab | |||
| db015efba6 | |||
| bd3619137f | |||
| ff22423496 | |||
| 56e826e580 | |||
| e1670934a1 | |||
| 62daa023f5 | |||
| df4f2ebd99 | |||
| bcaeba5ea6 | |||
| 9248a9a84d | |||
| f0a8abb380 | |||
| fb1ab2f8b5 | |||
| bdd3eee69f | |||
| 8e315ff557 | |||
| 3ae91523d8 | |||
| 535c790c39 | |||
| 73aa5c4ac2 | |||
| 077f920986 | |||
| 77443e2369 | |||
| 9acb554dd4 | |||
| e3f8f7b8c8 | |||
| 62ca9f2378 | |||
| f73d612666 |
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 ./...
|
||||
162
README.md
162
README.md
@@ -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/`.
|
||||
[](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@latest
|
||||
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.
|
||||
|
||||
@@ -43,22 +43,24 @@ func (a *Application) Start(ctx context.Context, wg *sync.WaitGroup) error {
|
||||
WithDirMode(os.FileMode(a.config.DirMode)).
|
||||
WithFileMode(os.FileMode(a.config.FileMode)).
|
||||
WithErrLogger(func(err error) {
|
||||
log.Println(err)
|
||||
log.Println("ERROR:", err.Error())
|
||||
})
|
||||
|
||||
if a.config.Overwrite {
|
||||
org = org.WithOverwrite()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if !a.config.SkipFullSync {
|
||||
if err := org.FullSync(ctx); err != nil {
|
||||
return fmt.Errorf("full sync: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ const (
|
||||
ModeMove Mode = "move"
|
||||
)
|
||||
|
||||
var SupportedModes = []Mode{ModeHardlink, ModeSymlink, ModeMove, ModeCopy}
|
||||
|
||||
type Config struct {
|
||||
SourceDir string
|
||||
TargetDir string
|
||||
@@ -22,6 +24,7 @@ type Config struct {
|
||||
DirMode uint64
|
||||
FileMode uint64
|
||||
Watch bool
|
||||
SkipFullSync bool
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
@@ -33,8 +36,12 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("target dir is required")
|
||||
}
|
||||
|
||||
if !slices.Contains([]Mode{ModeHardlink, ModeSymlink, ModeMove, ModeSymlink}, c.Mode) {
|
||||
return fmt.Errorf("invalid mode %s", c.Mode)
|
||||
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
|
||||
|
||||
26
flake.lock
generated
Normal file
26
flake.lock
generated
Normal 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
|
||||
}
|
||||
128
flake.nix
Normal file
128
flake.nix
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
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.4";
|
||||
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, ... }: {
|
||||
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} ${if sync.overwrite then "-overwrite" else ""}
|
||||
'') else null;
|
||||
script = "photocatalog -source ${sync.source} -target ${sync.target} -skip-full-sync -watch -mode ${sync.mode} ${if sync.overwrite then "-overwrite" else ""}";
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -7,5 +7,5 @@ WantedBy=default.target
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/home/%u/.config/photocatalog
|
||||
ExecStart=photocatalog -mode $MODE -target $TARGET -monitor $MONITOR -update_mtime $UPDATECTIME
|
||||
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}
|
||||
|
||||
@@ -5,12 +5,18 @@ import (
|
||||
"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_RDWR|os.O_CREATE, mode)
|
||||
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)
|
||||
}
|
||||
@@ -28,6 +34,17 @@ func (c Copy) PlaceIt(sourcePath, targetPath string, mode os.FileMode) error {
|
||||
_ = 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)
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
package modes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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
|
||||
}
|
||||
|
||||
return fmt.Errorf("create hard link: %w", err)
|
||||
log.Println("Create hardlink failed:", err.Error())
|
||||
hardLinkNotSupported.Store(true)
|
||||
|
||||
return h.fallBack(sourcePath, targetPath, mode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -11,12 +11,18 @@ import (
|
||||
"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)
|
||||
}
|
||||
@@ -31,8 +37,8 @@ func NewOrganizer(mode Mode, source, target string) *Organizer {
|
||||
sourceDir: source,
|
||||
targetDir: target,
|
||||
|
||||
dirMode: 0777,
|
||||
fileMode: 0644,
|
||||
dirMode: defaultDirMode,
|
||||
fileMode: defaultFileMode,
|
||||
|
||||
metaExtractors: map[string]MetaExtractor{
|
||||
"": &metadata.Default{},
|
||||
@@ -125,6 +131,8 @@ func (o *Organizer) Watch(ctx context.Context, wg *sync.WaitGroup) error {
|
||||
if err := watcher.Close(); err != nil {
|
||||
o.logErr(fmt.Errorf("close watcher: %w", err))
|
||||
}
|
||||
|
||||
syscall.Sync()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
@@ -144,15 +152,17 @@ func (o *Organizer) Watch(ctx context.Context, wg *sync.WaitGroup) error {
|
||||
// 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))
|
||||
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():
|
||||
@@ -175,7 +185,9 @@ func (o *Organizer) FullSync(ctx context.Context) error {
|
||||
}
|
||||
|
||||
if err := o.processFile(path); err != nil {
|
||||
return err
|
||||
log.Printf("Process file `%s` failed: %s", path, err.Error())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -198,7 +210,7 @@ func (o *Organizer) getMetaForPath(fp string) (metadata.Metadata, error) {
|
||||
|
||||
meta, err := o.getMetadata(fp, file)
|
||||
if err != nil {
|
||||
return metadata.Metadata{}, fmt.Errorf("get metadata: %w", err)
|
||||
return metadata.Metadata{}, fmt.Errorf("get metadatas: %w", err)
|
||||
}
|
||||
|
||||
return meta, nil
|
||||
@@ -241,7 +253,7 @@ func (o *Organizer) processFile(sourcePath string) error {
|
||||
return fmt.Errorf("build target path: %w", err)
|
||||
}
|
||||
|
||||
if pathExists(targetPath) && !o.overwrite {
|
||||
if o.pathExists(targetPath) && !o.overwrite {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -268,6 +280,7 @@ func (o *Organizer) BuildTargetPath(sourcePath string, meta metadata.Metadata) (
|
||||
o.targetDir,
|
||||
strconv.Itoa(meta.Created.Year()),
|
||||
strconv.Itoa(int(meta.Created.Month())),
|
||||
strconv.Itoa(meta.Created.Day()),
|
||||
sourcePath,
|
||||
)
|
||||
|
||||
@@ -275,7 +288,7 @@ func (o *Organizer) BuildTargetPath(sourcePath string, meta metadata.Metadata) (
|
||||
}
|
||||
|
||||
func (o *Organizer) ensureTargetPath(targetPath string) error {
|
||||
if pathExists(targetPath) {
|
||||
if o.pathExists(targetPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -289,21 +302,27 @@ func (o *Organizer) ensureTargetPath(targetPath string) error {
|
||||
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) {
|
||||
if err := os.Mkdir(dir, os.ModePerm); err != nil && !os.IsExist(err) {
|
||||
return fmt.Errorf("create target directory path at %s: %w", dir, err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(dir, os.ModePerm&o.dirMode); err != nil {
|
||||
return fmt.Errorf("chmod directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func pathExists(path string) bool {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
68
main.go
68
main.go
@@ -3,8 +3,10 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/signal"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -34,42 +36,60 @@ func main() {
|
||||
|
||||
func loadCfg() application.Config {
|
||||
cfg := application.Config{
|
||||
SourceDir: "",
|
||||
TargetDir: "",
|
||||
Mode: "",
|
||||
Overwrite: false,
|
||||
DirMode: 0,
|
||||
FileMode: 0,
|
||||
Watch: false,
|
||||
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")
|
||||
|
||||
var dirMode string
|
||||
var fileMode string
|
||||
flag.StringVar(&dirMode, "dirmode", "0777", "Mode bits for directories can be created while syncing")
|
||||
flag.StringVar(&fileMode, "filemode", "0644", "Mode bits for files created while syncing (not applicable for hardlink mode)")
|
||||
flag.Func("dir-mode", "Mode bits for directories can be created while syncing", func(s string) error {
|
||||
var err error
|
||||
|
||||
var mode string
|
||||
flag.StringVar(&mode, "mode", "hardlink", "Mode")
|
||||
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()
|
||||
|
||||
cfg.Mode = application.Mode(mode)
|
||||
// Legacy fallback
|
||||
if cfg.SourceDir == "" {
|
||||
log.Println("Source directory not specified. May be using old systemd unit file.")
|
||||
|
||||
var err error
|
||||
|
||||
cfg.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
|
||||
cfg.SourceDir = flag.Arg(0)
|
||||
}
|
||||
|
||||
return cfg
|
||||
|
||||
Reference in New Issue
Block a user