aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/model
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 15:33:23 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 15:33:23 +0200
commit4363c7ad13d5eae1752ea3c36e1cfe7c13707c0d (patch)
treece20a5be9accc7baf7421dbc675ab4e399652569 /gui/internal/model
parent85d65ebe0adf1b156324a3a4c220e415a79ba9ce (diff)
downloadkrino-4363c7ad13d5eae1752ea3c36e1cfe7c13707c0d.tar.gz
krino-4363c7ad13d5eae1752ea3c36e1cfe7c13707c0d.zip
gui: forms editor - rules and excludes as forms, with add, delete and move
Diffstat (limited to 'gui/internal/model')
-rw-r--r--gui/internal/model/forms.go254
-rw-r--r--gui/internal/model/forms_test.go240
2 files changed, 494 insertions, 0 deletions
diff --git a/gui/internal/model/forms.go b/gui/internal/model/forms.go
new file mode 100644
index 0000000..681c15c
--- /dev/null
+++ b/gui/internal/model/forms.go
@@ -0,0 +1,254 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "fmt"
+ "strings"
+
+ "krino/internal/config"
+ "krino/internal/sexp"
+)
+
+// FormKind is what an editable form is.
+type FormKind int
+
+const (
+ RuleForm FormKind = iota
+ ExcludeForm
+)
+
+// Form is one editable form of a directory's file, as the list on the left
+// of the Rules tab shows it (GUI design §5.1).
+type Form struct {
+ Kind FormKind
+ Label string // the rule's name, or the exclude as written
+ Pos sexp.Pos
+ End sexp.Pos
+ Rule *config.Rule // RuleForm
+ Exclude *config.Exclude // ExcludeForm
+}
+
+// Forms parses the text in the editor and lists what can be edited, in file
+// order: the excludes and rules of this directory. Text that does not parse
+// has no forms - the Text tab keeps it until it is fixed (GUI design §5.2).
+func (r *Rules) Forms() ([]Form, error) {
+ over := map[string][]byte{r.File: []byte(r.Text)}
+ cfg, diags := config.LoadWith(r.e.MainFile, over, r.Name)
+ if len(diags) > 0 {
+ return nil, fmt.Errorf("%s", diags[0])
+ }
+ var d *config.Dir
+ for _, cd := range cfg.Dirs {
+ if cd.Name == r.Name {
+ d = cd
+ break
+ }
+ }
+ if d == nil {
+ return nil, fmt.Errorf("model: %s is not in the configuration", r.Name)
+ }
+ var out []Form
+ for _, x := range d.Excludes {
+ out = append(out, Form{Kind: ExcludeForm, Label: x.Text, Pos: x.Pos, End: x.End, Exclude: x})
+ }
+ for _, rule := range d.Rules {
+ out = append(out, Form{Kind: RuleForm, Label: rule.Name, Pos: rule.Pos, End: rule.End, Rule: rule})
+ }
+ sortByPosition(out)
+ return out, nil
+}
+
+// sortByPosition puts forms in the order they are written, since excludes
+// and rules are parsed into separate lists.
+func sortByPosition(forms []Form) {
+ for i := 1; i < len(forms); i++ {
+ for j := i; j > 0 && forms[j].Pos.Offset < forms[j-1].Pos.Offset; j-- {
+ forms[j], forms[j-1] = forms[j-1], forms[j]
+ }
+ }
+}
+
+// ReplaceForm swaps form i's text for text - what config.PrintRule or
+// PrintExclude wrote for the edited form - leaving every other byte of the
+// file, comments and layout included, exactly as it was (GUI design §5.1).
+func (r *Rules) ReplaceForm(i int, text string) error {
+ f, err := r.form(i)
+ if err != nil {
+ return err
+ }
+ out, err := config.Splice([]byte(r.Text), f.Pos, f.End, strings.TrimRight(text, "\n"))
+ if err != nil {
+ return err
+ }
+ r.Text = string(out)
+ return nil
+}
+
+// DeleteForm removes form i and the comment lines directly above it - its
+// block (GUI design §5.1). A comment separated from the form by a blank
+// line belongs to the file, not to the form, and stays.
+func (r *Rules) DeleteForm(i int) error {
+ f, err := r.form(i)
+ if err != nil {
+ return err
+ }
+ start, end := r.block(f)
+ r.Text = join(r.Text[:start], r.Text[end:])
+ return nil
+}
+
+// MoveForm moves form i one place up (delta -1) or down (delta 1), with its
+// comments. Moving past either end does nothing.
+func (r *Rules) MoveForm(i, delta int) error {
+ forms, err := r.Forms()
+ if err != nil {
+ return err
+ }
+ j := i + delta
+ if i < 0 || i >= len(forms) || j < 0 || j >= len(forms) {
+ return nil
+ }
+ a, b := forms[i], forms[j]
+ if a.Pos.Offset > b.Pos.Offset {
+ a, b = b, a
+ }
+ as, ae := r.block(a)
+ bs, be := r.block(b)
+ if ae > bs {
+ return fmt.Errorf("model: the two forms share lines")
+ }
+ r.Text = r.Text[:as] + r.Text[bs:be] + r.Text[ae:bs] + r.Text[as:ae] + r.Text[be:]
+ return nil
+}
+
+// AddRuleAfter inserts a new rule after form i - after every form when i is
+// out of range - and returns its place in the new list. The rule it writes
+// is the smallest one that loads, for the form editor to fill in.
+func (r *Rules) AddRuleAfter(i int, name string) (int, error) {
+ forms, err := r.Forms()
+ if err != nil {
+ return 0, err
+ }
+ text := config.PrintRule(&config.Rule{
+ Name: name,
+ Actions: []config.Action{{Kind: config.Move, Arg: "TODO"}},
+ })
+ at := len(r.Text)
+ if i >= 0 && i < len(forms) {
+ _, at = r.block(forms[i])
+ }
+ r.Text = join(r.Text[:at], "\n"+text+r.Text[at:])
+ after, err := r.Forms()
+ if err != nil {
+ return 0, err
+ }
+ for n, f := range after {
+ if f.Kind == RuleForm && f.Label == name && !was(forms, f) {
+ return n, nil
+ }
+ }
+ return 0, fmt.Errorf("model: the new rule is not in the file")
+}
+
+// was reports whether a form of the same kind and label was already there
+// at the same offset, so AddRuleAfter can tell its new rule from a rule of
+// the same name that existed before.
+func was(before []Form, f Form) bool {
+ for _, b := range before {
+ if b.Kind == f.Kind && b.Label == f.Label && b.Pos.Offset == f.Pos.Offset {
+ return true
+ }
+ }
+ return false
+}
+
+// FormHasComments reports whether form i holds a comment inside it. A form
+// editor writes the form back from what was parsed, and the parser drops
+// comments, so the caller warns before replacing one (GUI design §5.1).
+func (r *Rules) FormHasComments(i int) (bool, error) {
+ f, err := r.form(i)
+ if err != nil {
+ return false, err
+ }
+ return hasComment(r.Text[f.Pos.Offset:f.End.Offset]), nil
+}
+
+// hasComment reports whether s holds a ";" that starts a comment: one
+// outside a string, since a name pattern may well contain a semicolon.
+func hasComment(s string) bool {
+ inString, escaped := false, false
+ for _, c := range s {
+ switch {
+ case escaped:
+ escaped = false
+ case c == '\\' && inString:
+ escaped = true
+ case c == '"':
+ inString = !inString
+ case c == ';' && !inString:
+ return true
+ }
+ }
+ return false
+}
+
+// form is form i of the current text.
+func (r *Rules) form(i int) (Form, error) {
+ forms, err := r.Forms()
+ if err != nil {
+ return Form{}, err
+ }
+ if i < 0 || i >= len(forms) {
+ return Form{}, fmt.Errorf("model: no form %d", i)
+ }
+ return forms[i], nil
+}
+
+// block is the span a form moves and is deleted with: from the start of the
+// first comment line that touches it, to the end of the line its closing
+// paren is on.
+func (r *Rules) block(f Form) (start, end int) {
+ start = lineStart(r.Text, f.Pos.Offset)
+ for start > 0 {
+ prev := lineStart(r.Text, start-1)
+ line := strings.TrimSpace(r.Text[prev : start-1])
+ if !strings.HasPrefix(line, ";") {
+ break
+ }
+ start = prev
+ }
+ end = lineEnd(r.Text, f.End.Offset)
+ return start, end
+}
+
+// lineStart is the offset just after the newline before at.
+func lineStart(s string, at int) int {
+ if at > len(s) {
+ at = len(s)
+ }
+ if i := strings.LastIndexByte(s[:at], '\n'); i >= 0 {
+ return i + 1
+ }
+ return 0
+}
+
+// lineEnd is the offset just past the newline that ends at's line.
+func lineEnd(s string, at int) int {
+ if at >= len(s) {
+ return len(s)
+ }
+ if i := strings.IndexByte(s[at:], '\n'); i >= 0 {
+ return at + i + 1
+ }
+ return len(s)
+}
+
+// join puts two pieces of a file back together without leaving a run of
+// blank lines where something was taken out or put in.
+func join(before, after string) string {
+ for strings.HasSuffix(before, "\n\n") && strings.HasPrefix(after, "\n") {
+ after = after[1:]
+ }
+ return before + after
+}
diff --git a/gui/internal/model/forms_test.go b/gui/internal/model/forms_test.go
new file mode 100644
index 0000000..2c23462
--- /dev/null
+++ b/gui/internal/model/forms_test.go
@@ -0,0 +1,240 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "strings"
+ "testing"
+
+ "krino/internal/config"
+)
+
+// formsFile is a directory file with comments in the places that matter:
+// above a rule (its own), inside a rule, and free-standing.
+const formsFile = `(path "~/dl")
+
+(exclude (name "^keep-"))
+
+;; invoices go to their year
+(rule "invoices"
+ (when (and (type pdf) (content "invoice")))
+ (move "Invoices/{mtime:%Y}")
+ (stop))
+
+(rule "images"
+ (when (type image)) ; anything the type table calls an image
+ (move "Images")
+ (stop))
+
+;; last resort
+
+(rule "rest"
+ (move "Other"))
+`
+
+// openForms puts text in an editor over the sandbox's file.
+func openForms(t *testing.T, text string) *Rules {
+ t.Helper()
+ e, _ := sandboxDir(t, formsFile, map[string]string{"a.pdf": "one"})
+ r, err := OpenRules(e, "dl")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if text != "" {
+ r.SetText(text)
+ }
+ return r
+}
+
+// TestFormsLists: every editable form of the file, in file order, named the
+// way the list shows it.
+func TestFormsLists(t *testing.T) {
+ r := openForms(t, "")
+ forms, err := r.Forms()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got []string
+ for _, f := range forms {
+ got = append(got, f.Label)
+ }
+ want := []string{`(exclude (name "^keep-"))`, "invoices", "images", "rest"}
+ if strings.Join(got, "|") != strings.Join(want, "|") {
+ t.Errorf("forms = %v, want %v", got, want)
+ }
+ if forms[0].Kind != ExcludeForm || forms[1].Kind != RuleForm {
+ t.Errorf("kinds = %v, %v", forms[0].Kind, forms[1].Kind)
+ }
+ if forms[1].Rule == nil || forms[1].Rule.Name != "invoices" {
+ t.Errorf("rule form carries no rule: %+v", forms[1])
+ }
+}
+
+// TestDeleteFormTakesItsComments: a rule's block is its form plus the
+// comment lines directly above it with no blank line between (GUI design
+// §5.1), so deleting it does not leave its comment stranded - while a
+// comment separated by a blank line stays.
+func TestDeleteFormTakesItsComments(t *testing.T) {
+ r := openForms(t, "")
+ if err := r.DeleteForm(1); err != nil { // invoices, with its comment
+ t.Fatal(err)
+ }
+ if strings.Contains(r.Text, "invoices go to their year") {
+ t.Errorf("the rule's own comment was left behind:\n%s", r.Text)
+ }
+ if strings.Contains(r.Text, `(rule "invoices"`) {
+ t.Errorf("the rule is still there:\n%s", r.Text)
+ }
+ // The free-standing comment above "rest" is separated by a blank line,
+ // so deleting that rule leaves it alone.
+ forms, err := r.Forms()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := r.DeleteForm(len(forms) - 1); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(r.Text, ";; last resort") {
+ t.Errorf("a comment separated by a blank line was taken too:\n%s", r.Text)
+ }
+ if diags := r.Check(); len(diags) > 0 {
+ t.Errorf("the file no longer loads: %v", diags)
+ }
+}
+
+// TestMoveForm: a rule moves with its comments, and the order of everything
+// else is kept.
+func TestMoveForm(t *testing.T) {
+ r := openForms(t, "")
+ if err := r.MoveForm(2, -1); err != nil { // images, up past invoices
+ t.Fatal(err)
+ }
+ forms, err := r.Forms()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got []string
+ for _, f := range forms {
+ got = append(got, f.Label)
+ }
+ want := []string{`(exclude (name "^keep-"))`, "images", "invoices", "rest"}
+ if strings.Join(got, "|") != strings.Join(want, "|") {
+ t.Fatalf("after moving up: %v, want %v", got, want)
+ }
+ // The comment travelled with its rule, and still sits above it.
+ iInvoices := strings.Index(r.Text, `(rule "invoices"`)
+ iComment := strings.Index(r.Text, ";; invoices go to their year")
+ if iComment < 0 || iComment > iInvoices {
+ t.Errorf("the comment did not travel with its rule:\n%s", r.Text)
+ }
+ if strings.Index(r.Text, `(rule "images"`) > iComment {
+ t.Errorf("images did not move above invoices:\n%s", r.Text)
+ }
+ if diags := r.Check(); len(diags) > 0 {
+ t.Errorf("the file no longer loads: %v", diags)
+ }
+ // Moving the first form up, or the last down, does nothing.
+ before := r.Text
+ if err := r.MoveForm(0, -1); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.MoveForm(len(forms)-1, 1); err != nil {
+ t.Fatal(err)
+ }
+ if r.Text != before {
+ t.Error("moving past the ends changed the file")
+ }
+}
+
+// TestAddRuleAfter: a new rule lands after the one selected, is the form
+// the printer writes, and the file still loads.
+func TestAddRuleAfter(t *testing.T) {
+ r := openForms(t, "")
+ i, err := r.AddRuleAfter(1, "new one")
+ if err != nil {
+ t.Fatal(err)
+ }
+ forms, err := r.Forms()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if i != 2 || forms[2].Label != "new one" {
+ t.Fatalf("new rule at %d: %v", i, labels(forms))
+ }
+ if diags := r.Check(); len(diags) > 0 {
+ t.Errorf("the file no longer loads: %v", diags)
+ }
+}
+
+// TestReplaceForm: editing one form rewrites that form's text and nothing
+// else - every comment and every other line stays byte for byte.
+func TestReplaceForm(t *testing.T) {
+ r := openForms(t, "")
+ forms, err := r.Forms()
+ if err != nil {
+ t.Fatal(err)
+ }
+ rule := *forms[1].Rule // invoices
+ rule.Actions = []config.Action{{Kind: config.Move, Arg: "Faktury"}}
+ if err := r.ReplaceForm(1, config.PrintRule(&rule)); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(r.Text, `(move "Faktury")`) {
+ t.Errorf("the edit did not land:\n%s", r.Text)
+ }
+ if strings.Contains(r.Text, `(move "Invoices/{mtime:%Y}")`) {
+ t.Errorf("the old action is still there:\n%s", r.Text)
+ }
+ for _, keep := range []string{";; invoices go to their year", ";; last resort",
+ "; anything the type table calls an image", `(exclude (name "^keep-"))`} {
+ if !strings.Contains(r.Text, keep) {
+ t.Errorf("replacing one form lost %q:\n%s", keep, r.Text)
+ }
+ }
+ if diags := r.Check(); len(diags) > 0 {
+ t.Errorf("the file no longer loads: %v", diags)
+ }
+}
+
+// TestFormHasComments: a form with a comment inside it cannot be rewritten
+// from a form editor without losing it, so the caller is warned; a
+// semicolon inside a string is not a comment (GUI design §5.1).
+func TestFormHasComments(t *testing.T) {
+ r := openForms(t, "")
+ forms, err := r.Forms()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, err := r.FormHasComments(2); err != nil || !got { // images
+ t.Errorf("FormHasComments(images) = %v, %v; want true", got, err)
+ }
+ if got, err := r.FormHasComments(1); err != nil || got { // invoices
+ t.Errorf("FormHasComments(invoices) = %v, %v; want false", got, err)
+ }
+ _ = forms
+
+ r.SetText("(path \"~/dl\")\n(rule \"semi\" (when (name \"a;b\")) (move \"Out\"))\n")
+ if got, err := r.FormHasComments(0); err != nil || got {
+ t.Errorf("a semicolon inside a string counted as a comment: %v, %v", got, err)
+ }
+}
+
+// TestFormsRefusesBrokenText: the forms list comes from parsing, so text
+// that does not parse has no forms to show, and says so.
+func TestFormsRefusesBrokenText(t *testing.T) {
+ r := openForms(t, "(path \"~/dl\")\n(rule \"unclosed\"\n")
+ if _, err := r.Forms(); err == nil {
+ t.Error("Forms answered for text that does not parse")
+ }
+ if err := r.DeleteForm(0); err == nil {
+ t.Error("DeleteForm acted on text that does not parse")
+ }
+}
+
+func labels(forms []Form) []string {
+ var out []string
+ for _, f := range forms {
+ out = append(out, f.Label)
+ }
+ return out
+}