2 * Adium is the legal property of its developers, whose names are listed in the copyright file included
3 * with this source distribution.
5 * This program is free software; you can redistribute it and/or modify it under the terms of the GNU
6 * General Public License as published by the Free Software Foundation; either version 2 of the License,
7 * or (at your option) any later version.
9 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
10 * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
11 * Public License for more details.
13 * You should have received a copy of the GNU General Public License along with this program; if not,
14 * write to the Free Software Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
17 #import "AILoggerPlugin.h"
19 #import "AILogFromGroup.h"
20 #import "AILogToGroup.h"
21 #import "AIMDLogViewerWindowController.h"
22 #import "AIXMLAppender.h"
23 #import <Adium/AIContentControllerProtocol.h>
24 #import <Adium/AIInterfaceControllerProtocol.h>
25 #import <Adium/AIChatControllerProtocol.h>
26 #import <Adium/AILoginControllerProtocol.h>
27 #import <Adium/AIMenuControllerProtocol.h>
28 #import <Adium/AIPreferenceControllerProtocol.h>
29 #import <Adium/AIToolbarControllerProtocol.h>
30 #import <Adium/AIAccount.h>
31 #import <Adium/AIChat.h>
32 #import <Adium/AIContentMessage.h>
33 #import <Adium/AIContentStatus.h>
34 #import <Adium/AIContentEvent.h>
35 #import <Adium/AIHTMLDecoder.h>
36 #import <Adium/AIListContact.h>
37 #import <Adium/AIService.h>
38 #import <AIUtilities/AIAttributedStringAdditions.h>
39 #import <AIUtilities/AIDictionaryAdditions.h>
40 #import <AIUtilities/AIFileManagerAdditions.h>
41 #import <AIUtilities/AIMenuAdditions.h>
42 #import <AIUtilities/AIStringAdditions.h>
43 #import <AIUtilities/AIToolbarUtilities.h>
44 #import <AIUtilities/AIApplicationAdditions.h>
45 #import <AIUtilities/AIDateFormatterAdditions.h>
46 #import <AIUtilities/AIImageAdditions.h>
47 #import <AIUtilities/NSCalendarDate+ISO8601Unparsing.h>
48 #import <AIUtilities/NSCalendarDate+ISO8601Parsing.h>
50 #import "AILogFileUpgradeWindowController.h"
52 #import "AdiumSpotlightImporter.h"
54 #define LOG_INDEX_NAME @"Logs.index"
55 #define DIRTY_LOG_ARRAY_NAME @"DirtyLogs.plist"
56 #define KEY_LOG_INDEX_VERSION @"Log Index Version"
58 #define LOG_INDEX_STATUS_INTERVAL 20 //Interval before updating the log indexing status
59 #define LOG_CLEAN_SAVE_INTERVAL 500 //Number of logs to index continuously before saving the dirty array and index
61 #define LOG_VIEWER AILocalizedString(@"Chat Transcript Viewer",nil)
62 #define VIEW_LOGS_WITH_CONTACT AILocalizedString(@"View Chat Transcripts",nil)
64 #define CURRENT_LOG_VERSION 8 //Version of the log index. Increase this number to reset everyone's index.
66 #define LOG_VIEWER_IDENTIFIER @"LogViewer"
68 #define NEW_LOGFILE_TIMEOUT 600 //10 minutes
70 #define ENABLE_PROXIMITY_SEARCH TRUE
72 @interface AILoggerPlugin (PRIVATE)
73 - (void)configureMenuItems;
74 - (SKIndexRef)createLogIndex;
75 - (void)closeLogIndex;
76 - (void)resetLogIndex;
77 - (NSString *)_logIndexPath;
78 - (void)loadDirtyLogArray;
79 - (void)_saveDirtyLogArray;
80 - (NSString *)_dirtyLogArrayPath;
81 - (void)_dirtyAllLogsThread;
82 - (void)_cleanDirtyLogsThread;
84 - (void)upgradeLogExtensions;
85 - (void)reimportLogsToSpotlightIfNeeded;
87 - (NSString *)keyForChat:(AIChat *)chat;
88 - (AIXMLAppender *)existingAppenderForChat:(AIChat *)chat;
89 - (AIXMLAppender *)appenderForChat:(AIChat *)chat;
90 - (void)closeAppenderForChat:(AIChat *)chat;
91 - (void)finishClosingAppender:(NSString *)chatKey;
94 static NSString *logBasePath = nil; //The base directory of all logs
95 Class LogViewerWindowControllerClass = NULL;
96 @implementation AILoggerPlugin
101 //XXX: this should be refactored now that we can rely on Tiger.
102 LogViewerWindowControllerClass = [AIMDLogViewerWindowController class];
105 observingContent = NO;
107 activeAppenders = [[NSMutableDictionary alloc] init];
109 xhtmlDecoder = [[AIHTMLDecoder alloc] initWithHeaders:NO
116 attachmentsAsText:YES
117 onlyIncludeOutgoingImages:NO
120 [xhtmlDecoder setGeneratesStrictXHTML:YES];
121 [xhtmlDecoder setUsesAttachmentTextEquivalents:YES];
123 statusTranslation = [[NSDictionary alloc] initWithObjectsAndKeys:
125 @"online",@"return_away",
127 @"offline",@"offline",
129 @"available",@"return_idle",
130 @"away",@"away_message",
133 //Setup our preferences
134 [[adium preferenceController] registerDefaults:[NSDictionary dictionaryNamed:LOGGING_DEFAULT_PREFS
135 forClass:[self class]]
136 forGroup:PREF_GROUP_LOGGING];
138 //Install the log viewer menu items
139 [self configureMenuItems];
141 //Create a logs directory
142 logBasePath = [[[[[adium loginController] userDirectory] stringByAppendingPathComponent:PATH_LOGS] stringByExpandingTildeInPath] retain];
143 [[NSFileManager defaultManager] createDirectoriesForPath:logBasePath];
145 //Observe preference changes
146 [[adium preferenceController] addObserver:self
147 forKeyPath:PREF_KEYPATH_LOGGER_ENABLE
148 options:NSKeyValueObservingOptionNew
150 [self observeValueForKeyPath:PREF_KEYPATH_LOGGER_ENABLE
151 ofObject:[adium preferenceController]
156 NSToolbarItem *toolbarItem;
157 toolbarItem = [AIToolbarUtilities toolbarItemWithIdentifier:LOG_VIEWER_IDENTIFIER
158 label:AILocalizedString(@"Transcripts",nil)
159 paletteLabel:AILocalizedString(@"View Chat Transcripts",nil)
160 toolTip:AILocalizedString(@"View previous conversations with this contact or chat",nil)
162 settingSelector:@selector(setImage:)
163 itemContent:[NSImage imageNamed:@"LogViewer" forClass:[self class]]
164 action:@selector(showLogViewerToSelectedContact:)
166 [[adium toolbarController] registerToolbarItem:toolbarItem forToolbarType:@"ListObject"];
170 stopIndexingThreads = NO;
171 suspendDirtyArraySave = NO;
172 indexingThreadLock = [[NSLock alloc] init];
173 dirtyLogLock = [[NSLock alloc] init];
174 logAccessLock = [[NSLock alloc] init];
176 //Init index searching
177 [self initLogIndexing];
179 [self upgradeLogExtensions];
180 [self reimportLogsToSpotlightIfNeeded];
182 [[adium notificationCenter] addObserver:self
183 selector:@selector(showLogNotification:)
184 name:AIShowLogAtPathNotification
188 - (void)uninstallPlugin
190 [activeAppenders release]; activeAppenders = nil;
191 [xhtmlDecoder release]; xhtmlDecoder = nil;
192 [statusTranslation release]; statusTranslation = nil;
194 [NSObject cancelPreviousPerformRequestsWithTarget:self];
196 [[adium notificationCenter] removeObserver:self];
197 [[adium preferenceController] removeObserver:self forKeyPath:PREF_KEYPATH_LOGGER_ENABLE];
200 //Update for the new preferences
201 - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
207 newLogValue = [[object valueForKeyPath:keyPath] boolValue];
208 if (newLogValue != observingContent) {
209 observingContent = newLogValue;
211 if (!observingContent) { //Stop Logging
212 [[adium notificationCenter] removeObserver:self name:Content_ContentObjectAdded object:nil];
214 [[adium notificationCenter] removeObserver:self name:Chat_DidOpen object:nil];
215 [[adium notificationCenter] removeObserver:self name:Chat_WillClose object:nil];
217 } else { //Start Logging
218 [[adium notificationCenter] addObserver:self
219 selector:@selector(contentObjectAdded:)
220 name:Content_ContentObjectAdded
223 [[adium notificationCenter] addObserver:self
224 selector:@selector(chatOpened:)
228 [[adium notificationCenter] addObserver:self
229 selector:@selector(chatClosed:)
232 [[adium notificationCenter] addObserver:self
233 selector:@selector(chatWillDelete:)
234 name:ChatLog_WillDelete
241 //Logging Paths --------------------------------------------------------------------------------------------------------
242 + (NSString *)logBasePath
247 //Returns the RELATIVE path to the folder where the log should be written
248 + (NSString *)relativePathForLogWithObject:(NSString *)object onAccount:(AIAccount *)account
250 return [NSString stringWithFormat:@"%@.%@/%@", [account serviceID], [[account UID] safeFilenameString], object];
253 + (NSString *)fileNameForLogWithObject:(NSString *)object onDate:(NSDate *)date
255 NSParameterAssert(date != nil);
256 NSParameterAssert(object != nil);
257 NSString *dateString = [date descriptionWithCalendarFormat:@"%Y-%m-%dT%H.%M.%S%z" timeZone:nil locale:nil];
259 NSAssert2(dateString != nil, @"Date string was invalid for the chatlog for %@ on %@", object, date);
261 return [NSString stringWithFormat:@"%@ (%@).chatlog", object, dateString];
264 + (NSString *)fullPathForLogOfChat:(AIChat *)chat onDate:(NSDate *)date
266 NSString *objectUID = [chat name];
267 AIAccount *account = [chat account];
269 if (!objectUID) objectUID = [[chat listObject] UID];
270 objectUID = [objectUID safeFilenameString];
272 NSString *fileName = [self fileNameForLogWithObject:objectUID onDate:date];
273 NSString *absolutePath = [logBasePath stringByAppendingPathComponent:[self relativePathForLogWithObject:objectUID onAccount:account]];
274 NSString *fullPath = [absolutePath stringByAppendingPathComponent:fileName];
279 //Menu Items -----------------------------------------------------------------------------------------------------------
280 #pragma mark Menu Items
281 //Configure the log viewer menu items
282 - (void)configureMenuItems
284 logViewerMenuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:LOG_VIEWER
286 action:@selector(showLogViewer:)
287 keyEquivalent:@"L"] autorelease];
288 [[adium menuController] addMenuItem:logViewerMenuItem toLocation:LOC_Window_Auxiliary];
290 viewContactLogsMenuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:VIEW_LOGS_WITH_CONTACT
292 action:@selector(showLogViewerToSelectedContact:)
293 keyEquivalent:@"l"] autorelease];
294 [[adium menuController] addMenuItem:viewContactLogsMenuItem toLocation:LOC_Contact_Info];
296 viewContactLogsContextMenuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:VIEW_LOGS_WITH_CONTACT
298 action:@selector(showLogViewerToSelectedContextContact:)
299 keyEquivalent:@""] autorelease];
300 [[adium menuController] addContextualMenuItem:viewContactLogsContextMenuItem toLocation:Context_Contact_Manage];
303 //Enable/Disable our view log menus
304 - (BOOL)validateMenuItem:(NSMenuItem *)menuItem
306 if (menuItem == viewContactLogsMenuItem) {
307 AIListObject *selectedObject = [[adium interfaceController] selectedListObject];
308 return selectedObject && [selectedObject isKindOfClass:[AIListContact class]];
310 } else if (menuItem == viewContactLogsContextMenuItem) {
311 AIListObject *selectedObject = [[adium menuController] currentContextMenuObject];
312 return selectedObject && [selectedObject isKindOfClass:[AIListContact class]];
320 * @brief Show the log viewer for no contact
322 * Invoked from the Window menu
324 - (void)showLogViewer:(id)sender
326 [LogViewerWindowControllerClass openForContact:nil
331 * @brief Show the log viewer, displaying only the selected contact's logs
333 * Invoked from the Contact menu
335 - (void)showLogViewerToSelectedContact:(id)sender
337 AIListObject *selectedObject = [[adium interfaceController] selectedListObject];
338 [LogViewerWindowControllerClass openForContact:([selectedObject isKindOfClass:[AIListContact class]] ?
339 (AIListContact *)selectedObject :
344 - (void)showLogViewerForLogAtPath:(NSString *)inPath
346 [LogViewerWindowControllerClass openLogAtPath:inPath plugin:self];
349 - (void)showLogNotification:(NSNotification *)inNotification
351 [self showLogViewerForLogAtPath:[inNotification object]];
355 * @brief Show the log viewer, displaying only the selected contact's logs
357 * Invoked from a contextual menu
359 - (void)showLogViewerToSelectedContextContact:(id)sender
361 AIListObject* object = [[adium menuController] currentContextMenuObject];
362 if ([object isKindOfClass:[AIListContact class]]) {
363 [NSApp activateIgnoringOtherApps:YES];
364 [[[LogViewerWindowControllerClass openForContact:(AIListContact *)object plugin:self] window]
365 makeKeyAndOrderFront:nil];
370 //Logging --------------------------------------------------------------------------------------------------------------
372 //Log any content that is sent or received
373 - (void)contentObjectAdded:(NSNotification *)notification
375 AIContentMessage *content = [[notification userInfo] objectForKey:@"AIContentObject"];
376 if ([content postProcessContent]) {
377 AIChat *chat = [notification object];
379 //Don't log chats for temporary accounts
380 if ([[chat account] isTemporary]) return;
383 NSString *contentType = [content type];
384 NSString *date = [[[content date] dateWithCalendarFormat:nil timeZone:nil] ISO8601DateString];
386 if ([contentType isEqualToString:CONTENT_MESSAGE_TYPE]) {
387 NSMutableArray *attributeKeys = [NSMutableArray arrayWithObjects:@"sender", @"time", nil];
388 NSMutableArray *attributeValues = [NSMutableArray arrayWithObjects:[[content source] UID], date, nil];
390 if([content isAutoreply])
392 [attributeKeys addObject:@"auto"];
393 [attributeValues addObject:@"true"];
396 [[self appenderForChat:chat] addElementWithName:@"message"
397 escapedContent:[xhtmlDecoder encodeHTML:[content message] imagesPath:nil]
398 attributeKeys:attributeKeys
399 attributeValues:attributeValues];
402 //XXX: Yucky hack. This is here because we get status and event updates for metas, not for individual contacts. Or something like that.
403 AIListObject *retardedMetaObject = [content source];
404 AIListObject *actualObject = nil;
405 AIListContact *participatingListObject = nil;
407 NSEnumerator *enumerator = [[chat participatingListObjects] objectEnumerator];
409 while ((participatingListObject = [enumerator nextObject])) {
410 if ([participatingListObject parentContact] == retardedMetaObject) {
411 actualObject = participatingListObject;
416 //If we can't find it for some reason, we probably shouldn't attempt logging.
418 if ([contentType isEqualToString:CONTENT_STATUS_TYPE]) {
419 NSString *translatedStatus = [statusTranslation objectForKey:[(AIContentStatus *)content status]];
420 if (translatedStatus == nil) {
421 AILog(@"AILogger: Don't know how to translate status: %@", [(AIContentStatus *)content status]);
423 [[self appenderForChat:chat] addElementWithName:@"status"
424 escapedContent:([(AIContentStatus *)content loggedMessage] ? [xhtmlDecoder encodeHTML:[(AIContentStatus *)content loggedMessage] imagesPath:nil] : nil)
425 attributeKeys:[NSArray arrayWithObjects:@"type", @"sender", @"time", nil]
426 attributeValues:[NSArray arrayWithObjects:
434 } else if ([contentType isEqualToString:CONTENT_EVENT_TYPE]) {
435 [[self appenderForChat:chat] addElementWithName:@"event"
436 escapedContent:[xhtmlDecoder encodeHTML:[content message] imagesPath:nil]
437 attributeKeys:[NSArray arrayWithObjects:@"type", @"sender", @"time", nil]
438 attributeValues:[NSArray arrayWithObjects:[(AIContentEvent *)content eventType], [[content source] UID], date, nil]];
443 //Don't create a new one if not needed
444 AIXMLAppender *appender = [self existingAppenderForChat:chat];
445 if (dirty && appender)
446 [self markLogDirtyAtPath:[appender path] forChat:chat];
450 - (void)chatOpened:(NSNotification *)notification
452 AIChat *chat = [notification object];
454 //Don't log chats for temporary accounts
455 if ([[chat account] isTemporary]) return;
457 //Try reusing the appender opject
458 AIXMLAppender *appender = [self existingAppenderForChat:chat];
460 //If there is an appender, add the windowOpened event
462 /* Ensure a timeout isn't set for closing the appender, since we're now using it.
463 * This gives us the desired behavior - if chat #2 opens before the timeout on the
464 * log file, then we want to keep the log continuous until the user has closed the
467 [NSObject cancelPreviousPerformRequestsWithTarget:self
468 selector:@selector(finishClosingAppender:)
469 object:[self keyForChat:chat]];
471 // Print the windowOpened event in the log
472 [appender addElementWithName:@"event"
474 attributeKeys:[NSArray arrayWithObjects:@"type", @"sender", @"time", nil]
475 attributeValues:[NSArray arrayWithObjects:@"windowOpened", [[chat account] UID], [[[NSDate date] dateWithCalendarFormat:nil timeZone:nil] ISO8601DateString], nil]];
477 [self markLogDirtyAtPath:[appender path] forChat:chat];
481 - (void)chatClosed:(NSNotification *)notification
483 AIChat *chat = [notification object];
485 //Don't log chats for temporary accounts
486 if ([[chat account] isTemporary]) return;
488 //Use this method so we don't create a new appender for chat close events
489 AIXMLAppender *appender = [self existingAppenderForChat:chat];
491 //If there is an appender, add the windowClose event
493 [appender addElementWithName:@"event"
495 attributeKeys:[NSArray arrayWithObjects:@"type", @"sender", @"time", nil]
496 attributeValues:[NSArray arrayWithObjects:@"windowClosed", [[chat account] UID], [[[NSDate date] dateWithCalendarFormat:nil timeZone:nil] ISO8601DateString], nil]];
498 [self closeAppenderForChat:chat];
500 [self markLogDirtyAtPath:[appender path] forChat:chat];
504 //Ugly method. Shouldn't this notification post an AIChat, not an AIChatLog?
505 - (void)chatWillDelete:(NSNotification *)notification
507 AIChatLog *chatLog = [notification object];
508 NSString *chatID = [NSString stringWithFormat:@"%@.%@-%@", [chatLog serviceClass], [chatLog from], [chatLog to]];
509 AIXMLAppender *appender = [activeAppenders objectForKey:chatID];
512 if ([[appender path] hasSuffix:[chatLog path]]) {
513 [NSObject cancelPreviousPerformRequestsWithTarget:self
514 selector:@selector(finishClosingAppender:)
516 [self finishClosingAppender:chatID];
521 - (NSString *)keyForChat:(AIChat *)chat
523 AIAccount *account = [chat account];
524 NSString *chatID = ([chat isGroupChat] ? [chat identifier] : [[chat listObject] UID]);
526 NSLog(@"Chat: %@",chat);
527 NSLog(@"service ID is %@",[account serviceID]);
528 NSLog(@"Account UID is %@",[account UID]);
529 NSLog(@"chat ID is %@",chatID);
532 return [NSString stringWithFormat:@"%@.%@-%@", [account serviceID], [account UID], chatID];
535 - (AIXMLAppender *)existingAppenderForChat:(AIChat *)chat
537 //Look up the key for this chat and use it to try to retrieve the appender
538 return [activeAppenders objectForKey:[self keyForChat:chat]];
541 - (AIXMLAppender *)appenderForChat:(AIChat *)chat
543 //Check if there is already an appender for this chat
544 AIXMLAppender *appender = [self existingAppenderForChat:chat];
547 //Ensure a timeout isn't set for closing the appender, since we're now using it
548 [NSObject cancelPreviousPerformRequestsWithTarget:self
549 selector:@selector(finishClosingAppender:)
550 object:[self keyForChat:chat]];
552 //If there isn't already an appender, create a new one and add it to the dictionary
553 NSDate *chatDate = [chat dateOpened];
554 NSString *fullPath = [AILoggerPlugin fullPathForLogOfChat:chat onDate:chatDate];
556 appender = [AIXMLAppender documentWithPath:fullPath];
557 [appender initializeDocumentWithRootElementName:@"chat"
558 attributeKeys:[NSArray arrayWithObjects:@"xmlns", @"account", @"service", nil]
559 attributeValues:[NSArray arrayWithObjects:
560 XML_LOGGING_NAMESPACE,
561 [[chat account] UID],
562 [[chat account] serviceID],
565 //Add the window opened event now
566 [appender addElementWithName:@"event"
568 attributeKeys:[NSArray arrayWithObjects:@"type", @"sender", @"time", nil]
569 attributeValues:[NSArray arrayWithObjects:@"windowOpened", [[chat account] UID], [[chatDate dateWithCalendarFormat:nil timeZone:nil] ISO8601DateString], nil]];
571 [activeAppenders setObject:appender forKey:[self keyForChat:chat]];
573 [self markLogDirtyAtPath:[appender path] forChat:chat];
579 - (void)closeAppenderForChat:(AIChat *)chat
581 //Create a new timer to fire after the timeout period, which will close the appender
582 NSString *chatKey = [self keyForChat:chat];
583 [NSObject cancelPreviousPerformRequestsWithTarget:self
584 selector:@selector(finishClosingAppender:)
586 [self performSelector:@selector(finishClosingAppender:)
588 afterDelay:NEW_LOGFILE_TIMEOUT];
591 - (void)finishClosingAppender:(NSString *)chatKey
593 //Remove the appender, closing its file descriptor upon dealloc
594 [activeAppenders removeObjectForKey:chatKey];
598 //Display a warning to the user that logging failed, and disable logging to prevent additional warnings
599 //XXX not currently used. We may want to shift these strings for use when xml logging fails, so I'm not removing them -eds
601 - (void)displayErrorAndDisableLogging
603 NSRunAlertPanel(AILocalizedString(@"Unable to write log", nil),
604 [NSString stringWithFormat:
605 AILocalizedString(@"Adium was unable to write the log file for this conversation. Please ensure you have appropriate file permissions to write to your log directory (%@) for and then re-enable logging in the General preferences.", nil), logBasePath],
606 AILocalizedString(@"OK", nil), nil, nil);
609 [[adium preferenceController] setPreference:[NSNumber numberWithBool:NO]
610 forKey:KEY_LOGGER_ENABLE
611 group:PREF_GROUP_LOGGING];
615 #pragma mark Message History
617 NSCalendarDate* getDateFromPath(NSString *path)
619 NSRange openParenRange, closeParenRange;
621 if ([path hasSuffix:@".chatlog"] && (openParenRange = [path rangeOfString:@"(" options:NSBackwardsSearch]).location != NSNotFound) {
622 openParenRange = NSMakeRange(openParenRange.location, [path length] - openParenRange.location);
623 if ((closeParenRange = [path rangeOfString:@")" options:0 range:openParenRange]).location != NSNotFound) {
624 //Add and subtract one to remove the parenthesis
625 NSString *dateString = [path substringWithRange:NSMakeRange(openParenRange.location + 1, (closeParenRange.location - openParenRange.location))];
626 NSCalendarDate *date = [NSCalendarDate calendarDateWithString:dateString timeSeparator:'.'];
633 int sortPaths(NSString *path1, NSString *path2, void *context)
635 NSCalendarDate *date1 = getDateFromPath(path1);
636 NSCalendarDate *date2 = getDateFromPath(path2);
639 return NSOrderedSame;
640 else if (date1 && date2)
641 return [date2 compare:date1];
643 return date2 ? NSOrderedDescending : NSOrderedAscending;
646 + (NSArray *)sortedArrayOfLogFilesForChat:(AIChat *)chat
648 NSString *baseLogPath = [[self fullPathForLogOfChat:chat onDate:[NSDate date]] stringByDeletingLastPathComponent];
649 NSArray *files = [[NSFileManager defaultManager] directoryContentsAtPath:baseLogPath];
651 return [files sortedArrayUsingFunction:&sortPaths context:NULL];
656 #pragma mark Upgrade code
657 - (void)upgradeLogExtensions
659 if (![[[adium preferenceController] preferenceForKey:@"Log Extensions Updated" group:PREF_GROUP_LOGGING] boolValue]) {
660 /* This could all be a simple NSDirectoryEnumerator call on basePath, but we wouldn't be able to show progress,
661 * and this could take a bit.
663 NSFileManager *defaultManager = [NSFileManager defaultManager];
664 NSArray *accountFolders = [defaultManager directoryContentsAtPath:logBasePath];
665 NSEnumerator *accountFolderEnumerator = [accountFolders objectEnumerator];
666 NSString *accountFolderName;
668 NSMutableSet *pathsToContactFolders = [NSMutableSet set];
669 while ((accountFolderName = [accountFolderEnumerator nextObject])) {
670 NSString *contactBasePath = [logBasePath stringByAppendingPathComponent:accountFolderName];
671 NSArray *contactFolders = [defaultManager directoryContentsAtPath:contactBasePath];
673 NSEnumerator *contactFolderEnumerator = [contactFolders objectEnumerator];
674 NSString *contactFolderName;
676 while ((contactFolderName = [contactFolderEnumerator nextObject])) {
677 [pathsToContactFolders addObject:[contactBasePath stringByAppendingPathComponent:contactFolderName]];
681 unsigned contactsToProcess = [pathsToContactFolders count];
682 unsigned processed = 0;
684 if (contactsToProcess) {
685 AILogFileUpgradeWindowController *upgradeWindowController;
687 upgradeWindowController = [[AILogFileUpgradeWindowController alloc] initWithWindowNibName:@"LogFileUpgrade"];
688 [[upgradeWindowController window] makeKeyAndOrderFront:nil];
690 NSEnumerator *pathsToContactFoldersEnumerator = [pathsToContactFolders objectEnumerator];
691 NSString *pathToContactFolder;
692 while ((pathToContactFolder = [pathsToContactFoldersEnumerator nextObject])) {
693 NSDirectoryEnumerator *enumerator = [defaultManager enumeratorAtPath:pathToContactFolder];
696 while ((file = [enumerator nextObject])) {
697 if (([[file pathExtension] isEqualToString:@"html"]) ||
698 ([[file pathExtension] isEqualToString:@"adiumLog"]) ||
699 (([[file pathExtension] isEqualToString:@"bak"]) && ([file hasSuffix:@".html.bak"] ||
700 [file hasSuffix:@".adiumLog.bak"]))) {
701 NSString *fullFile = [pathToContactFolder stringByAppendingPathComponent:file];
702 NSString *newFile = [[fullFile stringByDeletingPathExtension] stringByAppendingPathExtension:@"AdiumHTMLLog"];
704 [defaultManager movePath:fullFile
711 [upgradeWindowController setProgress:(processed*100.0)/contactsToProcess];
714 [upgradeWindowController close];
715 [upgradeWindowController release];
718 [[adium preferenceController] setPreference:[NSNumber numberWithBool:YES]
719 forKey:@"Log Extensions Updated"
720 group:PREF_GROUP_LOGGING];
724 - (BOOL)fileManager:(NSFileManager *)manager shouldProceedAfterError:(NSDictionary *)errorInfo
726 NSLog(@"Error: %@",errorInfo);
732 * @brief Instruct spotlight to reimport logs
734 * Adium 1.0.2 and earlier had a bug which made spotlight import not work properly.
735 * New logs are properly indexed, but previously created logs are not. On first launch of Adium 1.1,
736 * Adium will tell Spotlight to reimport those old logs.
738 - (void)reimportLogsToSpotlightIfNeeded
740 if (![[NSUserDefaults standardUserDefaults] boolForKey:@"Adium 1.1:Reimported Spotlight Logs"]) {
743 arguments = [NSArray arrayWithObjects:
745 [[[[[[NSBundle mainBundle] bundlePath] stringByAppendingPathComponent:@"Contents"] stringByAppendingPathComponent:@"Library"]
746 stringByAppendingPathComponent:@"Spotlight"] stringByAppendingPathComponent:[@"AdiumSpotlightImporter" stringByAppendingPathExtension:@"mdimporter"]],
748 [NSTask launchedTaskWithLaunchPath:@"/usr/bin/mdimport" arguments:arguments];
750 [[NSUserDefaults standardUserDefaults] setBool:YES
751 forKey:@"Adium 1.1:Reimported Spotlight Logs"];
755 //Log Indexing ---------------------------------------------------------------------------------------------------------
756 #pragma mark Log Indexing
757 /***** Everything below this point is related to log index generation and access ****/
759 /* For the log content searching, we are required to re-index a log whenever it changes. The solution below to
760 * this problem is along the lines of:
761 * - Keep an array of logs that need to be re-indexed
762 * - Whenever a log is changed, add it to this array
763 * - When the log viewer is opened, re-index all the logs in the array
767 * @brief Initialize log indexing
769 - (void)initLogIndexing
771 //Load the list of logs that need re-indexing
772 [self loadDirtyLogArray];
776 * @brief Prepare the log index for searching.
778 * Must call before attempting to use the logSearchIndex.
780 - (void)prepareLogContentSearching
782 /* Load the index and start indexing to make it current
783 * If we're going to need to re-index all our logs from scratch, it will make
784 * things faster if we start with a fresh log index as well.
786 if (!dirtyLogArray) {
787 [self resetLogIndex];
790 //Load the contentIndex immediately; this will clear dirtyLogArray if necessary
791 [self logContentIndex];
793 stopIndexingThreads = NO;
794 if (!dirtyLogArray) {
797 [self cleanDirtyLogs];
801 //Close down and clean up the log index (Call when finished using the logSearchIndex)
802 - (void)cleanUpLogContentSearching
804 [self stopIndexingThreads];
805 [self closeLogIndex];
808 //Returns the Search Kit index for log content searching
809 - (SKIndexRef)logContentIndex
811 SKIndexRef returnIndex;
813 [logAccessLock lock];
814 if (!index_Content) index_Content = [self createLogIndex];
815 returnIndex = (SKIndexRef)[[(NSObject *)index_Content retain] autorelease];
816 [logAccessLock unlock];
821 //Mark a log as needing a re-index
822 - (void)markLogDirtyAtPath:(NSString *)path forChat:(AIChat *)chat
824 NSString *dirtyKey = [@"LogIsDirty_" stringByAppendingString:path];
826 if (![chat integerStatusObjectForKey:dirtyKey]) {
827 //Add to dirty array (Lock to ensure that no one changes its content while we are)
830 if (!dirtyLogArray) dirtyLogArray = [[NSMutableArray alloc] init];
832 if (![dirtyLogArray containsObject:path]) {
833 [dirtyLogArray addObject:path];
836 [dirtyLogLock unlock];
838 //Save the dirty array immedientally
839 [self _saveDirtyLogArray];
841 //Flag the chat with 'LogIsDirty' for this filename. On the next message we can quickly check this flag.
842 [chat setStatusObject:[NSNumber numberWithBool:YES]
848 - (void)markLogDirtyAtPath:(NSString *)path
852 if (!dirtyLogArray) dirtyLogArray = [[NSMutableArray alloc] init];
854 if (![dirtyLogArray containsObject:path]) {
855 [dirtyLogArray addObject:path];
857 [dirtyLogLock unlock];
860 //Get the current status of indexing. Returns NO if indexing is not occuring
861 - (BOOL)getIndexingProgress:(int *)indexNumber outOf:(int *)total
863 //logsIndexed + 1 is the log we are currently indexing
864 if (indexNumber) *indexNumber = (logsIndexed + 1 <= logsToIndex) ? logsIndexed + 1 : logsToIndex;
865 if (total) *total = logsToIndex;
866 return (logsToIndex > 0);
870 //Log index ------------------------------------------------------------------------------------------------------------
871 //Search kit index used to searching log content
872 #pragma mark Log Index
874 * @brief Create the log index
876 * Should be called within logAccessLock being locked
878 - (SKIndexRef)createLogIndex
880 NSString *logIndexPath = [self _logIndexPath];
881 NSURL *logIndexPathURL = [NSURL fileURLWithPath:logIndexPath];
882 SKIndexRef newIndex = NULL;
884 if ([[NSFileManager defaultManager] fileExistsAtPath:logIndexPath]) {
885 newIndex = SKIndexOpenWithURL((CFURLRef)logIndexPathURL, (CFStringRef)@"Content", true);
886 AILog(@"Opened index %x from %@",newIndex,logIndexPathURL);
889 //It appears our index was somehow corrupt, since it exists but it could not be opened. Remove it so we can create a new one.
890 NSLog(@"*** Warning: The Chat Transcript searching index at %@ was corrupt. Removing it and starting fresh; transcripts will be re-indexed automatically.",
892 [[NSFileManager defaultManager] removeFileAtPath:logIndexPath handler:NULL];
896 NSDictionary *textAnalysisProperties;
898 textAnalysisProperties = [NSDictionary dictionaryWithObjectsAndKeys:
899 [NSNumber numberWithInt:0], kSKMaximumTerms,
900 [NSNumber numberWithInt:2], kSKMinTermLength,
901 #if ENABLE_PROXIMITY_SEARCH
902 kCFBooleanTrue, kSKProximityIndexing,
906 //Create the index if one doesn't exist or it couldn't be opened.
907 [[NSFileManager defaultManager] createDirectoriesForPath:[logIndexPath stringByDeletingLastPathComponent]];
909 newIndex = SKIndexCreateWithURL((CFURLRef)logIndexPathURL,
910 (CFStringRef)@"Content",
912 (CFDictionaryRef)textAnalysisProperties);
914 AILog(@"Created a new log index %x at %@ with textAnalysisProperties %@",newIndex,logIndexPathURL,textAnalysisProperties);
915 //Clear the dirty log array in case it was loaded (this can happen if the user mucks with the cache directory)
916 [[NSFileManager defaultManager] removeFileAtPath:[self _dirtyLogArrayPath] handler:NULL];
917 [dirtyLogArray release]; dirtyLogArray = nil;
919 AILog(@"AILoggerPlugin warning: SKIndexCreateWithURL() returned NULL");
926 - (void)releaseIndex:(SKIndexRef)inIndex
928 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
929 [logAccessLock lock];
931 AILog(@"**** Flushing index %p",inIndex);
932 SKIndexFlush(inIndex);
934 AILog(@"**** Finished flushing index %p, and released it",inIndex);
936 [logAccessLock unlock];
940 //Close the log index
941 - (void)closeLogIndex
943 [logAccessLock lock];
945 [NSThread detachNewThreadSelector:@selector(releaseIndex:)
947 withObject:(id)index_Content];
949 AILog(@"**** index_Content is now nil", nil);
951 [logAccessLock unlock];
954 //Delete the log index
955 - (void)resetLogIndex
957 if ([[NSFileManager defaultManager] fileExistsAtPath:[self _logIndexPath]]) {
958 [[NSFileManager defaultManager] removeFileAtPath:[self _logIndexPath] handler:NULL];
961 if ([[NSFileManager defaultManager] fileExistsAtPath:[self _dirtyLogArrayPath]]) {
962 [[NSFileManager defaultManager] removeFileAtPath:[self _dirtyLogArrayPath] handler:NULL];
966 //Path of log index file
967 - (NSString *)_logIndexPath
969 return [[adium cachesPath] stringByAppendingPathComponent:LOG_INDEX_NAME];
973 //Dirty Log Array ------------------------------------------------------------------------------------------------------
974 //Stores the absolute paths of logs that need to be re-indexed
975 #pragma mark Dirty Log Array
976 //Load the dirty log array
977 - (void)loadDirtyLogArray
979 if (!dirtyLogArray) {
980 int logVersion = [[[adium preferenceController] preferenceForKey:KEY_LOG_INDEX_VERSION
981 group:PREF_GROUP_LOGGING] intValue];
983 //If the log version has changed, we reset the index and don't load the dirty array (So all the logs are marked dirty)
984 if (logVersion >= CURRENT_LOG_VERSION) {
986 dirtyLogArray = [[NSMutableArray alloc] initWithContentsOfFile:[self _dirtyLogArrayPath]];
987 AILog(@"Got dirty logs %@",dirtyLogArray);
988 [dirtyLogLock unlock];
990 AILog(@"**** Log version upgrade. Resetting");
991 [self resetLogIndex];
992 [[adium preferenceController] setPreference:[NSNumber numberWithInt:CURRENT_LOG_VERSION]
993 forKey:KEY_LOG_INDEX_VERSION
994 group:PREF_GROUP_LOGGING];
999 //Save the dirty lod array
1000 - (void)_saveDirtyLogArray
1002 if (dirtyLogArray && !suspendDirtyArraySave) {
1003 [dirtyLogLock lock];
1004 [dirtyLogArray writeToFile:[self _dirtyLogArrayPath] atomically:NO];
1005 [dirtyLogLock unlock];
1009 //Path of the dirty log array file
1010 - (NSString *)_dirtyLogArrayPath
1012 return [[adium cachesPath] stringByAppendingPathComponent:DIRTY_LOG_ARRAY_NAME];
1016 //Threaded Indexing ----------------------------------------------------------------------------------------------------
1017 #pragma mark Threaded Indexing
1018 //Stop any indexing related threads
1019 - (void)stopIndexingThreads
1021 //Let any indexing threads know it's time to stop, and wait for them to finish.
1022 stopIndexingThreads = YES;
1025 //The following methods will be run in a separate thread to avoid blocking the interface during index operations
1026 //THREAD: Flag every log as dirty (Do this when there is no log index)
1027 - (void)dirtyAllLogs
1029 //Reset and rebuild the dirty array
1030 [dirtyLogArray release]; dirtyLogArray = [[NSMutableArray alloc] init];
1031 //[self _dirtyAllLogsThread];
1032 [NSThread detachNewThreadSelector:@selector(_dirtyAllLogsThread) toTarget:self withObject:nil];
1034 - (void)_dirtyAllLogsThread
1036 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
1037 NSEnumerator *fromEnumerator, *toEnumerator, *logEnumerator;
1039 AILogFromGroup *fromGroup = nil;
1040 AILogToGroup *toGroup;
1043 [indexingThreadLock lock];
1044 suspendDirtyArraySave = YES; //Prevent saving of the dirty array until we're finished building it
1046 //Create a fresh dirty log array
1047 [dirtyLogLock lock];
1048 [dirtyLogArray release]; dirtyLogArray = [[NSMutableArray alloc] init];
1049 [dirtyLogLock unlock];
1051 //Process each from folder
1052 fromEnumerator = [[[[NSFileManager defaultManager] directoryContentsAtPath:logBasePath] objectEnumerator] retain];
1053 while ((fromName = [[fromEnumerator nextObject] retain])) {
1054 fromGroup = [[AILogFromGroup alloc] initWithPath:fromName fromUID:fromName serviceClass:nil];
1056 //Walk through every 'to' group
1057 toEnumerator = [[[fromGroup toGroupArray] objectEnumerator] retain];
1058 while ((toGroup = [[toEnumerator nextObject] retain])) {
1059 //Walk through every log
1060 logEnumerator = [toGroup logEnumerator];
1061 while ((theLog = [logEnumerator nextObject])) {
1062 //Add this log's path to our dirty array. The dirty array is guarded with a lock
1063 //since it will be accessed from outside this thread as well
1064 [dirtyLogLock lock];
1065 if (theLog != nil) {
1066 [dirtyLogArray addObject:[logBasePath stringByAppendingPathComponent:[theLog path]]];
1068 [dirtyLogLock unlock];
1072 [pool release]; pool = [[NSAutoreleasePool alloc] init];
1076 [toEnumerator release];
1078 [fromGroup release];
1081 [fromEnumerator release];
1083 AILog(@"Finished dritying all logs");
1085 //Save the dirty array we just built
1086 [self _saveDirtyLogArray];
1087 suspendDirtyArraySave = NO; //Re-allow saving of the dirty array
1089 //Begin cleaning the logs (If the log viewer is open)
1090 if (!stopIndexingThreads && [LogViewerWindowControllerClass existingWindowController]) {
1091 [self cleanDirtyLogs];
1094 [indexingThreadLock unlock];
1099 * @brief Index all dirty logs
1101 * Indexing will occur on a thread
1103 - (void)cleanDirtyLogs
1105 //Do nothing if we're paused
1106 if (logIndexingPauses) return;
1108 //Reset the cleaning progress
1109 [dirtyLogLock lock];
1110 logsToIndex = [dirtyLogArray count];
1111 [dirtyLogLock unlock];
1113 AILog(@"cleanDirtyLogs: logsToIndex is %i",logsToIndex);
1114 if (logsToIndex > 0) {
1115 [NSThread detachNewThreadSelector:@selector(_cleanDirtyLogsThread:) toTarget:self withObject:(id)[self logContentIndex]];
1119 - (void)didCleanDirtyLogs
1121 //Update our progress
1124 [[LogViewerWindowControllerClass existingWindowController] logIndexingProgressUpdate];
1126 //Clear the dirty status of all open chats so they will be marked dirty if they receive another message
1127 NSEnumerator *enumerator = [[[adium chatController] openChats] objectEnumerator];
1130 while ((chat = [enumerator nextObject])) {
1131 NSString *existingAppenderPath = [[self existingAppenderForChat:chat] path];
1132 if (existingAppenderPath) {
1133 NSString *dirtyKey = [@"LogIsDirty_" stringByAppendingString:existingAppenderPath];
1135 if ([chat integerStatusObjectForKey:dirtyKey]) {
1136 [chat setStatusObject:nil
1138 notify:NotifyNever];
1144 - (void)pauseIndexing
1147 [self stopIndexingThreads];
1149 logIndexingPauses++;
1150 AILog(@"Pausing %i",logIndexingPauses);
1154 - (void)resumeIndexing
1156 if (logIndexingPauses)
1157 logIndexingPauses--;
1158 AILog(@"Told to resume; log indexing paauses is now %i",logIndexingPauses);
1159 if (logIndexingPauses == 0) {
1160 stopIndexingThreads = NO;
1161 [self cleanDirtyLogs];
1165 - (void)_cleanDirtyLogsThread:(SKIndexRef)searchIndex
1167 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
1169 //Ensure log indexing (in an old thread) isn't already going on and just waiting to stop
1170 [indexingThreadLock lock]; [indexingThreadLock unlock];
1172 //If log indexing was already in progress, we can just cancel since it is now complete
1173 if (logsToIndex == 0) {
1174 AILog(@"Nothing to clean!");
1175 [self performSelectorOnMainThread:@selector(didCleanDirtyLogs)
1183 NSLog(@"*** Warning: Called -[%@ _cleanDirtyLogsThread:] with a NULL searchIndex. That shouldn't happen!", self);
1188 [indexingThreadLock lock];
1190 CFRetain(searchIndex);
1192 //Start cleaning (If we're still supposed to go)
1193 if (!stopIndexingThreads) {
1194 UInt32 lastUpdate = TickCount();
1195 int unsavedChanges = 0;
1197 AILog(@"Cleaning %i dirty logs", [dirtyLogArray count]);
1199 //Scan until we're done or told to stop
1200 while (!stopIndexingThreads) {
1201 NSString *logPath = nil;
1203 //Get the next dirty log
1204 [dirtyLogLock lock];
1205 if ([dirtyLogArray count]) {
1206 logPath = [[[dirtyLogArray lastObject] retain] autorelease]; //retain to prevent deallocation when removing from the array
1207 [dirtyLogArray removeLastObject];
1209 [dirtyLogLock unlock];
1212 SKDocumentRef document;
1214 document = SKDocumentCreateWithURL((CFURLRef)[NSURL fileURLWithPath:logPath]);
1216 /* We _could_ use SKIndexAddDocument() and depend on our Spotlight plugin for importing.
1217 * However, this has three problems:
1218 * 1. Slower, especially to start initial indexing, which is the most common use case since the log viewer
1219 * indexes recently-modified ("dirty") logs when it opens.
1220 * 2. Sometimes logs don't appear to be associated with the right URI type and therefore don't get indexed.
1221 * 3. On 10.3, this means that logs' markup is indexed in addition to their text, which is undesireable.
1223 CFStringRef documentText = CopyTextContentForFile(NULL, (CFStringRef)logPath);
1225 SKIndexAddDocumentWithText(searchIndex,
1229 CFRelease(documentText);
1231 CFRelease(document);
1233 NSLog(@"Could not create document for %@ [%@]",logPath,[NSURL fileURLWithPath:logPath]);
1236 //Update our progress
1238 if (lastUpdate == 0 || TickCount() > lastUpdate + LOG_INDEX_STATUS_INTERVAL) {
1239 [[LogViewerWindowControllerClass existingWindowController]
1240 performSelectorOnMainThread:@selector(logIndexingProgressUpdate)
1243 lastUpdate = TickCount();
1246 //Save the dirty array
1247 if (unsavedChanges++ > LOG_CLEAN_SAVE_INTERVAL) {
1248 [self _saveDirtyLogArray];
1253 [pool release]; pool = [[NSAutoreleasePool alloc] init];
1257 break; //Exit when we run out of logs
1261 //Save the slimmed down dirty log array
1262 if (unsavedChanges) {
1263 [self _saveDirtyLogArray];
1266 [logAccessLock lock];
1267 SKIndexFlush(searchIndex);
1268 AILog(@"After cleaning dirty logs, the search index has a max ID of %i and a count of %i",
1269 SKIndexGetMaximumDocumentID(searchIndex),
1270 SKIndexGetDocumentCount(searchIndex));
1271 [logAccessLock unlock];
1274 [self performSelectorOnMainThread:@selector(didCleanDirtyLogs)
1279 CFRelease(searchIndex);
1281 [indexingThreadLock unlock];
1286 - (NSLock *)logAccessLock
1288 return logAccessLock;
1291 - (void)_removePathsFromIndexThread:(NSDictionary *)userInfo
1293 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
1295 [indexingThreadLock lock];
1297 SKIndexRef logSearchIndex = (SKIndexRef)[userInfo objectForKey:@"SKIndexRef"];
1298 NSEnumerator *enumerator = [[userInfo objectForKey:@"Paths"] objectEnumerator];
1301 while ((logPath = [enumerator nextObject])) {
1302 SKDocumentRef document = SKDocumentCreateWithURL((CFURLRef)[NSURL fileURLWithPath:logPath]);
1304 SKIndexRemoveDocument(logSearchIndex, document);
1305 CFRelease(document);
1309 [indexingThreadLock unlock];
1314 - (void)removePathsFromIndex:(NSSet *)paths
1316 [NSThread detachNewThreadSelector:@selector(_removePathsFromIndexThread:)
1318 withObject:[NSDictionary dictionaryWithObjectsAndKeys:
1319 (id)[self logContentIndex], @"SKIndexRef",