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 | |
35 | namespace app { |
36 | |
37 | using namespace ui; |
38 | using namespace app::skin; |
39 | |
40 | namespace { |
41 | |
42 | std::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 | |
64 | std::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 | |
121 | class NewsItem : public LinkLabel { |
122 | public: |
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 | |
131 | protected: |
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 | |
165 | private: |
166 | std::string m_title; |
167 | std::string m_desc; |
168 | }; |
169 | |
170 | class ProblemsItem : public NewsItem { |
171 | public: |
172 | ProblemsItem() |
173 | : NewsItem("" , Strings::news_listbox_problem_loading(), "" ) { |
174 | } |
175 | |
176 | protected: |
177 | void onClick() override { |
178 | static_cast<NewsListBox*>(parent())->reload(); |
179 | } |
180 | }; |
181 | |
182 | NewsListBox::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 | |
195 | NewsListBox::~NewsListBox() |
196 | { |
197 | if (m_timer.isRunning()) |
198 | m_timer.stop(); |
199 | |
200 | delete m_loader; |
201 | m_loader = nullptr; |
202 | } |
203 | |
204 | void 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 | |
220 | bool 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 | |
233 | void 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 | |
253 | void 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 | |
321 | bool 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 | |