67 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
9 changed files with 291 additions and 47 deletions

View File

@@ -6,9 +6,10 @@ A simple tool to organize your photos, videos, or other files by copying or hard
## 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. To avoid this, I needed a solution to back up and organize my photos without manual effort.
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.
Dumping everything into one folder wasn't an option — finding anything later would be a nightmare. 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.
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
@@ -28,8 +29,9 @@ sudo cp ${GOPATH}/bin/photocatalog /usr/local/bin/photocatalog
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.
- **hardlink** — Creates hardlinks to the source files, saving disk space. Ideal if the source and target are on the same partition, though file permissions remain linked to the original.
- **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.
@@ -44,6 +46,34 @@ Currently, the timestamp format is not customizable. Let me know if support for
## Usage
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)
```
`-skip-full-sync` and `-watch` are not compatible.
`-source` and `-target` are required.
## Examples
### One-Time Run
#### Copy Files

View File

@@ -43,24 +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 !a.config.SkipFullSync {
if err := org.FullSync(ctx); err != nil {
return fmt.Errorf("full sync: %w", err)
}
}
if a.config.Watch {
if err := org.Watch(ctx, wg); err != nil {
return fmt.Errorf("initialize watch: %w", err)
}
}
if !a.config.SkipFullSync {
if err := org.FullSync(ctx); err != nil {
return fmt.Errorf("full sync: %w", err)
}
}
return nil
}

View File

@@ -14,6 +14,8 @@ const (
ModeMove Mode = "move"
)
var SupportedModes = []Mode{ModeHardlink, ModeSymlink, ModeMove, ModeCopy}
type Config struct {
SourceDir string
TargetDir string
@@ -34,8 +36,8 @@ func (c *Config) Validate() error {
return fmt.Errorf("target dir is required")
}
if !slices.Contains([]Mode{ModeHardlink, ModeSymlink, ModeMove, ModeCopy}, c.Mode) {
return fmt.Errorf("invalid mode %s", c.Mode)
if !slices.Contains(SupportedModes, c.Mode) {
return fmt.Errorf("invalid mode %s, supported modes: %s", c.Mode, SupportedModes)
}
if c.SkipFullSync && !c.Watch {

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

View File

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

View File

@@ -11,6 +11,7 @@ import (
"strconv"
"strings"
"sync"
"syscall"
"github.com/fsnotify/fsnotify"
@@ -18,7 +19,7 @@ import (
)
const (
defaultDirMode = 0o777
defaultDirMode = 0o774
defaultFileMode = 0o644
)
@@ -130,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() {
@@ -149,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():
@@ -180,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
@@ -203,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
@@ -246,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
}
@@ -281,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
}
@@ -295,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
}

65
main.go
View File

@@ -3,8 +3,10 @@ package main
import (
"context"
"flag"
"fmt"
"log"
"os/signal"
"slices"
"strconv"
"sync"
"syscall"
@@ -33,36 +35,61 @@ func main() {
}
func loadCfg() application.Config {
cfg := 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", 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")
var dirMode string
var fileMode string
flag.StringVar(&dirMode, "dir-mode", "0777", "Mode bits for directories can be created while syncing")
flag.StringVar(&fileMode, "file-mode", "0644", "Mode bits for files created while syncing (not applicable for hardlink mode)")
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