diff --git a/chat/components/public/imILogger.idl b/chat/components/public/imILogger.idl index c3a64b6cf0..d4693322f0 100644 --- a/chat/components/public/imILogger.idl +++ b/chat/components/public/imILogger.idl @@ -42,14 +42,16 @@ interface imILog: nsISupports { imILogConversation getConversation(); }; -[scriptable, uuid(ab38c01c-2245-4279-9437-1d6bcc69d556)] +[scriptable, uuid(327ba58c-ee9c-4d1c-9216-fd505c45a3e0)] interface imILogger: nsISupports { - imILog getLogFromFile(in nsIFile aFile); + imILog getLogFromFile(in nsIFile aFile, [optional] in boolean aGroupByDay); nsIFile getLogFileForOngoingConversation(in prplIConversation aConversation); nsISimpleEnumerator getLogsForAccountBuddy(in imIAccountBuddy aAccountBuddy); nsISimpleEnumerator getLogsForBuddy(in imIBuddy aBuddy); nsISimpleEnumerator getLogsForContact(in imIContact aContact); - nsISimpleEnumerator getLogsForConversation(in prplIConversation aConversation); + nsISimpleEnumerator getLogsForConversation(in prplIConversation aConversation, + [optional] in boolean aGroupByDay); nsISimpleEnumerator getSystemLogsForAccount(in imIAccount aAccount); - nsISimpleEnumerator getSimilarLogs(in imILog aLog); + nsISimpleEnumerator getSimilarLogs(in imILog aLog, + [optional] in boolean aGroupByDay); }; diff --git a/chat/components/src/logger.js b/chat/components/src/logger.js index 681b0e1366..55b07df46d 100644 --- a/chat/components/src/logger.js +++ b/chat/components/src/logger.js @@ -74,7 +74,7 @@ ConversationLog.prototype = { _init: function cl_init() { let file = getLogFolderForAccount(this._conv.account, true); let name = this._conv.normalizedName; - if (this._conv.isChat && this._conv.account.protocol.id != "prpl-twitter") + if (convIsRealMUC(this._conv)) name += ".chat"; file.append(name); if (!file.exists()) @@ -299,31 +299,46 @@ function LogMessage(aData, aConversation) } LogMessage.prototype = GenericMessagePrototype; -function LogConversation(aLineInputStream) +function LogConversation(aLineInputStreams) { - let line = {value: ""}; - let more = aLineInputStream.readLine(line); - - if (!line.value) - throw "bad log file"; - - let data = JSON.parse(line.value); - this.name = data.name; - this.title = data.title; - this._accountName = data.account; - this._protocolName = data.protocol; + // If aLineInputStreams isn't an Array, we'll assume that it's a lone + // InputStream, and wrap it in an Array. + if (!Array.isArray(aLineInputStreams)) + aLineInputStreams = [aLineInputStreams]; this._messages = []; - while (more) { - more = aLineInputStream.readLine(line); + + // We'll read the name, title, account, and protocol data from the first + // stream, and skip the others. + let firstFile = true; + + for each (let inputStream in aLineInputStreams) { + let line = {value: ""}; + let more = inputStream.readLine(line); + if (!line.value) - break; - try { + throw "bad log file"; + + if (firstFile) { let data = JSON.parse(line.value); - this._messages.push(new LogMessage(data, this)); - } catch (e) { - // if a message line contains junk, just ignore the error and - // continue reading the conversation. + this.name = data.name; + this.title = data.title; + this._accountName = data.account; + this._protocolName = data.protocol; + firstFile = false; + } + + while (more) { + more = inputStream.readLine(line); + if (!line.value) + break; + try { + let data = JSON.parse(line.value); + this._messages.push(new LogMessage(data, this)); + } catch (e) { + // if a message line contains junk, just ignore the error and + // continue reading the conversation. + } } } } @@ -350,19 +365,15 @@ function Log(aFile) { this.file = aFile; this.path = aFile.path; - const regexp = /([0-9]{4})-([0-9]{2})-([0-9]{2}).([0-9]{2})([0-9]{2})([0-9]{2})([+-])([0-9]{2})([0-9]{2}).*\.([a-z]+)$/; - let r = aFile.leafName.match(regexp); - if (!r) { + + let [date, format] = getDateFromFilename(aFile.leafName); + if (!date || !format) { this.format = "invalid"; this.time = 0; return; } - let date = new Date(r[1], r[2] - 1, r[3], r[4], r[5], r[6]); - let offset = r[7] * 60 + r[8]; - if (r[6] == -1) - offset *= -1; - this.time = date.valueOf() / 1000; // ignore the timezone offset for now (FIXME) - this.format = r[10]; + this.time = date.valueOf() / 1000; + this.format = format; } Log.prototype = { __proto__: ClassInfo("imILog", "Log object"), @@ -388,6 +399,38 @@ Log.prototype = { } }; +/** + * Takes a properly formatted log file name and extracts the date information + * and filetype, returning the results as an Array. + * + * Filenames are expected to be formatted as: + * + * YYYY-MM-DD.HHmmSS+ZZzz.format + * + * @param aFilename the name of the file + * @returns an Array, where the first element is a Date object for the date + * that the log file represents, and the file type as a string. + */ +function getDateFromFilename(aFilename) { + const kRegExp = /([\d]{4})-([\d]{2})-([\d]{2}).([\d]{2})([\d]{2})([\d]{2})([+-])([\d]{2})([\d]{2}).*\.([A-Za-z]+)$/; + + let r = aFilename.match(kRegExp); + if (!r) + return []; + + // We ignore the timezone offset for now (FIXME) + return [new Date(r[1], r[2] - 1, r[3], r[4], r[5], r[6]), r[10]]; +} + +/** + * Returns true if a Conversation is both a chat conversation, and not + * a Twitter conversation. + */ +function convIsRealMUC(aConversation) { + return (aConversation.isChat && + aConversation.account.protocol.id != "prpl-twitter"); +} + function LogEnumerator(aEntries) { this._entries = aEntries; @@ -404,17 +447,158 @@ LogEnumerator.prototype = { QueryInterface: XPCOMUtils.generateQI([Ci.nsISimpleEnumerator]) }; +function DailyLogEnumerator(aEntries) { + this._entries = {}; + + for each (entry in aEntries) { + while (entry.hasMoreElements()) { + let file = entry.getNext(); + if (!(file instanceof Ci.nsIFile)) + continue; + + let [logDate] = getDateFromFilename(file.leafName); + if (!logDate) { + // We'll skip this one, since it's got a busted filename. + continue; + } + + // We want to cluster all of the logs that occur on the same day + // into the same Arrays. We clone the date for the log, reset it to + // the 0th hour/minute/second, and use that to construct an ID for the + // Array we'll put the log in. + let dateForID = new Date(logDate); + dateForID.setHours(0); + dateForID.setMinutes(0); + dateForID.setSeconds(0); + let dayID = dateForID.toISOString(); + + if (!(dayID in this._entries)) + this._entries[dayID] = []; + + this._entries[dayID].push({ + file: file, + time: logDate + }); + } + } + + this._days = Object.keys(this._entries).sort(); + this._index = 0; +} +DailyLogEnumerator.prototype = { + _entries: {}, + _days: [], + _index: 0, + hasMoreElements: function() this._index < this._days.length, + getNext: function() new LogCluster(this._entries[this._days[this._index++]]), + QueryInterface: XPCOMUtils.generateQI([Ci.nsISimpleEnumerator]) +}; + +/** + * A LogCluster is a Log representing several log files all at once. The + * constructor expects aEntries, which is an array of objects that each + * have two properties: file and time. The file is the nsIFile for the + * log file, and the time is the Date object extracted from the filename for + * the log file. + */ +function LogCluster(aEntries) { + if (!aEntries.length) + throw new Error("LogCluster was passed an empty Array"); + + // Sort our list of entries for this day in increasing order. + aEntries.sort(function(aLeft, aRight) aLeft.time - aRight.time); + + this._entries = aEntries; + // Calculate the timestamp for the first entry down to the day. + let timestamp = new Date(aEntries[0].time); + timestamp.setHours(0); + timestamp.setMinutes(0); + timestamp.setSeconds(0); + this.time = timestamp.valueOf() / 1000; + // Path is used to uniquely identify a Log, and sometimes used to + // quickly determine which directory a log file is from. We'll use + // the first file's path. + this.path = aEntries[0].file.path; +} +LogCluster.prototype = { + __proto__: ClassInfo("imILog", "LogCluster object"), + format: "json", + + getConversation: function() { + const PR_RDONLY = 0x01; + let streams = []; + for each (let entry in this._entries) { + let fis = new FileInputStream(entry.file, PR_RDONLY, 0444, + Ci.nsIFileInputStream.CLOSE_ON_EOF); + // Pass in 0x0 so that we throw exceptions on unknown bytes. + let lis = new ConverterInputStream(fis, "UTF-8", 1024, 0x0); + lis.QueryInterface(Ci.nsIUnicharLineInputStream); + streams.push(lis); + } + + try { + return new LogConversation(streams); + } catch (e) { + // If the file contains some junk (invalid JSON), the + // LogConversation code will still read the messages it can parse. + // If the first line of meta data is corrupt, there's really no + // useful data we can extract from the file so the + // LogConversation constructor will throw. + return null; + } + } +}; + function Logger() { } Logger.prototype = { - _enumerateLogs: function logger__enumerateLogs(aAccount, aNormalizedName) { + _enumerateLogs: function logger__enumerateLogs(aAccount, aNormalizedName, + aGroupByDay) { let file = getLogFolderForAccount(aAccount); file.append(aNormalizedName); if (!file.exists()) return EmptyEnumerator; - return new LogEnumerator([file.directoryEntries]); + let enumerator = aGroupByDay ? DailyLogEnumerator : LogEnumerator; + + return new enumerator([file.directoryEntries]); + }, + getLogFromFile: function logger_getLogFromFile(aFile, aGroupByDay) { + if (aGroupByDay) + return this._getDailyLogFromFile(aFile); + + return new Log(aFile); + }, + _getDailyLogFromFile: function logger_getDailyLogsForFile(aFile) { + let [targetDate] = getDateFromFilename(aFile.leafName); + if (!targetDate) + return null; + + let targetDay = Math.floor(targetDate / (86400 * 1000)); + + // Get the path for the log file - we'll assume that the files relevant + // to our interests are in the same folder. + let path = aFile.path; + let folder = aFile.parent.directoryEntries; + let relevantEntries = []; + // Pick out the files that start within our date range. + while (folder.hasMoreElements()) { + let file = folder.getNext(); + if (!(file instanceof Ci.nsIFile)) + continue; + + let [logTime] = getDateFromFilename(file.leafName); + + let day = Math.floor(logTime / (86400 * 1000)); + if (targetDay == day) { + relevantEntries.push({ + file: file, + time: logTime + }); + } + } + + return new LogCluster(relevantEntries); }, - getLogFromFile: function logger_getLogFromFile(aFile) new Log(aFile), getLogFileForOngoingConversation: function logger_getLogFileForOngoingConversation(aConversation) getLogForConversation(aConversation).file, getLogsForContact: function logger_getLogsForContact(aContact) { @@ -441,17 +625,20 @@ Logger.prototype = { }, getLogsForAccountBuddy: function logger_getLogsForAccountBuddy(aAccountBuddy) this._enumerateLogs(aAccountBuddy.account, aAccountBuddy.normalizedName), - getLogsForConversation: function logger_getLogsForConversation(aConversation) { + getLogsForConversation: function logger_getLogsForConversation(aConversation, + aGroupByDay) { let name = aConversation.normalizedName; - if (aConversation.isChat && - aConversation.account.protocol.id != "prpl-twitter") + if (convIsRealMUC(aConversation)) name += ".chat"; - return this._enumerateLogs(aConversation.account, name); + + return this._enumerateLogs(aConversation.account, name, aGroupByDay); }, getSystemLogsForAccount: function logger_getSystemLogsForAccount(aAccount) this._enumerateLogs(aAccount, ".system"), - getSimilarLogs: function(aLog) - new LogEnumerator([new LocalFile(aLog.path).parent.directoryEntries]), + getSimilarLogs: function(aLog, aGroupByDay) { + let enumerator = aGroupByDay ? DailyLogEnumerator : LogEnumerator; + return new enumerator([new LocalFile(aLog.path).parent.directoryEntries]); + }, observe: function logger_observe(aSubject, aTopic, aData) { switch (aTopic) { diff --git a/mail/components/im/content/chat-messenger-overlay.js b/mail/components/im/content/chat-messenger-overlay.js index a446472f6c..605316c9bc 100644 --- a/mail/components/im/content/chat-messenger-overlay.js +++ b/mail/components/im/content/chat-messenger-overlay.js @@ -548,7 +548,7 @@ var chatHandler = { if (item.getAttribute("id") == "searchResultConv") { let path = "logs/" + item.log.path; let file = FileUtils.getFile("ProfD", path.split("/")); - let log = imServices.logs.getLogFromFile(file); + let log = imServices.logs.getLogFromFile(file, true); document.getElementById("goToConversation").hidden = true; document.getElementById("contextPane").removeAttribute("chat"); let conv = log.getConversation(); @@ -563,8 +563,7 @@ var chatHandler = { cti.removeAttribute("statusTooltiptext"); cti.removeAttribute("topicEditable"); cti.removeAttribute("noTopic"); - - this._showLogList(imServices.logs.getSimilarLogs(log), log); + this._showLogList(imServices.logs.getSimilarLogs(log, true), log); this.observedContact = null; } else if (item.localName == "imconv") { @@ -587,11 +586,11 @@ var chatHandler = { item.convView.updateConvStatus(); item.update(); - this._showLogList(imServices.logs.getLogsForConversation(item.conv)); + this._showLogList(imServices.logs.getLogsForConversation(item.conv, true)); let contextPane = document.getElementById("contextPane"); if (item.conv.isChat) { contextPane.setAttribute("chat", "true"); - item.convView.showParticipants(); + item.convView.showParticipants(); } else contextPane.removeAttribute("chat"); @@ -1053,15 +1052,16 @@ chatLogTreeGroupItem.prototype = { getProperties: function(aProps) {} }; -function chatLogTreeLogItem(aLog, aText) { +function chatLogTreeLogItem(aLog, aText, aLevel) { this.log = aLog; this._text = aText; + this._level = aLevel; } chatLogTreeLogItem.prototype = { getText: function() this._text, get id() this.log.title, get open() false, - get level() 1, + get level() this._level, get children() [], getProperties: function(aProps) {} }; @@ -1076,6 +1076,11 @@ chatLogTreeView.prototype = { __proto__: new PROTO_TREE_VIEW(), _rebuild: function cLTV__rebuild() { + // Some date helpers... + const kDayInMsecs = 24 * 60 * 60 * 1000; + const kWeekInMsecs = 7 * kDayInMsecs; + const kTwoWeeksInMsecs = 2 * kWeekInMsecs; + // Drop the old rowMap. if (this._tree) this._tree.rowCountChanged(0, -this._rowMap.length); @@ -1084,17 +1089,15 @@ chatLogTreeView.prototype = { // The keys used in the 'groups' object should match string ids in // messenger.properties, except 'other' that has a special handling. let groups = { - today: [], - yesterday: [], lastWeek: [], twoWeeksAgo: [], other: [] }; - // Some date helpers... - const kDayInMsecs = 24 * 60 * 60 * 1000; - const kWeekInMsecs = 7 * kDayInMsecs; - const kTwoWeeksInMsecs = 2 * kWeekInMsecs; + // today and yesterday are treated differently, because they represent + // individual logs, and are not "groups". + let today = null, yesterday = null; + let dts = Components.classes["@mozilla.org/intl/scriptabledateformat;1"] .getService(Ci.nsIScriptableDateFormat); let formatDate = function(aDate) { @@ -1106,38 +1109,46 @@ chatLogTreeView.prototype = { nowDate.getDate()); // Build a chatLogTreeLogItem for each log, and put it in the right group. - let chatBundle = document.getElementById("chatBundle"); + let msgBundle = document.getElementById("bundle_messenger"); + for each (let log in fixIterator(this._logs)) { let logDate = new Date(log.time * 1000); let timeFromToday = todayDate - logDate; - let title = dts.FormatTime("", dts.timeFormatNoSeconds, - logDate.getHours(), logDate.getMinutes(), 0); - if (timeFromToday > kDayInMsecs) { - title = chatBundle.getFormattedString("dateTime", - [formatDate(logDate), title]); - } + let title = formatDate(logDate); let group; - if (timeFromToday <= 0) - group = groups.today; - else if (timeFromToday <= kDayInMsecs) - group = groups.yesterday; + if (timeFromToday <= 0) { + today = new chatLogTreeLogItem(log, msgBundle.getString("today"), 0); + continue; + } + else if (timeFromToday <= kDayInMsecs) { + yesterday = new chatLogTreeLogItem(log, msgBundle.getString("yesterday"), 0); + continue; + } else if (timeFromToday <= kWeekInMsecs) group = groups.lastWeek; else if (timeFromToday <= kTwoWeeksInMsecs) group = groups.twoWeeksAgo; else group = groups.other; - group.push(new chatLogTreeLogItem(log, title)); + group.push(new chatLogTreeLogItem(log, title, 1)); } - // Create a chatLogTreeGroupItem for each group. - let msgBundle = document.getElementById("bundle_messenger"); + if (today) + this._rowMap.push(today); + if (yesterday) + this._rowMap.push(yesterday); + for each (let [groupId, group] in Iterator(groups)) { if (!group.length) continue; + group.sort(function(l1, l2) l2.log.time - l1.log.time); + let groupName; if (groupId == "other") { + // If we're in the "other" group, the title will be the end and + // beginning dates for that group. + // Example: 28/08/2012 - 04/01/2012 groupName = formatDate(new Date(group[0].log.time * 1000)); if (group.length > 1) { let fromDate = new Date(group[group.length - 1].log.time * 1000); @@ -1145,6 +1156,7 @@ chatLogTreeView.prototype = { } } else { + // Otherwise, get the appropriate string for this group. groupName = msgBundle.getString(groupId); } this._rowMap.push(new chatLogTreeGroupItem(groupName, group));