76 Commits

Author SHA1 Message Date
e66070383f flake: Improve service preStart 2025-01-07 22:42:59 +03:00
438cd3d86f flake: Improve service preStart 2025-01-07 22:40:48 +03:00
0cf9b85787 flake: use enum for mode option 2025-01-07 22:15:08 +03:00
a56c582068 Add overwrite option to systemd service in flake 2025-01-07 22:11:15 +03:00
3be92806b1 Add overwrite option to systemd service in flake 2025-01-07 22:10:31 +03:00
65baf34032 Add overwrite option to systemd service in flake 2025-01-07 22:09:52 +03:00
986d16addf Add overwrite option to systemd service in flake 2025-01-07 22:08:09 +03:00
57eb22615d Cleanup 2025-01-07 21:58:05 +03:00
aa6ba0c07c Update nix flake, bugfix 2025-01-07 21:55:17 +03:00
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
11 changed files with 398 additions and 119 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 ./...

160
README.md
View File

@@ -1,98 +1,130 @@
# Simple photo cataloguer # Effortless Photo Organizer
Just copy/hardlink photos (or video, or any other files) from one place to [![Go](https://github.com/derfenix/photocatalog/actions/workflows/go.yml/badge.svg)](https://github.com/derfenix/photocatalog/actions/workflows/go.yml)
another, separating them in sub-directories like `$ROOT/year/month/day/`.
### 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 ## TL;DR
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.
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 ```bash
go install github.com/derfenix/photocatalog/v2@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 ```bash
sudo cp ${GOPATH}/bin/photocatalog /usr/local/bin/photocatalog sudo cp ${GOPATH}/bin/photocatalog /usr/local/bin/photocatalog
``` ```
## Migrating from v0.* ## Organization Modes
TODO The tool supports the following organization modes:
## Supported formats - **copy** — Copies files to the target directory. If the filesystem supports it, uses Copy-on-Write (COW) for efficiency (via FICLONE ioctl call).
At this moment supported jpeg files with filled exif data or any other - **hardlink** — Creates hardlinks to the source files, saving disk space. Ideal (and usable only) if the source and target are on the same partition,
files but with names matching pattern `yyymmdd_HHMMSS.ext`. Such though file permissions remain linked to the original. Fallback to copy on fail.
names format applied by android's camera software (I guess all cams - **move** — Moves files from the source to the target directory.
use this format, fix me if I'm wrong). - **symlink** — Creates symbolic links at the target pointing to the source files.
There is no support for changing names format without modifying source code ## Supported Formats
at this time.
- **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 ## Usage
### One-shot
#### Copy files (make a COW if fs supports it) Arguments
```bash ```
photocalog -mode copy -target ./photos/ -source ./sync/photos/ -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) `-skip-full-sync` and `-watch` are not compatible.
```bash
photocalog -mode hardlink -target ./photos/ -source ./sync/photos/ `-source` and `-target` are required.
```
or
```bash ## Examples
photocalog -target ./photos/ -source ./sync/photos/*
### One-Time Run
#### Copy Files
```shell
photocatalog -mode copy -target ./photos/ -source ./sync/photos/
``` ```
### Watch mode #### Create Hardlinks
#### Copy files (make a COW if fs supports it) ```shell
```bash photocatalog -mode hardlink -target ./photos/ -source ./sync/photos/
photocalog -mode copy -target ./photos -watch -source ./sync/photos/
``` ```
#### Create hardlinks (only withing one disk partition) ### Watch Mode
```bash
photocalog -mode hardlink -target ./photos/ -watch -source ./sync/photos/ Enable continuous monitoring of a source directory:
```
or #### Copy Files
```bash ```shell
photocalog -target ./photos/ -watch -source ./sync/photos/ 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 ## Running as a Service
```bash
### Systemd Setup
Install and configure the service:
```shell
sh ./init/install_service.sh systemd 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 This will:
```bash
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 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 Now, files added to the monitored directory (`MONITOR` in the config) will automatically be organized into the target directory under the corresponding subdirectories.
under corresponding sub-dir.
## FAQ ## FAQ
### Why this tool was created if there is awesome XXX tool? ### Why did you create this tool when awesome tool XXX already exists?
I had two good reasons: Two reasons:
1. I wish 1. I wanted to.
2. I can 2. I could.

View File

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

View File

@@ -14,6 +14,8 @@ const (
ModeMove Mode = "move" ModeMove Mode = "move"
) )
var SupportedModes = []Mode{ModeHardlink, ModeSymlink, ModeMove, ModeCopy}
type Config struct { type Config struct {
SourceDir string SourceDir string
TargetDir string TargetDir string
@@ -22,6 +24,7 @@ type Config struct {
DirMode uint64 DirMode uint64
FileMode uint64 FileMode uint64
Watch bool Watch bool
SkipFullSync bool
} }
func (c *Config) Validate() error { func (c *Config) Validate() error {
@@ -33,8 +36,12 @@ func (c *Config) Validate() error {
return fmt.Errorf("target dir is required") return fmt.Errorf("target dir is required")
} }
if !slices.Contains([]Mode{ModeHardlink, ModeSymlink, ModeMove, ModeCopy}, c.Mode) { if !slices.Contains(SupportedModes, c.Mode) {
return fmt.Errorf("invalid mode %s", c.Mode) return fmt.Errorf("invalid mode %s, supported modes: %s", c.Mode, SupportedModes)
}
if c.SkipFullSync && !c.Watch {
return fmt.Errorf("skip full sync and watch disabled — nothing to do")
} }
return nil return nil

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.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.enum [ "hardlink" "symlink" "move" "copy" ];
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 = ''
mkdir -p ${sync.target}
'' + (if !sync.skipFullSync then (''
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);
};
}

View File

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

View File

@@ -36,7 +36,7 @@ func (c Copy) PlaceIt(sourcePath, targetPath string, mode os.FileMode) error {
// Try to do a COW. // Try to do a COW.
if runtime.GOOS == "linux" && !cowDisabled.Load() { if runtime.GOOS == "linux" && !cowDisabled.Load() {
if err := unix.IoctlFileClone(int(targetFile.Fd()+1), int(sourceFile.Fd())); err == nil { if err := unix.IoctlFileClone(int(targetFile.Fd()), int(sourceFile.Fd())); err == nil {
return nil return nil
} else { } else {
log.Println(fmt.Errorf("COW attempt for %s failed: %w", targetPath, err)) log.Println(fmt.Errorf("COW attempt for %s failed: %w", targetPath, err))

View File

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

View File

@@ -11,12 +11,18 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"github.com/derfenix/photocatalog/v2/internal/metadata" "github.com/derfenix/photocatalog/v2/internal/metadata"
) )
const (
defaultDirMode = 0o774
defaultFileMode = 0o644
)
type MetaExtractor interface { type MetaExtractor interface {
Extract(_ string, data io.Reader) (metadata.Metadata, error) Extract(_ string, data io.Reader) (metadata.Metadata, error)
} }
@@ -31,8 +37,8 @@ func NewOrganizer(mode Mode, source, target string) *Organizer {
sourceDir: source, sourceDir: source,
targetDir: target, targetDir: target,
dirMode: 0777, dirMode: defaultDirMode,
fileMode: 0644, fileMode: defaultFileMode,
metaExtractors: map[string]MetaExtractor{ metaExtractors: map[string]MetaExtractor{
"": &metadata.Default{}, "": &metadata.Default{},
@@ -125,6 +131,8 @@ func (o *Organizer) Watch(ctx context.Context, wg *sync.WaitGroup) error {
if err := watcher.Close(); err != nil { if err := watcher.Close(); err != nil {
o.logErr(fmt.Errorf("close watcher: %w", err)) o.logErr(fmt.Errorf("close watcher: %w", err))
} }
syscall.Sync()
}() }()
go func() { go func() {
@@ -144,15 +152,17 @@ func (o *Organizer) Watch(ctx context.Context, wg *sync.WaitGroup) error {
// Add new directories to the watcher. // Add new directories to the watcher.
if stat.IsDir() { if stat.IsDir() {
if err := watcher.Add(event.Name); err != nil { if err := watcher.Add(event.Name); err != nil {
o.logErr(fmt.Errorf("watch dir: %w", err)) o.logErr(fmt.Errorf("add the directory %s to watcher: %w", event.Name, err))
} }
continue continue
} }
go func() {
if err := o.processFile(event.Name); err != nil { if err := o.processFile(event.Name); err != nil {
o.logErr(fmt.Errorf("process file %s: %w", event.Name, err)) o.logErr(fmt.Errorf("process file %s: %w", event.Name, err))
} }
}()
} }
case <-ctx.Done(): case <-ctx.Done():
@@ -175,7 +185,9 @@ func (o *Organizer) FullSync(ctx context.Context) error {
} }
if err := o.processFile(path); err != nil { if err := o.processFile(path); err != nil {
return err log.Printf("Process file `%s` failed: %s", path, err.Error())
return nil
} }
return nil return nil
@@ -198,7 +210,7 @@ func (o *Organizer) getMetaForPath(fp string) (metadata.Metadata, error) {
meta, err := o.getMetadata(fp, file) meta, err := o.getMetadata(fp, file)
if err != nil { if err != nil {
return metadata.Metadata{}, fmt.Errorf("get metadata: %w", err) return metadata.Metadata{}, fmt.Errorf("get metadatas: %w", err)
} }
return meta, nil return meta, nil
@@ -241,7 +253,7 @@ func (o *Organizer) processFile(sourcePath string) error {
return fmt.Errorf("build target path: %w", err) return fmt.Errorf("build target path: %w", err)
} }
if pathExists(targetPath) && !o.overwrite { if o.pathExists(targetPath) && !o.overwrite {
return nil return nil
} }
@@ -276,7 +288,7 @@ func (o *Organizer) BuildTargetPath(sourcePath string, meta metadata.Metadata) (
} }
func (o *Organizer) ensureTargetPath(targetPath string) error { func (o *Organizer) ensureTargetPath(targetPath string) error {
if pathExists(targetPath) { if o.pathExists(targetPath) {
return nil return nil
} }
@@ -290,21 +302,27 @@ func (o *Organizer) ensureTargetPath(targetPath string) error {
for _, part := range strings.Split(relPath, string(filepath.Separator)) { for _, part := range strings.Split(relPath, string(filepath.Separator)) {
dir = filepath.Join(dir, part) 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) 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 return nil
} }
func pathExists(path string) bool { func (o *Organizer) pathExists(path string) bool {
_, err := os.Stat(path) _, err := os.Stat(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return false return false
} }
o.logErr(fmt.Errorf("pathExists stat %s: %w", path, err))
return true return true
} }

68
main.go
View File

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