Add standalone desktop workflow
This commit is contained in:
@@ -5,9 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -322,7 +323,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
}
|
||||
|
||||
dstAbs := filepath.Join(destRoot, f.relPath)
|
||||
if err := rsyncFile(ctx, f.srcAbs, dstAbs); err != nil {
|
||||
if err := copyFile(ctx, f.srcAbs, dstAbs); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
c.tasks.Update(taskID, func(t *task.Task) {
|
||||
t.Status = task.StatusCanceled
|
||||
@@ -358,11 +359,24 @@ type fileEntry struct {
|
||||
}
|
||||
|
||||
func buildFileList(mediaPath string, rules []config.SourceFolder, skip map[string]struct{}) ([]fileEntry, error) {
|
||||
roots, ruleMap := normalizeSourceRules(rules)
|
||||
_ = mediaPath
|
||||
roots, selectedRoots, ruleMap := normalizeSourceRules(rules)
|
||||
aliases := sourceAliases(roots)
|
||||
|
||||
var result []fileEntry
|
||||
for _, src := range roots {
|
||||
dir := filepath.Join(mediaPath, src)
|
||||
for _, src := range selectedRoots {
|
||||
root := owningRoot(src, roots)
|
||||
if root == "" {
|
||||
root = src
|
||||
}
|
||||
alias := aliases[root]
|
||||
if alias == "" {
|
||||
alias = filepath.Base(root)
|
||||
if alias == "." || alias == "" || alias == string(filepath.Separator) {
|
||||
alias = "source-" + shortHash(root)
|
||||
}
|
||||
}
|
||||
dir := src
|
||||
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
if err != nil {
|
||||
@@ -371,29 +385,33 @@ func buildFileList(mediaPath string, rules []config.SourceFolder, skip map[strin
|
||||
if path == dir {
|
||||
return nil
|
||||
}
|
||||
rel, relErr := filepath.Rel(mediaPath, path)
|
||||
rel, relErr := filepath.Rel(root, path)
|
||||
if relErr != nil {
|
||||
return nil
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !isPathEnabled(rel, ruleMap) && !hasEnabledDescendant(rel, ruleMap) {
|
||||
if !isPathEnabled(path, ruleMap) && !hasEnabledDescendant(path, ruleMap) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(mediaPath, path)
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !isPathEnabled(rel, ruleMap) {
|
||||
if !isPathEnabled(path, ruleMap) {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
rel = filepath.ToSlash(rel)
|
||||
destRel := filepath.ToSlash(filepath.Join(alias, rel))
|
||||
if _, skipped := skip[rel]; skipped {
|
||||
return nil
|
||||
}
|
||||
if _, skipped := skip[destRel]; skipped {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
result = append(result, fileEntry{srcAbs: path, relPath: rel, size: info.Size()})
|
||||
result = append(result, fileEntry{srcAbs: path, relPath: destRel, size: info.Size()})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -403,30 +421,35 @@ func buildFileList(mediaPath string, rules []config.SourceFolder, skip map[strin
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeSourceRules(rules []config.SourceFolder) ([]string, map[string]bool) {
|
||||
func normalizeSourceRules(rules []config.SourceFolder) ([]string, []string, map[string]bool) {
|
||||
ruleMap := make(map[string]bool, len(rules))
|
||||
rootSet := make(map[string]struct{})
|
||||
for _, rule := range rules {
|
||||
src := filepath.ToSlash(filepath.Clean(strings.TrimSpace(rule.Path)))
|
||||
src = strings.TrimPrefix(src, "./")
|
||||
src = strings.TrimPrefix(src, "/")
|
||||
src := filepath.Clean(strings.TrimSpace(rule.Path))
|
||||
if src == "" || src == "." {
|
||||
continue
|
||||
}
|
||||
if src == ".." || strings.HasPrefix(src, "../") {
|
||||
continue
|
||||
}
|
||||
ruleMap[src] = rule.Enabled
|
||||
if rule.Root {
|
||||
rootSet[src] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
var roots []string
|
||||
for src := range rootSet {
|
||||
roots = append(roots, src)
|
||||
}
|
||||
sort.Strings(roots)
|
||||
|
||||
var selectedRoots []string
|
||||
for src, enabled := range ruleMap {
|
||||
if !enabled || hasEnabledAncestor(src, ruleMap) {
|
||||
continue
|
||||
}
|
||||
roots = append(roots, src)
|
||||
selectedRoots = append(selectedRoots, src)
|
||||
}
|
||||
sort.Strings(roots)
|
||||
return roots, ruleMap
|
||||
sort.Strings(selectedRoots)
|
||||
return roots, selectedRoots, ruleMap
|
||||
}
|
||||
|
||||
func hasEnabledAncestor(path string, ruleMap map[string]bool) bool {
|
||||
@@ -439,9 +462,8 @@ func hasEnabledAncestor(path string, ruleMap map[string]bool) bool {
|
||||
}
|
||||
|
||||
func hasEnabledDescendant(path string, ruleMap map[string]bool) bool {
|
||||
prefix := path + "/"
|
||||
for other, enabled := range ruleMap {
|
||||
if enabled && strings.HasPrefix(other, prefix) {
|
||||
if enabled && isPathInside(path, other) && other != path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -458,36 +480,153 @@ func isPathEnabled(path string, ruleMap map[string]bool) bool {
|
||||
}
|
||||
|
||||
func parentSourcePath(path string) string {
|
||||
idx := strings.LastIndex(path, "/")
|
||||
if idx < 0 {
|
||||
parent := filepath.Dir(path)
|
||||
if parent == "." || parent == path {
|
||||
return ""
|
||||
}
|
||||
return path[:idx]
|
||||
return parent
|
||||
}
|
||||
|
||||
// rsyncFile copies src to dst using rsync with resume support.
|
||||
// --partial keeps partial files on interruption.
|
||||
// --append-verify resumes partial transfers and verifies checksums.
|
||||
func rsyncFile(ctx context.Context, src, dst string) error {
|
||||
func owningRoot(path string, roots []string) string {
|
||||
var best string
|
||||
for _, root := range roots {
|
||||
if isPathInside(root, path) {
|
||||
if len(root) > len(best) {
|
||||
best = root
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func sourceAliases(roots []string) map[string]string {
|
||||
counts := make(map[string]int, len(roots))
|
||||
for _, root := range roots {
|
||||
counts[strings.ToLower(filepath.Base(root))]++
|
||||
}
|
||||
|
||||
aliases := make(map[string]string, len(roots))
|
||||
for _, root := range roots {
|
||||
base := filepath.Base(root)
|
||||
if base == "." || base == string(filepath.Separator) || base == "" {
|
||||
base = "source"
|
||||
}
|
||||
key := strings.ToLower(base)
|
||||
if counts[key] > 1 {
|
||||
base = fmt.Sprintf("%s-%s", base, shortHash(root))
|
||||
}
|
||||
aliases[root] = base
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
|
||||
func shortHash(value string) string {
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(value))
|
||||
return fmt.Sprintf("%08x", h.Sum32())[:6]
|
||||
}
|
||||
|
||||
func isPathInside(base, candidate string) bool {
|
||||
if candidate == base {
|
||||
return true
|
||||
}
|
||||
rel, err := filepath.Rel(base, candidate)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
||||
}
|
||||
|
||||
func copyFile(ctx context.Context, src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "rsync",
|
||||
"--partial",
|
||||
"--append-verify",
|
||||
"--times",
|
||||
"--no-perms",
|
||||
"--no-owner",
|
||||
"--no-group",
|
||||
"--chmod=ugo=rwx",
|
||||
src, dst,
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
srcInfo, err := srcFile.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
offset := int64(0)
|
||||
if dstInfo, err := os.Stat(dst); err == nil {
|
||||
switch {
|
||||
case dstInfo.Size() < srcInfo.Size():
|
||||
offset = dstInfo.Size()
|
||||
case dstInfo.Size() == srcInfo.Size():
|
||||
return os.Chtimes(dst, srcInfo.ModTime(), srcInfo.ModTime())
|
||||
default:
|
||||
if err := os.Remove(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("rsync: %w: %s", err, out)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
if offset > 0 {
|
||||
if _, err := srcFile.Seek(offset, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
if offset > 0 {
|
||||
if _, err := dstFile.Seek(offset, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := dstFile.Truncate(0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf := make([]byte, 1024*1024)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nr, readErr := srcFile.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, writeErr := dstFile.Write(buf[:nr])
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
if nw != nr {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
}
|
||||
|
||||
if readErr != nil {
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
return readErr
|
||||
}
|
||||
}
|
||||
|
||||
if err := dstFile.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chtimes(dst, srcInfo.ModTime(), srcInfo.ModTime()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstInfo, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dstInfo.Size() != srcInfo.Size() {
|
||||
return fmt.Errorf("copied size mismatch for %s", filepath.Base(src))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user