// Package thread builds local Usenet conversation trees from article headers. package thread import ( "sort" "strings" "aegis/internal/nntp" ) type Node struct { Header nntp.ArticleHeader Parent *Node Children []*Node } func Build(headers []nntp.ArticleHeader) []*Node { ordered := append([]nntp.ArticleHeader(nil), headers...) sort.SliceStable(ordered, func(i, j int) bool { if ordered[i].Number == ordered[j].Number { return ordered[i].MessageID < ordered[j].MessageID } return ordered[i].Number < ordered[j].Number }) nodes := make(map[string]*Node, len(ordered)) for _, header := range ordered { node := &Node{Header: header} if key := canonicalID(header.MessageID); key != "" { nodes[key] = node } } var roots []*Node for _, node := range nodesInOrder(ordered, nodes) { parentID := lastReference(node.Header.References) parent := nodes[canonicalID(parentID)] if parent == nil || parent == node { roots = append(roots, node) continue } node.Parent = parent parent.Children = append(parent.Children, node) } for _, node := range nodesInOrder(ordered, nodes) { sort.SliceStable(node.Children, func(i, j int) bool { return node.Children[i].Header.Number < node.Children[j].Header.Number }) } return roots } func nodesInOrder(headers []nntp.ArticleHeader, nodes map[string]*Node) []*Node { result := make([]*Node, 0, len(headers)) seen := make(map[*Node]struct{}, len(headers)) for _, header := range headers { node := nodes[canonicalID(header.MessageID)] if node == nil { continue } if _, ok := seen[node]; ok { continue } seen[node] = struct{}{} result = append(result, node) } return result } func lastReference(value string) string { parts := strings.Fields(value) if len(parts) == 0 { return "" } return parts[len(parts)-1] } func canonicalID(value string) string { return strings.ToLower(strings.TrimSpace(value)) }