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 containedObjects] 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 object
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 return [NSString stringWithFormat:@"%@.%@-%@", [account serviceID], [account UID], chatID];
529 - (AIXMLAppender *)existingAppenderForChat:(AIChat *)chat
531 //Look up the key for this chat and use it to try to retrieve the appender
532 return [activeAppenders objectForKey:[self keyForChat:chat]];
535 - (AIXMLAppender *)appenderForChat:(AIChat *)chat
537 //Check if there is already an appender for this chat
538 AIXMLAppender *appender = [self existingAppenderForChat:chat];
541 //Ensure a timeout isn't set for closing the appender, since we're now using it
542 [NSObject cancelPreviousPerformRequestsWithTarget:self
543 selector:@selector(finishClosingAppender:)
544 object:[self keyForChat:chat]];
546 //If there isn't already an appender, create a new one and add it to the dictionary
547 NSDate *chatDate = [chat dateOpened];
548 NSString *fullPath = [AILoggerPlugin fullPathForLogOfChat:chat onDate:chatDate];
550 appender = [AIXMLAppender documentWithPath:fullPath];
551 [appender initializeDocumentWithRootElementName:@"chat"
552 attributeKeys:[NSArray arrayWithObjects:@"xmlns", @"account", @"service", nil]
553 attributeValues:[NSArray arrayWithObjects:
554 XML_LOGGING_NAMESPACE,
555 [[chat account] UID],
556 [[chat account] serviceID],
559 //Add the window opened event now
560 [appender addElementWithName:@"event"
562 attributeKeys:[NSArray arrayWithObjects:@"type", @"sender", @"time", nil]
563 attributeValues:[NSArray arrayWithObjects:@"windowOpened", [[chat account] UID], [[chatDate dateWithCalendarFormat:nil timeZone:nil] ISO8601DateString], nil]];
565 [activeAppenders setObject:appender forKey:[self keyForChat:chat]];
567 [self markLogDirtyAtPath:[appender path] forChat:chat];
573 - (void)closeAppenderForChat:(AIChat *)chat
575 //Create a new timer to fire after the timeout period, which will close the appender
576 NSString *chatKey = [self keyForChat:chat];
577 [NSObject cancelPreviousPerformRequestsWithTarget:self
578 selector:@selector(finishClosingAppender:)
580 [self performSelector:@selector(finishClosingAppender:)
582 afterDelay:NEW_LOGFILE_TIMEOUT];
585 - (void)finishClosingAppender:(NSString *)chatKey
587 //Remove the appender, closing its file descriptor upon dealloc
588 [activeAppenders removeObjectForKey:chatKey];
592 //Display a warning to the user that logging failed, and disable logging to prevent additional warnings
593 //XXX not currently used. We may want to shift these strings for use when xml logging fails, so I'm not removing them -eds
595 - (void)displayErrorAndDisableLogging
597 NSRunAlertPanel(AILocalizedString(@"Unable to write log", nil),
598 [NSString stringWithFormat:
599 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],
600 AILocalizedString(@"OK", nil), nil, nil);
603 [[adium preferenceController] setPreference:[NSNumber numberWithBool:NO]
604 forKey:KEY_LOGGER_ENABLE
605 group:PREF_GROUP_LOGGING];
609 #pragma mark Message History
611 NSCalendarDate* getDateFromPath(NSString *path)
613 NSRange openParenRange, closeParenRange;
615 if ([path hasSuffix:@".chatlog"] && (openParenRange = [path rangeOfString:@"(" options:NSBackwardsSearch]).location != NSNotFound) {
616 openParenRange = NSMakeRange(openParenRange.location, [path length] - openParenRange.location);
617 if ((closeParenRange = [path rangeOfString:@")" options:0 range:openParenRange]).location != NSNotFound) {
618 //Add and subtract one to remove the parenthesis
619 NSString *dateString = [path substringWithRange:NSMakeRange(openParenRange.location + 1, (closeParenRange.location - openParenRange.location))];
620 NSCalendarDate *date = [NSCalendarDate calendarDateWithString:dateString timeSeparator:'.'];
627 int sortPaths(NSString *path1, NSString *path2, void *context)
629 NSCalendarDate *date1 = getDateFromPath(path1);
630 NSCalendarDate *date2 = getDateFromPath(path2);
633 return NSOrderedSame;
634 else if (date1 && date2)
635 return [date2 compare:date1];
637 return date2 ? NSOrderedDescending : NSOrderedAscending;
640 + (NSArray *)sortedArrayOfLogFilesForChat:(AIChat *)chat
642 NSString *baseLogPath = [[self fullPathForLogOfChat:chat onDate:[NSDate date]] stringByDeletingLastPathComponent];
643 NSArray *files = [[NSFileManager defaultManager] directoryContentsAtPath:baseLogPath];
645 return [files sortedArrayUsingFunction:&sortPaths context:NULL];
650 #pragma mark Upgrade code
651 - (void)upgradeLogExtensions
653 if (![[[adium preferenceController] preferenceForKey:@"Log Extensions Updated" group:PREF_GROUP_LOGGING] boolValue]) {
654 /* This could all be a simple NSDirectoryEnumerator call on basePath, but we wouldn't be able to show progress,
655 * and this could take a bit.
657 NSFileManager *defaultManager = [NSFileManager defaultManager];
658 NSArray *accountFolders = [defaultManager directoryContentsAtPath:logBasePath];
659 NSEnumerator *accountFolderEnumerator = [accountFolders objectEnumerator];
660 NSString *accountFolderName;
662 NSMutableSet *pathsToContactFolders = [NSMutableSet set];
663 while ((accountFolderName = [accountFolderEnumerator nextObject])) {
664 NSString *contactBasePath = [logBasePath stringByAppendingPathComponent:accountFolderName];
665 NSArray *contactFolders = [defaultManager directoryContentsAtPath:contactBasePath];
667 NSEnumerator *contactFolderEnumerator = [contactFolders objectEnumerator];
668 NSString *contactFolderName;
670 while ((contactFolderName = [contactFolderEnumerator nextObject])) {
671 [pathsToContactFolders addObject:[contactBasePath stringByAppendingPathComponent:contactFolderName]];
675 unsigned contactsToProcess = [pathsToContactFolders count];
676 unsigned processed = 0;
678 if (contactsToProcess) {
679 AILogFileUpgradeWindowController *upgradeWindowController;
681 upgradeWindowController = [[AILogFileUpgradeWindowController alloc] initWithWindowNibName:@"LogFileUpgrade"];
682 [[upgradeWindowController window] makeKeyAndOrderFront:nil];
684 NSEnumerator *pathsToContactFoldersEnumerator = [pathsToContactFolders objectEnumerator];
685 NSString *pathToContactFolder;
686 while ((pathToContactFolder = [pathsToContactFoldersEnumerator nextObject])) {
687 NSDirectoryEnumerator *enumerator = [defaultManager enumeratorAtPath:pathToContactFolder];
690 while ((file = [enumerator nextObject])) {
691 if (([[file pathExtension] isEqualToString:@"html"]) ||
692 ([[file pathExtension] isEqualToString:@"adiumLog"]) ||
693 (([[file pathExtension] isEqualToString:@"bak"]) && ([file hasSuffix:@".html.bak"] ||
694 [file hasSuffix:@".adiumLog.bak"]))) {
695 NSString *fullFile = [pathToContactFolder stringByAppendingPathComponent:file];
696 NSString *newFile = [[fullFile stringByDeletingPathExtension] stringByAppendingPathExtension:@"AdiumHTMLLog"];
698 [defaultManager movePath:fullFile
705 [upgradeWindowController setProgress:(processed*100.0)/contactsToProcess];
708 [upgradeWindowController close];
709 [upgradeWindowController release];
712 [[adium preferenceController] setPreference:[NSNumber numberWithBool:YES]
713 forKey:@"Log Extensions Updated"
714 group:PREF_GROUP_LOGGING];
718 - (BOOL)fileManager:(NSFileManager *)manager shouldProceedAfterError:(NSDictionary *)errorInfo
720 NSLog(@"Error: %@",errorInfo);
726 * @brief Instruct spotlight to reimport logs
728 * Adium 1.0.2 and earlier had a bug which made spotlight import not work properly.
729 * New logs are properly indexed, but previously created logs are not. On first launch of Adium 1.1,
730 * Adium will tell Spotlight to reimport those old logs.
732 - (void)reimportLogsToSpotlightIfNeeded
734 if (![[NSUserDefaults standardUserDefaults] boolForKey:@"Adium 1.1:Reimported Spotlight Logs"]) {
737 arguments = [NSArray arrayWithObjects:
739 [[[[[[NSBundle mainBundle] bundlePath] stringByAppendingPathComponent:@"Contents"] stringByAppendingPathComponent:@"Library"]
740 stringByAppendingPathComponent:@"Spotlight"] stringByAppendingPathComponent:[@"AdiumSpotlightImporter" stringByAppendingPathExtension:@"mdimporter"]],
742 [NSTask launchedTaskWithLaunchPath:@"/usr/bin/mdimport" arguments:arguments];
744 [[NSUserDefaults standardUserDefaults] setBool:YES
745 forKey:@"Adium 1.1:Reimported Spotlight Logs"];
749 //Log Indexing ---------------------------------------------------------------------------------------------------------
750 #pragma mark Log Indexing
751 /***** Everything below this point is related to log index generation and access ****/
753 /* For the log content searching, we are required to re-index a log whenever it changes. The solution below to
754 * this problem is along the lines of:
755 * - Keep an array of logs that need to be re-indexed
756 * - Whenever a log is changed, add it to this array
757 * - When the log viewer is opened, re-index all the logs in the array
761 * @brief Initialize log indexing
763 - (void)initLogIndexing
765 //Load the list of logs that need re-indexing
766 [self loadDirtyLogArray];
770 * @brief Prepare the log index for searching.
772 * Must call before attempting to use the logSearchIndex.
774 - (void)prepareLogContentSearching
776 /* Load the index and start indexing to make it current
777 * If we're going to need to re-index all our logs from scratch, it will make
778 * things faster if we start with a fresh log index as well.
780 if (!dirtyLogArray) {
781 [self resetLogIndex];
784 //Load the contentIndex immediately; this will clear dirtyLogArray if necessary
785 [self logContentIndex];
787 stopIndexingThreads = NO;
788 if (!dirtyLogArray) {
791 [self cleanDirtyLogs];
795 //Close down and clean up the log index (Call when finished using the logSearchIndex)
796 - (void)cleanUpLogContentSearching
798 [self stopIndexingThreads];
799 [self closeLogIndex];
802 //Returns the Search Kit index for log content searching
803 - (SKIndexRef)logContentIndex
805 SKIndexRef returnIndex;
807 [logAccessLock lock];
808 if (!index_Content) index_Content = [self createLogIndex];
809 returnIndex = (SKIndexRef)[[(NSObject *)index_Content retain] autorelease];
810 [logAccessLock unlock];
815 //Mark a log as needing a re-index
816 - (void)markLogDirtyAtPath:(NSString *)path forChat:(AIChat *)chat
818 NSString *dirtyKey = [@"LogIsDirty_" stringByAppendingString:path];
820 if (![chat integerStatusObjectForKey:dirtyKey]) {
821 //Add to dirty array (Lock to ensure that no one changes its content while we are)
824 if (!dirtyLogArray) dirtyLogArray = [[NSMutableArray alloc] init];
826 if (![dirtyLogArray containsObject:path]) {
827 [dirtyLogArray addObject:path];
830 [dirtyLogLock unlock];
832 //Save the dirty array immedientally
833 [self _saveDirtyLogArray];
835 //Flag the chat with 'LogIsDirty' for this filename. On the next message we can quickly check this flag.
836 [chat setStatusObject:[NSNumber numberWithBool:YES]
842 - (void)markLogDirtyAtPath:(NSString *)path
846 if (!dirtyLogArray) dirtyLogArray = [[NSMutableArray alloc] init];
848 if (![dirtyLogArray containsObject:path]) {
849 [dirtyLogArray addObject:path];
851 [dirtyLogLock unlock];
854 //Get the current status of indexing. Returns NO if indexing is not occuring
855 - (BOOL)getIndexingProgress:(int *)indexNumber outOf:(int *)total
857 //logsIndexed + 1 is the log we are currently indexing
858 if (indexNumber) *indexNumber = (logsIndexed + 1 <= logsToIndex) ? logsIndexed + 1 : logsToIndex;
859 if (total) *total = logsToIndex;
860 return (logsToIndex > 0);
864 //Log index ------------------------------------------------------------------------------------------------------------
865 //Search kit index used to searching log content
866 #pragma mark Log Index
868 * @brief Create the log index
870 * Should be called within logAccessLock being locked
872 - (SKIndexRef)createLogIndex
874 NSString *logIndexPath = [self _logIndexPath];
875 NSURL *logIndexPathURL = [NSURL fileURLWithPath:logIndexPath];
876 SKIndexRef newIndex = NULL;
878 if ([[NSFileManager defaultManager] fileExistsAtPath:logIndexPath]) {
879 newIndex = SKIndexOpenWithURL((CFURLRef)logIndexPathURL, (CFStringRef)@"Content", true);
880 AILog(@"Opened index %x from %@",newIndex,logIndexPathURL);
883 //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.
884 NSLog(@"*** Warning: The Chat Transcript searching index at %@ was corrupt. Removing it and starting fresh; transcripts will be re-indexed automatically.",
886 [[NSFileManager defaultManager] removeFileAtPath:logIndexPath handler:NULL];
890 NSDictionary *textAnalysisProperties;
892 textAnalysisProperties = [NSDictionary dictionaryWithObjectsAndKeys:
893 [NSNumber numberWithInt:0], kSKMaximumTerms,
894 [NSNumber numberWithInt:2], kSKMinTermLength,
895 #if ENABLE_PROXIMITY_SEARCH
896 kCFBooleanTrue, kSKProximityIndexing,
900 //Create the index if one doesn't exist or it couldn't be opened.
901 [[NSFileManager defaultManager] createDirectoriesForPath:[logIndexPath stringByDeletingLastPathComponent]];
903 newIndex = SKIndexCreateWithURL((CFURLRef)logIndexPathURL,
904 (CFStringRef)@"Content",
906 (CFDictionaryRef)textAnalysisProperties);
908 AILog(@"Created a new log index %x at %@ with textAnalysisProperties %@",newIndex,logIndexPathURL,textAnalysisProperties);
909 //Clear the dirty log array in case it was loaded (this can happen if the user mucks with the cache directory)
910 [[NSFileManager defaultManager] removeFileAtPath:[self _dirtyLogArrayPath] handler:NULL];
911 [dirtyLogArray release]; dirtyLogArray = nil;
913 AILog(@"AILoggerPlugin warning: SKIndexCreateWithURL() returned NULL");
920 - (void)releaseIndex:(SKIndexRef)inIndex
922 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
923 [logAccessLock lock];
925 AILog(@"**** Flushing index %p",inIndex);
926 SKIndexFlush(inIndex);
928 AILog(@"**** Finished flushing index %p, and released it",inIndex);
930 [logAccessLock unlock];
934 //Close the log index
935 - (void)closeLogIndex
937 [logAccessLock lock];
939 [NSThread detachNewThreadSelector:@selector(releaseIndex:)
941 withObject:(id)index_Content];
943 AILog(@"**** index_Content is now nil", nil);
945 [logAccessLock unlock];
948 //Delete the log index
949 - (void)resetLogIndex
951 if ([[NSFileManager defaultManager] fileExistsAtPath:[self _logIndexPath]]) {
952 [[NSFileManager defaultManager] removeFileAtPath:[self _logIndexPath] handler:NULL];
955 if ([[NSFileManager defaultManager] fileExistsAtPath:[self _dirtyLogArrayPath]]) {
956 [[NSFileManager defaultManager] removeFileAtPath:[self _dirtyLogArrayPath] handler:NULL];
960 //Path of log index file
961 - (NSString *)_logIndexPath
963 return [[adium cachesPath] stringByAppendingPathComponent:LOG_INDEX_NAME];
967 //Dirty Log Array ------------------------------------------------------------------------------------------------------
968 //Stores the absolute paths of logs that need to be re-indexed
969 #pragma mark Dirty Log Array
970 //Load the dirty log array
971 - (void)loadDirtyLogArray
973 if (!dirtyLogArray) {
974 int logVersion = [[[adium preferenceController] preferenceForKey:KEY_LOG_INDEX_VERSION
975 group:PREF_GROUP_LOGGING] intValue];
977 //If the log version has changed, we reset the index and don't load the dirty array (So all the logs are marked dirty)
978 if (logVersion >= CURRENT_LOG_VERSION) {
980 dirtyLogArray = [[NSMutableArray alloc] initWithContentsOfFile:[self _dirtyLogArrayPath]];
981 AILog(@"Got dirty logs %@",dirtyLogArray);
982 [dirtyLogLock unlock];
984 AILog(@"**** Log version upgrade. Resetting");
985 [self resetLogIndex];
986 [[adium preferenceController] setPreference:[NSNumber numberWithInt:CURRENT_LOG_VERSION]
987 forKey:KEY_LOG_INDEX_VERSION
988 group:PREF_GROUP_LOGGING];
993 //Save the dirty lod array
994 - (void)_saveDirtyLogArray
996 if (dirtyLogArray && !suspendDirtyArraySave) {
998 [dirtyLogArray writeToFile:[self _dirtyLogArrayPath] atomically:NO];
999 [dirtyLogLock unlock];
1003 //Path of the dirty log array file
1004 - (NSString *)_dirtyLogArrayPath
1006 return [[adium cachesPath] stringByAppendingPathComponent:DIRTY_LOG_ARRAY_NAME];
1010 //Threaded Indexing ----------------------------------------------------------------------------------------------------
1011 #pragma mark Threaded Indexing
1012 //Stop any indexing related threads
1013 - (void)stopIndexingThreads
1015 //Let any indexing threads know it's time to stop, and wait for them to finish.
1016 stopIndexingThreads = YES;
1019 //The following methods will be run in a separate thread to avoid blocking the interface during index operations
1020 //THREAD: Flag every log as dirty (Do this when there is no log index)
1021 - (void)dirtyAllLogs
1023 //Reset and rebuild the dirty array
1024 [dirtyLogArray release]; dirtyLogArray = [[NSMutableArray alloc] init];
1025 //[self _dirtyAllLogsThread];
1026 [NSThread detachNewThreadSelector:@selector(_dirtyAllLogsThread) toTarget:self withObject:nil];
1028 - (void)_dirtyAllLogsThread
1030 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
1031 NSEnumerator *fromEnumerator, *toEnumerator, *logEnumerator;
1033 AILogFromGroup *fromGroup = nil;
1034 AILogToGroup *toGroup;
1037 [indexingThreadLock lock];
1038 suspendDirtyArraySave = YES; //Prevent saving of the dirty array until we're finished building it
1040 //Create a fresh dirty log array
1041 [dirtyLogLock lock];
1042 [dirtyLogArray release]; dirtyLogArray = [[NSMutableArray alloc] init];
1043 [dirtyLogLock unlock];
1045 //Process each from folder
1046 fromEnumerator = [[[[NSFileManager defaultManager] directoryContentsAtPath:logBasePath] objectEnumerator] retain];
1047 while ((fromName = [[fromEnumerator nextObject] retain])) {
1048 fromGroup = [[AILogFromGroup alloc] initWithPath:fromName fromUID:fromName serviceClass:nil];
1050 //Walk through every 'to' group
1051 toEnumerator = [[[fromGroup toGroupArray] objectEnumerator] retain];
1052 while ((toGroup = [[toEnumerator nextObject] retain])) {
1053 //Walk through every log
1054 logEnumerator = [toGroup logEnumerator];
1055 while ((theLog = [logEnumerator nextObject])) {
1056 //Add this log's path to our dirty array. The dirty array is guarded with a lock
1057 //since it will be accessed from outside this thread as well
1058 [dirtyLogLock lock];
1059 if (theLog != nil) {
1060 [dirtyLogArray addObject:[logBasePath stringByAppendingPathComponent:[theLog path]]];
1062 [dirtyLogLock unlock];
1066 [pool release]; pool = [[NSAutoreleasePool alloc] init];
1070 [toEnumerator release];
1072 [fromGroup release];
1075 [fromEnumerator release];
1077 AILog(@"Finished dritying all logs");
1079 //Save the dirty array we just built
1080 [self _saveDirtyLogArray];
1081 suspendDirtyArraySave = NO; //Re-allow saving of the dirty array
1083 //Begin cleaning the logs (If the log viewer is open)
1084 if (!stopIndexingThreads && [LogViewerWindowControllerClass existingWindowController]) {
1085 [self cleanDirtyLogs];
1088 [indexingThreadLock unlock];
1093 * @brief Index all dirty logs
1095 * Indexing will occur on a thread
1097 - (void)cleanDirtyLogs
1099 //Do nothing if we're paused
1100 if (logIndexingPauses) return;
1102 //Reset the cleaning progress
1103 [dirtyLogLock lock];
1104 logsToIndex = [dirtyLogArray count];
1105 [dirtyLogLock unlock];
1107 AILog(@"cleanDirtyLogs: logsToIndex is %i",logsToIndex);
1108 if (logsToIndex > 0) {
1109 [NSThread detachNewThreadSelector:@selector(_cleanDirtyLogsThread:) toTarget:self withObject:(id)[self logContentIndex]];
1113 - (void)didCleanDirtyLogs
1115 //Update our progress
1118 [[LogViewerWindowControllerClass existingWindowController] logIndexingProgressUpdate];
1120 //Clear the dirty status of all open chats so they will be marked dirty if they receive another message
1121 NSEnumerator *enumerator = [[[adium chatController] openChats] objectEnumerator];
1124 while ((chat = [enumerator nextObject])) {
1125 NSString *existingAppenderPath = [[self existingAppenderForChat:chat] path];
1126 if (existingAppenderPath) {
1127 NSString *dirtyKey = [@"LogIsDirty_" stringByAppendingString:existingAppenderPath];
1129 if ([chat integerStatusObjectForKey:dirtyKey]) {
1130 [chat setStatusObject:nil
1132 notify:NotifyNever];
1138 - (void)pauseIndexing
1141 [self stopIndexingThreads];
1143 logIndexingPauses++;
1144 AILog(@"Pausing %i",logIndexingPauses);
1148 - (void)resumeIndexing
1150 if (logIndexingPauses)
1151 logIndexingPauses--;
1152 AILog(@"Told to resume; log indexing paauses is now %i",logIndexingPauses);
1153 if (logIndexingPauses == 0) {
1154 stopIndexingThreads = NO;
1155 [self cleanDirtyLogs];
1159 - (void)_cleanDirtyLogsThread:(SKIndexRef)searchIndex
1161 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
1163 //Ensure log indexing (in an old thread) isn't already going on and just waiting to stop
1164 [indexingThreadLock lock]; [indexingThreadLock unlock];
1166 //If log indexing was already in progress, we can just cancel since it is now complete
1167 if (logsToIndex == 0) {
1168 AILog(@"Nothing to clean!");
1169 [self performSelectorOnMainThread:@selector(didCleanDirtyLogs)
1177 NSLog(@"*** Warning: Called -[%@ _cleanDirtyLogsThread:] with a NULL searchIndex. That shouldn't happen!", self);
1182 [indexingThreadLock lock];
1184 CFRetain(searchIndex);
1186 //Start cleaning (If we're still supposed to go)
1187 if (!stopIndexingThreads) {
1188 UInt32 lastUpdate = TickCount();
1189 int unsavedChanges = 0;
1191 AILog(@"Cleaning %i dirty logs", [dirtyLogArray count]);
1193 //Scan until we're done or told to stop
1194 while (!stopIndexingThreads) {
1195 NSString *logPath = nil;
1197 //Get the next dirty log
1198 [dirtyLogLock lock];
1199 if ([dirtyLogArray count]) {
1200 logPath = [[[dirtyLogArray lastObject] retain] autorelease]; //retain to prevent deallocation when removing from the array
1201 [dirtyLogArray removeLastObject];
1203 [dirtyLogLock unlock];
1206 SKDocumentRef document;
1208 document = SKDocumentCreateWithURL((CFURLRef)[NSURL fileURLWithPath:logPath]);
1210 /* We _could_ use SKIndexAddDocument() and depend on our Spotlight plugin for importing.
1211 * However, this has three problems:
1212 * 1. Slower, especially to start initial indexing, which is the most common use case since the log viewer
1213 * indexes recently-modified ("dirty") logs when it opens.
1214 * 2. Sometimes logs don't appear to be associated with the right URI type and therefore don't get indexed.
1215 * 3. On 10.3, this means that logs' markup is indexed in addition to their text, which is undesireable.
1217 CFStringRef documentText = CopyTextContentForFile(NULL, (CFStringRef)logPath);
1219 SKIndexAddDocumentWithText(searchIndex,
1223 CFRelease(documentText);
1225 CFRelease(document);
1227 NSLog(@"Could not create document for %@ [%@]",logPath,[NSURL fileURLWithPath:logPath]);
1230 //Update our progress
1232 if (lastUpdate == 0 || TickCount() > lastUpdate + LOG_INDEX_STATUS_INTERVAL) {
1233 [[LogViewerWindowControllerClass existingWindowController]
1234 performSelectorOnMainThread:@selector(logIndexingProgressUpdate)
1237 lastUpdate = TickCount();
1240 //Save the dirty array
1241 if (unsavedChanges++ > LOG_CLEAN_SAVE_INTERVAL) {
1242 [self _saveDirtyLogArray];
1247 [pool release]; pool = [[NSAutoreleasePool alloc] init];
1251 break; //Exit when we run out of logs
1255 //Save the slimmed down dirty log array
1256 if (unsavedChanges) {
1257 [self _saveDirtyLogArray];
1260 [logAccessLock lock];
1261 SKIndexFlush(searchIndex);
1262 AILog(@"After cleaning dirty logs, the search index has a max ID of %i and a count of %i",
1263 SKIndexGetMaximumDocumentID(searchIndex),
1264 SKIndexGetDocumentCount(searchIndex));
1265 [logAccessLock unlock];
1268 [self performSelectorOnMainThread:@selector(didCleanDirtyLogs)
1273 CFRelease(searchIndex);
1275 [indexingThreadLock unlock];
1280 - (NSLock *)logAccessLock
1282 return logAccessLock;
1285 - (void)_removePathsFromIndexThread:(NSDictionary *)userInfo
1287 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
1289 [indexingThreadLock lock];
1291 SKIndexRef logSearchIndex = (SKIndexRef)[userInfo objectForKey:@"SKIndexRef"];
1292 NSEnumerator *enumerator = [[userInfo objectForKey:@"Paths"] objectEnumerator];
1295 while ((logPath = [enumerator nextObject])) {
1296 SKDocumentRef document = SKDocumentCreateWithURL((CFURLRef)[NSURL fileURLWithPath:logPath]);
1298 SKIndexRemoveDocument(logSearchIndex, document);
1299 CFRelease(document);
1303 [indexingThreadLock unlock];
1308 - (void)removePathsFromIndex:(NSSet *)paths
1310 [NSThread detachNewThreadSelector:@selector(_removePathsFromIndexThread:)
1312 withObject:[NSDictionary dictionaryWithObjectsAndKeys:
1313 (id)[self logContentIndex], @"SKIndexRef",