2 // AIAbstractLogViewerWindowController.m
5 // Created by Evan Schoenberg on 3/24/06.
8 #import "AIAbstractLogViewerWindowController.h"
10 #import "AILogFromGroup.h"
11 #import "AILogToGroup.h"
12 #import "AILoggerPlugin.h"
13 #import "ESRankingCell.h"
14 #import "GBChatlogHTMLConverter.h"
15 #import "AILogDateFormatter.h"
17 #import <Adium/AIAccountControllerProtocol.h>
18 #import <Adium/AIPreferenceControllerProtocol.h>
19 #import <Adium/AIChatControllerProtocol.h>
20 #import <Adium/AIContactControllerProtocol.h>
21 #import <Adium/AIContentControllerProtocol.h>
22 #import <Adium/AIMenuControllerProtocol.h>
23 #import <Adium/AIHTMLDecoder.h>
24 #import <Adium/AIInterfaceControllerProtocol.h>
25 #import <Adium/AIListContact.h>
26 #import <Adium/AIMetaContact.h>
27 #import <Adium/AIServiceIcons.h>
28 #import <Adium/AIUserIcons.h>
29 #import <Adium/KFTypeSelectTableView.h>
30 #import <Adium/KNShelfSplitView.h>
31 #import <AIUtilities/AIArrayAdditions.h>
32 #import <AIUtilities/AIAttributedStringAdditions.h>
33 #import <AIUtilities/AIDateFormatterAdditions.h>
34 #import <AIUtilities/AIFileManagerAdditions.h>
35 #import <AIUtilities/AIImageAdditions.h>
36 #import <AIUtilities/AIImageTextCell.h>
37 #import <AIUtilities/AIOutlineViewAdditions.h>
38 #import <AIUtilities/AISplitView.h>
39 #import <AIUtilities/AIStringAdditions.h>
40 #import <AIUtilities/AITableViewAdditions.h>
41 #import <AIUtilities/AITextAttributes.h>
42 #import <AIUtilities/AIToolbarUtilities.h>
43 #import <AIUtilities/AIApplicationAdditions.h>
44 #import <AIUtilities/AIDividedAlternatingRowOutlineView.h>
46 #define KEY_LOG_VIEWER_WINDOW_FRAME @"Log Viewer Frame"
47 #define PREF_GROUP_CONTACT_LIST @"Contact List"
48 #define KEY_LOG_VIEWER_GROUP_STATE @"Log Viewer Group State" //Expand/Collapse state of groups
49 #define TOOLBAR_LOG_VIEWER @"Log Viewer Toolbar"
51 #define MAX_LOGS_TO_SORT_WHILE_SEARCHING 10000 //Max number of logs we will live sort while searching
52 #define LOG_SEARCH_STATUS_INTERVAL 20 //1/60ths of a second to wait before refreshing search status
54 #define SEARCH_MENU AILocalizedString(@"Search Menu",nil)
55 #define FROM AILocalizedString(@"From",nil)
56 #define TO AILocalizedString(@"To",nil)
57 #define DATE AILocalizedString(@"Date",nil)
58 #define CONTENT AILocalizedString(@"Content",nil)
59 #define DELETE AILocalizedString(@"Delete",nil)
60 #define DELETEALL AILocalizedString(@"Delete All",nil)
61 #define SEARCH AILocalizedString(@"Search",nil)
63 #define HIDE_EMOTICONS AILocalizedString(@"Hide Emoticons",nil)
64 #define SHOW_EMOTICONS AILocalizedString(@"Show Emoticons",nil)
66 #define IMAGE_EMOTICONS_OFF @"emoticon32"
67 #define IMAGE_EMOTICONS_ON @"emoticon32_transparent"
69 #define REFRESH_RESULTS_INTERVAL 1.0 //Interval between results refreshes while searching
71 @interface AIAbstractLogViewerWindowController (PRIVATE)
72 - (id)initWithWindowNibName:(NSString *)windowNibName plugin:(id)inPlugin;
73 - (void)initLogFiltering;
74 - (void)displayLog:(AIChatLog *)log;
75 - (void)hilightOccurrencesOfString:(NSString *)littleString inString:(NSMutableAttributedString *)bigString firstOccurrence:(NSRange *)outRange;
76 - (void)sortCurrentSearchResultsForTableColumn:(NSTableColumn *)tableColumn direction:(BOOL)direction;
77 - (void)startSearchingClearingCurrentResults:(BOOL)clearCurrentResults;
78 - (void)buildSearchMenu;
79 - (NSMenuItem *)_menuItemWithTitle:(NSString *)title forSearchMode:(LogSearchMode)mode;
80 - (void)_logContentFilter:(NSString *)searchString searchID:(int)searchID onSearchIndex:(SKIndexRef)logSearchIndex;
81 - (void)_logFilter:(NSString *)searchString searchID:(int)searchID mode:(LogSearchMode)mode;
82 - (void)installToolbar;
83 - (void)updateRankColumnVisibility;
84 - (void)openLogAtPath:(NSString *)inPath;
85 - (void)rebuildContactsList;
86 - (void)filterForContact:(AIListContact *)inContact;
87 - (void)selectCachedIndex;
89 - (void)_willOpenForContact;
90 - (void)_didOpenForContact;
92 - (void)deleteSelection:(id)sender;
95 @implementation AIAbstractLogViewerWindowController
97 static AIAbstractLogViewerWindowController *sharedLogViewerInstance = nil;
98 static int toArraySort(id itemA, id itemB, void *context);
100 + (NSString *)nibName
105 + (id)openForPlugin:(id)inPlugin
107 if (!sharedLogViewerInstance) {
108 sharedLogViewerInstance = [[self alloc] initWithWindowNibName:[self nibName] plugin:inPlugin];
111 [sharedLogViewerInstance showWindow:nil];
113 return sharedLogViewerInstance;
116 + (id)openLogAtPath:(NSString *)inPath plugin:(id)inPlugin
118 [self openForPlugin:inPlugin];
120 [sharedLogViewerInstance openLogAtPath:inPath];
122 return sharedLogViewerInstance;
125 //Open the log viewer window to a specific contact's logs
126 + (id)openForContact:(AIListContact *)inContact plugin:(id)inPlugin
128 if (!sharedLogViewerInstance) {
129 sharedLogViewerInstance = [[self alloc] initWithWindowNibName:[self nibName] plugin:inPlugin];
132 [sharedLogViewerInstance _willOpenForContact];
133 [sharedLogViewerInstance showWindow:nil];
134 [sharedLogViewerInstance filterForContact:inContact];
135 [sharedLogViewerInstance _didOpenForContact];
137 return sharedLogViewerInstance;
140 //Returns the window controller if one exists
141 + (id)existingWindowController
143 return sharedLogViewerInstance;
146 //Close the log viewer window
147 + (void)closeSharedInstance
149 if (sharedLogViewerInstance) {
150 [sharedLogViewerInstance closeWindow:nil];
155 - (id)initWithWindowNibName:(NSString *)windowNibName plugin:(id)inPlugin
159 selectedColumn = nil;
162 automaticSearch = YES;
164 activeSearchString = nil;
165 displayedLogArray = nil;
166 windowIsClosing = NO;
167 desiredContactsSourceListDeltaX = 0;
169 blankImage = [[NSImage alloc] initWithSize:NSMakeSize(16,16)];
172 searchMode = LOG_SEARCH_CONTENT;
173 headerDateFormatter = [[NSDateFormatter alloc] initWithDateFormat:[[NSUserDefaults standardUserDefaults] stringForKey:NSDateFormatString]
174 allowNaturalLanguage:NO];
175 currentSearchResults = [[NSMutableArray alloc] init];
176 fromArray = [[NSMutableArray alloc] init];
177 fromServiceArray = [[NSMutableArray alloc] init];
178 logFromGroupDict = [[NSMutableDictionary alloc] init];
179 toArray = [[NSMutableArray alloc] init];
180 toServiceArray = [[NSMutableArray alloc] init];
181 logToGroupDict = [[NSMutableDictionary alloc] init];
182 resultsLock = [[NSRecursiveLock alloc] init];
183 searchingLock = [[NSLock alloc] init];
184 contactIDsToFilter = [[NSMutableSet alloc] initWithCapacity:1];
186 allContactsIdentifier = [[NSNumber alloc] initWithInt:-1];
188 undoManager = [[NSUndoManager alloc] init];
190 [super initWithWindowNibName:windowNibName];
198 [resultsLock release];
199 [searchingLock release];
201 [fromServiceArray release];
203 [toServiceArray release];
204 [currentSearchResults release];
205 [selectedColumn release];
206 [headerDateFormatter release];
207 [displayedLogArray release];
208 [blankImage release];
209 [activeSearchString release];
210 [contactIDsToFilter release];
212 [logFromGroupDict release]; logFromGroupDict = nil;
213 [logToGroupDict release]; logToGroupDict = nil;
215 [filterForAccountName release]; filterForAccountName = nil;
217 [horizontalRule release]; horizontalRule = nil;
219 [adiumIcon release]; adiumIcon = nil;
220 [adiumIconHighlighted release]; adiumIconHighlighted = nil;
222 //We loaded view_DatePicker from a nib manually, so we must release it
223 [view_DatePicker release]; view_DatePicker = nil;
225 [allContactsIdentifier release];
226 [undoManager release]; undoManager = nil;
231 //Init our log filtering tree
232 - (void)initLogFiltering
234 NSEnumerator *enumerator;
235 NSString *folderName;
236 NSMutableDictionary *toDict = [NSMutableDictionary dictionary];
237 NSString *basePath = [AILoggerPlugin logBasePath];
238 NSString *fromUID, *serviceClass;
240 //Process each account folder (/Logs/SERVICE.ACCOUNT_NAME/) - sorting by compare: will result in an ordered list
241 //first by service, then by account name.
242 enumerator = [[[[NSFileManager defaultManager] directoryContentsAtPath:basePath] sortedArrayUsingSelector:@selector(compare:)] objectEnumerator];
243 while ((folderName = [enumerator nextObject])) {
244 if (![folderName isEqualToString:@".DS_Store"]) { // avoid the directory info
245 NSEnumerator *toEnum;
246 AILogToGroup *currentToGroup;
247 AILogFromGroup *logFromGroup;
248 NSMutableSet *toSetForThisService;
249 NSArray *serviceAndFromUIDArray;
251 /* Determine the service and fromUID - should be SERVICE.ACCOUNT_NAME
252 * Check against count to guard in case of old, malformed or otherwise odd folders & whatnot sitting in log base
254 serviceAndFromUIDArray = [folderName componentsSeparatedByString:@"."];
256 if ([serviceAndFromUIDArray count] >= 2) {
257 serviceClass = [serviceAndFromUIDArray objectAtIndex:0];
259 //Use substringFromIndex so we include the rest of the string in the case of a UID with a . in it
260 fromUID = [folderName substringFromIndex:([serviceClass length] + 1)]; //One off for the '.'
262 //Fallback: blank non-nil serviceClass; folderName as the fromUID
264 fromUID = folderName;
267 logFromGroup = [[AILogFromGroup alloc] initWithPath:folderName fromUID:fromUID serviceClass:serviceClass];
269 //Store logFromGroup on a key in the form "SERVICE.ACCOUNT_NAME"
270 [logFromGroupDict setObject:logFromGroup forKey:folderName];
273 if (!(toSetForThisService = [toDict objectForKey:serviceClass])) {
274 toSetForThisService = [NSMutableSet set];
275 [toDict setObject:toSetForThisService
276 forKey:serviceClass];
279 //Add the 'to' for each grouping on this account
280 toEnum = [[logFromGroup toGroupArray] objectEnumerator];
281 while ((currentToGroup = [toEnum nextObject])) {
284 if ((currentTo = [currentToGroup to])) {
285 //Store currentToGroup on a key in the form "SERVICE.ACCOUNT_NAME/TARGET_CONTACT"
286 [logToGroupDict setObject:currentToGroup forKey:[currentToGroup path]];
290 [logFromGroup release];
294 [self rebuildContactsList];
297 - (void)rebuildContactsList
299 NSEnumerator *enumerator = [logFromGroupDict objectEnumerator];
300 AILogFromGroup *logFromGroup;
302 int oldCount = [toArray count];
303 [toArray release]; toArray = [[NSMutableArray alloc] initWithCapacity:(oldCount ? oldCount : 20)];
305 while ((logFromGroup = [enumerator nextObject])) {
306 NSEnumerator *toEnum;
307 AILogToGroup *currentToGroup;
308 NSString *serviceClass = [logFromGroup serviceClass];
310 //Add the 'to' for each grouping on this account
311 toEnum = [[logFromGroup toGroupArray] objectEnumerator];
312 while ((currentToGroup = [toEnum nextObject])) {
315 if ((currentTo = [currentToGroup to])) {
316 AIListObject *listObject = ((serviceClass && currentTo) ?
317 [[adium contactController] existingListObjectWithUniqueID:[AIListObject internalObjectIDForServiceID:serviceClass
320 if (listObject && [listObject isKindOfClass:[AIListContact class]]) {
321 AIListContact *parentContact = [(AIListContact *)listObject parentContact];
322 if (![toArray containsObjectIdenticalTo:parentContact]) {
323 [toArray addObject:parentContact];
327 if (![toArray containsObject:currentToGroup]) {
328 [toArray addObject:currentToGroup];
335 [toArray sortUsingFunction:toArraySort context:NULL];
336 [outlineView_contacts reloadData];
338 if (!isOpeningForContact) {
339 //If we're opening for a contact, the outline view selection will be changed in a moment anyways
340 [self outlineViewSelectionDidChange:nil];
345 - (NSString *)adiumFrameAutosaveName
347 return KEY_LOG_VIEWER_WINDOW_FRAME;
350 //Setup the window before it is displayed
351 - (void)windowDidLoad
353 suppressSearchRequests = YES;
355 [super windowDidLoad];
357 [plugin pauseIndexing];
359 [[self window] setTitle:AILocalizedString(@"Chat Transcript Viewer",nil)];
360 [textField_progress setStringValue:@""];
362 //Autosave doesn't do anything yet
363 [shelf_splitView setAutosaveName:@"LogViewer:Shelf"];
364 [shelf_splitView setFrame:[[[self window] contentView] frame]];
366 // Pull our main article/display split view out of the nib and position it in the shelf view
367 [containingView_results retain];
368 [containingView_results removeFromSuperview];
369 [shelf_splitView setContentView:containingView_results];
370 [containingView_results release];
371 [tableView_results accessibilitySetOverrideValue:AILocalizedString(@"Transcripts", nil)
372 forAttribute:NSAccessibilityRoleDescriptionAttribute];
374 // Pull our source view out of the nib and position it in the shelf view
375 [containingView_contactsSourceList retain];
376 [containingView_contactsSourceList removeFromSuperview];
377 [shelf_splitView setShelfView:containingView_contactsSourceList];
378 [outlineView_contacts accessibilitySetOverrideValue:AILocalizedString(@"Contacts", nil)
379 forAttribute:NSAccessibilityRoleDescriptionAttribute];
380 [containingView_contactsSourceList release];
382 //Set emoticon filtering
383 showEmoticons = [[[adium preferenceController] preferenceForKey:KEY_LOG_VIEWER_EMOTICONS
384 group:PREF_GROUP_LOGGING] boolValue];
385 [[toolbarItems objectForKey:@"toggleemoticons"] setLabel:(showEmoticons ? HIDE_EMOTICONS : SHOW_EMOTICONS)];
386 [[toolbarItems objectForKey:@"toggleemoticons"] setImage:[NSImage imageNamed:(showEmoticons ? IMAGE_EMOTICONS_ON : IMAGE_EMOTICONS_OFF) forClass:[self class]]];
389 [self installToolbar];
391 //This is the Mail.app source list background color... which differs from the iTunes one.
392 [outlineView_contacts setBackgroundColor:[NSColor colorWithCalibratedRed:.9059
397 AIImageTextCell *dataCell = [[AIImageTextCell alloc] init];
398 NSTableColumn *tableColumn = [[outlineView_contacts tableColumns] objectAtIndex:0];
399 [tableColumn setDataCell:dataCell];
400 [tableColumn setEditable:NO];
401 [dataCell setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]];
404 [outlineView_contacts setDrawsGradientSelection:YES];
405 // Set the selector for doubleAction
406 [outlineView_contacts setDoubleAction:@selector(openChatOnDoubleAction:)];
408 //Localize tableView_results column headers
409 [[[tableView_results tableColumnWithIdentifier:@"To"] headerCell] setStringValue:TO];
410 [[[tableView_results tableColumnWithIdentifier:@"From"] headerCell] setStringValue:FROM];
411 [[[tableView_results tableColumnWithIdentifier:@"Date"] headerCell] setStringValue:DATE];
412 [self tableViewColumnDidResize:nil];
414 [tableView_results sizeLastColumnToFit];
416 //Prepare the search controls
417 [self buildSearchMenu];
418 if ([textView_content respondsToSelector:@selector(setUsesFindPanel:)]) {
419 [textView_content setUsesFindPanel:YES];
422 //Sort by preference, defaulting to sorting by date
423 NSString *selectedTableColumnPref;
424 if ((selectedTableColumnPref = [[adium preferenceController] preferenceForKey:KEY_LOG_VIEWER_SELECTED_COLUMN
425 group:PREF_GROUP_LOGGING])) {
426 selectedColumn = [[tableView_results tableColumnWithIdentifier:selectedTableColumnPref] retain];
428 if (!selectedColumn) {
429 selectedColumn = [[tableView_results tableColumnWithIdentifier:@"Date"] retain];
431 [self sortCurrentSearchResultsForTableColumn:selectedColumn direction:YES];
433 //Prepare indexing and filter searching
434 [plugin prepareLogContentSearching];
435 [self initLogFiltering];
437 //Begin our initial search
438 [self setSearchMode:LOG_SEARCH_TO];
440 [searchField_logs setStringValue:(activeSearchString ? activeSearchString : @"")];
441 suppressSearchRequests = NO;
443 if (!isOpeningForContact) {
444 //If we're opening for a contact, we'll select it and then begin searching
445 [self startSearchingClearingCurrentResults:YES];
448 [plugin resumeIndexing];
451 -(void)rebuildIndices
453 //Rebuild the 'global' log indexes
454 [logFromGroupDict release]; logFromGroupDict = [[NSMutableDictionary alloc] init];
455 [toArray removeAllObjects]; //note: even if there are no logs, the name will remain [bug or feature?]
456 [toServiceArray removeAllObjects];
457 [fromArray removeAllObjects];
458 [fromServiceArray removeAllObjects];
460 [self initLogFiltering];
462 [tableView_results reloadData];
463 [self selectDisplayedLog];
466 //Called as the window closes
467 - (void)windowWillClose:(id)sender
469 [super windowWillClose:sender];
471 //Set preference for emoticon filtering
472 [[adium preferenceController] setPreference:[NSNumber numberWithBool:showEmoticons]
473 forKey:KEY_LOG_VIEWER_EMOTICONS
474 group:PREF_GROUP_LOGGING];
476 //Set preference for selected column
477 [[adium preferenceController] setPreference:[selectedColumn identifier]
478 forKey:KEY_LOG_VIEWER_SELECTED_COLUMN
479 group:PREF_GROUP_LOGGING];
481 /* Disable the search field. If we don't disable the search field, it will often try to call its target action
482 * after the window has closed (and we are gone). I'm not sure why this happens, but disabling the field
483 * before we close the window down seems to prevent the crash.
485 [searchField_logs setEnabled:NO];
487 /* Note that the window is closing so we don't take behaviors which could cause messages to the window after
488 * it was gone, like responding to a logIndexUpdated message
490 windowIsClosing = YES;
492 //Abort any in-progress searching and indexing, and wait for their completion
493 [self stopSearching];
494 [plugin cleanUpLogContentSearching];
496 //Reset our column widths if needed
497 [activeSearchString release]; activeSearchString = nil;
498 [self updateRankColumnVisibility];
500 [sharedLogViewerInstance autorelease]; sharedLogViewerInstance = nil;
501 [toolbarItems autorelease]; toolbarItems = nil;
504 //Display --------------------------------------------------------------------------------------------------------------
506 //Update log viewer progress string to reflect current status
507 - (void)updateProgressDisplay
509 NSMutableString *progress = nil;
510 int indexNumber, indexTotal;
513 //We always convey the number of logs being displayed
515 unsigned count = [currentSearchResults count];
516 if (activeSearchString && [activeSearchString length]) {
517 [shelf_splitView setResizeThumbStringValue:[NSString stringWithFormat:((count != 1) ?
518 AILocalizedString(@"%i matching transcripts",nil) :
519 AILocalizedString(@"1 matching transcript",nil)),count]];
521 [shelf_splitView setResizeThumbStringValue:[NSString stringWithFormat:((count != 1) ?
522 AILocalizedString(@"%i transcripts",nil) :
523 AILocalizedString(@"1 transcript",nil)),count]];
525 //We are searching, but there is no active search string. This indicates we're still opening logs.
527 progress = [[AILocalizedString(@"Opening transcripts",nil) mutableCopy] autorelease];
530 [resultsLock unlock];
532 indexing = [plugin getIndexingProgress:&indexNumber outOf:&indexTotal];
534 //Append search progress
535 if (activeSearchString && [activeSearchString length]) {
537 [progress appendString:@" - "];
539 progress = [NSMutableString string];
542 if (searching || indexing) {
543 [progress appendString:[NSString stringWithFormat:AILocalizedString(@"Searching for '%@'",nil),activeSearchString]];
545 [progress appendString:[NSString stringWithFormat:AILocalizedString(@"Search for '%@' complete.",nil),activeSearchString]];
549 //Append indexing progress
552 [progress appendString:@" - "];
554 progress = [NSMutableString string];
557 [progress appendString:[NSString stringWithFormat:AILocalizedString(@"Indexing %i of %i transcripts",nil), indexNumber, indexTotal]];
560 if (progress && (searching || indexing || !(activeSearchString && [activeSearchString length]))) {
561 [progress appendString:[NSString ellipsis]];
564 //Enable/disable the searching animation
565 if (searching || indexing) {
566 [progressIndicator startAnimation:nil];
568 [progressIndicator stopAnimation:nil];
571 [textField_progress setStringValue:(progress ? progress : @"")];
574 //The plugin is informing us that the log indexing changed
575 - (void)logIndexingProgressUpdate
577 //Don't do anything if the window is already closing
578 if (!windowIsClosing) {
579 [self updateProgressDisplay];
581 //If we are searching by content, we should re-search without clearing our current results so the
582 //the newly-indexed logs can be added without blanking the current table contents.
583 if (searchMode == LOG_SEARCH_CONTENT && (activeSearchString && [activeSearchString length])) {
585 //We're already searching; reattempt when done
586 searchIDToReattemptWhenComplete = activeSearchID;
588 //We're not searching - restart the search immediately every 10 updates to utilize the newly indexed logs
589 indexingUpdatesReceivedWhileSearching++;
590 if ((indexingUpdatesReceivedWhileSearching % 10) == 0)
591 [self startSearchingClearingCurrentResults:NO];
597 //Refresh the results table
598 - (void)refreshResults
600 [self updateProgressDisplay];
602 [self refreshResultsSearchIsComplete:NO];
605 - (void)refreshResultsSearchIsComplete:(BOOL)searchIsComplete
608 int count = [currentSearchResults count];
609 [resultsLock unlock];
610 AILog(@"refreshResultsSearchIsComplete: %i (count is %i)",searchIsComplete,count);
611 if (!searching || count <= MAX_LOGS_TO_SORT_WHILE_SEARCHING) {
612 //Sort the logs correctly which will also reload the table
615 if (searchIsComplete && automaticSearch) {
616 //If search is complete, select the first log if requested and possible
617 [self selectFirstLog];
620 BOOL oldAutomaticSearch = automaticSearch;
622 //We don't want the above re-selection to change our automaticSearch tracking
623 //(The only reason automaticSearch should change is in response to user action)
624 automaticSearch = oldAutomaticSearch;
628 if (searchIsComplete &&
629 ((activeSearchID == searchIDToReattemptWhenComplete) && !windowIsClosing)) {
630 searchIDToReattemptWhenComplete = -1;
631 [self startSearchingClearingCurrentResults:NO];
635 [self selectCachedIndex];
638 [self updateProgressDisplay];
641 - (void)searchComplete
643 [refreshResultsTimer invalidate]; [refreshResultsTimer release]; refreshResultsTimer = nil;
644 [self refreshResultsSearchIsComplete:YES];
647 // Called on doubleAction to open a chat
648 -(void)openChatOnDoubleAction:(id)sender
650 id item = [outlineView_contacts firstSelectedItem];
651 if ([item isKindOfClass:[AIListContact class]]) {
652 //Open a new message with the contact
653 [[adium interfaceController] setActiveChat:[[adium chatController] openChatWithContact:(AIListContact *)item onPreferredAccount:YES]];
657 //Displays the contents of the specified log in our window
658 - (void)displayLogs:(NSArray *)logArray;
660 NSMutableAttributedString *displayText = nil;
661 NSAttributedString *finalDisplayText = nil;
662 NSRange scrollRange = NSMakeRange(0,0);
663 BOOL appendedFirstLog = NO;
665 if (![logArray isEqualToArray:displayedLogArray]) {
666 [displayedLogArray release];
667 displayedLogArray = [logArray copy];
670 if ([logArray count] > 1) {
671 displayText = [[NSMutableAttributedString alloc] init];
674 NSEnumerator *enumerator = [logArray objectEnumerator];
676 NSString *logBasePath = [AILoggerPlugin logBasePath];
677 AILog(@"Displaying %@",logArray);
678 while ((theLog = [enumerator nextObject])) {
679 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
682 if (!horizontalRule) {
683 #define HORIZONTAL_BAR 0x2013
684 #define HORIZONTAL_RULE_LENGTH 18
686 const unichar separatorUTF16[HORIZONTAL_RULE_LENGTH] = {
687 HORIZONTAL_BAR, HORIZONTAL_BAR, HORIZONTAL_BAR, HORIZONTAL_BAR, HORIZONTAL_BAR, HORIZONTAL_BAR,
688 HORIZONTAL_BAR, HORIZONTAL_BAR, HORIZONTAL_BAR, HORIZONTAL_BAR, HORIZONTAL_BAR, HORIZONTAL_BAR,
689 HORIZONTAL_BAR, HORIZONTAL_BAR, HORIZONTAL_BAR, HORIZONTAL_BAR, HORIZONTAL_BAR, HORIZONTAL_BAR
691 horizontalRule = [[NSString alloc] initWithCharacters:separatorUTF16 length:HORIZONTAL_RULE_LENGTH];
694 [displayText appendString:[NSString stringWithFormat:@"%@%@\n%@ - %@\n%@\n\n",
695 (appendedFirstLog ? @"\n" : @""),
697 [headerDateFormatter stringFromDate:[theLog date]],
700 withAttributes:[[AITextAttributes textAttributesWithFontFamily:@"Helvetica" traits:NSBoldFontMask size:12] dictionary]];
703 if ([[theLog path] hasSuffix:@".AdiumHTMLLog"] || [[theLog path] hasSuffix:@".html"] || [[theLog path] hasSuffix:@".html.bak"]) {
705 NSString *logFileText = [NSString stringWithContentsOfFile:[logBasePath stringByAppendingPathComponent:[theLog path]]];
708 [displayText appendAttributedString:[AIHTMLDecoder decodeHTML:logFileText]];
710 displayText = [[AIHTMLDecoder decodeHTML:logFileText] mutableCopy];
713 } else if ([[theLog path] hasSuffix:@".chatlog"]){
715 NSString *logFullPath = [logBasePath stringByAppendingPathComponent:[theLog path]];
717 //If this log begins with a malformed UTF-8 BOM (which was written out by Adium for a brief time between 1.0b7 and 1.0b8), fix it before trying to read it in.
719 failedUtf8BomLength = 6
721 NSData *data = [NSData dataWithContentsOfMappedFile:logFullPath];
722 const unsigned char *ptr = [data bytes];
723 unsigned len = [data length];
724 if ((len >= failedUtf8BomLength)
732 //Yup. Back up the old file, then strip it off.
733 NSLog(@"Transcript file at %@ has unwanted bytes at the front of it. (This is a bug in a previous version of Adium, not this version.) Attempting recovery.", logFullPath);
734 NSString *backupPath = [logFullPath stringByAppendingPathExtension:@"bak"];
735 if(![[NSFileManager defaultManager] movePath:logFullPath toPath:backupPath handler:nil])
736 NSLog(@"Could not back up file; recovery failed. This transcript will probably appear blank in the transcript viewer.");
738 NSRange range = { failedUtf8BomLength, len - failedUtf8BomLength };
739 NSData *theRestOfIt = [data subdataWithRange:range];
740 if([theRestOfIt writeToFile:logFullPath atomically:YES])
741 NSLog(@"Wrote fixed version to same file. The corrupted version was renamed to %@; you may remove this file at your leisure after you are satisfied that the recovery succeeded. You can test this by viewing the transcript (%@) in the transcript viewer.", backupPath, [logFullPath lastPathComponent]);
743 NSLog(@"Could not write fix!");
746 NSString *logFileText = [GBChatlogHTMLConverter readFile:logFullPath];
750 [displayText appendAttributedString:[AIHTMLDecoder decodeHTML:logFileText]];
752 displayText = [[AIHTMLDecoder decodeHTML:logFileText] mutableCopy];
756 //Fallback: Plain text log
757 NSString *logFileText = [NSString stringWithContentsOfFile:[logBasePath stringByAppendingPathComponent:[theLog path]]];
759 AITextAttributes *textAttributes = [AITextAttributes textAttributesWithFontFamily:@"Helvetica" traits:0 size:12];
762 [displayText appendAttributedString:[[[NSAttributedString alloc] initWithString:logFileText
763 attributes:[textAttributes dictionary]] autorelease]];
765 displayText = [[NSMutableAttributedString alloc] initWithString:logFileText attributes:[textAttributes dictionary]];
770 appendedFirstLog = YES;
775 if (displayText && [displayText length]) {
776 //Add pretty formatting to links
777 [displayText addFormattingForLinks];
779 //If we are searching by content, highlight the search results
780 if ((searchMode == LOG_SEARCH_CONTENT) && [activeSearchString length]) {
781 NSEnumerator *enumerator;
782 NSString *searchWord;
783 NSMutableArray *searchWordsArray = [[activeSearchString componentsSeparatedByString:@" "] mutableCopy];
784 NSScanner *scanner = [NSScanner scannerWithString:activeSearchString];
786 //Look for an initial quote
787 while (![scanner isAtEnd]) {
788 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
790 [scanner scanUpToString:@"\"" intoString:NULL];
792 //Scan past the quote
793 if (![scanner scanString:@"\"" intoString:NULL]) continue;
795 NSString *quotedString;
797 if (![scanner isAtEnd] &&
798 [scanner scanUpToString:@"\"" intoString:"edString]) {
799 //Scan past the quote
800 [scanner scanString:@"\"" intoString:NULL];
801 /* If a string within quotes is found, remove the words from the quoted string and add the full string
802 * to what we'll be highlighting.
804 * We'll use indexOfObject: and removeObjectAtIndex: so we only remove _one_ instance. Otherwise, this string:
805 * "killer attack ninja kittens" OR ninja
806 * wouldn't highlight the word ninja by itself.
808 NSArray *quotedWords = [quotedString componentsSeparatedByString:@" "];
809 int quotedWordsCount = [quotedWords count];
811 for (int i = 0; i < quotedWordsCount; i++) {
812 NSString *quotedWord = [quotedWords objectAtIndex:i];
814 //Originally started with a quote, so put it back on
815 quotedWord = [@"\"" stringByAppendingString:quotedWord];
817 if (i == quotedWordsCount - 1) {
818 //Originally ended with a quote, so put it back on
819 quotedWord = [quotedWord stringByAppendingString:@"\""];
821 int searchWordsIndex = [searchWordsArray indexOfObject:quotedWord];
822 if (searchWordsIndex != NSNotFound) {
823 [searchWordsArray removeObjectAtIndex:searchWordsIndex];
825 NSLog(@"displayLog: Couldn't find %@ in %@", quotedWord, searchWordsArray);
829 //Add the full quoted string
830 [searchWordsArray addObject:quotedString];
835 BOOL shouldScrollToWord = NO;
836 scrollRange = NSMakeRange([displayText length],0);
838 enumerator = [searchWordsArray objectEnumerator];
839 while ((searchWord = [enumerator nextObject])) {
842 //Check against and/or. We don't just remove it from the array because then we couldn't check case insensitively.
843 if (([searchWord caseInsensitiveCompare:@"and"] != NSOrderedSame) &&
844 ([searchWord caseInsensitiveCompare:@"or"] != NSOrderedSame)) {
845 [self hilightOccurrencesOfString:searchWord inString:displayText firstOccurrence:&occurrence];
847 //We'll want to scroll to the first occurrance of any matching word or words
848 if (occurrence.location < scrollRange.location) {
849 scrollRange = occurrence;
850 shouldScrollToWord = YES;
855 //If we shouldn't be scrolling to a new range, we want to scroll to the top
856 if (!shouldScrollToWord) scrollRange = NSMakeRange(0, 0);
858 [searchWordsArray release];
863 finalDisplayText = [[adium contentController] filterAttributedString:displayText
864 usingFilterType:AIFilterMessageDisplay
865 direction:AIFilterOutgoing
868 finalDisplayText = displayText;
872 if (finalDisplayText) {
873 [[textView_content textStorage] setAttributedString:finalDisplayText];
875 //Set this string and scroll to the top/bottom/occurrence
876 if ((searchMode == LOG_SEARCH_CONTENT) || automaticSearch) {
877 [textView_content scrollRangeToVisible:scrollRange];
879 [textView_content scrollRangeToVisible:NSMakeRange(0,0)];
883 //No log selected, empty the view
884 [textView_content setString:@""];
887 [displayText release];
890 - (void)displayLog:(AIChatLog *)theLog
892 [self displayLogs:(theLog ? [NSArray arrayWithObject:theLog] : nil)];
895 //Reselect the displayed log (Or another log if not possible)
896 - (void)selectDisplayedLog
898 int firstIndex = NSNotFound;
900 /* Is the log we had selected still in the table?
901 * (When performing an automatic search, we ignore the previous selection. This ensures that we always
902 * end up with the newest log selected, even when a search takes multiple passes/refreshes to complete).
904 if (!automaticSearch) {
906 [tableView_results selectItemsInArray:displayedLogArray usingSourceArray:currentSearchResults];
907 [resultsLock unlock];
909 firstIndex = [[tableView_results selectedRowIndexes] firstIndex];
912 if (firstIndex != NSNotFound) {
913 [tableView_results scrollRowToVisible:[[tableView_results selectedRowIndexes] firstIndex]];
915 if (useSame == YES && sameSelection > 0) {
916 [tableView_results selectRow:sameSelection byExtendingSelection:NO];
918 [self selectFirstLog];
925 - (void)selectFirstLog
927 AIChatLog *theLog = nil;
929 //If our selected log is no more, select the first one in the list
931 if ([currentSearchResults count] != 0) {
932 theLog = [currentSearchResults objectAtIndex:0];
934 [resultsLock unlock];
936 //Change the table selection to this new log
937 //We need a little trickery here. When we change the row, the table view will call our tableViewSelectionDidChange: method.
938 //This method will clear the automaticSearch flag, and break any scroll-to-bottom behavior we have going on for the custom
939 //search. As a quick hack, I've added an ignoreSelectionChange flag that can be set to inform our selectionDidChange method
940 //that we instantiated this selection change, and not the user.
941 ignoreSelectionChange = YES;
942 [tableView_results selectRow:0 byExtendingSelection:NO];
943 [tableView_results scrollRowToVisible:0];
944 ignoreSelectionChange = NO;
946 [self displayLog:theLog]; //Manually update the displayed log
949 //Highlight the occurences of a search string within a displayed log
950 - (void)hilightOccurrencesOfString:(NSString *)littleString inString:(NSMutableAttributedString *)bigString firstOccurrence:(NSRange *)outRange
953 NSRange searchRange, foundRange;
954 NSString *plainBigString = [bigString string];
955 unsigned plainBigStringLength = [plainBigString length];
956 NSMutableDictionary *attributeDictionary = nil;
958 outRange->location = NSNotFound;
960 //Search for the little string in the big string
961 while (location != NSNotFound && location < plainBigStringLength) {
962 searchRange = NSMakeRange(location, plainBigStringLength-location);
963 foundRange = [plainBigString rangeOfString:littleString options:NSCaseInsensitiveSearch range:searchRange];
965 //Bold and color this match
966 if (foundRange.location != NSNotFound) {
967 if (outRange->location == NSNotFound) *outRange = foundRange;
969 if (!attributeDictionary) {
970 attributeDictionary = [NSDictionary dictionaryWithObjectsAndKeys:
971 [NSFont boldSystemFontOfSize:14], NSFontAttributeName,
972 [NSColor yellowColor], NSBackgroundColorAttributeName,
975 [bigString addAttributes:attributeDictionary
979 location = NSMaxRange(foundRange);
984 //Sorting --------------------------------------------------------------------------------------------------------------
988 NSString *identifier = [selectedColumn identifier];
992 if ([identifier isEqualToString:@"To"]) {
993 [currentSearchResults sortUsingSelector:(sortDirection ? @selector(compareToReverse:) : @selector(compareTo:))];
995 } else if ([identifier isEqualToString:@"From"]) {
996 [currentSearchResults sortUsingSelector:(sortDirection ? @selector(compareFromReverse:) : @selector(compareFrom:))];
998 } else if ([identifier isEqualToString:@"Date"]) {
999 [currentSearchResults sortUsingSelector:(sortDirection ? @selector(compareDateReverse:) : @selector(compareDate:))];
1001 } else if ([identifier isEqualToString:@"Rank"]) {
1002 [currentSearchResults sortUsingSelector:(sortDirection ? @selector(compareRankReverse:) : @selector(compareRank:))];
1005 [resultsLock unlock];
1008 [tableView_results reloadData];
1010 //Reapply the selection
1011 [self selectDisplayedLog];
1014 //Sorts the selected log array and adjusts the selected column
1015 - (void)sortCurrentSearchResultsForTableColumn:(NSTableColumn *)tableColumn direction:(BOOL)direction
1017 //If there already was a sorted column, remove the indicator image from it.
1018 if (selectedColumn && selectedColumn != tableColumn) {
1019 [tableView_results setIndicatorImage:nil inTableColumn:selectedColumn];
1022 //Set the indicator image in the newly selected column
1023 [tableView_results setIndicatorImage:[NSImage imageNamed:(direction ? @"NSDescendingSortIndicator" : @"NSAscendingSortIndicator")]
1024 inTableColumn:tableColumn];
1026 //Set the highlighted table column.
1027 [tableView_results setHighlightedTableColumn:tableColumn];
1028 [selectedColumn release]; selectedColumn = [tableColumn retain];
1029 sortDirection = direction;
1034 //Searching ------------------------------------------------------------------------------------------------------------
1035 #pragma mark Searching
1036 //(Jag)Change search string
1037 - (void)controlTextDidChange:(NSNotification *)notification
1039 if (searchMode != LOG_SEARCH_CONTENT) {
1040 [self updateSearch:nil];
1044 //Change search string (Called by searchfield)
1045 - (IBAction)updateSearch:(id)sender
1047 automaticSearch = NO;
1048 [self setSearchString:[[[searchField_logs stringValue] copy] autorelease]];
1049 AILog(@"updateSearch calling startSearching");
1050 [self startSearchingClearingCurrentResults:YES];
1053 //Change search mode (Called by mode menu)
1054 - (IBAction)selectSearchType:(id)sender
1056 automaticSearch = NO;
1058 //First, update the search mode to the newly selected type
1059 [self setSearchMode:[sender tag]];
1061 //Then, ensure we are ready to search using the current string
1062 [self setSearchString:activeSearchString];
1064 //Now we are ready to start searching
1065 AILog(@"selectSearchType calling startSearching");
1066 [self startSearchingClearingCurrentResults:YES];
1069 //Begin a specific search
1070 - (void)setSearchString:(NSString *)inString mode:(LogSearchMode)inMode
1072 automaticSearch = YES;
1073 //Apply the search mode first since the behavior of setSearchString changes depending on the current mode
1074 [self setSearchMode:inMode];
1075 [self setSearchString:inString];
1077 AILog(@"setSearchString:mode: calling startSearching");
1078 [self startSearchingClearingCurrentResults:YES];
1081 //Begin the current search
1082 - (void)startSearchingClearingCurrentResults:(BOOL)clearCurrentResults
1084 NSDictionary *searchDict;
1086 if (suppressSearchRequests) return;
1087 AILog(@"Starting a search for %@",activeSearchString);
1089 //Once all searches have exited, we can start a new one
1090 if (clearCurrentResults) {
1092 //Stop any existing searches inside of resultsLock so we won't get any additions results added that we don't want
1093 [self stopSearching];
1095 [currentSearchResults release]; currentSearchResults = [[NSMutableArray alloc] init];
1096 [resultsLock unlock];
1098 //Stop any existing searches
1099 [self stopSearching];
1103 indexingUpdatesReceivedWhileSearching = 0;
1104 searchDict = [NSDictionary dictionaryWithObjectsAndKeys:
1105 [NSNumber numberWithInt:activeSearchID], @"ID",
1106 [NSNumber numberWithInt:searchMode], @"Mode",
1107 activeSearchString, @"String",
1108 [plugin logContentIndex], @"SearchIndex",
1110 [NSThread detachNewThreadSelector:@selector(filterLogsWithSearch:) toTarget:self withObject:searchDict];
1112 //Update the table periodically while the logs load.
1113 [refreshResultsTimer invalidate]; [refreshResultsTimer release];
1114 refreshResultsTimer = [[NSTimer scheduledTimerWithTimeInterval:REFRESH_RESULTS_INTERVAL
1116 selector:@selector(refreshResults)
1118 repeats:YES] retain];
1121 //Abort any active searches
1122 - (void)stopSearching
1124 [refreshResultsTimer invalidate]; [refreshResultsTimer release]; refreshResultsTimer = nil;
1126 //Increase the active search ID so any existing searches stop, and then
1127 //wait for any active searches to finish and release the lock
1131 //Set the active search mode (Does not invoke a search)
1132 - (void)setSearchMode:(LogSearchMode)inMode
1134 NSTextFieldCell *cell = [searchField_logs cell];
1136 searchMode = inMode;
1138 //Clear any filter from the table if it's the current mode, as well
1139 switch (searchMode) {
1140 case LOG_SEARCH_FROM:
1141 [cell setPlaceholderString:AILocalizedString(@"Search From","Placeholder for searching logs from an account")];
1145 [cell setPlaceholderString:AILocalizedString(@"Search To","Placeholder for searching logs with/to a contact")];
1148 case LOG_SEARCH_DATE:
1149 [cell setPlaceholderString:AILocalizedString(@"Search by Date","Placeholder for searching logs by date")];
1152 case LOG_SEARCH_CONTENT:
1153 [cell setPlaceholderString:AILocalizedString(@"Search Content","Placeholder for searching logs by content")];
1157 [self updateRankColumnVisibility];
1158 [self buildSearchMenu];
1161 - (void)updateRankColumnVisibility
1163 NSTableColumn *resultsColumn = [tableView_results tableColumnWithIdentifier:@"Rank"];
1165 if ((searchMode == LOG_SEARCH_CONTENT) && ([activeSearchString length])) {
1166 //Add the resultsColumn and resize if it should be shown but is not at present
1167 if (!resultsColumn) {
1168 NSArray *tableColumns;
1170 //Set up the results column
1171 resultsColumn = [[NSTableColumn alloc] initWithIdentifier:@"Rank"];
1172 [[resultsColumn headerCell] setTitle:AILocalizedString(@"Rank",nil)];
1173 [resultsColumn setDataCell:[[[ESRankingCell alloc] init] autorelease]];
1175 //Add it to the table
1176 [tableView_results addTableColumn:resultsColumn];
1178 //Make it half again as large as the desired width from the @"Rank" header title
1179 [resultsColumn sizeToFit];
1180 [resultsColumn setWidth:([resultsColumn width] * 1.5)];
1182 tableColumns = [tableView_results tableColumns];
1183 if ([tableColumns indexOfObject:resultsColumn] > 0) {
1184 NSTableColumn *nextDoorNeighbor;
1186 //Adjust the column to the results column's left so results is now visible
1187 nextDoorNeighbor = [tableColumns objectAtIndex:([tableColumns indexOfObject:resultsColumn] - 1)];
1188 [nextDoorNeighbor setWidth:[nextDoorNeighbor width]-[resultsColumn width]];
1192 //Remove the resultsColumn and resize if it should not be shown but is at present
1193 if (resultsColumn) {
1194 NSArray *tableColumns;
1196 tableColumns = [tableView_results tableColumns];
1197 if ([tableColumns indexOfObject:resultsColumn] > 0) {
1198 NSTableColumn *nextDoorNeighbor;
1200 //Adjust the column to the results column's left to take up the space again
1201 tableColumns = [tableView_results tableColumns];
1202 nextDoorNeighbor = [tableColumns objectAtIndex:([tableColumns indexOfObject:resultsColumn] - 1)];
1203 [nextDoorNeighbor setWidth:[nextDoorNeighbor width]+[resultsColumn width]];
1207 [tableView_results removeTableColumn:resultsColumn];
1212 //Set the active search string (Does not invoke a search)
1213 - (void)setSearchString:(NSString *)inString
1215 if (![[searchField_logs stringValue] isEqualToString:inString]) {
1216 [searchField_logs setStringValue:(inString ? inString : @"")];
1219 //Use autorelease so activeSearchString can be passed back to here
1220 if (activeSearchString != inString) {
1221 [activeSearchString release];
1222 activeSearchString = [inString retain];
1225 [self updateRankColumnVisibility];
1228 //Build the search mode menu
1229 - (void)buildSearchMenu
1231 NSMenu *cellMenu = [[[NSMenu allocWithZone:[NSMenu menuZone]] initWithTitle:SEARCH_MENU] autorelease];
1232 [cellMenu addItem:[self _menuItemWithTitle:FROM forSearchMode:LOG_SEARCH_FROM]];
1233 [cellMenu addItem:[self _menuItemWithTitle:TO forSearchMode:LOG_SEARCH_TO]];
1234 [cellMenu addItem:[self _menuItemWithTitle:DATE forSearchMode:LOG_SEARCH_DATE]];
1235 [cellMenu addItem:[self _menuItemWithTitle:CONTENT forSearchMode:LOG_SEARCH_CONTENT]];
1237 [[searchField_logs cell] setSearchMenuTemplate:cellMenu];
1240 - (void)_willOpenForContact
1242 isOpeningForContact = YES;
1245 - (void)_didOpenForContact
1247 isOpeningForContact = NO;
1251 * @brief Focus the log viewer on a particular contact
1253 * If the contact is within a metacontact, the metacontact will be focused.
1255 - (void)filterForContact:(AIListContact *)inContact
1257 AIListContact *parentContact = [inContact parentContact];
1259 if (!isOpeningForContact) {
1260 /* Ensure the contacts list includes this contact, since only existing AIListContacts are to be used
1261 * (with AILogToGroup objects used if an AIListContact isn't available) but that situation may have changed
1262 * with regard to inContact since the log viewer opened.
1264 * If we're opening initially, the list is guaranteed fresh.
1266 [self rebuildContactsList];
1269 //If the search mode is currently the TO field, switch it to content, which is what it should now intuitively do
1270 if (searchMode == LOG_SEARCH_TO) {
1271 [self setSearchMode:LOG_SEARCH_CONTENT];
1273 //Update our search string to ensure we're configured for content searching
1274 [self setSearchString:activeSearchString];
1277 //Changing the selection will start a new search
1278 [outlineView_contacts selectItemsInArray:[NSArray arrayWithObject:(parentContact ? (id)parentContact : (id)allContactsIdentifier)]];
1279 unsigned int selectedRow = [[outlineView_contacts selectedRowIndexes] firstIndex];
1280 if (selectedRow != NSNotFound) {
1281 [outlineView_contacts scrollRowToVisible:selectedRow];
1286 * @brief Returns a menu item for the search mode menu
1288 - (NSMenuItem *)_menuItemWithTitle:(NSString *)title forSearchMode:(LogSearchMode)mode
1290 NSMenuItem *menuItem = [[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:title
1291 action:@selector(selectSearchType:)
1293 [menuItem setTag:mode];
1294 [menuItem setState:(mode == searchMode ? NSOnState : NSOffState)];
1296 return [menuItem autorelease];
1299 #pragma mark Filtering search results
1301 - (BOOL)chatLogMatchesDateFilter:(AIChatLog *)inChatLog
1303 BOOL matchesDateFilter;
1305 switch (filterDateType) {
1306 case AIDateTypeAfter:
1307 matchesDateFilter = ([[inChatLog date] timeIntervalSinceDate:filterDate] > 0);
1309 case AIDateTypeBefore:
1310 matchesDateFilter = ([[inChatLog date] timeIntervalSinceDate:filterDate] < 0);
1312 case AIDateTypeExactly:
1313 matchesDateFilter = [inChatLog isFromSameDayAsDate:filterDate];
1316 matchesDateFilter = YES;
1320 return matchesDateFilter;
1324 NSArray *pathComponentsForDocument(SKDocumentRef inDocument)
1326 CFURLRef url = SKDocumentCopyURL(inDocument);
1327 CFStringRef logPath = CFURLCopyFileSystemPath(url, kCFURLPOSIXPathStyle);
1328 NSArray *pathComponents = [(NSString *)logPath pathComponents];
1333 return pathComponents;
1337 * @brief Should a search display a document with the given information?
1339 - (BOOL)searchShouldDisplayDocument:(SKDocumentRef)inDocument pathComponents:(NSArray *)pathComponents testDate:(BOOL)testDate
1341 BOOL shouldDisplayDocument = YES;
1343 if ([contactIDsToFilter count]) {
1344 //Determine the path components if we weren't supplied them
1345 if (!pathComponents) pathComponents = pathComponentsForDocument(inDocument);
1347 unsigned int numPathComponents = [pathComponents count];
1349 NSArray *serviceAndFromUIDArray = [[pathComponents objectAtIndex:numPathComponents-3] componentsSeparatedByString:@"."];
1350 NSString *serviceClass = (([serviceAndFromUIDArray count] >= 2) ? [serviceAndFromUIDArray objectAtIndex:0] : @"");
1352 NSString *contactName = [pathComponents objectAtIndex:(numPathComponents-2)];
1354 shouldDisplayDocument = [contactIDsToFilter containsObject:[[NSString stringWithFormat:@"%@.%@",serviceClass,contactName] compactedString]];
1357 if (shouldDisplayDocument && testDate && (filterDateType != AIDateTypeAnyDate)) {
1358 if (!pathComponents) pathComponents = pathComponentsForDocument(inDocument);
1360 unsigned int numPathComponents = [pathComponents count];
1361 NSString *toPath = [NSString stringWithFormat:@"%@/%@",
1362 [pathComponents objectAtIndex:numPathComponents-3],
1363 [pathComponents objectAtIndex:numPathComponents-2]];
1364 NSString *path = [NSString stringWithFormat:@"%@/%@",toPath,[pathComponents objectAtIndex:numPathComponents-1]];
1367 theLog = [[logToGroupDict objectForKey:toPath] logAtPath:path];
1369 shouldDisplayDocument = [self chatLogMatchesDateFilter:theLog];
1372 return shouldDisplayDocument;
1375 //Threaded filter/search methods ---------------------------------------------------------------------------------------
1376 #pragma mark Threaded filter/search methods
1377 //Search the logs, filtering out any matching logs into the currentSearchResults
1378 - (void)filterLogsWithSearch:(NSDictionary *)searchInfoDict
1380 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
1381 int mode = [[searchInfoDict objectForKey:@"Mode"] intValue];
1382 int searchID = [[searchInfoDict objectForKey:@"ID"] intValue];
1383 NSString *searchString = [searchInfoDict objectForKey:@"String"];
1385 if (searchID == activeSearchID) { //If we're still supposed to go
1387 AILog(@"filterLogsWithSearch (search ID %i): %@",searchID,searchInfoDict);
1389 [plugin pauseIndexing];
1390 if (searchString && [searchString length]) {
1392 case LOG_SEARCH_FROM:
1394 case LOG_SEARCH_DATE:
1395 [self _logFilter:searchString
1399 case LOG_SEARCH_CONTENT:
1400 [self _logContentFilter:searchString
1402 onSearchIndex:(SKIndexRef)[searchInfoDict objectForKey:@"SearchIndex"]];
1406 [self _logFilter:nil
1413 [plugin resumeIndexing];
1414 [self performSelectorOnMainThread:@selector(searchComplete) withObject:nil waitUntilDone:NO];
1415 AILog(@"filterLogsWithSearch (search ID %i): finished",searchID);
1422 //Perform a filter search based on source name, destination name, or date
1423 - (void)_logFilter:(NSString *)searchString searchID:(int)searchID mode:(LogSearchMode)mode
1425 NSEnumerator *fromEnumerator, *toEnumerator, *logEnumerator;
1426 AILogToGroup *toGroup;
1427 AILogFromGroup *fromGroup;
1429 UInt32 lastUpdate = TickCount();
1431 NSCalendarDate *searchStringDate = nil;
1433 if ((mode == LOG_SEARCH_DATE) && (searchString != nil)) {
1434 searchStringDate = [[NSDate dateWithNaturalLanguageString:searchString] dateWithCalendarFormat:nil timeZone:nil];
1437 //Walk through every 'from' group
1438 fromEnumerator = [logFromGroupDict objectEnumerator];
1439 while ((fromGroup = [fromEnumerator nextObject]) && (searchID == activeSearchID)) {
1441 //When searching in LOG_SEARCH_FROM, we only proceed into matching groups
1442 if ((mode != LOG_SEARCH_FROM) ||
1444 ([[fromGroup fromUID] rangeOfString:searchString options:NSCaseInsensitiveSearch].location != NSNotFound)) {
1446 //Walk through every 'to' group
1447 toEnumerator = [[fromGroup toGroupArray] objectEnumerator];
1448 while ((toGroup = [toEnumerator nextObject]) && (searchID == activeSearchID)) {
1450 /* When searching in LOG_SEARCH_TO, we only proceed into matching groups
1451 * For all other search modes, we always proceed here so long as either:
1452 * a) We are not filtering for specific contact names or
1453 * b) The contact name matches one of the names in contactIDsToFilter
1455 if ((![contactIDsToFilter count] || [contactIDsToFilter containsObject:[[NSString stringWithFormat:@"%@.%@",[toGroup serviceClass],[toGroup to]] compactedString]]) &&
1456 ((mode != LOG_SEARCH_TO) ||
1458 ([[toGroup to] rangeOfString:searchString options:NSCaseInsensitiveSearch].location != NSNotFound))) {
1460 //Walk through every log
1462 logEnumerator = [toGroup logEnumerator];
1463 while ((theLog = [logEnumerator nextObject]) && (searchID == activeSearchID)) {
1464 /* When searching in LOG_SEARCH_DATE, we must have matching dates
1465 * For all other search modes, we always proceed here
1467 if ((mode != LOG_SEARCH_DATE) ||
1469 (searchStringDate && [theLog isFromSameDayAsDate:searchStringDate])) {
1471 if ([self chatLogMatchesDateFilter:theLog]) {
1474 [currentSearchResults addObject:theLog];
1475 [resultsLock unlock];
1478 if (lastUpdate == 0 || TickCount() > lastUpdate + LOG_SEARCH_STATUS_INTERVAL) {
1479 [self performSelectorOnMainThread:@selector(updateProgressDisplay)
1482 lastUpdate = TickCount();
1493 //Search results table view --------------------------------------------------------------------------------------------
1494 #pragma mark Search results table view
1495 //Since this table view's source data will be accessed from within other threads, we need to lock before
1496 //accessing it. We also must be very sure that an incorrect row request is handled silently, since this
1497 //can occur if the array size is changed during the reload.
1498 - (int)numberOfRowsInTableView:(NSTableView *)tableView
1503 count = [currentSearchResults count];
1504 [resultsLock unlock];
1510 - (void)tableView:(NSTableView *)aTableView willDisplayCell:(id)aCell forTableColumn:(NSTableColumn *)tableColumn row:(int)row
1512 NSString *identifier = [tableColumn identifier];
1514 if ([identifier isEqualToString:@"Rank"] && row >= 0 && row < [currentSearchResults count]) {
1515 AIChatLog *theLog = [currentSearchResults objectAtIndex:row];
1517 [aCell setPercentage:[theLog rankingPercentage]];
1522 - (id)tableView:(NSTableView *)tableView objectValueForTableColumn:(NSTableColumn *)tableColumn row:(int)row
1524 NSString *identifier = [tableColumn identifier];
1528 if (row < 0 || row >= [currentSearchResults count]) {
1529 if ([identifier isEqualToString:@"Service"]) {
1536 AIChatLog *theLog = [currentSearchResults objectAtIndex:row];
1538 if ([identifier isEqualToString:@"To"]) {
1539 // Get ListObject for to-UID
1540 AIListObject *listObject = [[adium contactController] existingListObjectWithUniqueID:[AIListObject internalObjectIDForServiceID:[theLog serviceClass]
1543 //Use the longDisplayName, following the user's contact list preferences as this is presumably how she wants to view contacts' names.
1544 value = [listObject longDisplayName];
1547 //No username available
1548 value = [theLog to];
1551 } else if ([identifier isEqualToString:@"From"]) {
1552 value = [theLog from];
1554 } else if ([identifier isEqualToString:@"Date"]) {
1555 value = [theLog date];
1557 } else if ([identifier isEqualToString:@"Service"]) {
1558 NSString *serviceClass;
1561 serviceClass = [theLog serviceClass];
1562 image = [AIServiceIcons serviceIconForService:[[adium accountController] firstServiceWithServiceID:serviceClass]
1563 type:AIServiceIconSmall
1564 direction:AIIconNormal];
1565 value = (image ? image : blankImage);
1568 [resultsLock unlock];
1574 - (void)tableViewSelectionDidChange:(NSNotification *)notification
1576 if (!ignoreSelectionChange) {
1577 NSArray *selectedLogs;
1579 //Update the displayed log
1580 automaticSearch = NO;
1583 selectedLogs = [tableView_results arrayOfSelectedItemsUsingSourceArray:currentSearchResults];
1584 [resultsLock unlock];
1586 [self displayLogs:selectedLogs];
1590 //Sort the log array & reflect the new column
1591 - (void)tableView:(NSTableView*)tableView didClickTableColumn:(NSTableColumn *)tableColumn
1593 [self sortCurrentSearchResultsForTableColumn:tableColumn
1594 direction:(selectedColumn == tableColumn ? !sortDirection : sortDirection)];
1597 - (void)tableViewDeleteSelectedRows:(NSTableView *)tableView
1599 [self deleteSelection:nil];
1602 - (void)configureTypeSelectTableView:(KFTypeSelectTableView *)tableView
1604 if (tableView == tableView_results) {
1605 [tableView setSearchColumnIdentifiers:[NSSet setWithObjects:@"To", @"From", nil]];
1606 [tableView setSearchWraps:YES];
1608 } else if (tableView == (KFTypeSelectTableView *)outlineView_contacts) {
1609 [tableView setSearchWraps:YES];
1613 - (void)tableViewColumnDidResize:(NSNotification *)aNotification
1615 NSTableColumn *dateTableColumn = [tableView_results tableColumnWithIdentifier:@"Date"];
1617 if (!aNotification ||
1618 ([[aNotification userInfo] objectForKey:@"NSTableColumn"] == dateTableColumn)) {
1619 NSDateFormatter *dateFormatter;
1620 NSCell *cell = [dateTableColumn dataCell];
1622 [cell setObjectValue:[NSDate date]];
1624 float width = [dateTableColumn width];
1626 #define NUMBER_TIME_STYLES 2
1627 #define NUMBER_DATE_STYLES 4
1628 NSDateFormatterStyle timeFormatterStyles[NUMBER_TIME_STYLES] = { NSDateFormatterShortStyle, NSDateFormatterNoStyle};
1629 NSDateFormatterStyle formatterStyles[NUMBER_DATE_STYLES] = { NSDateFormatterFullStyle, NSDateFormatterLongStyle, NSDateFormatterMediumStyle, NSDateFormatterShortStyle };
1630 float requiredWidth;
1632 dateFormatter = [cell formatter];
1633 if (!dateFormatter) {
1634 dateFormatter = [[[AILogDateFormatter alloc] init] autorelease];
1635 [dateFormatter setFormatterBehavior:NSDateFormatterBehavior10_4];
1636 [cell setFormatter:dateFormatter];
1639 requiredWidth = width + 1;
1640 for (int i = 0; (i < NUMBER_TIME_STYLES) && (requiredWidth > width); i++) {
1641 [dateFormatter setTimeStyle:timeFormatterStyles[i]];
1643 for (int j = 0; (j < NUMBER_DATE_STYLES) && (requiredWidth > width); j++) {
1644 [dateFormatter setDateStyle:formatterStyles[j]];
1645 requiredWidth = [cell cellSizeForBounds:NSMakeRect(0,0,1e6,1e6)].width;
1646 //Require a bit of space so the date looks comfortable. Very long dates relative to the current date can still overflow...
1653 - (IBAction)toggleEmoticonFiltering:(id)sender
1655 showEmoticons = !showEmoticons;
1656 [sender setLabel:(showEmoticons ? HIDE_EMOTICONS : SHOW_EMOTICONS)];
1657 [sender setImage:[NSImage imageNamed:(showEmoticons ? IMAGE_EMOTICONS_ON : IMAGE_EMOTICONS_OFF) forClass:[self class]]];
1659 [self displayLogs:displayedLogArray];
1662 #pragma mark Outline View Data source
1663 - (id)outlineView:(NSOutlineView *)outlineView child:(int)index ofItem:(id)item
1667 return allContactsIdentifier;
1670 return [toArray objectAtIndex:index-1]; //-1 for the All item, which is index 0
1674 if ([item isKindOfClass:[AIMetaContact class]]) {
1675 return [[(AIMetaContact *)item listContactsIncludingOfflineAccounts] objectAtIndex:index];
1682 - (BOOL)outlineView:(NSOutlineView *)outlineView isItemExpandable:(id)item
1685 ([item isKindOfClass:[AIMetaContact class]] && ([[(AIMetaContact *)item listContactsIncludingOfflineAccounts] count] > 1)) ||
1686 [item isKindOfClass:[NSArray class]]);
1689 - (int)outlineView:(NSOutlineView *)outlineView numberOfChildrenOfItem:(id)item
1692 return [toArray count] + 1; //+1 for the All item
1694 } else if ([item isKindOfClass:[AIMetaContact class]]) {
1695 unsigned count = [[(AIMetaContact *)item listContactsIncludingOfflineAccounts] count];
1706 - (id)outlineView:(NSOutlineView *)outlineView objectValueForTableColumn:(NSTableColumn *)tableColumn byItem:(id)item
1708 Class itemClass = [item class];
1710 if (itemClass == [AIMetaContact class]) {
1711 return [(AIMetaContact *)item longDisplayName];
1713 } else if (itemClass == [AIListContact class]) {
1714 if ([(AIListContact *)item parentContact] != item) {
1715 //This contact is within a metacontact - always show its UID
1716 return [(AIListContact *)item formattedUID];
1718 return [(AIListContact *)item longDisplayName];
1721 } else if (itemClass == [AILogToGroup class]) {
1722 return [(AILogToGroup *)item to];
1724 } else if (itemClass == [allContactsIdentifier class]) {
1725 int contactCount = [toArray count];
1726 return [NSString stringWithFormat:AILocalizedString(@"All (%@)", nil),
1727 ((contactCount == 1) ?
1728 AILocalizedString(@"1 Contact", nil) :
1729 [NSString stringWithFormat:AILocalizedString(@"%i Contacts", nil), contactCount])];
1731 } else if (itemClass == [NSString class]) {
1735 NSLog(@"%@: no idea",item);
1740 - (void)outlineView:(NSOutlineView *)outlineView willDisplayCell:(id)cell forTableColumn:(NSTableColumn *)tableColumn item:(id)item
1742 if ([item isKindOfClass:[AIMetaContact class]] &&
1743 [[(AIMetaContact *)item listContactsIncludingOfflineAccounts] count] > 1) {
1744 /* If the metacontact contains a single contact, fall through (isKindOfClass:[AIListContact class]) and allow using of a service icon.
1745 * If it has multiple contacts, use no icon unless a user icon is present.
1747 NSImage *image = [AIUserIcons listUserIconForContact:(AIListContact *)item
1748 size:NSMakeSize(16,16)];
1749 if (!image) image = [[[NSImage alloc] initWithSize:NSMakeSize(16, 16)] autorelease];
1751 [cell setImage:image];
1753 } else if ([item isKindOfClass:[AIListContact class]]) {
1754 NSImage *image = [AIUserIcons listUserIconForContact:(AIListContact *)item
1755 size:NSMakeSize(16,16)];
1756 if (!image) image = [AIServiceIcons serviceIconForObject:(AIListContact *)item
1757 type:AIServiceIconSmall
1758 direction:AIIconFlipped];
1759 [cell setImage:image];
1761 } else if ([item isKindOfClass:[AILogToGroup class]]) {
1762 [cell setImage:[AIServiceIcons serviceIconForServiceID:[(AILogToGroup *)item serviceClass]
1763 type:AIServiceIconSmall
1764 direction:AIIconFlipped]];
1766 } else if ([item isKindOfClass:[allContactsIdentifier class]]) {
1767 if ([[outlineView arrayOfSelectedItems] containsObjectIdenticalTo:item] &&
1768 ([[self window] isKeyWindow] && ([[self window] firstResponder] == self))) {
1769 if (!adiumIconHighlighted) {
1770 adiumIconHighlighted = [[NSImage imageNamed:@"adiumHighlight"
1771 forClass:[self class]] retain];
1774 [cell setImage:adiumIconHighlighted];
1778 adiumIcon = [[NSImage imageNamed:@"adium"
1779 forClass:[self class]] retain];
1782 [cell setImage:adiumIcon];
1785 } else if ([item isKindOfClass:[NSString class]]) {
1786 [cell setImage:nil];
1789 NSLog(@"%@: no idea",item);
1790 [cell setImage:nil];
1795 * @brief Is item supposed to have a divider below?
1798 - (AIDividerPosition)outlineView:(NSOutlineView*)outlineView dividerPositionForItem:(id)item
1800 if ([item isKindOfClass:[allContactsIdentifier class]]) {
1801 return AIDividerPositionBelow;
1803 return AIDividerPositionNone;
1807 - (void)outlineViewDeleteSelectedRows:(NSTableView *)tableView
1809 [self deleteSelection:nil];
1812 - (void)outlineViewSelectionDidChange:(NSNotification *)notification
1814 NSArray *selectedItems = [outlineView_contacts arrayOfSelectedItems];
1816 [contactIDsToFilter removeAllObjects];
1818 if ([selectedItems count] && ![selectedItems containsObject:allContactsIdentifier]) {
1820 NSEnumerator *enumerator;
1822 enumerator = [selectedItems objectEnumerator];
1823 while ((item = [enumerator nextObject])) {
1824 if ([item isKindOfClass:[AIMetaContact class]]) {
1825 NSEnumerator *metaEnumerator;
1826 AIListContact *contact;
1828 metaEnumerator = [[(AIMetaContact *)item listContactsIncludingOfflineAccounts] objectEnumerator];
1829 while ((contact = [metaEnumerator nextObject])) {
1830 [contactIDsToFilter addObject:
1831 [[[NSString stringWithFormat:@"%@.%@",[contact serviceID],[contact UID]] compactedString] safeFilenameString]];
1834 } else if ([item isKindOfClass:[AIListContact class]]) {
1835 [contactIDsToFilter addObject:
1836 [[[NSString stringWithFormat:@"%@.%@",[(AIListContact *)item serviceID],[(AIListContact *)item UID]] compactedString] safeFilenameString]];
1838 } else if ([item isKindOfClass:[AILogToGroup class]]) {
1839 [contactIDsToFilter addObject:[[NSString stringWithFormat:@"%@.%@",[(AILogToGroup *)item serviceClass],[(AILogToGroup *)item to]] compactedString]];
1844 [self startSearchingClearingCurrentResults:YES];
1847 - (NSMenu *)outlineView:(NSOutlineView *)outlineView menuForEvent:(NSEvent *)theEvent;
1849 if (outlineView == outlineView_contacts) {
1850 int clickedRow = [outlineView_contacts rowAtPoint:[outlineView_contacts convertPoint:[theEvent locationInWindow]
1852 id item = [outlineView_contacts itemAtRow:clickedRow];
1854 //If we have a To group, see if we can make a contact out of it
1855 if ([item isKindOfClass:[AILogToGroup class]]) {
1856 if ([(AILogToGroup *)item to] && [(AILogToGroup *)item serviceClass]) {
1857 //We need a service with ther right service ID
1858 AIService *service = [[adium accountController] firstServiceWithServiceID:[(AILogToGroup *)item serviceClass]];
1860 NSEnumerator *enumerator = [[[adium accountController] accountsCompatibleWithService:service] objectEnumerator];
1863 //Next, we want an online account
1864 while ((account = [enumerator nextObject])) {
1865 if ([account online]) break;
1869 //Finally, make a contact
1870 item = [[adium contactController] contactWithService:service
1872 UID:[(AILogToGroup *)item to]];
1879 if ([item isKindOfClass:[AIListContact class]]) {
1880 NSArray *locationsArray = [NSArray arrayWithObjects:
1881 [NSNumber numberWithInt:Context_Contact_Message],
1882 [NSNumber numberWithInt:Context_Contact_Manage],
1883 [NSNumber numberWithInt:Context_Contact_Action],
1884 [NSNumber numberWithInt:Context_Contact_ListAction],
1885 [NSNumber numberWithInt:Context_Contact_NegativeAction],
1886 [NSNumber numberWithInt:Context_Contact_Additions], nil];
1888 return [[adium menuController] contextualMenuWithLocations:locationsArray
1889 forListObject:(AIListContact *)item];
1896 static int toArraySort(id itemA, id itemB, void *context)
1898 NSString *nameA = [sharedLogViewerInstance outlineView:nil objectValueForTableColumn:nil byItem:itemA];
1899 NSString *nameB = [sharedLogViewerInstance outlineView:nil objectValueForTableColumn:nil byItem:itemB];
1900 NSComparisonResult result = [nameA caseInsensitiveCompare:nameB];
1901 if (result == NSOrderedSame) result = [nameA compare:nameB];
1906 - (void)draggedDividerRightBy:(float)deltaX
1908 desiredContactsSourceListDeltaX = deltaX;
1909 [splitView_contacts_results resizeSubviewsWithOldSize:[splitView_contacts_results frame].size];
1910 desiredContactsSourceListDeltaX = 0;
1914 - (void)splitView:(NSSplitView *)sender resizeSubviewsWithOldSize:(NSSize)oldSize
1916 if ((sender == splitView_contacts_results) &&
1917 desiredContactsSourceListDeltaX != 0) {
1918 float dividerThickness = [sender dividerThickness];
1920 NSRect newFrame = [sender frame];
1921 NSRect leftFrame = [containingView_contactsSourceList frame];
1922 NSRect rightFrame = [containingView_results frame];
1924 leftFrame.size.width += desiredContactsSourceListDeltaX;
1925 leftFrame.size.height = newFrame.size.height;
1926 leftFrame.origin = NSMakePoint(0,0);
1928 rightFrame.size.width = newFrame.size.width - leftFrame.size.width - dividerThickness;
1929 rightFrame.size.height = newFrame.size.height;
1930 rightFrame.origin.x = leftFrame.size.width + dividerThickness;
1932 [containingView_contactsSourceList setFrame:leftFrame];
1933 [containingView_contactsSourceList setNeedsDisplay:YES];
1934 [containingView_results setFrame:rightFrame];
1935 [containingView_results setNeedsDisplay:YES];
1938 //Perform the default implementation
1939 [sender adjustSubviews];
1944 //Window Toolbar -------------------------------------------------------------------------------------------------------
1945 #pragma mark Window Toolbar
1946 - (NSString *)dateItemNibName
1951 - (void)installToolbar
1953 [NSBundle loadNibNamed:[self dateItemNibName] owner:self];
1955 NSToolbar *toolbar = [[[NSToolbar alloc] initWithIdentifier:TOOLBAR_LOG_VIEWER] autorelease];
1956 NSToolbarItem *toolbarItem;
1958 [toolbar setDelegate:self];
1959 [toolbar setDisplayMode:NSToolbarDisplayModeIconAndLabel];
1960 [toolbar setSizeMode:NSToolbarSizeModeRegular];
1961 [toolbar setVisible:YES];
1962 [toolbar setAllowsUserCustomization:YES];
1963 [toolbar setAutosavesConfiguration:YES];
1964 toolbarItems = [[NSMutableDictionary alloc] init];
1967 [AIToolbarUtilities addToolbarItemToDictionary:toolbarItems
1968 withIdentifier:@"delete"
1971 toolTip:AILocalizedString(@"Delete the selection",nil)
1973 settingSelector:@selector(setImage:)
1974 itemContent:[NSImage imageNamed:@"remove" forClass:[self class]]
1975 action:@selector(deleteSelection:)
1979 [self window]; //Ensure the window is loaded, since we're pulling the search view from our nib
1980 toolbarItem = [AIToolbarUtilities toolbarItemWithIdentifier:@"search"
1983 toolTip:AILocalizedString(@"Search or filter logs",nil)
1985 settingSelector:@selector(setView:)
1986 itemContent:view_SearchField
1987 action:@selector(updateSearch:)
1989 if ([toolbarItem respondsToSelector:@selector(setVisibilityPriority:)]) {
1990 [toolbarItem setVisibilityPriority:(NSToolbarItemVisibilityPriorityHigh + 1)];
1992 [toolbarItem setMinSize:NSMakeSize(130, NSHeight([view_SearchField frame]))];
1993 [toolbarItem setMaxSize:NSMakeSize(230, NSHeight([view_SearchField frame]))];
1994 [toolbarItems setObject:toolbarItem forKey:[toolbarItem itemIdentifier]];
1996 toolbarItem = [AIToolbarUtilities toolbarItemWithIdentifier:DATE_ITEM_IDENTIFIER
1997 label:AILocalizedString(@"Date", nil)
1998 paletteLabel:AILocalizedString(@"Date", nil)
1999 toolTip:AILocalizedString(@"Filter logs by date",nil)
2001 settingSelector:@selector(setView:)
2002 itemContent:view_DatePicker
2005 if ([toolbarItem respondsToSelector:@selector(setVisibilityPriority:)]) {
2006 [toolbarItem setVisibilityPriority:NSToolbarItemVisibilityPriorityHigh];
2008 [toolbarItem setMinSize:[view_DatePicker frame].size];
2009 [toolbarItem setMaxSize:[view_DatePicker frame].size];
2010 [toolbarItems setObject:toolbarItem forKey:[toolbarItem itemIdentifier]];
2013 [AIToolbarUtilities addToolbarItemToDictionary:toolbarItems
2014 withIdentifier:@"toggleemoticons"
2015 label:(showEmoticons ? HIDE_EMOTICONS : SHOW_EMOTICONS)
2016 paletteLabel:AILocalizedString(@"Show/Hide Emoticons",nil)
2017 toolTip:AILocalizedString(@"Show or hide emoticons in logs",nil)
2019 settingSelector:@selector(setImage:)
2020 itemContent:[NSImage imageNamed:(showEmoticons ? IMAGE_EMOTICONS_ON : IMAGE_EMOTICONS_OFF) forClass:[self class]]
2021 action:@selector(toggleEmoticonFiltering:)
2024 [[self window] setToolbar:toolbar];
2026 [self configureDateFilter];
2029 - (NSToolbarItem *)toolbar:(NSToolbar *)toolbar itemForItemIdentifier:(NSString *)itemIdentifier willBeInsertedIntoToolbar:(BOOL)flag
2031 return [AIToolbarUtilities toolbarItemFromDictionary:toolbarItems withIdentifier:itemIdentifier];
2034 - (NSArray *)toolbarDefaultItemIdentifiers:(NSToolbar*)toolbar
2036 return [NSArray arrayWithObjects:DATE_ITEM_IDENTIFIER, NSToolbarFlexibleSpaceItemIdentifier,
2037 @"delete", @"toggleemoticons", NSToolbarPrintItemIdentifier, NSToolbarFlexibleSpaceItemIdentifier,
2041 - (NSArray *)toolbarAllowedItemIdentifiers:(NSToolbar*)toolbar
2043 return [[toolbarItems allKeys] arrayByAddingObjectsFromArray:
2044 [NSArray arrayWithObjects:NSToolbarSeparatorItemIdentifier,
2045 NSToolbarSpaceItemIdentifier,
2046 NSToolbarFlexibleSpaceItemIdentifier,
2047 NSToolbarCustomizeToolbarItemIdentifier,
2048 NSToolbarPrintItemIdentifier, nil]];
2051 - (void)toolbarWillAddItem:(NSNotification *)notification
2053 NSToolbarItem *item = [[notification userInfo] objectForKey:@"item"];
2054 if ([[item itemIdentifier] isEqualToString:NSToolbarPrintItemIdentifier]) {
2055 [item setTarget:self];
2056 [item setAction:@selector(adiumPrint:)];
2060 #pragma mark Date filter
2063 * @brief Returns a menu item for the date type filter menu
2065 - (NSMenuItem *)_menuItemForDateType:(AIDateType)dateType dict:(NSDictionary *)dateTypeTitleDict
2067 NSMenuItem *menuItem = [[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:[dateTypeTitleDict objectForKey:[NSNumber numberWithInt:dateType]]
2068 action:@selector(selectDateType:)
2070 [menuItem setTag:dateType];
2072 return [menuItem autorelease];
2075 - (NSMenu *)dateTypeMenu
2077 NSDictionary *dateTypeTitleDict = [NSDictionary dictionaryWithObjectsAndKeys:
2078 AILocalizedString(@"Any Date", nil), [NSNumber numberWithInt:AIDateTypeAnyDate],
2079 AILocalizedString(@"Today", nil), [NSNumber numberWithInt:AIDateTypeToday],
2080 AILocalizedString(@"Since Yesterday", nil), [NSNumber numberWithInt:AIDateTypeSinceYesterday],
2081 AILocalizedString(@"This Week", nil), [NSNumber numberWithInt:AIDateTypeThisWeek],
2082 AILocalizedString(@"Within Last 2 Weeks", nil), [NSNumber numberWithInt:AIDateTypeWithinLastTwoWeeks],
2083 AILocalizedString(@"This Month", nil), [NSNumber numberWithInt:AIDateTypeThisMonth],
2084 AILocalizedString(@"Within Last 2 Months", nil), [NSNumber numberWithInt:AIDateTypeWithinLastTwoMonths],
2086 NSMenu *dateTypeMenu = [[NSMenu alloc] init];
2087 AIDateType dateType;
2089 [dateTypeMenu addItem:[self _menuItemForDateType:AIDateTypeAnyDate dict:dateTypeTitleDict]];
2090 [dateTypeMenu addItem:[NSMenuItem separatorItem]];
2092 for (dateType = AIDateTypeToday; dateType < AIDateTypeExactly; dateType++) {
2093 [dateTypeMenu addItem:[self _menuItemForDateType:dateType dict:dateTypeTitleDict]];
2096 return [dateTypeMenu autorelease];
2099 - (int)daysSinceStartOfWeekGivenToday:(NSCalendarDate *)today
2101 int todayDayOfWeek = [today dayOfWeek];
2103 //Try to look at the iCal preferences if possible
2104 if (!iCalFirstDayOfWeekDetermined) {
2105 CFPropertyListRef iCalFirstDayOfWeek = CFPreferencesCopyAppValue(CFSTR("first day of week"),CFSTR("com.apple.iCal"));
2106 if (iCalFirstDayOfWeek) {
2107 //This should return a CFNumberRef... we're using another app's prefs, so make sure.
2108 if (CFGetTypeID(iCalFirstDayOfWeek) == CFNumberGetTypeID()) {
2109 firstDayOfWeek = [(NSNumber *)iCalFirstDayOfWeek intValue];
2112 CFRelease(iCalFirstDayOfWeek);
2116 iCalFirstDayOfWeekDetermined = YES;
2119 return ((todayDayOfWeek >= firstDayOfWeek) ? (todayDayOfWeek - firstDayOfWeek) : ((todayDayOfWeek + 7) - firstDayOfWeek));
2123 * @brief A new date type was selected
2125 * This does not start a search
2127 - (void)selectedDateType:(AIDateType)dateType
2129 NSCalendarDate *today = [NSCalendarDate date];
2131 [filterDate release]; filterDate = nil;
2134 case AIDateTypeAnyDate:
2135 filterDateType = AIDateTypeAnyDate;
2138 case AIDateTypeToday:
2139 filterDateType = AIDateTypeExactly;
2140 filterDate = [today retain];
2143 case AIDateTypeSinceYesterday:
2144 filterDateType = AIDateTypeAfter;
2145 filterDate = [[today dateByAddingYears:0
2148 hours:-[today hourOfDay]
2149 minutes:-[today minuteOfHour]
2150 seconds:-([today secondOfMinute] + 1)] retain];
2153 case AIDateTypeThisWeek:
2154 filterDateType = AIDateTypeAfter;
2155 filterDate = [[today dateByAddingYears:0
2157 days:-[self daysSinceStartOfWeekGivenToday:today]
2158 hours:-[today hourOfDay]
2159 minutes:-[today minuteOfHour]
2160 seconds:-([today secondOfMinute] + 1)] retain];
2163 case AIDateTypeWithinLastTwoWeeks:
2164 filterDateType = AIDateTypeAfter;
2165 filterDate = [[today dateByAddingYears:0
2168 hours:-[today hourOfDay]
2169 minutes:-[today minuteOfHour]
2170 seconds:-([today secondOfMinute] + 1)] retain];
2173 case AIDateTypeThisMonth:
2174 filterDateType = AIDateTypeAfter;
2175 filterDate = [[[NSCalendarDate date] dateByAddingYears:0
2177 days:-[today dayOfMonth]
2180 seconds:-1] retain];
2183 case AIDateTypeWithinLastTwoMonths:
2184 filterDateType = AIDateTypeAfter;
2185 filterDate = [[[NSCalendarDate date] dateByAddingYears:0
2187 days:-[today dayOfMonth]
2190 seconds:-1] retain];
2199 * @brief Select the date type
2201 - (void)selectDateType:(id)sender
2203 [self selectedDateType:[sender tag]];
2204 [self startSearchingClearingCurrentResults:YES];
2207 - (void)configureDateFilter
2209 firstDayOfWeek = 0; /* Sunday */
2210 iCalFirstDayOfWeekDetermined = NO;
2212 [popUp_dateFilter setMenu:[self dateTypeMenu]];
2213 int index = [popUp_dateFilter indexOfItemWithTag:AIDateTypeAnyDate];
2214 if(index != NSNotFound)
2215 [popUp_dateFilter selectItemAtIndex:index];
2216 [self selectedDateType:AIDateTypeAnyDate];
2219 #pragma mark Open Log
2221 - (void)openLogAtPath:(NSString *)inPath
2223 AIChatLog *chatLog = nil;
2224 NSString *basePath = [AILoggerPlugin logBasePath];
2226 //inPath should be in a folder of the form SERVICE.ACCOUNT_NAME/CONTACT_NAME/log.extension
2227 NSArray *pathComponents = [inPath pathComponents];
2228 int lastIndex = [pathComponents count];
2229 NSString *logName = [pathComponents objectAtIndex:--lastIndex];
2230 NSString *contactName = [pathComponents objectAtIndex:--lastIndex];
2231 NSString *serviceAndAccountName = [pathComponents objectAtIndex:--lastIndex];
2232 NSString *relativeToGroupPath = [serviceAndAccountName stringByAppendingPathComponent:contactName];
2234 NSString *serviceID = [[serviceAndAccountName componentsSeparatedByString:@"."] objectAtIndex:0];
2235 //Filter for logs from the contact associated with the log we're loading
2236 [self filterForContact:[[adium contactController] contactWithService:[[adium accountController] firstServiceWithServiceID:serviceID]
2240 NSString *canonicalBasePath = [basePath stringByStandardizingPath];
2241 NSString *canonicalInPath = [inPath stringByStandardizingPath];
2243 if ([canonicalInPath hasPrefix:[canonicalBasePath stringByAppendingString:@"/"]]) {
2244 AILogToGroup *logToGroup = [logToGroupDict objectForKey:[serviceAndAccountName stringByAppendingPathComponent:contactName]];
2246 chatLog = [logToGroup logAtPath:[relativeToGroupPath stringByAppendingPathComponent:logName]];
2249 /* Different Adium user... this sucks. We're given a path like this:
2250 * /Users/evands/Application Support/Adium 2.0/Users/OtherUser/Logs/AIM.Tekjew/HotChick001/HotChick001 (3-30-2005).AdiumLog
2251 * and we want to make it relative to our current user's logs folder, which might be
2252 * /Users/evands/Application Support/Adium 2.0/Users/Default/Logs
2254 * To achieve this, add a "/.." for each directory in our current user's logs folder, then add the full path to the log.
2256 NSString *fakeRelativePath = @"";
2258 //Use .. to get back to the root from the base path
2259 int componentsOfBasePath = [[canonicalBasePath pathComponents] count];
2260 for (int i = 0; i < componentsOfBasePath; i++) {
2261 fakeRelativePath = [fakeRelativePath stringByAppendingPathComponent:@".."];
2264 //Now add the path from the root to the actual log
2265 fakeRelativePath = [fakeRelativePath stringByAppendingPathComponent:canonicalInPath];
2266 chatLog = [[[AIChatLog alloc] initWithPath:fakeRelativePath
2267 from:[serviceAndAccountName substringFromIndex:([serviceID length] + 1)] //One off for the '.'
2269 serviceClass:serviceID] autorelease];
2272 //Now display the requested log
2274 [self displayLog:chatLog];
2278 #pragma mark Printing
2280 - (void)adiumPrint:(id)sender
2282 NSTextView *printView;
2283 NSPrintOperation *printOperation;
2284 NSPrintInfo *printInfo = [NSPrintInfo sharedPrintInfo];
2286 [printInfo setHorizontalPagination:NSFitPagination];
2287 [printInfo setHorizontallyCentered:NO];
2288 [printInfo setVerticallyCentered:NO];
2290 printView = [[NSTextView alloc] initWithFrame:[[NSPrintInfo sharedPrintInfo] imageablePageBounds]];
2291 [printView setVerticallyResizable:YES];
2292 [printView setHorizontallyResizable:NO];
2294 [[printView textStorage] setAttributedString:[textView_content textStorage]];
2296 printOperation = [NSPrintOperation printOperationWithView:printView printInfo:printInfo];
2297 [printOperation runOperationModalForWindow:[self window] delegate:nil
2298 didRunSelector:NULL contextInfo:NULL];
2299 [printView release];
2302 - (BOOL)validatePrintMenuItem:(NSMenuItem *)menuItem
2304 return ([displayedLogArray count] > 0);
2307 - (BOOL)validateToolbarItem:(NSToolbarItem *)theItem
2309 if ([[theItem itemIdentifier] isEqualToString:NSToolbarPrintItemIdentifier]) {
2310 return [self validatePrintMenuItem:nil];
2317 - (void)selectCachedIndex
2319 int numberOfRows = [tableView_results numberOfRows];
2321 if (cachedSelectionIndex < numberOfRows) {
2322 [tableView_results selectRowIndexes:[NSIndexSet indexSetWithIndex:cachedSelectionIndex]
2323 byExtendingSelection:NO];
2326 [tableView_results selectRowIndexes:[NSIndexSet indexSetWithIndex:(numberOfRows-1)]
2327 byExtendingSelection:NO];
2331 [tableView_results scrollRowToVisible:[[tableView_results selectedRowIndexes] firstIndex]];
2334 deleteOccurred = NO;
2337 #pragma mark Deletion
2340 * @brief Get an NSAlert to request deletion of multiple logs
2342 - (NSAlert *)alertForDeletionOfLogCount:(int)logCount
2344 NSAlert *alert = [[NSAlert alloc] init];
2345 [alert setMessageText:AILocalizedString(@"Delete Logs?",nil)];
2346 [alert setInformativeText:[NSString stringWithFormat:
2347 AILocalizedString(@"Are you sure you want to send %i logs to the Trash?",nil), logCount]];
2348 [alert addButtonWithTitle:DELETE];
2349 [alert addButtonWithTitle:AILocalizedString(@"Cancel",nil)];
2351 return [alert autorelease];
2355 * @brief Undo the deletion of one or more AIChatLogs
2357 * The logs will be marked for readdition to the index
2359 - (void)restoreDeletedLogs:(NSArray *)deletedLogs
2361 NSEnumerator *enumerator;
2363 NSFileManager *fileManager = [NSFileManager defaultManager];
2364 NSString *trashPath = [fileManager findFolderOfType:kTrashFolderType inDomain:kUserDomain createFolder:NO];
2366 enumerator = [deletedLogs objectEnumerator];
2367 while ((aLog = [enumerator nextObject])) {
2368 NSString *logPath = [[AILoggerPlugin logBasePath] stringByAppendingPathComponent:[aLog path]];
2370 [fileManager createDirectoriesForPath:[logPath stringByDeletingLastPathComponent]];
2372 [fileManager movePath:[trashPath stringByAppendingPathComponent:[logPath lastPathComponent]]
2376 [plugin markLogDirtyAtPath:logPath];
2379 [self rebuildIndices];
2382 - (void)deleteLogsAlertDidEnd:(NSAlert *)alert returnCode:(int)returnCode contextInfo:(void *)contextInfo;
2384 NSArray *selectedLogs = (NSArray *)contextInfo;
2385 if (returnCode == NSAlertFirstButtonReturn) {
2389 NSEnumerator *enumerator;
2390 NSMutableSet *logPaths = [NSMutableSet set];
2392 cachedSelectionIndex = [[tableView_results selectedRowIndexes] firstIndex];
2394 enumerator = [selectedLogs objectEnumerator];
2395 while ((aLog = [enumerator nextObject])) {
2396 NSString *logPath = [[AILoggerPlugin logBasePath] stringByAppendingPathComponent:[aLog path]];
2398 [[adium notificationCenter] postNotificationName:ChatLog_WillDelete object:aLog userInfo:nil];
2399 AILogToGroup *logToGroup = [logToGroupDict objectForKey:[NSString stringWithFormat:@"%@.%@/%@",[aLog serviceClass],[aLog from],[aLog to]]];
2400 BOOL success = [logToGroup trashLog:aLog];
2401 AILog(@"Trashing %@: %i",[aLog path], success);
2402 //Clear the to group out if it no longer has anything of interest
2403 if ([logToGroup logCount] == 0) {
2404 AILogFromGroup *logFromGroup = [logFromGroupDict objectForKey:[NSString stringWithFormat:@"%@.%@",[aLog serviceClass],[aLog from]]];
2405 [logFromGroup removeToGroup:logToGroup];
2408 [logPaths addObject:logPath];
2409 [currentSearchResults removeObjectIdenticalTo:aLog];
2412 [plugin removePathsFromIndex:logPaths];
2414 [undoManager registerUndoWithTarget:self
2415 selector:@selector(restoreDeletedLogs:)
2416 object:selectedLogs];
2417 [undoManager setActionName:DELETE];
2419 [resultsLock unlock];
2420 [tableView_results reloadData];
2422 deleteOccurred = YES;
2424 [self rebuildContactsList];
2425 [self updateProgressDisplay];
2427 [selectedLogs release];
2431 * @brief Delete logs
2433 * If two or more logs are passed, confirmation will be requested.
2434 * This operation registers with the window controller's undo manager.
2436 * @param selectedLogs An NSArray of logs to delete
2438 - (void)deleteLogs:(NSArray *)selectedLogs
2440 if ([selectedLogs count] > 1) {
2441 NSAlert *alert = [self alertForDeletionOfLogCount:[selectedLogs count]];
2442 [alert beginSheetModalForWindow:[self window]
2444 didEndSelector:@selector(deleteLogsAlertDidEnd:returnCode:contextInfo:)
2445 contextInfo:[selectedLogs retain]];
2447 [self deleteLogsAlertDidEnd:nil
2448 returnCode:NSAlertFirstButtonReturn
2449 contextInfo:[selectedLogs retain]];
2454 * @brief Returns a set of all selected to groups on all accounts
2456 * @param totalLogCount If non-NULL, will be set to the total number of logs on return
2458 - (NSArray *)allSelectedToGroups:(int *)totalLogCount
2460 NSEnumerator *fromEnumerator;
2461 AILogFromGroup *fromGroup;
2462 NSMutableArray *allToGroups = [NSMutableArray array];
2464 if (totalLogCount) *totalLogCount = 0;
2466 //Walk through every 'from' group
2467 fromEnumerator = [logFromGroupDict objectEnumerator];
2468 while ((fromGroup = [fromEnumerator nextObject])) {
2469 NSEnumerator *toEnumerator;
2470 AILogToGroup *toGroup;
2472 //Walk through every 'to' group
2473 toEnumerator = [[fromGroup toGroupArray] objectEnumerator];
2474 while ((toGroup = [toEnumerator nextObject])) {
2475 if (![contactIDsToFilter count] || [contactIDsToFilter containsObject:[[NSString stringWithFormat:@"%@.%@",[toGroup serviceClass],[toGroup to]] compactedString]]) {
2476 if (totalLogCount) {
2477 *totalLogCount += [toGroup logCount];
2480 [allToGroups addObject:toGroup];
2489 * @brief Undo the deletion of one or more AILogToGroups and their associated logs
2491 * The logs will be marked for readdition to the index
2493 - (void)restoreDeletedToGroups:(NSArray *)toGroups
2495 NSEnumerator *enumerator;
2496 AILogToGroup *toGroup;
2497 NSFileManager *fileManager = [NSFileManager defaultManager];
2498 NSString *trashPath = [fileManager findFolderOfType:kTrashFolderType inDomain:kUserDomain createFolder:NO];
2499 NSString *logBasePath = [AILoggerPlugin logBasePath];
2501 enumerator = [toGroups objectEnumerator];
2502 while ((toGroup = [enumerator nextObject])) {
2503 NSString *toGroupPath = [logBasePath stringByAppendingPathComponent:[toGroup path]];
2505 [fileManager createDirectoriesForPath:[toGroupPath stringByDeletingLastPathComponent]];
2506 if ([fileManager fileExistsAtPath:toGroupPath]) {
2507 AILog(@"Removing path %@ to make way for %@",
2508 toGroupPath,[trashPath stringByAppendingPathComponent:[toGroupPath lastPathComponent]]);
2509 [fileManager removeFileAtPath:toGroupPath
2512 [fileManager movePath:[trashPath stringByAppendingPathComponent:[toGroupPath lastPathComponent]]
2516 NSEnumerator *logEnumerator = [toGroup logEnumerator];
2519 while ((aLog = [logEnumerator nextObject])) {
2520 [plugin markLogDirtyAtPath:[logBasePath stringByAppendingPathComponent:[aLog path]]];
2524 [self rebuildIndices];
2527 - (void)deleteSelectedContactsFromSourceListAlertDidEnd:(NSAlert *)alert returnCode:(int)returnCode contextInfo:(void *)contextInfo;
2529 NSArray *allSelectedToGroups = (NSArray *)contextInfo;
2530 if (returnCode == NSAlertFirstButtonReturn) {
2531 AILogToGroup *logToGroup;
2532 NSEnumerator *enumerator;
2533 NSMutableSet *logPaths = [NSMutableSet set];
2535 enumerator = [allSelectedToGroups objectEnumerator];
2536 while ((logToGroup = [enumerator nextObject])) {
2537 NSEnumerator *logEnumerator;
2540 logEnumerator = [logToGroup logEnumerator];
2541 while ((aLog = [logEnumerator nextObject])) {
2542 NSString *logPath = [[AILoggerPlugin logBasePath] stringByAppendingPathComponent:[aLog path]];
2543 [logPaths addObject:logPath];
2546 AILogFromGroup *logFromGroup = [logFromGroupDict objectForKey:[NSString stringWithFormat:@"%@.%@",[logToGroup serviceClass],[logToGroup from]]];
2547 [logFromGroup removeToGroup:logToGroup];
2550 [plugin removePathsFromIndex:logPaths];
2552 [undoManager registerUndoWithTarget:self
2553 selector:@selector(restoreDeletedToGroups:)
2554 object:allSelectedToGroups];
2555 [undoManager setActionName:DELETE];
2557 [self rebuildIndices];
2558 [self updateProgressDisplay];
2561 [allSelectedToGroups release];
2565 * @brief Delete entirely the logs of all contacts selected in the source list
2567 * Confirmation by the user will be required.
2569 * Note: A single item in the source list may have multiple associated AILogToGroups.
2571 - (void)deleteSelectedContactsFromSourceList
2574 NSArray *allSelectedToGroups = [self allSelectedToGroups:&totalLogCount];
2576 if (totalLogCount > 1) {
2577 NSAlert *alert = [self alertForDeletionOfLogCount:totalLogCount];
2578 [alert beginSheetModalForWindow:[self window]
2580 didEndSelector:@selector(deleteSelectedContactsFromSourceListAlertDidEnd:returnCode:contextInfo:)
2581 contextInfo:[allSelectedToGroups retain]];
2583 [self deleteSelectedContactsFromSourceListAlertDidEnd:nil
2584 returnCode:NSAlertFirstButtonReturn
2585 contextInfo:[allSelectedToGroups retain]];
2590 * @brief Delete the current selection
2592 * If the contacts outline view is selected, one or more contacts' logs will be trashed.
2593 * If anything else is selected, the currently selected search result logs will be trashed.
2595 - (void)deleteSelection:(id)sender
2597 if ([[self window] firstResponder] == outlineView_contacts) {
2598 [self deleteSelectedContactsFromSourceList];
2602 NSArray *selectedLogs = [tableView_results arrayOfSelectedItemsUsingSourceArray:currentSearchResults];
2603 [resultsLock unlock];
2605 [self deleteLogs:selectedLogs];
2611 * @brief Supply our undo manager when we are within the responder chain
2613 - (NSUndoManager *)windowWillReturnUndoManager:(NSWindow *)sender