Click here to Skip to main content
15,867,686 members
Articles / Web Development / IIS
Article

DotNetNuke Module Packager

Rate me:
Please Sign up or sign in to vote.
4.67/5 (13 votes)
21 Oct 200523 min read 161.5K   1.8K   82   35
This article describes the DotNetNuke Module Packager application and source code. The application enables the user to generate DotNetNuke private assemblies that are useable out of the box from a custom module defined in the programmer's development environment.

Image 1

Update!

The DotNetNuke Module Packager has been updated to work with DotNetNuke 3!

Introduction

You are probably reading this because you have created a DotNetNuke module and want to deploy it as a private assembly without doing all of the work of creating a package by hand. Therefore, you probably are keenly aware of what DotNetNuke is, so I won't go into any detail about it except to say that it is a great platform upon which to build powerful web applications. For those who are here for any other reason, you can learn more about it at the DotNetNuke web site.

I built the DNN Module Packager for two reasons. The first was that it seemed that any other freely available tool didn't really go all the way to make it possible to generate a deployable private assembly out of the box (or even get close without a significant amount of attention to the package) straight from my development environment. There are some applications that will generate a PA shell that you can build from, but that's not really what I wanted either.

The second reason was that the only other applications that seem to do what I want (I say seem because I wasn't willing to pay money to find out) cost money. And that seems silly for something that is so basic to creating modules in DotNetNuke (keep in mind I'm a much better philanthropist than a business man). I wanted a tool that would create a private assembly from a module I had been working on in my locally installed DotNetNuke development environment. The basic idea was to simply be able to build the module focusing on its functionality and letting another application handle creating the private assembly package.

To develop this application, I had several choices. I opted to build a desktop GUI based application for the following reasons:

  • As a developer, I like my development tools to be simple yet powerful. Installing it as a web module itself seemed to defeat that ideal.
  • I figure everyone is pretty much like me and would like a simple tool that they can just use after running an installer.

If you're not like me, feel free to rip into the code and make it into a module or anything else. Just remember that the license is GPL.

I started out creating an application that was more like an MDI with a place to edit the .dnn file once it had been generated, but I felt that was overkill and didn't necessarily even make sense. What I decided would be better was to use a wizard style interface since all I am intending to do is walk the end-user through the process of creating a Private Assembly for a custom DNN module. There are several wizard controls out there, but I found Al Gardener's Designer centric Wizard control to really be the best. It's great because his event handling is at the page level. This means that you can shuffle pages around in any order you want even after you've done some work on them, and the logic won't be affected in any way. I highly recommend his control.

I should also make one final note. I have used several different tools in this application all of which are open-source. For zipping, I use the ICSharpCode.SharpZipLib assembly from the Sharp Develop guys, and of course, as I already mentioned, I am using Al Gardener's Designer centric Wizard control. The project links in this article only contain binaries for these libraries, however, you can get the source for each of them at the links I've provided in this introduction.

Assumptions

They always say that you should never assume because it just makes an "ass" out of "u" and "me". Well, in this case, it's the only way I could get this application to work, so if I've made you feel like an ass, I apologize. In all seriousness though, the beauty of these types of assumptions is that some of them can be changed if a better way emerges. I leave it to the community to help make that determination. OK, so on to the "ass" "u" "me" ptions.

The application assumes that:

  • You have been working on a custom module in DotNetNuke and you have manually set up your module definition and module controls through the host account. This is imperative. The whole idea of the packager is that developers can use their development environment to generate a private assembly. The packager simply won't work if you have not done this.
  • You have created your database objects (tables, stored procedures, etc.) using a prefix to help distinguish your objects from others in the database. While this one isn't required to generate a module, if you do have database objects you want scripted out, and you haven't provided a prefix, you will have to manually script those yourself and then manually add your script filename to the .dnn manifest file and the file itself to the ZIP package.
  • The web application (virtual directory) you select is in fact a DotNetNuke installation. When you select a web application in the first step of the wizard, the packager looks through the local file system where that virtual directory points, to find a Web.config file. If it doesn't find one, it will force you to select a different project. From the config file, the application is able to extract your connection string. With that in hand, it is able to do everything it needs. If, however, it finds that there is no DesktopModules table in the next step, it will warn you and force you to return to the start page to select a different web application again.
  • You are NOT using the DotNetNuke namespace. This really should go without saying, however, I have seen enough people trying to use the DotNetNuke namespace for their DNN modules to warrant my warning against it. Here is what you should keep in mind if you fall into this category: You cannot build a private assembly if you use the DotNetNuke namespace. If you do use the DNN namespace, you will have to recompile the main DotNetNuke assembly and package this with your PA. I'm not sure if the module installer will catch this if you try to overwrite the DotNetNuke.dll during the install, but it seems to me that even if it doesn't, there would probably be a file access violation. Please, just take my word for it and don't try it. OK? Anyhow, don't confuse what I'm saying here with using DotNetNuke as a reference. You, in fact, have to use DotNetNuke as a reference in order to allow your controls to inherit from DotNetNuke.PortalModuleControl (DNN2) or DotNetNuke.Entities.Modules.PortalModuleBase (DNN3). You must, however, put your modules in your own namespace so that you are not compiling your module in with the DotNetNuke core.
  • The database owner is dbo. I have about enough database experience to be dangerous (don't you love clichés), so bear with me on this one. And please, if you see glaring issues, let me know what I've missed so I can fix it. When I create my database objects, I ensure that they are all qualified with "[dbo]." at the beginning. As far as I understand it, "[dbo]." is an alias for whoever the database owner is in the current context. I believe it's a generic way to just say owner rather than having to struggle with managing users when you need to use a script to create the database structure in another database. In the application, when the SQL script gets generated, it replaces all instances of "[dbo]." with "{databaseOwner}". {databaseOwner} is the token that the DNN Module Installer uses to install your DB script with the correct owner. If your database owner comes up as something other than dbo during the process, the script will fail when trying to install your PA because the database owner prefix won't be supplanted with the {databaseOwner} token.
  • You are using a Visual Studio .NET, SQL Server, IIS/ASP.NET Development Environment. This exact setup may not be required for the packager to work, however, it absolutely will not work if you do not have IIS running, and scripting won't work if SQLDMO is not available. I have provided a little bit of Microsoft Access® support for generating the manifest file, however, DB scripting is not implemented for Access.

    I access the IIS Metabase through directory services, so it must be available. And I generated a Primary Interop Assembly (PIA) for SQLDMO so that I could access it through managed code. This PIA is included in the project and in the installer, however, it won't work unless SQLDMO is actually available on your machine.

  • You are using DotNetNuke 2.1.2 or later. The module packager has been tested on DNN 2.1.2, 3.0, and 3.1. If you have problems using it with any of these, please let me know.

The PA Generation Process

I will discuss the pertinent parts of each of these steps in detail with code, however, it will help a bit to explain now at a high level how I thought about the process and what each of the steps are. When I considered the problem, I felt that what I wanted was a way to tell the application where to find a few pieces of information and have it handle the rest. To me, the logical starting place was allowing the user to select the web application (a.k.a. virtual directory) in which the custom module in question is implemented. Here is the basic flow:

  1. Select a web application (virtual directory) from a list of web applications found in the Metabase.
  2. Obtain the connection string from the Web.config file found in the local file system path pointed to by the selected web application (or fail).
  3. With the connection string, get a list of all of the module definitions in the selected web application (must be able to find the DesktopModules table or fail).
  4. Manually select the assemblies used by the module from a list of assemblies found in the web application bin directory (there is also an auto assembly dependency detection mechanism, but the manual route ensures you get what you want).
  5. Specify a prefix to use when searching for the database objects used by the module (e.g.: MyCompany_).
  6. Specify a ZIP filename to put all of the files into.
  7. Specify whether or not to delete all of the files that were placed into a temp directory for zipping and whether or not the system should try to auto detect any assembly dependencies that were missed when the assemblies were selected manually in step 4. You may not want to delete the temporary files, as sometimes, depending on your module, there will be files that you will want to add manually that the packager is not aware of.
  8. Sit back and watch the show.

Once the process has completed, if you specified that you wanted the application to keep the temporary files, you will find a ZIP file in the directory you specified for the ZIP file's output as well as a directory with the module name that will contain all of the loose files. Also, if you specified that you wanted the application to auto detect any assembly dependencies you may have missed, if it found any, you will see a report of what it found in the final page.

Web Application List (The Metabase)

(For all you Neil Stephenson fans, I keep wanting to call this thing the Metaverse ;-) In order to obtain a list of all available web applications, it seemed that the simplest way was to access the Metabase. From the Microsoft web site, in a nutshell, "The metabase is a hierarchical store of configuration information and schema that are used to configure IIS". For all intents and purposes, the metabase is an XML based configuration file (and schema). For more information, check out the site where the above definition came from. With a quick Internet search, I was able to find some basic information on how to programmatically access the Metabase. After seeing a few examples on the web and playing around with the MetaEdit utility (download it now to understand more clearly), which allowed me to see the schema hierarchy for the data, I had everything I needed to write the following code:

C#
public Hashtable GetSitePaths()
{
    Hashtable tmp = new Hashtable();
    DirectoryEntry root = new DirectoryEntry("IIS://localhost/w3svc/1/root");
    foreach( DirectoryEntry e in root.Children )
    {
        tmp[ e.Name ] = e.Path;
    }
    return tmp;
}

I used the System.DirectoryServices namespace to obtain a list of all of the directory entries for the server root. Notice the line DirectoryEntry root = new DirectoryEntry("IIS://localhost/w3svc/1/root");. That path gives us a list of all of the local web applications (virtual directories). If you look at the MetaEdit utility, you can see this graphically:

Image 2

When GetSitePaths method returns, the hashtable is populated with a list of virtual directory names and their corresponding virtual directory paths. This hashtable is used to populate the combo box and as a lookup for the path to the virtual directory once the selection has been made.

Image 3

Note about the use of Hashtables: This may be a common practice for you as a programmer, but I'm going to explain this a little bit for others who do not necessarily use this technique. I use hashtables for many of my data collections for one or both of the following reasons:

  • Hashtables provide a simple way to implement an MVC (Model View Controller) design. The hashtable is the model. In my application, the view is the combo box and the controller is the wizard application. Since I populate my combo box with the names of the web applications, I can also use that name as the key in the hashtable so that when I have selected the application I want, it is very simple to look up the value for that key by using (string)this.sites[ this.cmbVirtualDirectories.Text ] where sites is the hashtable containing my web applications/virtual directories and cmbVirtualDirectories.Text is the currently selected value in my combo box.
  • Hashtables by default only allow unique keys. This means that you can not have the same value twice for a key. This often comes in handy when you need a list of distinct values and you don't want to actually look through a collection to see if you already have found the current one previously. You simply set the value you have as the key and use anything you want as the value. If the key was already there, it just gets overwritten with the same value. If not, it gets added.

Module Definition Selection

When we selected a web application, the SelectedIndexChange event was fired. At this point, I am able to convert the virtual path associated with that selection into a local file system path, and simply locate the Web.config file for that application. Once I have the Web.config file, I can extract the connection string. I use that connection string for any database purposes in the remaining steps in the process. Here is the code I use to obtain the connection string:

C#
private void cmbVirtualDirectories_SelectedIndexChanged(object 
                                    sender, System.EventArgs e)
{
    // Make sure our combo box has a valid selection
    if( this.cmbVirtualDirectories.Text.Length > 0 )
    {
        // Translate the web path into a local filesystem path
        sitePath = new VirtualDirectoryUtility().GetVirtualDirLocalPath(
          (string)this.sites[ this.cmbVirtualDirectories.Text ] ) + "\\";
        if( sitePath.Length > 0 )
        {
            // Check to see if a web.config file exists
            if( !File.Exists( sitePath + "Web.config" ) )
            {
                // If not then fail and let user know
                // they need to make a different selection
                MessageBox.Show( this, 
                  "The web directory you selected has no Web.config file. " +
                  "Please select a different web directory", 
                  "Invalid Web Directory", 
                  MessageBoxButtons.OK, 
                  MessageBoxIcon.Exclamation );
                return;
            }
            // Load the web config into an XML document
            XmlDocument config = new XmlDocument();
            config.Load( sitePath + "Web.config" );

            // First check to see whether we're using DNN 2 or 3
            XmlNode siteSqlServer = 
              config["configuration"]["appSettings"].SelectSingleNode(
                                    "add[@key = \"SiteSqlServer\"]" );
            if( siteSqlServer != null )
                this.version = DNN_VERSION.DNN3;

            // Find out which data provider type we are using
            XmlNode dataNode = config["configuration"]["dotnetnuke"]["data"];
            // If both of these are null, then we're not
            // finding what we need to continue --
            // must go back and try again
            if( dataNode == null && siteSqlServer == null )
            {
                // this node is required. If it's not there,
                // then we need to error out.
                MessageBox.Show( this, 
                  "The Web.config file found does" + 
                  " have the proper XML format. " +
                  "Please check to make sure that you" + 
                  " are using a valid DotNetNuke 2 installation. " + 
                  "Please select a different web directory", 
                  "Invalid Web.config File", 
                  MessageBoxButtons.OK, 
                  MessageBoxIcon.Exclamation );
                return;
            }
            if( dataNode !=  null && siteSqlServer == null )
                this.version = DNN_VERSION.DNN2;

            this.dataProviderType = 
              dataNode.Attributes["defaultProvider"].Value;

            // Find the correct key field
            switch( this.version )
            {
                case DNN_VERSION.DNN2:
                    XmlNode providerNode = 
                      config["configuration"]["dotnetnuke"]["data"]
                            ["providers"].SelectSingleNode( "add[@name=\"" + 
                            dataProviderType + "\"]" );
                    // Set the connection string instance
                    // variable for use throughout
                    // the rest of the process and then break out of the loop.
                    if( dataProviderType.Equals( "SqlDataProvider" ) )
                    {
                        connectionString = 
                          providerNode.Attributes["connectionString"].Value;
                    }
                    else
                    {
                        // Have to attach the datasource to the
                        .. connection string manually or we won't be
                        // able to connect to it. We assume that is is in the
                        // (root)/Providers/DataProviders/AccessDataProvider directory
                        string fileName = 
                          providerNode.Attributes["databaseFilename"].Value;
                        connectionString = 
                          providerNode.Attributes["connectionString"].Value + 
                          "Data Source=" + 
                          sitePath + 
                          @"Providers\DataProviders\AccessDataProvider\" + 
                          fileName;
                    }
                    break;
                case DNN_VERSION.DNN3:
                    connectionString = siteSqlServer.Attributes["value"].Value;
                    break;
            }
            // Enable the next button now that we have a valid selection.
            this.wizardMain.NextEnabled = true;
        }
    }
}

In the line sitePath = new VirtualDirectoryUtility().GetVirtualDirLocalPath( (string)this.sites[ this.cmbVirtualDirectories.Text ] ) + "\\";, I am calling out to a utility to provide me with a translation of the virtual directory path to the actual file system path. To gain a better understanding of this, let's say, for instance, that I select a DotNetNuke installation on my local system called DNNSB. The virtual path associated with the DNNSB virtual directory as loaded by my GetSitePaths() method (see above) would be "IIS://localhost/w3svc/1/root/DNNSB". Now, the utility function code to convert the virtual path to a file system path looks like this:

C#
public string GetVirtualDirLocalPath( string directoryEntry )
{
    DirectoryEntry virtualPath = new DirectoryEntry( directoryEntry );
    return virtualPath.Properties["Path"].Value.ToString();
}

This simply opens the "IIS://localhost/w3svc/1/root/DNNSB" directory entry and looks up the "Path" property. "Path" contains the full path to the file system directory.

Note: If you would like to know what other properties are available in the DirectoryEntry.Properties collection when loading a virtual directory, open the Metabase with the MetaEdit utility and look at the properties under the Schema node. Remember that the MetaEdit utility is just a simple tool that provides a tree view of the Metabase XML and schema files. If you're not familiar with XML, just remember that the schema is a definition of the data, and what you see under the LM node is the actual implementation of the data. Drill down in the MetaEdit utility under the LM node to a specific virtual directory under "IIS://localhost/w3svc/1/root" and you will see what properties are actually implemented on the virtual directory you select.

With the file system path known, I simply append "Web.config" to the path and load it into an XML document. I can then search through the XML nodes until I find the appropriate token for either DNN2 or DNN3. Notice that the method supports grabbing the connection string for both SQL Server and Microsoft Access. Once I have a valid connection string, I can obtain a list of module definitions found in the database. The code to do that looks like this:

C#
private void LoadModuleDefs()
{
    try
    {
        // Ensure that we have a valid connection string
        if( connectionString.Length <= 0 )
        {
            // If not, warn the user and go back to the
            // web application selection page in order
            // to select a valid application.
            MessageBox.Show( this, "The connection string was not found while" + 
                            " parsing the web.config file for the current web" + 
                            " application. Please go back and select" + 
                            " a different web application.", "Bad Connection String", 
                            MessageBoxButtons.OK, MessageBoxIcon.Error );
            this.wizardMain.BackTo( this.wpSiteSelect );
            return;
        }

        Cursor.Current = Cursors.WaitCursor;
        // Connection to Sql Server
        if( this.dataProviderType.Equals( "SqlDataProvider" ) )
        {
            // Connect to the DB
            SqlConnection connection = new SqlConnection( connectionString );
            connection.Open();

            // Obtain all Desktop Module definitions in the selected DB
            SqlCommand command = new SqlCommand( "SELECT DesktopModuleID, 
                         FriendlyName FROM DesktopModules", connection );
            SqlDataReader reader = command.ExecuteReader();

            // Instantiate the modules instance variable
            modules = new Hashtable();
            while( reader.Read() )
            {
                // Use the "FriendlyName" as the key and
                // the "DesktopModuleID" as the value when
                // adding to our modules hashtable instance variable
                modules[reader[1].ToString()] = reader[0].ToString();
                // Add the "FriendlyName" to the list of module definitions
                this.cmbModuleDefinitions.Items.Add( reader[1].ToString() );
            }

            reader.Close();
            connection.Close();
        }
        // Connection to Access
        else
        {
            // Connect to the DB
            OleDbConnection connection = new OleDbConnection( connectionString );
            connection.Open();

            // Obtain all Desktop Module definitions in the selected DB
            OleDbCommand command = new OleDbCommand( "SELECT DesktopModuleID, 
                   FriendlyName FROM DotNetNuke_DesktopModules", connection);
            OleDbDataReader reader = command.ExecuteReader();

            // Instantiate the modules instance variable
            modules = new Hashtable();
            while(reader.Read())
            {
                // Use the "FriendlyName" as the key and
                // the "DesktopModuleID" as the value when
                // adding to our modules hashtable instance variable
                modules[reader[1].ToString()] = reader[0].ToString();
                // Add the "FriendlyName" to the list of module definitions
                this.cmbModuleDefinitions.Items.Add( reader[1].ToString() );
            }

            reader.Close();
            connection.Close();
        }
        Cursor.Current = Cursors.Default;
    }
    catch ( Exception ex )
    {
        // Something went wrong. We should notify the user and the go back to the
        // web application selection page.
        MessageBox.Show( this, ex.Message, "Exception Caught" );
        this.wizardMain.BackTo( this.wpSiteSelect );
        return;
    }
}

Notice once again that I have provided support for both SQL Server and Microsoft Access. Now that the module definitions are loaded into the combo box, the user can select the module they would like to package up and then click Next.

Image 4

Selecting Assemblies

I toyed with the idea of just auto-detecting the module dependencies exclusively, but it seemed to me that, at best, it was probably only slightly more efficient to do so, and at worst (e.g., if it's not very accurate at detecting them), you would have to select your assemblies manually anyhow. The path I chose was to enable the user to select the assemblies used by the module manually from a list of assemblies found in the site bin directory. Then, later in the process, I give the user the ability to allow the auto-detection feature to try to find anything that may be missing. (I discuss the auto-detection feature in greater depth later--turns out that auto-detection works pretty well.) The list box gets populated with all of the assemblies found in the bin directory (I just append "\bin\" to the path found in the Metabase to find this) of the virtual directory currently selected. The list box allows multiple selections. Users simply select the assemblies associated with their module and click Next.

Image 5

Database Scripting

The next step allows the user to provide a prefix that is used to locate database objects related to the module being packaged.

Image 6

Please read the "Assumptions" section at the beginning of this article so you know what to expect when it comes to creating your database objects with a prefix. This part simply uses the SQLDMO to script out the objects that it finds according to the prefix provided. I created another utility class to accomplish this. You will find the code below. Keep in mind that by showing this part now, it may seem that it is at this point in the wizard process when this code gets run. In actuality, the processing doesn't start until you get to the progress screen at the end of the wizard. I am only collecting the prefix at this stage, however, it seems like a good place in the article to discuss what the application will do with this prefix once processing has actually begun. Here is the code:

C#
public static string GetScript(string hostName, string dbName, 
                               string username, string password, 
                               string prefix )
{
    // Instantiate the Sql Server Object
    SQLDMO.SQLServer srv = new SQLDMO.SQLServer();
    
    // If there is no username provided, we assume that we're going to use
    // a trusted connection
    if( username.Length <= 0 )
    {
        srv.LoginSecure = true;
        srv.Connect( hostName, "", "" );
    }
    // Otherwise we log in with the specified credentials
    else
        srv.Connect(hostName, username, password );

    // We have to set some scripting parameters before we start
    SQLDMO.SQLDMO_SCRIPT_TYPE param = SQLDMO_SCRIPT_TYPE.SQLDMOScript_Default|
        // Script out indexes
        SQLDMO.SQLDMO_SCRIPT_TYPE.SQLDMOScript_Indexes |
        // Script out drop statements
        SQLDMO_SCRIPT_TYPE.SQLDMOScript_Drops |
        // Prefix the object name with the database owner
        SQLDMO_SCRIPT_TYPE.SQLDMOScript_OwnerQualify;

    string script = "";
    foreach(SQLDMO.Database db in srv.Databases)
    {
        // Have to iterate through the list of databases to find the one that
        // was specified
        if(db.Name!=null && db.Name.ToLower().Equals(dbName.ToLower()) )
        {
            // First search through all of the tables and locate the ones
            // with the prefix provided (case insensitive)
            foreach( SQLDMO.Table table in db.Tables )
            {
                if( table.Name.ToLower().StartsWith( prefix.ToLower() ) )
                {
                    // Append the script for the current table
                    script += table.Script( param, null, null, 
                          SQLDMO.SQLDMO_SCRIPT2_TYPE.SQLDMOScript2_Default );
                }
            }
            // Next search through all of the stored procedures and locate 
            // the ones with the prefix provided (case insensitive)
            foreach( SQLDMO.StoredProcedure proc in db.StoredProcedures )
            {
                if( proc.Name.ToLower().StartsWith( prefix.ToLower() ) )
                {
                    // Append the script for the current stored procedure
                    script += proc.Script( param, null, 
                         SQLDMO.SQLDMO_SCRIPT2_TYPE.SQLDMOScript2_Default );
                }
            }
            break;
        }
    }
    return script;

}

You can see that I specify several parameters for the SQLDMO scripting mechanism to use, including using default settings, creating indexes, creating drop statements, and prefixing the object names with the database owner name. When we return from this method, we have a complete script in hand. To achieve "out-of-the-box" usability, we now need to replace the database owner prefix (which must be "[dbo].") with the {databaseOwner} token that the DotNetNuke module installer expects. Here is what that calling code looks like:

C#
private void CreateSqlScript()
{
    try
    {
        // Get the script according to the specified connection string and
        // db prefix
        string script = DbScriptingUtility.GetScript( connectionString, 
                                                     this.dbPrefix );


        // Replace the db owner with the token expected by the DNN Module 
        // Installer
        script = script.Replace( "[dbo].", "{databaseOwner}" );

        // Set the version information
        string ver = ( this.manifestCreator.Version.Length > 0 ) ? 
                       this.manifestCreator.Version : "01.00.00";

        // Write out the file to the temporary directory where our other
        // files are being copied.
        StreamWriter writer = File.CreateText( tempDirPath + "\\" + 
                      TEMP_DIR_NAME + "\\" + ver + ".SqlDataProvider" );

        writer.Write( script );
        writer.Close();
    }
    catch ( Exception ex )
    {
        MessageBox.Show( this, ex.Message, "Exception Caught" );
    }
}

For right now, you can ignore the code where we are using the manifestCreator. We will be discussing that in greater depth in the next section. Just suffice it to say that once these methods have run, we will have a database script that the DNN module installer can use to create our objects when the private assembly is installed through the DNN file manager.

Manifest Creator

The manifest creator is really the heart and soul of the DNN Module Packager. It generates our .dnn manifest file with all of the information we have provided. It begins by getting information about the module from the DesktopModules table in the database. From there, we acquire module name, description, and version. Next, we select module control data by joining against the ModuleDefinitions and ModuleControls tables. Once this data is available, we simply add it to the XML .dnn file in the appropriate order with the appropriate tags. Here is the ManifestCreator's Generate method:

C#
public XmlDocument Generate( string connectionString, 
                   string desktopModuleId, string dnnVersion )
{
    // Set the intance variables
    this.connectionString = connectionString;
    this.desktopModuleId = desktopModuleId;

    // connectionString and desktopModuleId cannot be null or empty
    Debug.Assert( this.connectionString.Length > 0, 
      "ManifestCreator.connectionString must be set before generating." );
    Debug.Assert( this.desktopModuleId.Length > 0, 
      "ManifestCreator.desktopModuleId must be set before generating." );

    // Create our XML document and its top level node
    doc = new XmlDocument();
    XmlElement topElement = doc.CreateElement( "dotnetnuke" );

    // Add some attributes to the top level node
    topElement.SetAttribute( "version", dnnVersion );
    topElement.SetAttribute( "type", "Module" );
    doc.AppendChild( topElement );

    // Create the folders tag
    XmlElement folders = doc.CreateElement( "folders" );
    topElement.AppendChild( folders );

    // Create the folder tag
    XmlElement folder = doc.CreateElement( "folder" );
    folders.AppendChild( folder );

    // Call virtual method to load the module definition
    // information. Overriden child class version is called. 
    // See SqlServerManifestCreator or AccessManifestCreator for details.
    LoadModuleDefinitionInfo();

    // Create xml elements to hold the data found in the db
    XmlElement name = doc.CreateElement( "name" );
    XmlElement desc = doc.CreateElement( "description" );
    XmlElement version = doc.CreateElement( "version" );

    // Set the xml inner text to reflect the data found in the db
    name.InnerText = friendlyName.Replace( " ", "_" );
    desc.InnerText    = description;
    if( ver == null || ver.Length <= 0 )
        ver = "01.00.00";
    version.InnerText = ver;

    // Append these nodes to the folder node
    folder.AppendChild( name );
    folder.AppendChild( desc );
    folder.AppendChild( version );

    // Create the modules node. 
    XmlElement modules = doc.CreateElement( "modules" );
    folder.AppendChild( modules );

    // Call virtual method to load the list of modules.
    // Overriden child class version is called. 
    // See SqlServerManifestCreator or AccessManifestCreator for details.
    LoadModules();

    // For each of the modules we found,
    // we have to create a module xml node as well
    // as each of the control nodes.
    Hashtable files = new Hashtable();
    bool exPathWasSet = false;
    foreach( string key in friendlyNames.Keys )
    {
        // Create the module node
        XmlElement mod = doc.CreateElement( "module" );
        // Create the friendlyname node for the module node
        XmlElement fName = doc.CreateElement( "friendlyname" );
        // Append the nodes to the document
        modules.AppendChild( mod );
        mod.AppendChild( fName );
        // Set the friendly name equal to the key name
        fName.InnerText = key;

        // Created the controls node to hold all of the control nodes
        XmlElement controls = doc.CreateElement( "controls" );
        mod.AppendChild( controls );

        foreach( object[] row in items )
        {
            // If this is the module we're currently working on
            if( row[8].ToString().Equals( key ) )
            {
                // Create a control node
                XmlElement control = doc.CreateElement( "control" );
                controls.AppendChild( control );

                // 2 - key, 3 - title, 4 - src, 5 - iconfile, 6 - controltype

                // Create the control key node
                if( row[2].ToString().Length > 0 )
                {
                    XmlElement itemKey = doc.CreateElement( "key" );
                    itemKey.InnerText = row[2].ToString();
                    control.AppendChild( itemKey );
                }
                // Create the control title node
                if( row[3].ToString().Length > 0 )
                {
                    XmlElement itemTitle = doc.CreateElement( "title" );
                    itemTitle.InnerText = row[3].ToString();
                    control.AppendChild( itemTitle );
                }
                // Create the control src node
                if( row[4].ToString().Length > 0 )
                {
                    XmlElement itemSrc = doc.CreateElement( "src" );
                    string source = row[4].ToString();
                    itemSrc.InnerText = 
                      source.Substring( source.LastIndexOf( "/" ) + 1 );
                    // Keep a list of all of the files found
                    // while iterating to keep from
                    // having to run through the dataset again.
                    files[source] = 1;
                    control.AppendChild( itemSrc );
                    if( !exPathWasSet )
                    {
                        this.extendedPath = source.Substring( 0, 
                                            source.LastIndexOf( "/" ) );
                        exPathWasSet = true;
                    }
                }
                // Create the control iconfile node
                if( row[5].ToString().Length > 0 )
                {
                    XmlElement itemIconFile = doc.CreateElement( "iconfile" );
                    itemIconFile.InnerText = row[5].ToString();
                    // Keep a list of all of the files found
                    // while iterating to keep from
                    // having to run through the dataset again.
                    files[row[5].ToString()] = 1;
                    control.AppendChild( itemIconFile );
                }
                // Create the control type node and set the correct type name
                if( row[6].ToString().Length > 0 )
                {
                    XmlElement itemType = doc.CreateElement( "type" );
                    switch( row[6].ToString() )
                    {
                        case "-2":
                            itemType.InnerText = "Skin Object";
                            break;
                        case "-1":
                            itemType.InnerText = "Anonymous";
                            break;
                        case "0":
                            itemType.InnerText = "View";
                            break;
                        case "1":
                            itemType.InnerText = "Edit";
                            break;
                        case "2":
                            itemType.InnerText = "Admin";
                            break;
                        case "3":
                            itemType.InnerText = "Host";
                            break;
                    }
                    control.AppendChild( itemType );
                }
                
            }
        }
    }
    // Create the files node
    xfiles = doc.CreateElement( "files" );
    folder.AppendChild( xfiles );

    int fileCount = 0;
    // contentFiles can be accessed as a property
    // once the generate method has run. This
    // is where we populate it with a list of all of the files
    contentFiles = new string[files.Keys.Count];
    foreach( string key in files.Keys )
    {
        // Add the filename to the contentFiles array
        contentFiles[fileCount] = key;
        // Create the file node for the current file
        XmlElement file = doc.CreateElement( "file" );
        xfiles.AppendChild( file );
        // Create a name node to be appended to the file node
        XmlElement fileName = doc.CreateElement( "name" );
        // We only need the filename for the manifest file,
        // so we extract it from the path
        string source = key.Substring( key.LastIndexOf( "/" ) + 1 );
        // Set the name node's inner text
        fileName.InnerText = source;
        file.AppendChild( fileName );
        fileCount++;
    }

    this.hasBeenGenerated = true;
    return doc;
}

There are a significant number of comments in this code to try to explain what exactly is going on, so read through those thoroughly. Don't let the code overwhelm you though. If you have ever generated an XML document tag by tag before, then you'll recognize that there really isn't anything earth shattering going on here. It is just taking the data we got from the database, along with a list of the content files, assembly files, and the SQL script file, and generating an XML based .dnn manifest file.

Notes on Module Version

If you do not have a version set for your module, the version used by the application will automatically default to 01.00.00. If you don't want this to happen, then you will need to change your module's version in the database before running the packager. The module version can be set in the DesktopModules table manually using Enterprise Manager. In order to check and see if you have set the module version, log into your DotNetNuke site as the host account and select Host > Module Definitions. Now click on the edit pencil next to your module definition. You will see a screen like the one below. Notice that the version field is not editable (which is the reason you have to set it manually in the database).

Image 7

If the version is not set in your module, then go into the DesktopModules database table in Enterprise Manager and locate your module's record. Set your version number in the Version column in the format xx.xx.xx. This version is used for both the .dnn file info and for your SqlDataProvider SQL file which is named according to the value found (as in 01.00.00.SqlDataProvider).

Automatic Dependency Detection

Detecting module dependencies was definitely one of the more interesting parts of this project. Again, I had to make several assumptions in order to make it work. The basic process of dependency detection is done by obtaining a list of all of the content files and reading through the header lines to find:

  • The namespace for the current content file.
  • Any other web control references.

There are two lines that I look for. The first is the Control tag. It looks something like this:

ASP.NET
<%@ Control Language="c#" 
        AutoEventWireup="false" 
        Codebehind="EditProduct.ascx.cs" 
        Inherits="SkyeRoad.ProductCatalog.EditProduct" 
        TargetSchema="http://schemas.microsoft.com/intellisense/ie5"%>

I search for the Inherits tag and grab that value. I assume that the assembly name that references this object will be the full class namespace minus the class name. So in the example code above, I am assuming that the assembly (DLL) we're looking for is SkyeRoad.ProductCatalog.dll since the full class namespace is SkyeRoad.ProductCatalog.EditProduct.

I also look for lines that contain information about an external control. It looks something like this:

ASP.NET
<%@ Register TagPrefix="SRSWC" 
        Namespace="SkyeRoad.WebControls" 
        Assembly="SkyeRoad.WebControls"%>

I search for the Assembly tag and grab that value and append .dll to it. In this case, it isn't necessary to assume what the assembly name might be since it is clearly spelled out for us.

A third way I detect dependencies is I use the Assembly.GetReferencedAssemblies() method on any primary assembly. I refer to any assembly found in the Control tag of a content file as a primary assembly.

Once again, I created a utility class to handle the work of locating dependencies. Below is the method FindAssemblies(). Notice that this class uses regular expressions to determine if a line contains the tags we're looking for.

C#
public static string[] FindAssemblies( string[] contentFiles, string sitePath )
{
    // We create a hashtable to hold the names of the assemblies we find. Since
    // the default behaviour of a hashtable is to store unique keys only, it 
    // provides a simple shortcut to ensure that we only have one of each 
    // assembly found. Since we're iterating through multiple content files, 
    // it is possible that we'll find the same assembly name twice so this 
    // ensures that it will only be in the final list once.
    Hashtable assemblies = new Hashtable();
    foreach( string contentFilepath in contentFiles )
    {
        StreamReader reader = File.OpenText( contentFilepath );
        //            FileInfo fInfo = new FileInfo( contentFilepath );
        string line = "";
        while( ((line = reader.ReadLine()) != null) )
        {
            if( line.IndexOf( "<%@" ) > -1 )
            {
                // If we find an Assembly key, then we have some sort of 
                // other dependency on the page, probably a web control of 
                // some sort. We will need to list this as a dependency
                Regex regex = new Regex( "Assembly\\s*=\\s*\"?(?[^\"]+)\"", 
                               RegexOptions.IgnoreCase );
                Match match = regex.Match( line );
                if( match.Success )
                    assemblies[match.Groups["assembly"].Value + ".dll"] = 0;

                // The Inherits tag indicates what namespace the main assembly
                // is in for this particular content file. We need to grab 
                // this as well
                regex = new Regex( "Inherits\\s*=\\s*\"?(?<namespace>[^\"]+)\"",
                                   RegexOptions.IgnoreCase );
                match = regex.Match( line );
                if( match.Success )
                {
                    // Because the inherits tag gets us a full namespace, we
                    // need to leave off the name of the actual object in 
                    // order to know the assembly name.
                    string nameSpace = 
                              match.Groups["namespace"].Value.Substring( 0, 
                         match.Groups["namespace"].Value.LastIndexOf( "." ) );
                    assemblies[nameSpace + ".dll"] = 1;
                }                
            }
        }
        reader.Close();

        ArrayList dependencies = new ArrayList();
        // Iterate through our assemblies list and use reflection to 
        // determine any dependencies we may have missed.
        foreach( string key in assemblies.Keys )
        {
            // If it was set to 1 then we know that it is our primary assembly
            if( (int)assemblies[key] == 1 )
            {
                if( File.Exists( sitePath + "\\bin\\" + key ) )
                {
                    AssemblyName[] assemblyNames = Assembly.LoadFrom( 
                      sitePath + "\\bin\\" + key ).GetReferencedAssemblies();
                    foreach( AssemblyName aName in assemblyNames )
                    {
                        // Make sure they aren't framework assemblies, 
                        // mscorlib, or the dotnetnuke assembly itself
                        if( !aName.Name.ToLower().StartsWith( "system" ) &&
                            !aName.Name.ToLower().StartsWith( "microsoft" ) &&
                            !aName.Name.ToLower().StartsWith( "mscorlib" ) &&
                            !aName.Name.ToLower().StartsWith( "dotnetnuke" ) )
                        {
                            dependencies.Add( aName.Name + ".dll" );
                        }
                    }
                }
            }
        }

        foreach( string dep in dependencies )
        {
            assemblies[dep] = 1;
        }
    }
    // Convert our hashtable to an array to pass back to the caller
    string[] retArray = new string[ assemblies.Count ];
    int index = 0;
    foreach( string key in assemblies.Keys )
    {
        retArray[index++] = key;
    }

    // Return the array
    return retArray;
}

I start out by opening the content file and reading through it. If a line contains the string "<%@", then I know I have found one of the lines I am interested in. Next, I check the line to see if it has the Assembly or Inherits tags. If it does, I extract the value for each of those and determine what assembly I need from there according to the assumptions I stated earlier in this section. Then, I add the assembly to a hashtable and set its value to 1 if it is a primary assembly and a 0 if it is not (see above for definition of "primary assembly").

The next thing I do is iterate through all of the assemblies I have found, and if they have a 1 assigned to them, I use reflection to obtain a list of dependencies through the GetReferencedAssemblies() call. You'll notice that I have chosen to exclude certain assemblies from the detection process. We don't need .NET framework assemblies to be included in our module, nor do we need MSCorLib or the DotNetNuke assembly. There may be others that I've overlooked, but I will only know for sure over time and with a lot of use.

The final step before returning is to convert the contents to an array from a hashtable. This creates some extra overhead, but I prefer to return a regular array than a hashtable in this instance. This is really a personal preference more than anything. There is really nothing special about doing it this way.

Processing Options Selection

The last thing you do before the processing begins is to select from two options:

  • Whether you want to keep the temporary files.
  • Whether you want to try to auto detect assemblies that were missed at the assembly dependency selection page.

Image 8

Conclusion

When the process has completed, you will be provided with information about the final outcome. You are informed:

  • Of the location of the packaged (.zip) file.
  • If the temporary files were left intact (i.e. you left unchecked the "Delete temporary files retaining only zip package" check box) and the location of the loose files.
  • If there were any dependencies detected that you missed while selecting assemblies.

Image 9

The DNN Module Packager was an exercise of great learning for me. I'm sure there is still more that I need to add to this to make it work flawlessly, however, in the mean time it provides a simple and quick way to generate a private assembly for your DotNetNuke custom modules from your development environment.

History

  • 10/18/2005 - Fixed scripting utility to output file with UTF-8 encoding. DNN expects UTF-8 when parsing SQL scripts.
  • 10/12/2005 - Added support for DNN 3.1.1. The database objects naming structure changed in DNN 3.1.1, so this accounts for the prefix "dnn_".
  • 09/07/2005 - Added support for DotNetNuke 3.0 and higher modules. Also supports obtaining resx files found in the custom module's App_LocalResources directory.
  • 12/23/2004 - Fixed some issues (Thanks Jerry!).
    • Was previously using the old connectionString value from DNN 1. Corrected it to use the DataProvider section.
    • Added MS Access support (minus scripting capabilities).
    • Made database prefix usage case insensitive.
    • Fixed problem with parsing the connection string for use with SQLDMO. Now uses the proper key for password ("pwd").
  • 12/21/2004 - Initial post.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here


Written By
Web Developer
United States United States
Matt Long is the Director of Technology for Skye Road Systems, Inc. in Colorado Springs, Colorado. He provides software architecture consulting services to small businesses. To contact Matt ( perlmunger ) send an email to matt@skyeroadsystems.com.

Comments and Discussions

 
GeneralSupport for sites in subfolders. Pin
Michael Freidgeim3-May-06 17:38
Michael Freidgeim3-May-06 17:38 
QuestionDNN 3.2 has a method "Create Private Assembly" Pin
Michael Freidgeim12-Feb-06 18:32
Michael Freidgeim12-Feb-06 18:32 
AnswerRe: DNN 3.2 has a method "Create Private Assembly" Pin
perlmunger13-Feb-06 4:44
perlmunger13-Feb-06 4:44 
GeneralSmall Thought Pin
codegalaxy20-Oct-05 5:37
codegalaxy20-Oct-05 5:37 
AnswerRe: Small Thought Pin
perlmunger20-Oct-05 6:57
perlmunger20-Oct-05 6:57 
GeneralRe: Small Thought Pin
codegalaxy20-Oct-05 8:00
codegalaxy20-Oct-05 8:00 
GeneralRe: Small Thought Pin
perlmunger20-Oct-05 9:27
perlmunger20-Oct-05 9:27 
GeneralError Selecting Web Application After Install Pin
kyoseix19-Oct-05 9:14
kyoseix19-Oct-05 9:14 
GeneralRe: Error Selecting Web Application After Install Pin
perlmunger19-Oct-05 9:42
perlmunger19-Oct-05 9:42 
GeneralRe: Error Selecting Web Application After Install Pin
Artem Smirnov11-Mar-06 5:41
professionalArtem Smirnov11-Mar-06 5:41 
AnswerRe: Error Selecting Web Application After Install Pin
Artem Smirnov11-Mar-06 6:51
professionalArtem Smirnov11-Mar-06 6:51 
QuestionDNN 3.1: problem on 03.00.00.sqldataprovider Pin
cynxian11-Oct-05 20:36
cynxian11-Oct-05 20:36 
AnswerRe: DNN 3.1: problem on 03.00.00.sqldataprovider Pin
perlmunger12-Oct-05 4:17
perlmunger12-Oct-05 4:17 
GeneralRe: DNN 3.1: problem on 03.00.00.sqldataprovider Pin
cynxian12-Oct-05 19:53
cynxian12-Oct-05 19:53 
GeneralRe: DNN 3.1: problem on 03.00.00.sqldataprovider Pin
perlmunger13-Oct-05 5:19
perlmunger13-Oct-05 5:19 
GeneralRe: DNN 3.1: problem on 03.00.00.sqldataprovider Pin
cynxian13-Oct-05 17:44
cynxian13-Oct-05 17:44 
yes, thats happening, all files work well, i examined the packaged files and they are all good.. its just that.. the dotnetnuke core doesnt include the "+" character in a SQL statement. for example..
this is some part which the packager generated(sql)

CREATE procedure dbo.GetDocument<br />
@ItemId   int,<br />
@ModuleId int<br />
select 'URL' = case when Files.FileName is null then Blastasia_Documents.URL else Files.Folder + Files.FileName end,<br />
'CreatedByUser' = Users.FirstName + ' ' + Users.LastName


when i upload the file using the module definition (in the DNN)
it would show an error in some of my sql statements.. for example, the sql statement on top would show as:
CREATE procedure dbo.GetDocument<br />
@ItemId   int,<br />
@ModuleId int<br />
select 'URL' = case when Files.FileName is null then Blastasia_Documents.URL else Files.Folder Files.FileName end,<br />
'CreatedByUser' = Users.FirstName ' ' Users.LastName

in this code, it eliminate the "+" sign..

ill post later if i found a solution for this.. thanks
GeneralRe: DNN 3.1: problem on 03.00.00.sqldataprovider Pin
perlmunger13-Oct-05 18:45
perlmunger13-Oct-05 18:45 
GeneralRe: DNN 3.1: problem on 03.00.00.sqldataprovider Pin
perlmunger18-Oct-05 7:45
perlmunger18-Oct-05 7:45 
GeneralRe: DNN 3.1: problem on 03.00.00.sqldataprovider Pin
cynxian18-Oct-05 17:32
cynxian18-Oct-05 17:32 
GeneralI use with DNN 2.1.and it is OK ! Pin
Crie Portal25-May-05 17:49
sussCrie Portal25-May-05 17:49 
GeneralVery good... Pin
joelsef16-Jan-05 12:23
joelsef16-Jan-05 12:23 
GeneralSmall addition recommended... Pin
NC Hal6-Jan-05 5:42
NC Hal6-Jan-05 5:42 
GeneralReally great! Pin
alexdresko29-Dec-04 8:57
alexdresko29-Dec-04 8:57 
GeneralRe: Really great! Pin
perlmunger4-Jan-05 8:52
perlmunger4-Jan-05 8:52 
QuestionDNN 3.0 ? Pin
Anonymous28-Dec-04 5:49
Anonymous28-Dec-04 5:49 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.