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>:-&lt;</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