/*******************************************************************************
* Copyright (c) 2013, 2017 Pivotal, Inc.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* Pivotal, Inc. - initial API and implementation
*******************************************************************************/
package org.springframework.ide.eclipse.boot.wizard;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.Assert;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.Path;
import org.eclipse.core.runtime.SubMonitor;
import org.eclipse.jface.operation.IRunnableWithProgress;
import org.eclipse.jface.preference.IPreferenceStore;
import org.eclipse.ui.IWorkingSet;
import org.eclipse.ui.IWorkingSetManager;
import org.eclipse.ui.PlatformUI;
import org.springframework.ide.eclipse.boot.core.BootActivator;
import org.springframework.ide.eclipse.boot.core.BootPreferences;
import org.springframework.ide.eclipse.boot.core.initializr.InitializrServiceSpec;
import org.springframework.ide.eclipse.boot.core.initializr.InitializrServiceSpec.Dependency;
import org.springframework.ide.eclipse.boot.core.initializr.InitializrServiceSpec.DependencyGroup;
import org.springframework.ide.eclipse.boot.core.initializr.InitializrServiceSpec.Option;
import org.springframework.ide.eclipse.boot.core.initializr.InitializrServiceSpec.Type;
import org.springframework.ide.eclipse.boot.wizard.CheckBoxesSection.CheckBoxModel;
import org.springframework.ide.eclipse.boot.wizard.content.BuildType;
import org.springframework.ide.eclipse.boot.wizard.content.CodeSet;
import org.springframework.ide.eclipse.boot.wizard.importing.ImportStrategy;
import org.springframework.ide.eclipse.boot.wizard.importing.ImportUtils;
import org.springsource.ide.eclipse.commons.core.util.NameGenerator;
import org.springsource.ide.eclipse.commons.frameworks.core.downloadmanager.DownloadManager;
import org.springsource.ide.eclipse.commons.frameworks.core.downloadmanager.DownloadableItem;
import org.springsource.ide.eclipse.commons.frameworks.core.downloadmanager.URLConnectionFactory;
import org.springsource.ide.eclipse.commons.livexp.core.FieldModel;
import org.springsource.ide.eclipse.commons.livexp.core.LiveExpression;
import org.springsource.ide.eclipse.commons.livexp.core.LiveVariable;
import org.springsource.ide.eclipse.commons.livexp.core.StringFieldModel;
import org.springsource.ide.eclipse.commons.livexp.core.ValidationResult;
import org.springsource.ide.eclipse.commons.livexp.core.Validator;
import org.springsource.ide.eclipse.commons.livexp.core.ValueListener;
import org.springsource.ide.eclipse.commons.livexp.core.validators.NewProjectLocationValidator;
import org.springsource.ide.eclipse.commons.livexp.core.validators.NewProjectNameValidator;
import org.springsource.ide.eclipse.commons.livexp.core.validators.UrlValidator;
import org.springsource.ide.eclipse.commons.livexp.ui.ProjectLocationSection;
import org.springsource.ide.eclipse.commons.livexp.util.Filter;
import com.google.common.base.Objects;
/**
* This is the model for the 'New Spring Starter Project' wizard.
*/
public class NewSpringBootWizardModel {
private static final String NAME_PROPRTY_ID = "name";
private static final String ARTIFACT_PROPERTY_ID = "artifactId";
private static final Map<String,BuildType> KNOWN_TYPES = new HashMap<>();
static {
KNOWN_TYPES.put("gradle-project", BuildType.GRADLE); // New version of initialzr app
KNOWN_TYPES.put("maven-project", BuildType.MAVEN); // New versions of initialzr app
KNOWN_TYPES.put("gradle.zip", BuildType.GRADLE); //Legacy, can remove when new initializr app uses "gradle-project" definitively
KNOWN_TYPES.put("starter.zip", BuildType.MAVEN); //Legacy, can remove when initializr app uses "maven-project" definitively
}
/**
* Lists known query parameters that map onto a String input field. The default values for these
* parameters will be pulled from the json spec document.
*/
private static final Map<String,String> KNOWN_STRING_INPUTS = new LinkedHashMap<>();
static {
KNOWN_STRING_INPUTS.put(NAME_PROPRTY_ID, "Name");
KNOWN_STRING_INPUTS.put("groupId", "Group");
KNOWN_STRING_INPUTS.put(ARTIFACT_PROPERTY_ID, "Artifact");
KNOWN_STRING_INPUTS.put("version", "Version");
KNOWN_STRING_INPUTS.put("description", "Description");
KNOWN_STRING_INPUTS.put("packageName", "Package");
};
private static final Map<String, String> KNOWN_SINGLE_SELECTS = new LinkedHashMap<>();
static {
KNOWN_SINGLE_SELECTS.put("packaging", "Packaging:");
KNOWN_SINGLE_SELECTS.put("javaVersion", "Java Version:");
KNOWN_SINGLE_SELECTS.put("language", "Language:");
KNOWN_SINGLE_SELECTS.put("bootVersion", "Spring Boot Version:");
}
private final URLConnectionFactory urlConnectionFactory;
private final String JSON_URL;
private PopularityTracker popularities;
private PreferredSelections preferredSelections;
private DefaultDependencies defaultDependencies;
public NewSpringBootWizardModel(IPreferenceStore prefs) throws Exception {
this(
BootActivator.getUrlConnectionFactory(),
prefs
);
}
public NewSpringBootWizardModel() throws Exception {
this(
BootActivator.getUrlConnectionFactory(),
BootWizardActivator.getDefault().getPreferenceStore()
);
}
public NewSpringBootWizardModel(URLConnectionFactory urlConnectionFactory, IPreferenceStore prefs) throws Exception {
this(urlConnectionFactory, BootPreferences.getInitializrUrl(), prefs);
}
public NewSpringBootWizardModel(URLConnectionFactory urlConnectionFactory, String jsonUrl, IPreferenceStore prefs) throws Exception {
this.popularities = new PopularityTracker(prefs);
this.preferredSelections = new PreferredSelections(prefs) {
@Override
protected boolean isInteresting(FieldModel<String> input) {
if (NAME_PROPRTY_ID.equals(input.getName())
|| ARTIFACT_PROPERTY_ID.equals(input.getName())) {
return false;
}
return super.isInteresting(input);
}
};
this.defaultDependencies = new DefaultDependencies(prefs);
this.urlConnectionFactory = urlConnectionFactory;
this.JSON_URL = jsonUrl;
baseUrl = new LiveVariable<>("<computed>");
baseUrlValidator = new UrlValidator("Base Url", baseUrl);
discoverOptions(stringInputs, dependencies);
dependencies.sort();
projectName = stringInputs.getField(NAME_PROPRTY_ID);
projectName.validator(new NewProjectNameValidator(projectName.getVariable()));
generateValidProjectName();
location = new LiveVariable<>(ProjectLocationSection.getDefaultProjectLocation(projectName.getValue()));
locationValidator = new NewProjectLocationValidator("Location", location, projectName.getVariable());
Assert.isNotNull(projectName, "The service at "+JSON_URL+" doesn't specify a 'name' text input");
UrlMaker computedUrl = new UrlMaker(baseUrl);
for (FieldModel<String> param : stringInputs) {
computedUrl.addField(param);
}
computedUrl.addField(dependencies);
for (RadioGroup group : radioGroups.getGroups()) {
computedUrl.addField(group);
}
computedUrl.addListener(new ValueListener<String>() {
public void gotValue(LiveExpression<String> exp, String value) {
downloadUrl.setValue(value);
}
});
addBuildTypeValidator();
getArtifactId().setValue(projectName.getValue());
syncOneDirectionally(projectName, getArtifactId());
preferredSelections.restore(this);
defaultDependencies.restore(dependencies);
}
/**
* Establish one-directional value copying from one field to another. When the two field have equal contents,
* the value is copied from the 'fromField' to the 'toField' any time the 'fromField' is changed (keeping them
* in sync).
* <p>
* However, when the toField is changed the value is not copied back to the fromField and the synchronization
* at that point is 'broken' (until the fields again become equal, at which point syncing becomes enabled again).
*
* @param fromField
* @param toField
*/
private void syncOneDirectionally(FieldModel<String> fromField, FieldModel<String> toField) {
if (fromField!=null && toField!=null) {
syncOneDirectionally(fromField.getVariable(), toField.getVariable());
}
}
private void syncOneDirectionally(LiveVariable<String> fromVar, LiveVariable<String> toVar) {
ValueListener<String> copyValue = (e, value) -> {
toVar.setValue(value);
};
ValueListener<String> enableOrDisableSyncing = (e, value) -> {
if (Objects.equal(fromVar.getValue(), toVar.getValue())){
fromVar.addListener(copyValue);
} else {
fromVar.removeListener(copyValue);
}
};
fromVar.addListener(enableOrDisableSyncing);
toVar.addListener(enableOrDisableSyncing);
}
private void generateValidProjectName() {
boolean projectNameValid = projectName.getValidator().getValue() == ValidationResult.OK;
if (!projectNameValid) {
NameGenerator generator = NameGenerator.create(projectName.getValue(), "-");
while (!projectNameValid) {
projectName.setValue(generator.generateNext());
projectNameValid = projectName.getValidator().getValue() == ValidationResult.OK;
}
}
}
/**
* If this wizard has a 'type' radioGroup to select the build type then add a validator to check if the
* build type is supported.
*/
private void addBuildTypeValidator() {
final RadioGroup buildTypeGroup = getRadioGroups().getGroup("type");
if (buildTypeGroup!=null) {
buildTypeGroup.validator(new Validator() {
{
dependsOn(buildTypeGroup.getVariable());
}
@Override
protected ValidationResult compute() {
ImportStrategy s = getImportStrategy();
if (s==null) {
return ValidationResult.error("No 'type' selected");
} else if (!s.isSupported()) {
//This means some required STS component like m2e or gradle tooling is not installed
return ValidationResult.error(s.getNotInstalledMessage());
}
return ValidationResult.OK;
}
});
}
}
@SuppressWarnings("unchecked")
public final FieldArrayModel<String> stringInputs = new FieldArrayModel<>(
//The fields need to be discovered by parsing json from rest endpoint.
);
public final HierarchicalMultiSelectionFieldModel<Dependency> dependencies = new HierarchicalMultiSelectionFieldModel<>(Dependency.class, "dependencies")
.label("Dependencies:");
private final FieldModel<String> projectName; //an alias for stringFields.getField("name");
private final LiveVariable<String> location;
private final NewProjectLocationValidator locationValidator;
private boolean allowUIThread = false;
public final LiveVariable<String> baseUrl;
public final LiveExpression<ValidationResult> baseUrlValidator;
public final LiveVariable<String> downloadUrl = new LiveVariable<>();
private IWorkingSet[] workingSets = new IWorkingSet[0];
private RadioGroups radioGroups = new RadioGroups();
private RadioGroup bootVersion;
private DependencyFilterBox filterBox = new DependencyFilterBox();
/**
* Retrieves the most popular dependencies based on the number of times they have
* been used to create a project.
*
* @param howMany is an upper limit on the number of most popular items to be returned.
* @return An array of the most popular dependencies. May return fewer items than requested.
*/
public List<CheckBoxModel<Dependency>> getMostPopular(int howMany) {
return popularities.getMostPopular(dependencies, howMany);
}
/**
* Retrieves currently set default dependencies
* @return list of default dependencies check-box models
*/
public List<CheckBoxModel<Dependency>> getDefaultDependencies() {
return defaultDependencies.getDependencies(dependencies);
}
/**
* Retrieves frequently used dependencies based on currently set default dependencies and the most popular dependencies
*
* @param numberOfMostPopular max number of most popular dependencies
* @return list of frequently used dependencies
*/
public List<CheckBoxModel<Dependency>> getFrequentlyUsedDependencies(int numberOfMostPopular) {
List<CheckBoxModel<Dependency>> defaultDependencies = getDefaultDependencies();
Set<String> defaultDependecyIds = getDefaultDependenciesIds();
getMostPopular(numberOfMostPopular).stream().filter(checkboxModel -> {
return !defaultDependecyIds.contains(checkboxModel.getValue().getId());
}).forEach(defaultDependencies::add);
// Sort alphbetically
defaultDependencies.sort(new Comparator<CheckBoxModel<Dependency>>() {
@Override
public int compare(CheckBoxModel<Dependency> d1, CheckBoxModel<Dependency> d2) {
return d1.getLabel().compareTo(d2.getLabel());
}
});
return defaultDependencies;
}
public Set<String> getDefaultDependenciesIds() {
return defaultDependencies.getDependciesIdSet();
}
/**
* Shouldn't be public really. This is just to make it easier to call from unit test.
*/
public void updateUsageCounts() {
popularities.incrementUsageCount(dependencies.getCurrentSelection());
}
public boolean saveDefaultDependencies() {
return defaultDependencies.save(dependencies);
}
public void performFinish(IProgressMonitor mon) throws InvocationTargetException, InterruptedException {
mon.beginTask("Importing "+baseUrl.getValue(), 4);
updateUsageCounts();
preferredSelections.save(this);
DownloadManager downloader = null;
try {
downloader = new DownloadManager(urlConnectionFactory).allowUIThread(allowUIThread);
DownloadableItem zip = new DownloadableItem(newURL(downloadUrl .getValue()), downloader);
String projectNameValue = projectName.getValue();
CodeSet cs = CodeSet.fromZip(projectNameValue, zip, new Path("/"));
ImportStrategy strat = getImportStrategy();
if (strat==null) {
strat = BuildType.GENERAL.getDefaultStrategy();
}
IRunnableWithProgress oper = strat.createOperation(ImportUtils.importConfig(
new Path(location.getValue()),
projectNameValue,
cs
));
oper.run(SubMonitor.convert(mon, 3));
IProject project = ResourcesPlugin.getWorkspace().getRoot().getProject(projectNameValue);
addToWorkingSets(project, SubMonitor.convert(mon, 1));
} catch (IOException e) {
throw new InvocationTargetException(e);
} finally {
if (downloader!=null) {
downloader.dispose();
}
mon.done();
}
}
/**
* Get currently selected import strategy.
*/
public ImportStrategy getImportStrategy() {
TypeRadioInfo selected = getSelectedTypeRadio();
if (selected!=null) {
return selected.getImportStrategy();
}
return null;
}
/**
* Convenience method so that test code can easily select an import strategy.
* This will throw an exception if the given importstragey is not present
* in this wizardmodel.
*/
public void setImportStrategy(ImportStrategy is) {
RadioGroup typeRadios = getRadioGroups().getGroup("type");
RadioInfo radio = typeRadios.getRadio(is.getId());
Assert.isLegal(radio!=null);
typeRadios.setValue(radio);
}
/**
* Gets the currently selected BuildType.
*/
public BuildType getBuildType() {
ImportStrategy is = getImportStrategy();
if (is!=null) {
return is.getBuildType();
}
return null;
}
private TypeRadioInfo getSelectedTypeRadio() {
RadioGroup buildTypeRadios = getRadioGroups().getGroup("type");
if (buildTypeRadios!=null) {
return (TypeRadioInfo) buildTypeRadios.getSelection().selection.getValue();
}
return null;
}
private void addToWorkingSets(IProject project, IProgressMonitor monitor) {
monitor.beginTask("Add '"+project.getName()+"' to working sets", 1);
try {
if (workingSets==null || workingSets.length==0) {
return;
}
IWorkingSetManager wsm = PlatformUI.getWorkbench().getWorkingSetManager();
wsm.addToWorkingSets(project, workingSets);
} finally {
monitor.done();
}
}
/**
* Dynamically discover input fields and 'style' options by parsing initializr form.
*/
private void discoverOptions(FieldArrayModel<String> fields, HierarchicalMultiSelectionFieldModel<Dependency> dependencies) throws Exception {
InitializrServiceSpec serviceSpec = parseJsonFrom(new URL(JSON_URL));
Map<String, String> textInputs = serviceSpec.getTextInputs();
for (Entry<String, String> e : KNOWN_STRING_INPUTS.entrySet()) {
String name = e.getKey();
String defaultValue = textInputs.get(name);
if (defaultValue!=null) {
fields.add(new StringFieldModel(name, defaultValue).label(e.getValue()));
}
}
{ //field: type
String groupName = "type";
RadioGroup group = radioGroups.ensureGroup(groupName);
group.label("Type:");
for (Type type : serviceSpec.getTypeOptions(groupName)) {
BuildType bt = KNOWN_TYPES.get(type.getId());
if (bt!=null) {
for (ImportStrategy is : bt.getImportStrategies()) {
TypeRadioInfo radio = new TypeRadioInfo(groupName, type, is);
radio.setLabel(is.displayName());
group.add(radio);
}
}
}
//When a type is selected the 'baseUrl' should be update according to its action.
group.getSelection().selection.addListener(new ValueListener<RadioInfo>() {
public void gotValue(LiveExpression<RadioInfo> exp, RadioInfo value) {
try {
if (value!=null) {
URI base = new URI(JSON_URL);
URI resolved = base.resolve(((TypeRadioInfo)value).getAction());
baseUrl.setValue(resolved.toString());
}
} catch (Exception e) {
BootWizardActivator.log(e);
}
}
});
}
for (Entry<String, String> e : KNOWN_SINGLE_SELECTS.entrySet()) {
String groupName = e.getKey();
RadioGroup group = radioGroups.ensureGroup(groupName);
group.label(e.getValue());
addOptions(group, serviceSpec.getSingleSelectOptions(groupName));
if (groupName.equals("bootVersion")) {
this.bootVersion = group;
}
}
//styles
for (DependencyGroup dgroup : serviceSpec.getDependencies()) {
String catName = dgroup.getName();
for (Dependency dep : dgroup.getContent()) {
dependencies.choice(catName, dep.getName(), dep, () -> {
// Setup link template variable values
Map<String, String> variables = new HashMap<>();
variables.put(InitializrServiceSpec.BOOT_VERSION_LINK_TEMPLATE_VARIABLE,
bootVersion.getSelection().selection.getValue().getValue());
return DependencyHtmlContent.generateHtmlDocumentation(dep, variables);
}, createEnablementExp(bootVersion, dep));
}
}
}
private LiveExpression<Boolean> createEnablementExp(final RadioGroup bootVersion, final Dependency dep) {
try {
String versionRange = dep.getVersionRange();
if (StringUtils.isNotBlank(versionRange)) {
return new LiveExpression<Boolean>() {
{ dependsOn(bootVersion.getSelection().selection); }
@Override
protected Boolean compute() {
RadioInfo radio = bootVersion.getValue();
if (radio!=null) {
String versionString = radio.getValue();
return dep.isSupportedFor(versionString);
}
return true;
}
};
}
} catch (Exception e) {
BootWizardActivator.log(e);
}
return LiveExpression.TRUE;
}
private void addOptions(RadioGroup group, Option[] options) {
for (Option option : options) {
RadioInfo radio = new RadioInfo(group.getName(), option.getId(), option.isDefault());
radio.setLabel(option.getName());
group.add(radio);
}
}
private InitializrServiceSpec parseJsonFrom(URL url) throws Exception {
return InitializrServiceSpec.parseFrom(urlConnectionFactory, url);
}
private URL newURL(String value) {
try {
return new URL(value);
} catch (MalformedURLException e) {
//This should be impossible because the URL syntax is validated beforehand.
BootWizardActivator.log(e);
return null;
}
}
/**
* This is mostly for testing purposes where it is just easier to run stuff in the UIThread (test do so
* by default). But in production we shouldn't allow downloading stuff in the UIThread.
*/
public void allowUIThread(boolean allow) {
this.allowUIThread = allow;
}
public LiveExpression<ValidationResult> getLocationValidator() {
return locationValidator;
}
public LiveVariable<String> getLocation() {
return location;
}
public FieldModel<String> getProjectName() {
return projectName;
}
public void setWorkingSets(IWorkingSet[] workingSets) {
this.workingSets = workingSets;
}
public RadioGroups getRadioGroups() {
return this.radioGroups;
}
public RadioGroup getBootVersion() {
return bootVersion;
}
public FieldModel<String> getArtifactId() {
String fieldName = ARTIFACT_PROPERTY_ID;
return getStringInput(fieldName);
}
public FieldModel<String> getStringInput(String fieldName) {
for (FieldModel<String> fieldModel : stringInputs) {
if (fieldName.equals(fieldModel.getName())) {
return fieldModel;
}
}
return null;
}
public LiveVariable<String> getDependencyFilterBoxText() {
return filterBox.getText();
}
public LiveExpression<Filter<Dependency>> getDependencyFilter() {
return filterBox.getFilter();
}
/**
* Convenience method for easier scripting of the wizard model (used in testing). Not used
* by the UI itself. If the dependencyId isn't found in the wizard model then an IllegalArgumentException
* will be raised.
*/
public void addDependency(String dependencyId){
for (String catName : dependencies.getCategories()) {
MultiSelectionFieldModel<Dependency> cat = dependencies.getContents(catName);
for (Dependency dep : cat.getChoices()) {
if (dependencyId.equals(dep.getId())) {
cat.select(dep);
return; //dep found and added to selection
}
}
}
throw new IllegalArgumentException("No such dependency: "+dependencyId);
}
}