Files
kmess/src/dialogs/chathistorydialog.cpp
T
2009-07-17 15:44:01 +00:00

808 lines
25 KiB
C++

/***************************************************************************
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 <QDir>
#include <QTimer>
#include <QStandardItemModel>
#include <QSortFilterProxyModel>
#include <QtXml/QDomDocument>
#include <QtXml/QDomNode>
#include <QClipboard>
#include <KApplication>
#include <KMessageBox>
#include <KLocalizedString>
#include <KDateTime>
#include <KMenu>
#include <KAction>
#include <KHTMLView>
#include <KStandardAction>
#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>( "QDomElement" );
// Save the dialog and splitters sizes
KConfigGroup group = KMessConfig::instance()->getGlobalConfig( "ChatHistoryDialog" );
restoreDialogSize( group );
mainSplitter_ ->setSizes( group.readEntry( "mainSplitterSizes", QList<int>() << 1 << 100 ) );
rightSplitter_->setSizes( group.readEntry( "rightSplitterSizes", QList<int>() << 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( "<conversations></conversations>" ) );
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 <i>&quot;%1&quot;</i>. "
"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<QString, bool> 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 << "<messageRoot>" ;
QMapIterator<int,QDomNode> 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 << "</messageRoot>";
}
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<QStandardItem*> 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<const QString,QByteArray>::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 <conversation> has a valid timestamp
const QDomNode &timestampNode = 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 <messageRoot> 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"