/* ProjectDocument.m Implementation of the ProjectDocument class for the ProjectManager application. Copyright (C) 2005, 2006 Saso Kiselkov 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., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA */ #import "ProjectDocument.h" #import #import #import #import #import #import #import #import #import #import #import #import #import "SourceEditorDocument.h" #import "ProjectWindowController.h" #import "ProjectType.h" #import "ProjectTypeLoader.h" #import "ProjectModule.h" #import "ProjectModuleLoader.h" #import "ProjectCreator.h" #import "NSArrayAdditions.h" NSString * const ProjectNameDidChangeNotification = @"ProjectNameDidChangeNotification"; NSString * const ProjectDocumentErrorDomain = @"ProjectDocumentErrorDomain"; /** * Compares two version numbers. The arguments are version strings, * which is a string containing numbers delimited by ".", such as * "0.1.2". The numbers must be ordered from most significant number * to least significant. * * @return The standard values of the NSComparisonResult enum. */ static NSComparisonResult CompareVersions (NSString * first, NSString * second) { NSArray * firstVersion = [first componentsSeparatedByString: @"."], * secondVersion = [second componentsSeparatedByString: @"."]; int i, n1, n2; for (i = 0, n1 = [firstVersion count], n2 = [secondVersion count]; i < n1 || i < n2; i++) { int firstNumber, secondNumber; if (i < n1) { firstNumber = [[firstVersion objectAtIndex: i] intValue]; } else { firstNumber = 0; } if (i < n2) { secondNumber = [[secondVersion objectAtIndex: i] intValue]; } else { secondNumber = 0; } // if it's greater we don't have to compare the rest anymore if (firstNumber > secondNumber) { return NSOrderedDescending; } // simmilarily, if it's lower the rest doesn't matter anymore else if (firstNumber < secondNumber) { return NSOrderedAscending; } } // both versions are equal return NSOrderedSame; } /** * Checks whether the provided string is a valid framework (i.e * doesn't contain spaces and a couple of other limitations). * If the name isn't valid, the user is informed of the fact with * an alert panel. * * @return YES if the string is a valid framework name, NO otherwise. */ BOOL IsValidFrameworkName (NSString * frameworkName) { if ([frameworkName length] != [[frameworkName stringByTrimmingCharactersInSet: [NSCharacterSet whitespaceAndNewlineCharacterSet]] length]) { NSRunAlertPanel(_(@"Invalid framework name"), _(@"A framework name may not contain whitespace characters."), nil, nil, nil); return NO; } return YES; } /** * Gets the category contents array of a category description * contained in `supercategoryContentsArray' for a category named * `categoryName'. * * @return The category contents array if the category is found, * otherwise nil. */ static NSMutableArray * GetCategoryContentsArray(NSArray * supercategoryContentsArray, NSString * categoryName) { NSEnumerator * e = [supercategoryContentsArray objectEnumerator]; NSDictionary * entry; Class dictionaryClass = [NSDictionary class]; while ((entry = [e nextObject]) != nil) { if ([entry isKindOfClass: dictionaryClass] && [[entry objectForKey: @"Name"] isEqualToString: categoryName]) { return [entry objectForKey: @"Contents"]; } } return nil; } @interface ProjectDocument (Private) - (BOOL) loadProjectModules: (NSArray *) moduleNames withInfoDictionaries: (NSDictionary *) dicts forProjectType: (NSString *) typeName; - (NSDictionary *) getProjectModulesData; + (NSArray *) projectModulesForProjectType: (NSString *) projectTypeID; @end /** * @class ProjectDocument * * This class is the principal document class for project files. * It's responsibility is to manage it's project modules, the project type * object, and it's window controller. */ @implementation ProjectDocument static NSString * const ProjectManagerVersion = @"0.2"; /** * Checks whether a given string is a valid project name. * * @param projectName The project name which to check. * @param error A pointer which if not set to NULL will be filled with * an object describing the problem with the name. * * @return YES if the provided name is a valid project name, NO if it isn't. */ + (BOOL) validateProjectName: (NSString *) aProjectName error: (NSError **) error { static NSCharacterSet * allowedProjectNameCharacters = nil; if (allowedProjectNameCharacters == nil) { allowedProjectNameCharacters = [[NSCharacterSet characterSetWithCharactersInString: @"abcdefghijklmnopqrstuvwxyz" @"ABCDEFGHIJKLMNOPQRSTUVWXYZ" @"0123456789" @"-_"] retain]; } if ([[aProjectName stringByTrimmingCharactersInSet: allowedProjectNameCharacters] length] != 0) { if (error != NULL) { NSDictionary * userInfo; userInfo = [NSDictionary dictionaryWithObject: _(@"A project name may only contain " @"non-accented letters a-z, the digits 0-9 and the " @"characters \"-\" and \"_\".") forKey: NSLocalizedDescriptionKey]; *error = [NSError errorWithDomain: ProjectDocumentErrorDomain code: ProjectNameInvalidError userInfo: userInfo]; } return NO; } else { return YES; } } - (void) dealloc { TEST_RELEASE (projectDirectory); TEST_RELEASE (projectName); TEST_RELEASE (projectTypeID); TEST_RELEASE (projectType); TEST_RELEASE (projectModules); TEST_RELEASE (moduleMenuEntries); TEST_RELEASE (wc); [super dealloc]; } - (BOOL) readFromFile: (NSString *) fileName ofType: (NSString *) fileType { NSDictionary * projectFile; NSDictionary * projectTypeData, * projectModulesData; NSString * versionString; ASSIGN (projectDirectory, [fileName stringByDeletingLastPathComponent]); projectFile = [NSDictionary dictionaryWithContentsOfFile: fileName]; if (projectFile == nil) { return NO; } versionString = [projectFile objectForKey: @"Version"]; // as a special precaution - the project file format changed in an // incompatible way in version 0.2, so warn about stuff older than that if (CompareVersions (@"0.2", versionString) == NSOrderedDescending) { NSString * message; if (versionString != nil) { message = _(@"The project has been created by Project Manager version %@,\n" @"but since the project format has been changed in an\n" @"incompatible way, so your project is likely not going to\n" @"work correctly. Do you want to try loading it anyway?"); } else { message = _(@"The project has probably been created by a Project Manager\n" @"build older than version 0.2, but in 0.2 the project format\n" @"has changed in an incompatible way, so your project is\n" @"likely not going to work correctly. Do you want to try\n" @"loading it anyway?"); } if (NSRunAlertPanel (_(@"Old version of project"), message, _(@"No"), _(@"Yes"), nil, versionString) == NSAlertDefaultReturn) { return NO; } } if (versionString != nil) { if (CompareVersions (ProjectManagerVersion, versionString) == NSOrderedAscending) { if (NSRunAlertPanel(_(@"Newer version of project"), _(@"This project was created with a newer version (%@)\n" @"of ProjectManager than is this one (%@).\n" @"Do you want me to try to open the project anyway?"), _(@"Yes"), _(@"Cancel"), nil, versionString, ProjectManagerVersion) == NSAlertAlternateReturn) { return NO; } } } ASSIGN (projectTypeID, [projectFile objectForKey: @"ProjectType"]); if (projectTypeID == nil) { return NO; } ASSIGN (projectName, [projectFile objectForKey: @"ProjectName"]); if (projectName == nil) { return NO; } projectTypeData = [projectFile objectForKey: @"ProjectTypeData"]; projectModulesData = [projectFile objectForKey: @"ProjectModulesData"]; if (![self loadProjectModules: [ProjectDocument projectModulesForProjectType: projectTypeID] withInfoDictionaries: projectModulesData forProjectType: projectTypeID]) { return NO; } // get the project type object ASSIGN (projectType, [[ProjectTypeLoader shared] projectTypeForTypeID: projectTypeID project: self infoDictionary: projectTypeData projectModules: projectModules]); if (projectType == nil) { return NO; } [projectModules makeObjectsPerformSelector: @selector (finishInit)]; return YES; } - (BOOL) writeToFile: (NSString *) fileName ofType: (NSString *) fileType { BOOL result; NSMutableDictionary * dictionary; NSDictionary * projectData; dictionary = [NSMutableDictionary dictionary]; [dictionary setObject: ProjectManagerVersion forKey: @"Version"]; [dictionary setObject: projectTypeID forKey: @"ProjectType"]; [dictionary setObject: projectName forKey: @"ProjectName"]; if (![projectType regenerateDerivedFiles]) { return NO; } projectData = [projectType infoDictionary]; if (projectData != nil) { [dictionary setObject: projectData forKey: @"ProjectTypeData"]; } [dictionary setObject: [self getProjectModulesData] forKey: @"ProjectModulesData"]; result = [dictionary writeToFile: fileName atomically: YES]; if (result == YES) { [[wc window] setMiniwindowImage: [NSImage imageNamed: @"File_project"]]; } return result; } - (void) makeWindowControllers { wc = [[ProjectWindowController alloc] initWithWindowNibName: @"Project" ownerDocument: self]; [self addWindowController: wc]; [[wc window] setMiniwindowImage: [NSImage imageNamed: @"File_project"]]; } - (NSString *) displayName { return projectName; } /** * This message is sent to the receiver by the app delegate when the * receiver becomes the currently active document in the app to determine * what menu entries of it to put into the main menu's 'Project' submenu. * * @return an array of NSMenuItem objects bound to submenus containing * the menu items of the individual project modules. */ - (NSArray *) projectMenuEntries { // recreate this list lazily if (moduleMenuEntries == nil) { NSMutableArray * menuEntries = [NSMutableArray arrayWithCapacity: [projectModules count]]; NSEnumerator * e; id module; e = [projectModules objectEnumerator]; while ((module = [e nextObject]) != nil) { NSArray * moduleMenuItems; // generate the module's menu items if necessary moduleMenuItems = [module moduleMenuItems]; if ([moduleMenuItems count] > 0) { NSString * menuTitle = [[module class] humanReadableModuleName]; NSMenuItem * rootItem; NSMenu * submenu; NSEnumerator * e; NSMenuItem * item; rootItem = [[[NSMenuItem alloc] initWithTitle: menuTitle action: NULL keyEquivalent: nil] autorelease]; submenu = [[[NSMenu alloc] initWithTitle: menuTitle] autorelease]; [rootItem setSubmenu: submenu]; e = [moduleMenuItems objectEnumerator]; while ((item = [e nextObject]) != nil) { [submenu addItem: item]; } [rootItem setSubmenu: submenu]; [menuEntries addObject: rootItem]; } } ASSIGNCOPY (moduleMenuEntries, menuEntries); } return moduleMenuEntries; } /** * Returns the abstract project name. * * @see -[ProjectDocument setProjectName:] */ - (NSString *) projectName { return projectName; } /** * Sets a new project name. The project's name doesn't necessarily need * to be the same as the project file's or project directory's name, but * can instead by anything that the user finds descriptive. * * @see -[ProjectDocument projectName] */ - (void) setProjectName: (NSString *) aName { NSError * error; if ([ProjectDocument validateProjectName: aName error: &error]) { ASSIGN (projectName, aName); [[NSNotificationCenter defaultCenter] postNotificationName: ProjectNameDidChangeNotification object: self userInfo: nil]; [self updateChangeCount: NSChangeDone]; } else { NSRunAlertPanel(_(@"Invalid project name"), [[error userInfo] objectForKey: NSLocalizedDescriptionKey], nil, nil, nil); } } /** * Returns a path to where the project's directory is located. The * location of the project's project file can be determined by simply * saying -[ProjectDocument fileName]. */ - (NSString *) projectDirectory { return projectDirectory; } /** * Returns the type ID of the project's project type. */ - (NSString *) projectTypeID { return projectTypeID; } /** * Returns the project modules of the receiver. */ - (NSArray *) projectModules { return projectModules; } /** * Returns the project module of the specified name, or `nil' if * no such module is found. */ - (id ) projectModuleWithName: (NSString *) moduleName { NSEnumerator * e; id module; e = [projectModules objectEnumerator]; while ((module = [e nextObject]) != nil) { if ([[[module class] moduleName] isEqualToString: moduleName]) { return module; } } // not found return nil; } /** * Sets the currently displayed project module in the receiver's project * window. * * @param aModule The project module which to display. It must one * of the project's modules. */ - (void) setCurrentProjectModule: (id ) aModule { [wc setCurrentModule: aModule]; } /** * Returns the currently displayed project module. */ - (id ) currentProjectModule { return [wc currentModule]; } /** * Returns the project type object associated currently with the project. */ - (id ) projectType { return projectType; } /** * Opens a specified file in a code editor (either the internal code * editor, or an external one, if configured to do so). * * @param aPath The file which to open. * @param aLine The line number at which to open the file. * If the file is already open, it is scrolled to that line. * If you pass aLine < 0, no scrolling occurs and the file is * only opened. * * @return YES if opening the file succeeded, NO if it didn't. */ - (BOOL) openFile: (NSString *) aPath inCodeEditorOnLine: (int) aLine { NSUserDefaults * df = [NSUserDefaults standardUserDefaults]; NSString * appName; appName = [df objectForKey: @"ExternalCodeEditorApp"]; if (appName != nil) { return [[NSWorkspace sharedWorkspace] openFile: aPath]; // TODO /* NSString * appName; id app; app = [self contactApp: appName]; if (app != nil && [app respondsToSelector: @selector(openFile:onLine:)]) { return [app openFile: aPath onLine: aLine]; } else { // as a last resort, try to let the workspace open it (though // line information will be lost) return [[NSWorkspace sharedWorkspace] openFile: aPath]; }*/ } else { NSDocumentController * dc = [NSDocumentController sharedDocumentController]; SourceEditorDocument * doc; doc = [dc documentForFileName: aPath]; if (doc == nil) { doc = [[[SourceEditorDocument alloc] initWithContentsOfFile: aPath ofType: [aPath pathExtension]] autorelease]; if (doc == nil) { return NO; } [dc addDocument: doc]; [doc makeWindowControllers]; } // this must go first, to make sure the document has loaded it's windows // before trying to scroll it [doc showWindows]; if (aLine >= 0) { [doc goToLineNumber: aLine]; } return YES; } } /** * Appends a message to the project log. This method serves as * a frontend to the -[ProjectWindowController logMessage:] method, * so that project modules and project types can log messages (since * they don't have direct access to the window controller object). * * @param aMessage The message which to send to the log. */ - (void) logMessage: (NSString *) aMessage { [wc logMessage: aMessage]; } - (void) updateChangeCount: (NSDocumentChangeType) change { if (change == NSChangeDone && [self isDocumentEdited] == NO) { [[wc window] setMiniwindowImage: [NSImage imageNamed: @"File_project_mod"]]; } [super updateChangeCount: change]; } @end @implementation ProjectDocument (Private) /** * Attempts to load project modules given by names in `moduleNames'. * Their info dictionaries are in `dicts', each bound to the name * of the respective project module. * The `typeName' argument is only used to identify the project type * in case loading a module fails. * * @return YES if loading all project modules succeeds, NO otherwise. */ - (BOOL) loadProjectModules: (NSArray *) moduleNames withInfoDictionaries: (NSDictionary *) dicts forProjectType: (NSString *) typeName { NSMutableArray * modules = [NSMutableArray arrayWithCapacity: [moduleNames count]]; NSEnumerator * e; NSString * moduleName; ProjectModuleLoader * loader = [ProjectModuleLoader shared]; e = [moduleNames objectEnumerator]; while ((moduleName = [e nextObject]) != nil) { id module; NSDictionary * infoDict = [dicts objectForKey: moduleName]; module = [loader projectModuleForModuleName: moduleName project: self infoDictionary: infoDict]; if (module != nil) { [modules addObject: module]; } else { NSLog(_(@"Warning: project module %@ required by project type %@ " @"not found."), moduleName, typeName); return NO; } } ASSIGNCOPY (projectModules, modules); return YES; } /** * Queries all project modules for their data dictionaries and * returns them all in one aggregate dictionary. * * @return The data of all project modules in a dictionary. Each * key in the dictionary represents the data of one project module. * Not all project modules necessarily have an entry in the * returned dictionary - modules which don't have an module data * are ommited. */ - (NSDictionary *) getProjectModulesData { NSMutableDictionary * dict; NSEnumerator * e; id module; dict = [NSMutableDictionary dictionaryWithCapacity: [projectModules count]]; e = [projectModules objectEnumerator]; while ((module = [e nextObject]) != nil) { NSDictionary * moduleData = [module infoDictionary]; if (moduleData != nil) { [dict setObject: moduleData forKey: [[module class] moduleName]]; } } return [[dict copy] autorelease]; } /** * Returns an array of project module names to be loaded for a project * type. * * @param type The project type for which to return the list. * * @return An array of project module names to load for the project type. * This array includes the standard required modules, plus the list of * user-defined modules to be loaded. */ + (NSArray *) projectModulesForProjectType: (NSString *) type { // we use an array here so that the list is sorted according to how the // project module wants - extra user-defined modules thus appear at // the list's end. NSMutableArray * list; NSUserDefaults * df = [NSUserDefaults standardUserDefaults]; NSArray * extraModules; NSEnumerator * e; NSString * module; list = [[[(Class) [[(ProjectTypeLoader *) [ProjectTypeLoader shared] projectTypes] objectForKey: type] projectModules] mutableCopy] autorelease]; extraModules = [[df objectForKey: @"ExtraProjectModules"] objectForKey: type]; e = [extraModules objectEnumerator]; while ((module = [e nextObject]) != nil) { if (![list containsObject: module]) { [list addObject: module]; } } return [[list copy] autorelease]; } @end