* Adium is the legal property of its developers, whose names are listed in the copyright file included * with this source distribution. * This program is free software; you can redistribute it and/or modify it under the terms of the GNU * General Public License as published by the Free Software Foundation; either version 2 of the License, * or (at your option) any later version. * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General * Public License for more details. * You should have received a copy of the GNU General Public License along with this program; if not, * write to the Free Software Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. #import <Adium/AIContentControllerProtocol.h> #import <Adium/AIMenuControllerProtocol.h> #import <Adium/AIToolbarControllerProtocol.h> #import "ESApplescriptabilityController.h" #import "GBApplescriptFiltersPlugin.h" #import <AIUtilities/AIMenuAdditions.h> #import <AIUtilities/AIToolbarUtilities.h> #import <AIUtilities/AIImageAdditions.h> #import <AIUtilities/MVMenuButton.h> #import <Adium/AIContentObject.h> #import <Adium/AIHTMLDecoder.h> #define TITLE_INSERT_SCRIPT AILocalizedString(@"Insert Script",nil) #define SCRIPT_BUNDLE_EXTENSION @"AdiumScripts" #define SCRIPTS_PATH_NAME @"Scripts" #define SCRIPT_EXTENSION @"scpt" #define SCRIPT_IDENTIFIER @"InsertScript" #define SCRIPT_TIMEOUT 30 @interface GBApplescriptFiltersPlugin () - (NSArray *)_argumentsFromString:(NSString *)inString forScript:(NSMutableDictionary *)scriptDict; - (void)_appendScripts:(NSArray *)scripts toMenu:(NSMenu *)menu; - (void)registerToolbarItem; - (void)xtrasChanged:(NSNotification *)notification; - (IBAction)selectScript:(id)sender; - (void)applescriptDidRun:(id)userInfo resultString:(NSString *)resultString; - (IBAction)dummyTarget:(id)sender; - (void)_replaceKeyword:(NSString *)keyword withScript:(NSMutableDictionary *)infoDict inString:(NSString *)inString inAttributedString:(NSMutableAttributedString *)attributedString uniqueID:(unsigned long long)uniqueID; - (void)_executeScript:(NSMutableDictionary *)infoDict withArguments:(NSArray *)arguments forAttributedString:(NSMutableAttributedString *)attributedString keywordRange:(NSRange)keywordRange uniqueID:(unsigned long long)uniqueID; NSInteger _scriptTitleSort(id scriptA, id scriptB, void *context); NSInteger _scriptKeywordLengthSort(id scriptA, id scriptB, void *context); * @class GBApplescriptFiltersPlugin * @brief Filter component to allow .AdiumScripts applescript-based filters for outgoing messages @implementation GBApplescriptFiltersPlugin [adium createResourcePathForName:@"Scripts"]; //We have an array of scripts for building the menu, and a dictionary of scripts used for the actual substition //Prepare our script menu item (which will have the Scripts menu as its submenu) scriptMenuItem = [[NSMenuItem alloc] initWithTitle:TITLE_INSERT_SCRIPT action:@selector(dummyTarget:) //Perform substitutions on outgoing content; we may be slow, so register as a delayed content filter [adium.contentController registerDelayedContentFilter:self direction:AIFilterOutgoing]; //Observe for installation of new scripts [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(xtrasChanged:) name:AIXtrasDidChangeNotification [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(toolbarWillAddItem:) name:NSToolbarWillAddItemNotification //Start building the script menu [self buildScriptMenu]; //this also sets the submenu for the menu item. [adium.menuController addMenuItem:scriptMenuItem toLocation:LOC_Edit_Additions]; contextualScriptMenuItem = [scriptMenuItem copy]; [adium.menuController addContextualMenuItem:contextualScriptMenuItem toLocation:Context_TextView_Edit]; [[NSNotificationCenter defaultCenter] removeObserver:self]; [scriptArray release]; scriptArray = nil; [flatScriptArray release]; flatScriptArray = nil; [scriptMenuItem release]; scriptMenuItem = nil; [contextualScriptMenuItem release]; contextualScriptMenuItem = nil; * If the scripts xtras changed, rebuild our menus. - (void)xtrasChanged:(NSNotification *)notification if ([[notification object] caseInsensitiveCompare:@"AdiumScripts"] == NSOrderedSame) { [self registerToolbarItem]; //Update our toolbar item's menu //[self toolbarWillAddItem:nil]; //Script Loading ------------------------------------------------------------------------------------------------------- #pragma mark Script Loading * @brief Load our scripts * This will clear out and then load from available scripts (external and internal) into flatScriptArray and scriptArray. [scriptArray release]; scriptArray = [[NSMutableArray alloc] init]; [flatScriptArray release]; flatScriptArray = [[NSMutableArray alloc] init]; for (NSString *filePath in [adium allResourcesForName:@"Scripts" withExtensions:SCRIPT_BUNDLE_EXTENSION]) { if ((scriptBundle = [NSBundle bundleWithPath:filePath])) { NSString *scriptsSetName; NSDictionary *infoDict = [NSDictionary dictionaryWithContentsOfFile:[[scriptBundle bundlePath] stringByAppendingPathComponent:@"Info.plist"]]; if (!infoDict) infoDict= [scriptBundle infoDictionary]; NSDictionary *localizedInfoDict = [scriptBundle localizedInfoDictionary]; //Get the name of the set these scripts will go into scriptsSetName = [localizedInfoDict objectForKey:@"Set"]; if (!scriptsSetName) scriptsSetName = [infoDict objectForKey:@"Set"]; //Now enumerate each script the bundle claims as its own for (NSDictionary *scriptDict in [infoDict objectForKey:@"Scripts"]) { NSString *scriptFileName, *scriptFilePath, *keyword, *title; NSNumber *prefixOnlyNumber; if ((scriptFileName = [scriptDict objectForKey:@"File"]) && (scriptFilePath = [scriptBundle pathForResource:scriptFileName ofType:SCRIPT_EXTENSION])) { keyword = [scriptDict objectForKey:@"Keyword"]; title = [scriptDict objectForKey:@"Title"]; //The keywords titles are keyed by their English version in the localized info dict NSString *localizedKeyword = [localizedInfoDict objectForKey:keyword]; if (localizedKeyword) keyword = localizedKeyword; NSString *localizedTitle = [localizedInfoDict objectForKey:title]; if (localizedTitle) title = localizedTitle; if (keyword && [keyword length] && title && [title length]) { NSMutableDictionary *newInfoDict; arguments = [[scriptDict objectForKey:@"Arguments"] componentsSeparatedByString:@","]; //Assume "Prefix Only" is NO unless told otherwise or the keyword starts with '/' prefixOnlyNumber = [scriptDict objectForKey:@"Prefix Only"]; prefixOnlyNumber = [NSNumber numberWithBool:[keyword hasPrefix:@"/"]]; newInfoDict = [NSMutableDictionary dictionaryWithObjectsAndKeys: scriptFilePath, @"Path", keyword, @"Keyword", title, @"Title", prefixOnlyNumber, @"PrefixOnly", nil]; //The bundle may not be part of (or for defining) a set of scripts [newInfoDict setObject:scriptsSetName forKey:@"Set"]; [newInfoDict setObject:arguments forKey:@"Arguments"]; //Place the entry in our script arrays [scriptArray addObject:newInfoDict]; [flatScriptArray addObject:newInfoDict]; //Scripts must always be updated via polling [adium.contentController registerFilterStringWhichRequiresPolling:keyword]; NSLog(@"Warning: Could not load Adium script bundle at %@",filePath); //Script Menu ---------------------------------------------------------------------------------------------------------- * @brief Build the script menu * Loads the scrpts as necessary, sorts them, then builds menus for the menu bar, the contextual menu, [scriptArray sortUsingFunction:_scriptTitleSort context:nil]; [flatScriptArray sortUsingFunction:_scriptKeywordLengthSort context:nil]; [scriptMenu release]; scriptMenu = [[NSMenu alloc] initWithTitle:TITLE_INSERT_SCRIPT]; [self _appendScripts:scriptArray toMenu:scriptMenu]; [scriptMenuItem setSubmenu:scriptMenu]; [contextualScriptMenuItem setSubmenu:[[scriptMenu copy] autorelease]]; [self registerToolbarItem]; * @brief Sort first by set, then by title within sets NSInteger _scriptTitleSort(id scriptA, id scriptB, void *context) { NSComparisonResult result; NSString *setA = [scriptA objectForKey:@"Set"]; NSString *setB = [scriptB objectForKey:@"Set"]; //If both are within sets, sort by set; if they are within the same set, sort by title if ((result = [setA caseInsensitiveCompare:setB]) == NSOrderedSame) { result = [(NSString *)[scriptA objectForKey:@"Title"] caseInsensitiveCompare:[scriptB objectForKey:@"Title"]]; //Sort by title if neither is in a set; otherwise sort the one in a set to the top result = [(NSString *)[scriptA objectForKey:@"Title"] caseInsensitiveCompare:[scriptB objectForKey:@"Title"]]; result = NSOrderedDescending; result = NSOrderedAscending; * @brief Sort by descending length so the longest keywords are at the beginning of the array NSInteger _scriptKeywordLengthSort(id scriptA, id scriptB, void *context) NSComparisonResult result; NSUInteger lengthA = [(NSString *)[scriptA objectForKey:@"Keyword"] length]; NSUInteger lengthB = [(NSString *)[scriptB objectForKey:@"Keyword"] length]; result = NSOrderedAscending; } else if (lengthA < lengthB) { result = NSOrderedDescending; * @brief Append an array of scripts to a menu * @param scripts The scripts, each of which is represented by an NSDictionary instance * @param menu The menu to which to add the scripts - (void)_appendScripts:(NSArray *)scripts toMenu:(NSMenu *)menu NSDictionary *appendDict; NSInteger indentationLevel; for (appendDict in scripts) { if ((set = [appendDict objectForKey:@"Set"])) { if (![set isEqualToString:lastSet]) { //We have a new set of scripts; create a section header for them item = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:set keyEquivalent:@""] autorelease]; if ([item respondsToSelector:@selector(setIndentationLevel:)]) [item setIndentationLevel:0]; [lastSet release]; lastSet = [set retain]; //Scripts not in sets need not be indented [lastSet release]; lastSet = nil; if ([appendDict objectForKey:@"Title"]) { title = [NSString stringWithFormat:@"%@ (%@)", [appendDict objectForKey:@"Title"], [appendDict objectForKey:@"Keyword"]]; title = [appendDict objectForKey:@"Keyword"]; item = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:title action:@selector(selectScript:) keyEquivalent:@""] autorelease]; [item setRepresentedObject:appendDict]; if ([item respondsToSelector:@selector(setIndentationLevel:)]) [item setIndentationLevel:indentationLevel]; * @brief Insert a script's keyword into the text entry area * This will be called by an NSMenuItem when it is clicked. - (IBAction)selectScript:(id)sender NSResponder *responder = [[[NSApplication sharedApplication] keyWindow] firstResponder]; //Append our string into the responder if possible if (responder && [responder isKindOfClass:[NSTextView class]]) { NSArray *arguments = [[sender representedObject] objectForKey:@"Arguments"]; NSString *replacementText = [[sender representedObject] objectForKey:@"Keyword"]; [(NSTextView *)responder insertText:replacementText]; //Append arg list to replacement string, to show the user what they can pass NSDictionary *originalTypingAttributes = [(NSTextView *)responder typingAttributes]; NSMutableDictionary *italicizedTypingAttributes = [originalTypingAttributes mutableCopy]; [italicizedTypingAttributes setObject:[[NSFontManager sharedFontManager] convertFont:[originalTypingAttributes objectForKey:NSFontAttributeName] toHaveTrait:NSItalicFontMask] forKey:NSFontAttributeName]; [(NSTextView *)responder insertText:@"{"]; //Will that be a five minute argument or the full half hour? for (anArgument in arguments) { //Insert a comma after each argument past the first [(NSTextView *)responder insertText:@","]; //Turn on the italics version, insert the argument, then go back to normal for either the comma or the ending [(NSTextView *)responder setTypingAttributes:italicizedTypingAttributes]; [(NSTextView *)responder insertText:anArgument]; [(NSTextView *)responder setTypingAttributes:originalTypingAttributes]; [(NSTextView *)responder insertText:@"}"]; [italicizedTypingAttributes release]; * @brief Fake target to allow validateMenuItem: to be called -(IBAction)dummyTarget:(id)sender{ * @brief Validate menu item * Disable the insertion if a text field is not active - (BOOL)validateMenuItem:(NSMenuItem *)menuItem if ((menuItem == scriptMenuItem) || (menuItem == contextualScriptMenuItem)) { return YES; //Always keep the submenu enabled so users can see the available scripts NSResponder *responder = [[[NSApplication sharedApplication] keyWindow] firstResponder]; if (responder && [responder isKindOfClass:[NSText class]]) { return [(NSText *)responder isEditable]; //Message Filtering ---------------------------------------------------------------------------------------------------- #pragma mark Message Filtering * @brief Delayed filter messages for keywords to replace * Will eventually replace any script keywords with the result of running the script (with arguments as appropriate). * @result YES if we began a delayed filtration; NO if we did not - (BOOL)delayedFilterAttributedString:(NSAttributedString *)inAttributedString context:(id)context uniqueID:(unsigned long long)uniqueID BOOL beganProcessing = NO; if ((stringMessage = [inAttributedString string])) { for (NSMutableDictionary *infoDict in flatScriptArray) { NSString *keyword = [infoDict objectForKey:@"Keyword"]; BOOL prefixOnly = [[infoDict objectForKey:@"PrefixOnly"] boolValue]; if ((prefixOnly && ([stringMessage rangeOfString:keyword options:(NSCaseInsensitiveSearch | NSAnchoredSearch)].location == 0)) || (!prefixOnly && [stringMessage rangeOfString:keyword options:NSCaseInsensitiveSearch].location != NSNotFound)) { NSNumber *shouldSendNumber; [self _replaceKeyword:keyword inAttributedString:[[inAttributedString mutableCopy] autorelease] shouldSendNumber = [infoDict objectForKey:@"ShouldSend"]; if ((shouldSendNumber) && (![shouldSendNumber boolValue]) && ([context isKindOfClass:[AIContentObject class]])) { [(AIContentObject *)context setSendContent:NO]; * Filter earlier than the default - (CGFloat)filterPriority return HIGH_FILTER_PRIORITY; * @brief Replace one instance of a keyword within a string. This will be called once for each instance. - (void)_replaceKeyword:(NSString *)keyword withScript:(NSMutableDictionary *)infoDict inString:(NSString *)inString inAttributedString:(NSMutableAttributedString *)attributedString uniqueID:(unsigned long long)uniqueID scanner = [NSScanner scannerWithString:inString]; while (![scanner isAtEnd] && !foundKeyword) { [scanner scanUpToString:keyword intoString:nil]; if (([scanner scanString:keyword intoString:nil]) && ([attributedString attribute:NSLinkAttributeName atIndex:([scanner scanLocation]-1) /* The scanner ends up one past the keyword */ effectiveRange:nil] == nil)) { //Scan the keyword and ensure it was not found within a link NSInteger keywordStart, keywordEnd; keywordStart = [scanner scanLocation] - [keyword length]; if ([scanner scanString:@"{" intoString:nil]) { if ([scanner scanUpToString:@"}" intoString:&argString]) { argArray = [self _argumentsFromString:argString forScript:infoDict]; [scanner scanString:@"}" intoString:nil]; keywordEnd = [scanner scanLocation]; NSRange keywordRange = NSMakeRange(keywordStart, keywordEnd - keywordStart); [self _executeScript:infoDict forAttributedString:attributedString keywordRange:keywordRange * @brief Execute the script as a separate task * When the task is complete, we will be notified, at which point we perform the replacement for the script result * and pass the modified attributed string back to the content controller for use. - (void)_executeScript:(NSMutableDictionary *)infoDict withArguments:(NSArray *)arguments forAttributedString:(NSMutableAttributedString *)attributedString keywordRange:(NSRange)keywordRange uniqueID:(unsigned long long)uniqueID NSDictionary *userInfo = [NSDictionary dictionaryWithObjectsAndKeys: attributedString, @"Mutable Attributed String", NSStringFromRange(keywordRange), @"Range", [NSNumber numberWithUnsignedLongLong:uniqueID], @"uniqueID", (context ? context : [NSNull null]), @"context", [adium.applescriptabilityController runApplescriptAtPath:[infoDict objectForKey:@"Path"] selector:@selector(applescriptDidRun:resultString:) * @brief A script finished running - (void)applescriptDidRun:(id)userInfo resultString:(NSString *)resultString NSMutableAttributedString *attributedString = [userInfo objectForKey:@"Mutable Attributed String"]; NSRange keywordRange = NSRangeFromString([userInfo objectForKey:@"Range"]); unsigned long long uniqueID = [[userInfo objectForKey:@"uniqueID"] unsignedLongLongValue]; //If the script fails, eat the keyword if (!resultString) resultString = @""; //Replace the substring with script result if (NSMaxRange(keywordRange) <= [attributedString length]) { if (([resultString hasPrefix:@"<HTML>"])) { //Obtain the attributed string version of the HTML, passing our current attributes as the default ones NSAttributedString *attributedScriptResult = [AIHTMLDecoder decodeHTML:resultString withDefaultAttributes:[attributedString attributesAtIndex:keywordRange.location [attributedString replaceCharactersInRange:keywordRange withAttributedString:attributedScriptResult]; [attributedString replaceCharactersInRange:keywordRange withString:resultString]; //Inform the content controller that we're done if we don't need to do any more filtering if (![self delayedFilterAttributedString:attributedString context:[userInfo objectForKey:@"context"] [adium.contentController delayedFilterDidFinish:attributedString * @brief Determine the arguments for a script execution * @param inString The string of potential arguments * @param scriptDict The script being executed * @result An NSArray of NSString instances - (NSArray *)_argumentsFromString:(NSString *)inString forScript:(NSMutableDictionary *)scriptDict NSArray *scriptArguments = [scriptDict objectForKey:@"Arguments"]; NSMutableArray *argArray = [NSMutableArray array]; NSArray *inStringComponents = [inString componentsSeparatedByString:@","]; NSUInteger count = (scriptArguments ? [scriptArguments count] : 0); NSUInteger inStringComponentsCount = [inStringComponents count]; //Add each argument of inString to argArray so long as the number of arguments is less //than the number of expected arguments for the script and the number of supplied arguments while ((i < count) && (i < inStringComponentsCount)) { [argArray addObject:[inStringComponents objectAtIndex:i]]; //If more components were passed than were actually requested, the last argument gets the if (i < inStringComponentsCount) { //i was incremented to end the while loop if i > 0, so subtract 1 to reexamine the last object remainingRange.location = ((i > 0) ? i-1 : 0); remainingRange.length = (inStringComponentsCount - remainingRange.location); if (remainingRange.location != NSNotFound) { //Remove that last, incomplete argument if it was added if ([argArray count]) [argArray removeLastObject]; //Create the last argument by joining all remaining comma-separated arguments with a comma lastArgument = [[inStringComponents subarrayWithRange:remainingRange] componentsJoinedByString:@","]; [argArray addObject:lastArgument]; #pragma mark Toolbar item * @brief Register our insert script toolbar item - (void)registerToolbarItem //Unregister the existing toolbar item first [adium.toolbarController unregisterToolbarItem:toolbarItem forToolbarType:@"TextEntry"]; [toolbarItem release]; toolbarItem = nil; //Register our toolbar item button = [[[MVMenuButton alloc] initWithFrame:NSMakeRect(0,0,32,32)] autorelease]; [button setImage:[NSImage imageNamed:@"msg-insert-script" forClass:[self class] loadLazily:YES]]; toolbarItem = [[AIToolbarUtilities toolbarItemWithIdentifier:SCRIPT_IDENTIFIER label:AILocalizedString(@"Scripts",nil) paletteLabel:TITLE_INSERT_SCRIPT toolTip:AILocalizedString(@"Insert a script",nil) settingSelector:@selector(setView:) action:@selector(selectScript:) [toolbarItem setMinSize:NSMakeSize(32,32)]; [toolbarItem setMaxSize:NSMakeSize(32,32)]; [button setToolbarItem:toolbarItem]; [adium.toolbarController registerToolbarItem:toolbarItem forToolbarType:@"TextEntry"]; * @brief After the toolbar has added the item we can set up the submenus - (void)toolbarWillAddItem:(NSNotification *)notification NSToolbarItem *item = [[notification userInfo] objectForKey:@"item"]; if (!notification || ([[item itemIdentifier] isEqualToString:SCRIPT_IDENTIFIER])) { NSMenu *menu = [[[scriptMenuItem submenu] copy] autorelease]; [[item view] setMenu:menu]; //Add menu to toolbar item (for text mode) NSMenuItem *mItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] init] autorelease]; [mItem setTitle:[menu title]]; [item setMenuFormRepresentation:mItem];