1 /*
2 * This file is part of Licq, an instant messaging client for UNIX.
3 * Copyright (C) 2003-2014 Licq developers <licq-dev@googlegroups.com>
4 *
5 * Licq is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * Licq is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with Licq; if not, write to the Free Software
17 * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
18 */
19
20 #include "emoticons.h"
21
22 #include "config.h"
23
24 #ifdef USE_KDE
25 # include <kapplication.h>
26 #else
27 # include <QApplication>
28 #endif
29
30 //#define EMOTICON_DEBUG
31
32 #ifdef EMOTICON_DEBUG
33 # define TRACE(x...) qDebug(x)
34 #else
35 # define TRACE(x...) ((void)0)
36 #endif
37
38 #include <QDir>
39 #include <QDomDocument>
40 #include <QLinkedList>
41 #include <QRegExp>
42 #include <QTextDocument>
43
44 #include <licq/logging/log.h>
45
46
47 using namespace LicqQtGui;
48
49 const QString Emoticons::DEFAULT_THEME =
50 QString::fromLatin1(QT_TRANSLATE_NOOP("LicqQtGui::Emoticons", "Default"));
51 const QString Emoticons::NO_THEME =
52 QString::fromLatin1(QT_TRANSLATE_NOOP("LicqQtGui::Emoticons", "None"));
53
54 struct Emoticon
55 {
56 QString file;
57 QString smiley;
58 QString escapedSmiley;
59 };
60
61 /// Private data and functions for Emotions
62 class Emoticons::Impl
63 {
64 public:
65 QStringList basedirs;
66 QString currentTheme;
67
68 // Maps first char in smiley to its Emoticon instance.
69 QMap<QChar, QLinkedList<Emoticon> > emoticons;
70
71 // Maps an emoticon's filename to a smiley.
72 QMap<QString, QString> fileSmiley;
73
74 QString themeDir(const QString &theme) const;
75 };
76
77 /**
78 * @param theme the untranslated name of a theme
79 * @returns the full path to @a theme, or QString::null if no such theme was found.
80 */
themeDir(const QString & theme) const81 QString Emoticons::Impl::themeDir(const QString &theme) const
82 {
83 QStringList::ConstIterator basedir = basedirs.begin();
84 for (; basedir != basedirs.end(); basedir++)
85 {
86 const QString dir = QString("%1/%2").arg(*basedir).arg(theme);
87 if (QFile::exists(QString("%1/emoticons.xml").arg(dir)))
88 return dir;
89 }
90
91 return QString::null;
92 }
93
94
95 // By making the application object parent, this instance will be
96 // deleted when the application is closed.
Emoticons()97 Emoticons::Emoticons()
98 #ifdef USE_KDE
99 : QObject(kapp)
100 #else
101 : QObject(qApp)
102 #endif
103 {
104 pimpl = new Impl;
105 pimpl->currentTheme = NO_THEME;
106 }
107
~Emoticons()108 Emoticons::~Emoticons()
109 {
110 delete pimpl;
111 }
112
113 Emoticons* Emoticons::m_self = 0L;
self()114 Emoticons* Emoticons::self()
115 {
116 if (!m_self)
117 m_self = new Emoticons;
118 return m_self;
119 }
120
translateThemeName(const QString & name)121 QString Emoticons::translateThemeName(const QString &name)
122 {
123 if (name == DEFAULT_THEME || name == NO_THEME)
124 return tr(name.toLatin1());
125 return name;
126 }
127
untranslateThemeName(const QString & name)128 QString Emoticons::untranslateThemeName(const QString &name)
129 {
130 if (name == tr(DEFAULT_THEME.toLatin1()))
131 return DEFAULT_THEME;
132 else if (name == tr(NO_THEME.toLatin1()))
133 return NO_THEME;
134 else
135 return name;
136 }
137
setBasedirs(const QStringList & basedirs)138 void Emoticons::setBasedirs(const QStringList &basedirs)
139 {
140 pimpl->basedirs.clear();
141 QStringList::ConstIterator basedir = basedirs.begin();
142 for (; basedir != basedirs.end(); basedir++)
143 pimpl->basedirs += QDir(*basedir).absolutePath();
144 }
145
146 /**
147 * In every subdir in every basedir, we check for a file
148 * named emoticons.xml, and if we find one, subdir is added
149 * to the list of themes.
150 */
themes() const151 QStringList Emoticons::themes() const
152 {
153 QStringList themes;
154 bool defaultExists = false;
155
156 QStringList::ConstIterator basedir = pimpl->basedirs.begin();
157 for (; basedir != pimpl->basedirs.end(); basedir++)
158 {
159 QDir dir(*basedir, QString::null, QDir::Unsorted, QDir::Dirs);
160 const QStringList subdirs = dir.entryList();
161
162 QStringList::ConstIterator subdir = subdirs.begin();
163 for (; subdir != subdirs.end(); subdir++)
164 {
165 if (*subdir == "." || *subdir == "..")
166 continue;
167
168 if (*subdir == NO_THEME)
169 continue; // Add this later
170
171 if (QFile::exists(QString("%1/%2/emoticons.xml").arg(*basedir).arg(*subdir)))
172 {
173 if (*subdir == DEFAULT_THEME)
174 {
175 defaultExists = true;
176 continue; // Add this later
177 }
178
179 // Only add unique entires
180 if (themes.indexOf(*subdir) == -1)
181 themes += *subdir;
182 }
183 }
184 }
185
186 themes.sort();
187
188 // Adding these at the front so that they will be first in the list shown to the user.
189 if (defaultExists)
190 themes.push_front(translateThemeName(DEFAULT_THEME));
191 themes.push_front(translateThemeName(NO_THEME));
192
193 return themes;
194 }
195
196 /**
197 * @param dir directory to search in
198 * @param file filename (without extension) to search for
199 * @returns the full filename or QString::null if no such file exists.
200 */
fullFilename(const QString & dir,const QString & file)201 static QString fullFilename(const QString& dir, const QString& file)
202 {
203 const QString base = QString("%1/%2").arg(dir).arg(file);
204
205 if (QFile::exists(base)) // First try without extension
206 return base;
207 else if (QFile::exists(base + ".png"))
208 return base + ".png";
209 else if (QFile::exists(base + ".jpg"))
210 return base + ".jpg";
211 else if (QFile::exists(base + ".gif"))
212 return base + ".gif";
213 else if (QFile::exists(base + ".mng"))
214 return base + ".mng";
215
216 Licq::gLog.warning("Unknown file '%s'", base.toLatin1().constData());
217 return QString::null;
218 }
219
220 /**
221 * Parses the emoticons.xml file in @a dir.
222 * @param emoticons For every smiley, the first character is added as a key
223 * and its Emoticon instance is appened to the list.
224 * @param fileSmiley Maps the filename of an emoticon to a smiley.
225 * @returns true on success; otherwise false.
226 *
227 * A short emoticons.xml file could look like this:
228 * <?xml version="1.0"?>
229 * <messaging-emoticon-map >
230 *
231 * <emoticon file="biggrin">
232 * <string>:-<</string>
233 * <string>:D</string>
234 * </emoticon>
235 *
236 * <emoticon file="confused">
237 * <string>:-S</string>
238 * </emoticon>
239 *
240 * </messaging-emoticon-map>
241 */
parseXml(const QString & dir,QMap<QChar,QLinkedList<Emoticon>> * emoticons,QMap<QString,QString> * fileSmiley)242 static bool parseXml(const QString& dir, QMap<QChar, QLinkedList<Emoticon> >* emoticons, QMap<QString, QString>* fileSmiley)
243 {
244 QFile xmlfile(dir + QString::fromLatin1("/emoticons.xml"));
245 if (!xmlfile.open(QIODevice::ReadOnly))
246 return false;
247
248 QDomDocument doc("emoticons");
249 if (!doc.setContent(&xmlfile))
250 {
251 xmlfile.close();
252 return false;
253 }
254 xmlfile.close();
255
256 QDomElement docElem = doc.documentElement();
257
258 // Walk through all <emoticon> elements
259 QDomNode n = docElem.firstChild();
260 for (; !n.isNull(); n = n.nextSibling())
261 {
262 QDomElement e = n.toElement();
263 if (!e.isNull() && e.tagName() == QString::fromLatin1("emoticon"))
264 {
265 const QString file = fullFilename(dir, e.attribute("file"));
266 if (file.isNull())
267 continue;
268
269 bool first = true;
270 QDomNode stringNode = n.firstChild();
271 for (; !stringNode.isNull(); stringNode = stringNode.nextSibling())
272 {
273 // We extract all smileys from <string> elements (<string>smiley</string>).
274 // The first one is added to fileSmiley, so that when the user clicks
275 // on the icon, this is the smiley that is inserted into the document.
276 //
277 // All smileys are then indexed in the emoticons map on the first character
278 // in the escaped smiley.
279 QDomElement string = stringNode.toElement();
280 if (!string.isNull() && string.tagName() == QString::fromLatin1("string"))
281 {
282 Emoticon emo;
283 emo.smiley = string.text();
284 #if QT_VERSION >= QT_VERSION_CHECK(5, 0, 0)
285 emo.escapedSmiley = emo.smiley.toHtmlEscaped();
286 #else
287 emo.escapedSmiley = Qt::escape(emo.smiley);
288 #endif
289 emo.file = file;
290
291 if (first)
292 {
293 (*fileSmiley)[emo.file] = emo.smiley;
294 first = false;
295 }
296
297 // Insert the smiley sorted by length with longest first. This way, if we have
298 // a smiley :) with image A and :)) with image B, the string :)) will always
299 // be replaced by image B.
300 QLinkedList<Emoticon>::iterator it = (*emoticons)[emo.escapedSmiley[0]].begin();
301 QLinkedList<Emoticon>::iterator end = (*emoticons)[emo.escapedSmiley[0]].end();
302 while (it != end)
303 {
304 #ifdef EMOTICON_DEBUG
305 if ((*it).escapedSmiley == emo.escapedSmiley)
306 TRACE("The smiley '%s' (%s) is already mapped to %s",
307 emo.smiley.toLatin1().constData(),
308 QFileInfo(file).fileName().toLatin1().constData(),
309 QFileInfo((*it).file).fileName().toLatin1().constData());
310 #endif
311 if ((*it).escapedSmiley.length() < emo.escapedSmiley.length())
312 break;
313 else
314 it++;
315 }
316 (*emoticons)[emo.escapedSmiley[0]].insert(it, emo);
317 }
318 else
319 {
320 Licq::gLog.warning("Element '%s' in '%s' unknown",
321 string.tagName().toLatin1().constData(),
322 xmlfile.fileName().toLatin1().constData());
323 }
324 }
325 }
326 }
327
328 return true;
329 }
330
fileList() const331 QStringList Emoticons::fileList() const
332 {
333 return pimpl->fileSmiley.keys();
334 }
335
336 // Similar to setTheme(const QString_&) but with the difference that
337 // here we don't update currentTheme. We're just interested in getting
338 // the filelist.
fileList(const QString & theme_in) const339 QStringList Emoticons::fileList(const QString& theme_in) const
340 {
341 const QString theme = untranslateThemeName(theme_in);
342
343 if (theme.isEmpty() || theme == NO_THEME)
344 return QStringList();
345
346 if (theme == pimpl->currentTheme)
347 return fileList();
348
349 const QString dir = pimpl->themeDir(theme);
350 if (dir.isNull())
351 return QStringList();
352
353 QMap<QChar, QLinkedList<Emoticon> > emoticons;
354 QMap<QString, QString> fileSmiley;
355
356 const bool parsed = parseXml(dir, &emoticons, &fileSmiley);
357 if (parsed)
358 return fileSmiley.keys();
359
360 return QStringList();
361 }
362
setTheme(const QString & theme_in)363 bool Emoticons::setTheme(const QString& theme_in)
364 {
365 const QString theme = untranslateThemeName(theme_in);
366
367 if (theme.isEmpty() || theme == NO_THEME)
368 {
369 if (pimpl->currentTheme == NO_THEME)
370 return true;
371
372 pimpl->currentTheme = NO_THEME;
373 pimpl->emoticons.clear();
374 pimpl->fileSmiley.clear();
375 emit themeChanged();
376 return true;
377 }
378
379 if (theme == pimpl->currentTheme)
380 return true;
381
382 const QString dir = pimpl->themeDir(theme);
383 if (dir.isNull())
384 return false;
385
386 QMap<QChar, QLinkedList<Emoticon> > emoticons;
387 QMap<QString, QString> fileSmiley;
388
389 if (!parseXml(dir, &emoticons, &fileSmiley))
390 return false;
391
392 pimpl->currentTheme = theme;
393 pimpl->emoticons = emoticons;
394 pimpl->fileSmiley = fileSmiley;
395 emit themeChanged();
396 return true;
397 }
398
theme() const399 QString Emoticons::theme() const
400 {
401 return translateThemeName(pimpl->currentTheme);
402 }
403
emoticonsKeys() const404 QMap<QString, QString> Emoticons::emoticonsKeys() const
405 {
406 return pimpl->fileSmiley;
407 }
408
409 /**
410 * @returns true if s1[start:start+s2.length] == s2
411 */
containsAt(const QString & s1,const QString & s2,const uint start)412 static bool containsAt(const QString& s1, const QString& s2, const uint start)
413 {
414 const uint end = start + s2.length();
415 const uint s1_length = static_cast<uint>(s1.length());
416 if (s1_length < end || start > s1_length)
417 return false;
418
419 for (uint pos = start; pos < end; pos++)
420 {
421 if (s1[pos] != s2[pos - start])
422 return false;
423 }
424 return true;
425 }
426
427 /**
428 * @param message is assumed to be in html, so that all \< is part of a tag
429 * @param mode the parsing mode
430 */
parseMessage(QString & message,ParseMode mode) const431 void Emoticons::parseMessage(QString& message, ParseMode mode) const
432 {
433 // Short-circuit if we don't have any emoticons
434 if (pimpl->emoticons.isEmpty())
435 return;
436
437 TRACE("message pre: '%s'", message.toLatin1().constData());
438
439 QChar p(' '), c; // previous and current char
440 for (int pos = 0; pos < message.length(); pos++)
441 {
442 c = message[pos];
443
444 if (c == '<')
445 {
446 // If this is an a tag ("<a "), skip it completly
447 if (message[pos + 1] == 'a' && message[pos + 2].isSpace())
448 {
449 const int index = message.indexOf("</a>", pos);
450 if (index == -1)
451 return; // Bad html
452 pos = index + 3; // Fast-forward pos to point at '>'
453 }
454 else // Skip just the tag
455 {
456 const int index = message.indexOf('>', pos);
457 if (index == -1)
458 return; // Bad html
459 pos = index; // Fast-forward pos to point at '>'
460 }
461 p = '>';
462 continue;
463 }
464
465 // Only insert smileys after a space in strict and normal mode
466 if (mode == StrictMode || mode == NormalMode)
467 {
468 if (!p.isSpace() && !containsAt(message, QString::fromLatin1("<br />"), pos - 6))
469 {
470 p = c;
471 continue;
472 }
473 }
474
475 if (pimpl->emoticons.contains(c))
476 {
477 const QLinkedList<Emoticon> emolist = pimpl->emoticons[c];
478 QLinkedList<Emoticon>::ConstIterator it = emolist.begin();
479
480 for (; it != emolist.end(); it++)
481 {
482 const Emoticon& emo = *it;
483 if (containsAt(message, emo.escapedSmiley, pos))
484 {
485 // In strict and normal mode we need to check the char after the smiley
486 if (mode == StrictMode || mode == NormalMode)
487 {
488 const uint nextPos = pos + emo.escapedSmiley.length();
489 const QChar& n = message[nextPos];
490 if (!(n.isSpace() || n.isNull() || containsAt(message, QString::fromLatin1("<br"), nextPos)))
491 {
492 if (mode == StrictMode)
493 break;
494 else if (!n.isPunct()) // In normal mode we allow punct as well
495 break;
496 }
497 }
498
499 QString img = QString::fromLocal8Bit("<img src=\"file://%1#LICQ%2\">")
500 .arg(emo.file)
501 .arg(emo.escapedSmiley);
502 TRACE("Replacing '%s' with '%s'",
503 message.mid(pos, emo.escapedSmiley.length()).toLatin1().constData(),
504 img.toLatin1().constData());
505 message.replace(pos, emo.escapedSmiley.length(), img);
506 pos += img.length() - 1; // Point pos at '>'
507 c = '>';
508 break;
509 }
510 }
511 }
512
513 p = c;
514 }
515 TRACE("message post: '%s'", message.toLatin1().constData());
516 }
517
518 /**
519 * "unparse" the message, removing all \<img\> tags and replacing them with the smiley.
520 */
unparseMessage(QString & message)521 void Emoticons::unparseMessage(QString& message)
522 {
523 QRegExp deicon("<img src=\"file://.*#LICQ(.*)\".*>");
524 deicon.setMinimal(true);
525 message.replace(deicon, "\\1");
526 }
527