1// 2// ZoomStory.m 3// ZoomCocoa 4// 5// Created by Andrew Hunter on Tue Jan 13 2004. 6// Copyright (c) 2004 Andrew Hunter. All rights reserved. 7// 8 9#import "ZoomStory.h" 10#import "ZoomStoryID.h" 11 12#import "ZoomMetadata.h" 13#import "ZoomBlorbFile.h" 14#import "ZoomPreferences.h" 15 16#import "ZoomAppDelegate.h" 17 18#include "ifmetabase.h" 19 20NSString* ZoomStoryDataHasChangedNotification = @"ZoomStoryDataHasChangedNotification"; 21NSString* ZoomStoryExtraMetadata = @"ZoomStoryExtraMetadata"; 22 23NSString* ZoomStoryExtraMetadataChangedNotification = @"ZoomStoryExtraMetadataChangedNotification"; 24 25@implementation ZoomStory 26 27+ (void) initialize { 28 NSUserDefaults* defs = [NSUserDefaults standardUserDefaults]; 29 30 [defs registerDefaults: 31 [NSDictionary dictionaryWithObjectsAndKeys: 32 [NSDictionary dictionary], ZoomStoryExtraMetadata, 33 nil]]; 34} 35 36+ (NSString*) nameForKey: (NSString*) key { 37 // FIXME: internationalisation (this FIXME applies to most of Zoom, which is why it hasn't happened yet) 38 static NSDictionary* keyNameDict = nil; 39 40 if (keyNameDict == nil) { 41 keyNameDict = [NSDictionary dictionaryWithObjectsAndKeys: 42 @"Title", @"title", 43 @"Headline", @"headline", 44 @"Author", @"author", 45 @"Genre", @"genre", 46 @"Group", @"group", 47 @"Year", @"year", 48 @"Zarfian rating", @"zarfian", 49 @"Teaser", @"teaser", 50 @"Comments", @"comment", 51 @"My Rating", @"rating", 52 @"Description", @"description", 53 @"Cover picture number", @"coverpicture", 54 nil]; 55 56 [keyNameDict retain]; 57 } 58 59 return [keyNameDict objectForKey: key]; 60} 61 62+ (NSString*) keyForTag: (int) tag { 63 switch (tag) { 64 case 0: return @"title"; 65 case 1: return @"headline"; 66 case 2: return @"author"; 67 case 3: return @"genre"; 68 case 4: return @"group"; 69 case 5: return @"year"; 70 case 6: return @"zarfian"; 71 case 7: return @"teaser"; 72 case 8: return @"comment"; 73 case 9: return @"rating"; 74 case 10: return @"description"; 75 case 11: return @"coverpicture"; 76 } 77 78 return nil; 79} 80 81+ (ZoomStory*) defaultMetadataForFile: (NSString*) filename { 82 // Gets the standard metadata for the given file 83 BOOL isDir; 84 85 if (![[NSFileManager defaultManager] fileExistsAtPath: filename 86 isDirectory: &isDir]) return nil; 87 if (isDir) return nil; 88 89 // Get the ID for this file 90 // NSData* fileData = [NSData dataWithContentsOfFile: filename]; 91 ZoomStoryID* fileID = [[ZoomStoryID idForFile: filename] retain]; 92 ZoomMetadata* fileMetadata = nil; 93 94 if (fileID == nil) { 95 fileID = [[[ZoomStoryID alloc] initWithData: [NSData dataWithContentsOfFile: filename]] autorelease]; 96 } 97 98 // If this file is a blorb file, then extract the IFmd chunk 99 NSFileHandle* fh = [NSFileHandle fileHandleForReadingAtPath: filename]; 100 NSData* data = [[[fh readDataOfLength: 64] retain] autorelease]; 101 const unsigned char* bytes = [data bytes]; 102 [fh closeFile]; 103 104 ZoomBlorbFile* blorb = nil; 105 if (bytes[0] == 'F' && bytes[1] == 'O' && bytes[2] == 'R' && bytes[3] == 'M') { 106 blorb = [[ZoomBlorbFile alloc] initWithContentsOfFile: filename]; 107 NSData* ifMD = [blorb dataForChunkWithType: @"IFmd"]; 108 109 if (ifMD != nil) { 110 fileMetadata = [[ZoomMetadata alloc] initWithData: ifMD]; 111 } else { 112 NSLog(@"Warning: found a game with an IFmd chunk, but was not able to parse it"); 113 } 114 115 [blorb autorelease]; 116 } 117 118 // If we've got an ifMD chunk, then see if we can extract the story from it 119 ZoomStory* result = nil; 120 121 if (fileMetadata && [fileMetadata containsStoryWithIdent: fileID]) { 122 result = [[fileMetadata findOrCreateStory: fileID] retain]; 123 124 if (result == nil) { 125 NSLog(@"Warning: found a game with an IFmd chunk, but which did not appear to contain any relevant metadata (looked for ID: %@)", fileID); 126 } 127 } 128 129 // If there's no result, then make up the data from the filename 130 if (result == nil) { 131 result = [[[(ZoomAppDelegate*)[NSApp delegate] userMetadata] findOrCreateStory: fileID] retain]; 132 133 // Add the ID 134 [result addID: fileID]; 135 136 // Behaviour is different for stories that are organised 137 NSString* orgDir = [[[ZoomPreferences globalPreferences] organiserDirectory] stringByStandardizingPath]; 138 BOOL storyIsOrganised = NO; 139 140 NSString* mightBeOrgDir = [[[filename stringByDeletingLastPathComponent] stringByDeletingLastPathComponent] stringByDeletingLastPathComponent]; 141 mightBeOrgDir = [mightBeOrgDir stringByStandardizingPath]; 142 143 if ([orgDir caseInsensitiveCompare: mightBeOrgDir] == NSOrderedSame) storyIsOrganised = YES; 144 if (![[[[filename lastPathComponent] stringByDeletingPathExtension] lowercaseString] isEqualToString: @"game"]) storyIsOrganised = NO; 145 146 // Build the metadata 147 NSString* groupName; 148 NSString* gameName; 149 150 if (storyIsOrganised) { 151 gameName = [[filename stringByDeletingLastPathComponent] lastPathComponent]; 152 groupName = [[[filename stringByDeletingLastPathComponent] stringByDeletingLastPathComponent] lastPathComponent]; 153 } else { 154 gameName = [[filename stringByDeletingPathExtension] lastPathComponent]; 155 groupName = @""; 156 } 157 158 [result setTitle: gameName]; 159 [result setGroup: groupName]; 160 } 161 162 if (result != nil && ([result group] == nil || [[result group] isEqualToString: @""])) { 163 // Use a default group based on the type of game this is 164 BOOL isUlx = NO; 165 166 if (blorb) { 167 isUlx = [blorb dataForChunkWithType: @"GLUL"] != nil; 168 } else { 169 isUlx = bytes[0] == 'G' && bytes[1] == 'l' && bytes[2] == 'u' && bytes[3] == 'l'; 170 } 171 172 if (isUlx) { 173 [result setGroup: @"Glulx"]; 174 } else { 175 [result setGroup: @"Z-Code"]; 176 } 177 } 178 179 // Clean up 180 [fileID release]; 181 [fileMetadata release]; 182 183 // Return the result 184 return [result autorelease]; 185} 186 187// = Initialisation = 188 189- (id) init { 190 [NSException raise: @"ZoomCannotInitialiseStoryException" 191 format: @"Cannot initialise a ZoomStory object without a corresponding metabase"]; 192 return nil; 193} 194 195- (id) initWithStory: (IFStory) s 196 metadata: (ZoomMetadata*) metadataContainer { 197 self = [super init]; 198 199 if (self) { 200 story = s; 201 needsFreeing = NO; 202 metadata = [metadataContainer retain]; 203 204 extraMetadata = nil; 205 206 [[NSNotificationCenter defaultCenter] addObserver: self 207 selector: @selector(storyDying:) 208 name: ZoomMetadataWillDestroyStory 209 object: metadataContainer]; 210 } 211 212 return self; 213} 214 215- (void) dealloc { 216 if (needsFreeing && story) { 217 } 218 219 if (metadata) [metadata release]; 220 if (extraMetadata) [extraMetadata release]; 221 222 [[NSNotificationCenter defaultCenter] removeObserver: self]; 223 224 [super dealloc]; 225} 226 227// = Notifications = 228 229- (void) storyDying: (NSNotification*) not { 230 // If this story is removed from the metabase, then invalidate this object 231 // 232 // Ideally, all story objects should be destroyed before they get removed from the metabase, but 233 // it's going to be far too hard to keep track of them all, so this will do as an alternative. 234 // 235 // An improvement that might be made: stories could be put into a temporary metabase here so that 236 // they continue to be completely valid (and recoverable if necessary). However, this is not yet 237 // a required feature. 238 // 239 240 ZoomStoryID* ident = [[not userInfo] objectForKey: @"Ident"]; 241 242 if ([self hasID: ident]) { 243 story = NULL; 244 } 245} 246 247// = Accessors = 248 249- (struct IFStory*) story { 250 return story; 251} 252 253- (void) addID: (ZoomStoryID*) newID { 254 if (story == NULL) return; 255 256 IFID oldId = IFMB_IdForStory(story); 257 258 if (IFMB_CompareIds(oldId, [newID ident]) != 0) { 259 IFID newIdArray[2] = { oldId, [newID ident] }; 260 IFID newStoryId = IFMB_CompoundId(2, newIdArray); 261 262 IFMB_CopyStory(NULL, story, newStoryId); 263 IFMB_FreeId(newStoryId); 264 } 265} 266 267- (NSString*) title { 268 return [self objectForKey: @"title"]; 269} 270 271- (NSString*) headline { 272 return [self objectForKey: @"headline"]; 273} 274 275- (NSString*) author { 276 return [self objectForKey: @"author"]; 277} 278 279- (NSString*) genre { 280 return [self objectForKey: @"genre"]; 281} 282 283- (int) year { 284 NSString* stringYear = [self objectForKey: @"year"]; 285 286 if (stringYear) 287 return [stringYear intValue]; 288 else 289 return 0; 290} 291 292- (NSString*) group { 293 return [self objectForKey: @"group"]; 294} 295 296- (unsigned) zarfian { 297 NSString* zarfian = [[self objectForKey: @"zarfian"] lowercaseString]; 298 299 if ([zarfian isEqualToString: @"merciful"]) { 300 return IFMD_Merciful; 301 } else if ([zarfian isEqualToString: @"polite"]) { 302 return IFMD_Polite; 303 } else if ([zarfian isEqualToString: @"tough"]) { 304 return IFMD_Tough; 305 } else if ([zarfian isEqualToString: @"nasty"]) { 306 return IFMD_Nasty; 307 } else if ([zarfian isEqualToString: @"cruel"]) { 308 return IFMD_Cruel; 309 } 310 311 return IFMD_Unrated; 312} 313 314- (NSString*) teaser { 315 return [self objectForKey: @"teaser"]; 316} 317 318- (NSString*) comment { 319 return [self objectForKey: @"comment"]; 320} 321 322- (float) rating { 323 NSString* rating = [self objectForKey: @"rating"]; 324 325 if (rating) { 326 return [rating floatValue]; 327 } else { 328 return -1; 329 } 330} 331 332- (int) coverPicture { 333 NSString* coverPicture = [self objectForKey: @"coverpicture"]; 334 335 if (coverPicture) { 336 return [coverPicture intValue]; 337 } else { 338 return -1; 339 } 340} 341 342- (NSString*) description { 343 return [self objectForKey: @"description"]; 344} 345 346// = Setting data = 347 348// Setting data 349- (void) setTitle: (NSString*) newTitle { 350 [self setObject: newTitle 351 forKey: @"title"]; 352} 353 354- (void) setHeadline: (NSString*) newHeadline { 355 [self setObject: newHeadline 356 forKey: @"headline"]; 357} 358 359- (void) setAuthor: (NSString*) newAuthor { 360 [self setObject: newAuthor 361 forKey: @"author"]; 362} 363 364- (void) setGenre: (NSString*) genre { 365 [self setObject: genre 366 forKey: @"genre"]; 367} 368 369- (void) setYear: (int) year { 370 if (year > 0) { 371 [self setObject: [NSString stringWithFormat: @"%i", year] 372 forKey: @"year"]; 373 } else { 374 [self setObject: nil 375 forKey: @"year"]; 376 } 377} 378 379- (void) setGroup: (NSString*) group { 380 [self setObject: group 381 forKey: @"group"]; 382} 383 384- (void) setZarfian: (unsigned) zarfian { 385 NSString* narf = nil; /* Are you pondering what I'm pondering? */ 386 387 switch (zarfian) { 388 case IFMD_Merciful: narf = @"Merciful"; break; 389 case IFMD_Polite: narf = @"Polite"; break; 390 case IFMD_Tough: narf = @"Tough"; break; 391 case IFMD_Nasty: narf = @"Nasty"; break; 392 case IFMD_Cruel: narf = @"Cruel"; break; 393 } 394 395 [self setObject: narf 396 forKey: @"zarfian"]; 397} 398 399- (void) setTeaser: (NSString*) teaser { 400 [self setObject: teaser 401 forKey: @"teaser"]; 402} 403 404- (void) setComment: (NSString*) comment { 405 [self setObject: comment 406 forKey: @"comment"]; 407} 408 409- (void) setRating: (float) rating { 410 if (rating >= 0) { 411 [self setObject: [NSString stringWithFormat: @"%g", rating] 412 forKey: @"rating"]; 413 } else { 414 [self setObject: nil 415 forKey: @"rating"]; 416 } 417} 418 419- (void) setCoverPicture: (int) coverpicture { 420 if (coverpicture >= 0) { 421 [self setObject: [NSString stringWithFormat: @"%i", coverpicture] 422 forKey: @"coverpicture"]; 423 } else { 424 [self setObject: nil 425 forKey: @"coverpicture"]; 426 } 427} 428 429- (void) setDescription: (NSString*) description { 430 [self setObject: description 431 forKey: @"description"]; 432} 433 434// = NSCopying = 435 436/* 437- (id) copyWithZone: (NSZone*) zone { 438 IFMDStory* newStory = IFStory_Alloc(); 439 IFStory_Copy(newStory, story); 440 441 ZoomStory* res; 442 443 res = [[ZoomStory alloc] initWithStory: newStory]; 444 res->needsFreeing = YES; 445 446 return res; 447} 448*/ 449 450// = Story pseudo-dictionary methods = 451 452- (void) loadExtraMetadata { 453 if (extraMetadata != nil) return; 454 455 NSDictionary* dict = [[NSUserDefaults standardUserDefaults] objectForKey: ZoomStoryExtraMetadata]; 456 457 // We retrieve the data for the first story ID only. Assuming nothing funny has happened, it 458 // will be the same for all IDs associated with this story. 459 if (dict == nil || ![dict isKindOfClass: [NSDictionary class]]) { 460 extraMetadata = [[NSMutableDictionary alloc] init]; 461 } else { 462 extraMetadata = [[dict objectForKey: [[[self storyIDs] objectAtIndex: 0] description]] mutableCopy]; 463 } 464 465 if (extraMetadata == nil) { 466 extraMetadata = [[NSMutableDictionary alloc] init]; 467 } 468} 469 470- (void) storeExtraMetadata { 471 // Make a mutable copy of the metadata dictionary 472 NSMutableDictionary* newExtraData = [[[[NSUserDefaults standardUserDefaults] objectForKey: ZoomStoryExtraMetadata] mutableCopy] autorelease]; 473 474 if (newExtraData == nil || ![newExtraData isKindOfClass: [NSMutableDictionary class]]) { 475 newExtraData = [[[NSMutableDictionary alloc] init] autorelease]; 476 } 477 478 // Add the data for all our story IDs 479 NSEnumerator* idEnum = [[self storyIDs] objectEnumerator]; 480 ZoomStoryID* storyID; 481 482 while (storyID = [idEnum nextObject]) { 483 [newExtraData setObject: extraMetadata 484 forKey: [storyID description]]; 485 } 486 487 // Store in the defaults 488 [[NSUserDefaults standardUserDefaults] setObject: newExtraData 489 forKey: ZoomStoryExtraMetadata]; 490 491 // Notify the other stories about the change 492 [[NSNotificationCenter defaultCenter] postNotificationName: ZoomStoryExtraMetadataChangedNotification 493 object: self]; 494} 495 496- (void) extraDataChanged: (NSNotification*) not { 497 // Respond to notifications about changing metadata 498 if (extraMetadata) { 499 [extraMetadata release]; 500 extraMetadata = nil; 501 502 // (Reloading prevents a potential bug in the future. It's not absolutely required right now) 503 [self loadExtraMetadata]; 504 } 505} 506 507- (NSString*) newKeyForOld: (NSString*) key { 508 if ([key isEqualToString: @"title"]) { 509 return @"bibliographic.title"; 510 } else if ([key isEqualToString: @"headline"]) { 511 return @"bibliographic.headline"; 512 } else if ([key isEqualToString: @"author"]) { 513 return @"bibliographic.author"; 514 } else if ([key isEqualToString: @"genre"]) { 515 return @"bibliographic.genre"; 516 } else if ([key isEqualToString: @"group"]) { 517 return @"bibliographic.group"; 518 } else if ([key isEqualToString: @"year"]) { 519 return @"bibliographic.firstpublished"; 520 } else if ([key isEqualToString: @"zarfian"]) { 521 return @"bibliographic.forgiveness"; 522 } else if ([key isEqualToString: @"teaser"]) { 523 return @"zoom.teaser"; 524 } else if ([key isEqualToString: @"comment"]) { 525 return @"zoom.comment"; 526 } else if ([key isEqualToString: @"rating"]) { 527 return @"zoom.rating"; 528 } else if ([key isEqualToString: @"description"]) { 529 return @"bibliographic.description"; 530 } else if ([key isEqualToString: @"coverpicture"]) { 531 return @"zcode.coverpicture"; 532 } 533 534 int x; 535 536 for (x=0; x<[key length]; x++) { 537 if ([key characterAtIndex: x] == '.') return key; 538 } 539 540 return [NSString stringWithFormat: @"zoom.extra.%@", key]; 541} 542 543- (id) objectForKey: (id) key { 544 if (story == NULL) return nil; 545 546 if (![key isKindOfClass: [NSString class]]) { 547 [NSException raise: @"ZoomKeyNotString" 548 format: @"Metadata key is not a string"]; 549 return nil; 550 } 551 552 [metadata lock]; 553 554 id newKey = [self newKeyForOld: key]; 555 IFChar* value = IFMB_GetValue(story, [newKey UTF8String]); 556 557 if (value != nil) { 558 int len = IFMB_StrLen(value); 559 unichar* characters = malloc(sizeof(unichar)*len); 560 int x; 561 562 for (x=0; x<len; x++) characters[x] = value[x]; 563 564 NSString* result = [NSString stringWithCharacters: characters 565 length: len]; 566 567 free(characters); 568 [metadata unlock]; 569 return result; 570 } else { 571 [metadata unlock]; 572 [self loadExtraMetadata]; 573 return [extraMetadata objectForKey: key]; 574 } 575} 576 577- (void) setObject: (id) value 578 forKey: (id) key { 579 if (story == NULL) return; 580 581 if ([key isEqualToString: @"rating"] && [value isKindOfClass: [NSNumber class]]) { 582 [self setRating: [value floatValue]]; 583 return; 584 } 585 586 if (![value isKindOfClass: [NSString class]] && value != nil) { 587 [NSException raise: @"ZoomBadValue" format: @"Metadata value is not a string"]; 588 return; 589 } 590 if (![key isKindOfClass: [NSString class]]) { 591 [NSException raise: @"ZoomKeyNotString" format: @"Metadata key is not a string"]; 592 return; 593 } 594 595 if ([[self objectForKey: key] isEqualTo: value] && [self objectForKey: key] != value) { 596 // Nothing to do 597 return; 598 } 599 600 [metadata lock]; 601 602 IFChar* metaValue = nil; 603 604 if (value != nil) { 605 metaValue = malloc(sizeof(IFChar)*([value length]+1)); 606 607 unichar* characters = malloc(sizeof(unichar)*[value length]); 608 int x; 609 610 [value getCharacters: characters]; 611 612 for (x=0; x<[value length]; x++) { 613 metaValue[x] = characters[x]; 614 } 615 metaValue[x] = 0; 616 617 free(characters); 618 } 619 620 IFMB_SetValue(story, [[self newKeyForOld: key] UTF8String], metaValue); 621 if (metaValue) free(metaValue); 622 623 [metadata unlock]; 624 625 [self heyLookThingsHaveChangedOohShiney]; 626} 627 628// = Searching = 629 630- (BOOL) containsText: (NSString*) text { 631 if (story == NULL) return NO; 632 633 // List of strings to check against 634 NSArray* stringsToCheck = [[NSArray alloc] initWithObjects: 635 [self title], [self headline], [self author], [self genre], [self group], nil]; 636 637 // List of words to match against (we take off a word for each match) 638 NSMutableArray* words = [[text componentsSeparatedByString: @" "] mutableCopy]; 639 640 // Loop through each string to check against 641 NSEnumerator* searchEnum = [stringsToCheck objectEnumerator]; 642 NSString* string; 643 644 while ([words count] > 0 && (string = [searchEnum nextObject])) { 645 int num; 646 647 for (num=0; num<[words count]; num++) { 648 if ([(NSString*)[words objectAtIndex: num] length] == 0 || 649 [string rangeOfString: [words objectAtIndex: num] 650 options: NSCaseInsensitiveSearch].location != NSNotFound) { 651 // Found this word 652 [words removeObjectAtIndex: num]; 653 num--; 654 continue; 655 } 656 } 657 } 658 659 // Finish up 660 BOOL success = [words count] <= 0; 661 662 [words release]; 663 [stringsToCheck release]; 664 665 // Is true if there are no words left to match 666 return success; 667} 668 669// = Sending notifications = 670 671- (void) heyLookThingsHaveChangedOohShiney { 672 [[NSNotificationCenter defaultCenter] postNotificationName: ZoomStoryDataHasChangedNotification 673 object: self]; 674} 675 676// Identifying and comparing stories 677 678- (ZoomStoryID*) storyID { 679 if (story == NULL) return nil; 680 return [[[ZoomStoryID alloc] initWithIdent: IFMB_IdForStory(story)] autorelease]; 681} 682 683- (NSArray*) storyIDs { 684 if (story == NULL) return nil; 685 686 NSMutableArray* idArray = [NSMutableArray array]; 687 688 [metadata lock]; 689 690 int ident; 691 int count; 692 693 IFID singleId[1] = { IFMB_IdForStory(story) }; 694 IFID* ids = IFMB_SplitId(singleId[0], &count); 695 696 if (ids == NULL) { 697 ids = singleId; 698 count = 1; 699 } 700 701 for (ident = 0; ident < count; ident++) { 702 ZoomStoryID* theId = [[ZoomStoryID alloc] initWithIdent: ids[ident]]; 703 if (theId) { 704 [idArray addObject: theId]; 705 [theId release]; 706 } 707 } 708 709 [metadata unlock]; 710 711 return idArray; 712} 713 714- (BOOL) hasID: (ZoomStoryID*) storyID { 715 if (story == NULL) return NO; 716 717 NSArray* ourIds = [self storyIDs]; 718 719 return [ourIds containsObject: storyID]; 720} 721 722- (BOOL) isEquivalentToStory: (ZoomStory*) eqStory { 723 if (story == NULL) return NO; 724 725 if (eqStory == self) return YES; // Shortcut 726 727 NSArray* theirIds = [eqStory storyIDs]; 728 NSArray* ourIds = [self storyIDs]; 729 730 [metadata lock]; 731 732 NSEnumerator* idEnum = [theirIds objectEnumerator]; 733 ZoomStoryID* thisId; 734 735 while (thisId = [idEnum nextObject]) { 736 if ([ourIds containsObject: thisId]) return YES; 737 } 738 739 [metadata unlock]; 740 741 return NO; 742} 743 744@end 745