Move us off of deprecated CFXMLParser and onto NSXMLDocument for parsing chat transcripts.
2 * Adium is the legal property of its developers, whose names are listed in the copyright file included
3 * with this source distribution.
5 * This program is free software; you can redistribute it and/or modify it under the terms of the GNU
6 * General Public License as published by the Free Software Foundation; either version 2 of the License,
7 * or (at your option) any later version.
9 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
10 * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
11 * Public License for more details.
13 * You should have received a copy of the GNU General Public License along with this program; if not,
14 * write to the Free Software Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
17 #import "AIXMLChatlogConverter.h"
18 #import "AIStandardListWindowController.h"
19 #import <Adium/AIHTMLDecoder.h>
20 #import <Adium/AIListContact.h>
21 #import <Adium/AIService.h>
22 #import <Adium/AIAccountControllerProtocol.h>
23 #import <Adium/AIContactControllerProtocol.h>
24 #import <Adium/AIContentControllerProtocol.h>
25 #import <Adium/AIStatusControllerProtocol.h>
26 #import <AIUtilities/NSCalendarDate+ISO8601Parsing.h>
27 #import <AIUtilities/AIDateFormatterAdditions.h>
28 #import <AIUtilities/AIStringAdditions.h>
30 #define PREF_GROUP_WEBKIT_MESSAGE_DISPLAY @"WebKit Message Display"
31 #define KEY_WEBKIT_USE_NAME_FORMAT @"Use Custom Name Format"
32 #define KEY_WEBKIT_NAME_FORMAT @"Name Format"
34 @interface NSMutableString (XMLMethods)
35 - (void)stripInvalidCharacters;
38 @implementation NSMutableString (XMLMethods)
40 //Strip invalid XML characters
41 - (void)stripInvalidCharacters
43 static NSCharacterSet *invalidXMLCharacterSet;
45 if (invalidXMLCharacterSet == nil)
47 // First, create a character set containing all valid UTF8 characters.
48 NSMutableCharacterSet *xmlCharacterSet = [[NSMutableCharacterSet alloc] init];
49 [xmlCharacterSet addCharactersInRange:NSMakeRange(0x9, 1)];
50 [xmlCharacterSet addCharactersInRange:NSMakeRange(0xA, 1)];
51 [xmlCharacterSet addCharactersInRange:NSMakeRange(0xD, 1)];
52 [xmlCharacterSet addCharactersInRange:NSMakeRange(0x20, 0xD7FF - 0x20)];
53 [xmlCharacterSet addCharactersInRange:NSMakeRange(0xE000, 0xFFFD - 0xE000)];
54 [xmlCharacterSet addCharactersInRange:NSMakeRange(0x10000, 0x10FFFF - 0x10000)];
55 // Then create and retain an inverted set, which will thus contain all invalid XML characters.
56 invalidXMLCharacterSet = [[xmlCharacterSet invertedSet] retain];
57 [xmlCharacterSet release];
60 // Are there any invalid characters in this string?
61 NSRange range = [self rangeOfCharacterFromSet:invalidXMLCharacterSet];
63 // Otherwise go through and remove any illegal XML characters from a copy of the string.
64 while (range.length > 0)
66 [self deleteCharactersInRange:range];
67 range = [self rangeOfCharacterFromSet:invalidXMLCharacterSet
69 range:NSMakeRange(range.location,[self length]-range.location)];
75 @interface NSXMLElement (AIAttributeDict)
76 - (NSDictionary *)AIAttributesAsDictionary;
79 @implementation NSXMLElement (AIAttributeDict)
81 - (NSDictionary *)AIAttributesAsDictionary
83 NSArray *attrArray = [self attributes];
84 NSMutableDictionary *attributes = [NSMutableDictionary dictionary];
85 for (NSXMLNode *attr in attrArray) {
86 [attributes setObject:attr forKey:[attr name]];
93 @interface AIXMLChatlogConverter()
94 - (NSAttributedString *)readData:(NSData *)xmlData withOptions:(NSDictionary *)options retrying:(BOOL)reentrancyFlag;
97 @implementation AIXMLChatlogConverter
99 + (NSAttributedString *)readFile:(NSString *)filePath withOptions:(NSDictionary *)options
101 static AIXMLChatlogConverter *converter;
103 converter = [[AIXMLChatlogConverter alloc] init];
105 NSData *xmlData = [NSData dataWithContentsOfURL:[NSURL fileURLWithPath:filePath]];
106 [converter->htmlDecoder setBaseURL:[filePath stringByDeletingLastPathComponent]];
107 NSAttributedString *result = nil;
109 result = [converter readData:xmlData withOptions:options retrying:NO];
110 } @catch (NSException *e) {
111 NSLog(@"Error \"%@\" parsing log file at %@.", e, filePath);
112 return [[[NSAttributedString alloc] initWithString:@"Sorry, there was an error parsing this transcript. It may be corrupt."] autorelease];
119 if ((self = [super init])) {
120 if (!newlineAttributedString) {
121 newlineAttributedString = [[NSAttributedString alloc] initWithString:@"\n" attributes:nil];
124 htmlDecoder = [[AIHTMLDecoder alloc] init];
125 dateFormatter = [[NSDateFormatter localizedDateFormatterShowingSeconds:YES showingAMorPM:YES] retain];
127 statusLookup = [[NSDictionary alloc] initWithObjectsAndKeys:
128 AILocalizedString(@"Online", nil), @"online",
129 AILocalizedString(@"Idle", nil), @"idle",
130 [adium.statusController localizedDescriptionForCoreStatusName:STATUS_NAME_OFFLINE], @"offline",
131 [adium.statusController localizedDescriptionForCoreStatusName:STATUS_NAME_AWAY], @"away",
132 [adium.statusController localizedDescriptionForCoreStatusName:STATUS_NAME_AVAILABLE], @"available",
133 [adium.statusController localizedDescriptionForCoreStatusName:STATUS_NAME_BUSY], @"busy",
134 [adium.statusController localizedDescriptionForCoreStatusName:STATUS_NAME_NOT_AT_HOME], @"notAtHome",
135 [adium.statusController localizedDescriptionForCoreStatusName:STATUS_NAME_PHONE], @"onThePhone",
136 [adium.statusController localizedDescriptionForCoreStatusName:STATUS_NAME_VACATION], @"onVacation",
137 [adium.statusController localizedDescriptionForCoreStatusName:STATUS_NAME_DND], @"doNotDisturb",
138 [adium.statusController localizedDescriptionForCoreStatusName:STATUS_NAME_EXTENDED_AWAY], @"extendedAway",
139 [adium.statusController localizedDescriptionForCoreStatusName:STATUS_NAME_BRB], @"beRightBack",
140 [adium.statusController localizedDescriptionForCoreStatusName:STATUS_NAME_NOT_AVAILABLE], @"notAvailable",
141 [adium.statusController localizedDescriptionForCoreStatusName:STATUS_NAME_NOT_AT_DESK], @"notAtMyDesk",
142 [adium.statusController localizedDescriptionForCoreStatusName:STATUS_NAME_NOT_IN_OFFICE], @"notInTheOffice",
143 [adium.statusController localizedDescriptionForCoreStatusName:STATUS_NAME_STEPPED_OUT], @"steppedOut",
152 [dateFormatter release];
153 [statusLookup release];
154 [htmlDecoder release];
158 - (NSAttributedString *)readData:(NSData *)xmlData withOptions:(NSDictionary *)options retrying:(BOOL)reentrancyFlag
161 return [[[NSAttributedString alloc] initWithString:@""] autorelease];
163 AINameFormat nameFormat;
164 if ([[adium.preferenceController preferenceForKey:KEY_WEBKIT_USE_NAME_FORMAT
165 group:PREF_GROUP_WEBKIT_MESSAGE_DISPLAY] boolValue]) {
166 nameFormat = [[adium.preferenceController preferenceForKey:KEY_WEBKIT_NAME_FORMAT
167 group:PREF_GROUP_WEBKIT_MESSAGE_DISPLAY] intValue];
169 nameFormat = AIDefaultName;
171 NSMutableAttributedString *output = [[[NSMutableAttributedString alloc] init] autorelease];
174 NSXMLDocument *xmlDoc = [[[NSXMLDocument alloc] initWithData:xmlData
175 options:NSXMLNodePreserveCDATA
176 error:&err] autorelease];
183 BOOL showTimestamps = [[options objectForKey:@"showTimestamps"] boolValue];
184 BOOL showEmoticons = [[options objectForKey:@"showEmoticons"] boolValue];
186 NSXMLElement *chatElement = [[xmlDoc nodesForXPath:@"//chat" error:&err] lastObject];
188 NSDictionary *chatAttributes = [chatElement AIAttributesAsDictionary];
189 NSString *mySN = [[chatAttributes objectForKey:@"account"] stringValue];
190 NSString *service = [[chatAttributes objectForKey:@"service"] stringValue];
192 NSString *myDisplayName = nil;
194 for (AIAccount *account in adium.accountController.accounts) {
195 if ([[account.UID compactedString] isEqualToString:[mySN compactedString]] &&
196 [account.service.serviceID isEqualToString:service]) {
197 myDisplayName = [account.displayName retain];
202 NSArray *elements = [xmlDoc nodesForXPath:@"//message | //status" error:&err];
207 for (NSXMLElement *element in elements) {
208 NSString *type = [element name];
210 NSDictionary *attributes = [element AIAttributesAsDictionary];
212 if ([type isEqualToString:@"message"]) {
213 NSString *senderAlias = [[attributes objectForKey:@"alias"] stringValue];
214 NSString *dateStr = [[attributes objectForKey:@"time"] stringValue];
215 NSDate *date = dateStr ? [NSCalendarDate calendarDateWithString:dateStr] : nil;
216 NSString *sender = [[attributes objectForKey:@"sender"] stringValue];
217 NSString *shownSender = (senderAlias ? senderAlias : sender);
218 BOOL autoResponse = [[[attributes objectForKey:@"auto"] stringValue] isEqualToString:@"true"];
220 NSMutableString *messageXML = [NSMutableString string];
221 for (NSXMLNode *node in [element children]) {
222 [messageXML appendString:[node XMLString]];
225 NSString *displayName = nil, *longDisplayName = nil;
227 BOOL sentMessage = [mySN isEqualToString:sender];
231 //Find an account if one exists, and use its name
232 displayName = (myDisplayName ? myDisplayName : sender);
234 AIListObject *listObject = [adium.contactController existingListObjectWithUniqueID:[AIListObject internalObjectIDForServiceID:service UID:sender]];
236 displayName = listObject.displayName;
237 longDisplayName = [listObject longDisplayName];
240 if (displayName && !sentMessage) {
241 switch (nameFormat) {
243 shownSender = (longDisplayName ? longDisplayName : displayName);
247 shownSender = displayName;
250 case AIDisplayName_ScreenName:
251 shownSender = [NSString stringWithFormat:@"%@ (%@)",displayName,sender];
254 case AIScreenName_DisplayName:
255 shownSender = [NSString stringWithFormat:@"%@ (%@)",sender,displayName];
259 shownSender = sender;
264 NSString *timestampStr = [dateFormatter stringFromDate:date];
266 [output appendAttributedString:[htmlDecoder decodeHTML:[NSString stringWithFormat:
267 @"<div class=\"%@\">%@<span class=\"sender\">%@%@:</span></div> ",
268 (sentMessage ? @"send" : @"receive"),
269 (showTimestamps ? [NSString stringWithFormat:@"<span class=\"timestamp\">%@</span> ", timestampStr] : @""),
270 shownSender, (autoResponse ? AILocalizedString(@" (Autoreply)", nil) : @"")]]];
272 NSAttributedString *attributedMessage = [htmlDecoder decodeHTML:messageXML];
274 attributedMessage = [adium.contentController filterAttributedString:attributedMessage
275 usingFilterType:AIFilterMessageDisplay
276 direction:(sentMessage ? AIFilterOutgoing : AIFilterIncoming)
279 [output appendAttributedString:attributedMessage];
280 [output appendAttributedString:newlineAttributedString];
281 } else if ([type isEqualToString:@"status"]) {
282 NSString *dateStr = [[attributes objectForKey:@"time"] stringValue];
283 NSDate *date = dateStr ? [NSCalendarDate calendarDateWithString:dateStr] : nil;
284 NSString *status = [[attributes objectForKey:@"type"] stringValue];
286 NSMutableString *messageXML = [NSMutableString string];
287 for (NSXMLNode *node in [element children]) {
288 [messageXML appendString:[node XMLString]];
291 NSString *displayMessage = nil;
292 //Note: I am diverging from what the AILoggerPlugin logs in this case. It can't handle every case we can have here
293 if([messageXML length]) {
294 if([statusLookup objectForKey:status]) {
295 displayMessage = [NSString stringWithFormat:AILocalizedString(@"Changed status to %@: %@", nil), [statusLookup objectForKey:status], messageXML];
297 displayMessage = [NSString stringWithFormat:AILocalizedString(@"%@", nil), messageXML];
299 } else if([status length] && [statusLookup objectForKey:status]) {
300 displayMessage = [NSString stringWithFormat:AILocalizedString(@"Changed status to %@", nil), [statusLookup objectForKey:status]];
303 if([displayMessage length]) {
304 [output appendAttributedString:[htmlDecoder decodeHTML:[NSString stringWithFormat:@"<div class=\"status\">%@ (%@)</div>\n",
306 [dateFormatter stringFromDate:date]]]];
314 if (!reentrancyFlag) {
315 NSMutableString *xmlString = [NSMutableString stringWithUTF8String:[xmlData bytes]];
316 [xmlString stripInvalidCharacters];
317 return [self readData:[xmlString dataUsingEncoding:NSUTF8StringEncoding] withOptions:options retrying:YES];
319 @throw [NSException exceptionWithName:@"Log File Parsing Error" reason:[err description] userInfo:nil];