summaryrefslogtreecommitdiff
path: root/vrsite/model.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-01 16:18:19 -0300
committerGabriel Schneider <[email protected]>2026-08-01 18:21:40 -0300
commit151ea12251ed876aa87bb486da1bc337e25369e9 (patch)
tree0363d90b5df6fc8b17b1737ef2793ba3825ba4d4 /vrsite/model.go
parent8ec4e135e318c7799b2576a8729493c063da4a97 (diff)
downloadnotevi-151ea12251ed876aa87bb486da1bc337e25369e9.tar.gz
notevi-151ea12251ed876aa87bb486da1bc337e25369e9.zip
Diffstat (limited to 'vrsite/model.go')
-rw-r--r--vrsite/model.go114
1 files changed, 102 insertions, 12 deletions
diff --git a/vrsite/model.go b/vrsite/model.go
index 678d5ee..b580b49 100644
--- a/vrsite/model.go
+++ b/vrsite/model.go
@@ -35,6 +35,7 @@ type Entry struct {
Pattern string `json:"pattern,omitempty"`
Matches int `json:"matches,omitempty"`
NoteID int `json:"note_id,omitempty"`
+ ReplyTo int `json:"reply_to,omitempty"`
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
}
@@ -52,10 +53,23 @@ type Note struct {
Session, Model, Reason string
File, Kind, Text, Change, Rev string
Start, End int
+ ReplyTo int
+ Replies, Mentions []*Note // notes that point back to this one
At time.Time
When, Ago string
}
+// ThreadNode is the tree-shaped rendering of the note DAG. A note reached by
+// more than one path is emitted fully once and then as a compact reference, so
+// the focused page keeps valid DOM ids and cannot recurse through malformed
+// cyclic logs.
+type ThreadNode struct {
+ Note *Note
+ Via string // "reply" | "mention"; empty for the focused root
+ Children []*ThreadNode
+ Reference bool
+}
+
type File struct {
Path, Change string
Traced bool
@@ -150,7 +164,8 @@ func buildModel(title string, entries []Entry, only string) *Site {
at := parseTime(e.Time)
n := &Note{ID: noteN, NoteID: e.NoteID, Agent: a, Session: e.Session,
Model: e.Model, Reason: e.Reason, File: e.File, Change: e.Change,
- Rev: e.Rev, Start: e.Start, End: e.End, Kind: e.Type, Text: e.Text,
+ Rev: e.Rev, Start: e.Start, End: e.End, ReplyTo: e.ReplyTo,
+ Kind: e.Type, Text: e.Text,
At: at, When: stamp(at), Ago: ago(at)}
s.Notes = append(s.Notes, n)
if e.File != "" {
@@ -183,6 +198,7 @@ func buildModel(title string, entries []Entry, only string) *Site {
s.Order = append(s.Order, p)
}
sort.Strings(s.Order)
+ linkNotes(s.Notes)
return s
}
@@ -243,17 +259,6 @@ func (s *Site) filesAt(paths []string, change string) []*File {
return all
}
-// maskColor mixes the theme's agent colors for a coverage bitmask.
-func (s *Site) maskColor(t *Theme, mask uint8) string {
- var cols []string
- for i, a := range s.Agents {
- if a.Bit != 0 && mask&a.Bit != 0 {
- cols = append(cols, t.Player(i))
- }
- }
- return mixColors(cols)
-}
-
// masks lists every bitmask combination the colored agents can produce.
func (s *Site) masks() []uint8 {
n := len(s.Agents)
@@ -318,6 +323,91 @@ func (s *Site) FilterNotes(f NoteFilter) []*Note {
return out
}
+// NoteByID finds a note by the id a permalink names. Ids count over the whole
+// log and the log only ever grows, so the link a reader was given goes on
+// meaning the same note however the site is later scoped or rebuilt.
+func (s *Site) NoteByID(id int) *Note {
+ for _, n := range s.Notes {
+ if n.ID == id {
+ return n
+ }
+ }
+ return nil
+}
+
+// linkNotes derives the graph without adding another log operation. Replies
+// are explicit reply_to edges; mentions are ordinary local Markdown links to
+// /note/N (or an equivalent relative path). If a reply also links its parent,
+// the explicit relationship wins and the child is not counted twice.
+func linkNotes(notes []*Note) {
+ byID := make(map[int]*Note, len(notes))
+ for _, n := range notes {
+ byID[n.ID] = n
+ }
+ links := newNoteLinkParser()
+ for _, child := range notes {
+ if parent := byID[child.ReplyTo]; parent != nil && parent != child {
+ parent.Replies = append(parent.Replies, child)
+ }
+ for _, id := range noteLinks(links, child.Text) {
+ parent := byID[id]
+ if parent == nil || parent == child || id == child.ReplyTo {
+ continue
+ }
+ parent.Mentions = append(parent.Mentions, child)
+ }
+ }
+}
+
+// NoteThread expands replies and mentions together, oldest first. The global
+// seen set turns a DAG into one readable Hacker News-style thread while still
+// leaving a reference at every additional incoming edge.
+func (s *Site) NoteThread(id int) *ThreadNode {
+ root := s.NoteByID(id)
+ if root == nil {
+ return nil
+ }
+ seen := map[int]bool{id: true}
+ var build func(*Note) *ThreadNode
+ build = func(n *Note) *ThreadNode {
+ node := &ThreadNode{Note: n}
+ for _, edge := range noteEdges(n) {
+ child := &ThreadNode{Note: edge.Note, Via: edge.Via}
+ if seen[edge.Note.ID] {
+ child.Reference = true
+ } else {
+ seen[edge.Note.ID] = true
+ child = build(edge.Note)
+ child.Via = edge.Via
+ }
+ node.Children = append(node.Children, child)
+ }
+ return node
+ }
+ return build(root)
+}
+
+type noteEdge struct {
+ Note *Note
+ Via string
+}
+
+func noteEdges(n *Note) []noteEdge {
+ seen := map[int]bool{}
+ edges := make([]noteEdge, 0, len(n.Replies)+len(n.Mentions))
+ for _, child := range n.Replies {
+ seen[child.ID] = true
+ edges = append(edges, noteEdge{Note: child, Via: "reply"})
+ }
+ for _, child := range n.Mentions {
+ if !seen[child.ID] {
+ edges = append(edges, noteEdge{Note: child, Via: "mention"})
+ }
+ }
+ sort.SliceStable(edges, func(i, j int) bool { return edges[i].Note.ID < edges[j].Note.ID })
+ return edges
+}
+
// Facets are the distinct values the note filters can select, so the form
// only ever offers choices that exist in the log.
type Facets struct {