diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 8e9cf57..b6b0be0 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -16,6 +16,34 @@ import ( var ErrNotTested = errors.New("accounts not fully tested") 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 { store *store.Store 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) } - // Planning pass: EXAMINE every folder up front to learn the total message - // count, so the UI can show an accurate overall bar / ETA before copying. - type folderPlan struct { - src, dst string - total int64 - } - plan := make([]folderPlan, 0, len(folders)) + // Planning pass: decide folders from the account's own config, then EXAMINE + // each to learn message counts for the overall progress bar. + plan := planFolders(folders, a.FolderMapping, a.ExcludedFolders) var grandTotal int64 - for _, folder := range folders { + for i := range plan { if actx.Err() != nil { break } - df := folder - if m, ok := task.FolderMapping[folder]; ok { - df = m - } - n, cerr := imapx.FolderMessageCount(src, folder) + n, cerr := imapx.FolderMessageCount(src, plan[i].src) 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 } o.hub.Publish(wshub.Event{Type: "plan", TaskID: task.ID, Data: map[string]any{ diff --git a/internal/orchestrator/planfolders_test.go b/internal/orchestrator/planfolders_test.go new file mode 100644 index 0000000..6dacb7f --- /dev/null +++ b/internal/orchestrator/planfolders_test.go @@ -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)) + } + } +}