From 67efe7cdb86304cd8badccaa3ccb03295a999b67 Mon Sep 17 00:00:00 2001 From: Gab <24553253+gabrix73@users.noreply.github.com> Date: Fri, 13 Jun 2025 18:47:59 +0200 Subject: Update onion-newsreader.go --- onion-newsreader.go | 1278 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 1214 insertions(+), 64 deletions(-) (limited to 'onion-newsreader.go') diff --git a/onion-newsreader.go b/onion-newsreader.go index f61f25d..836b9e4 100644 --- a/onion-newsreader.go +++ b/onion-newsreader.go @@ -1,4 +1,3 @@ - package main import ( @@ -38,6 +37,21 @@ type ArticleOverview struct { References string ByteCount int LineCount int + ParsedDate time.Time +} + +type ThreadNode struct { + Article *ArticleOverview + Children []*ThreadNode + Level int + IsRoot bool +} + +type Thread struct { + Root *ThreadNode + ArticleCount int + LastDate time.Time + Subject string } type NNTPClient struct { @@ -694,15 +708,36 @@ func (c *NNTPClient) parseOverviewLine(line string) *ArticleOverview { byteCount, _ := strconv.Atoi(parts[6]) lineCount, _ := strconv.Atoi(parts[7]) + // Parse the date for threading + parsedDate := time.Now() // fallback + if dateStr := strings.TrimSpace(parts[3]); dateStr != "" { + // Try common date formats + formats := []string{ + time.RFC1123Z, + time.RFC1123, + "Mon, 2 Jan 2006 15:04:05 -0700", + "2 Jan 2006 15:04:05 -0700", + "Mon, 2 Jan 2006 15:04:05 MST", + } + + for _, format := range formats { + if parsed, err := time.Parse(format, dateStr); err == nil { + parsedDate = parsed + break + } + } + } + return &ArticleOverview{ Number: number, Subject: parts[1], From: parts[2], Date: parts[3], - MessageID: parts[4], - References: parts[5], + MessageID: strings.TrimSpace(parts[4]), + References: strings.TrimSpace(parts[5]), ByteCount: byteCount, LineCount: lineCount, + ParsedDate: parsedDate, } } @@ -811,6 +846,267 @@ func (c *NNTPClient) disconnect() { c.isOnionService = false } +// Threading functions +func buildThreads(articles []*ArticleOverview) []*Thread { + log.Printf("๐งต Building threads from %d articles...", len(articles)) + + // Maps for fast lookup + articlesByMessageID := make(map[string]*ArticleOverview) + articlesBySubject := make(map[string][]*ArticleOverview) + + // Index articles + for _, article := range articles { + if article.MessageID != "" { + articlesByMessageID[article.MessageID] = article + } + + // Normalize subject for threading + normalizedSubject := normalizeSubject(article.Subject) + articlesBySubject[normalizedSubject] = append(articlesBySubject[normalizedSubject], article) + } + + // Build thread trees + threadRoots := make(map[string]*ThreadNode) + processedArticles := make(map[string]bool) + + for _, article := range articles { + if processedArticles[article.MessageID] { + continue + } + + // Find root of this thread + root := findOrCreateThreadRoot(article, articlesByMessageID, threadRoots) + buildThreadTree(root, article, articlesByMessageID, processedArticles) + } + + // Convert to Thread structs and sort + var threads []*Thread + for _, root := range threadRoots { + thread := &Thread{ + Root: root, + ArticleCount: countArticlesInThread(root), + LastDate: findLatestDateInThread(root), + Subject: root.Article.Subject, + } + threads = append(threads, thread) + } + + // Sort threads by last activity + sort.Slice(threads, func(i, j int) bool { + return threads[i].LastDate.After(threads[j].LastDate) + }) + + log.Printf("โ Built %d threads from %d articles", len(threads), len(articles)) + return threads +} + +func normalizeSubject(subject string) string { + // Remove Re:, Fwd:, etc. + subject = strings.TrimSpace(subject) + + // Common prefixes to remove + prefixes := []string{ + "Re:", "RE:", "re:", "Fwd:", "FWD:", "fwd:", + "Fw:", "FW:", "fw:", "AW:", "aw:", "Antw:", + } + + for { + trimmed := false + for _, prefix := range prefixes { + if strings.HasPrefix(subject, prefix) { + subject = strings.TrimSpace(subject[len(prefix):]) + trimmed = true + break + } + } + if !trimmed { + break + } + } + + return strings.ToLower(subject) +} + +func findOrCreateThreadRoot(article *ArticleOverview, articlesByMessageID map[string]*ArticleOverview, threadRoots map[string]*ThreadNode) *ThreadNode { + // If no references, this is a root + if article.References == "" { + normalizedSubject := normalizeSubject(article.Subject) + if root, exists := threadRoots[normalizedSubject]; exists { + return root + } + + root := &ThreadNode{ + Article: article, + Level: 0, + IsRoot: true, + } + threadRoots[normalizedSubject] = root + return root + } + + // Find the root by following references + references := parseReferences(article.References) + if len(references) == 0 { + normalizedSubject := normalizeSubject(article.Subject) + if root, exists := threadRoots[normalizedSubject]; exists { + return root + } + + root := &ThreadNode{ + Article: article, + Level: 0, + IsRoot: true, + } + threadRoots[normalizedSubject] = root + return root + } + + // The first reference is usually the thread root + rootMessageID := references[0] + if rootArticle, exists := articlesByMessageID[rootMessageID]; exists { + normalizedSubject := normalizeSubject(rootArticle.Subject) + if root, exists := threadRoots[normalizedSubject]; exists { + return root + } + + root := &ThreadNode{ + Article: rootArticle, + Level: 0, + IsRoot: true, + } + threadRoots[normalizedSubject] = root + return root + } + + // Fallback: use subject-based threading + normalizedSubject := normalizeSubject(article.Subject) + if root, exists := threadRoots[normalizedSubject]; exists { + return root + } + + root := &ThreadNode{ + Article: article, + Level: 0, + IsRoot: true, + } + threadRoots[normalizedSubject] = root + return root +} + +func buildThreadTree(root *ThreadNode, article *ArticleOverview, articlesByMessageID map[string]*ArticleOverview, processedArticles map[string]bool) { + if processedArticles[article.MessageID] { + return + } + + processedArticles[article.MessageID] = true + + // If this is the root article, we're done + if root.Article.MessageID == article.MessageID { + return + } + + // Find where to insert this article in the tree + insertLocation := findInsertLocation(root, article, articlesByMessageID) + + newNode := &ThreadNode{ + Article: article, + Level: insertLocation.Level + 1, + IsRoot: false, + } + + insertLocation.Children = append(insertLocation.Children, newNode) + + // Sort children by date + sort.Slice(insertLocation.Children, func(i, j int) bool { + return insertLocation.Children[i].Article.ParsedDate.Before(insertLocation.Children[j].Article.ParsedDate) + }) +} + +func findInsertLocation(root *ThreadNode, article *ArticleOverview, articlesByMessageID map[string]*ArticleOverview) *ThreadNode { + references := parseReferences(article.References) + if len(references) == 0 { + return root + } + + // Find the parent by looking for the last reference that exists in our tree + var parent *ThreadNode = root + + for i := len(references) - 1; i >= 0; i-- { + parentMessageID := references[i] + if foundParent := findNodeByMessageID(root, parentMessageID); foundParent != nil { + parent = foundParent + break + } + } + + return parent +} + +func findNodeByMessageID(node *ThreadNode, messageID string) *ThreadNode { + if node.Article.MessageID == messageID { + return node + } + + for _, child := range node.Children { + if found := findNodeByMessageID(child, messageID); found != nil { + return found + } + } + + return nil +} + +func parseReferences(references string) []string { + if references == "" { + return nil + } + + // Split by whitespace and clean up + var messageIDs []string + parts := strings.Fields(references) + + for _, part := range parts { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "<") && strings.HasSuffix(part, ">") { + messageIDs = append(messageIDs, part) + } + } + + return messageIDs +} + +func countArticlesInThread(node *ThreadNode) int { + count := 1 + for _, child := range node.Children { + count += countArticlesInThread(child) + } + return count +} + +func findLatestDateInThread(node *ThreadNode) time.Time { + latest := node.Article.ParsedDate + + for _, child := range node.Children { + childLatest := findLatestDateInThread(child) + if childLatest.After(latest) { + latest = childLatest + } + } + + return latest +} + +func flattenThread(node *ThreadNode) []*ArticleOverview { + var articles []*ArticleOverview + articles = append(articles, node.Article) + + for _, child := range node.Children { + articles = append(articles, flattenThread(child)...) + } + + return articles +} + func (s *NewsServer) GetHealthStatus() map[string]interface{} { s.mutex.RLock() defer s.mutex.RUnlock() @@ -1043,6 +1339,51 @@ func (s *NewsServer) handleActive(w http.ResponseWriter, r *http.Request) { .success { color: #00ff00; } .error { color: #ff8800; } .progress { margin: 10px 0; font-size: 0.9em; color: #888; } + .app-footer { + margin-top: 40px; + padding: 25px 0; + border-top: 2px solid #333; + background: #1a1a1a; + text-align: center; + } + .footer-links { + display: flex; + justify-content: center; + gap: 25px; + flex-wrap: wrap; + } + .footer-links a { + color: #00aaff; + text-decoration: none; + font-weight: 500; + padding: 8px 15px; + border-radius: 20px; + background: rgba(0, 170, 255, 0.1); + transition: all 0.3s ease; + border: 1px solid rgba(0, 170, 255, 0.2); + } + .footer-links a:hover { + background: #00aaff; + color: #0a0a0a; + } + .footer-links a[href^="monero:"] { + background: rgba(255, 102, 0, 0.1); + color: #ff6600; + border-color: rgba(255, 102, 0, 0.2); + } + .footer-links a[href^="monero:"]:hover { + background: #ff6600; + color: #0a0a0a; + } + .footer-links a[href*="github"] { + background: rgba(51, 51, 51, 0.1); + color: #00ff00; + border-color: rgba(51, 51, 51, 0.2); + } + .footer-links a[href*="github"]:hover { + background: #00ff00; + color: #0a0a0a; + }
@@ -1061,6 +1402,14 @@ func (s *NewsServer) handleActive(w http.ResponseWriter, r *http.Request) {Started at ` + time.Now().Format("15:04:05") + `
+ +