internal/ui/view.go
1
package ui
3
import (
4
"net/url"
5
"strings"
6
"unicode/utf8"
8
"github.com/koment-dev/koment/internal/anchor"
9
"github.com/koment-dev/koment/internal/application"
10
"github.com/koment-dev/koment/internal/store"
11
)
13
const sourceURL = "https://github.com/koment-dev/koment"
15
type view struct {
16
Total int
17
Tally []tallyEntry
18
Tree []treeNode
19
Loose []entry
20
Repositories []repositoryLink
21
Repository string
22
Current string
23
File *fileView
24
Empty bool
25
NotFound bool
26
Home string
27
Stylesheet string
28
Script string
29
LogoSVG string
30
LogoPNG string
31
SourceURL string
32
Snapshot *snapshot
33
WriteToken string
34
CanWrite bool
35
CreatedID string
36
WriteWarning string
37
ReviewURL string
38
}
40
type snapshot struct {
41
Commit string
42
CommitURL string
43
Banner string
44
BannerHref string
45
}
47
type repositoryLink struct {
48
ID string
49
Name string
50
Href string
51
Current bool
52
}
54
type links struct {
55
file func(target string) string
56
home string
57
stylesheet string
58
script string
59
logoSVG string
60
logoPNG string
61
}
63
func servedLinks(repositoryID string) links {
64
base := repositoryPrefix + repositoryID + "/"
65
return links{
66
file: func(target string) string { return base + "f/" + escapedFilePath(target) },
67
home: base,
68
stylesheet: "/assets/style.css",
69
script: "/assets/koment.js",
70
logoSVG: "/assets/koment-logo.svg",
71
logoPNG: "/assets/koment-logo.png",
72
}
73
}
75
func escapedFilePath(file string) string {
76
parts := strings.Split(file, "/")
77
for index, part := range parts {
78
parts[index] = url.PathEscape(part)
79
}
80
return strings.Join(parts, "/")
81
}
83
type tallyEntry struct {
84
Status anchor.Status
85
Count int
86
}
88
type entry struct {
89
Path string
90
Name string
91
Href string
92
Count int
93
Worst anchor.Status
94
Current bool
95
Search string
96
}
98
type fileView struct {
99
Path string
100
Lines []line
101
Notes []note
102
Detached []note
103
Missing bool
104
}
106
type line struct {
107
Number int
108
Text string
109
Marker anchor.Status
110
}
112
type note struct {
113
ID string
114
Kind string
115
Title string
116
Status anchor.Status
117
Line int
118
Body []string
119
Rest []string
120
Created string
121
Excerpt string
122
Stale bool
123
Warning string
124
}
126
var statusOrder = []anchor.Status{
127
anchor.StatusOK, anchor.StatusAmbiguous, anchor.StatusDrifted, anchor.StatusOrphaned,
128
}
130
func build(repositorySnapshot *application.RepositorySnapshot, requested string, how links) (*view, error) {
131
if len(repositorySnapshot.Files) == 0 {
132
return &view{
133
Empty: true, Stylesheet: how.stylesheet, Script: how.script,
134
Home: how.home, LogoSVG: how.logoSVG, LogoPNG: how.logoPNG, SourceURL: sourceURL,
135
}, nil
136
}
138
current := requested
139
if current == "" {
140
current = repositorySnapshot.Files[0].Path
141
}
143
built := &view{
144
Current: current,
145
Home: how.home,
146
Stylesheet: how.stylesheet,
147
Script: how.script,
148
LogoSVG: how.logoSVG,
149
LogoPNG: how.logoPNG,
150
SourceURL: sourceURL,
151
}
152
counts := map[anchor.Status]int{}
153
listed := make([]entry, 0, len(repositorySnapshot.Files))
155
for _, file := range repositorySnapshot.Files {
156
worst := anchor.StatusOK
157
var searchable strings.Builder
158
searchable.WriteString(file.Path)
159
for _, annotation := range file.Annotations {
160
counts[annotation.Status]++
161
built.Total++
162
if statusSeverity[annotation.Status] > statusSeverity[worst] {
163
worst = annotation.Status
164
}
165
searchable.WriteString("\n" + string(annotation.Record.Spec.Type) + "\n" + annotation.Record.Headline() + "\n" + annotation.Record.Spec.Body + "\n" + annotation.Record.Spec.Author.Name)
166
}
168
listed = append(listed, entry{
169
Path: file.Path,
170
Name: baseName(file.Path),
171
Href: how.file(file.Path),
172
Count: len(file.Annotations),
173
Worst: worst,
174
Current: file.Path == current,
175
Search: strings.ToLower(searchable.String()),
176
})
178
if file.Path == current {
179
built.File = buildFile(file)
180
}
181
}
183
if built.File == nil {
184
built.NotFound = true
185
}
186
built.Tally = tallyOf(counts)
187
built.Tree, built.Loose = buildTree(listed, current)
188
return built, nil
189
}
191
func baseName(file string) string {
192
if cut := strings.LastIndex(file, "/"); cut >= 0 {
193
return file[cut+1:]
194
}
195
return file
196
}
198
func tallyOf(counts map[anchor.Status]int) []tallyEntry {
199
var tally []tallyEntry
200
for _, status := range statusOrder {
201
if counts[status] > 0 {
202
tally = append(tally, tallyEntry{Status: status, Count: counts[status]})
203
}
204
}
205
return tally
206
}
208
func buildFile(file application.FileSnapshot) *fileView {
209
built := &fileView{Path: file.Path}
210
if !file.Exists {
211
built.Missing = true
212
for _, annotation := range file.Annotations {
213
built.Detached = append(built.Detached, describe(annotation))
214
}
215
return built
216
}
218
marked := map[int]anchor.Status{}
219
for _, annotation := range file.Annotations {
220
described := describe(annotation)
221
if annotation.Line == 0 {
222
built.Detached = append(built.Detached, described)
223
continue
224
}
225
built.Notes = append(built.Notes, described)
226
worst, seen := marked[annotation.Line]
227
if !seen || statusSeverity[annotation.Status] > statusSeverity[worst] {
228
marked[annotation.Line] = annotation.Status
229
}
230
}
232
for i, text := range strings.Split(strings.TrimSuffix(string(file.Content), "\n"), "\n") {
233
number := i + 1
234
built.Lines = append(built.Lines, line{Number: number, Text: text, Marker: marked[number]})
235
}
236
return built
237
}
239
func describe(annotation application.AnnotationView) note {
240
stale := annotation.Status.IsFailure()
241
shown, folded := splitBody(store.Paragraphs(annotation.Record.Spec.Body))
242
return note{
243
ID: annotation.Record.Metadata.ID,
244
Kind: string(annotation.Record.Spec.Type),
245
Status: annotation.Status,
246
Line: annotation.Line,
247
Title: annotation.Record.Headline(),
248
Body: shown,
249
Rest: folded,
250
Created: annotation.Record.Metadata.Created.Format("2006-01-02"),
251
Excerpt: annotation.Record.Spec.Anchor.Excerpt,
252
Stale: stale,
253
Warning: annotation.Warning,
254
}
255
}
257
const (
258
visibleBodyBudget = 600
259
shortestWorthHiding = 160
260
)
262
func splitBody(paragraphs []string) (shown, folded []string) {
263
if len(paragraphs) < 2 {
264
return paragraphs, nil
265
}
266
spent := utf8.RuneCountInString(paragraphs[0])
267
for index := 1; index < len(paragraphs); index++ {
268
paragraphLength := utf8.RuneCountInString(paragraphs[index])
269
if spent+paragraphLength <= visibleBodyBudget {
270
spent += paragraphLength
271
continue
272
}
273
tail := paragraphs[index:]
274
if lengthOf(tail) < shortestWorthHiding {
275
return paragraphs, nil
276
}
277
return paragraphs[:index], tail
278
}
279
return paragraphs, nil
280
}
282
func lengthOf(paragraphs []string) int {
283
total := 0
284
for _, paragraph := range paragraphs {
285
total += utf8.RuneCountInString(paragraph)
286
}
287
return total
288
}