/******************************************************************************* * 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); } }