// SPDX-License-Identifier: GPL-3.0-or-later // Package model is krino-gui's state and operations: what each tab shows, // and what a click does. It holds no GTK, so it can be tested the way the // engine's own packages are (GUI design §1.2). package model import ( "context" "fmt" "sort" "time" "krino/internal/engine" "krino/internal/lock" "krino/internal/plan" "krino/internal/scan" ) // Row is one line of the Plan tab: a file krino would act on, or one it // could not decide about. type Row struct { Rel string // the file, relative to the directory's root Path string // absolute, for acting on the file itself Size int64 ModTime time.Time // DuplicateOf is the copy a (duplicate) test matched this file // against, absolute; "" when none did. DuplicateOf string Steps []plan.Step Rule string // the rule that matched first, for the Rule column Warnings []string // Selected is the checkbox. A row that cannot be applied - every step // skipped, or nothing but warnings - is never selected and has no box. Selected bool Actable bool // Outcome is what applying did to this file: "", "done", "failed: ...", // "declined". Outcome string } // PlanTab is the state of one directory's plan. type PlanTab struct { Dir *engine.Dir Rows []Row Counts Counts Warnings []string // directory-level Applied bool // this plan has been applied and is now history sess *engine.Session dp *engine.DirPlan lock *lock.Lock } // Counts is the plan's summary line. type Counts struct { Scanned, Acting, Excluded, Skipped, Unmatched, Warned int } // Plan locks d and plans it, returning the tab to show. The lock is held // until Close, so what the window shows stays the truth while the user // chooses, and no other krino moves the files under them (GUI design §3). // A directory another run is already working in is not planned at all: the // error is lock.ErrHeld, naming the directory. // // Each plan gets its own session, so applying it is one run of krino with // its own run id in the log - the same shape a command line invocation // writes. A window is not a run: one that sorted a directory and later // undid something must not log both under one id, which would make the run // the undo of itself. func Plan(ctx context.Context, e *engine.Engine, d *engine.Dir) (*PlanTab, error) { sess, err := e.NewSession(false) if err != nil { return nil, err } l, err := sess.Lock(ctx, d, false) if err != nil { sess.Close() return nil, fmt.Errorf("%s: %w", d.Name, err) } dp, err := sess.Plan(ctx, d) if err != nil { l.Release() sess.Close() return nil, err } t := &PlanTab{Dir: d, sess: sess, dp: dp, lock: l} t.fill() return t, nil } // Run is the run id this plan was applied under, "" until Apply. func (t *PlanTab) Run() string { return t.sess.Run() } // Close releases the directory's lock and ends the run, which Apply also // does once the plan is history. Closing twice is not an error. func (t *PlanTab) Close() error { if t.lock == nil { return nil } l := t.lock t.lock = nil err := l.Release() if cerr := t.sess.Close(); err == nil { err = cerr } return err } // fill turns the engine's plan into rows and counts. func (t *PlanTab) fill() { r := t.dp.Result t.Warnings = append([]string(nil), r.Warnings...) warnings := map[string][]string{} files := map[string]scan.File{} dupes := map[string]string{} for _, fms := range [][]engine.FileMatch{r.Matched, r.Unmatched} { for _, fm := range fms { files[fm.File.Rel] = fm.File if fm.DuplicateOf != "" { dupes[fm.File.Rel] = fm.DuplicateOf } if len(fm.Warnings) > 0 { warnings[fm.File.Rel] = fm.Warnings } } } excluded := 0 for _, fm := range r.Matched { if fm.Excluded != "" { excluded++ } } t.Rows = nil for _, c := range t.dp.Chains { row := Row{Rel: c.File.Rel, Path: c.File.Path, Size: c.File.Size, ModTime: c.File.ModTime, DuplicateOf: dupes[c.File.Rel], Steps: c.Steps, Warnings: warnings[c.File.Rel]} for _, s := range c.Steps { if s.Skip == "" { row.Actable = true } if row.Rule == "" { row.Rule = s.Rule } } row.Selected = row.Actable if len(c.Steps) > 0 { t.Rows = append(t.Rows, row) } delete(warnings, c.File.Rel) } // Files no rule acted on that still raised a warning: krino could not // decide about them, and they are shown but never selectable. var rest []string for rel := range warnings { rest = append(rest, rel) } sort.Strings(rest) for _, rel := range rest { f := files[rel] t.Rows = append(t.Rows, Row{Rel: rel, Path: f.Path, Size: f.Size, ModTime: f.ModTime, DuplicateOf: dupes[rel], Warnings: warnings[rel]}) } t.Counts = Counts{ Scanned: len(r.Matched) + len(r.Unmatched) + len(r.Skipped), Excluded: excluded, Skipped: len(r.Skipped), Unmatched: len(r.Unmatched), } for _, row := range t.Rows { if row.Actable { t.Counts.Acting++ } if len(row.Warnings) > 0 { t.Counts.Warned++ } } } // SelectAll selects every row that can be applied; SelectNone clears them. func (t *PlanTab) SelectAll() { t.setAll(true) } func (t *PlanTab) SelectNone() { t.setAll(false) } func (t *PlanTab) setAll(on bool) { for i := range t.Rows { t.Rows[i].Selected = on && t.Rows[i].Actable } } // Toggle flips row i's checkbox; a row that cannot be applied stays off. func (t *PlanTab) Toggle(i int) { if i < 0 || i >= len(t.Rows) || !t.Rows[i].Actable { return } t.Rows[i].Selected = !t.Rows[i].Selected } // SelectedCount is how many files Apply would act on. func (t *PlanTab) SelectedCount() int { n := 0 for _, r := range t.Rows { if r.Selected { n++ } } return n } // Replace swaps a row's planned steps for the one step the user chose // instead - "Trash instead", "Delete permanently instead" - as the terminal // review's t and d keys do, and selects it. func (t *PlanTab) Replace(i int, kind plan.Kind) error { if i < 0 || i >= len(t.Rows) { return fmt.Errorf("model: no row %d", i) } if kind != plan.Trash && kind != plan.DeletePermanent { return fmt.Errorf("model: %s is not a replacement action", kind) } rel := t.Rows[i].Rel for j, c := range t.dp.Chains { if c.File.Rel != rel { continue } t.dp.Chains[j].Steps = []plan.Step{{ Kind: kind, Rule: "(review)", Src: c.File.Path, Reason: "chosen in review", }} t.Rows[i].Steps = t.dp.Chains[j].Steps t.Rows[i].Rule = "(review)" t.Rows[i].Actable = true t.Rows[i].Selected = true return nil } return fmt.Errorf("model: %s is not in this plan", rel) } // ReplaceSelected swaps the steps of every checked file for the one action // chosen - "Trash the checked files", "Delete them permanently" - and // reports how many were changed. Nothing happens on disk: like every other // review decision, it changes the plan, and Apply carries it out. func (t *PlanTab) ReplaceSelected(kind plan.Kind) (int, error) { n := 0 for i, r := range t.Rows { if !r.Selected { continue } if err := t.Replace(i, kind); err != nil { return n, err } n++ } return n, nil } // KeepThisCopy is the answer to "I want this one, not the one already // filed": the file takes the other copy's place, and the other copy goes to // the Trash, where krino undo can still reach it. It is a review decision, // like trashing a file by hand, so the rule that a duplicate is never // deleted - which binds rules, not the person reading the plan - does not // stand in its way. func (t *PlanTab) KeepThisCopy(i int) error { if i < 0 || i >= len(t.Rows) { return fmt.Errorf("model: no row %d", i) } row := t.Rows[i] if row.DuplicateOf == "" { return fmt.Errorf("model: %s is not a duplicate of anything krino looked at", row.Rel) } if row.Path == "" { return fmt.Errorf("model: %s has no path", row.Rel) } for j, c := range t.dp.Chains { if c.File.Rel != row.Rel { continue } t.dp.Chains[j].Steps = []plan.Step{{ Kind: plan.Move, Rule: "(review)", Src: row.Path, Dst: row.DuplicateOf, Displaces: row.DuplicateOf, Reason: "chosen in review: this copy replaces the one already there", }} t.Rows[i].Steps = t.dp.Chains[j].Steps t.Rows[i].Rule = "(review)" t.Rows[i].Actable = true t.Rows[i].Selected = true return nil } return fmt.Errorf("model: %s is not in this plan", row.Rel) } // Apply acts on the selected files and logs the rest as declined, exactly // as choosing per file in the terminal does. Each row then carries its // outcome. func (t *PlanTab) Apply(ctx context.Context) (*engine.ApplyResult, error) { approved := map[string]bool{} for _, r := range t.Rows { if r.Selected { approved[r.Rel] = true } } res, err := t.sess.Apply(ctx, t.dp, approved) t.Applied = true // An applied plan is history: the run is over and the directory free // again, without closing the window (GUI design §3). t.Close() if res != nil { t.record(res) } return res, err } // record writes each file's outcome onto its row. func (t *PlanTab) record(res *engine.ApplyResult) { byRel := map[string]string{} for _, fr := range res.Files { outcome := "done" for _, sr := range fr.Steps { switch sr.Status { case "failed": outcome = "failed: " + sr.Detail case "declined": outcome = "declined" } } byRel[fr.File.Rel] = outcome } for i, r := range t.Rows { if o, ok := byRel[r.Rel]; ok { t.Rows[i].Outcome = o } } } // AgeText is how long ago a file was last written, in the units krino's own // (age ...) test uses: minutes, hours, days and weeks, and years past that, // so a plan can be read at a glance. func AgeText(mod time.Time, now time.Time) string { if mod.IsZero() { return "" } d := now.Sub(mod) if d < 0 { return "0m" // a file dated in the future is not aged } switch { case d < time.Hour: return fmt.Sprintf("%dm", int(d.Minutes())) case d < 24*time.Hour: return fmt.Sprintf("%dh", int(d.Hours())) case d < 7*24*time.Hour: return fmt.Sprintf("%dd", int(d.Hours()/24)) case d < 52*7*24*time.Hour: return fmt.Sprintf("%dw", int(d.Hours()/(24*7))) } return fmt.Sprintf("%dy", int(d.Hours()/(24*365))) }