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