1// Aseprite
2// Copyright (C) 2020-2022 Igara Studio S.A.
3// Copyright (C) 2001-2017 David Capello
4//
5// This program is distributed under the terms of
6// the End-User License Agreement for Aseprite.
7
8#ifdef HAVE_CONFIG_H
9#include "config.h"
10#endif
11
12#include "app/ui/news_listbox.h"
13
14#include "app/app.h"
15#include "app/i18n/strings.h"
16#include "app/pref/preferences.h"
17#include "app/res/http_loader.h"
18#include "app/ui/skin/skin_theme.h"
19#include "app/xml_document.h"
20#include "base/fs.h"
21#include "base/string.h"
22#include "base/time.h"
23#include "ui/link_label.h"
24#include "ui/message.h"
25#include "ui/paint_event.h"
26#include "ui/size_hint_event.h"
27#include "ui/view.h"
28#include "ver/info.h"
29
30#include "tinyxml.h"
31
32#include <cctype>
33#include <sstream>
34
35namespace app {
36
37using namespace ui;
38using namespace app::skin;
39
40namespace {
41
42std::string convert_html_entity(const std::string& e)
43{
44 if (e.size() >= 3 && e[0] == '#' && std::isdigit(e[1])) {
45 long unicodeChar;
46 if (e[2] == 'x')
47 unicodeChar = std::strtol(e.c_str()+1, nullptr, 16);
48 else
49 unicodeChar = std::strtol(e.c_str()+1, nullptr, 10);
50
51 if (unicodeChar == 0x2018) return "\x60";
52 if (unicodeChar == 0x2019) return "'";
53 else {
54 std::wstring wstr(1, (wchar_t)unicodeChar);
55 return base::to_utf8(wstr);
56 }
57 }
58 else if (e == "lt") return "<";
59 else if (e == "gt") return ">";
60 else if (e == "amp") return "&";
61 return "";
62}
63
64std::string parse_html(const std::string& str)
65{
66 bool paraOpen = true;
67 std::string result;
68 size_t i = 0;
69 while (i < str.size()) {
70 // Ignore content between <...> symbols
71 if (str[i] == '<') {
72 size_t j = ++i;
73 while (i < str.size() && str[i] != '>')
74 ++i;
75
76 if (i < str.size()) {
77 ASSERT(str[i] == '>');
78
79 std::string tag = str.substr(j, i - j);
80 if (tag == "li") {
81 if (!paraOpen)
82 result.push_back('\n');
83 result.push_back((char)0xc2);
84 result.push_back((char)0xb7); // middle dot
85 result.push_back(' ');
86 paraOpen = false;
87 }
88 else if (tag == "p" || tag == "ul") {
89 if (!paraOpen)
90 result.push_back('\n');
91 paraOpen = true;
92 }
93
94 ++i;
95 }
96 }
97 else if (str[i] == '&') {
98 size_t j = ++i;
99 while (i < str.size() && str[i] != ';')
100 ++i;
101
102 if (i < str.size()) {
103 ASSERT(str[i] == ';');
104 std::string entity = str.substr(j, i - j);
105 result += convert_html_entity(entity);
106 ++i;
107 }
108
109 paraOpen = false;
110 }
111 else {
112 result.push_back(str[i++]);
113 paraOpen = false;
114 }
115 }
116 return result;
117}
118
119}
120
121class NewsItem : public LinkLabel {
122public:
123 NewsItem(const std::string& link,
124 const std::string& title,
125 const std::string& desc)
126 : LinkLabel(link, title)
127 , m_title(title)
128 , m_desc(desc) {
129 }
130
131protected:
132 void onSizeHint(SizeHintEvent& ev) override {
133 auto theme = SkinTheme::get(this);
134 ui::Style* style = theme->styles.newsItem();
135
136 setTextQuiet(m_title);
137 gfx::Size sz = theme->calcSizeHint(this, style);
138
139 if (!m_desc.empty())
140 sz.h *= 5;
141
142 ev.setSizeHint(gfx::Size(0, sz.h));
143 }
144
145 void onPaint(PaintEvent& ev) override {
146 auto theme = SkinTheme::get(this);
147 Graphics* g = ev.graphics();
148 gfx::Rect bounds = clientBounds();
149 ui::Style* style = theme->styles.newsItem();
150 ui::Style* styleDetail = theme->styles.newsItemDetail();
151
152 setTextQuiet(m_title);
153 gfx::Size textSize = theme->calcSizeHint(this, style);
154 gfx::Rect textBounds(bounds.x, bounds.y, bounds.w, textSize.h);
155 gfx::Rect detailsBounds(
156 bounds.x, bounds.y+textSize.h,
157 bounds.w, bounds.h-textSize.h);
158
159 theme->paintWidget(g, this, style, textBounds);
160
161 setTextQuiet(m_desc);
162 theme->paintWidget(g, this, styleDetail, detailsBounds);
163 }
164
165private:
166 std::string m_title;
167 std::string m_desc;
168};
169
170class ProblemsItem : public NewsItem {
171public:
172 ProblemsItem()
173 : NewsItem("", Strings::news_listbox_problem_loading(), "") {
174 }
175
176protected:
177 void onClick() override {
178 static_cast<NewsListBox*>(parent())->reload();
179 }
180};
181
182NewsListBox::NewsListBox()
183 : m_timer(250, this)
184 , m_loader(nullptr)
185{
186 m_timer.Tick.connect(&NewsListBox::onTick, this);
187
188 std::string cache = Preferences::instance().news.cacheFile();
189 if (!cache.empty() && base::is_file(cache) && validCache(cache))
190 parseFile(cache);
191 else
192 reload();
193}
194
195NewsListBox::~NewsListBox()
196{
197 if (m_timer.isRunning())
198 m_timer.stop();
199
200 delete m_loader;
201 m_loader = nullptr;
202}
203
204void NewsListBox::reload()
205{
206 if (m_loader || m_timer.isRunning())
207 return;
208
209 while (auto child = lastChild())
210 removeChild(child);
211
212 View* view = View::getView(this);
213 if (view)
214 view->updateView();
215
216 m_loader = new HttpLoader(get_app_news_rss_url());
217 m_timer.start();
218}
219
220bool NewsListBox::onProcessMessage(ui::Message* msg)
221{
222 switch (msg->type()) {
223
224 case kCloseMessage:
225 if (m_loader)
226 m_loader->abort();
227 break;
228 }
229
230 return ListBox::onProcessMessage(msg);
231}
232
233void NewsListBox::onTick()
234{
235 if (!m_loader || !m_loader->isDone())
236 return;
237
238 std::string fn = m_loader->filename();
239
240 delete m_loader;
241 m_loader = nullptr;
242 m_timer.stop();
243
244 if (fn.empty()) {
245 addChild(new ProblemsItem());
246 View::getView(this)->updateView();
247 return;
248 }
249
250 parseFile(fn);
251}
252
253void NewsListBox::parseFile(const std::string& filename)
254{
255 View* view = View::getView(this);
256
257 XmlDocumentRef doc;
258 try {
259 doc = open_xml(filename);
260 }
261 catch (...) {
262 addChild(new ProblemsItem());
263 if (view)
264 view->updateView();
265 return;
266 }
267
268 TiXmlHandle handle(doc.get());
269 TiXmlElement* itemXml = handle
270 .FirstChild("rss")
271 .FirstChild("channel")
272 .FirstChild("item").ToElement();
273
274 int count = 0;
275
276 while (itemXml) {
277 TiXmlElement* titleXml = itemXml->FirstChildElement("title");
278 TiXmlElement* descXml = itemXml->FirstChildElement("description");
279 TiXmlElement* linkXml = itemXml->FirstChildElement("link");
280 if (titleXml && titleXml->GetText() &&
281 descXml && descXml->GetText() &&
282 linkXml && linkXml->GetText()) {
283 std::string link = linkXml->GetText();
284 std::string title = titleXml->GetText();
285 std::string desc = parse_html(descXml->GetText());
286 // Limit the description text to 4 lines
287 std::string::size_type i = 0;
288 int j = 0;
289 while (true) {
290 i = desc.find('\n', i);
291 if (i == std::string::npos)
292 break;
293 i++;
294 j++;
295 if (j == 5)
296 desc = desc.substr(0, i);
297 }
298
299 addChild(new NewsItem(link, title, desc));
300 if (++count == 4)
301 break;
302 }
303 itemXml = itemXml->NextSiblingElement();
304 }
305
306 TiXmlElement* linkXml = handle
307 .FirstChild("rss")
308 .FirstChild("channel")
309 .FirstChild("link").ToElement();
310 if (linkXml && linkXml->GetText())
311 addChild(
312 new NewsItem(linkXml->GetText(), Strings::news_listbox_more(), ""));
313
314 if (view)
315 view->updateView();
316
317 // Save as cached news
318 Preferences::instance().news.cacheFile(filename);
319}
320
321bool NewsListBox::validCache(const std::string& filename)
322{
323 base::Time
324 now = base::current_time(),
325 time = base::get_modification_time(filename);
326
327 now.dateOnly();
328 time.dateOnly();
329
330 return (now == time);
331}
332
333} // namespace app
334