mirror of
https://github.com/derfenix/photocatalog.git
synced 2026-03-11 21:35:34 +03:00
Compare commits
60 Commits
3ae91523d8
...
v2.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
161
README.md
161
README.md
@@ -1,111 +1,130 @@
|
|||||||
[](https://github.com/derfenix/photocatalog/actions/workflows/go.yml)
|
|
||||||
|
|
||||||
# Effortless Photo Organizer
|
# Effortless Photo Organizer
|
||||||
|
|
||||||
Just copy/hardlink photos (or video, or any other files) from one place to
|
[](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 use a smartphone along with Syncthing to seamlessly sync all my photos to my PC without any manual effort. However, there's a catch: I can't keep all my photos in the synced folder indefinitely. If I clear my phone's memory, the photos on my PC get deleted as well. To avoid this, I need to remember to copy the files to another location before cleaning up my phone.
|
## TL;DR
|
||||||
|
|
||||||
Simply dumping all my photos into one folder isn't a solution either — finding anything later would be a nightmare, and a folder with thousands of unsorted photos is far from ideal.
|
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 address these issues, I created this tool in just one evening. Its primary purpose is to copy (or create hardlinks for) files from one location to another, while organizing them into a simple, date-based directory structure.
|
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.
|
||||||
|
|
||||||
This tool was built for personal use and has been serving me well for quite some time without any problems. However, if you encounter any issues, feel free to report them — I’d be happy to help.
|
## 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:
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
Next organization modes supported:
|
## Supported Formats
|
||||||
|
|
||||||
- **copy** — copy files to target root. Make COW (using syscall) if FS supports it.
|
|
||||||
- **hardlink** — create hardlink to the source file instead of copying.
|
|
||||||
The best choice if source and target are in same partition for compatibility
|
|
||||||
and resource usage, but we can't chmod target files, because of original file mode will
|
|
||||||
be changed too.
|
|
||||||
- **move** — moves original files to new place.
|
|
||||||
- **symlink** — create a symlink at the target for the source files.
|
|
||||||
|
|
||||||
## Supported formats
|
- **JPEG and TIFF files** with valid EXIF metadata.
|
||||||
At this moment supported jpeg and tiff files with filled exif data and any other
|
- 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.
|
||||||
files but with names matching pattern `yyymmdd_HHMMSS.ext` with optional suffixes after a timestamp.
|
|
||||||
Such names format applied by the Android's camera software (I guess all cams
|
|
||||||
use this format, fix me if I'm wrong).
|
|
||||||
|
|
||||||
Jpeg/Tiff files without modification date if exif will be fallen back to the name parsing.
|
If a file lacks EXIF data, the tool falls back to parsing the filename.
|
||||||
|
|
||||||
No able to change names format without modifying source code for now. Just because
|
Currently, the timestamp format is not customizable. Let me know if support for additional formats is required.
|
||||||
I have reasons to believe that this format is the most popular for the application use cases.
|
|
||||||
But let me know if you need different timestamp formats support.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
### One-shot
|
|
||||||
#### Copy files
|
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
|
`-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
|
```shell
|
||||||
```bash
|
photocatalog -mode hardlink -target ./photos/ -source ./sync/photos/
|
||||||
photocalog -mode copy -target ./photos -watch -source ./sync/photos/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Create hardlinks
|
### 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.
|
||||||
|
|
||||||
|
|||||||
@@ -43,24 +43,24 @@ func (a *Application) Start(ctx context.Context, wg *sync.WaitGroup) error {
|
|||||||
WithDirMode(os.FileMode(a.config.DirMode)).
|
WithDirMode(os.FileMode(a.config.DirMode)).
|
||||||
WithFileMode(os.FileMode(a.config.FileMode)).
|
WithFileMode(os.FileMode(a.config.FileMode)).
|
||||||
WithErrLogger(func(err error) {
|
WithErrLogger(func(err error) {
|
||||||
log.Println(err)
|
log.Println("ERROR:", err.Error())
|
||||||
})
|
})
|
||||||
|
|
||||||
if a.config.Overwrite {
|
if a.config.Overwrite {
|
||||||
org = org.WithOverwrite()
|
org = org.WithOverwrite()
|
||||||
}
|
}
|
||||||
|
|
||||||
if !a.config.SkipFullSync {
|
|
||||||
if err := org.FullSync(ctx); err != nil {
|
|
||||||
return fmt.Errorf("full sync: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.config.Watch {
|
if a.config.Watch {
|
||||||
if err := org.Watch(ctx, wg); err != nil {
|
if err := org.Watch(ctx, wg); err != nil {
|
||||||
return fmt.Errorf("initialize watch: %w", err)
|
return fmt.Errorf("initialize watch: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !a.config.SkipFullSync {
|
||||||
|
if err := org.FullSync(ctx); err != nil {
|
||||||
|
return fmt.Errorf("full sync: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const (
|
|||||||
ModeMove Mode = "move"
|
ModeMove Mode = "move"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var SupportedModes = []Mode{ModeHardlink, ModeSymlink, ModeMove, ModeCopy}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
SourceDir string
|
SourceDir string
|
||||||
TargetDir string
|
TargetDir string
|
||||||
@@ -34,8 +36,8 @@ func (c *Config) Validate() error {
|
|||||||
return fmt.Errorf("target dir is required")
|
return fmt.Errorf("target dir is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains([]Mode{ModeHardlink, ModeSymlink, ModeMove, ModeCopy}, c.Mode) {
|
if !slices.Contains(SupportedModes, c.Mode) {
|
||||||
return fmt.Errorf("invalid mode %s", c.Mode)
|
return fmt.Errorf("invalid mode %s, supported modes: %s", c.Mode, SupportedModes)
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.SkipFullSync && !c.Watch {
|
if c.SkipFullSync && !c.Watch {
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
129
flake.nix
Normal file
129
flake.nix
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultDirMode = 0o777
|
defaultDirMode = 0o774
|
||||||
defaultFileMode = 0o644
|
defaultFileMode = 0o644
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -130,6 +131,8 @@ func (o *Organizer) Watch(ctx context.Context, wg *sync.WaitGroup) error {
|
|||||||
if err := watcher.Close(); err != nil {
|
if err := watcher.Close(); err != nil {
|
||||||
o.logErr(fmt.Errorf("close watcher: %w", err))
|
o.logErr(fmt.Errorf("close watcher: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syscall.Sync()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -149,15 +152,17 @@ func (o *Organizer) Watch(ctx context.Context, wg *sync.WaitGroup) error {
|
|||||||
// Add new directories to the watcher.
|
// Add new directories to the watcher.
|
||||||
if stat.IsDir() {
|
if stat.IsDir() {
|
||||||
if err := watcher.Add(event.Name); err != nil {
|
if err := watcher.Add(event.Name); err != nil {
|
||||||
o.logErr(fmt.Errorf("watch dir: %w", err))
|
o.logErr(fmt.Errorf("add the directory %s to watcher: %w", event.Name, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := o.processFile(event.Name); err != nil {
|
go func() {
|
||||||
o.logErr(fmt.Errorf("process file %s: %w", event.Name, err))
|
if err := o.processFile(event.Name); err != nil {
|
||||||
}
|
o.logErr(fmt.Errorf("process file %s: %w", event.Name, err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -180,7 +185,9 @@ func (o *Organizer) FullSync(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := o.processFile(path); err != nil {
|
if err := o.processFile(path); err != nil {
|
||||||
return err
|
log.Printf("Process file `%s` failed: %s", path, err.Error())
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -203,7 +210,7 @@ func (o *Organizer) getMetaForPath(fp string) (metadata.Metadata, error) {
|
|||||||
|
|
||||||
meta, err := o.getMetadata(fp, file)
|
meta, err := o.getMetadata(fp, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return metadata.Metadata{}, fmt.Errorf("get metadata: %w", err)
|
return metadata.Metadata{}, fmt.Errorf("get metadatas: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return meta, nil
|
return meta, nil
|
||||||
@@ -246,7 +253,7 @@ func (o *Organizer) processFile(sourcePath string) error {
|
|||||||
return fmt.Errorf("build target path: %w", err)
|
return fmt.Errorf("build target path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if pathExists(targetPath) && !o.overwrite {
|
if o.pathExists(targetPath) && !o.overwrite {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +288,7 @@ func (o *Organizer) BuildTargetPath(sourcePath string, meta metadata.Metadata) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (o *Organizer) ensureTargetPath(targetPath string) error {
|
func (o *Organizer) ensureTargetPath(targetPath string) error {
|
||||||
if pathExists(targetPath) {
|
if o.pathExists(targetPath) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,13 +310,15 @@ func (o *Organizer) ensureTargetPath(targetPath string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func pathExists(path string) bool {
|
func (o *Organizer) pathExists(path string) bool {
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
o.logErr(fmt.Errorf("pathExists stat %s: %w", path, err))
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
65
main.go
65
main.go
@@ -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"
|
||||||
@@ -33,36 +35,61 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadCfg() application.Config {
|
func loadCfg() application.Config {
|
||||||
cfg := application.Config{}
|
cfg := application.Config{
|
||||||
|
DirMode: 0774,
|
||||||
|
FileMode: 0644,
|
||||||
|
}
|
||||||
|
|
||||||
flag.StringVar(&cfg.SourceDir, "source", "", "Source directory")
|
flag.StringVar(&cfg.SourceDir, "source", "", "Source directory")
|
||||||
flag.StringVar(&cfg.TargetDir, "target", "", "Target directory")
|
flag.StringVar(&cfg.TargetDir, "target", "", "Target directory")
|
||||||
flag.BoolVar(&cfg.Overwrite, "overwrite", false, "Overwrite existing files")
|
flag.BoolVar(&cfg.Overwrite, "overwrite", false, "Overwrite existing files")
|
||||||
flag.BoolVar(&cfg.Watch, "watch", true, "Watch for changes in the source directory")
|
flag.BoolVar(&cfg.Watch, "watch", false, "Watch for changes in the source directory")
|
||||||
|
flag.BoolVar(&cfg.Watch, "monitor", false, "Watch for changes in the source directory") // Legacy option
|
||||||
flag.BoolVar(&cfg.SkipFullSync, "skip-full-sync", false, "Skip full sync at startup")
|
flag.BoolVar(&cfg.SkipFullSync, "skip-full-sync", false, "Skip full sync at startup")
|
||||||
|
|
||||||
var dirMode string
|
flag.Func("dir-mode", "Mode bits for directories can be created while syncing", func(s string) error {
|
||||||
var fileMode string
|
var err error
|
||||||
flag.StringVar(&dirMode, "dir-mode", "0777", "Mode bits for directories can be created while syncing")
|
|
||||||
flag.StringVar(&fileMode, "file-mode", "0644", "Mode bits for files created while syncing (not applicable for hardlink mode)")
|
|
||||||
|
|
||||||
var mode string
|
cfg.DirMode, err = strconv.ParseUint(s, 8, 32)
|
||||||
flag.StringVar(&mode, "mode", "hardlink", "Mode")
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
flag.Func("file-mode", "Mode bits for files created while syncing (not applicable for hardlink mode)", func(s string) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
cfg.FileMode, err = strconv.ParseUint(s, 8, 32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
flag.Func("mode", "Organizing mode", func(s string) error {
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user