]> git.r.bdr.sh - rbdr/mobius/blobdiff - hotline/news.go
Fix tracker registration logging
[rbdr/mobius] / hotline / news.go
index 11e2a27dc54ee9bcc729faf6b429ad943d09ef98..a490a89c39529c2e82e0bacc1da34463f52d1faa 100644 (file)
@@ -1,26 +1,44 @@
 package hotline
 
 import (
-       "bytes"
-       "crypto/rand"
+       "cmp"
        "encoding/binary"
-       "sort"
+       "github.com/stretchr/testify/mock"
+       "io"
+       "slices"
 )
 
+var (
+       NewsBundle   = [2]byte{0, 2}
+       NewsCategory = [2]byte{0, 3}
+)
+
+type ThreadedNewsMgr interface {
+       ListArticles(newsPath []string) NewsArtListData
+       GetArticle(newsPath []string, articleID uint32) *NewsArtData
+       DeleteArticle(newsPath []string, articleID uint32, recursive bool) error
+       PostArticle(newsPath []string, parentArticleID uint32, article NewsArtData) error
+       CreateGrouping(newsPath []string, name string, t [2]byte) error
+       GetCategories(paths []string) []NewsCategoryListData15
+       NewsItem(newsPath []string) NewsCategoryListData15
+       DeleteNewsItem(newsPath []string) error
+}
+
+// ThreadedNews contains the top level of threaded news categories, bundles, and articles.
 type ThreadedNews struct {
        Categories map[string]NewsCategoryListData15 `yaml:"Categories"`
 }
 
 type NewsCategoryListData15 struct {
-       Type     []byte `yaml:"Type"` // Size 2 ; Bundle (2) or category (3)
-       Count    []byte // Article or SubCategory count Size 2
-       NameSize byte
-       Name     string                            `yaml:"Name"`     //
+       Type     [2]byte                           `yaml:"Type,flow"` // Bundle (2) or category (3)
+       Name     string                            `yaml:"Name"`
        Articles map[uint32]*NewsArtData           `yaml:"Articles"` // Optional, if Type is Category
        SubCats  map[string]NewsCategoryListData15 `yaml:"SubCats"`
-       GUID     []byte                            // Size 16
-       AddSN    []byte                            // Size 4
-       DeleteSN []byte                            // Size 4
+       GUID     [16]byte                          `yaml:"-"` // What does this do?  Undocumented and seeming unused.
+       AddSN    [4]byte                           `yaml:"-"` // What does this do?  Undocumented and seeming unused.
+       DeleteSN [4]byte                           `yaml:"-"` // What does this do?  Undocumented and seeming unused.
+
+       readOffset int // Internal offset to track read progress
 }
 
 func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData {
@@ -28,88 +46,104 @@ func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData {
        var newsArtsPayload []byte
 
        for i, art := range newscat.Articles {
-               ID := make([]byte, 4)
-               binary.BigEndian.PutUint32(ID, i)
+               id := make([]byte, 4)
+               binary.BigEndian.PutUint32(id, i)
 
-               newArt := NewsArtList{
-                       ID:          ID,
+               newsArts = append(newsArts, NewsArtList{
+                       ID:          [4]byte(id),
                        TimeStamp:   art.Date,
                        ParentID:    art.ParentArt,
-                       Flags:       []byte{0, 0, 0, 0},
-                       FlavorCount: []byte{0, 0},
                        Title:       []byte(art.Title),
                        Poster:      []byte(art.Poster),
                        ArticleSize: art.DataSize(),
-               }
-               newsArts = append(newsArts, newArt)
+               })
        }
 
-       sort.Sort(byID(newsArts))
+       // Sort the articles by ID.  This is important for displaying the message threading correctly on the client side.
+       slices.SortFunc(newsArts, func(a, b NewsArtList) int {
+               return cmp.Compare(
+                       binary.BigEndian.Uint32(a.ID[:]),
+                       binary.BigEndian.Uint32(b.ID[:]),
+               )
+       })
 
        for _, v := range newsArts {
-               newsArtsPayload = append(newsArtsPayload, v.Payload()...)
+               b, err := io.ReadAll(&v)
+               if err != nil {
+                       // TODO
+                       panic(err)
+               }
+               newsArtsPayload = append(newsArtsPayload, b...)
        }
 
-       nald := NewsArtListData{
-               ID:          []byte{0, 0, 0, 0},
+       return NewsArtListData{
                Count:       len(newsArts),
                Name:        []byte{},
                Description: []byte{},
                NewsArtList: newsArtsPayload,
        }
-
-       return nald
 }
 
-// NewsArtData represents single news article
+// NewsArtData represents an individual news article.
 type NewsArtData struct {
-       Title         string `yaml:"Title"`
-       Poster        string `yaml:"Poster"`
-       Date          []byte `yaml:"Date"`             // size 8
-       PrevArt       []byte `yaml:"PrevArt"`          // size 4
-       NextArt       []byte `yaml:"NextArt"`          // size 4
-       ParentArt     []byte `yaml:"ParentArt"`        // size 4
-       FirstChildArt []byte `yaml:"FirstChildArtArt"` // size 4
-       DataFlav      []byte `yaml:"DataFlav"`         // "text/plain"
-       Data          string `yaml:"Data"`
-}
-
-func (art *NewsArtData) DataSize() []byte {
+       Title         string  `yaml:"Title"`
+       Poster        string  `yaml:"Poster"`
+       Date          [8]byte `yaml:"Date,flow"`
+       PrevArt       [4]byte `yaml:"PrevArt,flow"`
+       NextArt       [4]byte `yaml:"NextArt,flow"`
+       ParentArt     [4]byte `yaml:"ParentArt,flow"`
+       FirstChildArt [4]byte `yaml:"FirstChildArtArt,flow"`
+       DataFlav      []byte  `yaml:"-"` // MIME type string.  Always "text/plain".
+       Data          string  `yaml:"Data"`
+}
+
+func (art *NewsArtData) DataSize() [2]byte {
        dataLen := make([]byte, 2)
        binary.BigEndian.PutUint16(dataLen, uint16(len(art.Data)))
 
-       return dataLen
+       return [2]byte(dataLen)
 }
 
 type NewsArtListData struct {
-       ID          []byte `yaml:"ID"` // Size 4
-       Name        []byte `yaml:"Name"`
-       Description []byte `yaml:"Description"` // not used?
-       NewsArtList []byte // List of articles                  Optional (if article count > 0)
+       ID          [4]byte `yaml:"Type"`
+       Name        []byte  `yaml:"Name"`
+       Description []byte  `yaml:"Description"` // not used?
+       NewsArtList []byte  // List of articles                 Optional (if article count > 0)
        Count       int
+
+       readOffset int // Internal offset to track read progress
 }
 
-func (nald *NewsArtListData) Payload() []byte {
+func (nald *NewsArtListData) Read(p []byte) (int, error) {
        count := make([]byte, 4)
        binary.BigEndian.PutUint32(count, uint32(nald.Count))
 
-       out := append(nald.ID, count...)
-       out = append(out, []byte{uint8(len(nald.Name))}...)
-       out = append(out, nald.Name...)
-       out = append(out, []byte{uint8(len(nald.Description))}...)
-       out = append(out, nald.Description...)
-       out = append(out, nald.NewsArtList...)
+       buf := slices.Concat(
+               nald.ID[:],
+               count,
+               []byte{uint8(len(nald.Name))},
+               nald.Name,
+               []byte{uint8(len(nald.Description))},
+               nald.Description,
+               nald.NewsArtList,
+       )
+
+       if nald.readOffset >= len(buf) {
+               return 0, io.EOF // All bytes have been read
+       }
+       n := copy(p, buf[nald.readOffset:])
+       nald.readOffset += n
 
-       return out
+       return n, nil
 }
 
 // NewsArtList is a summarized version of a NewArtData record for display in list view
 type NewsArtList struct {
-       ID          []byte // Size 4
-       TimeStamp   []byte // Year (2 bytes), milliseconds (2 bytes) and seconds (4 bytes)
-       ParentID    []byte // Size 4
-       Flags       []byte // Size 4
-       FlavorCount []byte // Size 2
+       ID          [4]byte
+       TimeStamp   [8]byte // Year (2 bytes), milliseconds (2 bytes) and seconds (4 bytes)
+       ParentID    [4]byte
+       Flags       [4]byte
+       FlavorCount [2]byte
        // Title size   1
        Title []byte // string
        // Poster size  1
@@ -117,36 +151,40 @@ type NewsArtList struct {
        Poster     []byte
        FlavorList []NewsFlavorList
        // Flavor list…                       Optional (if flavor count > 0)
-       ArticleSize []byte // Size 2
-}
-
-type byID []NewsArtList
+       ArticleSize [2]byte // Size 2
 
-func (s byID) Len() int {
-       return len(s)
-}
-func (s byID) Swap(i, j int) {
-       s[i], s[j] = s[j], s[i]
-}
-func (s byID) Less(i, j int) bool {
-       return binary.BigEndian.Uint32(s[i].ID) < binary.BigEndian.Uint32(s[j].ID)
+       readOffset int // Internal offset to track read progress
 }
 
-func (nal *NewsArtList) Payload() []byte {
-       out := append(nal.ID, nal.TimeStamp...)
-       out = append(out, nal.ParentID...)
-       out = append(out, nal.Flags...)
+var (
+       NewsFlavorLen = []byte{0x0a}
+       NewsFlavor    = []byte("text/plain")
+)
 
-       out = append(out, []byte{0, 1}...)
+func (nal *NewsArtList) Read(p []byte) (int, error) {
+       out := slices.Concat(
+               nal.ID[:],
+               nal.TimeStamp[:],
+               nal.ParentID[:],
+               nal.Flags[:],
+               []byte{0, 1}, // Flavor Count TODO: make this not hardcoded
+               []byte{uint8(len(nal.Title))},
+               nal.Title,
+               []byte{uint8(len(nal.Poster))},
+               nal.Poster,
+               NewsFlavorLen,
+               NewsFlavor,
+               nal.ArticleSize[:],
+       )
+
+       if nal.readOffset >= len(out) {
+               return 0, io.EOF // All bytes have been read
+       }
 
-       out = append(out, []byte{uint8(len(nal.Title))}...)
-       out = append(out, nal.Title...)
-       out = append(out, []byte{uint8(len(nal.Poster))}...)
-       out = append(out, nal.Poster...)
-       out = append(out, []byte{0x0a, 0x74, 0x65, 0x78, 0x74, 0x2f, 0x70, 0x6c, 0x61, 0x69, 0x6e}...) // TODO: wat?
-       out = append(out, nal.ArticleSize...)
+       n := copy(p, out[nal.readOffset:])
+       nal.readOffset += n
 
-       return out
+       return n, io.EOF
 }
 
 type NewsFlavorList struct {
@@ -155,79 +193,97 @@ type NewsFlavorList struct {
        // Article size 2
 }
 
-func (newscat *NewsCategoryListData15) MarshalBinary() (data []byte, err error) {
+func (newscat *NewsCategoryListData15) Read(p []byte) (int, error) {
        count := make([]byte, 2)
        binary.BigEndian.PutUint16(count, uint16(len(newscat.Articles)+len(newscat.SubCats)))
 
-       out := append(newscat.Type, count...)
-
-       if bytes.Equal(newscat.Type, []byte{0, 3}) {
-               // Generate a random GUID // TODO: does this need to be random?
-               b := make([]byte, 16)
-               _, err := rand.Read(b)
-               if err != nil {
-                       return data, err
-               }
+       out := slices.Concat(
+               newscat.Type[:],
+               count,
+       )
+       if newscat.Type == NewsCategory {
+               out = slices.Concat(out,
+                       newscat.GUID[:],
+                       newscat.AddSN[:],
+                       newscat.DeleteSN[:],
+               )
+       }
+       out = slices.Concat(out,
+               newscat.nameLen(),
+               []byte(newscat.Name),
+       )
 
-               out = append(out, b...)                  // GUID
-               out = append(out, []byte{0, 0, 0, 1}...) // Add SN (TODO: not sure what this is)
-               out = append(out, []byte{0, 0, 0, 2}...) // Delete SN (TODO: not sure what this is)
+       if newscat.readOffset >= len(out) {
+               return 0, io.EOF // All bytes have been read
        }
 
-       out = append(out, newscat.nameLen()...)
-       out = append(out, []byte(newscat.Name)...)
+       n := copy(p, out)
+
+       newscat.readOffset = n
 
-       return out, err
+       return n, nil
 }
 
-// ReadNewsCategoryListData parses a byte slice into a NewsCategoryListData15 struct
-// For use on the client side
-func ReadNewsCategoryListData(payload []byte) NewsCategoryListData15 {
-       ncld := NewsCategoryListData15{
-               Type:  payload[0:2],
-               Count: payload[2:4],
-       }
+func (newscat *NewsCategoryListData15) nameLen() []byte {
+       return []byte{uint8(len(newscat.Name))}
+}
 
-       if bytes.Equal(ncld.Type, []byte{0, 3}) {
-               ncld.GUID = payload[4:20]
-               ncld.AddSN = payload[20:24]
-               ncld.AddSN = payload[24:28]
-               ncld.Name = string(payload[29:])
-       } else {
-               ncld.Name = string(payload[5:])
+// newsPathScanner implements bufio.SplitFunc for parsing incoming byte slices into complete tokens
+func newsPathScanner(data []byte, _ bool) (advance int, token []byte, err error) {
+       if len(data) < 3 {
+               return 0, nil, nil
        }
 
-       return ncld
+       advance = 3 + int(data[2])
+       return advance, data[3:advance], nil
 }
 
-func (newscat *NewsCategoryListData15) nameLen() []byte {
-       return []byte{uint8(len(newscat.Name))}
+type MockThreadNewsMgr struct {
+       mock.Mock
 }
 
-// TODO: re-implement as bufio.Scanner interface
-func ReadNewsPath(newsPath []byte) []string {
-       if len(newsPath) == 0 {
-               return []string{}
-       }
-       pathCount := binary.BigEndian.Uint16(newsPath[0:2])
+func (m *MockThreadNewsMgr) ListArticles(newsPath []string) NewsArtListData {
+       args := m.Called(newsPath)
 
-       pathData := newsPath[2:]
-       var paths []string
+       return args.Get(0).(NewsArtListData)
+}
 
-       for i := uint16(0); i < pathCount; i++ {
-               pathLen := pathData[2]
-               paths = append(paths, string(pathData[3:3+pathLen]))
+func (m *MockThreadNewsMgr) GetArticle(newsPath []string, articleID uint32) *NewsArtData {
+       args := m.Called(newsPath, articleID)
 
-               pathData = pathData[pathLen+3:]
-       }
+       return args.Get(0).(*NewsArtData)
+}
+func (m *MockThreadNewsMgr) DeleteArticle(newsPath []string, articleID uint32, recursive bool) error {
+       args := m.Called(newsPath, articleID, recursive)
 
-       return paths
+       return args.Error(0)
 }
 
-func (s *Server) GetNewsCatByPath(paths []string) map[string]NewsCategoryListData15 {
-       cats := s.ThreadedNews.Categories
-       for _, path := range paths {
-               cats = cats[path].SubCats
-       }
-       return cats
+func (m *MockThreadNewsMgr) PostArticle(newsPath []string, parentArticleID uint32, article NewsArtData) error {
+       args := m.Called(newsPath, parentArticleID, article)
+
+       return args.Error(0)
+}
+func (m *MockThreadNewsMgr) CreateGrouping(newsPath []string, name string, itemType [2]byte) error {
+       args := m.Called(newsPath, name, itemType)
+
+       return args.Error(0)
+}
+
+func (m *MockThreadNewsMgr) GetCategories(paths []string) []NewsCategoryListData15 {
+       args := m.Called(paths)
+
+       return args.Get(0).([]NewsCategoryListData15)
+}
+
+func (m *MockThreadNewsMgr) NewsItem(newsPath []string) NewsCategoryListData15 {
+       args := m.Called(newsPath)
+
+       return args.Get(0).(NewsCategoryListData15)
+}
+
+func (m *MockThreadNewsMgr) DeleteNewsItem(newsPath []string) error {
+       args := m.Called(newsPath)
+
+       return args.Error(0)
 }