X-Git-Url: https://git.r.bdr.sh/rbdr/mobius/blobdiff_plain/2d92d26e8abe2c368796e9d38c9ad87d0ac53df5..HEAD:/hotline/news.go diff --git a/hotline/news.go b/hotline/news.go index 38db97a..a490a89 100644 --- a/hotline/news.go +++ b/hotline/news.go @@ -1,34 +1,44 @@ package hotline import ( - "bytes" - "crypto/rand" + "cmp" "encoding/binary" - "sort" + "github.com/stretchr/testify/mock" + "io" + "slices" ) -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 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 { @@ -36,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 @@ -125,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 { @@ -163,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) }