The preview/apply flow on the Organize tab did not work as expected, so it is removed along with its backend: the stored per-pair preview, the /organize/preview, /organize/plan and /organize/apply endpoints and their UI and types. The File naming card stays, and renaming now happens only through "Organize automatically" during scans, which keeps the underlying planning/moving code (now internal), undo, crash recovery and dry run. The rename-folders warning is unchanged. Tests now drive the kept planning and moving code directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
622 lines
17 KiB
Go
622 lines
17 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/cdnutter/playlink/internal/fsops"
|
|
"github.com/cdnutter/playlink/internal/store"
|
|
"github.com/cdnutter/playlink/internal/tags"
|
|
)
|
|
|
|
// The organizer renames audio files from their tags into one layout, by
|
|
// default Artist/Album/01 Title.ext. It only moves files within a library
|
|
// (same inode, so hardlinks survive), never rewrites them, and every move is
|
|
// an operation that can be undone.
|
|
|
|
const DefaultOrganizeTemplate = "{artist}/{album}/{track} {title}"
|
|
|
|
var organizeTokens = []string{"artist", "track_artist", "album", "track", "title", "disc", "year", "genre"}
|
|
|
|
var tokenRe = regexp.MustCompile(`\{([a-z_]+)\}`)
|
|
|
|
// ValidateTemplate checks a naming template.
|
|
func ValidateTemplate(t string) error {
|
|
if strings.TrimSpace(t) == "" {
|
|
return errors.New("the naming pattern is empty")
|
|
}
|
|
if !strings.Contains(t, "{title}") {
|
|
return errors.New("the naming pattern must contain {title}")
|
|
}
|
|
if strings.HasPrefix(t, "/") || strings.Contains(t, "..") || strings.Contains(t, "\\") {
|
|
return errors.New("the naming pattern must be a relative path without .. or backslashes")
|
|
}
|
|
known := map[string]bool{}
|
|
for _, k := range organizeTokens {
|
|
known[k] = true
|
|
}
|
|
for _, m := range tokenRe.FindAllStringSubmatch(t, -1) {
|
|
if !known[m[1]] {
|
|
return fmt.Errorf("unknown token {%s}; use %s", m[1], "{"+strings.Join(organizeTokens, "}, {")+"}")
|
|
}
|
|
}
|
|
for _, part := range strings.Split(t, "/") {
|
|
if strings.TrimSpace(part) == "" {
|
|
return errors.New("the naming pattern has an empty folder name")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeOrganize(p *store.Pair) error {
|
|
if strings.TrimSpace(p.OrganizeTemplate) == "" {
|
|
p.OrganizeTemplate = DefaultOrganizeTemplate
|
|
}
|
|
p.OrganizeTemplate = strings.TrimSpace(p.OrganizeTemplate)
|
|
if err := ValidateTemplate(p.OrganizeTemplate); err != nil {
|
|
return err
|
|
}
|
|
switch p.OrganizeMultiDisc {
|
|
case "":
|
|
p.OrganizeMultiDisc = "prefix"
|
|
case "prefix", "folder", "none":
|
|
default:
|
|
return errors.New("multi-disc style must be prefix, folder or none")
|
|
}
|
|
switch p.AutoOrganize {
|
|
case "":
|
|
p.AutoOrganize = "off"
|
|
case "off", "slave", "master", "both":
|
|
default:
|
|
return errors.New("automatic organizing must be off, slave, master or both")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- name cleanup ----
|
|
|
|
var unsafeChars = strings.NewReplacer(
|
|
"/", "-", "\\", "-", "|", "-", ":", " -", "*", "", "?", "", "\"", "'", "<", "", ">", "",
|
|
)
|
|
|
|
var spaces = regexp.MustCompile(`\s+`)
|
|
|
|
// maxComponent keeps names well below the 255-byte filesystem limit.
|
|
const maxComponent = 150
|
|
|
|
// cleanComponent makes a tag value safe as a file or folder name, including
|
|
// on Windows/SMB shares: path separators and reserved characters are
|
|
// replaced, control characters dropped, and leading/trailing dots and spaces
|
|
// removed (a leading dot would hide the file).
|
|
func cleanComponent(s string) string {
|
|
s = unsafeChars.Replace(s)
|
|
s = strings.Map(func(r rune) rune {
|
|
if unicode.IsControl(r) {
|
|
return -1
|
|
}
|
|
return r
|
|
}, s)
|
|
s = strings.TrimSpace(spaces.ReplaceAllString(s, " "))
|
|
s = strings.Trim(s, ". ")
|
|
for len(s) > maxComponent {
|
|
_, size := utf8.DecodeLastRuneInString(s)
|
|
s = strings.TrimRight(s[:len(s)-size], ". ")
|
|
}
|
|
return s
|
|
}
|
|
|
|
func leadingNumber(s string) int {
|
|
s = strings.TrimSpace(s)
|
|
end := 0
|
|
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
|
|
end++
|
|
}
|
|
n, _ := strconv.Atoi(s[:end])
|
|
return n
|
|
}
|
|
|
|
// renderName builds the organized path for a file from its tags. It returns
|
|
// the missing tag when a required one is empty.
|
|
func renderName(tpl, multiDisc, ext string, f map[string]string) (string, string) {
|
|
artist := f[tags.AlbumArtist]
|
|
if strings.TrimSpace(artist) == "" {
|
|
artist = f[tags.Artist]
|
|
}
|
|
track := leadingNumber(f[tags.Track])
|
|
total := leadingNumber(f[tags.TrackTotal])
|
|
disc, discTotal := leadingNumber(f[tags.Disc]), leadingNumber(f[tags.DiscTotal])
|
|
multi := discTotal > 1 || disc > 1
|
|
trackStr := ""
|
|
if track > 0 {
|
|
width := 2
|
|
if total >= 100 || track >= 100 {
|
|
width = 3
|
|
}
|
|
trackStr = fmt.Sprintf("%0*d", width, track)
|
|
if multi && multiDisc == "prefix" && disc > 0 {
|
|
trackStr = fmt.Sprintf("%d-%s", disc, trackStr)
|
|
}
|
|
}
|
|
year := ""
|
|
if d := strings.TrimSpace(f[tags.Date]); len(d) >= 4 && leadingNumber(d[:4]) > 0 {
|
|
year = d[:4]
|
|
}
|
|
values := map[string]string{
|
|
"artist": artist, "track_artist": f[tags.Artist], "album": f[tags.Album], "track": trackStr,
|
|
"title": f[tags.Title], "disc": strconv.Itoa(disc), "year": year, "genre": f[tags.Genre],
|
|
}
|
|
if disc == 0 {
|
|
values["disc"] = ""
|
|
}
|
|
optional := map[string]bool{"track": true}
|
|
parts := strings.Split(tpl, "/")
|
|
for i, part := range parts {
|
|
missing := ""
|
|
out := tokenRe.ReplaceAllStringFunc(part, func(tok string) string {
|
|
name := tok[1 : len(tok)-1]
|
|
v := cleanComponent(values[name])
|
|
if v == "" && !optional[name] && missing == "" {
|
|
missing = name
|
|
}
|
|
return v
|
|
})
|
|
if missing != "" {
|
|
if missing == "artist" {
|
|
missing = "artist or album artist"
|
|
}
|
|
return "", missing
|
|
}
|
|
// A missing optional token must not leave "- " or double spaces.
|
|
out = strings.Trim(spaces.ReplaceAllString(out, " "), " -.")
|
|
if out == "" {
|
|
return "", "tags for " + part
|
|
}
|
|
parts[i] = out
|
|
}
|
|
if multi && multiDisc == "folder" && disc > 0 {
|
|
last := len(parts) - 1
|
|
parts = append(parts[:last], fmt.Sprintf("Disc %d", disc), parts[last])
|
|
}
|
|
return filepath.Join(parts...) + strings.ToLower(ext), ""
|
|
}
|
|
|
|
// ---- tag cache ----
|
|
|
|
type cachedTags struct {
|
|
size, mtime int64
|
|
fields map[string]string
|
|
err string
|
|
}
|
|
|
|
type tagCache struct {
|
|
mu sync.Mutex
|
|
m map[string]cachedTags
|
|
}
|
|
|
|
func (c *tagCache) get(ctx context.Context, t tags.Tools, abs string, in fsops.Info) (map[string]string, error) {
|
|
c.mu.Lock()
|
|
if c.m == nil {
|
|
c.m = map[string]cachedTags{}
|
|
}
|
|
if v, ok := c.m[abs]; ok && v.size == in.Size && v.mtime == in.MtimeNs {
|
|
c.mu.Unlock()
|
|
if v.err != "" {
|
|
return nil, errors.New(v.err)
|
|
}
|
|
return v.fields, nil
|
|
}
|
|
c.mu.Unlock()
|
|
f, err := t.Read(ctx, abs)
|
|
v := cachedTags{size: in.Size, mtime: in.MtimeNs, fields: f.Fields}
|
|
if err != nil {
|
|
v.err = err.Error()
|
|
}
|
|
c.mu.Lock()
|
|
c.m[abs] = v
|
|
c.mu.Unlock()
|
|
return v.fields, err
|
|
}
|
|
|
|
// ---- planning ----
|
|
|
|
// organizeItem is one file the organizer looked at.
|
|
type organizeItem struct {
|
|
Side string
|
|
From string
|
|
To string
|
|
Move bool
|
|
Reason string // why it is skipped
|
|
Sidecars []string // e.g. lyrics files moving along
|
|
|
|
ino uint64
|
|
size, mtime int64
|
|
}
|
|
|
|
// organizePlan is what organizing would do right now.
|
|
type organizePlan struct {
|
|
Scanned int
|
|
Conforming int
|
|
Moves int
|
|
Skips map[string]int
|
|
Items []organizeItem
|
|
}
|
|
|
|
// sidecarExts move together with the song of the same name.
|
|
var sidecarExts = []string{".lrc"}
|
|
|
|
// folderArt follows an album folder whose songs all moved to one folder.
|
|
var folderArt = map[string]bool{"cover.jpg": true, "cover.png": true, "folder.jpg": true, "folder.png": true,
|
|
"front.jpg": true, "front.png": true, "albumart.jpg": true, "albumartsmall.jpg": true}
|
|
|
|
func sidesOf(s string) []string {
|
|
switch s {
|
|
case "both":
|
|
return []string{SideSlave, SideMaster}
|
|
case SideMaster, SideSlave:
|
|
return []string{s}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []string) (*organizePlan, error) {
|
|
plan := &organizePlan{Skips: map[string]int{}}
|
|
for _, side := range sides {
|
|
root, _ := sideRoot(l, side)
|
|
files := map[string]fsops.Info{}
|
|
if _, err := walk(ctx, l, root, files); err != nil {
|
|
return nil, err
|
|
}
|
|
j.phase("Reading tags ("+side+")", int64(len(files)))
|
|
claimed := map[string]string{} // lower(target) -> source
|
|
for _, rel := range sortedKeys(files) {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
j.tick()
|
|
plan.Scanned++
|
|
in := files[rel]
|
|
item := organizeItem{Side: side, From: rel, ino: in.Ino, size: in.Size, mtime: in.MtimeNs}
|
|
if _, ok := tags.FormatOf(rel); !ok {
|
|
item.Reason = "file type without editable tags"
|
|
} else if fields, err := e.tagCache.get(ctx, e.tagTools, filepath.Join(root.Path, rel), in); err != nil {
|
|
item.Reason = "tags could not be read"
|
|
} else if to, missing := renderName(l.pair.OrganizeTemplate, l.pair.OrganizeMultiDisc, filepath.Ext(rel), fields); missing != "" {
|
|
item.Reason = "missing " + missing + " tag"
|
|
} else if to == rel {
|
|
plan.Conforming++
|
|
continue
|
|
} else {
|
|
item.To = to
|
|
key := strings.ToLower(to)
|
|
switch {
|
|
case claimed[key] != "":
|
|
item.Reason = "another file gets the same name (" + claimed[key] + ")"
|
|
case targetTaken(root, rel, to):
|
|
item.Reason = "a different file already has that name"
|
|
default:
|
|
claimed[key] = rel
|
|
item.Move = true
|
|
item.Sidecars = sidecarsOf(root, rel)
|
|
}
|
|
}
|
|
if item.Move {
|
|
plan.Moves++
|
|
} else {
|
|
plan.Skips[item.Reason]++
|
|
}
|
|
plan.Items = append(plan.Items, item)
|
|
}
|
|
}
|
|
return plan, nil
|
|
}
|
|
|
|
// targetTaken reports whether moving rel to to would overwrite another file.
|
|
// A case-only rename onto itself is fine.
|
|
func targetTaken(root fsops.Root, rel, to string) bool {
|
|
abs, err := root.Resolve(to)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
ti, err := os.Lstat(abs)
|
|
if err != nil {
|
|
return !errors.Is(err, fs.ErrNotExist)
|
|
}
|
|
if strings.EqualFold(rel, to) {
|
|
si, err := os.Lstat(filepath.Join(root.Path, rel))
|
|
return err != nil || !os.SameFile(si, ti)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func sidecarsOf(root fsops.Root, rel string) []string {
|
|
var out []string
|
|
stem := strings.TrimSuffix(rel, filepath.Ext(rel))
|
|
for _, ext := range sidecarExts {
|
|
if fi, err := os.Lstat(filepath.Join(root.Path, stem+ext)); err == nil && fi.Mode().IsRegular() {
|
|
out = append(out, stem+ext)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
type moveDetail struct {
|
|
Side string `json:"side"`
|
|
Sidecars [][2]string `json:"sidecars,omitempty"` // [from, to]
|
|
Art bool `json:"art,omitempty"` // a folder art file following its album
|
|
}
|
|
|
|
func (e *Engine) applyOrganize(ctx context.Context, j *Job, l *libs, items []organizeItem, trigger string) error {
|
|
j.phase("Renaming files", int64(len(items)))
|
|
// Remember where each source folder's songs went, to move cover art along.
|
|
type dirMove struct {
|
|
targets map[string]bool
|
|
side string
|
|
}
|
|
dirs := map[string]*dirMove{}
|
|
for _, it := range items {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
if err := e.moveOne(ctx, l, it, trigger); err != nil {
|
|
j.skip(it.From + ": " + err.Error())
|
|
} else {
|
|
j.count("renamed")
|
|
key := it.Side + "\x00" + filepath.Dir(it.From)
|
|
if dirs[key] == nil {
|
|
dirs[key] = &dirMove{targets: map[string]bool{}, side: it.Side}
|
|
}
|
|
dirs[key].targets[filepath.Dir(it.To)] = true
|
|
}
|
|
j.tick()
|
|
}
|
|
if e.st.DryRun(ctx) {
|
|
return nil
|
|
}
|
|
for key, dm := range dirs {
|
|
from := strings.SplitN(key, "\x00", 2)[1]
|
|
root, _ := sideRoot(l, dm.side)
|
|
if len(dm.targets) == 1 {
|
|
for to := range dm.targets {
|
|
e.moveFolderArt(ctx, l, root, dm.side, from, to, trigger)
|
|
}
|
|
}
|
|
if abs, err := root.Resolve(filepath.Join(from, "x")); err == nil {
|
|
pruneEmpty(filepath.Dir(abs), root.Path)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// moveFolderArt moves cover art out of a folder that has no songs left.
|
|
func (e *Engine) moveFolderArt(ctx context.Context, l *libs, root fsops.Root, side, from, to, trigger string) {
|
|
if from == to {
|
|
return
|
|
}
|
|
src := filepath.Join(root.Path, from)
|
|
ents, err := os.ReadDir(src)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, en := range ents {
|
|
if !en.IsDir() && l.hasExt(en.Name()) {
|
|
return // songs remain: the art still belongs here
|
|
}
|
|
}
|
|
for _, en := range ents {
|
|
if !en.Type().IsRegular() || !folderArt[strings.ToLower(en.Name())] {
|
|
continue
|
|
}
|
|
item := organizeItem{Side: side, From: filepath.Join(from, en.Name()), To: filepath.Join(to, en.Name())}
|
|
e.mu.Lock()
|
|
dst, err := root.Resolve(item.To)
|
|
if err == nil {
|
|
if _, err := os.Lstat(dst); errors.Is(err, fs.ErrNotExist) {
|
|
o := store.Operation{PairID: l.pair.ID, Action: store.ActMove, RelNew: item.To, Trigger: trigger}
|
|
setSidePath(&o, side, item.From)
|
|
o.Detail, _ = json.Marshal(moveDetail{Side: side, Art: true})
|
|
if e.begin(ctx, &o) == nil && !o.DryRun {
|
|
_ = e.finish(ctx, &o, moveFile(filepath.Join(root.Path, item.From), dst))
|
|
}
|
|
}
|
|
}
|
|
e.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
func setSidePath(o *store.Operation, side, rel string) {
|
|
if side == SideMaster {
|
|
o.RelMaster = rel
|
|
} else {
|
|
o.RelSlave = rel
|
|
}
|
|
}
|
|
|
|
func (e *Engine) moveOne(ctx context.Context, l *libs, it organizeItem, trigger string) error {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
root, err := sideRoot(l, it.Side)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
src, err := root.Resolve(it.From)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dst, err := root.Resolve(it.To)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
in, err := fsops.RegularFile(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if in.Ino != it.ino || !in.Unchanged(it.size, it.mtime) {
|
|
return fmt.Errorf("changed since the preview: %w", fsops.ErrChanged)
|
|
}
|
|
if targetTaken(root, it.From, it.To) {
|
|
return fmt.Errorf("%s: %w", it.To, fsops.ErrExists)
|
|
}
|
|
d := moveDetail{Side: it.Side}
|
|
for _, sc := range it.Sidecars {
|
|
to := strings.TrimSuffix(it.To, filepath.Ext(it.To)) + filepath.Ext(sc)
|
|
if !targetTaken(root, sc, to) {
|
|
d.Sidecars = append(d.Sidecars, [2]string{sc, to})
|
|
}
|
|
}
|
|
o := store.Operation{PairID: l.pair.ID, Action: store.ActMove, RelNew: it.To, Trigger: trigger, Bytes: 0}
|
|
setSidePath(&o, it.Side, it.From)
|
|
o.Detail, _ = json.Marshal(d)
|
|
if err := e.begin(ctx, &o); err != nil {
|
|
return err
|
|
}
|
|
if o.DryRun {
|
|
return nil
|
|
}
|
|
err = func() error {
|
|
if err := fsops.MkdirAllLike(filepath.Dir(dst)); err != nil {
|
|
return err
|
|
}
|
|
if err := moveFile(src, dst); err != nil {
|
|
pruneEmpty(filepath.Dir(dst), root.Path)
|
|
return err
|
|
}
|
|
for _, sc := range d.Sidecars {
|
|
_ = moveFile(filepath.Join(root.Path, sc[0]), filepath.Join(root.Path, sc[1]))
|
|
}
|
|
return nil
|
|
}()
|
|
return e.finish(ctx, &o, err)
|
|
}
|
|
|
|
// moveFile renames a file within a library. Case-only renames go through a
|
|
// temporary name so they also work on case-insensitive filesystems.
|
|
func moveFile(src, dst string) error {
|
|
if strings.EqualFold(src, dst) && src != dst {
|
|
var b [6]byte
|
|
_, _ = rand.Read(b[:])
|
|
tmp := filepath.Join(filepath.Dir(src), fsops.TmpPrefix+"mv-"+hex.EncodeToString(b[:]))
|
|
if err := os.Rename(src, tmp); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, dst); err != nil {
|
|
_ = os.Rename(tmp, src)
|
|
return err
|
|
}
|
|
} else if err := os.Rename(src, dst); err != nil {
|
|
return err
|
|
}
|
|
if err := fsops.SyncDir(filepath.Dir(dst)); err != nil {
|
|
return err
|
|
}
|
|
return fsops.SyncDir(filepath.Dir(src))
|
|
}
|
|
|
|
// undoMove moves a file (and its sidecars) back.
|
|
func undoMove(l *libs, o store.Operation) error {
|
|
var d moveDetail
|
|
if err := json.Unmarshal(o.Detail, &d); err != nil {
|
|
return err
|
|
}
|
|
root, err := sideRoot(l, d.Side)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
from := o.RelSlave
|
|
if d.Side == SideMaster {
|
|
from = o.RelMaster
|
|
}
|
|
cur, err := root.Resolve(o.RelNew)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
old, err := root.Resolve(from)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := fsops.RegularFile(cur); err != nil {
|
|
return fmt.Errorf("%s is no longer there", o.RelNew)
|
|
}
|
|
if targetTaken(root, o.RelNew, from) {
|
|
return fmt.Errorf("%s: %w", from, fsops.ErrExists)
|
|
}
|
|
if err := fsops.MkdirAllLike(filepath.Dir(old)); err != nil {
|
|
return err
|
|
}
|
|
if err := moveFile(cur, old); err != nil {
|
|
return err
|
|
}
|
|
for _, sc := range d.Sidecars {
|
|
_ = moveFile(filepath.Join(root.Path, sc[1]), filepath.Join(root.Path, sc[0]))
|
|
}
|
|
pruneEmpty(filepath.Dir(cur), root.Path)
|
|
return nil
|
|
}
|
|
|
|
// recoverMove finishes a case-only file rename cut short by a crash.
|
|
func recoverMove(l *libs, o store.Operation) {
|
|
var d moveDetail
|
|
if json.Unmarshal(o.Detail, &d) != nil {
|
|
return
|
|
}
|
|
root, err := sideRoot(l, d.Side)
|
|
if err != nil {
|
|
return
|
|
}
|
|
from := o.RelSlave
|
|
if d.Side == SideMaster {
|
|
from = o.RelMaster
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(root.Path, from)); err == nil {
|
|
return // never moved
|
|
}
|
|
dst := filepath.Join(root.Path, o.RelNew)
|
|
if _, err := os.Lstat(dst); err == nil {
|
|
return // completed
|
|
}
|
|
dir := filepath.Dir(filepath.Join(root.Path, from))
|
|
ents, _ := os.ReadDir(dir)
|
|
for _, en := range ents {
|
|
if en.Type().IsRegular() && strings.HasPrefix(en.Name(), fsops.TmpPrefix+"mv-") {
|
|
_ = os.Rename(filepath.Join(dir, en.Name()), dst)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// autoOrganize runs during a scan when the pair asks for it: every clean
|
|
// rename is applied, anything doubtful is left for the preview.
|
|
func (e *Engine) autoOrganize(ctx context.Context, j *Job, l *libs) error {
|
|
sides := sidesOf(l.pair.AutoOrganize)
|
|
if sides == nil || !e.TagEditing() {
|
|
return nil
|
|
}
|
|
plan, err := e.planOrganize(ctx, j, l, sides)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var moves []organizeItem
|
|
for _, it := range plan.Items {
|
|
if it.Move {
|
|
moves = append(moves, it)
|
|
}
|
|
}
|
|
if len(moves) == 0 {
|
|
return nil
|
|
}
|
|
e.log.Info("auto-organizing", "pair", l.pair.Name, "files", len(moves))
|
|
return e.applyOrganize(ctx, j, l, moves, "auto-organize")
|
|
}
|