Bug 787149 - Change Chat log tree so that it aggregates logs into days. r=florian, ui-r=bwinton.
authorMike Conley <mconley@mozilla.com>
Tue, 18 Sep 2012 10:08:12 -0400
changeset 13667 8f1eedc904bbbb90001e48c0910eab7be7fc0c22
parent 13666 0629a3f8aad6ed3e4c7d8153269fe14dcaf0c172
child 13668 d210f0b60e38801fe23890a2597fc8d04bb9857f
push id762
push userbugzilla@standard8.plus.com
push dateMon, 19 Nov 2012 21:16:42 +0000
treeherdercomm-beta@4a2f61509b17 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersflorian, bwinton
bugs787149
Bug 787149 - Change Chat log tree so that it aggregates logs into days. r=florian, ui-r=bwinton.
chat/components/public/imILogger.idl
chat/components/src/logger.js
mail/components/im/content/chat-messenger-overlay.js
--- a/chat/components/public/imILogger.idl
+++ b/chat/components/public/imILogger.idl
@@ -37,19 +37,21 @@ interface imILogConversation: nsISupport
 interface imILog: nsISupports {
   readonly attribute AUTF8String path;
   readonly attribute PRTime time;
   readonly attribute AUTF8String format;
   // Will return null if the log format isn't json.
   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);
 };
--- a/chat/components/src/logger.js
+++ b/chat/components/src/logger.js
@@ -69,17 +69,17 @@ function ConversationLog(aConversation)
 }
 ConversationLog.prototype = {
   _log: null,
   file: null,
   format: "txt",
   _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())
       file.create(Ci.nsIFile.DIRECTORY_TYPE, 0777);
     if (Services.prefs.getCharPref("purple.logging.format") == "json")
       this.format = "json";
     file.append(getNewLogFileName(this.format));
     this.file = file;
@@ -294,41 +294,56 @@ function LogMessage(aData, aConversation
   this.time = Math.round(new Date(aData.date) / 1000);
   if ("alias" in aData)
     this._alias = aData.alias;
   for each (let flag in aData.flags)
     this[flag] = true;
 }
 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.
+      }
     }
   }
 }
 LogConversation.prototype = {
   __proto__: ClassInfo("imILogConversation", "Log conversation object"),
   get isChat() false,
   get buddy() null,
   get account() ({
@@ -345,29 +360,25 @@ LogConversation.prototype = {
   }
 };
 
 /* Generic log enumeration stuff */
 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"),
   getConversation: function() {
     if (this.format != "json")
       return null;
 
     const PR_RDONLY = 0x01;
@@ -383,43 +394,216 @@ Log.prototype = {
       // 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;
     }
   }
 };
 
+/**
+ * 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;
 }
 LogEnumerator.prototype = {
   _entries: [],
   hasMoreElements: function() {
     while (this._entries.length > 0 && !this._entries[0].hasMoreElements())
       this._entries.shift();
     return this._entries.length > 0;
   },
   getNext: function()
     new Log(this._entries[0].getNext().QueryInterface(Ci.nsIFile)),
   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);
   },
-  getLogFromFile: function logger_getLogFromFile(aFile) 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);
+  },
   getLogFileForOngoingConversation: function logger_getLogFileForOngoingConversation(aConversation)
     getLogForConversation(aConversation).file,
   getLogsForContact: function logger_getLogsForContact(aContact) {
     let entries = [];
     aContact.getBuddies().forEach(function (aBuddy) {
       aBuddy.getAccountBuddies().forEach(function (aAccountBuddy) {
         let file = getLogFolderForAccount(aAccountBuddy.account);
         file.append(aAccountBuddy.normalizedName);
@@ -436,27 +620,30 @@ Logger.prototype = {
       file.append(aAccountBuddy.normalizedName);
       if (file.exists())
         entries.push(file.directoryEntries);
     });
     return new LogEnumerator(entries);
   },
   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) {
     case "profile-after-change":
       Services.obs.addObserver(this, "final-ui-startup", false);
       break;
     case "final-ui-startup":
       Services.obs.removeObserver(this, "final-ui-startup");
--- a/mail/components/im/content/chat-messenger-overlay.js
+++ b/mail/components/im/content/chat-messenger-overlay.js
@@ -543,33 +543,32 @@ var chatHandler = {
       return;
     }
 
     this._hideContextPane(false);
 
     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();
       this._showLog(conv, file.path, item.searchTerm || undefined);
       let cti = document.getElementById("conv-top-info");
       cti.setAttribute("displayName", conv.title);
       cti.removeAttribute("userIcon");
       cti.removeAttribute("statusMessageWithDash");
       cti.removeAttribute("statusMessage");
       cti.removeAttribute("status");
       cti.removeAttribute("statusTypeTooltiptext");
       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") {
       let convDeck = document.getElementById("conversationsDeck");
       if (!item.convView) {
         let conv = document.createElement("imconversation");
         convDeck.appendChild(conv);
         conv.conv = item.conv;
@@ -582,21 +581,21 @@ var chatHandler = {
       }
       else
         item.convView.onConvResize();
 
       convDeck.selectedPanel = item.convView;
       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");
 
       let button = document.getElementById("goToConversation");
       let bundle = document.getElementById("chatBundle");
       button.label = bundle.getString("goBackToCurrentConversation.button");
       button.disabled = false;
@@ -1048,108 +1047,121 @@ chatLogTreeGroupItem.prototype = {
   get id() this._title,
   get open() this._open,
   get level() 0,
   get _parent() null,
   get children() this._children,
   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) {}
 };
 
 function chatLogTreeView(aTree, aLogs) {
   this._tree = aTree;
   this._logs = aLogs;
   this._tree.view = this;
   this._rebuild();
 }
 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);
     this._rowMap = [];
 
     // 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) {
       return dts.FormatDate("", dts.dateFormatShort, aDate.getFullYear(),
                             aDate.getMonth() + 1, aDate.getDate());
     };
     let nowDate = new Date();
     let todayDate = new Date(nowDate.getFullYear(), nowDate.getMonth(),
                              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) {
+        today = new chatLogTreeLogItem(log, msgBundle.getString("today"), 0);
+        continue;
       }
-      let group;
-      if (timeFromToday <= 0)
-        group = groups.today;
-      else if (timeFromToday <= kDayInMsecs)
-        group = groups.yesterday;
+      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);
           groupName += " - " + formatDate(fromDate);
         }
       }
       else {
+        // Otherwise, get the appropriate string for this group.
         groupName = msgBundle.getString(groupId);
       }
       this._rowMap.push(new chatLogTreeGroupItem(groupName, group));
     }
 
     // Finally, notify the tree.
     if (this._tree)
       this._tree.rowCountChanged(0, this._rowMap.length);