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}