I'm trying to make a Visual Studio (2010) template (multi-project). Everything seems good, except that the projects are being created in a sub-directory of the solution. This is not the behavior I'm looking for.
The zip file contains:
Folder1
+-- Project1
+-- Project1.vstemplate
+-- Project2
+-- Project2.vstemplate
myapplication.vstemplate
Here's my root template:
<VSTemplate Version="3.0.0" Type="ProjectGroup" xmlns="http://schemas.microsoft.com/developer/vstemplate/2005">
<TemplateData>
<Name>My application</Name>
<Description></Description>
<Icon>Icon.ico</Icon>
<ProjectType>CSharp</ProjectType>
<RequiredFrameworkVersion>4.0</RequiredFrameworkVersion>
<DefaultName>MyApplication</DefaultName>
<CreateNewFolder>false</CreateNewFolder>
</TemplateData>
<TemplateContent>
<ProjectCollection>
<SolutionFolder Name="Folder1">
<ProjectTemplateLink ProjectName="$safeprojectname$.Project1">Folder1\Project1\Project1.vstemplate</ProjectTemplateLink>
<ProjectTemplateLink ProjectName="$safeprojectname$.Project2">Folder2\Project2\Project2.vstemplate</ProjectTemplateLink>
</SolutionFolder>
</ProjectCollection>
</TemplateContent>
</VSTemplate>
And, when creating the solution using this template, I end up with directories like this:
Projects
+-- MyApplication1
+-- MyApplication1 // I'd like to have NOT this directory
+-- Folder1
+-- Project1
+-- Project2
solution file
Any help?
EDIT:
It seems that modifying <CreateNewFolder>false</CreateNewFolder>, either to true or false, doesn't change anything.
To create solution at root level (not nest them in subfolder) you must create two templates:
1) ProjectGroup stub template with your wizard inside that will create new project at the end from your
2) Project template
use the following approach for that
1. Add template something like this
<VSTemplate Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vstemplate/2005" Type="ProjectGroup">
<TemplateData>
<Name>X Application</Name>
<Description>X Shell.</Description>
<ProjectType>CSharp</ProjectType>
<Icon>__TemplateIcon.ico</Icon>
</TemplateData>
<TemplateContent>
</TemplateContent>
<WizardExtension>
<Assembly>XWizard, Version=1.0.0.0, Culture=neutral</Assembly>
<FullClassName>XWizard.FixRootFolderWizard</FullClassName>
</WizardExtension>
</VSTemplate>
2. Add code to wizard
// creates new project at root level instead of subfolder.
public class FixRootFolderWizard : IWizard
{
#region Fields
private string defaultDestinationFolder_;
private string templatePath_;
private string desiredNamespace_;
#endregion
#region Public Methods
...
public void RunFinished()
{
AddXProject(
defaultDestinationFolder_,
templatePath_,
desiredNamespace_);
}
public void RunStarted(object automationObject,
Dictionary<string, string> replacementsDictionary,
WizardRunKind runKind, object[] customParams)
{
defaultDestinationFolder_ = replacementsDictionary["$destinationdirectory$"];
templatePath_ =
Path.Combine(
Path.GetDirectoryName((string)customParams[0]),
#"Template\XSubProjectTemplateWizard.vstemplate");
desiredNamespace_ = replacementsDictionary["$safeprojectname$"];
string error;
if (!ValidateNamespace(desiredNamespace_, out error))
{
controller_.ShowError("Entered namespace is invalid: {0}", error);
controller_.CancelWizard();
}
}
public bool ShouldAddProjectItem(string filePath)
{
return true;
}
#endregion
}
public void AddXProject(
string defaultDestinationFolder,
string templatePath,
string desiredNamespace)
{
var dte2 = (DTE) System.Runtime.InteropServices.Marshal.GetActiveObject("VisualStudio.DTE.10.0");
var solution = (EnvDTE100.Solution4) dte2.Solution;
string destinationPath =
Path.Combine(
Path.GetDirectoryName(defaultDestinationFolder),
"X");
solution.AddFromTemplate(
templatePath,
destinationPath,
desiredNamespace,
false);
Directory.Delete(defaultDestinationFolder);
}
This is based on #drweb86 answer with some improvments and explanations.
Please notice few things:
The real template with projects links is under some dummy folder since you can't have more than one root vstemplate. (Visual studio will not display your template at all at such condition).
All the sub projects\templates have to be located under the real template file folder.
Zip template internal structure example:
RootTemplateFix.vstemplate
-> Template Folder
YourMultiTemplate.vstemplate
-->Sub Project Folder 1
SubProjectTemplate1.vstemplate
-->Sub Project Folder 2
SubProjectTemplate2.vstemplate
...
On the root template wizard you can run your user selection form and add them into a static variable. Sub wizards can copy these Global Parameters into their private dictionary.
Example:
public class WebAppRootWizard : IWizard
{
private EnvDTE._DTE _dte;
private string _originalDestinationFolder;
private string _solutionFolder;
private string _realTemplatePath;
private string _desiredNamespace;
internal readonly static Dictionary<string, string> GlobalParameters = new Dictionary<string, string>();
public void BeforeOpeningFile(ProjectItem projectItem)
{
}
public void ProjectFinishedGenerating(Project project)
{
}
public void ProjectItemFinishedGenerating(ProjectItem
projectItem)
{
}
public void RunFinished()
{
//Run the real template
_dte.Solution.AddFromTemplate(
_realTemplatePath,
_solutionFolder,
_desiredNamespace,
false);
//This is the old undesired folder
ThreadPool.QueueUserWorkItem(new System.Threading.WaitCallback(DeleteDummyDir), _originalDestinationFolder);
}
private void DeleteDummyDir(object oDir)
{
//Let the solution and dummy generated and exit...
System.Threading.Thread.Sleep(2000);
//Delete the original destination folder
string dir = (string)oDir;
if (!string.IsNullOrWhiteSpace(dir) && Directory.Exists(dir))
{
Directory.Delete(dir);
}
}
public void RunStarted(object automationObject,
Dictionary<string, string> replacementsDictionary,
WizardRunKind runKind, object[] customParams)
{
try
{
this._dte = automationObject as EnvDTE._DTE;
//Create the desired path and namespace to generate the project at
string temlateFilePath = (string)customParams[0];
string vsixFilePath = Path.GetDirectoryName(temlateFilePath);
_originalDestinationFolder = replacementsDictionary["$destinationdirectory$"];
_solutionFolder = replacementsDictionary["$solutiondirectory$"];
_realTemplatePath = Path.Combine(
vsixFilePath,
#"Template\BNHPWebApplication.vstemplate");
_desiredNamespace = replacementsDictionary["$safeprojectname$"];
//Set Organization
GlobalParameters.Add("$registeredorganization$", "My Organization");
//User selections interface
WebAppInstallationWizard inputForm = new WebAppInstallationWizard();
if (inputForm.ShowDialog() == DialogResult.Cancel)
{
throw new WizardCancelledException("The user cancelled the template creation");
}
// Add user selection parameters.
GlobalParameters.Add("$my_user_selection$",
inputForm.Param1Value);
}
catch (Exception ex)
{
MessageBox.Show(ex.ToString(), "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
public bool ShouldAddProjectItem(string filePath)
{
return true;
}
}
Notice that the original destination folder deletion is done via a different thread.
The reason is that the solution is generated after your wizard ends and this destination folder will get recreated.
By using ohter thread we assume that the solution and final destination folder will get created and only then we can safely delete this folder.
Another solution with using a Wizard alone:
public void RunStarted(object automationObject, Dictionary<string, string> replacementsDictionary, WizardRunKind runKind, object[] customParams)
{
try
{
_dte = automationObject as DTE2;
_destinationDirectory = replacementsDictionary["$destinationdirectory$"];
_safeProjectName = replacementsDictionary["$safeprojectname$"];
//Add custom parameters
}
catch (WizardCancelledException)
{
throw;
}
catch (Exception ex)
{
MessageBox.Show(ex + Environment.NewLine + ex.StackTrace, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
throw new WizardCancelledException("Wizard Exception", ex);
}
}
public void RunFinished()
{
if (!_destinationDirectory.EndsWith(_safeProjectName + Path.DirectorySeparatorChar + _safeProjectName))
return;
//The projects were created under a seperate folder -- lets fix it
var projectsObjects = new List<Tuple<Project,Project>>();
foreach (Project childProject in _dte.Solution.Projects)
{
if (string.IsNullOrEmpty(childProject.FileName)) //Solution Folder
{
projectsObjects.AddRange(from dynamic projectItem in childProject.ProjectItems select new Tuple<Project, Project>(childProject, projectItem.Object as Project));
}
else
{
projectsObjects.Add(new Tuple<Project, Project>(null, childProject));
}
}
foreach (var projectObject in projectsObjects)
{
var projectBadPath = projectObject.Item2.FileName;
var projectGoodPath = projectBadPath.Replace(
_safeProjectName + Path.DirectorySeparatorChar + _safeProjectName + Path.DirectorySeparatorChar,
_safeProjectName + Path.DirectorySeparatorChar);
_dte.Solution.Remove(projectObject.Item2);
Directory.Move(Path.GetDirectoryName(projectBadPath), Path.GetDirectoryName(projectGoodPath));
if (projectObject.Item1 != null) //Solution Folder
{
var solutionFolder = (SolutionFolder)projectObject.Item1.Object;
solutionFolder.AddFromFile(projectGoodPath);
}
else
{
_dte.Solution.AddFromFile(projectGoodPath);
}
}
ThreadPool.QueueUserWorkItem(dir =>
{
System.Threading.Thread.Sleep(2000);
Directory.Delete(_destinationDirectory, true);
}, _destinationDirectory);
}
This supports one level of solution folder (if you want you can make my solution recursive to support every levels)
Make Sure to put the projects in the <ProjectCollection> tag in order of most referenced to least referenced. because of the removal and adding of projects.
Multi-project templates are very tricky. I've found that the handling of $safeprojectname$ makes it almost impossible to create a multi-project template and have the namespace values replaced correctly. I've had to create a custom wizard which light up a new variable $saferootprojectname$ which is always the value that the user enters into the name for the new project.
In SideWaffle (which is a template pack with many templates) we have a couple multi-project templates. SideWaffle uses the TemplateBuilder NuGet package. TemplateBuilder has the wizards that you'll need for your multi-project template.
I have a 6 minute video on creating project templates with TemplateBuilder. For multi-project templates the process is a bit more cumbersome (but still much better than w/o TemplateBuilder. I have a sample multi-project template in the SideWaffle sources at https://github.com/ligershark/side-waffle/tree/master/TemplatePack/ProjectTemplates/Web/_Sample%20Multi%20Project.
Actually there is a workaround, it is ugly, but after diggin' the web I couldnt invent anything better. So, when creating a new instance of multiproject solution, you have to uncheck the 'create new folder' checkbox in the dialog. And before you start the directory structure should be like
Projects
{no dedicated folder yet}
After you create a solution a structure would be the following:
Projects
+--MyApplication1
+-- Project1
+-- Project2
solution file
So the only minor difference from the desired structure is the place of the solution file. So the first thing you should do after the new solution is generated and shown - select the solution and select "Save as" in menu, then move the file into the MyApplication1 folder. Then delete the previous solution file and here you are, the file structure is like this:
Projects
+--MyApplication1
+-- Project1
+-- Project2
solution file
I made a project that keys off the YouTube tutorial of Joche Ojeda and the answer by EliSherer above that addresses the question at the top of this article, and also allows us to create a dialog box that shows check boxes to toggle which sub-projects get generated.
Please click here for my GitHub repo that does the dialog box and tries to fix the folder issue in this question.
The README.md at the Repository root goes into excruciating depth as to the solution.
EDIT 1: Relevant Code
I want to add to this post the relevant code that addresses the OP's question.
First, we have to deal with folder naming conventions for solutions. Note, that my code is only designed to deal with the case where we are NOT putting the .csproj and .sln in the same folder; i.e., the following checkbox should be left blank:
Leaving the Place Solution and Project in the Same Directory check box blank
NOTE: The construct /* ... */ is used to signify other code that is not relevant to this answer. Also, the try/catch block structure I utilize is pretty much identical to that of EliSherer, so I won't reproduce that here, either.
We need to put the following fields in the beginning of the WizardImpl class in the MyProjectWizard DLL (this is the Root DLL that is called during the generation of the Solution). Please note that all code snippets are taken from my GitHub Repo I am linking to, and I am only going to show the pieces that have to deal with answering the OP's question. I will, however, echo all using's where relevant:
using Core.Config;
using Core.Files;
using EnvDTE;
using Microsoft.VisualStudio.TemplateWizard;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows.Forms;
namespace MyProjectWizard
{
/// <summary>
/// Implements a new project wizard in Visual Studio.
/// </summary>
public class WizardImpl : IWizard
{
/// <summary>
/// String containing the fully-qualified pathname
/// of the erroneously-generated sub-folder of the
/// Solution that is going to contain the individual
/// projects' folders.
/// </summary>
private string _erroneouslyCreatedProjectContainerFolder;
/// <summary>
/// String containing the name of the folder that
/// contains the generated <c>.sln</c> file.
/// </summary>
private string _solutionFileContainerFolderName;
/* ... */
}
}
Here's how we initialize these fields (in the RunStarted method of the same class):
using Core.Config;
using Core.Files;
using EnvDTE;
using Microsoft.VisualStudio.TemplateWizard;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows.Forms;
namespace MyProjectWizard
{
/// <summary>
/// Implements a new project wizard in Visual Studio.
/// </summary>
public class WizardImpl : IWizard
{
/* ... */
public void RunStarted(object automationObject,
Dictionary<string, string> replacementsDictionary,
WizardRunKind runKind, object[] customParams)
{
/* ... */
// Grab the path to the folder that
// is erroneously created to contain the sub-projects.
_erroneouslyCreatedProjectContainerFolder =
replacementsDictionary["$destinationdirectory$"];
// Here, in the 'root' wizard, the $safeprojectname$ variable
// contains the name of the containing folder of the .sln file
// generated by the process.
_solutionFileContainerFolderName =
replacementsDictionary["$safeprojectname$"];
/* ... */
}
}
}
To be fair, I don't think that the value in the _solutionFileContainerFolderName field is ever used, but I wanted to put it there so you can see what value $safeprojectname$ takes on in the Root Wizard.
In the screen shots in this article and in the GitHub, I call the example dummy project BrianApplication1 and the solution is named the same. In this example, then, the _solutionFileContainerFolderName field will have the value of BrianApplication1.
If I tell Visual Studio I want to create the solution and project (really, the multi-project template) in the C:\temp folder, then $destinationdirectory$ gets filled with C:\temp\BrianApplication1\BrianApplication1.
The projects in the multi-project template all get initially generated underneath the C:\temp\BrianApplication1\BrianApplication1 folder, like so:
C:\
|
--- temp
|
--- BrianApplication1
|
--- BrianApplication1.sln
|
--- BrianApplication1 <-- extra folder that needs to go away
|
--- BrianApplication1.DAL
| |
| --- BrianApplication1.DAL.csproj
| |
| --- <other project files and folders>
--- BrianApplication1.WindowsApp
| |
| --- BrianApplication1.WindowsApp.csproj
| |
| --- <other project files and folders>
The whole point of the OP's post, and my solution, is to create a folder structure that hews to convention; i.e.:
C:\
|
--- temp
|
--- BrianApplication1
|
--- BrianApplication1.sln
|
--- BrianApplication1.DAL
| |
| --- BrianApplication1.DAL.csproj
| |
| --- <other project files and folders>
--- BrianApplication1.WindowsApp
| |
| --- BrianApplication1.WindowsApp.csproj
| |
| --- <other project files and folders>
We are almost done with the Root implementation of IWizard's job. We still need to implement the RunFinished method (btw, the other IWizard methods are irrelevant to this solution).
The job of the RunFinished method is to simply remove the erroneously-created container folder for the sub-projects, now that they've all been moved up one level in the file system:
using Core.Config;
using Core.Files;
using EnvDTE;
using Microsoft.VisualStudio.TemplateWizard;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows.Forms;
namespace MyProjectWizard
{
/// <summary>
/// Implements a new project wizard in Visual Studio.
/// </summary>
public class WizardImpl : IWizard
{
/* ... */
/// <summary>Runs custom wizard logic when the wizard
/// has completed all tasks.</summary>
public void RunFinished()
{
// Here, _erroneouslyCreatedProjectContainerFolder holds the path to the
// erroneously-created container folder for the
// sub projects. When we get here, this folder should be
// empty by now, so just remove it.
if (!Directory.Exists(_erroneouslyCreatedProjectContainerFolder) ||
!IsDirectoryEmpty(_erroneouslyCreatedProjectContainerFolder))
return; // If the folder does not exist or is not empty, then do nothing
if (Directory.Exists(_erroneouslyCreatedProjectContainerFolder))
Directory.Delete(
_erroneouslyCreatedProjectContainerFolder, true
);
}
/* ... */
/// <summary>
/// Checks whether the folder having the specified <paramref name="path" /> is
/// empty.
/// </summary>
/// <param name="path">
/// (Required.) String containing the fully-qualified pathname of the folder to be
/// checked.
/// </param>
/// <returns>
/// <see langword="true" /> if the folder contains no files nor
/// subfolders; <see langword="false" /> otherwise.
/// </returns>
/// <exception cref="T:System.ArgumentException">
/// Thrown if the required parameter,
/// <paramref name="path" />, is passed a blank or <see langword="null" /> string
/// for a value.
/// </exception>
/// <exception cref="T:System.IO.DirectoryNotFoundException">
/// Thrown if the folder whose path is specified by the <paramref name="path" />
/// parameter cannot be located.
/// </exception>
private static bool IsDirectoryEmpty(string path)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException(
"Value cannot be null or whitespace.", nameof(path)
);
if (!Directory.Exists(path))
throw new DirectoryNotFoundException(
$"The folder having path '{path}' could not be located."
);
return !Directory.EnumerateFileSystemEntries(path)
.Any();
}
/* ... */
}
}
}
The implementation for the IsDirectoryEmpty method was inspired by a Stack Overflow answer and validated by my own knowledge; unfortunately, I lost the link to the appropriate article; if I can find it, I'll make an update.
OKAY, so now we've handled the job of the Root Wizard. Next is the Child Wizard. This where we add (a slight variation of) EliSherer's answer.
First, the fields we need to declare are:
using Core.Common;
using Core.Config;
using Core.Files;
using EnvDTE;
using EnvDTE80;
using Microsoft.VisualStudio.TemplateWizard;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using Thread = System.Threading.Thread;
namespace ChildWizard
{
/// <summary>
/// Implements a wizard for the generation of an individual project in the
/// solution.
/// </summary>
public class WizardImpl : IWizard
{
/* ... */
/// <summary>
/// Contains the name of the folder that was erroneously
/// generated in order to contain the generated sub-projects,
/// which we assume has the same name as the solution (without
/// the <c>.sln</c> file extension, so we are giving it a
/// descriptive name as such.
/// </summary>
private string _containingSolutionName;
/// <summary>
/// Reference to an instance of an object that implements the
/// <see cref="T:EnvDTE.DTE" /> interface.
/// </summary>
private DTE _dte;
/// <summary>
/// String containing the fully-qualified pathname of the
/// sub-folder in which this particular project (this Wizard
/// is called once for each sub-project in a multi-project
/// template) is going to live in.
/// </summary>
private string _generatedSubProjectFolder;
/// <summary>
/// String containing the name of the project that is safe to use.
/// </summary>
private string _subProjectName;
/* ... */
}
}
We initialize these fields in the RunStarted method thusly:
using Core.Common;
using Core.Config;
using Core.Files;
using EnvDTE;
using EnvDTE80;
using Microsoft.VisualStudio.TemplateWizard;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using Thread = System.Threading.Thread;
namespace ChildWizard
{
/// <summary>
/// Implements a wizard for the generation of an individual project in the
/// solution.
/// </summary>
public class WizardImpl : IWizard
{
/* ... */
/// <summary>Runs custom wizard logic at the beginning of a template wizard run.</summary>
/// <param name="automationObject">
/// The automation object being used by the template
/// wizard.
/// </param>
/// <param name="replacementsDictionary">
/// The list of standard parameters to be
/// replaced.
/// </param>
/// <param name="runKind">
/// A
/// <see cref="T:Microsoft.VisualStudio.TemplateWizard.WizardRunKind" /> indicating
/// the type of wizard run.
/// </param>
/// <param name="customParams">
/// The custom parameters with which to perform
/// parameter replacement in the project.
/// </param>
public void RunStarted(object automationObject,
Dictionary<string, string> replacementsDictionary,
WizardRunKind runKind, object[] customParams)
{
/* ... */
_dte = automationObject as DTE;
_generatedSubProjectFolder =
replacementsDictionary["$destinationdirectory$"];
_subProjectName = replacementsDictionary["$safeprojectname$"];
// Assume that the name of the solution is the same as that of the folder
// one folder level up from this particular sub-project.
_containingSolutionName = Path.GetFileName(
Path.GetDirectoryName(_generatedSubProjectFolder)
);
/* ... */
}
/* ... */
}
}
When this Child Wizard is called, e.g., to generate the BrianApplication1.DAL project, the fields get the following values:
_dte = Reference to the automation object exposed by the EnvDTE.DTE interface
_generatedSubProjectFolder = C:\temp\BrianApplication1\BrianApplication1\BrianApplication1.DAL
_subProjectName = BrianApplication1.DAL
_containingSolutionName = BrianApplcation1
Relevant to the OP's answer, initializing these fields is all the work that RunStarted needs to do. Now, let's see how I needed to adapt EliSherer's answer in the RunFinished method of the Child Wizard's code:
using Core.Common;
using Core.Config;
using Core.Files;
using EnvDTE;
using EnvDTE80;
using Microsoft.VisualStudio.TemplateWizard;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using Thread = System.Threading.Thread;
namespace ChildWizard
{
/// <summary>
/// Implements a wizard for the generation of an individual project in the
/// solution.
/// </summary>
public class WizardImpl : IWizard
{
/* ... */
/// <summary>Runs custom wizard logic when the
/// wizard has completed all tasks.</summary>
public void RunFinished()
{
try
{
if (!_generatedSubProjectFolder.Contains(
_containingSolutionName + Path.DirectorySeparatorChar +
_containingSolutionName
))
return;
//The projects were created under a separate folder -- lets fix
//it
var projectsObjects = new List<Tuple<Project, Project>>();
foreach (Project childProject in _dte.Solution.Projects)
if (string.IsNullOrEmpty(
childProject.FileName
)) //Solution Folder
projectsObjects.AddRange(
from dynamic projectItem in
childProject.ProjectItems
select new Tuple<Project, Project>(
childProject, projectItem.Object as Project
)
);
else
projectsObjects.Add(
new Tuple<Project, Project>(null, childProject)
);
foreach (var projectObject in projectsObjects)
{
var projectBadPath = projectObject.Item2.FileName;
if (!projectBadPath.Contains(_subProjectName))
continue; // wrong project
var projectGoodPath = projectBadPath.Replace(
_containingSolutionName + Path.DirectorySeparatorChar +
_containingSolutionName + Path.DirectorySeparatorChar,
_containingSolutionName + Path.DirectorySeparatorChar
);
_dte.Solution.Remove(projectObject.Item2);
var projectBadPathDirectory =
Path.GetDirectoryName(projectBadPath);
var projectGoodPathDirectory =
Path.GetDirectoryName(projectGoodPath);
if (Directory.Exists(projectBadPathDirectory) &&
!string.IsNullOrWhiteSpace(projectGoodPathDirectory))
Directory.Move(
projectBadPathDirectory, projectGoodPathDirectory
);
if (projectObject.Item1 != null) //Solution Folder
{
var solutionFolder =
(SolutionFolder)projectObject.Item1.Object;
solutionFolder.AddFromFile(projectGoodPath);
}
else
{
// TO BE COMPLETELY ROBUST, we should do
// File.Exists() on the projectGoodPath; since
// we are in a try/catch and Directory.Move would
// have otherwise thrown an exception if the
// folder move operation failed, it can be safely
// assumed here that projectGoodPath refers to a
// file that actually exists on the disk.
_dte.Solution.AddFromFile(projectGoodPath);
}
}
ThreadPool.QueueUserWorkItem(
dir =>
{
Thread.Sleep(2000);
if (Directory.Exists(_generatedSubProjectFolder))
Directory.Delete(_generatedSubProjectFolder, true);
}, _generatedSubProjectFolder
);
}
catch (Exception ex)
{
DumpToLog(ex);
}
}
/* ... */
}
}
More or less, this is the same answer as EliSherer, except, where he uses the expression _safeProjectName + Path.DirectorySeparatorChar + _safeProjectName, I substitute _safeProjectName with _containingSolutionName, which, if you look above the listing to the fields and their descriptive comments and example values, makes more sense in this context.
NOTE: I thought about explaining the RunFinished code in the Child Wizard line-by-line but I think I will leave that to the reader to figure out. Let me do some broad-brush:
We check whether the path of the generated sub-project folder contains <solution-name>\<solution-name> such as is shown in the example value of the _generatedSubProjectFolder field and the OP's issue. If not, then stop as there is nothing to do.
NOTE: I use a Contains search and not an EndsWith as in EliSherer's original answer, due to the example value being what it is (and what I actually encountered during the crafting of this project).
The next loop, through the solution's Projects, is basically copied straight from EliSherer. We sort out which Projects are merely Solution Folders and which are actual, well, bona-fide .csproj-based project entries. Like EliSherer, we just go one level down in Solution Folders. Recursion is left as an exercise for the reader.
The loop that follows, which is over the List<Tuple<Project, Project>> that is built up in #2, is again, almost identical to EliSherer's answer, but with two important modifications:
We check the projectBadPath whether it contains the _subProjectName; if not, then we actually are iterating over one of the OTHER projects in the solution BESIDES the one that this particular call to the Child Wizard is dealing with; if so, we use a continue statement to skip it.
In the EliSherer answer, everywhere he used the contents of $safeprojectname$ in his pathname parsing expressions, I am using the "solution name" I derived from parsing the folder path in RunStarted, via the _containingSolutionName field.
Then DTE is used to remove the project from the Solution being generated, temporarily. We then move the project's folder up on level in the file system. For robustness' sake, I test whether the projectBadPathDirectory (the "source" folder for the Directory.Move call) exists (pretty reasonable) and I also use string.IsNullOrWhiteSpace on the projectGoodPathDirectory just in case Path.GetDirectoryName does not return a valid value when called on the projectGoodPath for some reason.
I then again, adapted the EliSherer code for dealing with a SolutionFolder or a project with a .csproj pathname to have DTE add the project BACK to the Solution being generated, this time, from the correct file system path.
I am fairly certain this code works because I did LOTS of logging (which then got removed, otherwise it would be like trying to see the trees through the forest). The logging infrastructure functions are still there in the body of the WizardImpl classes in both MyProjectWizard and ChildWizard, if you care to use them again.
As always, I make no promises regarding edge cases... =)
I tried many iterations of the EliSherer code before I could get all the test cases to work. By the way, which reminds me:
Test Cases
In each case, the desired outcome is the same: the folder structure of the generated .sln and .csproj should match convention, i.e., in the second folder-structure fence diagram above.
Each case simply says which project(s) to toggle on and off in the Wizard as shown in the GitHub repo.
Generate DAL: True, Generate UI Layer: True
Generate DAL: False, Generate UI Layer: True
Generate DAL: True, Generate UI Layer: False
Since it's pointless to even run the generation process if both are set to False, then we simply do not include that as a fourth test case.
With the code I supply both above and in the repo linked, all test cases pass. With "passing" meaning, Visual Studio Solutions are generated with only the sub-project(s) selected, and the folder structure matches the conventional folder layout that solves the OP's original issue.
Related
I have an installer (.msi) project that uses Wix Toolset v3.14. For some reason it is never up-to-date -- i.e. building it again always produces some activity (C:\Program Files (x86)\WiX Toolset v3.14\bin\Light.exe gets called, but not candle.exe). Is there any way to track down and fix the cause?
Here is what I observe when detailed output is ON:
Target "ReadPreviousBindInputsAndBuiltOutputs" in file "C:\Program Files (x86)\MSBuild\Microsoft\WiX\v3.x\wix.targets" from project "<my-project>" (target "Link" depends on it):
Task "ReadLinesFromFile"
Task Parameter:File=obj\x64\Debug\<my-project>.wixproj.BindContentsFileListen-us.txt
Output Item(s):
_BindInputs=
C:\Users\<me>\AppData\Local\Temp\a5uljxg1\MergeId.418703\api_ms_win_core_console_l1_1_0.dll.AF4EABEE_4589_3789_BA0A_C83A71662E1D
...
Done building target "ReadPreviousBindInputsAndBuiltOutputs" in project "<my-project>.wixproj".
Target "Link" in file "C:\Program Files (x86)\MSBuild\Microsoft\WiX\v3.x\wix.targets" from project "<my-project>.wixproj" (target "CompileAndLink" depends on it):
Building target "Link" completely.
Input file "C:\Users\<me>\AppData\Local\Temp\a5uljxg1\MergeId.418703\api_ms_win_core_console_l1_1_0.dll.AF4EABEE_4589_3789_BA0A_C83A71662E1D" does not exist.
...
<and here it executes Light.exe>
So, it looks like it reads BindContentsFileListen-us.txt and expects it to contain files that were inputs during last build run. But, unfortunately some of these files were generated in temporary folder and got wiped out (presumably during last build) and since they don't exist anymore -- Link step is re-executed. I observe this pattern every time I press F7, only number in MergeId.418703 gets changed every time (looks like process id to me).
UPDATE: this is a known (and pretty old) issue. As of now it is planned to be fixed in WiX v4.0.
I have hit the same issue, and the only information I found apart from this question was a pretty unhelpful mail thread from 2013 (1, 2) and an issue from the same era.
Troubleshooting
Reading through the logs and Wix's source code shows that the bugs occurs as follows:
light.exe, the linker, receives all of the object (.wixobj) files it should combine, some of them referencing the .msm merge module's file path.
light.exe uses a combination of mergemod.dll's IMsmMerge::ExtractCAB and cabinet.dll's ::FDICopy (through their own winterop.dll) to extract the contents of the merge module to a temporary path:
// Binder.cs:5612, ProcessMergeModules
// extract the module cabinet, then explode all of the files to a temp directory
string moduleCabPath = String.Concat(this.TempFilesLocation, Path.DirectorySeparatorChar, safeMergeId, ".module.cab");
merge.ExtractCAB(moduleCabPath);
string mergeIdPath = String.Concat(this.TempFilesLocation, Path.DirectorySeparatorChar, "MergeId.", safeMergeId);
Directory.CreateDirectory(mergeIdPath);
using (WixExtractCab extractCab = new WixExtractCab())
{
try
{
extractCab.Extract(moduleCabPath, mergeIdPath);
}
// [...]
}
At the same time, the contents of the merge module are inserted among the other input files in the fileRows collection:
// Binder.cs:5517, ProcessMergeModules
// NOTE: this is very tricky - the merge module file rows are not added to the
// file table because they should not be created via idt import. Instead, these
// rows are created by merging in the actual modules
FileRow fileRow = new FileRow(null, this.core.TableDefinitions["File"]);
// [...]
fileRow.Source = String.Concat(this.TempFilesLocation, Path.DirectorySeparatorChar, "MergeId.", wixMergeRow.Number.ToString(CultureInfo.InvariantCulture.NumberFormat), Path.DirectorySeparatorChar, record[1]);
FileRow collidingFileRow = fileRows[fileRow.File];
FileRow collidingModuleFileRow = (FileRow)uniqueModuleFileIdentifiers[fileRow.File];
if (null == collidingFileRow && null == collidingModuleFileRow)
{
fileRows.Add(fileRow);
// keep track of file identifiers in this merge module
uniqueModuleFileIdentifiers.Add(fileRow.File, fileRow);
}
// [...]
fileRows ends up being written to the <project_name>BindContentsFileList<culture>.txt file inside the intermediate directory, including the temporary (and randomly named) files extracted from the merge module:
// Binder.cs:7346
private void CreateContentsFile(string path, FileRowCollection fileRows)
{
string directory = Path.GetDirectoryName(path);
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
using (StreamWriter contents = new StreamWriter(path, false))
{
foreach (FileRow fileRow in fileRows)
{
contents.WriteLine(fileRow.Source);
}
}
}
During the next build, the ReadPreviousBindInputsAndBuiltOutputs target from wix2010.targets reads the file into the #(_BindInputs) item group. This item group is then listed as input to the Link target. Since the temporary files have since disappeared, the target is always considered out-of-date and re-run, generating an new set of temporary files that get listed in BindContentsFileList, and so on.
Workaround
An actual fix would be to patch Wix so that merge modules discoverd in .wixobj files are listed in BindContentsFileList, and files extracted from them during linking aren't. Unfortunately I wasn't able to make Wix's source code compile, and can't be bothered to go through its distribution process. Hence, here is the workaround I have implemented.
Removing temporary files from the input list
This is done using a custom target which slots in-between ReadPreviousBindInputsAndBuiltOutputs and Link and filters #(_BindInputs) to remove whatever is under %temp%.
<Target
Name="RemoveTempFilesFromBindInputs"
DependsOnTargets="ReadPreviousBindInputsAndBuiltOutputs"
BeforeTargets="Link"
>
<PropertyGroup>
<!-- This includes a final backslash, so we can use StartsWith. -->
<TemporaryDirectory>$([System.IO.Path]::GetTempPath())</TemporaryDirectory>
</PropertyGroup>
<ItemGroup>
<_BindInputs
Remove="#(_BindInputs)"
Condition="$([System.String]::new('%(FullPath)').StartsWith('$(TemporaryDirectory)'))"
/>
</ItemGroup>
</Target>
At that point, Link triggers only when actual input files change. Success! However, changes to the .msm files are not detected. This might be good enough a solution anyway, since merge modules are generally static. Otherwise...
Detecting changes to merge modules
The main hurdle is that the only reference to the .msm file is within a .wxs source file, so we need to bridge the gap between that and MSBuild. There are a couple ways that can be used, such as parsing the .wixobj to fish out the WixMerge tables. However, I already had code in place to generate Wix code, so I went that way, lifting the merge modules into an MSBuild item group and using a custom task to generate a .wxs file referencing them in a feature. Full code below:
<Target
Name="GenerateMsmFragment"
BeforeTargets="GenerateCompileWithObjectPath"
Inputs="#(MsmFiles)"
Outputs="$(IntermediateOutputPath)MsmFiles.wxs"
>
<GenerateMsmFragment
MsmFiles="#(MsmFiles)"
FeatureName="MsmFiles"
MediaId="2"
OutputFile="$(IntermediateOutputPath)MsmFiles.wxs"
>
<Output TaskParameter="OutputFile" ItemName="Compile" />
</GenerateMsmFragment>
</Target>
// GenerateMsmFragment.cs
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
namespace tasks
{
[ComVisible(false)]
public class GenerateMsmFragment : Task
{
[Required]
public ITaskItem[] MsmFiles { get; set; }
[Required]
public string FeatureName { get; set; }
[Required]
public string MediaId { get; set; }
[Output]
public ITaskItem OutputFile { get; set; }
public override bool Execute()
{
var xmlns = "http://schemas.microsoft.com/wix/2006/wi";
var outputXml = new XmlDocument();
outputXml.AppendChild(outputXml.CreateXmlDeclaration("1.0", "utf-8", null));
var fragmentElem = outputXml
.AppendElement("Wix", xmlns)
.AppendElement("Fragment", xmlns);
{
var mediaElem = fragmentElem.AppendElement("Media", xmlns);
mediaElem.SetAttribute("Id", MediaId);
mediaElem.SetAttribute("Cabinet", "MsmFiles.cab");
mediaElem.SetAttribute("EmbedCab", "yes");
}
{
var directoryRefElem = fragmentElem.AppendElement("DirectoryRef", xmlns);
directoryRefElem.SetAttribute("Id", "TARGETDIR");
var featureElem = fragmentElem.AppendElement("Feature", xmlns);
featureElem.SetAttribute("Id", FeatureName);
featureElem.SetAttribute("Title", "Imported MSM files");
featureElem.SetAttribute("AllowAdvertise", "no");
featureElem.SetAttribute("Display", "hidden");
featureElem.SetAttribute("Level", "1");
foreach (var msmFilePath in MsmFiles.Select(i => i.ItemSpec)) {
var mergeElem = directoryRefElem.AppendElement("Merge", xmlns);
mergeElem.SetAttribute("Id", msmFilePath);
mergeElem.SetAttribute("SourceFile", msmFilePath);
mergeElem.SetAttribute("DiskId", MediaId);
mergeElem.SetAttribute("Language", "0");
featureElem
.AppendElement("MergeRef", xmlns)
.SetAttribute("Id", msmFilePath);
}
}
Directory.CreateDirectory(Path.GetDirectoryName(OutputFile.GetMetadata("FullPath")));
outputXml.Save(OutputFile.GetMetadata("FullPath"));
return true;
}
}
}
// XmlExt.cs
using System.Xml;
namespace nrm
{
public static class XmlExt
{
public static XmlElement AppendElement(this XmlDocument element, string qualifiedName, string namespaceURI)
{
var newElement = element.CreateElement(qualifiedName, namespaceURI);
element.AppendChild(newElement);
return newElement;
}
public static XmlElement AppendElement(this XmlNode element, string qualifiedName, string namespaceURI)
{
var newElement = element.OwnerDocument.CreateElement(qualifiedName, namespaceURI);
element.AppendChild(newElement);
return newElement;
}
}
}
And voilĂ , working up-to-date detection for merge modules.
I've been fighting with this problem for hours... and I can't find what it is...
I'm just trying to localize the _Layout.cshtml file. Both the IStringLocalizer and the IHtmlLocalizer do not seem to find the Resource files.
I've followed and searched for:
https://github.com/MormonJesus69420/SharedResourcesExample
.Net Core Data Annotations - localization with shared resources
https://stackoverflow.com/search?q=shared+resources+.net+core
https://andrewlock.net/adding-localisation-to-an-asp-net-core-application/
There's something silly that I may be overlooking.
Here's my startup.cs:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using EduPlaTools.Data;
using EduPlaTools.Models;
using EduPlaTools.Services;
using System.Globalization;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc.Razor;
using Pomelo.EntityFrameworkCore.MySql;
using Pomelo.EntityFrameworkCore.MySql.Infrastructure;
using Microsoft.AspNetCore.HttpOverrides;
namespace EduPlaTools
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
// This is for string translation!
// Adds Localization Services (StringLocalizer, HtmlLocalizer, etc.)
// the opts.ResourcesPath = is the path in which the resources are found.
// In our case the folder is named Resources!
// There's specific and neutral resources. (Specific en-US). (Neutral: es)
/**
* If no ResourcesPath is specified, the view's resources will be expected to be next to the views.
* If ResourcesPath were set to "resources", then view resources would be expected to be ina Resource directory,
* in a path speicifc to their veiw (Resources/Views/Home/About.en.resx, for example).
*
* */
services.AddLocalization(opts => opts.ResourcesPath = "Resources");
// services.AddBContext
// There are subtle differences between the original and the modified version.
services.AddDbContextPool<ApplicationDbContext>(options =>
options.UseMySql(Configuration.GetConnectionString("MySQLConnection"),
mysqlOptions =>
{
mysqlOptions.ServerVersion(new Version(8, 0, 12), ServerType.MySql); // replace with your Server Version and Type
}
));
//options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
// Add application services.
services.AddTransient<IEmailSender, EmailSender>();
services.AddMvc()
.AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix, options => options.ResourcesPath = "Resources")
.AddDataAnnotationsLocalization();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
// This may be dangerous and is not recommended
using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>()
.CreateScope())
{
serviceScope.ServiceProvider.GetService<ApplicationDbContext>()
.Database.Migrate();
}
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
if (env.IsDevelopment())
{
app.UseBrowserLink();
app.UseDeveloperExceptionPage();
app.UseDatabaseErrorPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
// These must line up with the ending of the .resx files.
// Example: SharedResources.en.resx, SharedResources.es.rex
// If you want to add specific, then do it like:
// new CultureInfo("en-US")
List<CultureInfo> supportedCultures = new List<CultureInfo>
{
new CultureInfo("es"),
new CultureInfo("en"),
new CultureInfo("es-ES"),
new CultureInfo("en-US")
};
// Registers the localization, and changes the localization per request.
app.UseRequestLocalization(new RequestLocalizationOptions
{
// We give the default support of Spanish.
DefaultRequestCulture = new RequestCulture("es"),
// Format numbers, dates, etc.
SupportedCultures = supportedCultures,
// The strings that we have localized
SupportedUICultures = supportedCultures
});
// This will seed the databse:
SeedDatabase.Initialize(app.ApplicationServices);
app.UseStaticFiles();
app.UseAuthentication();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
}
}
}
Here's how I'm trying to call it inside the _Layout.cshtml:
#using Microsoft.AspNetCore.Mvc.Localization
#inject IViewLocalizer Localizer
#inject IStringLocalizer<SharedResources> SharedLocalizer
#inject IHtmlLocalizer<SharedResources> _localizer;
#SharedLocalizer["Menu_Home"]
Here's the directory structure:
Here are the contents of SharedResources.cs:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace EduPlaTools
{
/**
* This is a dummy class that is needed so Localization works.
* Now in .NET Core Localization works as a service, and implementsw
* naming conventions (AT the file level). Therefore, if the files do not
* implement the correct name, there's going to be problems.
*
* See an example, here:
* https://github.com/SteinTheRuler/ASP.NET-Core-Localization/blob/master/Resources/SharedResources.cs
*
* This is a workaround to create a Resource File that can be read by the entire
* application. It's left in blank so the convention over configuration
* picks it up.
*
* */
public class SharedResources
{
}
}
Here are the contents of the resx files:
I've also tried renaming them to no avail.. (Tried Resources.es.rex, Resources.rex)
I tried setting breakpoints to see how it behaved. It of course, didn't find the Resource files. I then compared it with Mormon's repo by recalling an inexistent key. I compared it with my output, but Mormon's repo doesn't display the "SearchedLocation" (Was it introduced in a later .NET Core version?)
Mormon's Repo:
My repo:
I know this may be something silly... But it's been close to 4 hours, and I can't stop since I have a LOT to do!!
Any ideas?
if you want to implement localization with shared resource, you have to create your own culture localizer class:
public class CultureLocalizer
{
private readonly IStringLocalizer _localizer;
public CultureLocalizer(IStringLocalizerFactory factory)
{
var type = typeof(ViewResource);
var assemblyName = new AssemblyName(type.GetTypeInfo().Assembly.FullName);
_localizer = factory.Create("ViewResource", assemblyName.Name);
}
// if we have formatted string we can provide arguments
// e.g.: #Localizer.Text("Hello {0}", User.Name)
public LocalizedString Text(string key, params string[] arguments)
{
return arguments == null
? _localizer[key]
: _localizer[key, arguments];
}
}
then register it is startup:
services.AddSingleton<CultureLocalizer>();
and modify view locaization settings :
services.AddMvc()
.SetCompatibilityVersion(CompatibilityVersion.Version_2_1)
.AddViewLocalization(o=>o.ResourcesPath = "Resources")
in your views you have to inject the culture localizer class before using it.
those are initial settings for view localization with shared resource, you need to configure localization settings for DataAnnotation, ModelBinding and Identity error messages as well.
these articles could help for starting:
Developing multicultural web application with ASP.NET Core 2.1 Razor Pages:
http://www.ziyad.info/en/articles/10-Developing_Multicultural_Web_Application
it includes step by step tutorial for localizing using shared resources, additionally, this article is about localizing Identity error messages :
http://ziyad.info/en/articles/20-Localizing_Identity_Error_Messages
I wanted to add an answer which further develops Laz's solution. Just in case someone wants to have individual localized views.
Back in Startup.cs, you have:
services.AddMvc()
.SetCompatibilityVersion(CompatibilityVersion.Version_2_1)
.AddViewLocalization(o=>o.ResourcesPath = "Resources")
Technically, you are indicating MVC to look in the "Resources" folder as the main path, and then follow the convention to look for localized resource files.
Therefore
In case you want to localize the Login.cshtml view found in Views/Account/Login.chsmtl, you have to create the resource file in: Resources/Views/Account/Login.en.resx
You would then need to add the following either in the view directly Login.cshtml or in the _ViewImports.cshtml to reference it to all the views:
#using Microsoft.AspNetCore.Mvc.Localization
#inject IViewLocalizer Localizer
After that, in your code you can do:
Localizer["My_Resource_file_key"]
And you'll have it translated.
Here are some illustrations:
An update to the previous answers. Due to the recent breaking change in .NET Core 3 (https://github.com/dotnet/docs/issues/16964), the accepted answer will only work if the resource lives directly in the resource folder.
I have created a workaround to use shared resources in views (same applies to controllers, data annotations, services, whatever you need...).
First you need to create an empty class for your resources. This one has to live under YourApp.Resources namespace. then create your resources named same as your class (in my example I have Views.cs in the namespace MyApp.Resources.Shared and Views.resx).
Then here is the helper class to load the shared resources:
public class SharedViewLocalizer
{
private readonly IStringLocalizer _localizer;
public SharedViewLocalizer(IStringLocalizerFactory factory)
{
var assemblyName = new AssemblyName(typeof(Resources.Shared.Views).GetTypeInfo().Assembly.FullName);
localizer = factory.Create("Shared.Views", assemblyName.Name);
}
public string this[string key] => _localizer[key];
public string this[string key, params object[] arguments] => _localizer[key, arguments];
}
You have to register is in the Startup.Configure:
services.AddSingleton<SharedViewLocalizer>();
I suppose you use
services.AddLocalization(options => options.ResourcesPath = "Resources");
to setup default resources location.
And then in your view you use it as follows:
#inject IViewLocalizer _localizer
#inject SharedViewLocalizer _sharedLocalizer
#_localizer["View spacific resource"] // Resource from Resources/Views/ControllerName/ViewName.resx
#_sharedLocalizer["Shared resource"] // Resource from Resources/Shared/Views.resx
#_sharedLocalizer["Also supports {0} number of arguments", "unlimited"]
Same principle can be applied to DataAnnotations where we can use the built-in method in Startup.Configure:
services.AddMvc()
.AddDataAnnotationsLocalization(options =>
{
options.DataAnnotationLocalizerProvider = (type, factory) =>
{
var assemblyName = new AssemblyName(typeof(DataAnnotations).GetTypeInfo().Assembly.FullName);
return factory.Create("Shared.DataAnnotations", assemblyName.Name
};
})
.AddViewLocalization();
Again, I'm expecting my resources to live in the namespace Resources.Shared and have an empty class called DataAnnotations created.
Hope this helps to overcome the current breaking change problems.
I'd like to programmatically manipulate my rpt files using a macro or add-in within Visual Studio 2005. What I want to achieve is the ability to automate updating the custom functions in my reports, since there seems no way to have a single copy of the functions shared between reports.
So I'd like to have a macro to:
Read the function definitions from somewhere, eg an xml file in my project
Open each of the rpt files in my solution and replace the existing function definitions with the new ones.
Is there an API for interacting with the rpt files in this way? Any pointers or examples would be greatly appreciated.
Rory
I think the answer is No, there isn't within VS Crystal Reports. It looks like there's an API for other versions, e.g. this
As an alternative, I've changed to having lots of code in my report formula instead of using custom functions. I can then update the report formula using ReportDocument.DataDefinition.FormulaFields..Text
In my case I only want to update one formula in each report, named 'Period'. I've created a file PeriodFormula.txt and included it in the project with Build Action = EmbeddedResource.
I created this class to read the txt file and update all reports within a given directory. It's currently hardcoded to only update the Period formula, but could easily be modified to operate from a list etc.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Text;
using CrystalDecisions.CrystalReports.Engine;
using CrystalDecisions.Shared;
namespace RMReports
{
public class CustomFunctionUpdater
{
/// <summary>
/// Update all rpt files in the given directory and all subdirectories.
/// Currently only updates the Period formula.
/// </summary>
/// <param name="directoryPath"></param>
public static void UpdateAllReports(String directoryPath)
{
Debug.WriteLine(string.Format("Starting update on all reports within {0}", directoryPath));
const string formulaName = "Period";
int reportsUpdated = 0;
string formulaText = GetFormulaText(formulaName);
foreach (String filename in Directory.GetFiles(directoryPath, "*.rpt", SearchOption.AllDirectories))
{
try
{
if (UpdateReportFunction(filename, formulaName, formulaText))
{
reportsUpdated++;
Debug.WriteLine(string.Format("Updated: {0}", filename));
}
else
Debug.WriteLine(string.Format("No update to: {0}", filename));
}
catch(Exception ex)
{
Debug.WriteLine(string.Format("Failed to update: {0}. Error: {1}", filename, ex.Message));
}
}
Debug.WriteLine(string.Format("done. {0} reports updated", reportsUpdated));
}
/// <summary>
/// Opens the given report file, updates the specified formula with the given text
/// and saves the report.
/// </summary>
/// <param name="reportFilename">The report file to update</param>
/// <param name="formulaName">The name of the formula to update</param>
/// <param name="formulaText">The new text of the formula to update</param>
/// <returns>Whether the report was updated. If the formula doesn't exist this will be false.</returns>
public static bool UpdateReportFunction(String reportFilename, String formulaName, string formulaText)
{
if (String.IsNullOrEmpty(formulaText)) return false;
if (!File.Exists(reportFilename)) throw new FileNotFoundException("reportFilename", reportFilename);
bool updated = false;
ReportDocument document = new ReportDocument();
try
{
document.Load(reportFilename, OpenReportMethod.OpenReportByDefault);
foreach (FormulaFieldDefinition f in document.DataDefinition.FormulaFields)
{
if (f.Name != formulaName) continue;
if (f.Text == formulaText) break; // no update needed
f.Text = formulaText;
updated = true;
break;
}
if (updated)
document.SaveAs(reportFilename);
}
finally
{
if (document.IsLoaded)
document.Close();
}
return updated;
}
public static void UpdateReportFunction(String reportFilename, String formulaName)
{
string formulaText = GetFormulaText(formulaName);
UpdateReportFunction(reportFilename, formulaName, formulaText);
}
/// <summary>
/// Reads the text for the given formula from the current assembly. Assumes the formula
/// exists in a file named [formulaName]Formula.txt that's been compiled as an embedded resource
/// in the current assembly, e.g. DoStuffFormula.txt for a formula named DoStuff.
/// </summary>
/// <param name="formulaName"></param>
/// <returns></returns>
public static String GetFormulaText(String formulaName)
{
string resourceName = Assembly.GetExecutingAssembly().GetName().Name + "." + formulaName + "Formula.txt";
Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName);
if (stream==null) return null;
return (new StreamReader(stream)).ReadToEnd();
}
}
}
Then I use it like this, to update all my reports (which are in folders beneath a 'reports' folder).
DirectoryInfo d = Directory.GetParent(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location));
string reportDirectory = Path.Combine(d.Parent.FullName, "reports");
CustomFunctionUpdater.UpdateAllReports(reportDirectory);
Hopefully someone else finds this useful!
I am writing a custom tool and I currently have it doing what I want as far as functionality. I would like to be able to write to Visual Studio if something goes wrong. (Incorrectly formatted code or whatever).
Are there any standards for this? Right now I basically can force the tool to fail and Visual Studio puts in a warning that it has done so. I'd like a category in the Output window with any resulting messages I want to send. I could also live with a more descriptive task/warning in the Error list window.
Output Window
To write to the "General" output window in Visual Studio, you need to do the following:
IVsOutputWindow outWindow = Package.GetGlobalService( typeof( SVsOutputWindow ) ) as IVsOutputWindow;
Guid generalPaneGuid = VSConstants.GUID_OutWindowGeneralPane; // P.S. There's also the GUID_OutWindowDebugPane available.
IVsOutputWindowPane generalPane;
outWindow.GetPane( ref generalPaneGuid , out generalPane );
generalPane.OutputString( "Hello World!" );
generalPane.Activate(); // Brings this pane into view
If, however, you want to write to a custom window, this is what you need to do:
IVsOutputWindow outWindow = Package.GetGlobalService( typeof( SVsOutputWindow ) ) as IVsOutputWindow;
// Use e.g. Tools -> Create GUID to make a stable, but unique GUID for your pane.
// Also, in a real project, this should probably be a static constant, and not a local variable
Guid customGuid = new Guid("0F44E2D1-F5FA-4d2d-AB30-22BE8ECD9789");
string customTitle = "Custom Window Title";
outWindow.CreatePane( ref customGuid, customTitle, 1, 1 );
IVsOutputWindowPane customPane;
outWindow.GetPane( ref customGuid, out customPane);
customPane.OutputString( "Hello, Custom World!" );
customPane.Activate(); // Brings this pane into view
Details on IVsOutputWindow and IVsOutputWindowPane can be found on MSDN.
Error List
For adding items to the error list, the IVsSingleFileGenerator has a method call void Generate(...) which has a parameter of the type IVsGeneratorProgress. This interface has a method void GeneratorError() which lets you report errors and warnings to the Visual Studio error list.
public class MyCodeGenerator : IVsSingleFileGenerator
{
...
public void Generate( string inputFilePath, string inputFileContents, string defaultNamespace, out IntPtr outputFileContents, out int output, IVsGeneratorProgress generateProgress )
{
...
generateProgress.GeneratorError( false, 0, "An error occured", 2, 4);
...
}
...
}
The details of GeneratorError() can be found on MSDN.
There is another way using Marshal.GetActiveObject to grab a running DTE2 instance.
First reference EnvDTE and envdte80. This currently works in VisualStudio 2012, I haven't tried the others yet.
using System;
using System.Runtime.InteropServices;
using EnvDTE;
using EnvDTE80;
internal class VsOutputLogger
{
private static Lazy<Action<string>> _Logger = new Lazy<Action<string>>( () => GetWindow().OutputString );
private static Action<string> Logger
{
get { return _Logger.Value; }
}
public static void SetLogger( Action<string> logger )
{
_Logger = new Lazy<Action<string>>( () => logger );
}
public static void Write( string format, params object[] args)
{
var message = string.Format( format, args );
Write( message );
}
public static void Write( string message )
{
Logger( message + Environment.NewLine );
}
private static OutputWindowPane GetWindow()
{
var dte = (DTE2) Marshal.GetActiveObject( "VisualStudio.DTE" );
return dte.ToolWindows.OutputWindow.ActivePane;
}
}
If you want anything to appear in the Output window, it has to come from stdout. To do this, your app needs to be linked as a "console" app. Set the /SUBSYSTEM:CONSOLE flag in the project's property page, under Linker/System set the SubSystem property to CONSOLE.
Once you have your output in the window, if you include the text "Error:" it will appear as an error, or if you set "Warning:" it will appear as a warning. If your error text begins with a path/filename, followed by a line number in parenthesis, the IDE will recognize it as a "clickable" error, and navigate you automatically to the faulting line.
This is demonstrated in the following helper class from a Microsoft sample project:
https://github.com/microsoft/VSSDK-Extensibility-Samples/blob/df10d37b863feeff6e8fcaa6f4d172f602a882c5/Reference_Services/C%23/Reference.Services/HelperFunctions.cs#L28
The code is as follows:
using System;
using System.Diagnostics;
using Microsoft.VisualStudio.Shell.Interop;
namespace Microsoft.Samples.VisualStudio.Services
{
/// <summary>
/// This class is used to expose some utility functions used in this project.
/// </summary>
internal static class HelperFunctions
{
/// <summary>
/// This function is used to write a string on the Output window of Visual Studio.
/// </summary>
/// <param name="provider">The service provider to query for SVsOutputWindow</param>
/// <param name="text">The text to write</param>
internal static void WriteOnOutputWindow(IServiceProvider provider, string text)
{
// At first write the text on the debug output.
Debug.WriteLine(text);
// Check if we have a provider
if (null == provider)
{
// If there is no provider we can not do anything; exit now.
Debug.WriteLine("No service provider passed to WriteOnOutputWindow.");
return;
}
// Now get the SVsOutputWindow service from the service provider.
IVsOutputWindow outputWindow = provider.GetService(typeof(SVsOutputWindow)) as IVsOutputWindow;
if (null == outputWindow)
{
// If the provider doesn't expose the service there is nothing we can do.
// Write a message on the debug output and exit.
Debug.WriteLine("Can not get the SVsOutputWindow service.");
return;
}
// We can not write on the Output window itself, but only on one of its panes.
// Here we try to use the "General" pane.
Guid guidGeneral = Microsoft.VisualStudio.VSConstants.GUID_OutWindowGeneralPane;
IVsOutputWindowPane windowPane;
if (Microsoft.VisualStudio.ErrorHandler.Failed(outputWindow.GetPane(ref guidGeneral, out windowPane)) ||
(null == windowPane))
{
if (Microsoft.VisualStudio.ErrorHandler.Failed(outputWindow.CreatePane(ref guidGeneral, "General", 1, 0)))
{
// Nothing to do here, just debug output and exit
Debug.WriteLine("Failed to create the Output window pane.");
return;
}
if (Microsoft.VisualStudio.ErrorHandler.Failed(outputWindow.GetPane(ref guidGeneral, out windowPane)) ||
(null == windowPane))
{
// Again, there is nothing we can do to recover from this error, so write on the
// debug output and exit.
Debug.WriteLine("Failed to get the Output window pane.");
return;
}
if (Microsoft.VisualStudio.ErrorHandler.Failed(windowPane.Activate()))
{
Debug.WriteLine("Failed to activate the Output window pane.");
return;
}
}
// Finally we can write on the window pane.
if (Microsoft.VisualStudio.ErrorHandler.Failed(windowPane.OutputString(text)))
{
Debug.WriteLine("Failed to write on the Output window pane.");
}
}
}
}
You can use the Debug and/or Trace classes. There is some information here:
http://msdn.microsoft.com/en-us/library/bs4c1wda(VS.71).aspx
Best of luck.
use System.Diagnostics.Debugger.Message
I'm using Crystal to display the reports in my project, and I'd like to be able to display a small preview or thumbnail image of the report to the user when he or she is picking a report to display from my UI. Is there any way to produce these thumbnails dynamically from code?
The user has the option to add or remove reports by adding or removing them from the reports folder, so just making all the thumbnail images by hand isn't really an option.
I used the DSOFile object to obtain the thumbnail inside the report then used AxHost to convert the returned object to an image I could display. This wasn't the solution I wanted but the DSOFile is freely distributable so I guess this will work until I find something better.
Download and install the DSOFile DLL from Microsoft.
Add a reference to **DSO OLE Document Properties Reader 2.1
code
Here is my code, boiled down to the bare minimum:
namespace Ibs.Ui.OrderPrint
{
public partial class OrderPrintEdit
{
public OrderPrintEdit()
{
InitializeComponent();
}
#region -- reports_SelectedIndexChanged(sender, e) Event Handler --
private void reports_SelectedIndexChanged(object sender, EventArgs e)
{
try
{
DSOFile.OleDocumentPropertiesClass oleDocumentPropertiesClass = new DSOFile.OleDocumentPropertiesClass();
DirectoryInfo reportDirectory = new DirectoryInfo(Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location) + "\\Reports");
oleDocumentPropertiesClass.Open(reportDirectory + "\\" + reports.Text,true,DSOFile.dsoFileOpenOptions.dsoOptionDontAutoCreate);
Object thumbnail = oleDocumentPropertiesClass.SummaryProperties.Thumbnail;
if (thumbnail != null)
{
reportThumbnail.BackgroundImage = IPictureDispHost.GetPictureFromIPicture(thumbnail);
}
else
{
reportThumbnail.BackgroundImage = null;
}
oleDocumentPropertiesClass.Close(false);
}
catch (Exception ex)
{
}
}
#endregion
}
internal sealed class IPictureDispHost : AxHost
{
private IPictureDispHost() : base("{63109182-966B-4e3c-A8B2-8BC4A88D221C}")
{
}
/// <summary>
/// Convert the dispatch interface into an image object.
/// </summary>
/// <param name="picture">The picture interface</param>
/// <returns>An image instance.</returns>
public new static Image GetPictureFromIPicture(object picture)
{
return AxHost.GetPictureFromIPicture(picture);
}
}
}
I am filling a combobox with report names on the form load. In the SelectedIndexChanged event I get the Thumbnail object from the report and pass it to the conversion method. This should work for Office documents too.