aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/model/plan.go
blob: ccbf17b4048ecbd83423b576cef96fcc438246f1 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
// 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"

	"git.labunix.xyz/krino/internal/engine"
	"git.labunix.xyz/krino/internal/lock"
	"git.labunix.xyz/krino/internal/plan"
	"git.labunix.xyz/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)))
}