GitRoot

craft your forge, build your project, grow your community freely
  1// SPDX-FileCopyrightText: 2025 Romain Maneschi <romain@gitroot.dev>
  2//
  3// SPDX-License-Identifier: EUPL-1.2
  4
  5package main
  6
  7import (
  8	"bytes"
  9	"fmt"
 10	"io/fs"
 11	"path/filepath"
 12	"regexp"
 13	"strings"
 14
 15	"github.com/tidwall/gjson"
 16	"gitroot.dev/libs/golang/glob"
 17	"gitroot.dev/libs/golang/plugin/model"
 18)
 19
 20type conf struct {
 21	Title       string `json:"title"`
 22	Description string `json:"description"`
 23	Format      string `json:"format"`
 24	TableHeader string `json:"tableHeader"`
 25	For         string `json:"for"`
 26	glob        *glob.Glob
 27	Where       string   `json:"where"`
 28	Selects     []string `json:"selects"`
 29	selections  []*regexp.Regexp
 30	To          string `json:"to"`
 31	Paginator   int64  `json:"paginator"`
 32	Sort        string `json:"sort"`
 33	SortOrder   string `json:"sortOrder"`
 34}
 35
 36const roadmapDescription = `This file provides an overview of the direction this project is heading.
 37
 38> When it is obvious that the goals cannot be reached, don’t adjust the goals, adjust the action steps.
 39
 40_[Confucius](https://en.wikipedia.org/wiki/Confucius)_
 41
 42## List`
 43
 44var defaultConf = map[string]any{"boards": []conf{
 45	{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"},
 46	{Title: "Todo is lie", Description: "", Format: "embed", TableHeader: "", For: "**/*|!.gitroot/**", Where: "// TODO", Selects: []string{}, To: "boards/todos.md", Paginator: 0, Sort: "file", SortOrder: "asc"},
 47	{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"},
 48	{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"},
 49}}
 50
 51var defaultRun = []model.PluginRun{{
 52	Path:   "**/*",
 53	Branch: []string{"main"},
 54	When:   model.PluginRunWhenAll,
 55	Func:   []model.PluginFunc{},
 56	Write: model.PluginWrite{
 57		Git: []model.PluginWriteRight{{
 58			Path: "boards/*.md",
 59			Can:  model.PluginWriteRightCanAll,
 60		}},
 61		Web:      []model.PluginWriteRight{},
 62		Exec:     []model.PluginExecRight{},
 63		CallFunc: []model.PluginCallFuncRight{},
 64	},
 65	Configuration: defaultConf,
 66}}
 67
 68func (p *Plugin) unmarshalConf(serializedConf string) []conf {
 69	c := []conf{}
 70	for _, meta := range gjson.Parse(serializedConf).Get("boards").Array() {
 71		selects := make([]string, 0)
 72		for _, s := range meta.Get("selects").Array() {
 73			selects = append(selects, s.String())
 74		}
 75		myConf := conf{
 76			Title:       meta.Get("title").String(),
 77			Description: meta.Get("description").String(),
 78			Format:      meta.Get("format").String(),
 79			TableHeader: meta.Get("tableHeader").String(),
 80			For:         meta.Get("for").String(),
 81			Where:       meta.Get("where").String(),
 82			Selects:     selects,
 83			To:          meta.Get("to").String(),
 84			Paginator:   meta.Get("paginator").Int(),
 85			Sort:        meta.Get("sort").String(),
 86			SortOrder:   meta.Get("sortOrder").String(),
 87		}
 88		//default format
 89		if myConf.Format == "" {
 90			myConf.Format = "list"
 91		}
 92		//select to selections
 93		selections := make([]*regexp.Regexp, 0)
 94		for _, se := range myConf.Selects {
 95			if r, err := regexp.Compile(se); err != nil {
 96				p.server.LogError("conf regexp.Compile select="+se, err)
 97			} else {
 98				selections = append(selections, r)
 99			}
100		}
101		myConf.selections = selections
102		//default paginator
103		if myConf.Paginator == 0 {
104			myConf.Paginator = -1
105		}
106		//for glob
107		glob, err := glob.NewGlob(myConf.For)
108		if err != nil {
109			p.server.LogError("error glob", err)
110		}
111		myConf.glob = glob
112
113		c = append(c, myConf)
114	}
115	return c
116}
117
118func (c conf) initFile() string {
119	page := fmt.Sprintf("# %s\n", c.Title)
120	if c.Description != "" {
121		page = fmt.Sprintf("# %s\n\n%s\n", c.Title, c.Description)
122	}
123	if c.Format == "table" {
124		nb := len(c.selections) + 1
125		if c.TableHeader != "" {
126			page = fmt.Sprintf("%s\n%s\n", page, c.TableHeader)
127		} else {
128			page = fmt.Sprintf("%s\n|%s\n", page, strings.Repeat("   |", nb))
129		}
130		page = fmt.Sprintf("%s|%s", page, strings.Repeat("---|", nb))
131	}
132	return page
133}
134
135func (c conf) relativePath(toPath string) string {
136	absFromPath, _ := filepath.Abs(c.To)
137	dirFromPath, _ := filepath.Split(absFromPath)
138	absToPath, _ := filepath.Abs(toPath)
139	dirToPath, fileToPath := filepath.Split(absToPath)
140	path, err := filepath.Rel(dirFromPath, dirToPath)
141	if err != nil {
142		return toPath
143	}
144	return fmt.Sprintf("%s/%s", path, fileToPath)
145}
146
147func (c conf) makeLink(p *Plugin, cacheLine CacheLine) string {
148	path := c.relativePath(cacheLine.fp)
149	prepend := ""
150	if c.Format == "list" {
151		prepend = "- "
152	} else if c.Format == "task" {
153		prepend = "- [ ] "
154	} else if c.Format == "table" {
155		prepend = "| "
156	} else if c.Format == "embed" {
157		prepend = "### "
158	}
159
160	append := ""
161	if c.Format == "table" {
162		append = " |"
163	} else if c.Format == "embed" {
164		append = "\n\n"
165	}
166	t := cacheLine.selects
167	if len(t) > 0 {
168		for i, a := range t {
169			if i == 0 && c.Format == "embed" {
170				append = fmt.Sprintf("%s%s", append, a)
171			} else if c.Format == "table" {
172				append = fmt.Sprintf("%s %s |", append, a)
173			} else if a != " " { //special case du to serialization in cache
174				append = fmt.Sprintf("%s %s", append, a)
175			}
176		}
177	}
178	if c.Format == "embed" {
179		append = fmt.Sprintf("%s\n%s\n", append, c.findEmbed(p, cacheLine.fp))
180	}
181
182	return fmt.Sprintf("%s[%s](%s)%s", prepend, cacheLine.title, path, append)
183}
184
185func findTitle(path string, content []byte) string {
186	if strings.HasSuffix(path, ".md") { //special case, search first title
187		re := regexp.MustCompile(`(?m)^#\s(.*)$`)
188		t := re.FindSubmatch(content)
189		if len(t) > 1 {
190			return string(t[1])
191		}
192	}
193	return path
194}
195
196func (c conf) findEmbed(p *Plugin, path string) string {
197	content, err := fs.ReadFile(p.server.Worktree(), path)
198	if err != nil {
199		p.server.LogError("can't find file", err)
200		return ""
201	}
202	if strings.HasSuffix(path, ".md") { //special case, search first description
203		re := regexp.MustCompile(`(?m)^#\s(.*)$`)
204		t := re.FindSubmatch(content)
205		hasTitle := len(t) > 1
206		hasMeta := bytes.Contains(content, []byte("---"))
207		contents := bytes.Split(content, []byte("\n"))
208		for i, l := range contents {
209			titleCase := hasTitle && bytes.HasPrefix(l, []byte("# "))
210			metaCase := !hasTitle && hasMeta && i > 0 && bytes.HasPrefix(l, []byte("---"))
211			bodyCase := !hasTitle && !hasMeta
212			if titleCase || metaCase || bodyCase {
213				res := ""
214				if i+1 < len(contents) && len(contents[i+1]) > 0 {
215					res = fmt.Sprintf("%s\n%s", res, string(contents[i+1]))
216				}
217				if i+2 < len(contents) && len(contents[i+2]) > 0 {
218					res = fmt.Sprintf("%s\n%s", res, string(contents[i+2]))
219				}
220				if i+3 < len(contents) && len(contents[i+3]) > 0 {
221					res = fmt.Sprintf("%s\n%s", res, string(contents[i+3]))
222				}
223				return res
224			}
225		}
226	} else {
227		contents := bytes.Split(content, []byte("\n"))
228		for i, l := range contents {
229			if bytes.Contains(l, []byte(c.Where)) {
230				var buffer bytes.Buffer
231				buffer.Write([]byte("```\n"))
232				if i > 0 {
233					buffer.Write(contents[i-1])
234					buffer.Write([]byte("\n"))
235				}
236				buffer.Write(l)
237				if i < len(contents) {
238					buffer.Write([]byte("\n"))
239					buffer.Write(contents[i+1])
240				}
241				buffer.Write([]byte("\n```"))
242				return buffer.String()
243			}
244		}
245	}
246
247	return "-"
248}