From 151ea12251ed876aa87bb486da1bc337e25369e9 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sat, 1 Aug 2026 16:18:19 -0300 Subject: --- vrsite/model.go | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 102 insertions(+), 12 deletions(-) (limited to 'vrsite/model.go') 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 { -- cgit v1.3