diff --git a/beast-fx/src/main/java/beastfx/app/beauti/BeautiTabPane.java b/beast-fx/src/main/java/beastfx/app/beauti/BeautiTabPane.java index 29bfce2..ea855b3 100644 --- a/beast-fx/src/main/java/beastfx/app/beauti/BeautiTabPane.java +++ b/beast-fx/src/main/java/beastfx/app/beauti/BeautiTabPane.java @@ -26,8 +26,6 @@ import javafx.scene.web.WebView; import javafx.stage.Stage; -import org.w3c.dom.Document; -import org.w3c.dom.Element; import org.xml.sax.SAXException; import beastfx.app.beauti.theme.Default; @@ -62,7 +60,6 @@ import beast.pkgmgmt.PackageManager; import beast.pkgmgmt.Utils6; -import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import java.awt.Toolkit; @@ -960,36 +957,17 @@ class TemplateAction extends MyAction { String m_sFileName; String templateInfo; - public TemplateAction(File file) { - super("xx", file.getAbsolutePath(), null, null); - m_sFileName = file.getAbsolutePath(); - String fileSep = System.getProperty("file.separator"); - if (fileSep.equals("\\")) { - fileSep = "\\"; - } - int i = m_sFileName.lastIndexOf(fileSep) + 1; - String name = m_sFileName.substring( - i, m_sFileName.length() - 4); - //putValue(Action.NAME, name); - setText(name); - setId(name.replaceAll(" ", "")); - //((Label)getContent()).setText(name); - try { - DocumentBuilderFactory factory = DocumentBuilderFactory - .newInstance(); - Document doc = factory.newDocumentBuilder().parse(file); - doc.normalize(); - // get name and version of add-on - Element template = doc.getDocumentElement(); - templateInfo = template.getAttribute("templateinfo"); - if (templateInfo == null || templateInfo.length() == 0) { - templateInfo = "switch to " + name + " template"; - } - Tooltip tooltip = new Tooltip(templateInfo); -// Tooltip.install(getContent(), tooltip); - } catch (Exception e) { - // ignore - } + /** Construct from a {@link BeautiDoc.TemplateInfo} descriptor. The template + * may live on disk or inside a package JAR; {@code loadName} is whatever + * {@link BeautiDoc#loadNewTemplate} needs to resolve it. */ + public TemplateAction(BeautiDoc.TemplateInfo template) { + super("xx", template.info, null, null); + m_sFileName = template.loadName; + this.templateInfo = template.info; + setText(template.name); + setId(template.name.replaceAll(" ", "")); + Tooltip tooltip = new Tooltip(template.info); +// Tooltip.install(getContent(), tooltip); } @Override @@ -1017,46 +995,15 @@ public void actionPerformed(ActionEvent e) { private List getTemplateActions() { List actions = new ArrayList<>(); - List beastDirectories = PackageManager.getBeastDirectories(); - for (String dirName : beastDirectories) { - File dir = new File(dirName + "/" + BeautiConfig.TEMPLATE_DIR); - getTemplateActionForDir(dir, actions); + // BeautiDoc is the single source of truth for which templates exist, + // covering both loose files on disk and templates bundled inside package + // JARs (e.g. beast-fx.jar at beast.fx/fxtemplates/). + for (BeautiDoc.TemplateInfo template : BeautiDoc.getAvailableTemplates()) { + actions.add(new TemplateAction(template)); } return actions; } - private void getTemplateActionForDir(File dir, List actions) { - if (dir.exists() && dir.isDirectory()) { - File[] files = dir.listFiles(); - if (files != null) { - for (File template : files) { - if (template.getName().toLowerCase().endsWith(".xml")) { - try { - String xml2 = BeautiDoc.load(template.getAbsolutePath()); - if (xml2.contains("templateinfo=")) { - String fileName = template.getName(); - fileName = fileName.substring(0, fileName.length() - 4); - boolean duplicate = false; - for (MyAction action : actions) { - //String name = ((Label)action.getContent()).getText(); - String name = action.getText(); - if (name.equals(fileName)) { - duplicate = true; - } - } - if (!duplicate) { - actions.add(new TemplateAction(template)); - } - } - } catch (Exception e) { - Log.warning.println(e.getMessage()); - } - } - } - } - } - } - private List getWorkDirActions() { List actions = new ArrayList<>(); List beastDirectories = PackageManager.getBeastDirectories(); diff --git a/beast-fx/src/main/java/beastfx/app/inputeditor/BeautiDoc.java b/beast-fx/src/main/java/beastfx/app/inputeditor/BeautiDoc.java index ac4fcad..05deb69 100644 --- a/beast-fx/src/main/java/beastfx/app/inputeditor/BeautiDoc.java +++ b/beast-fx/src/main/java/beastfx/app/inputeditor/BeautiDoc.java @@ -29,11 +29,12 @@ import java.util.Collection; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; import beastfx.app.util.Alert; import beastfx.app.util.FXUtils; @@ -520,41 +521,12 @@ public void initialize(ActionOnExit endState, String xml, String template, Strin */ public String processTemplate(String fileName) throws IOException { final String MERGE_ELEMENT = "mergepoint"; - // first gather the set of potential directories with templates - Set dirs = new HashSet<>(); - String pathSep = System.getProperty("path.separator"); - String classpath = ""; - try { - classpath = BeastLauncher.getPath(false, null); - classpath = classpath.substring(1, classpath.length()-1); - } catch (NoSuchMethodException | SecurityException | ClassNotFoundException | IllegalAccessException - | IllegalArgumentException | InvocationTargetException e1) { - e1.printStackTrace(); - } - // Also include java.class.path — the package classpath above only - // covers installed packages; the app's own JARs (containing fxtemplates) - // are on java.class.path when launched via jpackage or bin/ scripts. - String javaClassPath = System.getProperty("java.class.path"); - if (javaClassPath != null && !javaClassPath.isEmpty()) { - classpath = classpath.isEmpty() ? javaClassPath : classpath + pathSep + javaClassPath; - } + // gather the set of potential directories with templates + Set dirs = getTemplateDirs(); String fileSep = System.getProperty("file.separator"); if (fileSep.equals("\\")) { fileSep = "\\\\"; } - dirs.add("."); - for (String path : classpath.split(pathSep)) { - path = path.replaceAll(fileSep, "/"); - if (path.endsWith(".jar")) { - path = path.substring(0, path.lastIndexOf("/")); - } - if (path.indexOf("/") >= 0) { - path = path.substring(0, path.lastIndexOf("/")); - } - if (!dirs.contains(path)) { - dirs.add(path); - } - } // read main template, try all template directories if necessary File mainTemplate = new File(fileName); @@ -612,67 +584,23 @@ public String processTemplate(String fileName) throws IOException { Set loadedTemplates = new HashSet<>(); loadedTemplates.add(new File(fileName).getName()); - // 1. Scan filesystem directories for sub-templates - for (String dirName : dirs) { - Log.info.println("Investigating " + dirName); - // Check both bare fxtemplates/ (deployed packages) and - // beast.fx/fxtemplates/ (module-namespaced, IDE development) - File templates = new File(dirName + fileSep + BeautiConfig.TEMPLATE_DIR); - if (!templates.exists()) { - templates = new File(dirName + fileSep + "beast.fx" + fileSep + BeautiConfig.TEMPLATE_DIR); - } - File[] files = templates.listFiles(); - if (files != null) { - for (File template : files) { - if (template.getName().toLowerCase().endsWith(".xml")) { - if (!loadedTemplates.contains(template.getName())) { - Log.warning.println("Processing " + template.getAbsolutePath()); - FXUtils.logToSplashScreen("Processing " + template.getName()); - loadedTemplates.add(template.getName()); - String xml2 = load(template.getAbsolutePath()); - processSubTemplate(template.getName(), xml2, mergePoints, loadedTemplates, namespaces); - } else { - Log.warning.println("Skipping " + template.getAbsolutePath() + " since " - + template.getName() + " is already processed"); - } - } - } + // Scan every available template (filesystem dirs and package JARs) for + // sub-templates to merge. getTemplateSources() already dedups by file + // name; loadedTemplates additionally skips the main template itself. + for (TemplateSource src : getTemplateSources()) { + if (loadedTemplates.contains(src.fileName)) { + Log.warning.println("Skipping " + src.describe() + " since " + + src.fileName + " is already processed"); + continue; } - } - - // 2. Scan module resources (JARs) for sub-templates - // Match both module-namespaced paths (e.g. beast.fx/fxtemplates/Standard.xml) - // and legacy paths (e.g. fxtemplates/Standard.xml) - String fxDir = "/" + BeautiConfig.TEMPLATE_DIR + "/"; - for (ModuleLayer layer : getAllModuleLayers()) { - for (ResolvedModule rm : layer.configuration().modules()) { - try (ModuleReader reader = rm.reference().open()) { - List fxResources = reader.list() - .filter(r -> r.contains(fxDir) && r.endsWith(".xml")) - .collect(Collectors.toList()); - for (String resource : fxResources) { - int idx = resource.lastIndexOf(fxDir); - String name = resource.substring(idx + fxDir.length()); - if (!loadedTemplates.contains(name)) { - loadedTemplates.add(name); - try { - Optional ois = reader.open(resource); - if (ois.isPresent()) { - try (InputStream is = ois.get()) { - String xml2 = new String(is.readAllBytes(), StandardCharsets.UTF_8); - Log.warning.println("Processing module resource: " + resource + " from " + rm.name()); - FXUtils.logToSplashScreen("Processing " + name); - processSubTemplate(name, xml2, mergePoints, loadedTemplates, namespaces); - } - } - } catch (IOException e) { - Log.warning.println("Failed to read resource " + resource + " from " + rm.name()); - } - } - } - } catch (IOException e) { - // skip unreadable modules - } + loadedTemplates.add(src.fileName); + try { + String xml2 = src.readXML(); + Log.warning.println("Processing " + src.describe()); + FXUtils.logToSplashScreen("Processing " + src.fileName); + processSubTemplate(src.fileName, xml2, mergePoints, loadedTemplates, namespaces); + } catch (IOException e) { + Log.warning.println("Failed to read template " + src.describe()); } } @@ -795,6 +723,184 @@ static public String load(File file) throws IOException { return buf.toString(); } + /** Descriptor for a switchable BEAUti template, i.e. an fxtemplates entry + * carrying a {@code templateinfo} attribute. Returned by + * {@link #getAvailableTemplates()} and consumed by the BEAUti menu. */ + public static class TemplateInfo { + /** Display name, e.g. "Standard". */ + public final String name; + /** Value to pass to {@link #loadNewTemplate(String)} to load this template. */ + public final String loadName; + /** The templateinfo text, used as the menu tooltip. */ + public final String info; + + public TemplateInfo(String name, String loadName, String info) { + this.name = name; + this.loadName = loadName; + this.info = info; + } + } + + /** A template file/resource discovered on disk or inside a package JAR. */ + private abstract static class TemplateSource { + /** Base file name, e.g. "Standard.xml". */ + final String fileName; + + TemplateSource(String fileName) { + this.fileName = fileName; + } + + /** The full XML content of the template. */ + abstract String readXML() throws IOException; + + /** Value to pass to {@link #loadNewTemplate(String)} to load this template. */ + abstract String loadName(); + + /** Human-readable origin, for logging. */ + abstract String describe(); + } + + private static class FileTemplateSource extends TemplateSource { + final File file; + + FileTemplateSource(File file) { + super(file.getName()); + this.file = file; + } + + @Override String readXML() throws IOException { return load(file.getAbsolutePath()); } + @Override String loadName() { return file.getAbsolutePath(); } + @Override String describe() { return file.getAbsolutePath(); } + } + + private static class ModuleTemplateSource extends TemplateSource { + final BEASTClassLoader.ModuleResource resource; + + ModuleTemplateSource(String fileName, BEASTClassLoader.ModuleResource resource) { + super(fileName); + this.resource = resource; + } + + @Override String readXML() throws IOException { + try (InputStream is = resource.open()) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + @Override String loadName() { return fileName; } + @Override String describe() { return "module resource " + resource.path + " in " + resource.moduleName; } + } + + /** Candidate directories that may hold an {@code fxtemplates/} folder: the + * classpath-derived directories (covering IDE/dev and jpackage layouts) plus + * the installed BEAST package directories. Insertion order is preserved so + * classpath entries take precedence over installed packages. */ + private static Set getTemplateDirs() { + Set dirs = new LinkedHashSet<>(); + String pathSep = System.getProperty("path.separator"); + String classpath = ""; + try { + classpath = BeastLauncher.getPath(false, null); + classpath = classpath.substring(1, classpath.length() - 1); + } catch (NoSuchMethodException | SecurityException | ClassNotFoundException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException | IOException e1) { + e1.printStackTrace(); + } + // The package classpath above only covers installed packages; the app's + // own JARs (containing fxtemplates) are on java.class.path when launched + // via jpackage or bin/ scripts. + String javaClassPath = System.getProperty("java.class.path"); + if (javaClassPath != null && !javaClassPath.isEmpty()) { + classpath = classpath.isEmpty() ? javaClassPath : classpath + pathSep + javaClassPath; + } + String fileSep = System.getProperty("file.separator"); + if (fileSep.equals("\\")) { + fileSep = "\\\\"; + } + dirs.add("."); + for (String path : classpath.split(pathSep)) { + path = path.replaceAll(fileSep, "/"); + if (path.endsWith(".jar")) { + path = path.substring(0, path.lastIndexOf("/")); + } + if (path.indexOf("/") >= 0) { + path = path.substring(0, path.lastIndexOf("/")); + } + dirs.add(path); + } + dirs.addAll(PackageManager.getBeastDirectories()); + return dirs; + } + + /** Enumerate every {@code fxtemplates/*.xml} available on the filesystem and + * inside package JARs. Filesystem entries take precedence; a JAR entry whose + * file name is already present on disk is skipped, so a template is never + * loaded twice (e.g. once from the dev area and once from a package). */ + private static List getTemplateSources() { + LinkedHashMap byName = new LinkedHashMap<>(); + // 1. filesystem: /fxtemplates (deployed packages) and + // /beast.fx/fxtemplates (module-namespaced, IDE development) + for (String dirName : getTemplateDirs()) { + for (String sub : new String[] {BeautiConfig.TEMPLATE_DIR, "beast.fx/" + BeautiConfig.TEMPLATE_DIR}) { + File[] files = new File(dirName + "/" + sub).listFiles(); + if (files != null) { + for (File f : files) { + if (f.getName().toLowerCase().endsWith(".xml") && !byName.containsKey(f.getName())) { + byName.put(f.getName(), new FileTemplateSource(f)); + } + } + } + } + } + // 2. package JARs: any module resource at .../fxtemplates/*.xml + String fxDir = BeautiConfig.TEMPLATE_DIR + "/"; + for (BEASTClassLoader.ModuleResource res : BEASTClassLoader.listResources(fxDir)) { + if (!res.path.toLowerCase().endsWith(".xml")) { + continue; + } + String base = res.path.substring(res.path.lastIndexOf(fxDir) + fxDir.length()); + if (!byName.containsKey(base)) { + byName.put(base, new ModuleTemplateSource(base, res)); + } + } + return new ArrayList<>(byName.values()); + } + + /** The switchable templates available to BEAUti: every fxtemplates entry, on + * disk or inside a package JAR, that carries a {@code templateinfo} attribute. + * This is the single source of truth shared by the loader and the menu. */ + public static List getAvailableTemplates() { + List templates = new ArrayList<>(); + for (TemplateSource src : getTemplateSources()) { + try { + String xml = src.readXML(); + if (xml.contains("templateinfo=")) { + String name = src.fileName.substring(0, src.fileName.length() - 4); + templates.add(new TemplateInfo(name, src.loadName(), templateInfoFromXML(xml, name))); + } + } catch (IOException e) { + Log.warning.println("Failed to read template " + src.describe()); + } + } + return templates; + } + + /** Read the {@code templateinfo} attribute from a template's root element, + * falling back to a generated description if absent or unparseable. */ + private static String templateInfoFromXML(String xml, String name) { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + Document doc = factory.newDocumentBuilder().parse(new InputSource(new StringReader(xml))); + doc.normalize(); + String info = doc.getDocumentElement().getAttribute("templateinfo"); + if (info != null && info.length() > 0) { + return info; + } + } catch (Exception e) { + // ignore, fall through to default + } + return "switch to " + name + " template"; + } + /** All module layers: boot + external packages. */ private static List getAllModuleLayers() { List layers = new ArrayList<>(); diff --git a/beast-pkgmgmt/src/main/java/beast/pkgmgmt/BEASTClassLoader.java b/beast-pkgmgmt/src/main/java/beast/pkgmgmt/BEASTClassLoader.java index 37a0372..73544f0 100644 --- a/beast-pkgmgmt/src/main/java/beast/pkgmgmt/BEASTClassLoader.java +++ b/beast-pkgmgmt/src/main/java/beast/pkgmgmt/BEASTClassLoader.java @@ -1,10 +1,12 @@ package beast.pkgmgmt; import java.io.File; +import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.lang.module.ModuleReader; +import java.lang.module.ModuleReference; import java.lang.module.ResolvedModule; import java.net.URL; import java.net.URLDecoder; @@ -468,6 +470,77 @@ public static URL getResource(String packageName, String resourceName) { return ClassLoader.getSystemResource(resourceName); } + /** + * A resource discovered inside a module (boot or plugin layer) by + * {@link #listResources(String)}. The {@code path} is the resource's path + * within its module (e.g. {@code beast.fx/fxtemplates/Standard.xml}); call + * {@link #open()} to read its contents. + */ + public static class ModuleResource { + public final String moduleName; + public final String path; + private final ModuleReference reference; + + ModuleResource(String moduleName, String path, ModuleReference reference) { + this.moduleName = moduleName; + this.path = path; + this.reference = reference; + } + + /** Open a stream to the resource. Closing the stream releases the + * underlying {@link ModuleReader}. */ + public InputStream open() throws IOException { + ModuleReader reader = reference.open(); + Optional ois; + try { + ois = reader.open(path); + } catch (IOException e) { + reader.close(); + throw e; + } + if (ois.isEmpty()) { + reader.close(); + throw new IOException("Resource no longer present: " + path + " in " + moduleName); + } + return new FilterInputStream(ois.get()) { + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + reader.close(); + } + } + }; + } + } + + /** + * List every resource across the boot layer and all registered plugin + * layers whose path contains {@code pathFragment}. Useful for discovering + * package-provided resources (e.g. BEAUti templates) without knowing in + * advance which module ships them. Unreadable modules are skipped. + */ + public static List listResources(String pathFragment) { + List result = new ArrayList<>(); + List layers = new ArrayList<>(); + layers.add(ModuleLayer.boot()); + layers.addAll(pluginLayers); + for (ModuleLayer layer : layers) { + for (ResolvedModule rm : layer.configuration().modules()) { + ModuleReference ref = rm.reference(); + try (ModuleReader reader = ref.open()) { + reader.list() + .filter(r -> r.contains(pathFragment)) + .forEach(r -> result.add(new ModuleResource(rm.name(), r, ref))); + } catch (IOException e) { + // skip unreadable modules + } + } + } + return result; + } + // ------------------------------------------------------------------ // Plugin layer management (JPMS) // ------------------------------------------------------------------