feat(orchestrator): build copy plan from per-account mapping + exclusions

This commit is contained in:
2026-07-03 11:46:31 +07:00
parent d6d17ee544
commit bc72f8172f
2 changed files with 75 additions and 15 deletions
+35 -15
View File
@@ -16,6 +16,34 @@ import (
var ErrNotTested = errors.New("accounts not fully tested") var ErrNotTested = errors.New("accounts not fully tested")
var ErrAlreadyRunning = errors.New("task already running") var ErrAlreadyRunning = errors.New("task already running")
// folderPlan is one source folder scheduled for copy and its destination name.
type folderPlan struct {
src, dst string
total int64
}
// planFolders decides which of an account's source folders to copy and where.
// Excluded source folders are dropped; a folder absent from mapping keeps its
// own name. Order follows the input folder list.
func planFolders(folders []string, mapping map[string]string, excluded []string) []folderPlan {
skip := make(map[string]struct{}, len(excluded))
for _, e := range excluded {
skip[e] = struct{}{}
}
plan := make([]folderPlan, 0, len(folders))
for _, f := range folders {
if _, ok := skip[f]; ok {
continue
}
df := f
if m, ok := mapping[f]; ok && m != "" {
df = m
}
plan = append(plan, folderPlan{src: f, dst: df})
}
return plan
}
type Orchestrator struct { type Orchestrator struct {
store *store.Store store *store.Store
hub *wshub.Hub hub *wshub.Hub
@@ -269,27 +297,19 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "src", err) return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "src", err)
} }
// Planning pass: EXAMINE every folder up front to learn the total message // Planning pass: decide folders from the account's own config, then EXAMINE
// count, so the UI can show an accurate overall bar / ETA before copying. // each to learn message counts for the overall progress bar.
type folderPlan struct { plan := planFolders(folders, a.FolderMapping, a.ExcludedFolders)
src, dst string
total int64
}
plan := make([]folderPlan, 0, len(folders))
var grandTotal int64 var grandTotal int64
for _, folder := range folders { for i := range plan {
if actx.Err() != nil { if actx.Err() != nil {
break break
} }
df := folder n, cerr := imapx.FolderMessageCount(src, plan[i].src)
if m, ok := task.FolderMapping[folder]; ok {
df = m
}
n, cerr := imapx.FolderMessageCount(src, folder)
if cerr != nil && actx.Err() == nil { if cerr != nil && actx.Err() == nil {
slog.Warn("count folder failed", "account", a.ID, "folder", folder, "err", cerr) slog.Warn("count folder failed", "account", a.ID, "folder", plan[i].src, "err", cerr)
} }
plan = append(plan, folderPlan{src: folder, dst: df, total: n}) plan[i].total = n
grandTotal += n grandTotal += n
} }
o.hub.Publish(wshub.Event{Type: "plan", TaskID: task.ID, Data: map[string]any{ o.hub.Publish(wshub.Event{Type: "plan", TaskID: task.ID, Data: map[string]any{
+40
View File
@@ -0,0 +1,40 @@
package orchestrator
import "testing"
func names(ps []folderPlan) []string {
out := make([]string, len(ps))
for i, p := range ps {
out[i] = p.src + "->" + p.dst
}
return out
}
func TestPlanFolders(t *testing.T) {
folders := []string{"INBOX", "Спам", "Trash", "Sent"}
// Empty config: every folder syncs to its own name, in order.
got := planFolders(folders, nil, nil)
if len(got) != 4 || got[0].src != "INBOX" || got[0].dst != "INBOX" {
t.Fatalf("empty config: %v", names(got))
}
// Rename + exclusion.
got = planFolders(folders,
map[string]string{"Спам": "Spam"},
[]string{"Trash"})
if len(got) != 3 {
t.Fatalf("want 3 plans (Trash excluded), got %v", names(got))
}
for _, p := range got {
if p.src == "Trash" {
t.Fatalf("excluded folder present: %v", names(got))
}
if p.src == "Спам" && p.dst != "Spam" {
t.Fatalf("rename not applied: %v", names(got))
}
if p.src == "INBOX" && p.dst != "INBOX" {
t.Fatalf("unmapped folder should keep its name: %v", names(got))
}
}
}