]> git.r.bdr.sh - rbdr/mobius/blob - hotline/news.go
Appease linter
[rbdr/mobius] / hotline / news.go
1 package hotline
2
3 import (
4 "cmp"
5 "encoding/binary"
6 "github.com/stretchr/testify/mock"
7 "io"
8 "slices"
9 )
10
11 var (
12 NewsBundle = [2]byte{0, 2}
13 NewsCategory = [2]byte{0, 3}
14 )
15
16 type ThreadedNewsMgr interface {
17 ListArticles(newsPath []string) NewsArtListData
18 GetArticle(newsPath []string, articleID uint32) *NewsArtData
19 DeleteArticle(newsPath []string, articleID uint32, recursive bool) error
20 PostArticle(newsPath []string, parentArticleID uint32, article NewsArtData) error
21 CreateGrouping(newsPath []string, name string, t [2]byte) error
22 GetCategories(paths []string) []NewsCategoryListData15
23 NewsItem(newsPath []string) NewsCategoryListData15
24 DeleteNewsItem(newsPath []string) error
25 }
26
27 // ThreadedNews contains the top level of threaded news categories, bundles, and articles.
28 type ThreadedNews struct {
29 Categories map[string]NewsCategoryListData15 `yaml:"Categories"`
30 }
31
32 type NewsCategoryListData15 struct {
33 Type [2]byte `yaml:"Type,flow"` // Bundle (2) or category (3)
34 Name string `yaml:"Name"`
35 Articles map[uint32]*NewsArtData `yaml:"Articles"` // Optional, if Type is Category
36 SubCats map[string]NewsCategoryListData15 `yaml:"SubCats"`
37 GUID [16]byte `yaml:"-"` // What does this do? Undocumented and seeming unused.
38 AddSN [4]byte `yaml:"-"` // What does this do? Undocumented and seeming unused.
39 DeleteSN [4]byte `yaml:"-"` // What does this do? Undocumented and seeming unused.
40
41 readOffset int // Internal offset to track read progress
42 }
43
44 func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData {
45 var newsArts []NewsArtList
46 var newsArtsPayload []byte
47
48 for i, art := range newscat.Articles {
49 id := make([]byte, 4)
50 binary.BigEndian.PutUint32(id, i)
51
52 newsArts = append(newsArts, NewsArtList{
53 ID: [4]byte(id),
54 TimeStamp: art.Date,
55 ParentID: art.ParentArt,
56 Title: []byte(art.Title),
57 Poster: []byte(art.Poster),
58 ArticleSize: art.DataSize(),
59 })
60 }
61
62 // Sort the articles by ID. This is important for displaying the message threading correctly on the client side.
63 slices.SortFunc(newsArts, func(a, b NewsArtList) int {
64 return cmp.Compare(
65 binary.BigEndian.Uint32(a.ID[:]),
66 binary.BigEndian.Uint32(b.ID[:]),
67 )
68 })
69
70 for _, v := range newsArts {
71 b, err := io.ReadAll(&v)
72 if err != nil {
73 // TODO
74 panic(err)
75 }
76 newsArtsPayload = append(newsArtsPayload, b...)
77 }
78
79 return NewsArtListData{
80 Count: len(newsArts),
81 Name: []byte{},
82 Description: []byte{},
83 NewsArtList: newsArtsPayload,
84 }
85 }
86
87 // NewsArtData represents an individual news article.
88 type NewsArtData struct {
89 Title string `yaml:"Title"`
90 Poster string `yaml:"Poster"`
91 Date [8]byte `yaml:"Date,flow"`
92 PrevArt [4]byte `yaml:"PrevArt,flow"`
93 NextArt [4]byte `yaml:"NextArt,flow"`
94 ParentArt [4]byte `yaml:"ParentArt,flow"`
95 FirstChildArt [4]byte `yaml:"FirstChildArtArt,flow"`
96 DataFlav []byte `yaml:"-"` // MIME type string. Always "text/plain".
97 Data string `yaml:"Data"`
98 }
99
100 func (art *NewsArtData) DataSize() [2]byte {
101 dataLen := make([]byte, 2)
102 binary.BigEndian.PutUint16(dataLen, uint16(len(art.Data)))
103
104 return [2]byte(dataLen)
105 }
106
107 type NewsArtListData struct {
108 ID [4]byte `yaml:"Type"`
109 Name []byte `yaml:"Name"`
110 Description []byte `yaml:"Description"` // not used?
111 NewsArtList []byte // List of articles Optional (if article count > 0)
112 Count int
113
114 readOffset int // Internal offset to track read progress
115 }
116
117 func (nald *NewsArtListData) Read(p []byte) (int, error) {
118 count := make([]byte, 4)
119 binary.BigEndian.PutUint32(count, uint32(nald.Count))
120
121 buf := slices.Concat(
122 nald.ID[:],
123 count,
124 []byte{uint8(len(nald.Name))},
125 nald.Name,
126 []byte{uint8(len(nald.Description))},
127 nald.Description,
128 nald.NewsArtList,
129 )
130
131 if nald.readOffset >= len(buf) {
132 return 0, io.EOF // All bytes have been read
133 }
134 n := copy(p, buf[nald.readOffset:])
135 nald.readOffset += n
136
137 return n, nil
138 }
139
140 // NewsArtList is a summarized version of a NewArtData record for display in list view
141 type NewsArtList struct {
142 ID [4]byte
143 TimeStamp [8]byte // Year (2 bytes), milliseconds (2 bytes) and seconds (4 bytes)
144 ParentID [4]byte
145 Flags [4]byte
146 FlavorCount [2]byte
147 // Title size 1
148 Title []byte // string
149 // Poster size 1
150 // Poster Poster string
151 Poster []byte
152 FlavorList []NewsFlavorList
153 // Flavor list… Optional (if flavor count > 0)
154 ArticleSize [2]byte // Size 2
155
156 readOffset int // Internal offset to track read progress
157 }
158
159 var (
160 NewsFlavorLen = []byte{0x0a}
161 NewsFlavor = []byte("text/plain")
162 )
163
164 func (nal *NewsArtList) Read(p []byte) (int, error) {
165 out := slices.Concat(
166 nal.ID[:],
167 nal.TimeStamp[:],
168 nal.ParentID[:],
169 nal.Flags[:],
170 []byte{0, 1}, // Flavor Count TODO: make this not hardcoded
171 []byte{uint8(len(nal.Title))},
172 nal.Title,
173 []byte{uint8(len(nal.Poster))},
174 nal.Poster,
175 NewsFlavorLen,
176 NewsFlavor,
177 nal.ArticleSize[:],
178 )
179
180 if nal.readOffset >= len(out) {
181 return 0, io.EOF // All bytes have been read
182 }
183
184 n := copy(p, out[nal.readOffset:])
185 nal.readOffset += n
186
187 return n, io.EOF
188 }
189
190 type NewsFlavorList struct {
191 // Flavor size 1
192 // Flavor text size MIME type string
193 // Article size 2
194 }
195
196 func (newscat *NewsCategoryListData15) Read(p []byte) (int, error) {
197 count := make([]byte, 2)
198 binary.BigEndian.PutUint16(count, uint16(len(newscat.Articles)+len(newscat.SubCats)))
199
200 out := slices.Concat(
201 newscat.Type[:],
202 count,
203 )
204 if newscat.Type == NewsCategory {
205 out = slices.Concat(out,
206 newscat.GUID[:],
207 newscat.AddSN[:],
208 newscat.DeleteSN[:],
209 )
210 }
211 out = slices.Concat(out,
212 newscat.nameLen(),
213 []byte(newscat.Name),
214 )
215
216 if newscat.readOffset >= len(out) {
217 return 0, io.EOF // All bytes have been read
218 }
219
220 n := copy(p, out)
221
222 newscat.readOffset = n
223
224 return n, nil
225 }
226
227 func (newscat *NewsCategoryListData15) nameLen() []byte {
228 return []byte{uint8(len(newscat.Name))}
229 }
230
231 // newsPathScanner implements bufio.SplitFunc for parsing incoming byte slices into complete tokens
232 func newsPathScanner(data []byte, _ bool) (advance int, token []byte, err error) {
233 if len(data) < 3 {
234 return 0, nil, nil
235 }
236
237 advance = 3 + int(data[2])
238 return advance, data[3:advance], nil
239 }
240
241 type MockThreadNewsMgr struct {
242 mock.Mock
243 }
244
245 func (m *MockThreadNewsMgr) ListArticles(newsPath []string) NewsArtListData {
246 args := m.Called(newsPath)
247
248 return args.Get(0).(NewsArtListData)
249 }
250
251 func (m *MockThreadNewsMgr) GetArticle(newsPath []string, articleID uint32) *NewsArtData {
252 args := m.Called(newsPath, articleID)
253
254 return args.Get(0).(*NewsArtData)
255 }
256 func (m *MockThreadNewsMgr) DeleteArticle(newsPath []string, articleID uint32, recursive bool) error {
257 args := m.Called(newsPath, articleID, recursive)
258
259 return args.Error(0)
260 }
261
262 func (m *MockThreadNewsMgr) PostArticle(newsPath []string, parentArticleID uint32, article NewsArtData) error {
263 args := m.Called(newsPath, parentArticleID, article)
264
265 return args.Error(0)
266 }
267 func (m *MockThreadNewsMgr) CreateGrouping(newsPath []string, name string, itemType [2]byte) error {
268 args := m.Called(newsPath, name, itemType)
269
270 return args.Error(0)
271 }
272
273 func (m *MockThreadNewsMgr) GetCategories(paths []string) []NewsCategoryListData15 {
274 args := m.Called(paths)
275
276 return args.Get(0).([]NewsCategoryListData15)
277 }
278
279 func (m *MockThreadNewsMgr) NewsItem(newsPath []string) NewsCategoryListData15 {
280 args := m.Called(newsPath)
281
282 return args.Get(0).(NewsCategoryListData15)
283 }
284
285 func (m *MockThreadNewsMgr) DeleteNewsItem(newsPath []string) error {
286 args := m.Called(newsPath)
287
288 return args.Error(0)
289 }