X-Git-Url: https://git.r.bdr.sh/rbdr/mobius/blobdiff_plain/0ed5132769e88cb385b5240986b706430f0ccd72..b6e3be945680d017874967ae72ef86ee4235dcc2:/hotline/news.go?ds=inline diff --git a/hotline/news.go b/hotline/news.go index cd3b6af..a490a89 100644 --- a/hotline/news.go +++ b/hotline/news.go @@ -1,22 +1,30 @@ package hotline import ( - "bytes" + "cmp" "encoding/binary" + "github.com/stretchr/testify/mock" "io" "slices" - "sort" ) -const defaultNewsDateFormat = "Jan02 15:04" // Jun23 20:49 - -const defaultNewsTemplate = `From %s (%s): - -%s +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 is the top level struct containing all threaded news categories, bundles, and articles +// ThreadedNews contains the top level of threaded news categories, bundles, and articles. type ThreadedNews struct { Categories map[string]NewsCategoryListData15 `yaml:"Categories"` } @@ -29,6 +37,8 @@ type NewsCategoryListData15 struct { 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 { @@ -39,19 +49,23 @@ func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData { id := make([]byte, 4) binary.BigEndian.PutUint32(id, i) - newArt := NewsArtList{ + newsArts = append(newsArts, NewsArtList{ ID: [4]byte(id), TimeStamp: art.Date, ParentID: art.ParentArt, 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 { b, err := io.ReadAll(&v) @@ -70,7 +84,7 @@ func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData { } } -// NewsArtData represents single news article +// NewsArtData represents an individual news article. type NewsArtData struct { Title string `yaml:"Title"` Poster string `yaml:"Poster"` @@ -79,19 +93,19 @@ type NewsArtData struct { NextArt [4]byte `yaml:"NextArt,flow"` ParentArt [4]byte `yaml:"ParentArt,flow"` FirstChildArt [4]byte `yaml:"FirstChildArtArt,flow"` - DataFlav []byte `yaml:"-"` // "text/plain" + DataFlav []byte `yaml:"-"` // MIME type string. Always "text/plain". Data string `yaml:"Data"` } -func (art *NewsArtData) DataSize() []byte { +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 [4]byte `yaml:"ID"` + 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) @@ -137,23 +151,11 @@ type NewsArtList struct { Poster []byte FlavorList []NewsFlavorList // Flavor list… Optional (if flavor count > 0) - ArticleSize []byte // Size 2 + ArticleSize [2]byte // Size 2 readOffset int // Internal offset to track read progress } -type byID []NewsArtList - -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[:]) -} - var ( NewsFlavorLen = []byte{0x0a} NewsFlavor = []byte("text/plain") @@ -165,14 +167,14 @@ func (nal *NewsArtList) Read(p []byte) (int, error) { nal.TimeStamp[:], nal.ParentID[:], nal.Flags[:], - []byte{0, 1}, // Flavor Count + []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, + nal.ArticleSize[:], ) if nal.readOffset >= len(out) { @@ -191,53 +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...) + 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), + ) - // If type is category - if bytes.Equal(newscat.Type[:], []byte{0, 3}) { - out = append(out, newscat.GUID[:]...) // GUID - out = append(out, newscat.AddSN[:]...) // Add SN - out = append(out, newscat.DeleteSN[:]...) // Delete SN + 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) - return out, err + newscat.readOffset = n + + return n, nil } func (newscat *NewsCategoryListData15) nameLen() []byte { return []byte{uint8(len(newscat.Name))} } -// TODO: re-implement as bufio.Scanner interface -func ReadNewsPath(newsPath []byte) []string { - if len(newsPath) == 0 { - return []string{} +// 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 } - pathCount := binary.BigEndian.Uint16(newsPath[0:2]) - pathData := newsPath[2:] - var paths []string + advance = 3 + int(data[2]) + return advance, data[3:advance], nil +} - for i := uint16(0); i < pathCount; i++ { - pathLen := pathData[2] - paths = append(paths, string(pathData[3:3+pathLen])) +type MockThreadNewsMgr struct { + mock.Mock +} - pathData = pathData[pathLen+3:] - } +func (m *MockThreadNewsMgr) ListArticles(newsPath []string) NewsArtListData { + args := m.Called(newsPath) - return paths + return args.Get(0).(NewsArtListData) } -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) GetArticle(newsPath []string, articleID uint32) *NewsArtData { + args := m.Called(newsPath, articleID) + + return args.Get(0).(*NewsArtData) +} +func (m *MockThreadNewsMgr) DeleteArticle(newsPath []string, articleID uint32, recursive bool) error { + args := m.Called(newsPath, articleID, recursive) + + return args.Error(0) +} + +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) }