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 "gitroot.dev/libs/golang/plugin"
 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 = []gitroot.PluginRun{{
 52	Path:   "**/*",
 53	Branch: []string{"main"},
 54	When:   gitroot.PluginRunWhenAll,
 55	Write: gitroot.PluginWrite{
 56		Git: []gitroot.PluginWriteRight{{
 57			Path: "boards/*.md",
 58			Can:  gitroot.PluginWriteRightCanAll,
 59		}},
 60		Web: []gitroot.PluginWriteRight{},
 61	},
 62	Configuration: defaultConf,
 63}}
 64
 65func (p *Plugin) unmarshalConf(serializedConf string) []conf {
 66	c := []conf{}
 67	for _, meta := range gjson.Parse(serializedConf).Get("boards").Array() {
 68		selects := make([]string, 0)
 69		for _, s := range meta.Get("selects").Array() {
 70			selects = append(selects, s.String())
 71		}
 72		myConf := conf{
 73			Title:       meta.Get("title").String(),
 74			Description: meta.Get("description").String(),
 75			Format:      meta.Get("format").String(),
 76			TableHeader: meta.Get("tableHeader").String(),
 77			For:         meta.Get("for").String(),
 78			Where:       meta.Get("where").String(),
 79			Selects:     selects,
 80			To:          meta.Get("to").String(),
 81			Paginator:   meta.Get("paginator").Int(),
 82			Sort:        meta.Get("sort").String(),
 83			SortOrder:   meta.Get("sortOrder").String(),
 84		}
 85		//default format
 86		if myConf.Format == "" {
 87			myConf.Format = "list"
 88		}
 89		//select to selections
 90		selections := make([]*regexp.Regexp, 0)
 91		for _, se := range myConf.Selects {
 92			if r, err := regexp.Compile(se); err != nil {
 93				p.server.LogError("conf regexp.Compile select="+se, err)
 94			} else {
 95				selections = append(selections, r)
 96			}
 97		}
 98		myConf.selections = selections
 99		//default paginator
100		if myConf.Paginator == 0 {
101			myConf.Paginator = -1
102		}
103		//for glob
104		glob, err := glob.NewGlob(myConf.For)
105		if err != nil {
106			p.server.LogError("error glob", err)
107		}
108		myConf.glob = glob
109
110		c = append(c, myConf)
111	}
112	return c
113}
114
115func (c conf) initFile() string {
116	page := fmt.Sprintf("# %s\n", c.Title)
117	if c.Description != "" {
118		page = fmt.Sprintf("# %s\n\n%s\n", c.Title, c.Description)
119	}
120	if c.Format == "table" {
121		nb := len(c.selections) + 1
122		if c.TableHeader != "" {
123			page = fmt.Sprintf("%s\n%s\n", page, c.TableHeader)
124		} else {
125			page = fmt.Sprintf("%s\n|%s\n", page, strings.Repeat("   |", nb))
126		}
127		page = fmt.Sprintf("%s|%s", page, strings.Repeat("---|", nb))
128	}
129	return page
130}
131
132func (c conf) relativePath(toPath string) string {
133	absFromPath, _ := filepath.Abs(c.To)
134	dirFromPath, _ := filepath.Split(absFromPath)
135	absToPath, _ := filepath.Abs(toPath)
136	dirToPath, fileToPath := filepath.Split(absToPath)
137	path, err := filepath.Rel(dirFromPath, dirToPath)
138	if err != nil {
139		return toPath
140	}
141	return fmt.Sprintf("%s/%s", path, fileToPath)
142}
143
144func (c conf) makeLink(p *Plugin, cacheLine CacheLine) string {
145	path := c.relativePath(cacheLine.fp)
146	prepend := ""
147	if c.Format == "list" {
148		prepend = "- "
149	} else if c.Format == "task" {
150		prepend = "- [ ] "
151	} else if c.Format == "table" {
152		prepend = "| "
153	} else if c.Format == "embed" {
154		prepend = "### "
155	}
156
157	append := ""
158	if c.Format == "table" {
159		append = " |"
160	} else if c.Format == "embed" {
161		append = "\n\n"
162	}
163	t := cacheLine.selects
164	if len(t) > 0 {
165		for i, a := range t {
166			if i == 0 && c.Format == "embed" {
167				append = fmt.Sprintf("%s%s", append, a)
168			} else if c.Format == "table" {
169				append = fmt.Sprintf("%s %s |", append, a)
170			} else if a != " " { //special case du to serialization in cache
171				append = fmt.Sprintf("%s %s", append, a)
172			}
173		}
174	}
175	if c.Format == "embed" {
176		append = fmt.Sprintf("%s\n%s\n", append, c.findEmbed(p, cacheLine.fp))
177	}
178
179	return fmt.Sprintf("%s[%s](%s)%s", prepend, cacheLine.title, path, append)
180}
181
182func findTitle(path string, content []byte) string {
183	if strings.HasSuffix(path, ".md") { //special case, search first title
184		re := regexp.MustCompile(`(?m)^#\s(.*)$`)
185		t := re.FindSubmatch(content)
186		if len(t) > 1 {
187			return string(t[1])
188		}
189	}
190	return path
191}
192
193func (c conf) findEmbed(p *Plugin, path string) string {
194	content, err := fs.ReadFile(p.server.Worktree(), path)
195	if err != nil {
196		p.server.LogError("can't find file", err)
197		return ""
198	}
199	if strings.HasSuffix(path, ".md") { //special case, search first description
200		re := regexp.MustCompile(`(?m)^#\s(.*)$`)
201		t := re.FindSubmatch(content)
202		hasTitle := len(t) > 1
203		hasMeta := bytes.Contains(content, []byte("---"))
204		contents := bytes.Split(content, []byte("\n"))
205		for i, l := range contents {
206			titleCase := hasTitle && bytes.HasPrefix(l, []byte("# "))
207			metaCase := !hasTitle && hasMeta && i > 0 && bytes.HasPrefix(l, []byte("---"))
208			bodyCase := !hasTitle && !hasMeta
209			if titleCase || metaCase || bodyCase {
210				res := ""
211				if i+1 < len(contents) && len(contents[i+1]) > 0 {
212					res = fmt.Sprintf("%s\n%s", res, string(contents[i+1]))
213				}
214				if i+2 < len(contents) && len(contents[i+2]) > 0 {
215					res = fmt.Sprintf("%s\n%s", res, string(contents[i+2]))
216				}
217				if i+3 < len(contents) && len(contents[i+3]) > 0 {
218					res = fmt.Sprintf("%s\n%s", res, string(contents[i+3]))
219				}
220				return res
221			}
222		}
223	} else {
224		contents := bytes.Split(content, []byte("\n"))
225		for i, l := range contents {
226			if bytes.Contains(l, []byte(c.Where)) {
227				var buffer bytes.Buffer
228				buffer.Write([]byte("```\n"))
229				if i > 0 {
230					buffer.Write(contents[i-1])
231					buffer.Write([]byte("\n"))
232				}
233				buffer.Write(l)
234				if i < len(contents) {
235					buffer.Write([]byte("\n"))
236					buffer.Write(contents[i+1])
237				}
238				buffer.Write([]byte("\n```"))
239				return buffer.String()
240			}
241		}
242	}
243
244	return "-"
245}