// SPDX-FileCopyrightText: 2025 Romain Maneschi // // SPDX-License-Identifier: EUPL-1.2 package main import ( "bytes" "fmt" "io/fs" "path/filepath" "regexp" "strings" "github.com/tidwall/gjson" "gitroot.dev/libs/golang/glob" "gitroot.dev/libs/golang/plugin/model" ) type conf struct { Title string `json:"title"` Description string `json:"description"` Format string `json:"format"` TableHeader string `json:"tableHeader"` For string `json:"for"` glob *glob.Glob Where string `json:"where"` Selects []string `json:"selects"` selections []*regexp.Regexp To string `json:"to"` Paginator int64 `json:"paginator"` Sort string `json:"sort"` SortOrder string `json:"sortOrder"` } const roadmapDescription = `This file provides an overview of the direction this project is heading. > When it is obvious that the goals cannot be reached, don’t adjust the goals, adjust the action steps. _[Confucius](https://en.wikipedia.org/wiki/Confucius)_ ## List` var defaultConf = map[string]any{"boards": []conf{ {Title: "Roadmaps", Description: roadmapDescription, Format: "table", TableHeader: "| | priority |", For: "**/*.md", Where: "kind: 'roadmap'", Selects: []string{"priority: (\\d+)"}, To: "boards/roadmap.md", Paginator: 0, Sort: "title", SortOrder: "asc"}, {Title: "Todo is lie", Description: "", Format: "embed", TableHeader: "", For: "**/*|!.gitroot/**", Where: "// TODO", Selects: []string{}, To: "boards/todos.md", Paginator: 0, Sort: "file", SortOrder: "asc"}, {Title: "All Issues", Description: "All\n[New](./triage.md)", Format: "table", TableHeader: "| | status | priority |", For: "**/*.md", Where: "kind: issue", Selects: []string{"status: (.*)", "priority: (\\d+)"}, To: "boards/issues.md", Paginator: 10, Sort: "select[1]", SortOrder: "desc"}, {Title: "New Issues", Description: "[All](./issues.md)\nNew", Format: "list", TableHeader: "", For: "**/*.md", Where: "status: triage", Selects: []string{}, To: "boards/triage.md", Paginator: 10, Sort: "", SortOrder: "asc"}, }} var defaultRun = []model.PluginRun{{ Path: "**/*", Branch: []string{"main"}, When: model.PluginRunWhenAll, Func: []model.PluginFunc{}, Write: model.PluginWrite{ Git: []model.PluginWriteRight{{ Path: "boards/*.md", Can: model.PluginWriteRightCanAll, }}, Web: []model.PluginWriteRight{}, Exec: []model.PluginExecRight{}, CallFunc: []model.PluginCallFuncRight{}, }, Configuration: defaultConf, }} func (p *Plugin) unmarshalConf(serializedConf string) []conf { c := []conf{} for _, meta := range gjson.Parse(serializedConf).Get("boards").Array() { selects := make([]string, 0) for _, s := range meta.Get("selects").Array() { selects = append(selects, s.String()) } myConf := conf{ Title: meta.Get("title").String(), Description: meta.Get("description").String(), Format: meta.Get("format").String(), TableHeader: meta.Get("tableHeader").String(), For: meta.Get("for").String(), Where: meta.Get("where").String(), Selects: selects, To: meta.Get("to").String(), Paginator: meta.Get("paginator").Int(), Sort: meta.Get("sort").String(), SortOrder: meta.Get("sortOrder").String(), } //default format if myConf.Format == "" { myConf.Format = "list" } //select to selections selections := make([]*regexp.Regexp, 0) for _, se := range myConf.Selects { if r, err := regexp.Compile(se); err != nil { p.server.LogError("conf regexp.Compile select="+se, err) } else { selections = append(selections, r) } } myConf.selections = selections //default paginator if myConf.Paginator == 0 { myConf.Paginator = -1 } //for glob glob, err := glob.NewGlob(myConf.For) if err != nil { p.server.LogError("error glob", err) } myConf.glob = glob c = append(c, myConf) } return c } func (c conf) initFile() string { page := fmt.Sprintf("# %s\n", c.Title) if c.Description != "" { page = fmt.Sprintf("# %s\n\n%s\n", c.Title, c.Description) } if c.Format == "table" { nb := len(c.selections) + 1 if c.TableHeader != "" { page = fmt.Sprintf("%s\n%s\n", page, c.TableHeader) } else { page = fmt.Sprintf("%s\n|%s\n", page, strings.Repeat(" |", nb)) } page = fmt.Sprintf("%s|%s", page, strings.Repeat("---|", nb)) } return page } func (c conf) relativePath(toPath string) string { absFromPath, _ := filepath.Abs(c.To) dirFromPath, _ := filepath.Split(absFromPath) absToPath, _ := filepath.Abs(toPath) dirToPath, fileToPath := filepath.Split(absToPath) path, err := filepath.Rel(dirFromPath, dirToPath) if err != nil { return toPath } return fmt.Sprintf("%s/%s", path, fileToPath) } func (c conf) makeLink(p *Plugin, cacheLine CacheLine) string { path := c.relativePath(cacheLine.fp) prepend := "" if c.Format == "list" { prepend = "- " } else if c.Format == "task" { prepend = "- [ ] " } else if c.Format == "table" { prepend = "| " } else if c.Format == "embed" { prepend = "### " } append := "" if c.Format == "table" { append = " |" } else if c.Format == "embed" { append = "\n\n" } t := cacheLine.selects if len(t) > 0 { for i, a := range t { if i == 0 && c.Format == "embed" { append = fmt.Sprintf("%s%s", append, a) } else if c.Format == "table" { append = fmt.Sprintf("%s %s |", append, a) } else if a != " " { //special case du to serialization in cache append = fmt.Sprintf("%s %s", append, a) } } } if c.Format == "embed" { append = fmt.Sprintf("%s\n%s\n", append, c.findEmbed(p, cacheLine.fp)) } return fmt.Sprintf("%s[%s](%s)%s", prepend, cacheLine.title, path, append) } func findTitle(path string, content []byte) string { if strings.HasSuffix(path, ".md") { //special case, search first title re := regexp.MustCompile(`(?m)^#\s(.*)$`) t := re.FindSubmatch(content) if len(t) > 1 { return string(t[1]) } } return path } func (c conf) findEmbed(p *Plugin, path string) string { content, err := fs.ReadFile(p.server.Worktree(), path) if err != nil { p.server.LogError("can't find file", err) return "" } if strings.HasSuffix(path, ".md") { //special case, search first description re := regexp.MustCompile(`(?m)^#\s(.*)$`) t := re.FindSubmatch(content) hasTitle := len(t) > 1 hasMeta := bytes.Contains(content, []byte("---")) contents := bytes.Split(content, []byte("\n")) for i, l := range contents { titleCase := hasTitle && bytes.HasPrefix(l, []byte("# ")) metaCase := !hasTitle && hasMeta && i > 0 && bytes.HasPrefix(l, []byte("---")) bodyCase := !hasTitle && !hasMeta if titleCase || metaCase || bodyCase { res := "" if i+1 < len(contents) && len(contents[i+1]) > 0 { res = fmt.Sprintf("%s\n%s", res, string(contents[i+1])) } if i+2 < len(contents) && len(contents[i+2]) > 0 { res = fmt.Sprintf("%s\n%s", res, string(contents[i+2])) } if i+3 < len(contents) && len(contents[i+3]) > 0 { res = fmt.Sprintf("%s\n%s", res, string(contents[i+3])) } return res } } } else { contents := bytes.Split(content, []byte("\n")) for i, l := range contents { if bytes.Contains(l, []byte(c.Where)) { var buffer bytes.Buffer buffer.Write([]byte("```\n")) if i > 0 { buffer.Write(contents[i-1]) buffer.Write([]byte("\n")) } buffer.Write(l) if i < len(contents) { buffer.Write([]byte("\n")) buffer.Write(contents[i+1]) } buffer.Write([]byte("\n```")) return buffer.String() } } } return "-" }