summaryrefslogtreecommitdiffstats
path: root/internal/thread
diff options
context:
space:
mode:
Diffstat (limited to 'internal/thread')
-rw-r--r--internal/thread/thread.go76
-rw-r--r--internal/thread/thread_test.go18
2 files changed, 94 insertions, 0 deletions
diff --git a/internal/thread/thread.go b/internal/thread/thread.go
new file mode 100644
index 0000000..ed9a47b
--- /dev/null
+++ b/internal/thread/thread.go
@@ -0,0 +1,76 @@
+// 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))
+}
diff --git a/internal/thread/thread_test.go b/internal/thread/thread_test.go
new file mode 100644
index 0000000..146ae0c
--- /dev/null
+++ b/internal/thread/thread_test.go
@@ -0,0 +1,18 @@
+package thread
+
+import (
+ "testing"
+
+ "aegis/internal/nntp"
+)
+
+func TestBuildUsesLastReference(t *testing.T) {
+ roots := Build([]nntp.ArticleHeader{
+ {Number: 1, MessageID: "<root@example>", Subject: "topic"},
+ {Number: 2, MessageID: "<reply@example>", References: "<root@example>"},
+ {Number: 3, MessageID: "<nested@example>", References: "<root@example> <reply@example>"},
+ })
+ if len(roots) != 1 || len(roots[0].Children) != 1 || len(roots[0].Children[0].Children) != 1 {
+ t.Fatalf("unexpected thread tree: %#v", roots)
+ }
+}