61 Commits

Author SHA1 Message Date
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
9 changed files with 254 additions and 48 deletions

View File

@@ -30,7 +30,8 @@ sudo cp ${GOPATH}/bin/photocatalog /usr/local/bin/photocatalog
The tool supports the following organization modes: 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). - **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 if the source and target are on the same partition, though file permissions remain linked to the original. - **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. - **move** — Moves files from the source to the target directory.
- **symlink** — Creates symbolic links at the target pointing to the source files. - **symlink** — Creates symbolic links at the target pointing to the source files.

View File

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

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

128
flake.nix Normal file
View 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.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 = 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);
};
}

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

@@ -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,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():
@@ -248,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
} }
@@ -283,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
} }
@@ -297,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
} }

71
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"
@@ -33,42 +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 ( flag.Func("dir-mode", "Mode bits for directories can be created while syncing", func(s string) error {
dirMode string var err error
fileMode string
mode string
)
flag.StringVar(&dirMode, "dir-mode", "0777", "Mode bits for directories can be created while syncing") cfg.DirMode, err = strconv.ParseUint(s, 8, 32)
flag.StringVar(&fileMode, "file-mode", "0644", "Mode bits for files created while syncing (not applicable for hardlink mode)") if err != nil {
flag.StringVar(&mode, "mode", "hardlink", "Organizing mode") 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 {
log.Println("Parse -dir-mode failed:", err)
cfg.DirMode = 0o777
}
cfg.FileMode, err = strconv.ParseUint(fileMode, 8, 32)
if err != nil {
log.Println("Parse -file-mode failed:", err)
cfg.DirMode = 0o644
} }
return cfg return cfg