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}