.fab exclusion scoping: classes in the manual export (Task 4, v0.136.0)
SQ6 over-capture FIXED for classified apps: the userdata root tar is exclude-scoped (keeps only ancestors/descendants of a SELECTED bind relpath — R1-C, the tier2Reconcile keep-rule); no selected userdata bind → no root tar (radarr state-only). New appbackup.ComputeFabBuckets (shared resolveGuardCollapse pipeline; class buckets; guards over ALL classes; no cross-bucket containment). appexport/fabplan.go: computeFabPlan + tarDirectoryExcluding + fabEstimateSplit. ExportRequest gains DeselectOptional/OptInExcluded (both start handlers — two-call-site); mandatory is a server-side floor. Manifest v1 + import UNTOUCHED; legacy apps byte-identical to v0.130.0. Estimate additive class split; export UI: locked-mandatory/optional-checkboxes/excluded-opt-in + two-number warning. All 6 §10 red-proofs verified. 6D Accept #1 now runs against this shape.
This commit is contained in:
@@ -79,37 +79,9 @@ func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier Capt
|
||||
}
|
||||
cs := CaptureSet{HasClassification: true}
|
||||
|
||||
// Stage 1+2: tier filter, then structural guards. Survivors are resolved to CapturePath.
|
||||
var resolved []CapturePath
|
||||
for _, b := range binds {
|
||||
if !tierKeeps(tier, b.Class) {
|
||||
continue // excluded (any tier) or optional@offsite — silently filtered, not skipped
|
||||
}
|
||||
if reason, bad := structuralGuard(b.Root, b.RelPath); bad {
|
||||
cs.Skipped = append(cs.Skipped, SkippedPath{
|
||||
Root: b.Root, RelPath: b.RelPath, Class: b.Class, Reason: reason,
|
||||
})
|
||||
continue
|
||||
}
|
||||
resolved = append(resolved, CapturePath{
|
||||
Abs: resolveAbs(hddPath, b.Root, b.RelPath), Root: b.Root, RelPath: b.RelPath, Class: b.Class,
|
||||
})
|
||||
}
|
||||
|
||||
// Stage 3: equal-Abs collapse — one entry per Abs, mandatory beats optional (mandatory semantics
|
||||
// must never degrade). Deterministic on ties: keep the lexicographically-smaller (Root, RelPath).
|
||||
byAbs := make(map[string]CapturePath, len(resolved))
|
||||
for _, cp := range resolved {
|
||||
if cur, ok := byAbs[cp.Abs]; ok {
|
||||
byAbs[cp.Abs] = strongerCapture(cur, cp)
|
||||
continue
|
||||
}
|
||||
byAbs[cp.Abs] = cp
|
||||
}
|
||||
uniq := make([]CapturePath, 0, len(byAbs))
|
||||
for _, cp := range byAbs {
|
||||
uniq = append(uniq, cp)
|
||||
}
|
||||
// Stages 1–3: tier filter → structural guards → equal-Abs collapse (shared with ComputeFabBuckets).
|
||||
uniq, skipped := resolveGuardCollapse(binds, hddPath, func(c BindClass) bool { return tierKeeps(tier, c) })
|
||||
cs.Skipped = skipped
|
||||
|
||||
// Stage 4: containment dedup — drop any path whose ancestor is already present (keep the ancestor).
|
||||
for _, cp := range uniq {
|
||||
@@ -130,6 +102,87 @@ func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier Capt
|
||||
return cs
|
||||
}
|
||||
|
||||
// resolveGuardCollapse is the pipeline shared by ComputeCaptureSet and ComputeFabBuckets: keep-filter
|
||||
// (the caller's predicate over class) → structural guards (Skipped) → resolve to Abs → equal-Abs
|
||||
// collapse (mandatory > optional > excluded; ties by smaller Root/RelPath). It does NOT apply
|
||||
// containment dedup — the caller decides (ComputeCaptureSet does; ComputeFabBuckets must not, so a
|
||||
// mandatory child inside an excluded parent stays independently addressable).
|
||||
func resolveGuardCollapse(binds []ClassifiedBind, hddPath string, keep func(BindClass) bool) (uniq []CapturePath, skipped []SkippedPath) {
|
||||
var resolved []CapturePath
|
||||
for _, b := range binds {
|
||||
if !keep(b.Class) {
|
||||
continue
|
||||
}
|
||||
if reason, bad := structuralGuard(b.Root, b.RelPath); bad {
|
||||
skipped = append(skipped, SkippedPath{Root: b.Root, RelPath: b.RelPath, Class: b.Class, Reason: reason})
|
||||
continue
|
||||
}
|
||||
resolved = append(resolved, CapturePath{
|
||||
Abs: resolveAbs(hddPath, b.Root, b.RelPath), Root: b.Root, RelPath: b.RelPath, Class: b.Class,
|
||||
})
|
||||
}
|
||||
byAbs := make(map[string]CapturePath, len(resolved))
|
||||
for _, cp := range resolved {
|
||||
if cur, ok := byAbs[cp.Abs]; ok {
|
||||
byAbs[cp.Abs] = strongerCapture(cur, cp)
|
||||
continue
|
||||
}
|
||||
byAbs[cp.Abs] = cp
|
||||
}
|
||||
uniq = make([]CapturePath, 0, len(byAbs))
|
||||
for _, cp := range byAbs {
|
||||
uniq = append(uniq, cp)
|
||||
}
|
||||
return uniq, skipped
|
||||
}
|
||||
|
||||
// FabBuckets is the class-bucketed capture set for the manual `.fab` export (Task 4). Unlike
|
||||
// ComputeCaptureSet it keeps ALL classes (structural guards run over every class — a traversal path is
|
||||
// never plannable, opt-in or not) and does NOT collapse across containment (mandatory `media/books`
|
||||
// inside excluded `media` both survive, in their own buckets). HasClassification=false ⇒ empty (the
|
||||
// legacy full-root capture, unchanged).
|
||||
type FabBuckets struct {
|
||||
HasClassification bool
|
||||
Mandatory []CapturePath
|
||||
Optional []CapturePath
|
||||
Excluded []CapturePath
|
||||
Skipped []SkippedPath
|
||||
}
|
||||
|
||||
// ComputeFabBuckets resolves an app's classified binds into per-class buckets for the `.fab` export
|
||||
// selection UI + plan. Same resolution + structural guards + equal-Abs collapse as ComputeCaptureSet
|
||||
// (via resolveGuardCollapse), bucketed by class, no cross-bucket containment dedup. Each bucket is
|
||||
// Abs-sorted (deterministic).
|
||||
func ComputeFabBuckets(binds []ClassifiedBind, hasClassification bool, hddPath string) FabBuckets {
|
||||
if !hasClassification {
|
||||
return FabBuckets{HasClassification: false}
|
||||
}
|
||||
fb := FabBuckets{HasClassification: true}
|
||||
uniq, skipped := resolveGuardCollapse(binds, hddPath, func(BindClass) bool { return true })
|
||||
fb.Skipped = skipped
|
||||
for _, cp := range uniq {
|
||||
switch cp.Class {
|
||||
case ClassMandatory:
|
||||
fb.Mandatory = append(fb.Mandatory, cp)
|
||||
case ClassOptional:
|
||||
fb.Optional = append(fb.Optional, cp)
|
||||
default:
|
||||
fb.Excluded = append(fb.Excluded, cp)
|
||||
}
|
||||
}
|
||||
for _, b := range []*[]CapturePath{&fb.Mandatory, &fb.Optional, &fb.Excluded} {
|
||||
bk := *b
|
||||
sort.Slice(bk, func(i, j int) bool { return bk[i].Abs < bk[j].Abs })
|
||||
}
|
||||
sort.Slice(fb.Skipped, func(i, j int) bool {
|
||||
if fb.Skipped[i].Root != fb.Skipped[j].Root {
|
||||
return fb.Skipped[i].Root < fb.Skipped[j].Root
|
||||
}
|
||||
return fb.Skipped[i].RelPath < fb.Skipped[j].RelPath
|
||||
})
|
||||
return fb
|
||||
}
|
||||
|
||||
// tierKeeps applies the §2 tier column: mandatory everywhere, optional only for secondary, excluded
|
||||
// never.
|
||||
func tierKeeps(tier CaptureTier, class BindClass) bool {
|
||||
|
||||
Reference in New Issue
Block a user