| Current Path : /home/hotlineuser/mobius/hotline/ |
| Current File : //home/hotlineuser/mobius/hotline/news.go |
package hotline
import (
"cmp"
"encoding/binary"
"io"
"slices"
"github.com/stretchr/testify/mock"
)
var (
NewsBundle = [2]byte{0, 2}
NewsCategory = [2]byte{0, 3}
)
type ThreadedNewsMgr interface {
ListArticles(newsPath []string) (NewsArtListData, error)
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 [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 [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
writeOffset int // Internal offset to track write progress
writeBuf []byte // Buffer for accumulating partial writes
}
func (newscat *NewsCategoryListData15) GetNewsArtListData() (NewsArtListData, error) {
var newsArts []NewsArtList
var newsArtsPayload []byte
for i, art := range newscat.Articles {
id := make([]byte, 4)
binary.BigEndian.PutUint32(id, i) // The article's map key in the Articles map is its ID.
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(),
})
}
// 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)
if err != nil {
return NewsArtListData{}, err
}
newsArtsPayload = append(newsArtsPayload, b...)
}
return NewsArtListData{
Count: len(newsArts),
Name: []byte{},
Description: []byte{},
NewsArtList: newsArtsPayload,
}, nil
}
// NewsArtData represents an individual news article.
type NewsArtData struct {
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 [2]byte(dataLen)
}
type NewsArtListData struct {
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
writeOffset int // Internal offset to track write progress
writeBuf []byte // Buffer for accumulating partial writes
}
func (nald *NewsArtListData) Read(p []byte) (int, error) {
count := make([]byte, 4)
binary.BigEndian.PutUint32(count, uint32(nald.Count))
buf := slices.Concat(
nald.ID[:],
count,
[]byte{uint8(len(nald.Name))},
nald.Name,
[]byte{uint8(len(nald.Description))},
nald.Description,
nald.NewsArtList,
)
return readFrom(p, &nald.readOffset, buf)
}
func (nald *NewsArtListData) Write(p []byte) (int, error) {
// Accumulate incoming bytes into the write buffer
nald.writeBuf = append(nald.writeBuf, p...)
bytesConsumed := len(p)
// If we've already parsed the header (writeOffset > 0), just accumulate article list data
if nald.writeOffset > 0 {
// Append new data to existing NewsArtList
nald.NewsArtList = append(nald.NewsArtList, p...)
return bytesConsumed, nil
}
// Minimum size: ID(4) + Count(4) + NameLen(1) + DescLen(1) = 10 bytes
if len(nald.writeBuf) < 10 {
return bytesConsumed, nil // Need more data
}
offset := 0
// Read ID (4 bytes)
copy(nald.ID[:], nald.writeBuf[offset:offset+4])
offset += 4
// Read Count (4 bytes)
nald.Count = int(binary.BigEndian.Uint32(nald.writeBuf[offset : offset+4]))
offset += 4
// Read Name length (1 byte)
nameLen := int(nald.writeBuf[offset])
offset += 1
// Check if we have enough data for Name
if len(nald.writeBuf) < offset+nameLen {
return bytesConsumed, nil // Need more data
}
// Read Name (nameLen bytes)
nald.Name = make([]byte, nameLen)
copy(nald.Name, nald.writeBuf[offset:offset+nameLen])
offset += nameLen
// Check if we have Description length byte
if len(nald.writeBuf) < offset+1 {
return bytesConsumed, nil // Need more data
}
// Read Description length (1 byte)
descLen := int(nald.writeBuf[offset])
offset += 1
// Check if we have enough data for Description
if len(nald.writeBuf) < offset+descLen {
return bytesConsumed, nil // Need more data
}
// Read Description (descLen bytes)
nald.Description = make([]byte, descLen)
copy(nald.Description, nald.writeBuf[offset:offset+descLen])
offset += descLen
// Read remaining bytes as NewsArtList
if len(nald.writeBuf) > offset {
nald.NewsArtList = make([]byte, len(nald.writeBuf)-offset)
copy(nald.NewsArtList, nald.writeBuf[offset:])
} else {
nald.NewsArtList = []byte{}
}
// Mark that we've successfully parsed the header
nald.writeOffset = offset
// Clear the buffer as we've successfully parsed all data
nald.writeBuf = nil
return bytesConsumed, nil
}
// NewsArtList is a summarized version of a NewArtData record for display in list view
type NewsArtList struct {
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
// Poster Poster string
Poster []byte
FlavorList []NewsFlavorList
// Flavor list… Optional (if flavor count > 0)
ArticleSize [2]byte // Size 2
readOffset int // Internal offset to track read progress
}
var (
NewsFlavor = []byte("text/plain") // NewsFlavor is always "text/plain"
NewsFlavorCount = []byte{0, 1} // NewsFlavorCount is always 1
)
func (nal *NewsArtList) Read(p []byte) (int, error) {
out := slices.Concat(
nal.ID[:],
nal.TimeStamp[:],
nal.ParentID[:],
nal.Flags[:],
NewsFlavorCount,
[]byte{uint8(len(nal.Title))},
nal.Title,
[]byte{uint8(len(nal.Poster))},
nal.Poster,
[]byte{uint8(len(NewsFlavor))},
NewsFlavor,
nal.ArticleSize[:],
)
return readFrom(p, &nal.readOffset, out)
}
type NewsFlavorList struct {
// Flavor size 1
// Flavor text size MIME type string
// Article size 2
}
func (newscat *NewsCategoryListData15) Read(p []byte) (int, error) {
count := make([]byte, 2)
binary.BigEndian.PutUint16(count, uint16(len(newscat.Articles)+len(newscat.SubCats)))
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),
)
return readFrom(p, &newscat.readOffset, out)
}
func (newscat *NewsCategoryListData15) Write(p []byte) (int, error) {
// Accumulate incoming bytes into the write buffer
newscat.writeBuf = append(newscat.writeBuf, p...)
bytesConsumed := len(p)
// Minimum size: Type(2) + Count(2) + NameLen(1) = 5 bytes
if len(newscat.writeBuf) < 5 {
return bytesConsumed, nil // Need more data
}
offset := 0
// Read Type (2 bytes)
copy(newscat.Type[:], newscat.writeBuf[offset:offset+2])
offset += 2
// Read count (2 bytes) - stored but not directly used as it's derived from maps
_ = binary.BigEndian.Uint16(newscat.writeBuf[offset : offset+2])
offset += 2
// If Type is NewsCategory, read GUID, AddSN, DeleteSN
if newscat.Type == NewsCategory {
// Need additional 24 bytes: GUID(16) + AddSN(4) + DeleteSN(4)
if len(newscat.writeBuf) < offset+24 {
return bytesConsumed, nil // Need more data
}
copy(newscat.GUID[:], newscat.writeBuf[offset:offset+16])
offset += 16
copy(newscat.AddSN[:], newscat.writeBuf[offset:offset+4])
offset += 4
copy(newscat.DeleteSN[:], newscat.writeBuf[offset:offset+4])
offset += 4
}
// Read name length (1 byte)
if len(newscat.writeBuf) < offset+1 {
return bytesConsumed, nil // Need more data
}
nameLen := int(newscat.writeBuf[offset])
offset += 1
// Read name (nameLen bytes)
if len(newscat.writeBuf) < offset+nameLen {
return bytesConsumed, nil // Need more data
}
newscat.Name = string(newscat.writeBuf[offset : offset+nameLen])
offset += nameLen
// Initialize maps if needed
if newscat.Articles == nil {
newscat.Articles = make(map[uint32]*NewsArtData)
}
if newscat.SubCats == nil {
newscat.SubCats = make(map[string]NewsCategoryListData15)
}
// Clear the buffer as we've successfully parsed all expected data
newscat.writeBuf = newscat.writeBuf[offset:]
newscat.writeOffset = offset
return bytesConsumed, nil
}
func (newscat *NewsCategoryListData15) nameLen() []byte {
return []byte{uint8(len(newscat.Name))}
}
// 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
}
advance = 3 + int(data[2])
return advance, data[3:advance], nil
}
type MockThreadNewsMgr struct {
mock.Mock
}
func (m *MockThreadNewsMgr) ListArticles(newsPath []string) (NewsArtListData, error) {
args := m.Called(newsPath)
return args.Get(0).(NewsArtListData), args.Error(1)
}
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)
}