/*************************************************************************** chathistorydialog.cpp - chat logs browser ------------------- begin : Sun Feb 22 2009 copyright : (C) 2009 by Dario Freddi email : drf54321@gmail.com ***************************************************************************/ /*************************************************************************** * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation; either version 2 of the License, or * * (at your option) any later version. * * * ***************************************************************************/ #include "chathistorydialog.h" #include "../chat/chatmessagestyle.h" #include "../chat/chatmessageview.h" #include "../utils/kmessconfig.h" #include "../utils/kmessshared.h" #include "../currentaccount.h" #include "../kmessdebug.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef KMESSDEBUG_CHATHISTORYDIALOG // #define KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE #endif // Constructor ChatHistoryDialog::ChatHistoryDialog( QWidget *parent ) : KDialog( parent ) , logParser_( 0 ) , model_( new QStandardItemModel( this ) ) , proxyModel_( new QSortFilterProxyModel( this ) ) , reloadingLogs_( false ) , reloadLogsTimer_(0) { // Set up the dialog QWidget *mainWidget = new QWidget( this ); setupUi( mainWidget ); setMainWidget( mainWidget ); setButtons( Help | Close ); setHelp( "using-chat-history" ); setWindowTitle( i18nc( "Dialog window title", "Chat History" ) ); loadingLabel_->hide(); // Let the dialog destroy itself when it's done setAttribute( Qt::WA_DeleteOnClose ); // Set a nice icon setWindowIcon( KMessShared::drawIconOverlay( KIconLoader::global()->loadIcon( "kmess", KIconLoader::Panel ), KIcon( "chronometer" ).pixmap( 20, 20 ) ) ); // Create the chat view to show the chat logs chatView_ = new ChatMessageView( mainWidget ); connect( chatView_, SIGNAL( popupMenu(const QString&,const QPoint&) ), this, SLOT ( slotShowContextMenu(const QString&,const QPoint&) ) ); chatView_->updateChatStyle(); chatView_->getStyle()->setAllowEmoticonLinks( false ); rightSplitter_->addWidget( chatView_->widget() ); // Make the splitter assign more space to the view than to the controls, // and set its minimum size QSizePolicy sizePolicy( QSizePolicy::Preferred, QSizePolicy::Expanding ); sizePolicy.setVerticalStretch( 100 ); chatView_->widget()->setMinimumSize( 300, 200 ); chatView_->widget()->setSizePolicy( sizePolicy ); loadContactsList(); // Initialize the model for the list of contacts proxyModel_->setSourceModel( model_ ); proxyModel_->setDynamicSortFilter( true ); proxyModel_->setFilterCaseSensitivity( Qt::CaseInsensitive ); listView_->setModel( proxyModel_ ); QItemSelectionModel *selectionModel = listView_->selectionModel(); // Attach the signals connect( searchEdit_, SIGNAL( textChanged(const QString&) ), proxyModel_, SLOT ( setFilterRegExp(const QString&) ) ); connect( selectionModel, SIGNAL( currentChanged(const QModelIndex&,const QModelIndex&) ), this, SLOT ( cacheContactXml(const QModelIndex&) ) ); connect( dateRadio_, SIGNAL( toggled(bool) ), this, SLOT ( reloadLogs() ) ); connect( fromBox_, SIGNAL( toggled(bool) ), this, SLOT ( reloadLogs() ) ); connect( toBox_, SIGNAL( toggled(bool) ), this, SLOT ( reloadLogs() ) ); connect( fromDate_, SIGNAL( changed(const QDate&) ), this, SLOT ( checkDates() ) ); connect( toDate_, SIGNAL( changed(const QDate&) ), this, SLOT ( checkDates() ) ); connect( conversationRadio_, SIGNAL( toggled(bool) ), this, SLOT ( reloadLogs() ) ); connect( conversationComboBox_, SIGNAL( currentIndexChanged(int) ), this, SLOT ( reloadLogs() ) ); // Register QDomElement as a meta type to allow the thread to send signals here qRegisterMetaType( "QDomElement" ); // Save the dialog and splitters sizes KConfigGroup group = KMessConfig::instance()->getGlobalConfig( "ChatHistoryDialog" ); restoreDialogSize( group ); mainSplitter_ ->setSizes( group.readEntry( "mainSplitterSizes", QList() << 1 << 100 ) ); rightSplitter_->setSizes( group.readEntry( "rightSplitterSizes", QList() << 1 << 100 ) ); // Normally the focus would be set on the Search box, thus making the // preset explanation text to disappear. listView_->setFocus(); } // Destructor ChatHistoryDialog::~ChatHistoryDialog() { // Save the dialog and splitters size KConfigGroup group = KMessConfig::instance()->getGlobalConfig( "ChatHistoryDialog" ); saveDialogSize( group ); group.writeEntry( "mainSplitterSizes", mainSplitter_ ->sizes() ); group.writeEntry( "rightSplitterSizes", rightSplitter_->sizes() ); } // A conversation has been parsed by the thread void ChatHistoryDialog::addConversation( int timestamp, const QDomElement &newConversation ) { #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << "Adding new conversation on" << timestamp; #endif QMutexLocker lock( &addConversationLock_ ); const QDateTime &date = QDateTime::fromTime_t( timestamp ); // Add this conversation to the internal list conversations_[ timestamp ] = newConversation; // Add the chat to the chat selection combo box, keeping it sorted by date (most recent first) // The first item (index 0) is skipped as it is a temporary item ("Loading...") which // should remain the first int index; for( index = 1; index < conversationComboBox_->count(); ++index ) { int currentTimestamp = conversationComboBox_->itemData( index, Qt::UserRole ).toUInt(); if( currentTimestamp < 1 ) { #ifdef KMESSDEBUG_CHATHISTORYDIALOG kWarning() << "Invalid timestamp!"; #endif continue; } if( currentTimestamp <= timestamp ) { break; } } // Prevent the insertion from generating a signal which regenerates the logs conversationComboBox_->blockSignals( true ); conversationComboBox_->insertItem( index, KGlobal::locale()->formatDateTime( date, KLocale::ShortDate ), timestamp ); conversationComboBox_->blockSignals( false ); #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << "Added as item" << index; #endif } // Caches the XML for the selected contact. Makes things a lot faster void ChatHistoryDialog::cacheContactXml( const QModelIndex &index ) { const QModelIndex contactIndex( proxyModel_->mapToSource( index ) ); if( ! contactIndex.isValid() ) { return; } // Initially the list view emits a currentChanged() signal, but the list // doesn't bother to highlight the selected item listView_->setCurrentIndex( index ); // Reset the xml loading thread if( logParser_ != 0 ) { logParser_->end(); logParser_ = 0; } // Reset the UI xml_.clear(); conversations_.clear(); setLoading( true ); // Avoid reentering while we're still loading a contact's chats listView_->setEnabled( false ); // Reset the cache xml_.setContent( QByteArray( "" ) ); QDomNode cachedConversations( xml_.firstChildElement( "conversations" ) ); const QString handle( model_->itemFromIndex( contactIndex )->text() ); QDir logsDir( KMessConfig::instance()->getAccountDirectory( CurrentAccount::instance()->getHandle() ) + "/chatlogs" ); #ifdef KMESSDEBUG_CHATHISTORYDIALOG kDebug() << "Loading conversations for contact:" << handle; #endif // Filter only to read from this contact's files, if any logsDir.setFilter( QDir::Files ); logsDir.setNameFilters( QStringList() << ( handle + "*.xml" ) ); bool showDate = chatView_->getStyle()->getShowMessageDate(); bool showTime = chatView_->getStyle()->getShowMessageTime(); bool showSeconds = chatView_->getStyle()->getShowMessageSeconds(); const QFileInfoList &list = logsDir.entryInfoList(); foreach( const QFileInfo &entry, list ) { QString currentHandle( entry.fileName() ); #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << "Reading conversations from file:" << entry.fileName(); #endif QFile file( entry.absoluteFilePath() ); if( ! file.open( QFile::ReadOnly | QFile::Text ) ) { // Very unlikely, but... KMessageBox::error( this, i18nc( "Dialog box text", "There has been an error while opening your logs. This " "is commonly a permission problem, check if you have " "read/write access to directory "%1". " "Otherwise, your logs may be corrupted.", logsDir.absolutePath() ), i18nc( "Dialog box title", "Could not open chat history" ) ); setLoading( false ); return; } if( logParser_ == 0 ) { #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << "Starting log parser thread..."; #endif logParser_ = new XmlLogParser( showDate, showTime, showSeconds ); connect( logParser_, SIGNAL( gotConversation(int,const QDomElement&) ), this, SLOT ( addConversation(int,const QDomElement&) ) ); connect( logParser_, SIGNAL( finished() ), this, SLOT ( setLoading() ) ); } #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << "Adding conversation" << entry.fileName() << "to log parser thread..."; #endif logParser_->addDocument( entry.fileName(), file.readAll() ); file.close(); } // start the thread if( logParser_ != 0 && !logParser_->isRunning() ) { kDebug() << "Starting the log parser thread!"; logParser_->start( QThread::HighPriority ); } if( list.isEmpty() ) { setLoading( false ); #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << "No conversations available."; #endif } #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE else { kDebug() << "Conversations are available."; } #endif // Restore the UI: the view will be reenabled by reloadLogs() listView_->setEnabled( true ); } // Verify whether the chat date interval is valid or not bool ChatHistoryDialog::checkDates( bool checkAndReloadLogs ) { // When the user first clicks on the "filter by date" radio button, neither the "from" // nor the "to" checkboxes are enabled, so show no chats. // It's better than showing *everything*, which may mean YEARS of chats. if( ! fromBox_->isChecked() && ! toBox_->isChecked() ) { #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << "No date filter chosen."; #endif return false; } QDate fromDate( fromDate_->date() ); QDate toDate ( toDate_ ->date() ); // Disallow negative date intervals if( fromDate > toDate ) { toDate_->setDate( fromDate ); // Setting the date calls this method for another check return false; } // Allow a maximum interval of one year of chats (and this may already // be an ENORMOUS amount of data, depending on the contact...) static const int maxDays = 365; int daysDifference = fromDate.daysTo( toDate ) - maxDays; if( daysDifference > 0 ) { #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << "Date difference is too long (" << maxDays << "days +" << daysDifference << "), fixing dates."; #endif toDate = toDate.addDays( - daysDifference ); toDate_->setDate( toDate ); // Setting the date calls this method for another check return false; } if( checkAndReloadLogs ) { reloadLogs(); } return true; } // Load the list of contacts for which logs are available void ChatHistoryDialog::loadContactsList() { // Find all XML chat log files in the logs directory QDir logsDir( KMessConfig::instance()->getAccountDirectory( CurrentAccount::instance()->getHandle() ) + "/chatlogs" ); logsDir.setFilter( QDir::Files ); logsDir.setNameFilters( QStringList() << "*.xml" ); // Add them to the list const QFileInfoList &list = logsDir.entryInfoList(); QHash loadedHandles; foreach( const QFileInfo &entry, list ) { QString handle( entry.fileName() ); handle.remove( handle.length() - 4, 4 ); // strip off the number information too. if( handle.at( handle.length() - 1 ).isNumber() ) { handle.truncate( handle.lastIndexOf( '.' ) ); } // now, only add the handle if it hasn't already been loaded. if ( loadedHandles.contains( handle ) ) continue; loadedHandles.insert( handle, true ); model_->appendRow( new QStandardItem( handle ) ); } } // Reload the current chat log history void ChatHistoryDialog::reloadLogs() { // Never load logs immediately: wait an instant to allow the user to do more changes. // This makes the interface zippy and the user happy if( reloadLogsTimer_ == 0 ) { reloadLogsTimer_ = new QTimer( this ); reloadLogsTimer_->setSingleShot( true ); connect( reloadLogsTimer_, SIGNAL( timeout() ), this, SLOT ( reloadLogs() ) ); } if( reloadLogsTimer_ != sender() ) { #ifdef KMESSDEBUG_CHATHISTORYDIALOG kDebug() << "Scheduling reload..."; #endif reloadLogsTimer_->start( 250 ); return; } // Reloading logs may be time consuming too, protect the operation from reentrancy // QMutexLocker lock( &addConversationLock_ ); if( reloadingLogs_ ) { #ifdef KMESSDEBUG_CHATHISTORYDIALOG kDebug() << "Already running, exit."; #endif return; } reloadingLogs_ = true; chatView_->widget()->setEnabled( false ); if( conversations_.isEmpty() ) { #ifdef KMESSDEBUG_CHATHISTORYDIALOG kWarning() << "No cached conversations!"; #endif reloadingLogs_ = false; return; } #ifdef KMESSDEBUG_CHATHISTORYDIALOG kDebug() << "Reloading..."; #endif // The text stream is used to dump the DOM nodes to an XML byte array. QString xmlData; QTextStream log( &xmlData ); // Show a specific chat if( conversationRadio_->isChecked() ) { // Get the timestamp of the requested chat from the combobox. int timestamp = conversationComboBox_->itemData( conversationComboBox_->currentIndex(), Qt::UserRole ).toUInt(); #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << "Conversation timestamp:" << timestamp; #endif if( ! conversations_.contains( timestamp ) ) { kWarning() << "Chat not found!"; reloadingLogs_ = false; return; } // Add the chat to the xml output log << conversations_[ timestamp ]; } // There are two options only, but you *never* know. else if( dateRadio_->isChecked() ) { // Assume the interval of dates makes sense: checkDates() fixes the dates for us. if( ! checkDates( false /*checkAndReloadLogs */ ) ) { reloadingLogs_ = false; return; } // All chats in the specified time interval will be merged into one. // Add to the xml string the starting tag log << "" ; QMapIterator it( conversations_ ); while( it.hasNext() ) { it.next(); const int timestamp = it.key(); const QDomNode &conversation = it.value(); const QDate &date = QDateTime::fromTime_t( timestamp ).date(); #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << "Conversation date:" << date; #endif if( fromBox_->isChecked() && date < fromDate_->date() ) { #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << "Filtering out conversation on:" << date << "<" << fromDate_->date(); #endif continue; } else if( toBox_->isChecked() && date > toDate_->date() ) { #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << "Filtering out conversation on:" << date << ">" << toDate_->date() << "and all later ones"; #endif // We're at the last possible chat, skip the rest break; } // Add all messages of this conversation to the output const QDomNodeList &messages = conversation.childNodes(); for( int index = 0; index < messages.size(); ++index ) { log << messages.at( index ); } // Without this, the data won't be fully sent to the byte array by the time setXml is called. log.flush(); } // Add the closing tag log << ""; } else { kWarning() << "UI error, no chat selection choice was active!"; reloadingLogs_ = false; return; } chatView_->setXml( xmlData ); chatView_->widget()->setEnabled( true ); #ifdef KMESSDEBUG_CHATHISTORYDIALOG kDebug() << "Reload done."; #endif reloadingLogs_ = false; } /** * Set the contact for whom the logs will be initially shown * * @param handle the handle to get contact history for * @returns whether there was history for the given contact contact */ bool ChatHistoryDialog::setContact( const QString &handle ) { if( handle.isEmpty() ) { #ifdef KMESSDEBUG_CHATHISTORYDIALOG kDebug() << "Handle is empty, pretending to have logs for the contact."; #endif return true; } QList items = model_->findItems( handle, Qt::MatchContains ); if( items.isEmpty() ) { #ifdef KMESSDEBUG_CHATHISTORYDIALOG kDebug() << handle << "has no chat history"; #endif return false; } listView_->setCurrentIndex( proxyModel_->mapFromSource( items.first()->index() ) ); return true; } // Set whether the dialog is loading chats or not void ChatHistoryDialog::setLoading( bool isLoading ) { if( isLoading ) { // Show the loading animation loadingLabel_->show(); loadingLabel_->start(); // Clear the chooser conversationComboBox_->clear(); conversationComboBox_->insertItem( 0, i18nc( "Combo box default item", "Loading..." ), -1 ); // Clear the view chatView_->clearView( true ); #ifdef KMESSDEBUG_CHATHISTORYDIALOG kDebug() << "Now loading..."; #endif } else { // Hide the loading animation loadingLabel_->stop(); loadingLabel_->hide(); if( conversationComboBox_->count() < 1 || conversations_.isEmpty() ) { #ifdef KMESSDEBUG_CHATHISTORYDIALOG kDebug() << "No more loading - list empty."; #endif conversationComboBox_->clear(); conversationComboBox_->insertItem( 0, i18nc( "Combo box default item", "No logged chats" ), -1 ); } else if( conversationComboBox_->itemData( 0, Qt::UserRole ) == -1 ) { conversationComboBox_->removeItem( 0 ); #ifdef KMESSDEBUG_CHATHISTORYDIALOG kDebug() << "No more loading - removed temp item."; #endif } #ifdef KMESSDEBUG_CHATHISTORYDIALOG else { kDebug() << "Wtf?"; } #endif } chatView_->widget()->setEnabled( ! isLoading ); } // Worker thread's constructor XmlLogParser::XmlLogParser( bool showDate, bool showTime, bool showSeconds ) : end_( false ) , showDate_( showDate ) , showTime_( showTime ) , showSeconds_( showSeconds ) { } // Add another document to the worker thread's queue void XmlLogParser::addDocument( const QString &fileName, const QByteArray &newDocument ) { documents_[ fileName ] = newDocument; } // Stop the worker thread void XmlLogParser::end() { disconnect( this ); end_ = true; // quit() here makes the threads exec() return, so it effectively "detaches" // from the main program; however, the run() method stays running until it's // done with the current document and it notices end_ is true. If that takes // more than the 5 seconds (in the wait() call below), it is terminated. quit(); // Wait 5s for the thread to finish: if it's not enough, kill it wait( 5000 ); if( isRunning() ) { terminate(); } deleteLater(); } // Worker thread's method to parse XML files into conversations void XmlLogParser::run() { #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << "Initially present:" << documents_.count() << "documents."; #endif while( ! documents_.isEmpty() ) { // take the first document from the hash for processing QHash::iterator it = documents_.begin(); const QString fileName = it.key(); const QByteArray documentBytes = documents_.take( fileName ); QDomDocument document; document.setContent( documentBytes ); #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << "Parsing document from XML file:" << fileName; #endif // There is no message root in this XML file QDomNode messageRoot( document.firstChildElement( "messageRoot" ) ); if( messageRoot.isNull() ) { kWarning() << "Invalid XML file!"; continue; } // Since in the chat logs only the message timestamps are saved, add to each message // the date and time in the current format const QDomNodeList &messages = document.elementsByTagName( "message" ); #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << messages.size() << "messages to parse."; #endif for( int indexMsg = 0; indexMsg < messages.size(); ++indexMsg ) { QDomElement message( messages.at( indexMsg ).toElement() ); int timestamp = message.attribute( "timestamp" ).toUInt(); const QDateTime &datetime = QDateTime::fromTime_t( timestamp ); if( ! message.hasAttribute( "timestamp" ) || timestamp == 0 ) { continue; } if ( showDate_ ) { message.setAttribute( "time", KGlobal::locale()->formatDateTime( datetime, KLocale::ShortDate, showSeconds_ ) ); } else { message.setAttribute( "time", KGlobal::locale()->formatTime( datetime.time(), showSeconds_ ) ); } } #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << messages.size() << "messages parsed."; #endif // Add all conversations from this file to the cached XML and the UI const QDomNodeList &fileConversations = messageRoot.childNodes(); for( int index = 0; index < fileConversations.size(); ++index ) { QDomElement conversation( fileConversations.at( index ).toElement() ); if( conversation.nodeName() != "conversation" ) { kWarning() << "Unexpected element" << conversation.nodeName() << "found in the XML log:" << fileName; continue; } // Check if the has a valid timestamp const QDomNode ×tampNode = conversation.attributes().namedItem( "timestamp" ); const int timestamp = timestampNode.nodeValue().toInt(); if( timestampNode.isNull() ) { #ifdef KMESSDEBUG_CHATHISTORYDIALOG kWarning() << "Invalid XML: invalid timestamp!"; #endif continue; } // Change it to a so that it'll be readily usable when selected conversation.setTagName( "messageRoot" ); // conversation.removeAttribute( "timestamp" ); // Ignored by ChatMessageView? emit gotConversation( timestamp, conversation ); } // if we're supposed to stop processing now, just stop. if( end_ ) { #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << "Caught end, closing."; #endif return; } } #ifdef KMESSDEBUG_CHATHISTORYDIALOG_VERBOSE kDebug() << "All documents have been parsed."; #endif } // The user right clicked at the KHTMLPart to show a popup. void ChatHistoryDialog::slotShowContextMenu( const QString &clickedUrl, const QPoint &point ) { Q_UNUSED( clickedUrl ) KMenu *popup = chatView_->popupMenu(); popup->exec( point ); delete popup; } #include "chathistorydialog.moc"