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 "AILogViewerWindowController.h"
22 #import "AIMDLogViewerWindowController.h"
23 #import "AIXMLAppender.h"
24 #import <Adium/AIContentControllerProtocol.h>
25 #import <Adium/AIInterfaceControllerProtocol.h>
26 #import <Adium/AIChatControllerProtocol.h>
27 #import <Adium/AILoginControllerProtocol.h>
28 #import <Adium/AIMenuControllerProtocol.h>
29 #import <Adium/AIPreferenceControllerProtocol.h>
30 #import <Adium/AIToolbarControllerProtocol.h>
31 #import <Adium/AIAccount.h>
32 #import <Adium/AIChat.h>
33 #import <Adium/AIContentMessage.h>
34 #import <Adium/AIContentStatus.h>
35 #import <Adium/AIContentEvent.h>
36 #import <Adium/AIHTMLDecoder.h>
37 #import <Adium/AIListContact.h>
38 #import <Adium/AIService.h>
39 #import <AIUtilities/AIAttributedStringAdditions.h>
40 #import <AIUtilities/AIDictionaryAdditions.h>
41 #import <AIUtilities/AIFileManagerAdditions.h>
42 #import <AIUtilities/AIMenuAdditions.h>
43 #import <AIUtilities/AIStringAdditions.h>
44 #import <AIUtilities/AIToolbarUtilities.h>
45 #import <AIUtilities/AIApplicationAdditions.h>
46 #import <AIUtilities/AIDateFormatterAdditions.h>
47 #import <AIUtilities/AIImageAdditions.h>
48 #import <AIUtilities/NSCalendarDate+ISO8601Unparsing.h>
49 #import <AIUtilities/NSCalendarDate+ISO8601Parsing.h>
51 #import "AILogFileUpgradeWindowController.h"
53 #import "AdiumSpotlightImporter.h"
55 #define LOG_INDEX_NAME @"Logs.index"
56 #define DIRTY_LOG_ARRAY_NAME @"DirtyLogs.plist"
57 #define KEY_LOG_INDEX_VERSION @"Log Index Version"
59 #define LOG_INDEX_STATUS_INTERVAL 20 //Interval before updating the log indexing status
60 #define LOG_CLEAN_SAVE_INTERVAL 500 //Number of logs to index continuously before saving the dirty array and index
62 #define LOG_VIEWER AILocalizedString(@"Chat Transcripts Viewer",nil)
63 #define VIEW_LOGS_WITH_CONTACT AILocalizedString(@"View Chat Transcripts",nil)
65 #define CURRENT_LOG_VERSION 7 //Version of the log index. Increase this number to reset everyone's index.
67 #define LOG_VIEWER_IDENTIFIER @"LogViewer"
69 #define XML_LOGGING_NAMESPACE @"http://purl.org/net/ulf/ns/0.4-02"
70 #define NEW_LOGFILE_TIMEOUT 600 //10 minutes
72 #define ENABLE_PROXIMITY_SEARCH TRUE
74 @interface AILoggerPlugin (PRIVATE)
75 - (void)configureMenuItems;
76 - (SKIndexRef)createLogIndex;
77 - (void)closeLogIndex;
78 - (void)resetLogIndex;
79 - (NSString *)_logIndexPath;
80 - (void)loadDirtyLogArray;
81 - (void)_saveDirtyLogArray;
82 - (NSString *)_dirtyLogArrayPath;
83 - (void)_dirtyAllLogsThread;
84 - (void)_cleanDirtyLogsThread;
86 - (void)upgradeLogExtensions;
88 - (NSString *)keyForChat:(AIChat *)chat;
89 - (AIXMLAppender *)existingAppenderForChat:(AIChat *)chat;
90 - (AIXMLAppender *)appenderForChat:(AIChat *)chat;
91 - (void)closeAppenderForChat:(AIChat *)chat;
94 static NSString *logBasePath = nil; //The base directory of all logs
95 Class LogViewerWindowControllerClass = NULL;
96 @implementation AILoggerPlugin
101 LogViewerWindowControllerClass = ([NSApp isOnTigerOrBetter] ?
102 [AIMDLogViewerWindowController class] :
103 [AILogViewerWindowController class]);
106 observingContent = NO;
108 activeAppenders = [[NSMutableDictionary alloc] init];
109 appenderCloseTimers = [[NSMutableDictionary alloc] init];
111 xhtmlDecoder = [[AIHTMLDecoder alloc] initWithHeaders:NO
118 attachmentsAsText:YES
119 onlyIncludeOutgoingImages:NO
122 [xhtmlDecoder setGeneratesStrictXHTML:YES];
123 [xhtmlDecoder setUsesAttachmentTextEquivalents:YES];
125 statusTranslation = [[NSDictionary alloc] initWithObjectsAndKeys:
127 @"online",@"return_away",
129 @"offline",@"offline",
131 @"available",@"return_idle",
132 @"away",@"away_message",
135 //Setup our preferences
136 [[adium preferenceController] registerDefaults:[NSDictionary dictionaryNamed:LOGGING_DEFAULT_PREFS
137 forClass:[self class]]
138 forGroup:PREF_GROUP_LOGGING];
140 //Install the log viewer menu items
141 [self configureMenuItems];
143 //Create a logs directory
144 logBasePath = [[[[[adium loginController] userDirectory] stringByAppendingPathComponent:PATH_LOGS] stringByExpandingTildeInPath] retain];
145 [[NSFileManager defaultManager] createDirectoriesForPath:logBasePath];
147 //Observe preference changes
148 [[adium preferenceController] addObserver:self
149 forKeyPath:PREF_KEYPATH_LOGGER_ENABLE
150 options:NSKeyValueObservingOptionNew
152 [self observeValueForKeyPath:PREF_KEYPATH_LOGGER_ENABLE
153 ofObject:[adium preferenceController]
158 NSToolbarItem *toolbarItem;
159 toolbarItem = [AIToolbarUtilities toolbarItemWithIdentifier:LOG_VIEWER_IDENTIFIER
160 label:AILocalizedString(@"Transcripts",nil)
161 paletteLabel:AILocalizedString(@"View Chat Transcripts",nil)
162 toolTip:AILocalizedString(@"View previous conversations with this contact or chat",nil)
164 settingSelector:@selector(setImage:)
165 itemContent:[NSImage imageNamed:@"LogViewer" forClass:[self class]]
166 action:@selector(showLogViewerToSelectedContact:)
168 [[adium toolbarController] registerToolbarItem:toolbarItem forToolbarType:@"ListObject"];
172 stopIndexingThreads = NO;
173 suspendDirtyArraySave = NO;
174 indexingThreadLock = [[NSLock alloc] init];
175 dirtyLogLock = [[NSLock alloc] init];
176 logAccessLock = [[NSLock alloc] init];
178 //Init index searching
179 [self initLogIndexing];
181 [self upgradeLogExtensions];
183 [[adium notificationCenter] addObserver:self
184 selector:@selector(showLogNotification:)
185 name:Adium_ShowLogAtPath
189 - (void)uninstallPlugin
191 [activeAppenders release];
192 [appenderCloseTimers release];
193 [xhtmlDecoder release];
194 [statusTranslation release];
196 [[adium preferenceController] removeObserver:self forKeyPath:PREF_KEYPATH_LOGGER_ENABLE];
199 //Update for the new preferences
200 - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
206 newLogValue = [[object valueForKeyPath:keyPath] boolValue];
207 if (newLogValue != observingContent) {
208 observingContent = newLogValue;
210 if (!observingContent) { //Stop Logging
211 [[adium notificationCenter] removeObserver:self name:Content_ContentObjectAdded object:nil];
213 [[adium notificationCenter] removeObserver:self name:Chat_DidOpen object:nil];
214 [[adium notificationCenter] removeObserver:self name:Chat_WillClose object:nil];
216 } else { //Start Logging
217 [[adium notificationCenter] addObserver:self
218 selector:@selector(contentObjectAdded:)
219 name:Content_ContentObjectAdded
222 [[adium notificationCenter] addObserver:self
223 selector:@selector(chatOpened:)
227 [[adium notificationCenter] addObserver:self
228 selector:@selector(chatClosed:)
231 [[adium notificationCenter] addObserver:self
232 selector:@selector(chatWillDelete:)
233 name:ChatLog_WillDelete
240 //Logging Paths --------------------------------------------------------------------------------------------------------
241 + (NSString *)logBasePath
246 //Returns the RELATIVE path to the folder where the log should be written
247 + (NSString *)relativePathForLogWithObject:(NSString *)object onAccount:(AIAccount *)account
249 return [NSString stringWithFormat:@"%@.%@/%@", [account serviceID], [[account UID] safeFilenameString], object];
252 + (NSString *)fileNameForLogWithObject:(NSString *)object onDate:(NSDate *)date
254 NSParameterAssert(date != nil);
255 NSParameterAssert(object != nil);
256 NSString *dateString = [date descriptionWithCalendarFormat:@"%Y-%m-%dT%H.%M.%S%z" timeZone:nil locale:nil];
258 NSAssert2(dateString != nil, @"Date string was invalid for the chatlog for %@ on %@", object, date);
260 return [NSString stringWithFormat:@"%@ (%@).chatlog", object, dateString];
263 + (NSString *)fullPathForLogOfChat:(AIChat *)chat onDate:(NSDate *)date
265 NSString *objectUID = [chat name];
266 AIAccount *account = [chat account];
268 if (!objectUID) objectUID = [[chat listObject] UID];
269 objectUID = [objectUID safeFilenameString];
271 NSString *fileName = [self fileNameForLogWithObject:objectUID onDate:date];
272 NSString *absolutePath = [logBasePath stringByAppendingPathComponent:[self relativePathForLogWithObject:objectUID onAccount:account]];
273 NSString *fullPath = [absolutePath stringByAppendingPathComponent:fileName];
278 //Menu Items -----------------------------------------------------------------------------------------------------------
279 #pragma mark Menu Items
280 //Configure the log viewer menu items
281 - (void)configureMenuItems
283 logViewerMenuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:LOG_VIEWER
285 action:@selector(showLogViewer:)
286 keyEquivalent:@"L"] autorelease];
287 [[adium menuController] addMenuItem:logViewerMenuItem toLocation:LOC_Window_Auxiliary];
289 viewContactLogsMenuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:VIEW_LOGS_WITH_CONTACT
291 action:@selector(showLogViewerToSelectedContact:)
292 keyEquivalent:@"l"] autorelease];
293 [[adium menuController] addMenuItem:viewContactLogsMenuItem toLocation:LOC_Contact_Info];
295 viewContactLogsContextMenuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:VIEW_LOGS_WITH_CONTACT
297 action:@selector(showLogViewerToSelectedContextContact:)
298 keyEquivalent:@""] autorelease];
299 [[adium menuController] addContextualMenuItem:viewContactLogsContextMenuItem toLocation:Context_Contact_Manage];
302 //Enable/Disable our view log menus
303 - (BOOL)validateMenuItem:(id <NSMenuItem>)menuItem
305 if (menuItem == viewContactLogsMenuItem) {
306 AIListObject *selectedObject = [[adium interfaceController] selectedListObject];
307 return selectedObject && [selectedObject isKindOfClass:[AIListContact class]];
309 } else if (menuItem == viewContactLogsContextMenuItem) {
310 AIListObject *selectedObject = [[adium menuController] currentContextMenuObject];
311 return selectedObject && [selectedObject isKindOfClass:[AIListContact class]];
319 * @brief Show the log viewer for no contact
321 * Invoked from the Window menu
323 - (void)showLogViewer:(id)sender
325 [LogViewerWindowControllerClass openForContact:nil
330 * @brief Show the log viewer, displaying only the selected contact's logs
332 * Invoked from the Contact menu
334 - (void)showLogViewerToSelectedContact:(id)sender
336 AIListObject *selectedObject = [[adium interfaceController] selectedListObject];
337 [LogViewerWindowControllerClass openForContact:([selectedObject isKindOfClass:[AIListContact class]] ?
338 (AIListContact *)selectedObject :
343 - (void)showLogViewerForLogAtPath:(NSString *)inPath
345 [LogViewerWindowControllerClass openLogAtPath:inPath plugin:self];
348 - (void)showLogNotification:(NSNotification *)inNotification
350 [self showLogViewerForLogAtPath:[inNotification object]];
354 * @brief Show the log viewer, displaying only the selected contact's logs
356 * Invoked from a contextual menu
358 - (void)showLogViewerToSelectedContextContact:(id)sender
360 AIListObject* object = [[adium menuController] currentContextMenuObject];
361 if ([object isKindOfClass:[AIListContact class]]) {
362 [NSApp activateIgnoringOtherApps:YES];
363 [[[LogViewerWindowControllerClass openForContact:(AIListContact *)object plugin:self] window]
364 makeKeyAndOrderFront:nil];
369 //Logging --------------------------------------------------------------------------------------------------------------
371 //Log any content that is sent or received
372 - (void)contentObjectAdded:(NSNotification *)notification
374 AIContentMessage *content = [[notification userInfo] objectForKey:@"AIContentObject"];
375 if ([content postProcessContent]) {
376 AIChat *chat = [notification object];
378 //Don't log chats for temporary accounts
379 if ([[chat account] isTemporary]) return;
382 NSString *contentType = [content type];
383 NSString *date = [[[content date] dateWithCalendarFormat:nil timeZone:nil] ISO8601DateString];
385 if ([contentType isEqualToString:CONTENT_MESSAGE_TYPE]) {
386 NSMutableArray *attributeKeys = [NSMutableArray arrayWithObjects:@"sender", @"time", nil];
387 NSMutableArray *attributeValues = [NSMutableArray arrayWithObjects:[[content source] UID], date, nil];
389 if([content isAutoreply])
391 [attributeKeys addObject:@"auto"];
392 [attributeValues addObject:@"true"];
395 [[self appenderForChat:chat] addElementWithName:@"message"
396 escapedContent:[xhtmlDecoder encodeHTML:[content message] imagesPath:nil]
397 attributeKeys:attributeKeys
398 attributeValues:attributeValues];
401 //XXX: Yucky hack. This is here because we get status and event updates for metas, not for individual contacts. Or something like that.
402 AIListObject *retardedMetaObject = [content source];
403 AIListObject *actualObject = nil;
404 AIListContact *participatingListObject = nil;
406 NSEnumerator *enumerator = [[chat participatingListObjects] objectEnumerator];
408 while ((participatingListObject = [enumerator nextObject])) {
409 if ([participatingListObject parentContact] == retardedMetaObject) {
410 actualObject = participatingListObject;
415 //If we can't find it for some reason, we probably shouldn't attempt logging.
417 if ([contentType isEqualToString:CONTENT_STATUS_TYPE]) {
418 NSString *translatedStatus = [statusTranslation objectForKey:[(AIContentStatus *)content status]];
419 if(translatedStatus == nil)
420 AILog(@"AILogger: Don't know how to translate status: %@", [(AIContentStatus *)content status]);
422 [[self appenderForChat:chat] addElementWithName:@"status"
423 escapedContent:([(AIContentStatus *)content loggedMessage] ? [xhtmlDecoder encodeHTML:[(AIContentStatus *)content loggedMessage] imagesPath:nil] : nil)
424 attributeKeys:[NSArray arrayWithObjects:@"type", @"sender", @"time", nil]
425 attributeValues:[NSArray arrayWithObjects:
433 } else if ([contentType isEqualToString:CONTENT_EVENT_TYPE]) {
434 [[self appenderForChat:chat] addElementWithName:@"event"
435 escapedContent:[xhtmlDecoder encodeHTML:[content message] imagesPath:nil]
436 attributeKeys:[NSArray arrayWithObjects:@"type", @"sender", @"time", nil]
437 attributeValues:[NSArray arrayWithObjects:[(AIContentEvent *)content eventType], [[content source] UID], date, nil]];
442 //Don't create a new one if not needed
443 AIXMLAppender *appender = [self existingAppenderForChat:chat];
444 if (dirty && appender)
445 [self markLogDirtyAtPath:[appender path] forChat:chat];
449 - (void)chatOpened:(NSNotification *)notification
451 AIChat *chat = [notification object];
453 //Don't log chats for temporary accounts
454 if ([[chat account] isTemporary]) return;
457 - (void)chatClosed:(NSNotification *)notification
459 AIChat *chat = [notification object];
461 //Don't log chats for temporary accounts
462 if ([[chat account] isTemporary]) return;
464 //Use this method so we don't create a new appender for chat close events
465 AIXMLAppender *appender = [self existingAppenderForChat:chat];
467 //If there is an appender, add the windowClose event
469 [appender addElementWithName:@"event"
471 attributeKeys:[NSArray arrayWithObjects:@"type", @"sender", @"time", nil]
472 attributeValues:[NSArray arrayWithObjects:@"windowClosed", [[chat account] UID], [[[chat dateOpened] dateWithCalendarFormat:nil timeZone:nil] ISO8601DateString], nil]];
474 [self closeAppenderForChat:chat];
476 [self markLogDirtyAtPath:[appender path] forChat:chat];
480 //Ugly method. Shouldn't this notification post an AIChat, not an AIChatLog?
481 - (void)chatWillDelete:(NSNotification *)notification
483 AIChatLog *chatLog = [notification object];
484 NSString *chatID = [NSString stringWithFormat:@"%@.%@-%@", [chatLog serviceClass], [chatLog from], [chatLog to]];
485 AIXMLAppender *appender = [activeAppenders objectForKey:chatID];
487 if (appender != nil) {
488 if ([[appender path] hasSuffix:[chatLog path]]) {
489 [activeAppenders removeObjectForKey:chatID];
490 [[appenderCloseTimers objectForKey:chatID] invalidate];
491 [appenderCloseTimers removeObjectForKey:chatID];
496 - (NSString *)keyForChat:(AIChat *)chat
498 AIAccount *account = [chat account];
499 NSString *chatID = [chat isGroupChat] ? [chat name] : [[chat listObject] UID];
501 return [NSString stringWithFormat:@"%@.%@-%@", [account serviceID], [account UID], chatID];
504 - (AIXMLAppender *)existingAppenderForChat:(AIChat *)chat
506 //Look up the key for this chat and use it to try to retrieve the appender
507 NSString *chatKey = [self keyForChat:chat];
508 return [activeAppenders objectForKey:chatKey];
511 - (AIXMLAppender *)appenderForChat:(AIChat *)chat
513 //Check if there is already an appender for this chat
514 AIXMLAppender *appender = [self existingAppenderForChat:chat];
515 NSString *chatKey = [self keyForChat:chat];
516 NSDate *chatDate = [chat dateOpened];
518 //If there's an appender scheduled to be closed for this chat, invalidate the timer, since we're using it now
519 if (appender && [appenderCloseTimers objectForKey:chatKey]) {
520 [[appenderCloseTimers objectForKey:chatKey] invalidate];
521 [appenderCloseTimers removeObjectForKey:chatKey];
522 //If there isn't already an appender, create a new one and add it to the dictionary
523 } else if (!appender) {
524 NSString *fullPath = [AILoggerPlugin fullPathForLogOfChat:chat onDate:chatDate];
526 appender = [AIXMLAppender documentWithPath:fullPath];
527 [appender initializeDocumentWithRootElementName:@"chat"
528 attributeKeys:[NSArray arrayWithObjects:@"xmlns", @"account", @"service", nil]
529 attributeValues:[NSArray arrayWithObjects:
530 XML_LOGGING_NAMESPACE,
531 [[chat account] UID],
532 [[chat account] serviceID],
535 //Add the window opened event now
536 [appender addElementWithName:@"event"
538 attributeKeys:[NSArray arrayWithObjects:@"type", @"sender", @"time", nil]
539 attributeValues:[NSArray arrayWithObjects:@"windowOpened", [[chat account] UID], [[chatDate dateWithCalendarFormat:nil timeZone:nil] ISO8601DateString], nil]];
541 [activeAppenders setObject:appender forKey:chatKey];
543 [self markLogDirtyAtPath:[appender path] forChat:chat];
549 - (void)closeAppenderForChat:(AIChat *)chat
551 //Create a new timer to fire after the timeout period, which will close the appender
552 NSString *chatKey = [self keyForChat:chat];
553 NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:NEW_LOGFILE_TIMEOUT
555 selector:@selector(finishClosingAppender:)
558 //Add it to the appenderCloseTimers dictionary
559 [appenderCloseTimers setObject:timer forKey:chatKey];
562 - (void)finishClosingAppender:(NSTimer *)timer
564 //Remove the appender, closing its file descriptor upon dealloc
565 [activeAppenders removeObjectForKey:[timer userInfo]];
566 //Remove the timer, it's invalid anyway and not very useful
567 [appenderCloseTimers removeObjectForKey:[timer userInfo]];
571 //Display a warning to the user that logging failed, and disable logging to prevent additional warnings
572 //XXX not currently used. We may want to shift these strings for use when xml logging fails, so I'm not removing them -eds
574 - (void)displayErrorAndDisableLogging
576 NSRunAlertPanel(AILocalizedString(@"Unable to write log", nil),
577 [NSString stringWithFormat:
578 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],
579 AILocalizedString(@"OK", nil), nil, nil);
582 [[adium preferenceController] setPreference:[NSNumber numberWithBool:NO]
583 forKey:KEY_LOGGER_ENABLE
584 group:PREF_GROUP_LOGGING];
588 #pragma mark Message History
590 NSCalendarDate* getDateFromPath(NSString *path)
592 NSRange openParenRange, closeParenRange;
594 if ([path hasSuffix:@".chatlog"] && (openParenRange = [path rangeOfString:@"(" options:NSBackwardsSearch]).location != NSNotFound) {
595 openParenRange = NSMakeRange(openParenRange.location, [path length] - openParenRange.location);
596 if ((closeParenRange = [path rangeOfString:@")" options:0 range:openParenRange]).location != NSNotFound) {
597 //Add and subtract one to remove the parenthesis
598 NSString *dateString = [path substringWithRange:NSMakeRange(openParenRange.location + 1, (closeParenRange.location - openParenRange.location))];
599 NSCalendarDate *date = [NSCalendarDate calendarDateWithString:dateString timeSeparator:'.'];
606 int sortPaths(NSString *path1, NSString *path2, void *context)
608 NSCalendarDate *date1 = getDateFromPath(path1);
609 NSCalendarDate *date2 = getDateFromPath(path2);
612 return NSOrderedSame;
613 else if (date1 && date2)
614 return [date2 compare:date1];
616 return date2 ? NSOrderedDescending : NSOrderedAscending;
619 + (NSArray *)sortedArrayOfLogFilesForChat:(AIChat *)chat
621 NSString *baseLogPath = [[self fullPathForLogOfChat:chat onDate:[NSDate date]] stringByDeletingLastPathComponent];
622 NSArray *files = [[NSFileManager defaultManager] directoryContentsAtPath:baseLogPath];
624 return [files sortedArrayUsingFunction:&sortPaths context:NULL];
629 #pragma mark Upgrade code
630 - (void)upgradeLogExtensions
632 if (![[[adium preferenceController] preferenceForKey:@"Log Extensions Updated" group:PREF_GROUP_LOGGING] boolValue]) {
633 /* This could all be a simple NSDirectoryEnumerator call on basePath, but we wouldn't be able to show progress,
634 * and this could take a bit.
636 NSFileManager *defaultManager = [NSFileManager defaultManager];
637 NSArray *accountFolders = [defaultManager directoryContentsAtPath:logBasePath];
638 NSEnumerator *accountFolderEnumerator = [accountFolders objectEnumerator];
639 NSString *accountFolderName;
641 NSMutableSet *pathsToContactFolders = [NSMutableSet set];
642 while ((accountFolderName = [accountFolderEnumerator nextObject])) {
643 NSString *contactBasePath = [logBasePath stringByAppendingPathComponent:accountFolderName];
644 NSArray *contactFolders = [defaultManager directoryContentsAtPath:contactBasePath];
646 NSEnumerator *contactFolderEnumerator = [contactFolders objectEnumerator];
647 NSString *contactFolderName;
649 while ((contactFolderName = [contactFolderEnumerator nextObject])) {
650 [pathsToContactFolders addObject:[contactBasePath stringByAppendingPathComponent:contactFolderName]];
654 unsigned contactsToProcess = [pathsToContactFolders count];
655 unsigned processed = 0;
657 if (contactsToProcess) {
658 AILogFileUpgradeWindowController *upgradeWindowController;
660 upgradeWindowController = [[AILogFileUpgradeWindowController alloc] initWithWindowNibName:@"LogFileUpgrade"];
661 [[upgradeWindowController window] makeKeyAndOrderFront:nil];
663 NSEnumerator *pathsToContactFoldersEnumerator = [pathsToContactFolders objectEnumerator];
664 NSString *pathToContactFolder;
665 while ((pathToContactFolder = [pathsToContactFoldersEnumerator nextObject])) {
666 NSDirectoryEnumerator *enumerator = [defaultManager enumeratorAtPath:pathToContactFolder];
669 while ((file = [enumerator nextObject])) {
670 if (([[file pathExtension] isEqualToString:@"html"]) ||
671 ([[file pathExtension] isEqualToString:@"adiumLog"]) ||
672 (([[file pathExtension] isEqualToString:@"bak"]) && ([file hasSuffix:@".html.bak"] ||
673 [file hasSuffix:@".adiumLog.bak"]))) {
674 NSString *fullFile = [pathToContactFolder stringByAppendingPathComponent:file];
675 NSString *newFile = [[fullFile stringByDeletingPathExtension] stringByAppendingPathExtension:@"AdiumHTMLLog"];
677 [defaultManager movePath:fullFile
684 [upgradeWindowController setProgress:(processed*100.0)/contactsToProcess];
687 [upgradeWindowController close];
688 [upgradeWindowController release];
691 [[adium preferenceController] setPreference:[NSNumber numberWithBool:YES]
692 forKey:@"Log Extensions Updated"
693 group:PREF_GROUP_LOGGING];
697 - (BOOL)fileManager:(NSFileManager *)manager shouldProceedAfterError:(NSDictionary *)errorInfo
699 NSLog(@"Error: %@",errorInfo);
704 //Log Indexing ---------------------------------------------------------------------------------------------------------
705 #pragma mark Log Indexing
706 /***** Everything below this point is related to log index generation and access ****/
708 /* For the log content searching, we are required to re-index a log whenever it changes. The solution below to
709 * this problem is along the lines of:
710 * - Keep an array of logs that need to be re-indexed
711 * - Whenever a log is changed, add it to this array
712 * - When the log viewer is opened, re-index all the logs in the array
716 * @brief Initialize log indexing
718 - (void)initLogIndexing
720 //Load the list of logs that need re-indexing
721 [self loadDirtyLogArray];
725 * @brief Prepare the log index for searching.
727 * Must call before attempting to use the logSearchIndex.
729 - (void)prepareLogContentSearching
731 /* Load the index and start indexing to make it current
732 * If we're going to need to re-index all our logs from scratch, it will make
733 * things faster if we start with a fresh log index as well.
735 if (!dirtyLogArray) {
736 [self resetLogIndex];
739 //Load the contentIndex immediately; this will clear dirtyLogArray if necessary
740 [self logContentIndex];
742 stopIndexingThreads = NO;
743 if (!dirtyLogArray) {
746 [self cleanDirtyLogs];
750 //Close down and clean up the log index (Call when finished using the logSearchIndex)
751 - (void)cleanUpLogContentSearching
753 [self stopIndexingThreads];
754 [self closeLogIndex];
757 //Returns the Search Kit index for log content searching
758 - (SKIndexRef)logContentIndex
760 SKIndexRef returnIndex;
762 [logAccessLock lock];
763 if (!index_Content) index_Content = [self createLogIndex];
764 returnIndex = (SKIndexRef)[[(NSObject *)index_Content retain] autorelease];
765 [logAccessLock unlock];
770 //Mark a log as needing a re-index
771 - (void)markLogDirtyAtPath:(NSString *)path forChat:(AIChat *)chat
773 NSString *dirtyKey = [@"LogIsDirty_" stringByAppendingString:path];
775 if (![chat integerStatusObjectForKey:dirtyKey]) {
776 //Add to dirty array (Lock to ensure that no one changes its content while we are)
779 if (!dirtyLogArray) dirtyLogArray = [[NSMutableArray alloc] init];
781 if (![dirtyLogArray containsObject:path]) {
782 [dirtyLogArray addObject:path];
785 [dirtyLogLock unlock];
787 //Save the dirty array immedientally
788 [self _saveDirtyLogArray];
790 //Flag the chat with 'LogIsDirty' for this filename. On the next message we can quickly check this flag.
791 [chat setStatusObject:[NSNumber numberWithBool:YES]
797 - (void)markLogDirtyAtPath:(NSString *)path
801 if (!dirtyLogArray) dirtyLogArray = [[NSMutableArray alloc] init];
803 if (![dirtyLogArray containsObject:path]) {
804 [dirtyLogArray addObject:path];
806 [dirtyLogLock unlock];
809 //Get the current status of indexing. Returns NO if indexing is not occuring
810 - (BOOL)getIndexingProgress:(int *)indexNumber outOf:(int *)total
812 //logsIndexed + 1 is the log we are currently indexing
813 if (indexNumber) *indexNumber = (logsIndexed + 1 <= logsToIndex) ? logsIndexed + 1 : logsToIndex;
814 if (total) *total = logsToIndex;
815 return (logsToIndex > 0);
819 //Log index ------------------------------------------------------------------------------------------------------------
820 //Search kit index used to searching log content
821 #pragma mark Log Index
823 * @brief Create the log index
825 * Should be called within logAccessLock being locked
827 - (SKIndexRef)createLogIndex
829 NSString *logIndexPath = [self _logIndexPath];
830 NSURL *logIndexPathURL = [NSURL fileURLWithPath:logIndexPath];
831 SKIndexRef newIndex = NULL;
833 if ([[NSFileManager defaultManager] fileExistsAtPath:logIndexPath]) {
834 newIndex = SKIndexOpenWithURL((CFURLRef)logIndexPathURL, (CFStringRef)@"Content", true);
835 AILog(@"Opened index %x from %@",newIndex,logIndexPathURL);
838 NSDictionary *textAnalysisProperties;
840 if ([NSApp isOnTigerOrBetter]) {
841 textAnalysisProperties = [NSDictionary dictionaryWithObjectsAndKeys:
842 [NSNumber numberWithInt:0], kSKMaximumTerms,
843 #if ENABLE_PROXIMITY_SEARCH
844 kCFBooleanTrue, kSKProximityIndexing,
849 textAnalysisProperties = nil;
852 //Create the index if one doesn't exist
853 [[NSFileManager defaultManager] createDirectoriesForPath:[logIndexPath stringByDeletingLastPathComponent]];
855 newIndex = SKIndexCreateWithURL((CFURLRef)logIndexPathURL,
856 (CFStringRef)@"Content",
858 (CFDictionaryRef)textAnalysisProperties);
859 AILog(@"Created a new log index %x at %@ with textAnalysisProperties %@",newIndex,logIndexPathURL,textAnalysisProperties);
860 //Clear the dirty log array in case it was loaded (this can happen if the user mucks with the cache directory)
861 [[NSFileManager defaultManager] removeFileAtPath:[self _dirtyLogArrayPath] handler:NULL];
862 [dirtyLogArray release]; dirtyLogArray = nil;
868 - (void)releaseIndex:(SKIndexRef)inIndex
870 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
875 //Close the log index
876 - (void)closeLogIndex
878 [logAccessLock lock];
880 [NSThread detachNewThreadSelector:@selector(releaseIndex:)
882 withObject:(id)index_Content];
885 [logAccessLock unlock];
888 //Delete the log index
889 - (void)resetLogIndex
891 if ([[NSFileManager defaultManager] fileExistsAtPath:[self _logIndexPath]]) {
892 [[NSFileManager defaultManager] removeFileAtPath:[self _logIndexPath] handler:NULL];
895 if ([[NSFileManager defaultManager] fileExistsAtPath:[self _dirtyLogArrayPath]]) {
896 [[NSFileManager defaultManager] removeFileAtPath:[self _dirtyLogArrayPath] handler:NULL];
900 //Path of log index file
901 - (NSString *)_logIndexPath
903 return [[adium cachesPath] stringByAppendingPathComponent:LOG_INDEX_NAME];
907 //Dirty Log Array ------------------------------------------------------------------------------------------------------
908 //Stores the absolute paths of logs that need to be re-indexed
909 #pragma mark Dirty Log Array
910 //Load the dirty log array
911 - (void)loadDirtyLogArray
913 if (!dirtyLogArray) {
914 int logVersion = [[[adium preferenceController] preferenceForKey:KEY_LOG_INDEX_VERSION
915 group:PREF_GROUP_LOGGING] intValue];
917 //If the log version has changed, we reset the index and don't load the dirty array (So all the logs are marked dirty)
918 if (logVersion >= CURRENT_LOG_VERSION) {
920 dirtyLogArray = [[NSMutableArray alloc] initWithContentsOfFile:[self _dirtyLogArrayPath]];
921 [dirtyLogLock unlock];
923 [self resetLogIndex];
924 [[adium preferenceController] setPreference:[NSNumber numberWithInt:CURRENT_LOG_VERSION]
925 forKey:KEY_LOG_INDEX_VERSION
926 group:PREF_GROUP_LOGGING];
931 //Save the dirty lod array
932 - (void)_saveDirtyLogArray
934 if (dirtyLogArray && !suspendDirtyArraySave) {
936 [dirtyLogArray writeToFile:[self _dirtyLogArrayPath] atomically:NO];
937 [dirtyLogLock unlock];
941 //Path of the dirty log array file
942 - (NSString *)_dirtyLogArrayPath
944 return [[adium cachesPath] stringByAppendingPathComponent:DIRTY_LOG_ARRAY_NAME];
948 //Threaded Indexing ----------------------------------------------------------------------------------------------------
949 #pragma mark Threaded Indexing
950 //Stop any indexing related threads
951 - (void)stopIndexingThreads
953 //Let any indexing threads know it's time to stop, and wait for them to finish.
954 stopIndexingThreads = YES;
957 //The following methods will be run in a separate thread to avoid blocking the interface during index operations
958 //THREAD: Flag every log as dirty (Do this when there is no log index)
961 //Reset and rebuild the dirty array
962 [dirtyLogArray release]; dirtyLogArray = [[NSMutableArray alloc] init];
963 //[self _dirtyAllLogsThread];
964 [NSThread detachNewThreadSelector:@selector(_dirtyAllLogsThread) toTarget:self withObject:nil];
966 - (void)_dirtyAllLogsThread
968 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
969 NSEnumerator *fromEnumerator, *toEnumerator, *logEnumerator;
971 AILogFromGroup *fromGroup = nil;
972 AILogToGroup *toGroup;
975 [indexingThreadLock lock];
976 suspendDirtyArraySave = YES; //Prevent saving of the dirty array until we're finished building it
978 //Create a fresh dirty log array
980 [dirtyLogArray release]; dirtyLogArray = [[NSMutableArray alloc] init];
981 [dirtyLogLock unlock];
983 //Process each from folder
984 fromEnumerator = [[[[NSFileManager defaultManager] directoryContentsAtPath:logBasePath] objectEnumerator] retain];
985 while ((fromName = [[fromEnumerator nextObject] retain])) {
986 fromGroup = [[AILogFromGroup alloc] initWithPath:fromName fromUID:fromName serviceClass:nil];
988 //Walk through every 'to' group
989 toEnumerator = [[[fromGroup toGroupArray] objectEnumerator] retain];
990 while (!stopIndexingThreads && (toGroup = [[toEnumerator nextObject] retain])) {
991 //Walk through every log
992 logEnumerator = [toGroup logEnumerator];
993 while ((theLog = [logEnumerator nextObject]) && !stopIndexingThreads) {
994 //Add this log's path to our dirty array. The dirty array is guarded with a lock
995 //since it will be accessed from outside this thread as well
998 [dirtyLogArray addObject:[logBasePath stringByAppendingPathComponent:[theLog path]]];
1000 [dirtyLogLock unlock];
1004 [pool release]; pool = [[NSAutoreleasePool alloc] init];
1008 [toEnumerator release];
1010 [fromGroup release];
1013 [fromEnumerator release];
1015 //Save the dirty array we just built
1016 if (!stopIndexingThreads) {
1017 [self _saveDirtyLogArray];
1018 suspendDirtyArraySave = NO; //Re-allow saving of the dirty array
1021 //Begin cleaning the logs (If the log viewer is open)
1022 if ([LogViewerWindowControllerClass existingWindowController]) {
1023 [self cleanDirtyLogs];
1026 [indexingThreadLock unlock];
1031 * @brief Index all dirty logs
1033 * Indexing will occur on a thread
1035 - (void)cleanDirtyLogs
1037 //Reset the cleaning progress
1038 [dirtyLogLock lock];
1039 logsToIndex = [dirtyLogArray count];
1040 [dirtyLogLock unlock];
1043 [NSThread detachNewThreadSelector:@selector(_cleanDirtyLogsThread:) toTarget:self withObject:(id)[self logContentIndex]];
1046 - (void)didCleanDirtyLogs
1048 //Update our progress
1049 if (!stopIndexingThreads) {
1051 [[LogViewerWindowControllerClass existingWindowController] logIndexingProgressUpdate];
1054 //Clear the dirty status of all open chats so they will be marked dirty if they receive another message
1055 NSEnumerator *enumerator = [[[adium chatController] openChats] objectEnumerator];
1058 while ((chat = [enumerator nextObject])) {
1059 NSString *existingAppenderPath = [[self existingAppenderForChat:chat] path];
1060 if (existingAppenderPath) {
1061 NSString *dirtyKey = [@"LogIsDirty_" stringByAppendingString:existingAppenderPath];
1063 if ([chat integerStatusObjectForKey:dirtyKey]) {
1064 [chat setStatusObject:nil
1066 notify:NotifyNever];
1072 - (void)_cleanDirtyLogsThread:(SKIndexRef)searchIndex
1074 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
1076 //Ensure log indexing (in an old thread) isn't already going on and just waiting to stop
1077 [indexingThreadLock lock]; [indexingThreadLock unlock];
1079 [indexingThreadLock lock];
1081 //Start cleaning (If we're still supposed to go)
1082 if (!stopIndexingThreads) {
1083 UInt32 lastUpdate = TickCount();
1084 int unsavedChanges = 0;
1086 AILog(@"Cleaning %i dirty logs", [dirtyLogArray count]);
1088 //Scan until we're done or told to stop
1089 while (!stopIndexingThreads) {
1090 NSString *logPath = nil;
1092 //Get the next dirty log
1093 [dirtyLogLock lock];
1094 if ([dirtyLogArray count]) {
1095 logPath = [[[dirtyLogArray lastObject] retain] autorelease]; //retain to prevent deallocation when removing from the array
1096 [dirtyLogArray removeLastObject];
1098 [dirtyLogLock unlock];
1101 SKDocumentRef document;
1103 document = SKDocumentCreateWithURL((CFURLRef)[NSURL fileURLWithPath:logPath]);
1105 /* We _could_ use SKIndexAddDocument() and depend on our Spotlight plugin for importing.
1106 * However, this has three problems:
1107 * 1. Slower, especially to start initial indexing, which is the most common use case since the log viewer
1108 * indexes recently-modified ("dirty") logs when it opens.
1109 * 2. Sometimes logs don't appear to be associated with the right URI type and therefore don't get indexed.
1110 * 3. On 10.3, this means that logs' markup is indexed in addition to their text, which is undesireable.
1112 CFStringRef documentText = CopyTextContentForFile(NULL, (CFStringRef)logPath);
1114 SKIndexAddDocumentWithText(searchIndex,
1118 CFRelease(documentText);
1120 CFRelease(document);
1122 NSLog(@"Could not create document for %@ [%@]",logPath,[NSURL fileURLWithPath:logPath]);
1125 //Update our progress
1127 if (lastUpdate == 0 || TickCount() > lastUpdate + LOG_INDEX_STATUS_INTERVAL) {
1128 [[LogViewerWindowControllerClass existingWindowController]
1129 performSelectorOnMainThread:@selector(logIndexingProgressUpdate)
1132 lastUpdate = TickCount();
1135 //Save the dirty array
1136 if (unsavedChanges++ > LOG_CLEAN_SAVE_INTERVAL) {
1137 [self _saveDirtyLogArray];
1142 [pool release]; pool = [[NSAutoreleasePool alloc] init];
1146 break; //Exit when we run out of logs
1150 //Save the slimmed down dirty log array
1151 if (unsavedChanges) {
1152 [self _saveDirtyLogArray];
1155 SKIndexFlush(searchIndex);
1157 [self performSelectorOnMainThread:@selector(didCleanDirtyLogs)
1162 [indexingThreadLock unlock];
1167 - (NSLock *)logAccessLock
1169 return logAccessLock;
1172 - (void)_removePathsFromIndexThread:(NSDictionary *)userInfo
1174 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
1176 [indexingThreadLock lock];
1178 SKIndexRef logSearchIndex = (SKIndexRef)[userInfo objectForKey:@"SKIndexRef"];
1179 NSEnumerator *enumerator = [[userInfo objectForKey:@"Paths"] objectEnumerator];
1182 while ((logPath = [enumerator nextObject])) {
1183 SKDocumentRef document = SKDocumentCreateWithURL((CFURLRef)[NSURL fileURLWithPath:logPath]);
1185 SKIndexRemoveDocument(logSearchIndex, document);
1186 CFRelease(document);
1190 [indexingThreadLock unlock];
1195 - (void)removePathsFromIndex:(NSSet *)paths
1197 [NSThread detachNewThreadSelector:@selector(_removePathsFromIndexThread:)
1199 withObject:[NSDictionary dictionaryWithObjectsAndKeys:
1200 (id)[self logContentIndex], @"SKIndexRef",