feat(orchestrator): build copy plan from per-account mapping + exclusions
This commit is contained in:
@@ -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{
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user